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 trace_capture::{resolve_trace_capture, TraceCapture},
13 unknown_manifest_keys::{nearest_known_key, UnknownManifestKey},
14};
15
16#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum TaskAcceptance {
21 None,
22 #[default]
23 Single,
24 Queue,
25}
26
27#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum AfterTask {
30 #[default]
31 Exit,
32 Sleep,
33}
34
35#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum ConversationMode {
38 #[default]
39 Stateless,
40 Threaded,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct LifecycleConfig {
45 pub task_acceptance: TaskAcceptance,
46 pub after_task: AfterTask,
47 pub queue_depth: usize,
48 pub input_timeout_secs: Option<u64>,
51 pub conversation_mode: ConversationMode,
55 pub max_task_reopens: u32,
61 pub shell_grace_secs: u64,
67 pub delegation_deadline_secs: u64,
81}
82
83impl LifecycleConfig {
84 pub fn can_receive_background_tasks(&self) -> bool {
92 self.task_acceptance == TaskAcceptance::Queue && self.after_task == AfterTask::Sleep
93 }
94
95 pub fn queue_has_room(&self) -> bool {
104 !(self.task_acceptance == TaskAcceptance::Queue && self.queue_depth == 0)
105 }
106
107 pub fn tasks_held_at_once(&self) -> usize {
115 match self.task_acceptance {
116 TaskAcceptance::None => 0,
117 TaskAcceptance::Single => 1,
118 TaskAcceptance::Queue => self.queue_depth + 1,
119 }
120 }
121}
122
123impl Default for LifecycleConfig {
124 fn default() -> Self {
125 Self {
126 task_acceptance: TaskAcceptance::Single,
127 after_task: AfterTask::Exit,
128 queue_depth: 1,
129 input_timeout_secs: None,
130 conversation_mode: ConversationMode::Stateless,
131 max_task_reopens: 1,
132 shell_grace_secs: 10,
133 delegation_deadline_secs: 600,
134 }
135 }
136}
137
138#[derive(Debug, Clone, Default)]
141pub struct LifecycleOverride {
142 pub task_acceptance: Option<TaskAcceptance>,
143 pub after_task: Option<AfterTask>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
164#[serde(rename_all = "lowercase")]
165pub enum ArtifactRuntime {
166 Tool,
167 Driver,
168 Hook,
169 Skill,
170}
171
172impl ArtifactRuntime {
173 pub fn is_llm_visible(&self) -> bool {
179 match self {
180 ArtifactRuntime::Tool => true,
181 ArtifactRuntime::Skill => true,
182 ArtifactRuntime::Driver => false,
183 ArtifactRuntime::Hook => false,
184 }
185 }
186
187 pub fn as_str(&self) -> &'static str {
188 match self {
189 ArtifactRuntime::Tool => "tool",
190 ArtifactRuntime::Driver => "driver",
191 ArtifactRuntime::Hook => "hook",
192 ArtifactRuntime::Skill => "skill",
193 }
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum ArtifactImplementation {
203 Wasm,
204 Native,
205}
206
207pub fn read_tool_implementation(
210 artifact_manifest_path: &Path,
211) -> Result<ArtifactImplementation, RuntimeManifestError> {
212 let content =
213 fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
214 path: artifact_manifest_path.display().to_string(),
215 source,
216 })?;
217 Ok(parse_tool_implementation_from_yaml(&content))
218}
219
220pub fn parse_tool_implementation_from_yaml(yaml: &str) -> ArtifactImplementation {
223 serde_yaml::from_str::<serde_yaml::Value>(yaml)
224 .map(|v| {
225 v.as_mapping()
226 .and_then(|m| m.get(serde_yaml::Value::String("implementation".to_string())))
227 .and_then(serde_yaml::Value::as_str)
228 .map(|s| match s {
229 "native" => ArtifactImplementation::Native,
230 _ => ArtifactImplementation::Wasm,
231 })
232 .unwrap_or(ArtifactImplementation::Wasm)
233 })
234 .unwrap_or(ArtifactImplementation::Wasm)
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum HookBinding {
245 OnStage,
247 OnSessionStart,
249 OnTaskStart,
251 OnInference,
253 OnToolCall,
255 OnShell,
257 OnCompaction,
259 OnTaskEnd,
261 OnSessionEnd,
263 All,
265}
266
267impl HookBinding {
268 #[must_use]
273 pub fn as_str(&self) -> &'static str {
274 match self {
275 Self::OnStage => "on-stage",
276 Self::OnSessionStart => "on-session-start",
277 Self::OnTaskStart => "on-task-start",
278 Self::OnInference => "on-inference",
279 Self::OnToolCall => "on-tool-call",
280 Self::OnShell => "on-shell",
281 Self::OnCompaction => "on-compaction",
282 Self::OnTaskEnd => "on-task-end",
283 Self::OnSessionEnd => "on-session-end",
284 Self::All => "all",
285 }
286 }
287}
288
289#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum HookExecutionMode {
295 Blocking,
297 Async,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq)]
306pub enum HookCommitPolicy {
307 None,
309 ReplaceContext,
311 WriteManifests,
314 ReopenTask,
317 SeedContext,
321 Deny,
329}
330
331impl HookCommitPolicy {
332 #[must_use]
334 pub fn as_str(&self) -> &'static str {
335 match self {
336 Self::None => "none",
337 Self::ReplaceContext => "replace-context",
338 Self::WriteManifests => "write-manifests",
339 Self::ReopenTask => "reopen-task",
340 Self::SeedContext => "seed-context",
341 Self::Deny => "deny",
342 }
343 }
344}
345
346#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
356#[serde(rename_all = "snake_case")]
357pub enum HookOverflowPolicy {
358 #[default]
362 Drop,
363 Block,
366}
367
368impl HookOverflowPolicy {
369 #[must_use]
371 pub fn as_str(&self) -> &'static str {
372 match self {
373 Self::Drop => "drop",
374 Self::Block => "block",
375 }
376 }
377}
378
379#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct HookConfig {
382 pub binding: HookBinding,
383 pub execution_mode: HookExecutionMode,
384 pub commit_policy: HookCommitPolicy,
385}
386
387impl Default for HookConfig {
388 fn default() -> Self {
389 Self {
390 binding: HookBinding::All,
391 execution_mode: HookExecutionMode::Blocking,
392 commit_policy: HookCommitPolicy::None,
393 }
394 }
395}
396
397#[must_use]
421pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
422 match binding {
423 HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
424 HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
425 HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
426 HookBinding::OnTaskStart => Some(HookCommitPolicy::SeedContext),
427 HookBinding::OnToolCall | HookBinding::OnShell => Some(HookCommitPolicy::Deny),
428 HookBinding::OnSessionStart
429 | HookBinding::OnInference
430 | HookBinding::OnSessionEnd
431 | HookBinding::All => None,
432 }
433}
434
435pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
437 let content =
438 fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
439 path: artifact_manifest_path.display().to_string(),
440 source,
441 })?;
442 parse_hook_config_from_yaml(&content).map_err(|msg| {
443 RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
444 })
445}
446
447pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
457 let v: serde_yaml::Value =
458 serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
459
460 let binding = v
461 .as_mapping()
462 .and_then(|m| m.get(serde_yaml::Value::String("binding".to_string())))
463 .and_then(serde_yaml::Value::as_str)
464 .map(|s| match s {
465 "on-stage" => Ok(HookBinding::OnStage),
466 "on-session-start" => Ok(HookBinding::OnSessionStart),
467 "on-task-start" => Ok(HookBinding::OnTaskStart),
468 "on-inference" => Ok(HookBinding::OnInference),
469 "on-tool-call" => Ok(HookBinding::OnToolCall),
470 "on-shell" => Ok(HookBinding::OnShell),
471 "on-compaction" => Ok(HookBinding::OnCompaction),
472 "on-task-end" => Ok(HookBinding::OnTaskEnd),
473 "on-session-end" => Ok(HookBinding::OnSessionEnd),
474 other => Err(format!(
475 "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
476 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
477 )),
478 })
479 .transpose()?
480 .unwrap_or(HookBinding::All);
481
482 let execution_mode = v
483 .as_mapping()
484 .and_then(|m| m.get(serde_yaml::Value::String("execution_mode".to_string())))
485 .and_then(serde_yaml::Value::as_str)
486 .map(|s| match s {
487 "blocking" => Ok(HookExecutionMode::Blocking),
488 "async" => Ok(HookExecutionMode::Async),
489 other => Err(format!(
490 "unknown execution_mode '{other}'; expected: blocking, async"
491 )),
492 })
493 .transpose()?
494 .unwrap_or(HookExecutionMode::Blocking);
495
496 let commit_policy = v
497 .as_mapping()
498 .and_then(|m| m.get(serde_yaml::Value::String("commit_policy".to_string())))
499 .and_then(serde_yaml::Value::as_str)
500 .map(|s| match s {
501 "none" => Ok(HookCommitPolicy::None),
502 "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
503 "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
504 "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
505 "seed-context" => Ok(HookCommitPolicy::SeedContext),
506 "deny" => Ok(HookCommitPolicy::Deny),
507 other => Err(format!(
508 "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
509 reopen-task, seed-context, deny"
510 )),
511 })
512 .transpose()?
513 .unwrap_or(HookCommitPolicy::None);
514
515 if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
517 return Err(format!(
518 "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
519 (got '{}')",
520 commit_policy.as_str()
521 ));
522 }
523 if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
524 return Err(
525 "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
526 'on-stage'"
527 .to_string(),
528 );
529 }
530 if binding == HookBinding::All && commit_policy == HookCommitPolicy::Deny {
534 return Err(
535 "commit_policy 'deny' requires an explicit binding: 'on-shell' or 'on-tool-call'; \
536 a hook with no binding: is dispatched at every event, including decision points \
537 it was not written to decide"
538 .to_string(),
539 );
540 }
541 if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
546 let honored = commit_policy_for_binding(&binding);
547 if honored.as_ref() != Some(&commit_policy) {
548 let note = if binding == HookBinding::OnInference {
549 " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
550 } else {
551 ""
552 };
553 return Err(format!(
554 "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
555 commit_policy '{}'{note}",
556 commit_policy.as_str(),
557 binding.as_str(),
558 binding.as_str(),
559 honored.as_ref().map_or("none", HookCommitPolicy::as_str)
560 ));
561 }
562 }
563
564 Ok(HookConfig {
565 binding,
566 execution_mode,
567 commit_policy,
568 })
569}
570
571#[derive(Debug, Clone, PartialEq, Eq)]
572pub struct RuntimeManifest {
573 pub name: String,
574 pub version: String,
575 pub artifacts: Vec<RuntimeArtifact>,
576 pub capabilities: Option<Capabilities>,
577 pub inference: Option<InferenceConfig>,
578 pub context: Option<ContextConfig>,
579 pub observability: Option<ObservabilityConfig>,
580 pub trace: Option<TraceConfig>,
581 pub network: Option<NetworkConfig>,
582 pub lifecycle: Option<LifecycleConfig>,
583 pub exports: Option<Exports>,
586 pub control: Option<ControlConfig>,
590 pub deploy: Option<DeployConfig>,
592 pub unknown_keys: Vec<UnknownManifestKey>,
602}
603
604impl RuntimeManifest {
605 pub fn effective_lifecycle(&self) -> LifecycleConfig {
606 self.lifecycle.clone().unwrap_or_default()
607 }
608
609 pub fn accepts_peer_tasks(&self) -> bool {
616 self.exports
617 .as_ref()
618 .is_some_and(Exports::accepts_peer_tasks)
619 }
620
621 pub fn deploy_mur_version(&self) -> Option<&str> {
624 self.deploy.as_ref()?.mur_version.as_deref()
625 }
626}
627
628#[derive(Debug, Clone, Default, PartialEq, Eq)]
630pub struct DeployConfig {
631 pub mur_version: Option<String>,
635}
636
637#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
644pub enum ControllableSetting {
645 InferenceMaxTokens,
648 InferenceDriver,
652}
653
654impl ControllableSetting {
655 pub const ALL: &'static [ControllableSetting] =
657 &[Self::InferenceMaxTokens, Self::InferenceDriver];
658
659 pub const AGENT_SETTABLE: &'static [ControllableSetting] = &[Self::InferenceDriver];
662
663 pub fn wire_name(self) -> &'static str {
665 match self {
666 Self::InferenceMaxTokens => "inference.max_tokens",
667 Self::InferenceDriver => "inference.driver",
668 }
669 }
670
671 pub fn from_wire_name(name: &str) -> Option<Self> {
673 Self::ALL
674 .iter()
675 .copied()
676 .find(|setting| setting.wire_name() == name)
677 }
678}
679
680#[derive(Debug, Clone, PartialEq, Eq, Default)]
685pub struct ControlConfig {
686 pub settings: Vec<ControllableSetting>,
688 pub secrets: Vec<String>,
692 pub agent_settings: Vec<ControllableSetting>,
696}
697
698impl ControlConfig {
699 pub fn declares_secret(&self, name: &str) -> bool {
701 self.secrets.iter().any(|secret| secret == name)
702 }
703
704 pub fn controller_may_set(&self, setting: ControllableSetting) -> bool {
706 self.settings.contains(&setting)
707 }
708
709 pub fn agent_may_set(&self, setting: ControllableSetting) -> bool {
711 self.agent_settings.contains(&setting)
712 }
713}
714
715#[derive(Debug, Clone, PartialEq, Eq)]
718pub struct NetworkConfig {
719 pub internal_port: Option<u16>,
721 pub authentication: Option<NetworkAuthentication>,
724}
725
726pub const DOOR_SCOPES: &[&str] = &[
730 "SendMessage",
731 "SendStreamingMessage",
732 "murmur.WatchSession",
733 "GetTask",
734 "ListTasks",
735 "CancelTask",
736 "SubscribeToTask",
737 "murmur.StopSession",
738 "resources/files",
739];
740
741pub const RETIRED_DOOR_SCOPES: &[(&str, &str)] = &[
750 ("message/send", "SendMessage"),
751 ("message/stream", "SendStreamingMessage"),
752 ("tasks/get", "GetTask"),
753 ("tasks/cancel", "CancelTask"),
754 ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
755 ("stream/watch", "murmur.WatchSession"),
756 ("session/stop", "murmur.StopSession"),
757];
758
759pub const OPERATOR_CREDENTIAL: &str = "operator";
762
763const MAX_CREDENTIAL_NAME_LEN: usize = 32;
765
766#[derive(Debug, Clone, Copy, PartialEq, Eq)]
770pub enum AuthenticationScheme {
771 Bearer,
773}
774
775impl AuthenticationScheme {
776 pub const ALL: &'static [AuthenticationScheme] = &[Self::Bearer];
778
779 pub fn wire_name(self) -> &'static str {
781 match self {
782 Self::Bearer => "bearer",
783 }
784 }
785}
786
787#[derive(Debug, Clone, PartialEq, Eq)]
790pub struct DoorCredential {
791 pub name: String,
793 pub scopes: Vec<String>,
795}
796
797#[derive(Debug, Clone, PartialEq, Eq)]
800pub struct NetworkAuthentication {
801 pub scheme: AuthenticationScheme,
802 pub credentials: Vec<DoorCredential>,
804}
805
806pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
810
811#[derive(Debug, Clone, PartialEq, Eq)]
819pub struct Exports {
820 pub files: Option<FileExport>,
823 pub peer_files: Option<PeerFilesExport>,
828 pub peer_tasks: Option<PeerTasksExport>,
832}
833
834impl Exports {
835 pub fn accepts_peer_tasks(&self) -> bool {
841 self.peer_tasks.is_some_and(|peer_tasks| peer_tasks.accept)
842 }
843}
844
845#[derive(Debug, Clone, Copy, PartialEq, Eq)]
856pub struct PeerTasksExport {
857 pub accept: bool,
859}
860
861#[derive(Debug, Clone, PartialEq, Eq)]
863pub struct FileExport {
864 pub root: String,
871 pub mode: ExportMode,
875 pub max_bytes: u64,
879}
880
881pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
884
885pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
894
895pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
897
898#[derive(Debug, Clone, PartialEq, Eq)]
905pub struct PeerFilesExport {
906 pub root: String,
910 pub max_ttl_secs: Option<u64>,
917 pub max_bytes: u64,
921}
922
923impl PeerFilesExport {
924 pub fn effective_max_ttl_secs(&self) -> u64 {
930 self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
931 }
932}
933
934#[derive(Debug, Clone, PartialEq, Eq)]
942pub struct PeerFetchCapabilities {
943 pub allow: Vec<String>,
947}
948
949pub const DURATION_ACCEPTED_FORM: &str =
952 "must be a duration: an integer, optionally suffixed s/m/h/d";
953
954pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
960 let trimmed = input.trim();
961 let (digits, multiplier) = match trimmed.strip_suffix('s') {
962 Some(digits) => (digits, 1u64),
963 None => match trimmed.strip_suffix('m') {
964 Some(digits) => (digits, 60),
965 None => match trimmed.strip_suffix('h') {
966 Some(digits) => (digits, 3600),
967 None => match trimmed.strip_suffix('d') {
968 Some(digits) => (digits, 86_400),
969 None => (trimmed, 1),
970 },
971 },
972 },
973 };
974 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
977 return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
978 }
979 digits
980 .parse::<u64>()
981 .ok()
982 .and_then(|value| value.checked_mul(multiplier))
983 .ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
984}
985
986#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
987pub enum ExportMode {
988 #[serde(rename = "read-only")]
989 ReadOnly,
990}
991
992impl ExportMode {
993 pub fn as_str(self) -> &'static str {
994 match self {
995 ExportMode::ReadOnly => "read-only",
996 }
997 }
998}
999
1000impl std::fmt::Display for ExportMode {
1001 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1002 f.write_str(self.as_str())
1003 }
1004}
1005
1006pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
1009
1010pub fn parse_byte_size(input: &str) -> Result<u64, String> {
1016 let trimmed = input.trim();
1017 let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
1018 Some(digits) => (digits, 1024u64),
1019 None => match trimmed.strip_suffix("Mi") {
1020 Some(digits) => (digits, 1024 * 1024),
1021 None => match trimmed.strip_suffix("Gi") {
1022 Some(digits) => (digits, 1024 * 1024 * 1024),
1023 None => (trimmed, 1),
1024 },
1025 },
1026 };
1027 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
1030 return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
1031 }
1032 digits
1033 .parse::<u64>()
1034 .ok()
1035 .and_then(|value| value.checked_mul(multiplier))
1036 .ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
1037}
1038
1039#[derive(Debug, Clone, PartialEq, Eq)]
1040pub struct RuntimeArtifact {
1041 pub name: String,
1042 pub version: String,
1043 pub runtime: ArtifactRuntime,
1044 pub source: Option<String>,
1050 pub local_source: bool,
1055 pub prompt_payload: bool,
1060 pub capabilities: Option<Capabilities>,
1079 pub on_overflow: HookOverflowPolicy,
1084 pub config: Option<serde_yaml::Value>,
1101 pub gateway: Option<ArtifactGateway>,
1112}
1113
1114impl RuntimeArtifact {
1115 #[must_use]
1119 pub fn tool_name(&self) -> &str {
1120 tool_name_of(&self.name)
1121 }
1122
1123 pub fn artifact_name(&self) -> Result<ArtifactName, ArtifactNameError> {
1125 ArtifactName::parse(&self.name)
1126 }
1127}
1128
1129#[must_use]
1132pub fn tool_name_of(name: &str) -> &str {
1133 name.split_once('/').map_or(name, |(_, bare)| bare)
1134}
1135
1136#[derive(Debug, Clone, PartialEq, Eq)]
1140pub struct ArtifactGateway {
1141 pub endpoint: String,
1144 pub api_key: Option<ApiKeyReference>,
1147 pub keyless: bool,
1151}
1152
1153#[derive(Debug, Clone, PartialEq, Eq)]
1154pub struct InferenceDriver {
1155 pub artifact: String,
1156 pub config: Option<String>,
1157}
1158
1159pub const PRIMARY_DRIVER_CHOICE: &str = "primary";
1162
1163#[derive(Debug, Clone, PartialEq, Eq)]
1169pub struct InferenceAlternate {
1170 pub name: String,
1172 pub model: String,
1174 pub driver: String,
1177}
1178
1179#[derive(Clone, PartialEq, Eq)]
1182pub enum ApiKeyReference {
1183 Environment(String),
1186 Literal(String),
1188}
1189
1190impl std::fmt::Debug for ApiKeyReference {
1191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1192 match self {
1193 Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
1194 Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
1195 }
1196 }
1197}
1198
1199#[derive(Debug, Clone, PartialEq, Eq)]
1200pub struct InferenceConfig {
1201 pub transport: String,
1202 pub model: String,
1203 pub driver: Option<InferenceDriver>,
1206 pub command: Option<String>,
1210 pub compaction: Option<CompactionConfig>,
1211 pub system_prompt: Option<String>,
1212 pub system_prompt_file: Option<String>,
1213 pub system_prompt_artifact: Option<String>,
1217 pub max_turns: u32,
1220 pub max_tokens: Option<u32>,
1227 pub max_session_tokens: Option<u64>,
1235 pub tool_refresh: ToolRefresh,
1239 pub alternates: Vec<InferenceAlternate>,
1243}
1244
1245#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1252pub enum ToolRefresh {
1253 #[default]
1256 Compaction,
1257 Immediate,
1259}
1260
1261impl ToolRefresh {
1262 pub const ALL: [ToolRefresh; 2] = [Self::Compaction, Self::Immediate];
1264
1265 #[must_use]
1267 pub fn wire_name(self) -> &'static str {
1268 match self {
1269 Self::Compaction => "compaction",
1270 Self::Immediate => "immediate",
1271 }
1272 }
1273}
1274
1275#[derive(Debug, Clone, PartialEq)]
1276pub struct CompactionConfig {
1277 pub threshold: Option<f32>,
1279 pub model: Option<String>,
1281 pub system_prompt: Option<String>,
1284 pub system_prompt_file: Option<String>,
1288 pub dump_summaries: Option<bool>,
1292}
1293
1294impl Eq for CompactionConfig {}
1296
1297pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
1301
1302pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
1306
1307#[derive(Debug, Clone, PartialEq)]
1308pub struct ContextConfig {
1309 pub max_tokens: Option<u32>,
1311 pub record: bool,
1315 pub record_store: Option<String>,
1319 pub seed_budget: f32,
1324 pub seed_overflow_margin: f32,
1329 pub retain: Option<ContextRetainConfig>,
1332}
1333
1334impl Eq for ContextConfig {}
1336
1337#[derive(Debug, Clone, PartialEq, Eq)]
1338pub struct ObservabilityConfig {
1339 pub otel_endpoint: Option<String>,
1340 pub eval: Option<EvalConfig>,
1341}
1342
1343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1344pub struct TraceConfig {
1345 pub capture: TraceCapture,
1348 pub retain: Option<TraceRetainConfig>,
1353}
1354
1355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1361pub struct TraceRetainConfig {
1362 pub max_sessions: Option<u32>,
1366 pub max_age_secs: Option<u64>,
1369}
1370
1371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1377pub struct ContextRetainConfig {
1378 pub max_messages: Option<u32>,
1381 pub max_age_secs: Option<u64>,
1384}
1385
1386#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1387pub struct EvalConfig {
1388 #[serde(skip_serializing_if = "Option::is_none")]
1389 pub dataset_id: Option<String>,
1390 pub scorers: Vec<ScorerConfig>,
1391}
1392
1393#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1394#[serde(tag = "type", rename_all = "snake_case")]
1395pub enum ScorerConfig {
1396 ExitOk { name: String },
1397 MaxTurns { name: String, max: u32 },
1398 MaxTokens { name: String, max: u64 },
1399 ToolSequence { name: String, expected: Vec<String> },
1400 LlmJudge { name: String },
1401}
1402
1403#[derive(Debug, Clone, PartialEq, Eq)]
1404pub struct NetworkCapabilities {
1405 pub allow: Vec<String>,
1406 pub unix_sockets: bool,
1414}
1415
1416#[derive(Debug, Clone, PartialEq, Eq)]
1417pub struct FilesystemCapabilities {
1418 pub scope: Option<String>,
1419 pub workdir_exec: bool,
1434 pub read_only: Vec<String>,
1441 pub records: bool,
1446}
1447
1448#[derive(Debug, Clone, PartialEq, Eq)]
1453pub struct TaskIoCapabilities {
1454 pub read: bool,
1458}
1459
1460#[derive(Debug, Clone, PartialEq, Eq)]
1465pub struct ConversationCapabilities {
1466 pub read: bool,
1469}
1470
1471#[derive(Debug, Clone, PartialEq, Eq)]
1475pub struct PlanCapabilities {
1476 pub submit: bool,
1479}
1480
1481#[derive(Debug, Clone, PartialEq, Eq)]
1488pub struct InstallCapabilities {
1489 pub skill: Vec<String>,
1491 pub tool: Vec<String>,
1493 pub from: InstallFrom,
1495}
1496
1497#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1500pub enum InstallFrom {
1501 #[default]
1503 Stores,
1504 StoresAndSources,
1507}
1508
1509impl InstallFrom {
1510 #[must_use]
1512 pub fn sources(self) -> bool {
1513 self == Self::StoresAndSources
1514 }
1515
1516 #[must_use]
1518 pub fn as_list(self) -> &'static str {
1519 match self {
1520 Self::Stores => "stores",
1521 Self::StoresAndSources => "stores, sources",
1522 }
1523 }
1524}
1525
1526#[derive(Debug, Clone, PartialEq, Eq)]
1527pub struct ShellCapabilities {
1528 pub allow: Vec<String>,
1529 pub strip_env: Option<Vec<String>>,
1530 pub baseline_env: Option<Vec<String>>,
1531 pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1538 pub staged_runtime: Vec<StagedRuntimeGrant>,
1544}
1545
1546#[derive(Debug, Clone, PartialEq, Eq)]
1551pub struct InterpreterRuntimeGrant {
1552 pub binary: String,
1556 pub dirs: Vec<InterpreterRuntimeDir>,
1559}
1560
1561#[derive(Debug, Clone, PartialEq, Eq)]
1563pub struct InterpreterRuntimeDir {
1564 pub path: String,
1566 pub list_dir: bool,
1571}
1572
1573#[derive(Debug, Clone, PartialEq, Eq)]
1590pub struct StagedRuntimeGrant {
1591 pub binary: String,
1595 pub source_path: String,
1599 pub pin: String,
1604}
1605
1606#[derive(Debug, Clone, PartialEq, Eq)]
1607pub struct SpawnCapabilities {
1608 pub allow: Vec<String>,
1609}
1610
1611#[derive(Debug, Clone, PartialEq, Eq, Default)]
1616pub struct EnvCapabilities {
1617 pub allow: Vec<String>,
1618}
1619
1620#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1628pub struct ResourceLimits {
1629 pub memory_bytes: Option<usize>,
1631 pub table_elements: Option<usize>,
1633 pub instances: Option<usize>,
1635 pub deadline_seconds: Option<u64>,
1637}
1638
1639#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1653pub struct ResourceCapabilities {
1654 pub max_processes: Option<u64>,
1659 pub max_open_files: Option<u64>,
1661 pub max_file_size_bytes: Option<u64>,
1663 pub cpu_seconds: Option<u64>,
1665 pub memory_bytes: Option<u64>,
1668 pub cgroup_memory_bytes: Option<u64>,
1670 pub cgroup_pids_max: Option<u64>,
1672 pub cgroup_cpu_percent: Option<u32>,
1674 pub cgroup_io_bytes_per_sec: Option<u64>,
1677 pub workdir_max_bytes: Option<u64>,
1679}
1680
1681#[derive(Debug, Clone, PartialEq, Eq, Default)]
1688pub struct StateCapabilities {
1689 pub store: Option<String>,
1697}
1698
1699#[derive(
1712 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1713)]
1714#[serde(rename_all = "snake_case")]
1715pub enum ContainmentClass {
1716 #[default]
1720 Advisory,
1721 Scoped,
1725 Sealed,
1733}
1734
1735impl ContainmentClass {
1736 pub fn as_str(self) -> &'static str {
1739 match self {
1740 Self::Advisory => "advisory",
1741 Self::Scoped => "scoped",
1742 Self::Sealed => "sealed",
1743 }
1744 }
1745
1746 pub const ALL: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1748}
1749
1750impl std::fmt::Display for ContainmentClass {
1751 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1752 f.write_str(self.as_str())
1753 }
1754}
1755
1756#[derive(Debug, Clone, PartialEq, Eq)]
1758pub struct ParseContainmentClassError {
1759 pub value: String,
1760}
1761
1762impl std::fmt::Display for ParseContainmentClassError {
1763 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1764 write!(
1765 f,
1766 "must be one of: advisory, scoped, sealed; got '{}'",
1767 self.value
1768 )
1769 }
1770}
1771
1772impl std::error::Error for ParseContainmentClassError {}
1773
1774impl std::str::FromStr for ContainmentClass {
1775 type Err = ParseContainmentClassError;
1776
1777 fn from_str(value: &str) -> Result<Self, Self::Err> {
1778 match value.trim() {
1779 "advisory" => Ok(Self::Advisory),
1780 "scoped" => Ok(Self::Scoped),
1781 "sealed" => Ok(Self::Sealed),
1782 other => Err(ParseContainmentClassError {
1783 value: other.to_string(),
1784 }),
1785 }
1786 }
1787}
1788
1789pub fn effective_containment_floor(
1795 workspace_default: Option<ContainmentClass>,
1796 manifest_declared: Option<ContainmentClass>,
1797 cli_flag: Option<ContainmentClass>,
1798) -> ContainmentClass {
1799 [workspace_default, manifest_declared, cli_flag]
1800 .into_iter()
1801 .flatten()
1802 .max()
1803 .unwrap_or_default()
1804}
1805
1806#[derive(Debug, Clone, PartialEq, Eq)]
1807pub struct Capabilities {
1808 pub network: Option<NetworkCapabilities>,
1809 pub peer_fetch: Option<PeerFetchCapabilities>,
1812 pub filesystem: Option<FilesystemCapabilities>,
1813 pub shell: Option<ShellCapabilities>,
1814 pub spawn: Option<SpawnCapabilities>,
1815 pub env: Option<EnvCapabilities>,
1816 pub limits: Option<ResourceLimits>,
1817 pub resources: Option<ResourceCapabilities>,
1818 pub state: Option<StateCapabilities>,
1821 pub task_io: Option<TaskIoCapabilities>,
1824 pub conversation: Option<ConversationCapabilities>,
1827 pub plan: Option<PlanCapabilities>,
1830 pub install: Option<InstallCapabilities>,
1833 pub containment: Option<ContainmentClass>,
1837}
1838
1839#[derive(Debug, Error)]
1840pub enum RuntimeManifestError {
1841 #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
1842 NotFound(String),
1843 #[error("{0}")]
1844 YamlSyntax(String),
1845 #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
1846 MissingField { field: String },
1847 #[error(
1848 "{}: invalid artifact declaration at index {index}: {message}",
1849 MANIFEST_FILENAME
1850 )]
1851 InvalidArtifact { index: usize, message: String },
1852 #[error(
1853 "{}: invalid inference config for '{field}': {message}",
1854 MANIFEST_FILENAME
1855 )]
1856 InvalidInferenceConfig { field: String, message: String },
1857 #[error(
1858 "{}: invalid capability config for '{field}': {message}",
1859 MANIFEST_FILENAME
1860 )]
1861 InvalidCapabilities { field: String, message: String },
1862 #[error(
1865 "{}: invalid capability config for 'capabilities.shell.allow[{index}]': '{}' {reason}; \
1866 an entry is a bare binary name",
1867 MANIFEST_FILENAME,
1868 entry.escape_debug()
1869 )]
1870 ShellAllowEntry {
1871 index: usize,
1872 entry: String,
1873 reason: &'static str,
1874 },
1875 #[error(
1876 "{}: invalid exports config for '{field}': {message}",
1877 MANIFEST_FILENAME
1878 )]
1879 InvalidExports { field: String, message: String },
1880 #[error(
1881 "{}: invalid control config for '{field}': {message}",
1882 MANIFEST_FILENAME
1883 )]
1884 InvalidControl { field: String, message: String },
1885 #[error(
1886 "{}: invalid network config for '{field}': {message}",
1887 MANIFEST_FILENAME
1888 )]
1889 InvalidNetworkConfig { field: String, message: String },
1890 #[error(
1893 "{}: invalid network config for '{field}': '{scope}' is a retired name of a door \
1894 method, and the door answers only the names its card lists",
1895 MANIFEST_FILENAME
1896 )]
1897 RetiredDoorScope {
1898 field: String,
1899 scope: String,
1900 replacement: &'static str,
1901 },
1902 #[error(
1905 "{}: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves task_acceptance: \
1906 queue no room for a waiting task",
1907 MANIFEST_FILENAME
1908 )]
1909 QueueWithoutRoom,
1910 #[error(
1913 "{}: 'mur_version' is not a top-level key — the mur release a deploy installs is pinned \
1914 as 'deploy.mur_version'",
1915 MANIFEST_FILENAME
1916 )]
1917 TopLevelMurVersion,
1918 #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
1919 InvalidTraceConfig { field: String, message: String },
1920 #[error("{}: invalid artifact name in '{field}': {message}", MANIFEST_FILENAME)]
1923 InvalidArtifactName {
1924 field: String,
1925 message: String,
1926 hint: Option<String>,
1927 },
1928 #[error("{}", REGISTRY_BLOCK_MESSAGE)]
1930 RegistryBlock,
1931 #[error(
1935 "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
1936 gateway.endpoint '{endpoint}' but binds no credential",
1937 MANIFEST_FILENAME
1938 )]
1939 GatewayWithoutCredential {
1940 index: usize,
1941 name: String,
1942 endpoint: String,
1943 },
1944 #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
1945 Io {
1946 path: String,
1947 #[source]
1948 source: std::io::Error,
1949 },
1950}
1951
1952#[derive(Debug, Deserialize)]
1953struct RawRuntimeManifest {
1954 name: Option<String>,
1955 version: Option<String>,
1956 #[serde(default)]
1957 artifacts: Vec<RawArtifact>,
1958 #[serde(default)]
1959 capabilities: Option<RawCapabilities>,
1960 #[serde(default)]
1961 inference: Option<RawInferenceConfig>,
1962 #[serde(default)]
1963 context: Option<RawContextConfig>,
1964 #[serde(default)]
1965 observability: Option<RawObservabilityConfig>,
1966 #[serde(default)]
1967 trace: Option<RawTraceConfig>,
1968 #[serde(default)]
1969 network: Option<RawNetworkConfig>,
1970 #[serde(default)]
1971 lifecycle: Option<RawLifecycleConfig>,
1972 #[serde(default)]
1973 exports: Option<RawExports>,
1974 #[serde(default)]
1975 control: Option<RawControl>,
1976 #[serde(default)]
1977 deploy: Option<RawDeploy>,
1978 #[serde(default, deserialize_with = "deserialize_present")]
1982 mur_version: Option<serde_yaml::Value>,
1983 #[serde(default, deserialize_with = "deserialize_present")]
1987 config: Option<serde_yaml::Value>,
1988 #[serde(default)]
1993 #[allow(dead_code)]
1994 runtime: Option<serde_yaml::Value>,
1995 #[serde(default)]
1999 #[allow(dead_code)]
2000 implementation: Option<serde_yaml::Value>,
2001 #[serde(default)]
2005 #[allow(dead_code)]
2006 execution: Option<serde_yaml::Value>,
2007 #[serde(default)]
2010 #[allow(dead_code)]
2011 requires_files: Option<serde_yaml::Value>,
2012 #[serde(flatten)]
2013 unknown: UnknownKeys,
2014}
2015
2016#[derive(Debug, Deserialize)]
2017struct RawDeploy {
2018 #[serde(default)]
2019 mur_version: Option<String>,
2020 #[serde(flatten)]
2021 unknown: UnknownKeys,
2022}
2023
2024#[derive(Debug, Deserialize)]
2025struct RawControl {
2026 #[serde(default)]
2030 settings: Option<serde_yaml::Value>,
2031 #[serde(default)]
2032 secrets: Option<serde_yaml::Value>,
2033 #[serde(default)]
2034 agent_settings: Option<serde_yaml::Value>,
2035 #[serde(flatten)]
2036 unknown: UnknownKeys,
2037}
2038
2039#[derive(Debug, Deserialize)]
2040struct RawExports {
2041 #[serde(default)]
2042 files: Option<RawFileExport>,
2043 #[serde(default)]
2044 peer_files: Option<RawPeerFilesExport>,
2045 #[serde(default)]
2046 peer_tasks: Option<RawPeerTasksExport>,
2047 #[serde(flatten)]
2048 unknown: UnknownKeys,
2049}
2050
2051#[derive(Debug, Deserialize)]
2052struct RawPeerTasksExport {
2053 #[serde(default)]
2056 accept: Option<bool>,
2057 #[serde(flatten)]
2058 unknown: UnknownKeys,
2059}
2060
2061#[derive(Debug, Deserialize)]
2062struct RawPeerFilesExport {
2063 #[serde(default)]
2064 root: Option<String>,
2065 #[serde(default)]
2069 max_ttl: Option<serde_yaml::Value>,
2070 #[serde(default)]
2071 max_bytes: Option<serde_yaml::Value>,
2072 #[serde(flatten)]
2073 unknown: UnknownKeys,
2074}
2075
2076#[derive(Debug, Deserialize)]
2077struct RawFileExport {
2078 #[serde(default)]
2079 root: Option<String>,
2080 #[serde(default)]
2081 mode: Option<String>,
2082 #[serde(default)]
2087 max_bytes: Option<serde_yaml::Value>,
2088 #[serde(flatten)]
2089 unknown: UnknownKeys,
2090}
2091
2092#[derive(Debug, Deserialize)]
2093struct RawLifecycleConfig {
2094 #[serde(default)]
2095 task_acceptance: Option<TaskAcceptance>,
2096 #[serde(default)]
2097 after_task: Option<AfterTask>,
2098 #[serde(default)]
2099 queue_depth: Option<usize>,
2100 #[serde(default)]
2101 input_timeout_secs: Option<u64>,
2102 #[serde(default)]
2103 conversation: Option<ConversationMode>,
2104 #[serde(default)]
2105 max_task_reopens: Option<u32>,
2106 #[serde(default)]
2107 shell_grace_secs: Option<u64>,
2108 #[serde(default)]
2109 delegation_deadline_secs: Option<u64>,
2110 #[serde(flatten)]
2111 unknown: UnknownKeys,
2112}
2113
2114#[derive(Debug, Deserialize)]
2115struct RawNetworkConfig {
2116 internal_port: Option<u16>,
2117 #[serde(default)]
2118 authentication: Option<RawNetworkAuthentication>,
2119 #[serde(flatten)]
2120 unknown: UnknownKeys,
2121}
2122
2123#[derive(Debug, Deserialize)]
2124struct RawNetworkAuthentication {
2125 #[serde(default)]
2126 scheme: Option<String>,
2127 #[serde(default)]
2130 credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
2131 #[serde(flatten)]
2132 unknown: UnknownKeys,
2133}
2134
2135#[derive(Debug, Deserialize)]
2136struct RawDoorCredential {
2137 #[serde(default)]
2141 scopes: Option<serde_yaml::Value>,
2142 #[serde(flatten)]
2143 unknown: UnknownKeys,
2144}
2145
2146#[derive(Debug, Deserialize)]
2147struct RawContextConfig {
2148 max_tokens: Option<u32>,
2149 #[serde(default)]
2152 record: Option<String>,
2153 #[serde(default)]
2154 record_store: Option<String>,
2155 #[serde(default)]
2156 seed_budget: Option<f32>,
2157 #[serde(default)]
2158 seed_overflow_margin: Option<f32>,
2159 #[serde(default, deserialize_with = "present_yaml_value")]
2162 retain: Option<serde_yaml::Value>,
2163 #[serde(flatten)]
2164 unknown: UnknownKeys,
2165}
2166
2167#[derive(Debug, Deserialize)]
2168struct RawObservabilityConfig {
2169 #[serde(default)]
2170 otel_endpoint: Option<String>,
2171 #[serde(default)]
2172 eval: Option<RawEvalConfig>,
2173 #[serde(flatten)]
2174 unknown: UnknownKeys,
2175}
2176
2177#[derive(Debug, Deserialize)]
2178struct RawTraceConfig {
2179 #[serde(default)]
2183 capture: Option<String>,
2184 #[serde(default, deserialize_with = "present_yaml_value")]
2186 retain: Option<serde_yaml::Value>,
2187 #[serde(flatten)]
2188 unknown: UnknownKeys,
2189}
2190
2191#[derive(Debug, Deserialize)]
2192struct RawEvalConfig {
2193 #[serde(default)]
2194 dataset_id: Option<String>,
2195 #[serde(default)]
2196 scorers: Vec<RawScorerConfig>,
2197 #[serde(flatten)]
2198 unknown: UnknownKeys,
2199}
2200
2201#[derive(Debug, Deserialize)]
2202struct RawScorerConfig {
2203 #[serde(rename = "type")]
2204 scorer_type: String,
2205 name: Option<String>,
2206 max: Option<u64>,
2207 expected: Option<Vec<String>>,
2208 #[serde(flatten)]
2209 unknown: UnknownKeys,
2210}
2211
2212#[derive(Debug, Deserialize)]
2213struct RawArtifact {
2214 name: Option<String>,
2215 version: Option<String>,
2216 runtime: Option<String>,
2217 #[serde(default)]
2218 source: Option<String>,
2219 #[serde(default)]
2220 local_source: Option<bool>,
2221 #[serde(default)]
2222 prompt_payload: Option<bool>,
2223 #[serde(default)]
2224 capabilities: Option<RawCapabilities>,
2225 #[serde(default)]
2226 on_overflow: Option<String>,
2227 #[serde(default, deserialize_with = "deserialize_present")]
2232 config: Option<serde_yaml::Value>,
2233 #[serde(default, deserialize_with = "deserialize_present")]
2236 gateway: Option<Option<RawArtifactGateway>>,
2237 #[serde(flatten)]
2238 unknown: UnknownKeys,
2239}
2240
2241#[derive(Debug, Deserialize)]
2242struct RawArtifactGateway {
2243 #[serde(default)]
2244 endpoint: Option<String>,
2245 #[serde(default, deserialize_with = "deserialize_present")]
2248 api_key: Option<Option<String>>,
2249 #[serde(default)]
2251 keyless: Option<serde_yaml::Value>,
2252 #[serde(flatten)]
2253 unknown: UnknownKeys,
2254}
2255
2256fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
2262where
2263 T: Deserialize<'de>,
2264 D: serde::Deserializer<'de>,
2265{
2266 T::deserialize(deserializer).map(Some)
2267}
2268
2269#[derive(Debug, Deserialize)]
2270struct RawCapabilities {
2271 #[serde(default)]
2272 network: Option<RawNetworkCapabilities>,
2273 #[serde(default)]
2274 peer_fetch: Option<RawPeerFetchCapabilities>,
2275 #[serde(default)]
2276 filesystem: Option<RawFilesystemCapabilities>,
2277 #[serde(default)]
2278 shell: Option<RawShellCapabilities>,
2279 #[serde(default)]
2280 spawn: Option<RawSpawnCapabilities>,
2281 #[serde(default)]
2282 env: Option<RawEnvCapabilities>,
2283 #[serde(default)]
2284 limits: Option<RawResourceLimits>,
2285 #[serde(default)]
2286 resources: Option<RawResourceCapabilities>,
2287 #[serde(default)]
2288 state: Option<RawStateCapabilities>,
2289 #[serde(default)]
2290 task_io: Option<RawTaskIoCapabilities>,
2291 #[serde(default)]
2292 conversation: Option<RawConversationCapabilities>,
2293 #[serde(default)]
2294 plan: Option<RawPlanCapabilities>,
2295 #[serde(default)]
2296 install: Option<RawInstallCapabilities>,
2297 #[serde(default)]
2301 containment: Option<String>,
2302 #[serde(flatten)]
2303 unknown: UnknownKeys,
2304}
2305
2306#[derive(Debug, Deserialize)]
2307struct RawTaskIoCapabilities {
2308 #[serde(default)]
2311 read: Option<bool>,
2312 #[serde(flatten)]
2313 unknown: UnknownKeys,
2314}
2315
2316#[derive(Debug, Deserialize)]
2317struct RawConversationCapabilities {
2318 #[serde(default)]
2321 read: Option<bool>,
2322 #[serde(flatten)]
2323 unknown: UnknownKeys,
2324}
2325
2326#[derive(Debug, Deserialize)]
2327struct RawPlanCapabilities {
2328 #[serde(default)]
2331 submit: Option<bool>,
2332 #[serde(flatten)]
2333 unknown: UnknownKeys,
2334}
2335
2336#[derive(Debug, Deserialize)]
2337struct RawInstallCapabilities {
2338 #[serde(default)]
2339 skill: Vec<String>,
2340 #[serde(default)]
2341 tool: Vec<String>,
2342 #[serde(default)]
2343 from: Option<Vec<String>>,
2344 #[serde(flatten)]
2345 unknown: UnknownKeys,
2346}
2347
2348#[derive(Debug, Deserialize)]
2349struct RawSpawnCapabilities {
2350 #[serde(default)]
2351 allow: Vec<String>,
2352 #[serde(flatten)]
2353 unknown: UnknownKeys,
2354}
2355
2356#[derive(Debug, Deserialize)]
2357struct RawEnvCapabilities {
2358 #[serde(default)]
2359 allow: Vec<String>,
2360 #[serde(flatten)]
2361 unknown: UnknownKeys,
2362}
2363
2364#[derive(Debug, Deserialize)]
2365struct RawResourceLimits {
2366 #[serde(default)]
2367 memory_bytes: Option<usize>,
2368 #[serde(default)]
2369 table_elements: Option<usize>,
2370 #[serde(default)]
2371 instances: Option<usize>,
2372 #[serde(default)]
2373 deadline_seconds: Option<u64>,
2374 #[serde(flatten)]
2375 unknown: UnknownKeys,
2376}
2377
2378#[derive(Debug, Deserialize)]
2379struct RawStateCapabilities {
2380 #[serde(default)]
2381 store: Option<String>,
2382 #[serde(flatten)]
2383 unknown: UnknownKeys,
2384}
2385
2386#[derive(Debug, Deserialize)]
2387struct RawResourceCapabilities {
2388 #[serde(default)]
2389 max_processes: Option<u64>,
2390 #[serde(default)]
2391 max_open_files: Option<u64>,
2392 #[serde(default)]
2393 max_file_size_bytes: Option<u64>,
2394 #[serde(default)]
2395 cpu_seconds: Option<u64>,
2396 #[serde(default)]
2397 memory_bytes: Option<u64>,
2398 #[serde(default)]
2399 cgroup_memory_bytes: Option<u64>,
2400 #[serde(default)]
2401 cgroup_pids_max: Option<u64>,
2402 #[serde(default)]
2403 cgroup_cpu_percent: Option<u32>,
2404 #[serde(default)]
2405 cgroup_io_bytes_per_sec: Option<u64>,
2406 #[serde(default)]
2407 workdir_max_bytes: Option<u64>,
2408 #[serde(flatten)]
2409 unknown: UnknownKeys,
2410}
2411
2412#[derive(Debug, Deserialize)]
2413struct RawNetworkCapabilities {
2414 #[serde(default)]
2415 allow: Vec<String>,
2416 #[serde(default)]
2417 unix_sockets: bool,
2418 #[serde(flatten)]
2419 unknown: UnknownKeys,
2420}
2421
2422#[derive(Debug, Deserialize)]
2423struct RawPeerFetchCapabilities {
2424 #[serde(default)]
2425 allow: Vec<String>,
2426 #[serde(flatten)]
2427 unknown: UnknownKeys,
2428}
2429
2430#[derive(Debug, Deserialize)]
2431struct RawFilesystemCapabilities {
2432 #[serde(default)]
2433 scope: Option<String>,
2434 #[serde(default)]
2438 workdir_exec: bool,
2439 #[serde(default)]
2442 read_only: Vec<String>,
2443 #[serde(default)]
2445 records: bool,
2446 #[serde(flatten)]
2447 unknown: UnknownKeys,
2448}
2449
2450#[derive(Debug, Deserialize)]
2451struct RawShellCapabilities {
2452 #[serde(default)]
2453 allow: Vec<String>,
2454 #[serde(default)]
2455 strip_env: Option<Vec<String>>,
2456 #[serde(default)]
2457 baseline_env: Option<Vec<String>>,
2458 #[serde(default)]
2459 interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
2460 #[serde(default)]
2461 staged_runtime: Vec<RawStagedRuntimeGrant>,
2462 #[serde(flatten)]
2463 unknown: UnknownKeys,
2464}
2465
2466#[derive(Debug, Deserialize)]
2467struct RawInterpreterRuntimeGrant {
2468 #[serde(default)]
2469 binary: Option<String>,
2470 #[serde(default)]
2471 dirs: Vec<RawInterpreterRuntimeDir>,
2472 #[serde(flatten)]
2473 unknown: UnknownKeys,
2474}
2475
2476#[derive(Debug, Deserialize)]
2477struct RawInterpreterRuntimeDir {
2478 #[serde(default)]
2479 path: Option<String>,
2480 #[serde(default)]
2484 list_dir: Option<bool>,
2485 #[serde(flatten)]
2486 unknown: UnknownKeys,
2487}
2488
2489#[derive(Debug, Deserialize)]
2490struct RawStagedRuntimeGrant {
2491 #[serde(default)]
2492 binary: Option<String>,
2493 #[serde(default)]
2494 source_path: Option<String>,
2495 #[serde(default)]
2499 pin: Option<String>,
2500 #[serde(flatten)]
2501 unknown: UnknownKeys,
2502}
2503
2504#[derive(Debug, Deserialize)]
2505struct RawInferenceConfig {
2506 transport: Option<String>,
2507 #[serde(default, deserialize_with = "deserialize_present")]
2510 endpoint: Option<serde_yaml::Value>,
2511 model: Option<String>,
2512 #[serde(default, deserialize_with = "deserialize_present")]
2515 api_key: Option<serde_yaml::Value>,
2516 #[serde(default)]
2517 command: Option<String>,
2518 #[serde(default)]
2519 system_prompt: Option<String>,
2520 #[serde(default)]
2521 system_prompt_file: Option<String>,
2522 #[serde(default)]
2523 system_prompt_artifact: Option<String>,
2524 #[serde(default)]
2525 driver: Option<RawInferenceDriver>,
2526 #[serde(default)]
2527 compaction: Option<RawCompactionConfig>,
2528 #[serde(default)]
2529 max_turns: Option<u32>,
2530 #[serde(default)]
2533 max_task_reopens: Option<u32>,
2534 #[serde(default)]
2535 max_tokens: Option<u32>,
2536 #[serde(default)]
2537 max_session_tokens: Option<u64>,
2538 #[serde(default)]
2539 tool_refresh: Option<String>,
2540 #[serde(default)]
2541 alternates: Option<Vec<RawInferenceAlternate>>,
2542 #[serde(flatten)]
2543 unknown: UnknownKeys,
2544}
2545
2546#[derive(Debug, Deserialize)]
2547struct RawInferenceAlternate {
2548 #[serde(default)]
2549 name: Option<String>,
2550 #[serde(default)]
2551 model: Option<String>,
2552 #[serde(default)]
2553 driver: Option<RawInferenceDriver>,
2554 #[serde(flatten)]
2555 unknown: UnknownKeys,
2556}
2557
2558#[derive(Debug, Deserialize)]
2559struct RawCompactionConfig {
2560 threshold: Option<f32>,
2561 model: Option<String>,
2562 system_prompt: Option<String>,
2563 system_prompt_file: Option<String>,
2564 dump_summaries: Option<bool>,
2565 #[serde(flatten)]
2566 unknown: UnknownKeys,
2567}
2568
2569#[derive(Debug, Deserialize)]
2570struct RawInferenceDriver {
2571 artifact: Option<String>,
2572 #[serde(default)]
2573 config: Option<serde_yaml::Value>,
2574 #[serde(flatten)]
2575 unknown: UnknownKeys,
2576}
2577
2578type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2585
2586trait RawBlock {
2593 const KNOWN_KEYS: &'static [&'static str];
2597
2598 fn unknown_keys(&self) -> &UnknownKeys;
2599
2600 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2608 let _ = (path, out);
2609 }
2610}
2611
2612impl RawBlock for RawRuntimeManifest {
2613 const KNOWN_KEYS: &'static [&'static str] = &[
2614 "name",
2615 "version",
2616 "artifacts",
2617 "capabilities",
2618 "inference",
2619 "context",
2620 "observability",
2621 "trace",
2622 "network",
2623 "lifecycle",
2624 "exports",
2625 "control",
2626 "deploy",
2627 "mur_version",
2628 "config",
2629 "runtime",
2630 "implementation",
2631 "execution",
2632 "requires_files",
2633 ];
2634 fn unknown_keys(&self) -> &UnknownKeys {
2635 &self.unknown
2636 }
2637
2638 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2639 let artifacts = child_path(path, "artifacts");
2640 for (index, artifact) in self.artifacts.iter().enumerate() {
2641 collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2642 }
2643 if let Some(capabilities) = &self.capabilities {
2644 collect_block(capabilities, &child_path(path, "capabilities"), out);
2645 }
2646 if let Some(inference) = &self.inference {
2647 collect_block(inference, &child_path(path, "inference"), out);
2648 }
2649 if let Some(context) = &self.context {
2650 collect_block(context, &child_path(path, "context"), out);
2651 }
2652 if let Some(observability) = &self.observability {
2653 collect_block(observability, &child_path(path, "observability"), out);
2654 }
2655 if let Some(trace) = &self.trace {
2656 collect_block(trace, &child_path(path, "trace"), out);
2657 }
2658 if let Some(network) = &self.network {
2659 collect_block(network, &child_path(path, "network"), out);
2660 }
2661 if let Some(lifecycle) = &self.lifecycle {
2662 collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2663 }
2664 if let Some(exports) = &self.exports {
2665 collect_block(exports, &child_path(path, "exports"), out);
2666 }
2667 if let Some(control) = &self.control {
2668 collect_block(control, &child_path(path, "control"), out);
2669 }
2670 if let Some(deploy) = &self.deploy {
2671 collect_block(deploy, &child_path(path, "deploy"), out);
2672 }
2673 }
2674}
2675
2676impl RawBlock for RawDeploy {
2677 const KNOWN_KEYS: &'static [&'static str] = &["mur_version"];
2678 fn unknown_keys(&self) -> &UnknownKeys {
2679 &self.unknown
2680 }
2681}
2682
2683impl RawBlock for RawControl {
2684 const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
2685 fn unknown_keys(&self) -> &UnknownKeys {
2686 &self.unknown
2687 }
2688}
2689
2690impl RawBlock for RawExports {
2691 const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files", "peer_tasks"];
2692 fn unknown_keys(&self) -> &UnknownKeys {
2693 &self.unknown
2694 }
2695
2696 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2697 if let Some(files) = &self.files {
2698 collect_block(files, &child_path(path, "files"), out);
2699 }
2700 if let Some(peer_files) = &self.peer_files {
2701 collect_block(peer_files, &child_path(path, "peer_files"), out);
2702 }
2703 if let Some(peer_tasks) = &self.peer_tasks {
2704 collect_block(peer_tasks, &child_path(path, "peer_tasks"), out);
2705 }
2706 }
2707}
2708
2709impl RawBlock for RawPeerTasksExport {
2710 const KNOWN_KEYS: &'static [&'static str] = &["accept"];
2711 fn unknown_keys(&self) -> &UnknownKeys {
2712 &self.unknown
2713 }
2714}
2715
2716impl RawBlock for RawPeerFilesExport {
2717 const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2718 fn unknown_keys(&self) -> &UnknownKeys {
2719 &self.unknown
2720 }
2721}
2722
2723impl RawBlock for RawFileExport {
2724 const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2725 fn unknown_keys(&self) -> &UnknownKeys {
2726 &self.unknown
2727 }
2728}
2729
2730impl RawBlock for RawLifecycleConfig {
2731 const KNOWN_KEYS: &'static [&'static str] = &[
2732 "task_acceptance",
2733 "after_task",
2734 "queue_depth",
2735 "input_timeout_secs",
2736 "conversation",
2737 "max_task_reopens",
2738 "shell_grace_secs",
2739 "delegation_deadline_secs",
2740 ];
2741 fn unknown_keys(&self) -> &UnknownKeys {
2742 &self.unknown
2743 }
2744}
2745
2746impl RawBlock for RawNetworkConfig {
2747 const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
2748 fn unknown_keys(&self) -> &UnknownKeys {
2749 &self.unknown
2750 }
2751
2752 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2753 if let Some(authentication) = &self.authentication {
2754 collect_block(authentication, &child_path(path, "authentication"), out);
2755 }
2756 }
2757}
2758
2759impl RawBlock for RawNetworkAuthentication {
2760 const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
2761 fn unknown_keys(&self) -> &UnknownKeys {
2762 &self.unknown
2763 }
2764
2765 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2766 let credentials_path = child_path(path, "credentials");
2767 for (name, credential) in self.credentials.iter().flatten() {
2768 collect_block(credential, &child_path(&credentials_path, name), out);
2769 }
2770 }
2771}
2772
2773impl RawBlock for RawDoorCredential {
2774 const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
2775 fn unknown_keys(&self) -> &UnknownKeys {
2776 &self.unknown
2777 }
2778}
2779
2780impl RawBlock for RawContextConfig {
2781 const KNOWN_KEYS: &'static [&'static str] = &[
2782 "max_tokens",
2783 "record",
2784 "record_store",
2785 "seed_budget",
2786 "seed_overflow_margin",
2787 "retain",
2788 ];
2789 fn unknown_keys(&self) -> &UnknownKeys {
2790 &self.unknown
2791 }
2792}
2793
2794impl RawBlock for RawObservabilityConfig {
2795 const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
2796 fn unknown_keys(&self) -> &UnknownKeys {
2797 &self.unknown
2798 }
2799
2800 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2801 if let Some(eval) = &self.eval {
2802 collect_block(eval, &child_path(path, "eval"), out);
2803 }
2804 }
2805}
2806
2807impl RawBlock for RawTraceConfig {
2808 const KNOWN_KEYS: &'static [&'static str] = &["capture", "retain"];
2809 fn unknown_keys(&self) -> &UnknownKeys {
2810 &self.unknown
2811 }
2812}
2813
2814impl RawBlock for RawEvalConfig {
2815 const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
2816 fn unknown_keys(&self) -> &UnknownKeys {
2817 &self.unknown
2818 }
2819
2820 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2821 let scorers = child_path(path, "scorers");
2822 for (index, scorer) in self.scorers.iter().enumerate() {
2823 collect_block(scorer, &format!("{scorers}[{index}]"), out);
2824 }
2825 }
2826}
2827
2828impl RawBlock for RawScorerConfig {
2829 const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
2830 fn unknown_keys(&self) -> &UnknownKeys {
2831 &self.unknown
2832 }
2833}
2834
2835impl RawBlock for RawArtifact {
2836 const KNOWN_KEYS: &'static [&'static str] = &[
2837 "name",
2838 "version",
2839 "runtime",
2840 "source",
2841 "local_source",
2842 "prompt_payload",
2843 "capabilities",
2844 "on_overflow",
2845 "config",
2846 "gateway",
2847 ];
2848 fn unknown_keys(&self) -> &UnknownKeys {
2849 &self.unknown
2850 }
2851
2852 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2853 if let Some(capabilities) = &self.capabilities {
2854 collect_block(capabilities, &child_path(path, "capabilities"), out);
2855 }
2856 if let Some(Some(gateway)) = &self.gateway {
2857 collect_block(gateway, &child_path(path, "gateway"), out);
2858 }
2859 }
2860}
2861
2862impl RawBlock for RawArtifactGateway {
2863 const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
2864 fn unknown_keys(&self) -> &UnknownKeys {
2865 &self.unknown
2866 }
2867}
2868
2869impl RawBlock for RawCapabilities {
2870 const KNOWN_KEYS: &'static [&'static str] = &[
2871 "network",
2872 "peer_fetch",
2873 "filesystem",
2874 "shell",
2875 "spawn",
2876 "env",
2877 "limits",
2878 "resources",
2879 "state",
2880 "task_io",
2881 "conversation",
2882 "plan",
2883 "install",
2884 "containment",
2885 ];
2886 fn unknown_keys(&self) -> &UnknownKeys {
2887 &self.unknown
2888 }
2889
2890 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2894 if let Some(network) = &self.network {
2895 collect_block(network, &child_path(path, "network"), out);
2896 }
2897 if let Some(peer_fetch) = &self.peer_fetch {
2898 collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
2899 }
2900 if let Some(filesystem) = &self.filesystem {
2901 collect_block(filesystem, &child_path(path, "filesystem"), out);
2902 }
2903 if let Some(shell) = &self.shell {
2904 collect_block(shell, &child_path(path, "shell"), out);
2905 }
2906 if let Some(spawn) = &self.spawn {
2907 collect_block(spawn, &child_path(path, "spawn"), out);
2908 }
2909 if let Some(env) = &self.env {
2910 collect_block(env, &child_path(path, "env"), out);
2911 }
2912 if let Some(limits) = &self.limits {
2913 collect_block(limits, &child_path(path, "limits"), out);
2914 }
2915 if let Some(resources) = &self.resources {
2916 collect_block(resources, &child_path(path, "resources"), out);
2917 }
2918 if let Some(state) = &self.state {
2919 collect_block(state, &child_path(path, "state"), out);
2920 }
2921 if let Some(task_io) = &self.task_io {
2922 collect_block(task_io, &child_path(path, "task_io"), out);
2923 }
2924 if let Some(conversation) = &self.conversation {
2925 collect_block(conversation, &child_path(path, "conversation"), out);
2926 }
2927 if let Some(plan) = &self.plan {
2928 collect_block(plan, &child_path(path, "plan"), out);
2929 }
2930 if let Some(install) = &self.install {
2931 collect_block(install, &child_path(path, "install"), out);
2932 }
2933 }
2934}
2935
2936impl RawBlock for RawTaskIoCapabilities {
2937 const KNOWN_KEYS: &'static [&'static str] = &["read"];
2938 fn unknown_keys(&self) -> &UnknownKeys {
2939 &self.unknown
2940 }
2941}
2942
2943impl RawBlock for RawConversationCapabilities {
2944 const KNOWN_KEYS: &'static [&'static str] = &["read"];
2945 fn unknown_keys(&self) -> &UnknownKeys {
2946 &self.unknown
2947 }
2948}
2949
2950impl RawBlock for RawPlanCapabilities {
2951 const KNOWN_KEYS: &'static [&'static str] = &["submit"];
2952 fn unknown_keys(&self) -> &UnknownKeys {
2953 &self.unknown
2954 }
2955}
2956
2957impl RawBlock for RawInstallCapabilities {
2958 const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool", "from"];
2959 fn unknown_keys(&self) -> &UnknownKeys {
2960 &self.unknown
2961 }
2962}
2963
2964impl RawBlock for RawSpawnCapabilities {
2965 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2966 fn unknown_keys(&self) -> &UnknownKeys {
2967 &self.unknown
2968 }
2969}
2970
2971impl RawBlock for RawEnvCapabilities {
2972 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2973 fn unknown_keys(&self) -> &UnknownKeys {
2974 &self.unknown
2975 }
2976}
2977
2978impl RawBlock for RawResourceLimits {
2979 const KNOWN_KEYS: &'static [&'static str] = &[
2980 "memory_bytes",
2981 "table_elements",
2982 "instances",
2983 "deadline_seconds",
2984 ];
2985 fn unknown_keys(&self) -> &UnknownKeys {
2986 &self.unknown
2987 }
2988}
2989
2990impl RawBlock for RawStateCapabilities {
2991 const KNOWN_KEYS: &'static [&'static str] = &["store"];
2992 fn unknown_keys(&self) -> &UnknownKeys {
2993 &self.unknown
2994 }
2995}
2996
2997impl RawBlock for RawResourceCapabilities {
2998 const KNOWN_KEYS: &'static [&'static str] = &[
2999 "max_processes",
3000 "max_open_files",
3001 "max_file_size_bytes",
3002 "cpu_seconds",
3003 "memory_bytes",
3004 "cgroup_memory_bytes",
3005 "cgroup_pids_max",
3006 "cgroup_cpu_percent",
3007 "cgroup_io_bytes_per_sec",
3008 "workdir_max_bytes",
3009 ];
3010 fn unknown_keys(&self) -> &UnknownKeys {
3011 &self.unknown
3012 }
3013}
3014
3015impl RawBlock for RawNetworkCapabilities {
3016 const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
3017 fn unknown_keys(&self) -> &UnknownKeys {
3018 &self.unknown
3019 }
3020}
3021
3022impl RawBlock for RawPeerFetchCapabilities {
3023 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3024 fn unknown_keys(&self) -> &UnknownKeys {
3025 &self.unknown
3026 }
3027}
3028
3029impl RawBlock for RawFilesystemCapabilities {
3030 const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only", "records"];
3031 fn unknown_keys(&self) -> &UnknownKeys {
3032 &self.unknown
3033 }
3034}
3035
3036impl RawBlock for RawShellCapabilities {
3037 const KNOWN_KEYS: &'static [&'static str] = &[
3038 "allow",
3039 "strip_env",
3040 "baseline_env",
3041 "interpreter_runtime",
3042 "staged_runtime",
3043 ];
3044 fn unknown_keys(&self) -> &UnknownKeys {
3045 &self.unknown
3046 }
3047
3048 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3049 let interpreter_runtime = child_path(path, "interpreter_runtime");
3050 for (index, grant) in self.interpreter_runtime.iter().enumerate() {
3051 collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
3052 }
3053 let staged_runtime = child_path(path, "staged_runtime");
3054 for (index, grant) in self.staged_runtime.iter().enumerate() {
3055 collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
3056 }
3057 }
3058}
3059
3060impl RawBlock for RawInterpreterRuntimeGrant {
3061 const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
3062 fn unknown_keys(&self) -> &UnknownKeys {
3063 &self.unknown
3064 }
3065
3066 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3067 let dirs = child_path(path, "dirs");
3068 for (index, dir) in self.dirs.iter().enumerate() {
3069 collect_block(dir, &format!("{dirs}[{index}]"), out);
3070 }
3071 }
3072}
3073
3074impl RawBlock for RawInterpreterRuntimeDir {
3075 const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
3076 fn unknown_keys(&self) -> &UnknownKeys {
3077 &self.unknown
3078 }
3079}
3080
3081impl RawBlock for RawStagedRuntimeGrant {
3082 const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
3083 fn unknown_keys(&self) -> &UnknownKeys {
3084 &self.unknown
3085 }
3086}
3087
3088impl RawBlock for RawInferenceConfig {
3089 const KNOWN_KEYS: &'static [&'static str] = &[
3090 "transport",
3091 "endpoint",
3092 "model",
3093 "api_key",
3094 "command",
3095 "system_prompt",
3096 "system_prompt_file",
3097 "system_prompt_artifact",
3098 "driver",
3099 "compaction",
3100 "max_turns",
3101 "max_task_reopens",
3102 "max_tokens",
3103 "max_session_tokens",
3104 "tool_refresh",
3105 "alternates",
3106 ];
3107 fn unknown_keys(&self) -> &UnknownKeys {
3108 &self.unknown
3109 }
3110
3111 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3112 if let Some(driver) = &self.driver {
3113 collect_block(driver, &child_path(path, "driver"), out);
3114 }
3115 if let Some(compaction) = &self.compaction {
3116 collect_block(compaction, &child_path(path, "compaction"), out);
3117 }
3118 if let Some(alternates) = &self.alternates {
3119 let alternates_path = child_path(path, "alternates");
3120 for (index, alternate) in alternates.iter().enumerate() {
3121 collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
3122 }
3123 }
3124 }
3125}
3126
3127impl RawBlock for RawInferenceAlternate {
3128 const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
3129 fn unknown_keys(&self) -> &UnknownKeys {
3130 &self.unknown
3131 }
3132
3133 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3134 if let Some(driver) = &self.driver {
3135 collect_block(driver, &child_path(path, "driver"), out);
3136 }
3137 }
3138}
3139
3140impl RawBlock for RawCompactionConfig {
3141 const KNOWN_KEYS: &'static [&'static str] = &[
3142 "threshold",
3143 "model",
3144 "system_prompt",
3145 "system_prompt_file",
3146 "dump_summaries",
3147 ];
3148 fn unknown_keys(&self) -> &UnknownKeys {
3149 &self.unknown
3150 }
3151}
3152
3153impl RawBlock for RawInferenceDriver {
3154 const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
3155 fn unknown_keys(&self) -> &UnknownKeys {
3156 &self.unknown
3157 }
3158}
3159
3160fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
3167 let mut out = Vec::new();
3168 collect_block(raw, "", &mut out);
3169 out
3170}
3171
3172fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
3178 for key in block.unknown_keys().keys() {
3179 out.push(UnknownManifestKey {
3180 key: key.clone(),
3181 block_path: path.to_string(),
3182 nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
3183 });
3184 }
3185 block.walk_children(path, out);
3186}
3187
3188fn child_path(parent: &str, field: &str) -> String {
3192 if parent.is_empty() {
3193 return field.to_string();
3194 }
3195 format!("{parent}.{field}")
3196}
3197
3198pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
3205 fs::read_to_string(path).map_err(|source| {
3206 if source.kind() == std::io::ErrorKind::NotFound {
3207 return RuntimeManifestError::NotFound(path.display().to_string());
3208 }
3209
3210 RuntimeManifestError::Io {
3211 path: path.display().to_string(),
3212 source,
3213 }
3214 })
3215}
3216
3217#[must_use = "validated runtime manifest is required before run"]
3218pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
3219 let content = read_runtime_manifest_text(path)?;
3220
3221 RuntimeManifest::from_yaml_str(&content)
3222}
3223
3224#[derive(Debug, Clone, PartialEq, Eq)]
3226pub struct ReferencedEnvVariable {
3227 pub field: String,
3229 pub variable: String,
3231}
3232
3233impl ReferencedEnvVariable {
3234 pub fn is_gateway_credential(&self) -> bool {
3237 self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
3238 }
3239}
3240
3241#[derive(Deserialize)]
3249struct ReferencedManifestFields {
3250 #[serde(default)]
3251 artifacts: Vec<ReferencedArtifactFields>,
3252}
3253
3254#[derive(Deserialize)]
3255struct ReferencedArtifactFields {
3256 #[serde(default)]
3257 name: Option<String>,
3258 #[serde(default)]
3259 gateway: Option<ReferencedGatewayFields>,
3260}
3261
3262#[derive(Deserialize)]
3263struct ReferencedGatewayFields {
3264 #[serde(default)]
3265 api_key: Option<String>,
3266}
3267
3268pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
3276 let Ok(raw) = serde_yaml::from_str::<ReferencedManifestFields>(manifest_yaml) else {
3277 return Vec::new();
3278 };
3279
3280 let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
3281 for artifact in raw.artifacts {
3282 let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
3283 continue;
3284 };
3285 if let Some(variable) = parse_env_reference(api_key.trim()) {
3286 let name = artifact.name.unwrap_or_default();
3287 referenced.push(ReferencedEnvVariable {
3288 field: format!("artifacts.{}.gateway.api_key", name.trim()),
3289 variable: variable.to_string(),
3290 });
3291 }
3292 }
3293
3294 referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
3295 referenced.dedup();
3296 referenced
3297}
3298
3299#[derive(Clone, Copy, PartialEq, Eq)]
3305enum SecretResolution {
3306 Resolve,
3308 Skip,
3311}
3312
3313impl RuntimeManifest {
3314 pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
3315 Self::parse(input, SecretResolution::Resolve)
3316 }
3317
3318 pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
3328 Self::parse(input, SecretResolution::Skip)
3329 }
3330
3331 fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
3332 let raw: RawRuntimeManifest = serde_yaml::from_str(input).map_err(|err| {
3333 if let Some(location) = err.location() {
3334 RuntimeManifestError::YamlSyntax(format!(
3335 "{}: YAML syntax error at line {}, column {}: {}",
3336 MANIFEST_FILENAME,
3337 location.line(),
3338 location.column(),
3339 err
3340 ))
3341 } else {
3342 RuntimeManifestError::YamlSyntax(format!(
3343 "{MANIFEST_FILENAME}: YAML syntax error: {err}"
3344 ))
3345 }
3346 })?;
3347
3348 if raw.unknown.contains_key(REGISTRY_BLOCK_KEY) {
3350 return Err(RuntimeManifestError::RegistryBlock);
3351 }
3352
3353 let unknown_keys = collect_unknown_keys(&raw);
3356
3357 if raw.mur_version.is_some() {
3358 return Err(RuntimeManifestError::TopLevelMurVersion);
3359 }
3360
3361 let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
3362 RuntimeManifestError::MissingField {
3363 field: "name".to_string(),
3364 }
3365 })?;
3366
3367 let version = raw
3368 .version
3369 .filter(|s| !s.trim().is_empty())
3370 .ok_or_else(|| RuntimeManifestError::MissingField {
3371 field: "version".to_string(),
3372 })?;
3373
3374 let gateway_references = raw_gateway_references(&raw.artifacts);
3377
3378 let artifacts = raw
3379 .artifacts
3380 .into_iter()
3381 .enumerate()
3382 .map(|(index, artifact)| {
3383 let name = artifact
3384 .name
3385 .filter(|s| !s.trim().is_empty())
3386 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3387 index,
3388 message: "missing required field 'name'".to_string(),
3389 })?;
3390
3391 let source = artifact.source.filter(|s| !s.trim().is_empty());
3392 check_entry_name(index, &name, source.is_some())?;
3393
3394 let runtime = match artifact.runtime.as_deref() {
3395 None | Some("tool") => ArtifactRuntime::Tool,
3396 Some("wasm") | Some("native") => {
3397 return Err(RuntimeManifestError::InvalidArtifact {
3398 index,
3399 message: format!(
3400 "use 'runtime: tool'; implementation is declared in the \
3401 artifact's own manifest (got '{}')",
3402 artifact.runtime.as_deref().unwrap_or("")
3403 ),
3404 });
3405 }
3406 Some("driver") => ArtifactRuntime::Driver,
3407 Some("hook") => ArtifactRuntime::Hook,
3408 Some("skill") => ArtifactRuntime::Skill,
3409 Some(other) => {
3410 return Err(RuntimeManifestError::InvalidArtifact {
3411 index,
3412 message: format!(
3413 "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
3414 ),
3415 });
3416 }
3417 };
3418
3419 let local_source = artifact
3423 .local_source
3424 .unwrap_or(runtime == ArtifactRuntime::Skill);
3425 let prompt_payload = artifact
3426 .prompt_payload
3427 .unwrap_or(runtime == ArtifactRuntime::Skill);
3428
3429 if source.is_some() && !local_source {
3431 return Err(RuntimeManifestError::InvalidArtifact {
3432 index,
3433 message: format!(
3434 "artifact '{name}' declares 'source:' but does not declare \
3435 'local_source: true' (runtime: {})",
3436 runtime.as_str()
3437 ),
3438 });
3439 }
3440
3441 let version = if source.is_some() {
3444 if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
3445 crate::warn_line(format_args!(
3446 "warning: artifact '{name}' declares both 'source:' and \
3447 'version: {explicit}'; version is ignored for local-source skills \
3448 (using 'local')"
3449 ));
3450 }
3451 "local".to_string()
3452 } else {
3453 artifact
3454 .version
3455 .filter(|s| !s.trim().is_empty())
3456 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3457 index,
3458 message: "missing required field 'version'".to_string(),
3459 })?
3460 };
3461
3462 let capabilities = match artifact.capabilities {
3468 None => None,
3469 Some(raw_caps) => {
3470 if raw_caps.install.is_some() {
3473 return Err(RuntimeManifestError::InvalidArtifact {
3474 index,
3475 message: format!(
3476 "artifact '{name}' declares 'capabilities.install:'; the \
3477 grant is capsule-wide and is only recognized in the \
3478 top-level capabilities block"
3479 ),
3480 });
3481 }
3482 if runtime == ArtifactRuntime::Skill {
3483 return Err(RuntimeManifestError::InvalidArtifact {
3484 index,
3485 message: format!(
3486 "artifact '{name}' declares per-artifact 'capabilities:' but \
3487 has 'runtime: {}'; per-artifact capabilities are only \
3488 recognized on 'runtime: hook', 'runtime: tool', and \
3489 'runtime: driver' entries — use the capsule-wide top-level \
3490 'capabilities:' block instead",
3491 runtime.as_str()
3492 ),
3493 });
3494 }
3495 if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
3501 return Err(RuntimeManifestError::InvalidArtifact {
3502 index,
3503 message: format!(
3504 "artifact '{name}' declares 'capabilities.task_io:' but has \
3505 'runtime: {}'; the key grants the murmur:task-io/read host \
3506 import and is only recognized on 'runtime: hook' entries",
3507 runtime.as_str()
3508 ),
3509 });
3510 }
3511 if let Some(filesystem) = raw_caps
3515 .filesystem
3516 .as_ref()
3517 .filter(|filesystem| filesystem.records)
3518 {
3519 if runtime != ArtifactRuntime::Hook {
3520 return Err(RuntimeManifestError::InvalidArtifact {
3521 index,
3522 message: format!(
3523 "artifact '{name}' declares \
3524 'capabilities.filesystem.records: true' but has \
3525 'runtime: {}'; the key gives a hook the records \
3526 directory's hooks/ and is only recognized on \
3527 'runtime: hook' entries",
3528 runtime.as_str()
3529 ),
3530 });
3531 }
3532 if filesystem
3533 .scope
3534 .as_deref()
3535 .is_some_and(|scope| !scope.trim().is_empty())
3536 {
3537 return Err(RuntimeManifestError::InvalidArtifact {
3538 index,
3539 message: format!(
3540 "artifact '{name}' declares both \
3541 'capabilities.filesystem.records: true' and \
3542 'capabilities.filesystem.scope'; a hook's \".\" is \
3543 either the records directory's hooks/ or a workdir \
3544 scope — declare one"
3545 ),
3546 });
3547 }
3548 }
3549 if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
3553 return Err(RuntimeManifestError::InvalidArtifact {
3554 index,
3555 message: format!(
3556 "artifact '{name}' declares 'capabilities.conversation:' but \
3557 has 'runtime: {}'; the key grants the \
3558 murmur:conversation/read host import and is only recognized \
3559 on 'runtime: hook' entries",
3560 runtime.as_str()
3561 ),
3562 });
3563 }
3564 parse_capabilities(Some(raw_caps))?
3565 }
3566 };
3567
3568 let on_overflow = match artifact.on_overflow.as_deref() {
3574 None => HookOverflowPolicy::default(),
3575 Some(raw) => {
3576 if runtime != ArtifactRuntime::Hook {
3577 return Err(RuntimeManifestError::InvalidArtifact {
3578 index,
3579 message: format!(
3580 "artifact '{name}' declares 'on_overflow:' but has \
3581 'runtime: {}'; the key governs an async hook's job queue \
3582 and is only recognized on 'runtime: hook' entries",
3583 runtime.as_str()
3584 ),
3585 });
3586 }
3587 match raw {
3588 "drop" => HookOverflowPolicy::Drop,
3589 "block" => HookOverflowPolicy::Block,
3590 other => {
3591 return Err(RuntimeManifestError::InvalidArtifact {
3592 index,
3593 message: format!(
3594 "artifact '{name}' declares unknown on_overflow \
3595 '{other}'; expected: drop, block"
3596 ),
3597 });
3598 }
3599 }
3600 }
3601 };
3602
3603 if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
3610 return Err(RuntimeManifestError::InvalidArtifact {
3611 index,
3612 message: format!(
3613 "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
3614 is recognized only on 'runtime: hook', 'runtime: tool', and \
3615 'runtime: driver' entries",
3616 runtime.as_str()
3617 ),
3618 });
3619 }
3620
3621 let gateway = match artifact.gateway {
3622 None => None,
3623 Some(raw_gateway) => {
3624 if runtime == ArtifactRuntime::Skill {
3625 return Err(RuntimeManifestError::InvalidArtifact {
3626 index,
3627 message: format!(
3628 "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
3629 the key is recognized only on 'runtime: hook', \
3630 'runtime: tool', and 'runtime: driver' entries",
3631 runtime.as_str()
3632 ),
3633 });
3634 }
3635 Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
3636 }
3637 };
3638
3639 Ok(RuntimeArtifact {
3640 name,
3641 version,
3642 runtime,
3643 source,
3644 local_source,
3645 prompt_payload,
3646 capabilities,
3647 on_overflow,
3648 config: artifact.config,
3649 gateway,
3650 })
3651 })
3652 .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
3653
3654 if raw
3658 .capabilities
3659 .as_ref()
3660 .is_some_and(|caps| caps.task_io.is_some())
3661 {
3662 return Err(RuntimeManifestError::InvalidCapabilities {
3663 field: "capabilities.task_io".to_string(),
3664 message: "is recognized only on 'runtime: hook' artifact entries, not in the \
3665 capsule-wide capabilities block — the grant is per-hook"
3666 .to_string(),
3667 });
3668 }
3669 if raw
3672 .capabilities
3673 .as_ref()
3674 .and_then(|caps| caps.filesystem.as_ref())
3675 .is_some_and(|filesystem| filesystem.records)
3676 {
3677 return Err(RuntimeManifestError::InvalidCapabilities {
3678 field: "capabilities.filesystem.records".to_string(),
3679 message: "is recognized only on 'runtime: hook' artifact entries, not in the \
3680 capsule-wide capabilities block — the grant is per-hook"
3681 .to_string(),
3682 });
3683 }
3684 if raw.config.is_some() {
3689 return Err(RuntimeManifestError::InvalidCapabilities {
3690 field: "config".to_string(),
3691 message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
3692 'runtime: driver' entry that reads it — there is no capsule-wide \
3693 config block"
3694 .to_string(),
3695 });
3696 }
3697 let capabilities = parse_capabilities(raw.capabilities)?;
3698 let inference = parse_inference(raw.inference)?;
3699 refuse_install_under_process_transport(capabilities.as_ref(), inference.as_ref())?;
3700 validate_driver_gateways(&artifacts, inference.as_ref())?;
3701 validate_driver_choices(&artifacts, inference.as_ref())?;
3702 validate_process_driver_entry(&artifacts, inference.as_ref())?;
3703
3704 if let Some(ref sp_art) = inference
3707 .as_ref()
3708 .and_then(|i| i.system_prompt_artifact.clone())
3709 {
3710 let matching = artifacts.iter().find(|a| &a.name == sp_art);
3711 match matching {
3712 None => {
3713 return Err(RuntimeManifestError::InvalidInferenceConfig {
3714 field: "inference.system_prompt_artifact".to_string(),
3715 message: not_declared_message(&artifacts, sp_art),
3716 });
3717 }
3718 Some(a) if !a.prompt_payload => {
3719 return Err(RuntimeManifestError::InvalidInferenceConfig {
3720 field: "inference.system_prompt_artifact".to_string(),
3721 message: format!(
3722 "artifact '{sp_art}' (runtime: {}) does not declare \
3723 'prompt_payload: true', which system_prompt_artifact requires",
3724 a.runtime.as_str()
3725 ),
3726 });
3727 }
3728 Some(_) => {} }
3730 }
3731 let context = parse_context(raw.context)?;
3732 let observability = parse_observability(raw.observability);
3733 let trace = raw
3734 .trace
3735 .map(|raw_trace| {
3736 let capture = resolve_trace_capture(raw_trace.capture.as_deref())?;
3737 let retain = parse_trace_retain(raw_trace.retain)?;
3738 Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
3739 })
3740 .transpose()?;
3741 let network = raw
3742 .network
3743 .map(|n| {
3744 Ok::<_, RuntimeManifestError>(NetworkConfig {
3745 internal_port: n.internal_port,
3746 authentication: parse_network_authentication(n.authentication)?,
3747 })
3748 })
3749 .transpose()?;
3750 refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
3751 let lifecycle = raw
3752 .lifecycle
3753 .map(|raw_lc| {
3754 let defaults = LifecycleConfig::default();
3755 let lifecycle = LifecycleConfig {
3756 task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
3757 after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
3758 queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
3759 input_timeout_secs: raw_lc.input_timeout_secs,
3760 conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
3761 max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
3762 shell_grace_secs: raw_lc.shell_grace_secs.unwrap_or(defaults.shell_grace_secs),
3763 delegation_deadline_secs: raw_lc
3764 .delegation_deadline_secs
3765 .unwrap_or(defaults.delegation_deadline_secs),
3766 };
3767 if lifecycle.queue_has_room() {
3768 Ok(lifecycle)
3769 } else {
3770 Err(RuntimeManifestError::QueueWithoutRoom)
3771 }
3772 })
3773 .transpose()?;
3774
3775 let exports = parse_exports(raw.exports)?;
3776 let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
3777 validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
3778
3779 Ok(Self {
3780 name,
3781 version,
3782 artifacts,
3783 capabilities,
3784 inference,
3785 context,
3786 observability,
3787 trace,
3788 network,
3789 lifecycle,
3790 exports,
3791 control,
3792 deploy: raw.deploy.map(|deploy| DeployConfig {
3793 mur_version: deploy.mur_version.filter(|s| !s.trim().is_empty()),
3794 }),
3795 unknown_keys,
3796 })
3797 }
3798}
3799
3800fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
3802 artifacts
3803 .iter()
3804 .filter_map(|artifact| {
3805 let name = artifact.name.as_deref()?.trim();
3806 let written = artifact
3807 .gateway
3808 .as_ref()?
3809 .as_ref()?
3810 .api_key
3811 .as_ref()?
3812 .as_ref()?;
3813 let variable = parse_env_reference(written.trim())?;
3814 Some((name.to_string(), variable.to_string()))
3815 })
3816 .collect()
3817}
3818
3819fn parse_control(
3826 raw: Option<RawControl>,
3827 gateway_references: &[(String, String)],
3828 inference: Option<&InferenceConfig>,
3829) -> Result<Option<ControlConfig>, RuntimeManifestError> {
3830 let Some(raw) = raw else {
3831 return Ok(None);
3832 };
3833 let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
3834 field: field.to_string(),
3835 message,
3836 };
3837 let settings_written = control_string_list("control.settings", raw.settings)?;
3838 let secrets_written = control_string_list("control.secrets", raw.secrets)?;
3839 let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
3840 if settings_written.is_empty()
3841 && secrets_written.is_empty()
3842 && agent_settings_written.is_empty()
3843 {
3844 return Ok(None);
3845 }
3846 let Some(inference) = inference else {
3847 return Err(refuse(
3848 "control",
3849 "is declared on a capsule with no inference: block; the control surface is served on \
3850 an agent capsule's A2A listener, and a script capsule opens none"
3851 .to_string(),
3852 ));
3853 };
3854
3855 let mut settings = Vec::new();
3856 for entry in settings_written {
3857 let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
3858 let controllable: Vec<&str> = ControllableSetting::ALL
3859 .iter()
3860 .map(|setting| setting.wire_name())
3861 .collect();
3862 return Err(refuse(
3863 "control.settings",
3864 format!(
3865 "'{entry}' is not a controllable setting; the controllable settings are: {}",
3866 controllable.join(", ")
3867 ),
3868 ));
3869 };
3870 if settings.contains(&setting) {
3871 return Err(refuse(
3872 "control.settings",
3873 format!("'{entry}' is listed more than once"),
3874 ));
3875 }
3876 match setting {
3877 ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
3878 return Err(refuse(
3879 "control.settings",
3880 format!(
3881 "'{entry}' is controllable only under inference.transport: http; a \
3882 transport: {} harness sets its own output cap",
3883 inference.transport
3884 ),
3885 ));
3886 }
3887 ControllableSetting::InferenceMaxTokens => {}
3888 ControllableSetting::InferenceDriver => {
3889 check_driver_setting(inference).map_err(|message| {
3890 refuse("control.settings", format!("'{entry}' {message}"))
3891 })?;
3892 }
3893 }
3894 settings.push(setting);
3895 }
3896
3897 let mut agent_settings = Vec::new();
3898 for entry in agent_settings_written {
3899 let Some(setting) = ControllableSetting::from_wire_name(&entry)
3900 .filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
3901 else {
3902 let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
3903 .iter()
3904 .map(|setting| setting.wire_name())
3905 .collect();
3906 return Err(refuse(
3907 "control.agent_settings",
3908 format!(
3909 "'{entry}' is not a setting the agent may change; control.agent_settings \
3910 accepts only: {}",
3911 settable.join(", ")
3912 ),
3913 ));
3914 };
3915 if agent_settings.contains(&setting) {
3916 return Err(refuse(
3917 "control.agent_settings",
3918 format!("'{entry}' is listed more than once"),
3919 ));
3920 }
3921 check_driver_setting(inference)
3922 .map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
3923 agent_settings.push(setting);
3924 }
3925
3926 let inference_driver = (inference.transport == "http")
3927 .then(|| {
3928 inference
3929 .driver
3930 .as_ref()
3931 .map(|driver| driver.artifact.as_str())
3932 })
3933 .flatten();
3934 let mut secrets: Vec<String> = Vec::new();
3935 for entry in secrets_written {
3936 if !is_valid_env_variable(&entry) {
3937 return Err(refuse(
3938 "control.secrets",
3939 format!(
3940 "'{entry}' is not a credential name; a name is uppercase letters, digits and \
3941 underscores, and does not start with a digit"
3942 ),
3943 ));
3944 }
3945 if secrets.contains(&entry) {
3946 return Err(refuse(
3947 "control.secrets",
3948 format!("'{entry}' is listed more than once"),
3949 ));
3950 }
3951 let backing: Vec<&str> = gateway_references
3952 .iter()
3953 .filter(|(_, variable)| *variable == entry)
3954 .map(|(artifact, _)| artifact.as_str())
3955 .collect();
3956 if backing.is_empty() {
3957 return Err(refuse(
3958 "control.secrets",
3959 format!(
3960 "'{entry}' is not referenced by any artifact's gateway.api_key as \
3961 ${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
3962 ),
3963 ));
3964 }
3965 if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
3966 return Err(refuse(
3967 "control.secrets",
3968 format!(
3969 "'{entry}' keys the configured inference driver's gateway \
3970 (artifacts.{driver}.gateway.api_key); the inference credential is resolved \
3971 at launch and cannot be supplied by a controller"
3972 ),
3973 ));
3974 }
3975 secrets.push(entry);
3976 }
3977
3978 Ok(Some(ControlConfig {
3979 settings,
3980 secrets,
3981 agent_settings,
3982 }))
3983}
3984
3985fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
3988 if inference.transport != "http" {
3989 return Err(format!(
3990 "is controllable only under inference.transport: http; a transport: {} harness owns \
3991 the model it runs",
3992 inference.transport
3993 ));
3994 }
3995 if inference.alternates.is_empty() {
3996 return Err(
3997 "selects among inference.alternates, and this capsule declares none; declare an \
3998 alternate to switch to"
3999 .to_string(),
4000 );
4001 }
4002 Ok(())
4003}
4004
4005fn control_string_list(
4007 field: &str,
4008 value: Option<serde_yaml::Value>,
4009) -> Result<Vec<String>, RuntimeManifestError> {
4010 let refuse = || RuntimeManifestError::InvalidControl {
4011 field: field.to_string(),
4012 message: "must be a list of names".to_string(),
4013 };
4014 let items = match value {
4015 None | Some(serde_yaml::Value::Null) => return Ok(Vec::new()),
4016 Some(serde_yaml::Value::Sequence(items)) => items,
4017 Some(_) => return Err(refuse()),
4018 };
4019 items
4020 .into_iter()
4021 .map(|item| match item {
4022 serde_yaml::Value::String(name) => Ok(name.trim().to_string()),
4023 _ => Err(refuse()),
4024 })
4025 .collect()
4026}
4027
4028fn parse_network_authentication(
4030 raw: Option<RawNetworkAuthentication>,
4031) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
4032 let Some(raw) = raw else {
4033 return Ok(None);
4034 };
4035 let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
4036 field: field.to_string(),
4037 message,
4038 };
4039 let schemes: Vec<&str> = AuthenticationScheme::ALL
4040 .iter()
4041 .map(|scheme| scheme.wire_name())
4042 .collect();
4043 let scheme = match raw.scheme.as_deref().map(str::trim) {
4044 None | Some("") => {
4045 return Err(refuse(
4046 "network.authentication.scheme",
4047 format!("is required; the schemes are: {}", schemes.join(", ")),
4048 ))
4049 }
4050 Some(written) => AuthenticationScheme::ALL
4051 .iter()
4052 .copied()
4053 .find(|scheme| scheme.wire_name() == written)
4054 .ok_or_else(|| {
4055 refuse(
4056 "network.authentication.scheme",
4057 format!(
4058 "'{written}' is not a door authentication scheme; the schemes are: {}",
4059 schemes.join(", ")
4060 ),
4061 )
4062 })?,
4063 };
4064
4065 let mut credentials = Vec::new();
4066 for (name, credential) in raw.credentials.unwrap_or_default() {
4067 if name == OPERATOR_CREDENTIAL {
4068 return Err(refuse(
4069 "network.authentication.credentials",
4070 format!(
4071 "'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
4072 token, which holds every scope"
4073 ),
4074 ));
4075 }
4076 if !is_valid_credential_name(&name) {
4077 return Err(refuse(
4078 "network.authentication.credentials",
4079 format!(
4080 "'{name}' is not a credential name; a name is a lowercase letter followed by \
4081 at most {} lowercase letters, digits, '_' or '-'",
4082 MAX_CREDENTIAL_NAME_LEN - 1
4083 ),
4084 ));
4085 }
4086 let field = format!("network.authentication.credentials.{name}.scopes");
4087 let scopes = door_scope_list(&field, credential.scopes)?;
4088 credentials.push(DoorCredential { name, scopes });
4089 }
4090
4091 Ok(Some(NetworkAuthentication {
4092 scheme,
4093 credentials,
4094 }))
4095}
4096
4097fn is_valid_credential_name(name: &str) -> bool {
4099 let mut chars = name.chars();
4100 chars.next().is_some_and(|first| first.is_ascii_lowercase())
4101 && name.len() <= MAX_CREDENTIAL_NAME_LEN
4102 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
4103}
4104
4105fn door_scope_list(
4107 field: &str,
4108 value: Option<serde_yaml::Value>,
4109) -> Result<Vec<String>, RuntimeManifestError> {
4110 let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
4111 field: field.to_string(),
4112 message,
4113 };
4114 let items = match value {
4115 None | Some(serde_yaml::Value::Null) => {
4116 return Err(refuse(
4117 "is required: a credential reaches only the scopes it lists".to_string(),
4118 ))
4119 }
4120 Some(serde_yaml::Value::Sequence(items)) => items,
4121 Some(_) => return Err(refuse("must be a list of scopes".to_string())),
4122 };
4123 if items.is_empty() {
4124 return Err(refuse(
4125 "lists no scope; a credential must reach at least one".to_string(),
4126 ));
4127 }
4128 let mut scopes: Vec<String> = Vec::new();
4129 for item in items {
4130 let serde_yaml::Value::String(scope) = item else {
4131 return Err(refuse("must be a list of scopes".to_string()));
4132 };
4133 let scope = scope.trim().to_string();
4134 if let Some(&(_, replacement)) = RETIRED_DOOR_SCOPES
4135 .iter()
4136 .find(|(retired, _)| *retired == scope)
4137 {
4138 return Err(RuntimeManifestError::RetiredDoorScope {
4139 field: field.to_string(),
4140 scope,
4141 replacement,
4142 });
4143 }
4144 if !DOOR_SCOPES.contains(&scope.as_str()) {
4145 return Err(refuse(format!(
4146 "'{scope}' is not a door scope; the scopes are: {}",
4147 DOOR_SCOPES.join(", ")
4148 )));
4149 }
4150 if scopes.contains(&scope) {
4151 return Err(refuse(format!("'{scope}' is listed more than once")));
4152 }
4153 scopes.push(scope);
4154 }
4155 Ok(scopes)
4156}
4157
4158fn refuse_authenticated_delegating_parent(
4163 network: Option<&NetworkConfig>,
4164 capabilities: Option<&Capabilities>,
4165) -> Result<(), RuntimeManifestError> {
4166 let authenticated = network.is_some_and(|network| network.authentication.is_some());
4167 let delegates = capabilities
4168 .and_then(|capabilities| capabilities.spawn.as_ref())
4169 .is_some_and(|spawn| !spawn.allow.is_empty());
4170 if authenticated && delegates {
4171 return Err(RuntimeManifestError::InvalidNetworkConfig {
4172 field: "network.authentication".to_string(),
4173 message: "cannot be declared together with capabilities.spawn.allow: a delegated \
4174 child posts its outcome to this door, and a delegated child cannot yet be \
4175 issued a token for this door"
4176 .to_string(),
4177 });
4178 }
4179 Ok(())
4180}
4181
4182fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
4189 let Some(raw_exports) = raw else {
4190 return Ok(None);
4191 };
4192 let files = raw_exports.files.map(parse_file_export).transpose()?;
4193 let peer_files = raw_exports
4194 .peer_files
4195 .map(parse_peer_files_export)
4196 .transpose()?;
4197 let peer_tasks = raw_exports
4198 .peer_tasks
4199 .map(|raw| {
4200 raw.accept
4201 .map(|accept| PeerTasksExport { accept })
4202 .ok_or_else(|| RuntimeManifestError::InvalidExports {
4203 field: "exports.peer_tasks.accept".to_string(),
4204 message: "must be set explicitly to true or false — consent to peer tasks is \
4205 never inferred"
4206 .to_string(),
4207 })
4208 })
4209 .transpose()?;
4210 Ok(Some(Exports {
4211 files,
4212 peer_files,
4213 peer_tasks,
4214 }))
4215}
4216
4217fn parse_peer_files_export(
4225 raw: RawPeerFilesExport,
4226) -> Result<PeerFilesExport, RuntimeManifestError> {
4227 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4228 field: field.to_string(),
4229 message,
4230 };
4231
4232 let root = raw
4233 .root
4234 .map(|root| root.trim().to_string())
4235 .filter(|root| !root.is_empty())
4236 .ok_or_else(|| {
4237 invalid(
4238 "exports.peer_files.root",
4239 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4240 )
4241 })?;
4242 check_export_root_shape("exports.peer_files.root", &root)?;
4243
4244 let max_ttl_secs = match raw.max_ttl {
4245 None => None,
4246 Some(value) => {
4247 let text = scalar_text(&value).ok_or_else(|| {
4248 invalid(
4249 "exports.peer_files.max_ttl",
4250 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
4251 )
4252 })?;
4253 let parsed = parse_duration_secs(&text)
4254 .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
4255 if parsed == 0 {
4256 return Err(invalid(
4257 "exports.peer_files.max_ttl",
4258 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
4259 ));
4260 }
4261 Some(parsed)
4262 }
4263 };
4264
4265 let max_bytes = match raw.max_bytes {
4266 None => DEFAULT_PEER_FILES_MAX_BYTES,
4267 Some(value) => {
4268 let text = scalar_text(&value).ok_or_else(|| {
4269 invalid(
4270 "exports.peer_files.max_bytes",
4271 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4272 )
4273 })?;
4274 let parsed = parse_byte_size(&text)
4275 .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
4276 if parsed == 0 {
4277 return Err(invalid(
4278 "exports.peer_files.max_bytes",
4279 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4280 ));
4281 }
4282 parsed
4283 }
4284 };
4285
4286 Ok(PeerFilesExport {
4287 root,
4288 max_ttl_secs,
4289 max_bytes,
4290 })
4291}
4292
4293fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_yaml::Value>, D::Error>
4301where
4302 D: serde::Deserializer<'de>,
4303{
4304 serde_yaml::Value::deserialize(deserializer).map(Some)
4305}
4306
4307fn scalar_text(value: &serde_yaml::Value) -> Option<String> {
4308 match value {
4309 serde_yaml::Value::String(text) => Some(text.clone()),
4310 serde_yaml::Value::Number(number) => Some(number.to_string()),
4311 _ => None,
4312 }
4313}
4314
4315fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
4319 for component in Path::new(root).components() {
4320 match component {
4321 std::path::Component::Normal(_) | std::path::Component::CurDir => {}
4322 std::path::Component::ParentDir => {
4323 return Err(RuntimeManifestError::InvalidExports {
4324 field: field.to_string(),
4325 message: format!(
4326 "'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}"
4327 ),
4328 });
4329 }
4330 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
4331 return Err(RuntimeManifestError::InvalidExports {
4332 field: field.to_string(),
4333 message: format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}"),
4334 });
4335 }
4336 }
4337 }
4338 Ok(())
4339}
4340
4341fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
4342 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4343 field: field.to_string(),
4344 message,
4345 };
4346
4347 let root = raw
4348 .root
4349 .map(|root| root.trim().to_string())
4350 .filter(|root| !root.is_empty())
4351 .ok_or_else(|| {
4352 invalid(
4353 "exports.files.root",
4354 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4355 )
4356 })?;
4357 check_export_root_shape("exports.files.root", &root)?;
4358
4359 let mode = match raw.mode.as_deref().map(str::trim) {
4360 None | Some("") => {
4361 return Err(invalid(
4362 "exports.files.mode",
4363 "is required and must be 'read-only'".to_string(),
4364 ));
4365 }
4366 Some("read-only") => ExportMode::ReadOnly,
4367 Some(other) => {
4368 return Err(invalid(
4369 "exports.files.mode",
4370 format!("'{other}' must be 'read-only'"),
4371 ));
4372 }
4373 };
4374
4375 let max_bytes = match raw.max_bytes {
4376 None => DEFAULT_EXPORT_MAX_BYTES,
4377 Some(value) => {
4378 let Some(text) = scalar_text(&value) else {
4379 return Err(invalid(
4380 "exports.files.max_bytes",
4381 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4382 ));
4383 };
4384 let parsed = parse_byte_size(&text)
4385 .map_err(|message| invalid("exports.files.max_bytes", message))?;
4386 if parsed == 0 {
4387 return Err(invalid(
4388 "exports.files.max_bytes",
4389 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4390 ));
4391 }
4392 parsed
4393 }
4394 };
4395
4396 Ok(FileExport {
4397 root,
4398 mode,
4399 max_bytes,
4400 })
4401}
4402
4403const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
4406
4407pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
4409
4410fn check_network_destination_shape(entry: &str) -> Result<(), String> {
4417 let trimmed = entry.trim();
4418 if trimmed.is_empty() {
4419 return Err(format!(
4420 "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4421 ));
4422 }
4423
4424 if trimmed.contains("://") {
4425 let url = Url::parse(trimmed).map_err(|error| {
4426 format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
4427 })?;
4428 let scheme = url.scheme().to_ascii_lowercase();
4429 if scheme != "http" && scheme != "https" {
4430 return Err(format!(
4431 "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
4432 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4433 ));
4434 }
4435 if url.host_str().is_none_or(str::is_empty) {
4436 return Err(format!(
4437 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4438 ));
4439 }
4440 if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
4441 return Err(format!(
4442 "'{entry}' must not include a path, query or fragment; \
4443 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4444 ));
4445 }
4446 return Ok(());
4447 }
4448
4449 if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
4450 return Err(format!(
4451 "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4452 ));
4453 }
4454 if let Some((host, port)) = trimmed.rsplit_once(':') {
4456 if !host.ends_with(']') {
4457 if host.is_empty() {
4458 return Err(format!(
4459 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4460 ));
4461 }
4462 if port.parse::<u16>().is_err() {
4463 return Err(format!(
4464 "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4465 ));
4466 }
4467 }
4468 }
4469 Ok(())
4470}
4471
4472fn parse_capabilities(
4473 raw: Option<RawCapabilities>,
4474) -> Result<Option<Capabilities>, RuntimeManifestError> {
4475 let Some(raw_caps) = raw else {
4476 return Ok(None);
4477 };
4478
4479 let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
4480 allow: raw_network.allow,
4481 unix_sockets: raw_network.unix_sockets,
4482 });
4483
4484 let peer_fetch = raw_caps
4485 .peer_fetch
4486 .map(|raw_peer_fetch| {
4487 if raw_peer_fetch.allow.is_empty() {
4488 return Err(RuntimeManifestError::InvalidCapabilities {
4489 field: "capabilities.peer_fetch.allow".to_string(),
4490 message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
4491 });
4492 }
4493 for entry in &raw_peer_fetch.allow {
4494 check_network_destination_shape(entry).map_err(|message| {
4495 RuntimeManifestError::InvalidCapabilities {
4496 field: "capabilities.peer_fetch.allow".to_string(),
4497 message,
4498 }
4499 })?;
4500 }
4501 Ok(PeerFetchCapabilities {
4502 allow: raw_peer_fetch.allow,
4503 })
4504 })
4505 .transpose()?;
4506
4507 let filesystem = raw_caps
4508 .filesystem
4509 .map(|raw_filesystem| FilesystemCapabilities {
4510 scope: raw_filesystem.scope.and_then(|scope| {
4511 let trimmed = scope.trim();
4512 (!trimmed.is_empty()).then(|| trimmed.to_string())
4513 }),
4514 workdir_exec: raw_filesystem.workdir_exec,
4519 read_only: raw_filesystem
4523 .read_only
4524 .into_iter()
4525 .filter_map(|entry| {
4526 let trimmed = entry.trim();
4527 (!trimmed.is_empty()).then(|| trimmed.to_string())
4528 })
4529 .collect(),
4530 records: raw_filesystem.records,
4532 });
4533
4534 let shell = raw_caps
4535 .shell
4536 .map(|raw_shell| {
4537 if raw_shell.allow.is_empty() {
4538 return Err(RuntimeManifestError::InvalidCapabilities {
4539 field: "capabilities.shell.allow".to_string(),
4540 message: "must contain at least one binary".to_string(),
4541 });
4542 }
4543 for (index, entry) in raw_shell.allow.iter().enumerate() {
4544 if let Some(reason) = shell_allow_entry_error(entry) {
4545 return Err(RuntimeManifestError::ShellAllowEntry {
4546 index,
4547 entry: entry.clone(),
4548 reason,
4549 });
4550 }
4551 }
4552
4553 let interpreter_runtime =
4554 parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
4555 let staged_runtime = parse_staged_runtime(
4558 &raw_shell.allow,
4559 &interpreter_runtime,
4560 raw_shell.staged_runtime,
4561 )?;
4562
4563 Ok(ShellCapabilities {
4564 allow: raw_shell.allow,
4565 strip_env: raw_shell.strip_env,
4566 baseline_env: raw_shell.baseline_env,
4567 interpreter_runtime,
4568 staged_runtime,
4569 })
4570 })
4571 .transpose()?;
4572
4573 let spawn = raw_caps
4574 .spawn
4575 .map(|raw_spawn| parse_spawn_allow(raw_spawn.allow))
4576 .transpose()?;
4577
4578 let env = raw_caps.env.map(|raw_env| EnvCapabilities {
4579 allow: raw_env.allow,
4580 });
4581
4582 let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
4583
4584 let resources = raw_caps
4585 .resources
4586 .map(parse_resource_capabilities)
4587 .transpose()?;
4588
4589 let state = raw_caps.state.map(parse_state_capabilities);
4590
4591 let task_io = raw_caps
4592 .task_io
4593 .map(|raw_task_io| {
4594 raw_task_io
4595 .read
4596 .map(|read| TaskIoCapabilities { read })
4597 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4598 field: "capabilities.task_io.read".to_string(),
4599 message: "must be set explicitly to true or false — a capability is never \
4600 inferred"
4601 .to_string(),
4602 })
4603 })
4604 .transpose()?;
4605
4606 let conversation = raw_caps
4607 .conversation
4608 .map(|raw_conversation| {
4609 raw_conversation
4610 .read
4611 .map(|read| ConversationCapabilities { read })
4612 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4613 field: "capabilities.conversation.read".to_string(),
4614 message: "must be set explicitly to true or false — a capability is never \
4615 inferred"
4616 .to_string(),
4617 })
4618 })
4619 .transpose()?;
4620
4621 let plan = raw_caps
4622 .plan
4623 .map(|raw_plan| {
4624 raw_plan
4625 .submit
4626 .map(|submit| PlanCapabilities { submit })
4627 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4628 field: "capabilities.plan.submit".to_string(),
4629 message: "must be set explicitly to true or false — a capability is never \
4630 inferred"
4631 .to_string(),
4632 })
4633 })
4634 .transpose()?;
4635
4636 let install = raw_caps.install.map(parse_install).transpose()?;
4637
4638 let containment = raw_caps
4639 .containment
4640 .as_deref()
4641 .map(str::trim)
4642 .filter(|value| !value.is_empty())
4643 .map(|value| {
4644 value.parse::<ContainmentClass>().map_err(|error| {
4645 RuntimeManifestError::InvalidCapabilities {
4646 field: "capabilities.containment".to_string(),
4647 message: error.to_string(),
4648 }
4649 })
4650 })
4651 .transpose()?;
4652
4653 Ok(Some(Capabilities {
4654 network,
4655 peer_fetch,
4656 filesystem,
4657 shell,
4658 spawn,
4659 env,
4660 limits,
4661 resources,
4662 state,
4663 task_io,
4664 conversation,
4665 plan,
4666 install,
4667 containment,
4668 }))
4669}
4670
4671fn parse_spawn_allow(allow: Vec<String>) -> Result<SpawnCapabilities, RuntimeManifestError> {
4674 let mut parsed: Vec<ArtifactName> = Vec::with_capacity(allow.len());
4675 for (index, entry) in allow.iter().enumerate() {
4676 let name = ArtifactName::parse(entry).map_err(|error| {
4677 RuntimeManifestError::InvalidArtifactName {
4678 field: format!("capabilities.spawn.allow[{index}]"),
4679 message: error.to_string(),
4680 hint: error.hint(),
4681 }
4682 })?;
4683 if let Some(earlier) = parsed
4684 .iter()
4685 .find(|earlier| earlier.name() == name.name() && **earlier != name)
4686 {
4687 return Err(RuntimeManifestError::InvalidArtifactName {
4688 field: format!("capabilities.spawn.allow[{index}]"),
4689 message: format!(
4690 "'{earlier}' and '{name}' share the name '{}', which is how delegate-task \
4691 offers a capsule to the model",
4692 name.name()
4693 ),
4694 hint: Some(format!(
4695 "keep one of '{earlier}' and '{name}' in capabilities.spawn.allow"
4696 )),
4697 });
4698 }
4699 parsed.push(name);
4700 }
4701 Ok(SpawnCapabilities { allow })
4702}
4703
4704fn check_entry_name(
4707 index: usize,
4708 name: &str,
4709 has_source: bool,
4710) -> Result<(), RuntimeManifestError> {
4711 let field = format!("artifacts[{index}].name");
4712 if !has_source {
4713 return ArtifactName::parse(name).map(|_| ()).map_err(|error| {
4714 RuntimeManifestError::InvalidArtifactName {
4715 field,
4716 message: error.to_string(),
4717 hint: error.hint(),
4718 }
4719 });
4720 }
4721 if let Some((_, bare)) = name.split_once('/') {
4722 return Err(RuntimeManifestError::InvalidArtifactName {
4723 field,
4724 message: format!(
4725 "'{name}' declares 'source:', so it is named by its bare name; a scope says \
4726 where a resolved artifact comes from, and a source: entry is never resolved"
4727 ),
4728 hint: Some(format!("drop the scope: name it '{bare}'")),
4729 });
4730 }
4731 match artifact_name_format_error(name) {
4732 Some(reason) => Err(RuntimeManifestError::InvalidArtifactName {
4733 field,
4734 message: format!("'{name}' is not a valid artifact name: it {reason}"),
4735 hint: None,
4736 }),
4737 None => Ok(()),
4738 }
4739}
4740
4741fn not_declared_message(artifacts: &[RuntimeArtifact], name: &str) -> String {
4745 let mut same_bare = artifacts
4746 .iter()
4747 .filter(|artifact| artifact.name != name && artifact.tool_name() == tool_name_of(name));
4748 match (same_bare.next(), same_bare.next()) {
4749 (Some(only), None) => format!(
4750 "artifact '{name}' is not declared in artifacts: (the declared '{}' has that name; \
4751 refer to it as '{}')",
4752 only.name, only.name
4753 ),
4754 _ => format!("artifact '{name}' is not declared in artifacts:"),
4755 }
4756}
4757
4758fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
4761 if raw.skill.is_empty() && raw.tool.is_empty() {
4762 return Err(RuntimeManifestError::InvalidCapabilities {
4763 field: "capabilities.install".to_string(),
4764 message: "must name at least one 'skill' or 'tool' entry — a capability is never \
4765 inferred; omit the block to grant no install at all"
4766 .to_string(),
4767 });
4768 }
4769 for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
4770 for (index, entry) in entries.iter().enumerate() {
4771 crate::install_pattern::validate(entry).map_err(|error| {
4772 RuntimeManifestError::InvalidArtifactName {
4773 field: format!("capabilities.install.{key}[{index}]"),
4774 message: format!(
4775 "entry {entry:?} is invalid ({}); expected {}",
4776 error.reason,
4777 crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
4778 ),
4779 hint: error.hint,
4780 }
4781 })?;
4782 }
4783 }
4784 let from = match raw.from {
4785 None => InstallFrom::Stores,
4786 Some(list) => parse_install_from(&list)?,
4787 };
4788 Ok(InstallCapabilities {
4789 skill: raw.skill,
4790 tool: raw.tool,
4791 from,
4792 })
4793}
4794
4795fn refuse_install_under_process_transport(
4798 capabilities: Option<&Capabilities>,
4799 inference: Option<&InferenceConfig>,
4800) -> Result<(), RuntimeManifestError> {
4801 let installs = capabilities.is_some_and(|capabilities| capabilities.install.is_some());
4802 let process = inference.is_some_and(|inference| inference.transport == "process");
4803 if installs && process {
4804 return Err(RuntimeManifestError::InvalidCapabilities {
4805 field: "capabilities.install".to_string(),
4806 message: "is not valid with transport: process; the harness owns its tool list, so a \
4807 pulled artifact could never reach it"
4808 .to_string(),
4809 });
4810 }
4811 Ok(())
4812}
4813
4814const INSTALL_FROM_ACCEPTED: &str = "[stores] or [stores, sources]";
4816
4817fn parse_install_from(list: &[String]) -> Result<InstallFrom, RuntimeManifestError> {
4820 let refuse = |message: String| RuntimeManifestError::InvalidCapabilities {
4821 field: "capabilities.install.from".to_string(),
4822 message: format!("{message}; expected {INSTALL_FROM_ACCEPTED}"),
4823 };
4824 if list.is_empty() {
4825 return Err(refuse("is empty".to_string()));
4826 }
4827 let (mut stores, mut sources) = (false, false);
4828 for entry in list {
4829 let seen = match entry.as_str() {
4830 "stores" => &mut stores,
4831 "sources" => &mut sources,
4832 other => return Err(refuse(format!("names {other:?}, which is not a place"))),
4833 };
4834 if *seen {
4835 return Err(refuse(format!("names {entry:?} twice")));
4836 }
4837 *seen = true;
4838 }
4839 if !stores {
4840 return Err(refuse(
4841 "does not name stores, which every pull is answered from first".to_string(),
4842 ));
4843 }
4844 Ok(if sources {
4845 InstallFrom::StoresAndSources
4846 } else {
4847 InstallFrom::Stores
4848 })
4849}
4850
4851fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
4861 StateCapabilities {
4862 store: raw.store.map(|store| store.trim().to_string()),
4863 }
4864}
4865
4866fn shell_allow_entry_error(entry: &str) -> Option<&'static str> {
4870 if entry.is_empty() {
4871 Some("is empty")
4872 } else if entry.contains('\0') {
4873 Some("contains a NUL byte")
4874 } else if entry.contains('/') {
4875 Some("is a path")
4876 } else if entry == "." || entry == ".." {
4877 Some("names a directory")
4878 } else if entry.chars().any(char::is_whitespace) {
4879 Some("contains whitespace")
4880 } else {
4881 None
4882 }
4883}
4884
4885fn parse_interpreter_runtime(
4891 allow: &[String],
4892 raw_grants: Vec<RawInterpreterRuntimeGrant>,
4893) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
4894 let mut grants = Vec::with_capacity(raw_grants.len());
4895 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
4896 let base = format!("capabilities.shell.interpreter_runtime[{index}]");
4897
4898 let binary = raw_grant
4899 .binary
4900 .filter(|b| !b.trim().is_empty())
4901 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4902 field: format!("{base}.binary"),
4903 message: "must name a binary".to_string(),
4904 })?;
4905
4906 if !allow.contains(&binary) {
4910 return Err(RuntimeManifestError::InvalidCapabilities {
4911 field: format!("{base}.binary"),
4912 message: format!(
4913 "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
4914 only narrow filesystem access for an already-allowlisted binary, never \
4915 grant exec"
4916 ),
4917 });
4918 }
4919
4920 if raw_grant.dirs.is_empty() {
4921 return Err(RuntimeManifestError::InvalidCapabilities {
4922 field: format!("{base}.dirs"),
4923 message: "must name at least one directory".to_string(),
4924 });
4925 }
4926
4927 let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
4928 for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
4929 let dir_base = format!("{base}.dirs[{dir_index}]");
4930
4931 let path = raw_dir
4932 .path
4933 .filter(|p| !p.trim().is_empty())
4934 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4935 field: format!("{dir_base}.path"),
4936 message: "must name a host directory".to_string(),
4937 })?;
4938 if !path.starts_with('/') {
4939 return Err(RuntimeManifestError::InvalidCapabilities {
4940 field: format!("{dir_base}.path"),
4941 message: format!(
4942 "'{path}' must be an absolute host path (start with '/') — these are \
4943 filesystem paths outside the workdir"
4944 ),
4945 });
4946 }
4947
4948 let list_dir =
4949 raw_dir
4950 .list_dir
4951 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4952 field: format!("{dir_base}.list_dir"),
4953 message: format!(
4954 "'{path}' must set list_dir explicitly to true or false — \
4955 enumerability is never inferred"
4956 ),
4957 })?;
4958
4959 dirs.push(InterpreterRuntimeDir { path, list_dir });
4960 }
4961
4962 grants.push(InterpreterRuntimeGrant { binary, dirs });
4963 }
4964
4965 Ok(grants)
4966}
4967
4968fn parse_staged_runtime(
4989 allow: &[String],
4990 interpreter_runtime: &[InterpreterRuntimeGrant],
4991 raw_grants: Vec<RawStagedRuntimeGrant>,
4992) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
4993 let mut grants = Vec::with_capacity(raw_grants.len());
4994 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
4995 let base = format!("capabilities.shell.staged_runtime[{index}]");
4996
4997 let binary = raw_grant
4998 .binary
4999 .filter(|b| !b.trim().is_empty())
5000 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5001 field: format!("{base}.binary"),
5002 message: "must name a binary".to_string(),
5003 })?;
5004
5005 if !allow.contains(&binary) {
5006 return Err(RuntimeManifestError::InvalidCapabilities {
5007 field: format!("{base}.binary"),
5008 message: format!(
5009 "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
5010 stage the runtime tree behind an already-allowlisted binary, never grant exec"
5011 ),
5012 });
5013 }
5014
5015 if interpreter_runtime
5016 .iter()
5017 .any(|grant| grant.binary == binary)
5018 {
5019 return Err(RuntimeManifestError::InvalidCapabilities {
5020 field: format!("{base}.binary"),
5021 message: format!(
5022 "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
5023 are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
5024 tree into the capsule's own root, which is what makes widening the capsule's \
5025 host filesystem scope unnecessary rather than something to add to it. Remove \
5026 the interpreter_runtime grant for '{binary}'"
5027 ),
5028 });
5029 }
5030
5031 let source_path = raw_grant
5032 .source_path
5033 .filter(|p| !p.trim().is_empty())
5034 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5035 field: format!("{base}.source_path"),
5036 message: "must name the host directory holding the pinned runtime tree".to_string(),
5037 })?;
5038 if !source_path.starts_with('/') {
5039 return Err(RuntimeManifestError::InvalidCapabilities {
5040 field: format!("{base}.source_path"),
5041 message: format!(
5042 "'{source_path}' must be an absolute host path (start with '/') — \
5043 staged-runtime sources are host filesystem paths outside the workdir"
5044 ),
5045 });
5046 }
5047
5048 let pin = raw_grant
5049 .pin
5050 .map(|p| p.trim().to_string())
5051 .filter(|p| !p.is_empty())
5052 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5053 field: format!("{base}.pin"),
5054 message: format!(
5055 "'{source_path}' must declare a non-empty pin identifying which build this \
5056 tree is — it is never inferred, because it is the value a human compares \
5057 across two hosts to confirm the same runtime shipped to both"
5058 ),
5059 })?;
5060
5061 grants.push(StagedRuntimeGrant {
5062 binary,
5063 source_path,
5064 pin,
5065 });
5066 }
5067
5068 Ok(grants)
5069}
5070
5071fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
5076 let reject_zero_usize =
5077 |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
5078 match value {
5079 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5080 field: format!("capabilities.limits.{field}"),
5081 message: "must be greater than zero".to_string(),
5082 }),
5083 _ => Ok(()),
5084 }
5085 };
5086
5087 reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
5088 reject_zero_usize(raw.table_elements, "table_elements")?;
5089 reject_zero_usize(raw.instances, "instances")?;
5090 if raw.deadline_seconds == Some(0) {
5091 return Err(RuntimeManifestError::InvalidCapabilities {
5092 field: "capabilities.limits.deadline_seconds".to_string(),
5093 message: "must be greater than zero".to_string(),
5094 });
5095 }
5096
5097 Ok(ResourceLimits {
5098 memory_bytes: raw.memory_bytes,
5099 table_elements: raw.table_elements,
5100 instances: raw.instances,
5101 deadline_seconds: raw.deadline_seconds,
5102 })
5103}
5104
5105fn parse_resource_capabilities(
5111 raw: RawResourceCapabilities,
5112) -> Result<ResourceCapabilities, RuntimeManifestError> {
5113 let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
5114 match value {
5115 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5116 field: format!("capabilities.resources.{field}"),
5117 message: "must be greater than zero".to_string(),
5118 }),
5119 _ => Ok(()),
5120 }
5121 };
5122
5123 reject_zero(raw.max_processes, "max_processes")?;
5124 reject_zero(raw.max_open_files, "max_open_files")?;
5125 reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
5126 reject_zero(raw.cpu_seconds, "cpu_seconds")?;
5127 reject_zero(raw.memory_bytes, "memory_bytes")?;
5128 reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
5129 reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
5130 reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
5131 reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
5132 reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
5133
5134 Ok(ResourceCapabilities {
5135 max_processes: raw.max_processes,
5136 max_open_files: raw.max_open_files,
5137 max_file_size_bytes: raw.max_file_size_bytes,
5138 cpu_seconds: raw.cpu_seconds,
5139 memory_bytes: raw.memory_bytes,
5140 cgroup_memory_bytes: raw.cgroup_memory_bytes,
5141 cgroup_pids_max: raw.cgroup_pids_max,
5142 cgroup_cpu_percent: raw.cgroup_cpu_percent,
5143 cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
5144 workdir_max_bytes: raw.workdir_max_bytes,
5145 })
5146}
5147
5148const PROCESS_DRIVER_REQUIRED: &str =
5152 "missing required field; a transport: process capsule must name the process driver that \
5153 knows how to drive its harness. inference.command no longer selects a harness — it only \
5154 overrides the binary the driver names. Add inference.driver.artifact naming a process \
5155 driver, for example murmur/driver-claude-code";
5156
5157fn parse_inference(
5158 raw: Option<RawInferenceConfig>,
5159) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
5160 let Some(raw) = raw else {
5161 return Ok(None);
5162 };
5163
5164 let transport = raw
5165 .transport
5166 .map(|s| s.trim().to_string())
5167 .filter(|s| !s.is_empty())
5168 .unwrap_or_else(|| "http".to_string());
5169
5170 let compaction = parse_compaction(raw.compaction)?;
5171 let system_prompt = optional_trimmed_string(raw.system_prompt);
5172 let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
5173 let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
5174
5175 let prompt_sources_set = [
5176 system_prompt.is_some(),
5177 system_prompt_file.is_some(),
5178 system_prompt_artifact.is_some(),
5179 ]
5180 .iter()
5181 .filter(|&&b| b)
5182 .count();
5183 if prompt_sources_set > 1 {
5184 return Err(RuntimeManifestError::InvalidInferenceConfig {
5185 field: "inference.system_prompt".to_string(),
5186 message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
5187 may be set"
5188 .to_string(),
5189 });
5190 }
5191
5192 let max_turns = match raw.max_turns {
5193 None => 10,
5194 Some(0) => {
5195 return Err(RuntimeManifestError::InvalidInferenceConfig {
5196 field: "inference.max_turns".to_string(),
5197 message: "must be greater than 0".to_string(),
5198 });
5199 }
5200 Some(n) => n,
5201 };
5202
5203 if raw.max_task_reopens.is_some() {
5207 return Err(RuntimeManifestError::InvalidInferenceConfig {
5208 field: "inference.max_task_reopens".to_string(),
5209 message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
5210 .to_string(),
5211 });
5212 }
5213
5214 for (present, removed, moved_to) in [
5218 (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
5219 (raw.api_key.is_some(), "api_key", "gateway.api_key"),
5220 ] {
5221 if !present {
5222 continue;
5223 }
5224 let entry = raw
5225 .driver
5226 .as_ref()
5227 .and_then(|driver| driver.artifact.as_deref())
5228 .map(str::trim)
5229 .filter(|name| !name.is_empty())
5230 .map(|name| format!("on the artifacts: entry '{name}'"))
5231 .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
5232 return Err(RuntimeManifestError::InvalidInferenceConfig {
5233 field: format!("inference.{removed}"),
5234 message: format!("was removed; set {moved_to} {entry} instead"),
5235 });
5236 }
5237
5238 match transport.as_str() {
5239 "http" => {
5240 let model = required_inference_field(raw.model, "model")?;
5241
5242 let driver_raw =
5243 raw.driver
5244 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5245 field: "inference.driver".to_string(),
5246 message: "missing required field".to_string(),
5247 })?;
5248 let driver = parse_inference_driver(driver_raw)?;
5249
5250 if raw.max_tokens == Some(0) {
5254 return Err(RuntimeManifestError::InvalidInferenceConfig {
5255 field: "inference.max_tokens".to_string(),
5256 message: "must be greater than 0".to_string(),
5257 });
5258 }
5259 if raw.max_session_tokens == Some(0) {
5260 return Err(RuntimeManifestError::InvalidInferenceConfig {
5261 field: "inference.max_session_tokens".to_string(),
5262 message: "must be greater than 0".to_string(),
5263 });
5264 }
5265 let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
5266 let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
5267
5268 Ok(Some(InferenceConfig {
5269 transport,
5270 model,
5271 driver: Some(driver),
5272 command: None,
5273 compaction,
5274 system_prompt,
5275 system_prompt_file,
5276 system_prompt_artifact,
5277 max_turns,
5278 max_tokens: raw.max_tokens,
5279 max_session_tokens: raw.max_session_tokens,
5280 tool_refresh,
5281 alternates,
5282 }))
5283 }
5284 "process" => {
5285 if raw.max_tokens.is_some() {
5288 return Err(RuntimeManifestError::InvalidInferenceConfig {
5289 field: "inference.max_tokens".to_string(),
5290 message: "is not valid with transport: process".to_string(),
5291 });
5292 }
5293 if raw.tool_refresh.is_some() {
5296 return Err(RuntimeManifestError::InvalidInferenceConfig {
5297 field: "inference.tool_refresh".to_string(),
5298 message: "is not valid with transport: process".to_string(),
5299 });
5300 }
5301 if raw.alternates.is_some() {
5304 return Err(RuntimeManifestError::InvalidInferenceConfig {
5305 field: "inference.alternates".to_string(),
5306 message: "is not valid with transport: process; the harness owns the \
5307 conversation and the model it runs"
5308 .to_string(),
5309 });
5310 }
5311
5312 if raw.max_session_tokens == Some(0) {
5316 return Err(RuntimeManifestError::InvalidInferenceConfig {
5317 field: "inference.max_session_tokens".to_string(),
5318 message: "must be greater than 0".to_string(),
5319 });
5320 }
5321
5322 let driver = raw.driver.map(parse_inference_driver).transpose()?;
5323 let command = optional_trimmed_string(raw.command);
5324 if driver.is_none() {
5328 return Err(RuntimeManifestError::InvalidInferenceConfig {
5329 field: "inference.driver.artifact".to_string(),
5330 message: PROCESS_DRIVER_REQUIRED.to_string(),
5331 });
5332 }
5333 let model = optional_trimmed_string(raw.model).unwrap_or_default();
5337
5338 Ok(Some(InferenceConfig {
5339 transport,
5340 model,
5341 driver,
5342 command,
5343 compaction,
5344 system_prompt,
5345 system_prompt_file,
5346 system_prompt_artifact,
5347 max_turns,
5348 max_tokens: None,
5349 max_session_tokens: raw.max_session_tokens,
5350 tool_refresh: ToolRefresh::default(),
5351 alternates: Vec::new(),
5352 }))
5353 }
5354 other => Err(RuntimeManifestError::InvalidInferenceConfig {
5355 field: "inference.transport".to_string(),
5356 message: format!("unknown value '{other}'"),
5357 }),
5358 }
5359}
5360
5361fn is_valid_driver_choice_name(name: &str) -> bool {
5364 let mut chars = name.chars();
5365 chars.next().is_some_and(|first| first.is_ascii_lowercase())
5366 && name.len() <= 63
5367 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
5368}
5369
5370fn parse_alternates(
5376 raw: Option<Vec<RawInferenceAlternate>>,
5377 primary_driver: &str,
5378 primary_model: &str,
5379) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
5380 let mut alternates: Vec<InferenceAlternate> = Vec::new();
5381 for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
5382 let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5383 field: if key.is_empty() {
5384 format!("inference.alternates[{index}]")
5385 } else {
5386 format!("inference.alternates[{index}].{key}")
5387 },
5388 message,
5389 };
5390 let name = optional_trimmed_string(entry.name)
5391 .ok_or_else(|| refuse("name", "missing required field".to_string()))?;
5392 if name == PRIMARY_DRIVER_CHOICE {
5393 return Err(refuse(
5394 "name",
5395 format!(
5396 "'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
5397 inference.model; name the alternate something else"
5398 ),
5399 ));
5400 }
5401 if !is_valid_driver_choice_name(&name) {
5402 return Err(refuse(
5403 "name",
5404 format!(
5405 "'{name}' is not a driver choice name; a name is a lowercase letter followed \
5406 by up to 62 lowercase letters, digits and hyphens"
5407 ),
5408 ));
5409 }
5410 if alternates.iter().any(|alternate| alternate.name == name) {
5411 return Err(refuse(
5412 "name",
5413 format!("'{name}' names more than one alternate"),
5414 ));
5415 }
5416 let model = optional_trimmed_string(entry.model)
5417 .ok_or_else(|| refuse("model", "missing required field".to_string()))?;
5418 let Some(driver) = entry.driver else {
5419 return Err(refuse(
5420 "driver.artifact",
5421 "missing required field".to_string(),
5422 ));
5423 };
5424 if driver
5425 .config
5426 .as_ref()
5427 .is_some_and(|config| !matches!(config, serde_yaml::Value::Null))
5428 {
5429 return Err(refuse(
5430 "driver.config",
5431 "is not valid on an alternate; inference.driver.config applies to every \
5432 declared driver, and per-driver settings belong in that driver's \
5433 artifacts[].config"
5434 .to_string(),
5435 ));
5436 }
5437 let driver = optional_trimmed_string(driver.artifact)
5438 .ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
5439 if driver == primary_driver && model == primary_model {
5440 return Err(refuse(
5441 "",
5442 format!(
5443 "'{name}' is driver '{driver}' with model '{model}', which is the primary \
5444 choice; an alternate names a different pair"
5445 ),
5446 ));
5447 }
5448 if let Some(twin) = alternates
5449 .iter()
5450 .find(|alternate| alternate.driver == driver && alternate.model == model)
5451 {
5452 return Err(refuse(
5453 "",
5454 format!(
5455 "'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
5456 already is; an alternate names a different pair",
5457 twin.name
5458 ),
5459 ));
5460 }
5461 alternates.push(InferenceAlternate {
5462 name,
5463 model,
5464 driver,
5465 });
5466 }
5467 Ok(alternates)
5468}
5469
5470fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
5472 let Some(value) = raw else {
5473 return Ok(ToolRefresh::default());
5474 };
5475 ToolRefresh::ALL
5476 .into_iter()
5477 .find(|refresh| refresh.wire_name() == value)
5478 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5479 field: "inference.tool_refresh".to_string(),
5480 message: format!(
5481 "must be one of: {}; got '{value}'",
5482 ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
5483 ),
5484 })
5485}
5486
5487fn parse_context(
5488 raw: Option<RawContextConfig>,
5489) -> Result<Option<ContextConfig>, RuntimeManifestError> {
5490 let Some(raw) = raw else {
5491 return Ok(None);
5492 };
5493
5494 if let Some(max_tokens) = raw.max_tokens {
5495 if max_tokens == 0 {
5496 return Err(RuntimeManifestError::InvalidInferenceConfig {
5497 field: "context.max_tokens".to_string(),
5498 message: "must be greater than 0".to_string(),
5499 });
5500 }
5501 }
5502
5503 let fraction = |field: &str, value: Option<f32>, default: f32| match value {
5504 None => Ok(default),
5505 Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
5506 Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
5507 field: field.to_string(),
5508 message: "must be between 0.0 and 1.0".to_string(),
5509 }),
5510 };
5511
5512 let record = match raw.record.as_deref().map(str::trim) {
5513 None => true,
5514 Some("on") => true,
5515 Some("off") => false,
5516 Some(other) => {
5517 return Err(RuntimeManifestError::InvalidInferenceConfig {
5518 field: "context.record".to_string(),
5519 message: format!("unknown value '{other}'; expected: on, off"),
5520 })
5521 }
5522 };
5523
5524 Ok(Some(ContextConfig {
5525 max_tokens: raw.max_tokens,
5526 record,
5527 record_store: raw.record_store.map(|store| store.trim().to_string()),
5528 seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
5529 seed_overflow_margin: fraction(
5530 "context.seed_overflow_margin",
5531 raw.seed_overflow_margin,
5532 DEFAULT_SEED_OVERFLOW_MARGIN,
5533 )?,
5534 retain: parse_context_retain(raw.retain)?,
5535 }))
5536}
5537
5538const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
5540
5541const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
5543
5544const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
5547 keep everything, which is what no policy means";
5548
5549fn parse_retain_block(
5555 block: &str,
5556 count_key: &str,
5557 accepted: &[&str],
5558 raw: serde_yaml::Value,
5559 invalid: &dyn Fn(String, String) -> RuntimeManifestError,
5560) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
5561 let mapping = match raw {
5562 serde_yaml::Value::Mapping(mapping) => mapping,
5563 serde_yaml::Value::Null => {
5565 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
5566 }
5567 other => {
5568 return Err(invalid(
5569 block.to_string(),
5570 format!(
5571 "must be a block of keys ({}), got '{other:?}'",
5572 accepted.join(", ")
5573 ),
5574 ))
5575 }
5576 };
5577 if mapping.is_empty() {
5578 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
5579 }
5580
5581 let mut count: Option<u32> = None;
5582 let mut max_age_secs: Option<u64> = None;
5583 for (key, value) in &mapping {
5584 let key = key.as_str().unwrap_or_default();
5585 let field = format!("{block}.{key}");
5586 if key == count_key {
5587 let parsed = value
5588 .as_u64()
5589 .and_then(|n| u32::try_from(n).ok())
5590 .ok_or_else(|| {
5591 invalid(
5592 field.clone(),
5593 "must be a whole number of at least 1".to_string(),
5594 )
5595 })?;
5596 if parsed == 0 {
5597 return Err(invalid(
5598 field,
5599 "must be at least 1 — a limit of zero would delete what retention exists \
5600 to keep; remove the key to leave it unbounded"
5601 .to_string(),
5602 ));
5603 }
5604 count = Some(parsed);
5605 } else if key == "max_age" {
5606 let text = scalar_text(value).ok_or_else(|| {
5607 invalid(
5608 field.clone(),
5609 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
5610 )
5611 })?;
5612 let parsed =
5613 parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
5614 if parsed == 0 {
5615 return Err(invalid(
5616 field,
5617 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
5618 ));
5619 }
5620 max_age_secs = Some(parsed);
5621 } else {
5622 return Err(invalid(
5623 block.to_string(),
5624 format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
5625 ));
5626 }
5627 }
5628 Ok((count, max_age_secs))
5629}
5630
5631fn parse_trace_retain(
5632 raw: Option<serde_yaml::Value>,
5633) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
5634 let Some(raw) = raw else {
5635 return Ok(None);
5636 };
5637 let (max_sessions, max_age_secs) = parse_retain_block(
5638 "trace.retain",
5639 "max_sessions",
5640 &TRACE_RETAIN_KEYS,
5641 raw,
5642 &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
5643 )?;
5644 Ok(Some(TraceRetainConfig {
5645 max_sessions,
5646 max_age_secs,
5647 }))
5648}
5649
5650fn parse_context_retain(
5651 raw: Option<serde_yaml::Value>,
5652) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
5653 let Some(raw) = raw else {
5654 return Ok(None);
5655 };
5656 let (max_messages, max_age_secs) = parse_retain_block(
5657 "context.retain",
5658 "max_messages",
5659 &CONTEXT_RETAIN_KEYS,
5660 raw,
5661 &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
5662 )?;
5663 Ok(Some(ContextRetainConfig {
5664 max_messages,
5665 max_age_secs,
5666 }))
5667}
5668
5669fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
5670 raw.map(|raw| ObservabilityConfig {
5671 otel_endpoint: raw
5672 .otel_endpoint
5673 .map(|value| value.trim().to_string())
5674 .filter(|value| !value.is_empty()),
5675 eval: raw.eval.map(parse_eval_config),
5676 })
5677}
5678
5679fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
5680 let scorers = raw
5681 .scorers
5682 .into_iter()
5683 .filter_map(|raw| {
5684 let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
5685 match raw.scorer_type.as_str() {
5686 "exit_ok" => Some(ScorerConfig::ExitOk { name }),
5687 "max_turns" => Some(ScorerConfig::MaxTurns {
5688 name,
5689 max: raw.max.unwrap_or(10) as u32,
5690 }),
5691 "max_tokens" => Some(ScorerConfig::MaxTokens {
5692 name,
5693 max: raw.max.unwrap_or(100_000),
5694 }),
5695 "tool_sequence" => Some(ScorerConfig::ToolSequence {
5696 name,
5697 expected: raw.expected.unwrap_or_default(),
5698 }),
5699 "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
5700 other => {
5701 crate::warn_line(format_args!(
5702 "[murmur-artifact] unknown scorer type '{other}' — skipping"
5703 ));
5704 None
5705 }
5706 }
5707 })
5708 .collect();
5709
5710 EvalConfig {
5711 dataset_id: raw.dataset_id,
5712 scorers,
5713 }
5714}
5715
5716fn parse_compaction(
5717 raw: Option<RawCompactionConfig>,
5718) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
5719 let Some(raw) = raw else {
5720 return Ok(None);
5721 };
5722
5723 if let Some(threshold) = raw.threshold {
5724 if threshold <= 0.0 || threshold > 1.0 {
5725 return Err(RuntimeManifestError::InvalidInferenceConfig {
5726 field: "inference.compaction.threshold".to_string(),
5727 message: format!("must be in (0.0, 1.0] but got {threshold}"),
5728 });
5729 }
5730 }
5731
5732 if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
5733 return Err(RuntimeManifestError::InvalidInferenceConfig {
5734 field: "inference.compaction.system_prompt".to_string(),
5735 message: "at most one of inference.compaction.system_prompt, \
5736 inference.compaction.system_prompt_file may be set"
5737 .to_string(),
5738 });
5739 }
5740
5741 Ok(Some(CompactionConfig {
5742 threshold: raw.threshold,
5743 model: raw.model,
5744 system_prompt: raw.system_prompt,
5745 system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
5746 dump_summaries: raw.dump_summaries,
5749 }))
5750}
5751
5752fn optional_trimmed_string(value: Option<String>) -> Option<String> {
5753 value
5754 .map(|s| s.trim().to_string())
5755 .filter(|s| !s.is_empty())
5756}
5757
5758fn required_inference_field(
5759 value: Option<String>,
5760 key: &str,
5761) -> Result<String, RuntimeManifestError> {
5762 value
5763 .map(|value| value.trim().to_string())
5764 .filter(|value| !value.is_empty())
5765 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5766 field: format!("inference.{key}"),
5767 message: "missing required field".to_string(),
5768 })
5769}
5770
5771fn parse_artifact_gateway(
5779 index: usize,
5780 name: &str,
5781 raw: Option<RawArtifactGateway>,
5782 secrets: SecretResolution,
5783) -> Result<ArtifactGateway, RuntimeManifestError> {
5784 let (endpoint, api_key, keyless) = match raw {
5785 Some(raw) => (
5786 optional_trimmed_string(raw.endpoint),
5787 raw.api_key,
5788 raw.keyless,
5789 ),
5790 None => (None, None, None),
5791 };
5792 let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
5793 index,
5794 message: format!(
5795 "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
5796 the upstream URL the runtime sends this artifact's keyed requests to"
5797 ),
5798 })?;
5799 validate_gateway_endpoint(name, &endpoint)
5800 .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
5801
5802 let keyless = match keyless {
5803 None | Some(serde_yaml::Value::Null) | Some(serde_yaml::Value::Bool(false)) => false,
5804 Some(serde_yaml::Value::Bool(true)) => true,
5805 Some(_) => {
5806 return Err(RuntimeManifestError::InvalidArtifact {
5807 index,
5808 message: format!(
5809 "artifact '{name}' declares gateway.keyless with a value that is not a \
5810 boolean; write keyless: true for an upstream that takes no key"
5811 ),
5812 })
5813 }
5814 };
5815 if keyless && api_key.is_some() {
5816 return Err(RuntimeManifestError::InvalidArtifact {
5817 index,
5818 message: format!(
5819 "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
5820 gateway either presents a key or is keyless"
5821 ),
5822 });
5823 }
5824 if keyless {
5825 return Ok(ArtifactGateway {
5826 endpoint,
5827 api_key: None,
5828 keyless: true,
5829 });
5830 }
5831
5832 let written = api_key
5833 .flatten()
5834 .map(|value| value.trim().to_string())
5835 .filter(|value| !value.is_empty());
5836 let Some(written) = written else {
5837 return Err(RuntimeManifestError::GatewayWithoutCredential {
5838 index,
5839 name: name.to_string(),
5840 endpoint,
5841 });
5842 };
5843 let api_key = match secrets {
5844 SecretResolution::Skip => None,
5845 SecretResolution::Resolve => Some(match parse_env_reference(&written) {
5846 Some(variable) => ApiKeyReference::Environment(variable.to_string()),
5847 None => ApiKeyReference::Literal(written),
5848 }),
5849 };
5850
5851 Ok(ArtifactGateway {
5852 endpoint,
5853 api_key,
5854 keyless: false,
5855 })
5856}
5857
5858fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
5885 let shown = mask_endpoint_userinfo(endpoint);
5886 let refuse =
5887 |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
5888 if endpoint.contains("${") {
5889 return Err(refuse(
5890 "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
5891 literally"
5892 .to_string(),
5893 ));
5894 }
5895 let url = Url::parse(endpoint)
5896 .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
5897 let host = match url.scheme() {
5898 "http" | "https" => url
5899 .host_str()
5900 .filter(|host| !host.is_empty())
5901 .ok_or_else(|| refuse("which has no host".to_string()))?,
5902 other => {
5903 return Err(refuse(format!(
5904 "whose scheme '{other}' is unsupported — expected http or https"
5905 )))
5906 }
5907 };
5908 if !url.username().is_empty() || url.password().is_some() {
5909 return Err(refuse(format!(
5910 "which carries userinfo before '@' — the gateway never sends it, and this URL \
5911 addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
5912 the key through gateway.api_key"
5913 )));
5914 }
5915 if url.query().is_some() {
5916 return Err(refuse(
5917 "which carries a query — the gateway keeps each request's own query, so write the \
5918 upstream without one"
5919 .to_string(),
5920 ));
5921 }
5922 if url.fragment().is_some() {
5923 return Err(refuse(
5924 "which carries a fragment — write the upstream without one".to_string(),
5925 ));
5926 }
5927 if url.scheme() == "http" {
5928 let bare = host.trim_start_matches('[').trim_end_matches(']');
5929 let is_loopback = bare == "localhost"
5930 || bare
5931 .parse::<IpAddr>()
5932 .map(|ip| ip.is_loopback())
5933 .unwrap_or(false);
5934 if !is_loopback {
5935 return Err(refuse(format!(
5936 "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
5937 remote upstream, or http://localhost or a loopback IP literal for a local one"
5938 )));
5939 }
5940 }
5941 Ok(())
5942}
5943
5944fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
5948 let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
5949 let rest = &endpoint[authority_start..];
5950 let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
5951 match rest[..authority_len].rfind('@') {
5952 Some(at) => format!(
5953 "{}***{}",
5954 &endpoint[..authority_start],
5955 &endpoint[authority_start + at..]
5956 )
5957 .into(),
5958 None => endpoint.into(),
5959 }
5960}
5961
5962fn validate_driver_gateways(
5969 artifacts: &[RuntimeArtifact],
5970 inference: Option<&InferenceConfig>,
5971) -> Result<(), RuntimeManifestError> {
5972 let configured_driver = inference
5973 .filter(|inference| inference.transport == "http")
5974 .and_then(|inference| inference.driver.as_ref())
5975 .map(|driver| driver.artifact.as_str());
5976 let alternate_drivers: Vec<&str> = inference
5977 .map(|inference| {
5978 inference
5979 .alternates
5980 .iter()
5981 .map(|alternate| alternate.driver.as_str())
5982 .collect()
5983 })
5984 .unwrap_or_default();
5985 let under_process = inference.is_some_and(|inference| inference.transport == "process");
5986
5987 if let Some(driver) = configured_driver {
5990 if !artifacts.iter().any(|artifact| artifact.name == driver) {
5991 return Err(RuntimeManifestError::InvalidInferenceConfig {
5992 field: "inference.driver.artifact".to_string(),
5993 message: not_declared_message(artifacts, driver),
5994 });
5995 }
5996 }
5997
5998 for (index, artifact) in artifacts.iter().enumerate() {
5999 if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
6000 continue;
6001 }
6002 if under_process {
6003 return Err(RuntimeManifestError::InvalidArtifact {
6004 index,
6005 message: format!(
6006 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
6007 valid with transport: process — the CLI subprocess holds its own \
6008 credentials and a process driver is granted nothing",
6009 artifact.name
6010 ),
6011 });
6012 }
6013 if configured_driver != Some(artifact.name.as_str())
6014 && !alternate_drivers.contains(&artifact.name.as_str())
6015 {
6016 return Err(RuntimeManifestError::InvalidArtifact {
6017 index,
6018 message: format!(
6019 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
6020 the configured inference driver; a driver's gateway is accepted only on the \
6021 entry inference.driver.artifact names under transport: http, or on one an \
6022 inference.alternates entry names",
6023 artifact.name
6024 ),
6025 });
6026 }
6027 }
6028
6029 if let Some(driver) = configured_driver {
6030 let Some((index, entry)) = artifacts
6031 .iter()
6032 .enumerate()
6033 .find(|(_, artifact)| artifact.name == driver)
6034 else {
6035 return Err(RuntimeManifestError::InvalidInferenceConfig {
6036 field: "inference.driver.artifact".to_string(),
6037 message: not_declared_message(artifacts, driver),
6038 });
6039 };
6040 if entry.gateway.is_none() {
6041 return Err(RuntimeManifestError::InvalidArtifact {
6042 index,
6043 message: format!(
6044 "artifact '{driver}' is the transport: http inference driver but declares no \
6045 gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
6046 on its entry"
6047 ),
6048 });
6049 }
6050 }
6051 Ok(())
6052}
6053
6054fn validate_driver_choices(
6060 artifacts: &[RuntimeArtifact],
6061 inference: Option<&InferenceConfig>,
6062) -> Result<(), RuntimeManifestError> {
6063 let Some(inference) = inference else {
6064 return Ok(());
6065 };
6066 let primary_driver = inference
6067 .driver
6068 .as_ref()
6069 .map(|driver| driver.artifact.as_str());
6070 for (index, alternate) in inference.alternates.iter().enumerate() {
6071 let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
6072 field: format!("inference.alternates[{index}].driver.artifact"),
6073 message,
6074 };
6075 let driver = alternate.driver.as_str();
6076 let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
6077 return Err(refuse(not_declared_message(artifacts, driver)));
6078 };
6079 if entry.runtime != ArtifactRuntime::Driver {
6080 return Err(refuse(format!(
6081 "artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
6082 runtime: driver entry",
6083 entry.runtime.as_str()
6084 )));
6085 }
6086 if entry.gateway.is_none() && primary_driver != Some(driver) {
6087 return Err(refuse(format!(
6088 "artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
6089 its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
6090 URL>, api_key: ... }}' on its entry"
6091 )));
6092 }
6093 }
6094 Ok(())
6095}
6096
6097fn validate_alternates_selectable(
6100 inference: Option<&InferenceConfig>,
6101 control: Option<&ControlConfig>,
6102) -> Result<(), RuntimeManifestError> {
6103 if inference.is_none_or(|inference| inference.alternates.is_empty()) {
6104 return Ok(());
6105 }
6106 let selectable = control.is_some_and(|control| {
6107 control.controller_may_set(ControllableSetting::InferenceDriver)
6108 || control.agent_may_set(ControllableSetting::InferenceDriver)
6109 });
6110 if selectable {
6111 return Ok(());
6112 }
6113 Err(RuntimeManifestError::InvalidInferenceConfig {
6114 field: "inference.alternates".to_string(),
6115 message: "declares alternates that nothing could ever select; list inference.driver in \
6116 control.settings for a controller to switch, or in control.agent_settings for \
6117 the agent to"
6118 .to_string(),
6119 })
6120}
6121
6122fn validate_process_driver_entry(
6130 artifacts: &[RuntimeArtifact],
6131 inference: Option<&InferenceConfig>,
6132) -> Result<(), RuntimeManifestError> {
6133 let Some(driver) = inference
6134 .filter(|inference| inference.transport == "process")
6135 .and_then(|inference| inference.driver.as_ref())
6136 .map(|driver| driver.artifact.as_str())
6137 else {
6138 return Ok(());
6139 };
6140 let Some((index, entry)) = artifacts
6141 .iter()
6142 .enumerate()
6143 .find(|(_, artifact)| artifact.name == driver)
6144 else {
6145 return Err(RuntimeManifestError::InvalidInferenceConfig {
6146 field: "inference.driver.artifact".to_string(),
6147 message: not_declared_message(artifacts, driver),
6148 });
6149 };
6150 if entry.runtime != ArtifactRuntime::Driver {
6151 return Err(RuntimeManifestError::InvalidArtifact {
6152 index,
6153 message: format!(
6154 "artifact '{driver}' is the transport: process driver and must be declared with \
6155 runtime: driver"
6156 ),
6157 });
6158 }
6159 if entry.capabilities.is_some() || entry.config.is_some() {
6160 return Err(RuntimeManifestError::InvalidArtifact {
6161 index,
6162 message: format!(
6163 "artifact '{driver}' is the transport: process driver, which is granted nothing; \
6164 remove capabilities:/config: from its entry — driver settings go in \
6165 inference.driver.config"
6166 ),
6167 });
6168 }
6169 Ok(())
6170}
6171
6172fn parse_inference_driver(
6173 raw: RawInferenceDriver,
6174) -> Result<InferenceDriver, RuntimeManifestError> {
6175 let artifact = raw
6176 .artifact
6177 .map(|value| value.trim().to_string())
6178 .filter(|value| !value.is_empty())
6179 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6180 field: "inference.driver.artifact".to_string(),
6181 message: "missing required field".to_string(),
6182 })?;
6183
6184 let config = parse_inference_driver_config(raw.config)?;
6185
6186 Ok(InferenceDriver { artifact, config })
6187}
6188
6189fn parse_inference_driver_config(
6190 raw: Option<serde_yaml::Value>,
6191) -> Result<Option<String>, RuntimeManifestError> {
6192 let Some(raw) = raw else {
6193 return Ok(None);
6194 };
6195
6196 if matches!(raw, serde_yaml::Value::Null) {
6197 return Ok(None);
6198 }
6199
6200 if !matches!(raw, serde_yaml::Value::Mapping(_)) {
6201 return Err(RuntimeManifestError::InvalidInferenceConfig {
6202 field: "inference.driver.config".to_string(),
6203 message: "expected mapping/object".to_string(),
6204 });
6205 }
6206
6207 let value =
6208 serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
6209 field: "inference.driver.config".to_string(),
6210 message: format!("failed to convert to JSON: {err}"),
6211 })?;
6212
6213 serde_json::to_string(&value).map(Some).map_err(|err| {
6214 RuntimeManifestError::InvalidInferenceConfig {
6215 field: "inference.driver.config".to_string(),
6216 message: format!("failed to encode JSON: {err}"),
6217 }
6218 })
6219}
6220
6221fn parse_env_reference(value: &str) -> Option<&str> {
6222 if !value.starts_with("${") || !value.ends_with('}') {
6223 return None;
6224 }
6225
6226 let variable = &value[2..value.len() - 1];
6227 is_valid_env_variable(variable).then_some(variable)
6228}
6229
6230fn is_valid_env_variable(value: &str) -> bool {
6231 let mut chars = value.chars();
6232 let Some(first) = chars.next() else {
6233 return false;
6234 };
6235
6236 if !(first == '_' || first.is_ascii_uppercase()) {
6237 return false;
6238 }
6239
6240 chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
6241}
6242
6243#[cfg(test)]
6244mod tests {
6245 use super::*;
6246 use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
6247
6248 fn manifest_with_trace(block: &str) -> String {
6249 format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
6250 }
6251
6252 fn lifecycle(task_acceptance: TaskAcceptance, queue_depth: usize) -> LifecycleConfig {
6253 LifecycleConfig {
6254 task_acceptance,
6255 queue_depth,
6256 ..Default::default()
6257 }
6258 }
6259
6260 #[test]
6261 fn tasks_held_at_once_under_queue_is_one_running_plus_queue_depth_waiting() {
6262 assert_eq!(lifecycle(TaskAcceptance::Queue, 1).tasks_held_at_once(), 2);
6263 assert_eq!(lifecycle(TaskAcceptance::Queue, 9).tasks_held_at_once(), 10);
6264 }
6265
6266 #[test]
6267 fn queue_has_room_is_false_only_for_a_queue_at_depth_zero() {
6268 assert!(!lifecycle(TaskAcceptance::Queue, 0).queue_has_room());
6269 assert!(lifecycle(TaskAcceptance::Queue, 1).queue_has_room());
6270 assert!(lifecycle(TaskAcceptance::Single, 0).queue_has_room());
6271 assert!(lifecycle(TaskAcceptance::None, 0).queue_has_room());
6272 assert!(lifecycle(TaskAcceptance::Single, 3).queue_has_room());
6273 }
6274
6275 fn parse_lifecycle(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
6276 RuntimeManifest::from_yaml_str(&format!(
6277 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n{block}"
6278 ))
6279 }
6280
6281 #[test]
6282 fn queue_depth_zero_under_queue_is_refused_at_load() {
6283 let error = parse_lifecycle(" task_acceptance: queue\n queue_depth: 0\n").unwrap_err();
6284 assert!(
6285 matches!(error, RuntimeManifestError::QueueWithoutRoom),
6286 "{error:?}"
6287 );
6288 assert_eq!(
6289 error.to_string(),
6290 "murmur.yaml: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves \
6291 task_acceptance: queue no room for a waiting task"
6292 );
6293 }
6294
6295 #[test]
6297 fn queue_depth_zero_outside_queue_still_parses() {
6298 for block in [
6299 " task_acceptance: single\n queue_depth: 0\n",
6300 " task_acceptance: none\n queue_depth: 0\n",
6301 " queue_depth: 0\n",
6302 ] {
6303 let lifecycle = parse_lifecycle(block)
6304 .unwrap_or_else(|error| panic!("{block:?}: {error}"))
6305 .effective_lifecycle();
6306 assert_eq!(lifecycle.queue_depth, 0, "{block:?}");
6307 }
6308 let queue = parse_lifecycle(" task_acceptance: queue\n queue_depth: 1\n")
6309 .unwrap()
6310 .effective_lifecycle();
6311 assert_eq!(queue.task_acceptance, TaskAcceptance::Queue);
6312 assert_eq!(queue.queue_depth, 1);
6313 }
6314
6315 #[test]
6316 fn tasks_held_at_once_under_single_is_one_whatever_the_depth() {
6317 assert_eq!(lifecycle(TaskAcceptance::Single, 1).tasks_held_at_once(), 1);
6318 assert_eq!(lifecycle(TaskAcceptance::Single, 5).tasks_held_at_once(), 1);
6319 }
6320
6321 #[test]
6322 fn tasks_held_at_once_under_none_is_zero_whatever_the_depth() {
6323 assert_eq!(lifecycle(TaskAcceptance::None, 1).tasks_held_at_once(), 0);
6324 assert_eq!(lifecycle(TaskAcceptance::None, 5).tasks_held_at_once(), 0);
6325 }
6326
6327 #[test]
6330 fn tasks_held_at_once_of_the_default_lifecycle_and_default_depth() {
6331 assert_eq!(LifecycleConfig::default().tasks_held_at_once(), 1);
6332 let queue = RuntimeManifest::from_yaml_str(
6333 "name: cap\nversion: 0.0.1\nlifecycle:\n task_acceptance: queue\n",
6334 )
6335 .unwrap()
6336 .effective_lifecycle();
6337 assert_eq!(queue.queue_depth, 1);
6338 assert_eq!(queue.tasks_held_at_once(), 2);
6339 }
6340
6341 #[test]
6344 fn absent_trace_block_stays_absent_and_defaults_to_meta() {
6345 let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
6346 assert_eq!(manifest.trace, None);
6347 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
6348 }
6349
6350 #[test]
6351 fn each_capture_mode_parses_from_the_trace_block() {
6352 for (yaml, expected) in [
6353 ("none", TraceCapture::None),
6354 ("meta", TraceCapture::Meta),
6355 ("content", TraceCapture::Content),
6356 ] {
6357 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
6358 " capture: {yaml}\n"
6359 )))
6360 .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
6361 assert_eq!(
6362 manifest.trace,
6363 Some(TraceConfig {
6364 capture: expected,
6365 retain: None
6366 })
6367 );
6368 }
6369 }
6370
6371 #[test]
6377 fn no_retain_block_anywhere_parses_as_no_policy() {
6378 let manifest = RuntimeManifest::from_yaml_str(
6379 "name: cap\nversion: 0.0.1\ntrace:\n capture: content\ncontext:\n record: on\n",
6380 )
6381 .unwrap();
6382 assert_eq!(manifest.trace.unwrap().retain, None);
6383 assert_eq!(manifest.context.unwrap().retain, None);
6384 }
6385
6386 #[test]
6387 fn trace_retain_parses_both_keys_and_the_day_suffix() {
6388 let manifest = RuntimeManifest::from_yaml_str(
6389 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 50\n max_age: 14d\n",
6390 )
6391 .unwrap();
6392 assert_eq!(
6393 manifest.trace.unwrap().retain,
6394 Some(TraceRetainConfig {
6395 max_sessions: Some(50),
6396 max_age_secs: Some(14 * 86_400),
6397 })
6398 );
6399 }
6400
6401 #[test]
6402 fn context_retain_parses_both_keys_and_either_alone() {
6403 let both = RuntimeManifest::from_yaml_str(
6404 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n max_messages: 2000\n max_age: 90d\n",
6405 )
6406 .unwrap();
6407 assert_eq!(
6408 both.context.unwrap().retain,
6409 Some(ContextRetainConfig {
6410 max_messages: Some(2000),
6411 max_age_secs: Some(90 * 86_400),
6412 })
6413 );
6414
6415 let count_only = RuntimeManifest::from_yaml_str(
6416 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 10\n",
6417 )
6418 .unwrap();
6419 assert_eq!(
6420 count_only.context.unwrap().retain,
6421 Some(ContextRetainConfig {
6422 max_messages: Some(10),
6423 max_age_secs: None,
6424 })
6425 );
6426 }
6427
6428 #[test]
6431 fn an_empty_retain_block_is_refused_naming_the_block() {
6432 for (yaml, expect_trace) in [
6433 (
6434 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain: {}\n",
6435 true,
6436 ),
6437 (
6438 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n",
6439 true,
6440 ),
6441 (
6442 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain: {}\n",
6443 false,
6444 ),
6445 (
6446 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n",
6447 false,
6448 ),
6449 ] {
6450 let err = RuntimeManifest::from_yaml_str(yaml)
6451 .expect_err("an empty retain block must be refused");
6452 let (field, message) = match (&err, expect_trace) {
6453 (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
6454 (field, message)
6455 }
6456 (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
6457 (field, message)
6458 }
6459 _ => panic!("wrong variant for {yaml:?}: {err:?}"),
6460 };
6461 assert_eq!(
6462 field,
6463 if expect_trace {
6464 "trace.retain"
6465 } else {
6466 "context.retain"
6467 }
6468 );
6469 assert!(message.contains("at least one key"), "{message}");
6470 }
6471 }
6472
6473 #[test]
6476 fn a_zero_retain_limit_is_refused_naming_the_key() {
6477 let trace = RuntimeManifest::from_yaml_str(
6478 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 0\n",
6479 )
6480 .expect_err("max_sessions: 0 must be refused");
6481 assert!(
6482 matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
6483 if field == "trace.retain.max_sessions"),
6484 "{trace:?}"
6485 );
6486
6487 let context = RuntimeManifest::from_yaml_str(
6488 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 0\n",
6489 )
6490 .expect_err("max_messages: 0 must be refused");
6491 assert!(
6492 matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
6493 if field == "context.retain.max_messages"),
6494 "{context:?}"
6495 );
6496
6497 let age = RuntimeManifest::from_yaml_str(
6498 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_age: 0\n",
6499 )
6500 .expect_err("max_age: 0 must be refused");
6501 assert!(
6502 matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
6503 if field == "trace.retain.max_age"),
6504 "{age:?}"
6505 );
6506 }
6507
6508 #[test]
6509 fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
6510 let err = RuntimeManifest::from_yaml_str(
6511 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_traces: 3\n",
6512 )
6513 .expect_err("an unknown key must be refused");
6514 match err {
6515 RuntimeManifestError::InvalidTraceConfig { field, message } => {
6516 assert_eq!(field, "trace.retain");
6517 assert!(message.contains("max_traces"), "{message}");
6518 assert!(message.contains("max_sessions"), "{message}");
6519 }
6520 other => panic!("expected InvalidTraceConfig, got {other:?}"),
6521 }
6522 }
6523
6524 #[test]
6525 fn an_unparseable_retain_duration_reports_the_accepted_form() {
6526 let err = RuntimeManifest::from_yaml_str(
6527 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_age: 5 weeks\n",
6528 )
6529 .expect_err("'5 weeks' must be refused");
6530 match err {
6531 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
6532 assert_eq!(field, "context.retain.max_age");
6533 assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
6534 }
6535 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
6536 }
6537 }
6538
6539 #[test]
6540 fn unparseable_capture_value_names_the_field_and_accepted_values() {
6541 let err = RuntimeManifest::from_yaml_str(&manifest_with_trace(" capture: verbose\n"))
6542 .expect_err("'verbose' is not a capture mode");
6543 let rendered = err.to_string();
6544 assert!(rendered.contains("trace.capture"), "{rendered}");
6545 for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
6546 assert!(rendered.contains(accepted), "{rendered}");
6547 }
6548 }
6549
6550 #[test]
6551 fn resources_block_is_optional_and_absent_stays_absent() {
6552 let manifest = RuntimeManifest::from_yaml_str(
6553 r#"
6554name: cap
6555version: 0.0.1
6556capabilities:
6557 shell:
6558 allow: [bash]
6559"#,
6560 )
6561 .unwrap();
6562
6563 let caps = manifest.capabilities.expect("shell block must parse");
6567 assert_eq!(caps.resources, None);
6568 }
6569
6570 #[test]
6571 fn resources_fields_parse_and_each_stays_independently_optional() {
6572 let manifest = RuntimeManifest::from_yaml_str(
6573 r#"
6574name: cap
6575version: 0.0.1
6576capabilities:
6577 resources:
6578 max_processes: 32
6579 max_open_files: 16
6580 max_file_size_bytes: 1048576
6581 cpu_seconds: 30
6582 memory_bytes: 268435456
6583 cgroup_memory_bytes: 536870912
6584 cgroup_pids_max: 64
6585 cgroup_cpu_percent: 50
6586 cgroup_io_bytes_per_sec: 1048576
6587 workdir_max_bytes: 2097152
6588"#,
6589 )
6590 .unwrap();
6591
6592 let resources = manifest
6593 .capabilities
6594 .and_then(|caps| caps.resources)
6595 .expect("resources block must parse");
6596 assert_eq!(resources.max_processes, Some(32));
6597 assert_eq!(resources.max_open_files, Some(16));
6598 assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
6599 assert_eq!(resources.cpu_seconds, Some(30));
6600 assert_eq!(resources.memory_bytes, Some(268_435_456));
6601 assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
6602 assert_eq!(resources.cgroup_pids_max, Some(64));
6603 assert_eq!(resources.cgroup_cpu_percent, Some(50));
6604 assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
6605 assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
6606 }
6607
6608 #[test]
6609 fn state_block_is_optional_and_absent_stays_absent() {
6610 let manifest = RuntimeManifest::from_yaml_str(
6611 r#"
6612name: cap
6613version: 0.0.1
6614artifacts:
6615 - name: local/notes-tool
6616 version: 1.0.0
6617 runtime: tool
6618 capabilities:
6619 filesystem:
6620 scope: cache
6621"#,
6622 )
6623 .unwrap();
6624
6625 let caps = manifest.artifacts[0]
6627 .capabilities
6628 .as_ref()
6629 .expect("filesystem block must parse");
6630 assert_eq!(caps.state, None);
6631 }
6632
6633 #[test]
6637 fn state_block_declared_empty_grants_the_capsule_named_store() {
6638 let manifest = RuntimeManifest::from_yaml_str(
6639 r#"
6640name: cap
6641version: 0.0.1
6642artifacts:
6643 - name: local/notes-tool
6644 version: 1.0.0
6645 runtime: tool
6646 capabilities:
6647 state: {}
6648"#,
6649 )
6650 .unwrap();
6651
6652 let state = manifest.artifacts[0]
6653 .capabilities
6654 .as_ref()
6655 .and_then(|caps| caps.state.as_ref())
6656 .expect("state block must parse");
6657 assert_eq!(state.store, None);
6658 }
6659
6660 #[test]
6661 fn state_store_name_parses_and_stays_independently_optional() {
6662 let manifest = RuntimeManifest::from_yaml_str(
6663 r#"
6664name: cap
6665version: 0.0.1
6666artifacts:
6667 - name: local/notes-tool
6668 version: 1.0.0
6669 runtime: tool
6670 capabilities:
6671 state:
6672 store: " shey "
6673 network:
6674 allow: [https://api.example.com]
6675"#,
6676 )
6677 .unwrap();
6678
6679 let caps = manifest.artifacts[0]
6680 .capabilities
6681 .as_ref()
6682 .expect("capabilities must parse");
6683 assert_eq!(
6686 caps.state.as_ref().and_then(|state| state.store.as_deref()),
6687 Some("shey")
6688 );
6689 assert_eq!(
6691 caps.network
6692 .as_ref()
6693 .map(|network| network.allow.as_slice()),
6694 Some(["https://api.example.com".to_string()].as_slice())
6695 );
6696 assert_eq!(caps.filesystem, None);
6697 }
6698
6699 #[test]
6703 fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
6704 let manifest = RuntimeManifest::from_yaml_str(
6705 r#"
6706name: cap
6707version: 0.0.1
6708artifacts:
6709 - name: local/notes-tool
6710 version: 1.0.0
6711 runtime: tool
6712 capabilities:
6713 state:
6714 store: ""
6715"#,
6716 )
6717 .unwrap();
6718
6719 assert_eq!(
6720 manifest.artifacts[0]
6721 .capabilities
6722 .as_ref()
6723 .and_then(|caps| caps.state.as_ref())
6724 .and_then(|state| state.store.as_deref()),
6725 Some("")
6726 );
6727 }
6728
6729 #[test]
6730 fn resources_partial_block_leaves_undeclared_fields_none() {
6731 let manifest = RuntimeManifest::from_yaml_str(
6732 r#"
6733name: cap
6734version: 0.0.1
6735capabilities:
6736 resources:
6737 max_open_files: 16
6738"#,
6739 )
6740 .unwrap();
6741
6742 let resources = manifest
6743 .capabilities
6744 .and_then(|caps| caps.resources)
6745 .expect("resources block must parse");
6746 assert_eq!(resources.max_open_files, Some(16));
6747 assert_eq!(resources.max_processes, None);
6748 assert_eq!(resources.workdir_max_bytes, None);
6749 }
6750
6751 #[test]
6755 fn resources_zero_is_rejected_on_every_field() {
6756 for field in [
6757 "max_processes",
6758 "max_open_files",
6759 "max_file_size_bytes",
6760 "cpu_seconds",
6761 "memory_bytes",
6762 "cgroup_memory_bytes",
6763 "cgroup_pids_max",
6764 "cgroup_cpu_percent",
6765 "cgroup_io_bytes_per_sec",
6766 "workdir_max_bytes",
6767 ] {
6768 let yaml =
6769 format!("name: cap\nversion: 0.0.1\ncapabilities:\n resources:\n {field}: 0\n");
6770 let error =
6771 RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
6772
6773 match error {
6774 RuntimeManifestError::InvalidCapabilities {
6775 field: reported,
6776 message,
6777 } => {
6778 assert_eq!(reported, format!("capabilities.resources.{field}"));
6779 assert_eq!(message, "must be greater than zero");
6780 }
6781 other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
6782 }
6783 }
6784 }
6785
6786 #[test]
6787 fn artifact_runtime_defaults_to_tool() {
6788 let manifest = RuntimeManifest::from_yaml_str(
6789 r#"
6790name: cap
6791version: 0.0.1
6792artifacts:
6793 - name: local/echo
6794 version: 1.2.3
6795"#,
6796 )
6797 .unwrap();
6798
6799 assert_eq!(manifest.artifacts.len(), 1);
6800 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
6801 }
6802
6803 #[test]
6804 fn artifact_runtime_tool_explicit() {
6805 let manifest = RuntimeManifest::from_yaml_str(
6806 r#"
6807name: cap
6808version: 0.0.1
6809artifacts:
6810 - name: local/echo
6811 version: 1.2.3
6812 runtime: tool
6813"#,
6814 )
6815 .unwrap();
6816
6817 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
6818 }
6819
6820 #[test]
6821 fn artifact_runtime_wasm_is_error() {
6822 let err = RuntimeManifest::from_yaml_str(
6823 r#"
6824name: cap
6825version: 0.0.1
6826artifacts:
6827 - name: local/echo
6828 version: 1.2.3
6829 runtime: wasm
6830"#,
6831 )
6832 .unwrap_err();
6833
6834 let msg = err.to_string();
6835 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
6836 assert!(msg.contains("wasm"), "error was: {msg}");
6837 }
6838
6839 #[test]
6840 fn artifact_runtime_native_is_error() {
6841 let err = RuntimeManifest::from_yaml_str(
6842 r#"
6843name: cap
6844version: 0.0.1
6845artifacts:
6846 - name: local/echo
6847 version: 1.2.3
6848 runtime: native
6849"#,
6850 )
6851 .unwrap_err();
6852
6853 let msg = err.to_string();
6854 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
6855 assert!(msg.contains("native"), "error was: {msg}");
6856 }
6857
6858 #[test]
6859 fn invalid_artifact_reports_index() {
6860 let err = RuntimeManifest::from_yaml_str(
6861 r#"
6862name: cap
6863version: 0.0.1
6864artifacts:
6865 - name: ""
6866 version: 1.2.3
6867"#,
6868 )
6869 .unwrap_err();
6870
6871 let msg = err.to_string();
6872 assert!(msg.contains("index 0"));
6873 assert!(msg.contains("missing required field 'name'"));
6874 }
6875
6876 #[test]
6877 fn capabilities_parse_when_all_fields_present() {
6878 let manifest = RuntimeManifest::from_yaml_str(
6879 r#"
6880name: cap
6881version: 0.0.1
6882artifacts:
6883 - name: local/echo
6884 version: 1.2.3
6885capabilities:
6886 network:
6887 allow:
6888 - https://api.anthropic.com
6889 - http://127.0.0.1:8080
6890 filesystem:
6891 scope: ./sandbox
6892"#,
6893 )
6894 .unwrap();
6895
6896 let capabilities = manifest.capabilities.expect("capabilities should exist");
6897 assert_eq!(
6898 capabilities.network.unwrap().allow,
6899 vec![
6900 "https://api.anthropic.com".to_string(),
6901 "http://127.0.0.1:8080".to_string()
6902 ]
6903 );
6904 assert_eq!(
6905 capabilities.filesystem.unwrap().scope,
6906 Some("./sandbox".to_string())
6907 );
6908 }
6909
6910 #[test]
6911 fn env_capabilities_parse_allowlist() {
6912 let manifest = RuntimeManifest::from_yaml_str(
6913 r#"
6914name: cap
6915version: 0.0.1
6916artifacts:
6917 - name: local/echo
6918 version: 1.2.3
6919capabilities:
6920 env:
6921 allow:
6922 - MY_APP_REGION
6923 - GITHUB_TOKEN
6924"#,
6925 )
6926 .unwrap();
6927
6928 let capabilities = manifest.capabilities.expect("capabilities should exist");
6929 assert_eq!(
6930 capabilities.env.unwrap().allow,
6931 vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
6932 );
6933 }
6934
6935 #[test]
6936 fn env_capabilities_absent_when_block_omitted() {
6937 let manifest = RuntimeManifest::from_yaml_str(
6938 r#"
6939name: cap
6940version: 0.0.1
6941artifacts:
6942 - name: local/echo
6943 version: 1.2.3
6944capabilities:
6945 network:
6946 allow:
6947 - https://api.anthropic.com
6948"#,
6949 )
6950 .unwrap();
6951
6952 assert!(manifest.capabilities.unwrap().env.is_none());
6953 }
6954
6955 #[test]
6958 fn env_capabilities_allow_empty_list_is_accepted() {
6959 let manifest = RuntimeManifest::from_yaml_str(
6960 r#"
6961name: cap
6962version: 0.0.1
6963artifacts:
6964 - name: local/echo
6965 version: 1.2.3
6966capabilities:
6967 env: {}
6968"#,
6969 )
6970 .unwrap();
6971
6972 assert_eq!(
6973 manifest.capabilities.unwrap().env,
6974 Some(EnvCapabilities { allow: vec![] })
6975 );
6976 }
6977
6978 #[test]
6979 fn missing_capabilities_block_is_backward_compatible() {
6980 let manifest = RuntimeManifest::from_yaml_str(
6981 r#"
6982name: cap
6983version: 0.0.1
6984artifacts:
6985 - name: local/echo
6986 version: 1.2.3
6987"#,
6988 )
6989 .unwrap();
6990
6991 assert!(manifest.capabilities.is_none());
6992 }
6993
6994 #[test]
6995 fn network_only_capabilities_parse() {
6996 let manifest = RuntimeManifest::from_yaml_str(
6997 r#"
6998name: cap
6999version: 0.0.1
7000artifacts:
7001 - name: local/echo
7002 version: 1.2.3
7003capabilities:
7004 network:
7005 allow:
7006 - https://allowed.example.com
7007"#,
7008 )
7009 .unwrap();
7010
7011 let capabilities = manifest.capabilities.unwrap();
7012 assert_eq!(
7013 capabilities.network.unwrap().allow,
7014 vec!["https://allowed.example.com".to_string()]
7015 );
7016 assert!(capabilities.filesystem.is_none());
7017 }
7018
7019 #[test]
7020 fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
7021 let manifest = RuntimeManifest::from_yaml_str(
7022 r#"
7023name: cap
7024version: 0.0.1
7025artifacts:
7026 - name: local/echo
7027 version: 1.2.3
7028capabilities:
7029 network: {}
7030"#,
7031 )
7032 .unwrap();
7033
7034 let capabilities = manifest.capabilities.unwrap();
7035 assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
7036 }
7037
7038 #[test]
7041 fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
7042 let manifest = RuntimeManifest::from_yaml_str(
7043 r#"
7044name: cap
7045version: 0.0.1
7046artifacts:
7047 - name: local/echo
7048 version: 1.2.3
7049capabilities:
7050 network:
7051 allow:
7052 - https://api.anthropic.com
7053"#,
7054 )
7055 .unwrap();
7056
7057 let network = manifest.capabilities.unwrap().network.unwrap();
7058 assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
7059 assert!(!network.unix_sockets);
7060 }
7061
7062 #[test]
7063 fn network_unix_sockets_parses_explicit_false() {
7064 let manifest = RuntimeManifest::from_yaml_str(
7065 r#"
7066name: cap
7067version: 0.0.1
7068artifacts:
7069 - name: local/echo
7070 version: 1.2.3
7071capabilities:
7072 network:
7073 unix_sockets: false
7074"#,
7075 )
7076 .unwrap();
7077
7078 assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
7079 }
7080
7081 #[test]
7082 fn network_unix_sockets_parses_explicit_true() {
7083 let manifest = RuntimeManifest::from_yaml_str(
7084 r#"
7085name: cap
7086version: 0.0.1
7087artifacts:
7088 - name: local/echo
7089 version: 1.2.3
7090capabilities:
7091 network:
7092 unix_sockets: true
7093"#,
7094 )
7095 .unwrap();
7096
7097 let network = manifest.capabilities.unwrap().network.unwrap();
7098 assert!(network.unix_sockets);
7099 assert_eq!(network.allow, Vec::<String>::new());
7101 }
7102
7103 #[test]
7107 fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
7108 let manifest = RuntimeManifest::from_yaml_str(
7109 r#"
7110name: cap
7111version: 0.0.1
7112capabilities:
7113 filesystem:
7114 read_only:
7115 - tests
7116 - " bench/fixtures "
7117 - ""
7118 - " "
7119"#,
7120 )
7121 .unwrap();
7122
7123 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7124 assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
7125 }
7126
7127 #[test]
7129 fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
7130 let manifest = RuntimeManifest::from_yaml_str(
7131 r#"
7132name: cap
7133version: 0.0.1
7134capabilities:
7135 filesystem:
7136 scope: workdir
7137"#,
7138 )
7139 .unwrap();
7140
7141 assert!(manifest
7142 .capabilities
7143 .unwrap()
7144 .filesystem
7145 .unwrap()
7146 .read_only
7147 .is_empty());
7148 }
7149
7150 #[test]
7153 fn filesystem_read_only_does_not_judge_path_shape() {
7154 let manifest = RuntimeManifest::from_yaml_str(
7155 r#"
7156name: cap
7157version: 0.0.1
7158capabilities:
7159 filesystem:
7160 read_only:
7161 - /etc
7162 - ../outside
7163"#,
7164 )
7165 .expect("shape is judged by the runtime, not the parser");
7166
7167 assert_eq!(
7168 manifest.capabilities.unwrap().filesystem.unwrap().read_only,
7169 vec!["/etc", "../outside"]
7170 );
7171 }
7172
7173 #[test]
7177 fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
7178 let manifest = RuntimeManifest::from_yaml_str(
7179 r#"
7180name: cap
7181version: 0.0.1
7182artifacts:
7183 - name: local/echo
7184 version: 1.2.3
7185capabilities:
7186 filesystem:
7187 scope: workdir
7188"#,
7189 )
7190 .unwrap();
7191
7192 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7193 assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
7194 assert!(!filesystem.workdir_exec);
7195 }
7196
7197 #[test]
7202 fn filesystem_block_stays_absent_when_undeclared() {
7203 let manifest = RuntimeManifest::from_yaml_str(
7204 r#"
7205name: cap
7206version: 0.0.1
7207artifacts:
7208 - name: local/echo
7209 version: 1.2.3
7210capabilities:
7211 network:
7212 allow:
7213 - https://api.anthropic.com
7214"#,
7215 )
7216 .unwrap();
7217
7218 assert!(manifest.capabilities.unwrap().filesystem.is_none());
7219 }
7220
7221 #[test]
7222 fn filesystem_workdir_exec_parses_explicit_false() {
7223 let manifest = RuntimeManifest::from_yaml_str(
7224 r#"
7225name: cap
7226version: 0.0.1
7227artifacts:
7228 - name: local/echo
7229 version: 1.2.3
7230capabilities:
7231 filesystem:
7232 workdir_exec: false
7233"#,
7234 )
7235 .unwrap();
7236
7237 assert!(
7238 !manifest
7239 .capabilities
7240 .unwrap()
7241 .filesystem
7242 .unwrap()
7243 .workdir_exec
7244 );
7245 }
7246
7247 #[test]
7248 fn filesystem_workdir_exec_parses_explicit_true() {
7249 let manifest = RuntimeManifest::from_yaml_str(
7250 r#"
7251name: cap
7252version: 0.0.1
7253artifacts:
7254 - name: local/echo
7255 version: 1.2.3
7256capabilities:
7257 filesystem:
7258 workdir_exec: true
7259"#,
7260 )
7261 .unwrap();
7262
7263 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7264 assert!(filesystem.workdir_exec);
7265 assert_eq!(filesystem.scope, None);
7268 }
7269
7270 #[test]
7271 fn network_internal_port_parses() {
7272 let manifest = RuntimeManifest::from_yaml_str(
7273 r#"
7274name: worker
7275version: 0.1.0
7276artifacts: []
7277
7278network:
7279 internal_port: 58172
7280"#,
7281 )
7282 .unwrap();
7283
7284 assert_eq!(
7285 manifest.network.unwrap().internal_port,
7286 Some(58172),
7287 "network.internal_port should parse to Some(58172)"
7288 );
7289 }
7290
7291 #[test]
7292 fn network_internal_port_absent_when_omitted() {
7293 let manifest = RuntimeManifest::from_yaml_str(
7294 r#"
7295name: worker
7296version: 0.1.0
7297artifacts: []
7298"#,
7299 )
7300 .unwrap();
7301
7302 assert!(manifest.network.is_none());
7303 }
7304
7305 #[test]
7306 fn shell_capabilities_parse_with_allow_and_env_fields() {
7307 let manifest = RuntimeManifest::from_yaml_str(
7308 r#"
7309name: cap
7310version: 0.0.1
7311artifacts: []
7312capabilities:
7313 shell:
7314 allow:
7315 - bash
7316 - git
7317 strip_env:
7318 - "*_TOKEN"
7319 - SECRET_KEY
7320 baseline_env:
7321 - SSH_AUTH_SOCK
7322"#,
7323 )
7324 .unwrap();
7325
7326 let shell = manifest
7327 .capabilities
7328 .unwrap()
7329 .shell
7330 .expect("shell capabilities should be present");
7331 assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
7332 assert_eq!(
7333 shell.strip_env,
7334 Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
7335 );
7336 assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
7337 }
7338
7339 #[test]
7340 fn shell_capabilities_require_non_empty_allowlist() {
7341 let err = RuntimeManifest::from_yaml_str(
7342 r#"
7343name: cap
7344version: 0.0.1
7345artifacts: []
7346capabilities:
7347 shell:
7348 allow: []
7349"#,
7350 )
7351 .unwrap_err();
7352
7353 let msg = err.to_string();
7354 assert!(msg.contains("capabilities.shell.allow"));
7355 assert!(msg.contains("at least one"));
7356 }
7357
7358 fn shell_allow_error(entry: &str) -> String {
7359 let yaml = format!(
7360 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n shell:\n allow: [bash, {}]\n",
7361 serde_json::to_string(entry).unwrap()
7362 );
7363 RuntimeManifest::from_yaml_str(&yaml)
7364 .expect_err(&format!("{entry:?} must be refused"))
7365 .to_string()
7366 }
7367
7368 #[test]
7369 fn shell_allow_refuses_an_entry_that_is_not_a_bare_name() {
7370 for (entry, reason) in [
7371 ("/tmp/uv-tool/bin/uv", "is a path"),
7372 ("bin/uv", "is a path"),
7373 ("..", "names a directory"),
7374 (".", "names a directory"),
7375 ("", "is empty"),
7376 ("a b", "contains whitespace"),
7377 ("uv\t", "contains whitespace"),
7378 ("uv\0", "contains a NUL byte"),
7379 ] {
7380 let message = shell_allow_error(entry);
7381 assert!(
7382 message.contains("'capabilities.shell.allow[1]'"),
7383 "{entry:?}: {message}"
7384 );
7385 assert!(
7386 message.contains(&format!(
7387 "'{}' {reason}; an entry is a bare binary name",
7388 entry.escape_debug()
7389 )),
7390 "{entry:?}: {message}"
7391 );
7392 }
7393 }
7394
7395 #[test]
7396 fn shell_allow_accepts_bare_names() {
7397 let manifest = RuntimeManifest::from_yaml_str(
7398 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n shell:\n allow: [bash, python3.12, git-lfs, \"c++\"]\n",
7399 )
7400 .unwrap();
7401 assert_eq!(
7402 manifest.capabilities.unwrap().shell.unwrap().allow,
7403 ["bash", "python3.12", "git-lfs", "c++"]
7404 );
7405 }
7406
7407 #[test]
7408 fn interpreter_runtime_parses_accepted_shape() {
7409 let manifest = RuntimeManifest::from_yaml_str(
7410 r#"
7411name: cap
7412version: 0.0.1
7413artifacts: []
7414capabilities:
7415 shell:
7416 allow:
7417 - python3
7418 interpreter_runtime:
7419 - binary: python3
7420 dirs:
7421 - path: /usr/lib/python3.11
7422 list_dir: true
7423 - path: /usr/lib/python3.11/lib-dynload
7424 list_dir: false
7425"#,
7426 )
7427 .unwrap();
7428
7429 let shell = manifest.capabilities.unwrap().shell.unwrap();
7430 assert_eq!(
7431 shell.interpreter_runtime,
7432 vec![InterpreterRuntimeGrant {
7433 binary: "python3".to_string(),
7434 dirs: vec![
7435 InterpreterRuntimeDir {
7436 path: "/usr/lib/python3.11".to_string(),
7437 list_dir: true,
7438 },
7439 InterpreterRuntimeDir {
7440 path: "/usr/lib/python3.11/lib-dynload".to_string(),
7441 list_dir: false,
7442 },
7443 ],
7444 }]
7445 );
7446 }
7447
7448 #[test]
7449 fn interpreter_runtime_defaults_to_empty_when_absent() {
7450 let manifest = RuntimeManifest::from_yaml_str(
7451 r#"
7452name: cap
7453version: 0.0.1
7454artifacts: []
7455capabilities:
7456 shell:
7457 allow:
7458 - python3
7459"#,
7460 )
7461 .unwrap();
7462
7463 let shell = manifest.capabilities.unwrap().shell.unwrap();
7464 assert!(shell.interpreter_runtime.is_empty());
7465 }
7466
7467 fn interpreter_runtime_reject(grant_yaml: &str) -> String {
7470 let yaml = format!(
7471 r#"
7472name: cap
7473version: 0.0.1
7474artifacts: []
7475capabilities:
7476 shell:
7477 allow:
7478 - python3
7479 interpreter_runtime:
7480{grant_yaml}
7481"#,
7482 );
7483 RuntimeManifest::from_yaml_str(&yaml)
7484 .expect_err("grant should be rejected")
7485 .to_string()
7486 }
7487
7488 #[test]
7489 fn interpreter_runtime_rejects_binary_not_in_allow() {
7490 let msg = interpreter_runtime_reject(
7491 " - binary: ruby\n dirs:\n - path: /usr/lib/ruby\n list_dir: true\n",
7492 );
7493 assert!(
7494 msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
7495 "{msg}"
7496 );
7497 assert!(msg.contains("ruby"), "{msg}");
7498 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
7499 }
7500
7501 #[test]
7502 fn interpreter_runtime_rejects_relative_path() {
7503 let msg = interpreter_runtime_reject(
7504 " - binary: python3\n dirs:\n - path: usr/lib/python3.11\n list_dir: true\n",
7505 );
7506 assert!(
7507 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
7508 "{msg}"
7509 );
7510 assert!(msg.contains("absolute"), "{msg}");
7511 }
7512
7513 fn manifest_with_plan(block: &str) -> String {
7517 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n{block}")
7518 }
7519
7520 #[test]
7521 fn plan_submit_true_grants_the_capsule() {
7522 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n"))
7523 .expect("a plan block parses");
7524 assert_eq!(
7525 manifest.capabilities.unwrap().plan,
7526 Some(PlanCapabilities { submit: true })
7527 );
7528 }
7529
7530 #[test]
7533 fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
7534 let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: false\n"))
7535 .expect("an explicit denial parses");
7536 assert_eq!(
7537 explicit.capabilities.unwrap().plan,
7538 Some(PlanCapabilities { submit: false })
7539 );
7540
7541 let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
7542 .expect("no capabilities block parses");
7543 assert!(absent.capabilities.is_none());
7544 }
7545
7546 #[test]
7549 fn plan_without_submit_is_rejected() {
7550 let err = RuntimeManifest::from_yaml_str(&manifest_with_plan(" {}\n"))
7551 .expect_err("an empty plan block is refused");
7552 let msg = err.to_string();
7553 assert!(msg.contains("capabilities.plan.submit"), "{msg}");
7554 assert!(msg.contains("never"), "{msg}");
7555 }
7556
7557 #[test]
7559 fn an_unknown_plan_key_is_reported_by_path() {
7560 let manifest =
7561 RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n depth: 3\n"))
7562 .expect("an unknown key is a warning, not a parse error");
7563 assert!(
7564 manifest
7565 .unknown_keys
7566 .iter()
7567 .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
7568 "{:?}",
7569 manifest.unknown_keys
7570 );
7571 }
7572
7573 fn manifest_with_install(block: &str) -> String {
7577 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n install:\n{block}")
7578 }
7579
7580 #[test]
7581 fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
7582 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
7583 " skill: [ana/code-review, \"ana/style-*\"]\n tool: [\"local/b-*\", local/a, \"murmur/*\", \"*\"]\n",
7584 ))
7585 .expect("an install block parses");
7586 assert_eq!(
7587 manifest.capabilities.unwrap().install,
7588 Some(InstallCapabilities {
7589 skill: vec!["ana/code-review".into(), "ana/style-*".into()],
7590 tool: vec![
7591 "local/b-*".into(),
7592 "local/a".into(),
7593 "murmur/*".into(),
7594 "*".into()
7595 ],
7596 from: InstallFrom::Stores,
7597 })
7598 );
7599 }
7600
7601 #[test]
7604 fn capabilities_install_accepts_either_list_alone() {
7605 let skill_only =
7606 RuntimeManifest::from_yaml_str(&manifest_with_install(" skill: [local/a]\n"))
7607 .expect("skill alone parses");
7608 assert_eq!(
7609 skill_only.capabilities.unwrap().install,
7610 Some(InstallCapabilities {
7611 skill: vec!["local/a".into()],
7612 tool: vec![],
7613 from: InstallFrom::Stores,
7614 })
7615 );
7616 let tool_only =
7617 RuntimeManifest::from_yaml_str(&manifest_with_install(" tool: [\"local/b-*\"]\n"))
7618 .expect("tool alone parses");
7619 assert_eq!(
7620 tool_only.capabilities.unwrap().install,
7621 Some(InstallCapabilities {
7622 skill: vec![],
7623 tool: vec!["local/b-*".into()],
7624 from: InstallFrom::Stores,
7625 })
7626 );
7627 }
7628
7629 #[test]
7630 fn capabilities_install_absent_is_none() {
7631 let manifest = RuntimeManifest::from_yaml_str(
7632 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n submit: true\n",
7633 )
7634 .expect("a capabilities block without install parses");
7635 assert_eq!(manifest.capabilities.unwrap().install, None);
7636 }
7637
7638 fn install_from(list: &str) -> Result<InstallFrom, RuntimeManifestError> {
7639 RuntimeManifest::from_yaml_str(&manifest_with_install(&format!(
7640 " tool: [\"murmur/tool-*\"]\n from: {list}\n"
7641 )))
7642 .map(|manifest| manifest.capabilities.unwrap().install.unwrap().from)
7643 }
7644
7645 #[test]
7646 fn install_from_absent_is_stores() {
7647 let manifest =
7648 RuntimeManifest::from_yaml_str(&manifest_with_install(" tool: [local/a]\n"))
7649 .unwrap();
7650 assert_eq!(
7651 manifest.capabilities.unwrap().install.unwrap().from,
7652 InstallFrom::Stores
7653 );
7654 }
7655
7656 #[test]
7657 fn install_from_accepts_stores_alone_and_with_sources_in_either_order() {
7658 assert_eq!(install_from("[stores]").unwrap(), InstallFrom::Stores);
7659 assert_eq!(
7660 install_from("[stores, sources]").unwrap(),
7661 InstallFrom::StoresAndSources
7662 );
7663 assert_eq!(
7664 install_from("[sources, stores]").unwrap(),
7665 InstallFrom::StoresAndSources
7666 );
7667 assert_eq!(InstallFrom::StoresAndSources.as_list(), "stores, sources");
7668 assert!(InstallFrom::StoresAndSources.sources());
7669 assert!(!InstallFrom::Stores.sources());
7670 }
7671
7672 #[test]
7673 fn install_from_refuses_empty_sourceless_duplicate_and_unknown_lists() {
7674 for (list, why) in [
7675 ("[]", "is empty"),
7676 ("[sources]", "does not name stores"),
7677 ("[stores, stores]", "names \"stores\" twice"),
7678 ("[github]", "names \"github\", which is not a place"),
7679 ] {
7680 match install_from(list) {
7681 Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
7682 assert_eq!(field, "capabilities.install.from", "{list}");
7683 assert!(message.contains(why), "{list}: {message}");
7684 assert!(
7685 message.contains("expected [stores] or [stores, sources]"),
7686 "{list}: {message}"
7687 );
7688 }
7689 other => panic!("{list}: expected InvalidCapabilities, got {other:?}"),
7690 }
7691 }
7692 }
7693
7694 #[test]
7695 fn install_from_is_no_unknown_key() {
7696 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
7697 " tool: [local/a]\n from: [stores]\n",
7698 ))
7699 .unwrap();
7700 assert!(
7701 manifest
7702 .unknown_keys
7703 .iter()
7704 .all(|key| key.block_path != "capabilities.install"),
7705 "{:?}",
7706 manifest.unknown_keys
7707 );
7708 }
7709
7710 #[test]
7711 fn install_from_capabilities_install_is_refused_under_transport_process() {
7712 let yaml = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n install:\n \
7713 tool: [\"murmur/tool-*\"]\ninference:\n transport: process\n \
7714 command: claude\n model: claude-haiku-4-5-20251001\n driver:\n \
7715 artifact: murmur/driver-claude-code\n";
7716 match RuntimeManifest::from_yaml_str(yaml) {
7717 Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
7718 assert_eq!(field, "capabilities.install");
7719 assert_eq!(
7720 message,
7721 "is not valid with transport: process; the harness owns its tool list, so \
7722 a pulled artifact could never reach it"
7723 );
7724 }
7725 other => panic!("expected InvalidCapabilities, got {other:?}"),
7726 }
7727 }
7728
7729 #[test]
7732 fn capabilities_install_that_names_nothing_is_refused() {
7733 for block in [
7734 " {}\n",
7735 " skill: []\n",
7736 " skill: []\n tool: []\n",
7737 " driver: [x]\n",
7738 ] {
7739 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
7740 .expect_err("an install block naming nothing is refused");
7741 match &err {
7742 RuntimeManifestError::InvalidCapabilities { field, message } => {
7743 assert_eq!(field, "capabilities.install", "{block:?}");
7744 assert!(message.contains("at least one"), "{message}");
7745 assert!(message.contains("omit the block"), "{message}");
7746 }
7747 other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
7748 }
7749 }
7750 }
7751
7752 #[test]
7753 fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
7754 for entry in [
7755 "a*b", "*x", "**", "Foo", "-a", "a/b/c", " ", "*/x", "a/*x", "a/x*y", "A/x",
7756 ] {
7757 for key in ["skill", "tool"] {
7758 let block = format!(" {key}: [{entry:?}]\n");
7759 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
7760 .expect_err("a bad entry is refused");
7761 match &err {
7762 RuntimeManifestError::InvalidArtifactName {
7763 field,
7764 message,
7765 hint,
7766 } => {
7767 assert_eq!(field, &format!("capabilities.install.{key}[0]"));
7768 assert!(message.contains(&format!("{entry:?}")), "{message}");
7769 assert!(
7770 message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
7771 "{message}"
7772 );
7773 assert_eq!(hint, &None, "{entry:?}");
7774 }
7775 other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
7776 }
7777 }
7778 }
7779 }
7780
7781 #[test]
7783 fn capabilities_install_refuses_an_unscoped_entry_with_the_scoped_hint() {
7784 for (entry, scoped) in [
7785 ("tool-*", "local/tool-*"),
7786 ("mur*", "local/mur*"),
7787 ("tool-x", "local/tool-x"),
7788 ("murmur-tool-editor", "murmur/tool-editor"),
7789 ] {
7790 let block = format!(" tool: [\"local/ok\", {entry:?}]\n");
7791 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
7792 .expect_err("an unscoped entry is refused");
7793 match &err {
7794 RuntimeManifestError::InvalidArtifactName {
7795 field,
7796 message,
7797 hint,
7798 } => {
7799 assert_eq!(field, "capabilities.install.tool[1]");
7800 assert_eq!(
7801 message,
7802 &format!(
7803 "entry {entry:?} is invalid ('{entry}' names no scope); expected {}",
7804 crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
7805 )
7806 );
7807 let hint = hint.as_deref().expect("an unscoped entry carries a hint");
7808 assert!(hint.contains(scoped), "{entry:?}: {hint}");
7809 }
7810 other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
7811 }
7812 assert!(
7813 err.to_string().contains("'capabilities.install.tool[1]'"),
7814 "{err}"
7815 );
7816 }
7817 }
7818
7819 #[test]
7821 fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
7822 for runtime in ["tool", "hook", "driver", "skill"] {
7823 let yaml = format!(
7824 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/a\n version: 0.1.0\n \
7825 runtime: {runtime}\n capabilities:\n install:\n skill: [x]\n"
7826 );
7827 let err = RuntimeManifest::from_yaml_str(&yaml)
7828 .expect_err("a per-artifact install block is refused");
7829 match &err {
7830 RuntimeManifestError::InvalidArtifact { index, message } => {
7831 assert_eq!(*index, 0);
7832 assert!(message.contains("capabilities.install"), "{message}");
7833 assert!(message.contains("capsule-wide"), "{message}");
7834 }
7835 other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
7836 }
7837 }
7838 }
7839
7840 #[test]
7842 fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
7843 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
7844 " skill: [local/a]\n driver: [x]\n",
7845 ))
7846 .expect("an unknown key beside a real entry is a warning, not a parse error");
7847 assert!(
7848 manifest
7849 .unknown_keys
7850 .iter()
7851 .any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
7852 "{:?}",
7853 manifest.unknown_keys
7854 );
7855 assert_eq!(
7856 manifest.capabilities.unwrap().install,
7857 Some(InstallCapabilities {
7858 skill: vec!["local/a".into()],
7859 tool: vec![],
7860 from: InstallFrom::Stores,
7861 })
7862 );
7863 }
7864
7865 fn manifest_with_task_io(runtime: &str, block: &str) -> String {
7869 format!(
7870 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/gate\n version: 1.2.3\n \
7871 runtime: {runtime}\n capabilities:\n task_io:\n{block}"
7872 )
7873 }
7874
7875 #[test]
7876 fn task_io_read_true_grants_the_hook() {
7877 let manifest =
7878 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: true\n"))
7879 .expect("task_io on a hook entry parses");
7880 assert_eq!(
7881 manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
7882 Some(TaskIoCapabilities { read: true })
7883 );
7884 }
7885
7886 #[test]
7889 fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
7890 let explicit =
7891 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: false\n"))
7892 .expect("an explicit denial parses");
7893 assert_eq!(
7894 explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
7895 Some(TaskIoCapabilities { read: false })
7896 );
7897
7898 let absent = RuntimeManifest::from_yaml_str(
7899 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/gate\n version: 1.2.3\n \
7900 runtime: hook\n",
7901 )
7902 .expect("no capabilities block parses");
7903 assert!(absent.artifacts[0].capabilities.is_none());
7904 }
7905
7906 #[test]
7909 fn task_io_without_read_is_rejected() {
7910 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " {}\n"))
7911 .unwrap_err();
7912 let msg = err.to_string();
7913 assert!(
7914 msg.contains("capabilities.task_io.read"),
7915 "error was: {msg}"
7916 );
7917 assert!(msg.contains("explicitly"), "error was: {msg}");
7918 }
7919
7920 #[test]
7923 fn task_io_outside_a_hook_entry_is_rejected() {
7924 for runtime in ["tool", "driver", "skill"] {
7925 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
7926 runtime,
7927 " read: true\n",
7928 ))
7929 .unwrap_err();
7930 let msg = err.to_string();
7931 assert!(
7932 msg.contains("capabilities") && msg.contains("runtime: hook"),
7933 "{runtime} entry: error was: {msg}"
7934 );
7935 }
7936 }
7937
7938 fn manifest_with_filesystem(runtime: &str, block: &str) -> String {
7942 format!(
7943 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/thing\n version: 0.1.0\n \
7944 runtime: {runtime}\n capabilities:\n filesystem:\n{block}"
7945 )
7946 }
7947
7948 #[test]
7950 fn filesystem_records_on_a_hook_entry_parses() {
7951 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
7952 "hook",
7953 " records: true\n",
7954 ))
7955 .expect("records on a hook entry parses");
7956 let filesystem = manifest.artifacts[0]
7957 .capabilities
7958 .as_ref()
7959 .unwrap()
7960 .filesystem
7961 .as_ref()
7962 .unwrap();
7963 assert!(filesystem.records);
7964 assert_eq!(filesystem.scope, None);
7965
7966 let scoped =
7967 RuntimeManifest::from_yaml_str(&manifest_with_filesystem("hook", " scope: .\n"))
7968 .unwrap();
7969 let filesystem = scoped.artifacts[0]
7970 .capabilities
7971 .as_ref()
7972 .unwrap()
7973 .filesystem
7974 .as_ref()
7975 .unwrap();
7976 assert!(!filesystem.records);
7977 assert!(scoped.unknown_keys.is_empty(), "{:?}", scoped.unknown_keys);
7978 }
7979
7980 #[test]
7983 fn filesystem_records_is_refused_beside_a_scope_off_a_hook_and_capsule_wide() {
7984 let beside = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
7985 "hook",
7986 " records: true\n scope: .\n",
7987 ))
7988 .unwrap_err()
7989 .to_string();
7990 assert!(
7991 beside.contains("capabilities.filesystem.records"),
7992 "{beside}"
7993 );
7994 assert!(beside.contains("capabilities.filesystem.scope"), "{beside}");
7995 for runtime in ["tool", "driver"] {
7996 let off_hook = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
7997 runtime,
7998 " records: true\n",
7999 ))
8000 .unwrap_err()
8001 .to_string();
8002 assert!(
8003 off_hook.contains("capabilities.filesystem.records"),
8004 "{off_hook}"
8005 );
8006 assert!(off_hook.contains("runtime: hook"), "{off_hook}");
8007 }
8008 let capsule_wide = RuntimeManifest::from_yaml_str(
8009 "name: cap\nversion: 0.0.1\ncapabilities:\n filesystem:\n records: true\n",
8010 )
8011 .unwrap_err()
8012 .to_string();
8013 assert!(
8014 capsule_wide.contains("capabilities.filesystem.records"),
8015 "{capsule_wide}"
8016 );
8017 }
8018
8019 #[test]
8022 fn task_io_in_the_capsule_wide_block_is_rejected() {
8023 let err = RuntimeManifest::from_yaml_str(
8024 "name: cap\nversion: 0.0.1\ncapabilities:\n task_io:\n read: true\n",
8025 )
8026 .unwrap_err();
8027 let msg = err.to_string();
8028 assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
8029 assert!(msg.contains("runtime: hook"), "error was: {msg}");
8030 }
8031
8032 fn manifest_with_conversation(runtime: &str, block: &str) -> String {
8036 format!(
8037 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/memory\n version: 1.2.3\n \
8038 runtime: {runtime}\n capabilities:\n conversation:\n{block}"
8039 )
8040 }
8041
8042 #[test]
8043 fn conversation_read_lowers_onto_the_hook_entry() {
8044 for (declared, expected) in [("true", true), ("false", false)] {
8045 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8046 "hook",
8047 &format!(" read: {declared}\n"),
8048 ))
8049 .expect("conversation on a hook entry parses");
8050 assert_eq!(
8051 manifest.artifacts[0]
8052 .capabilities
8053 .as_ref()
8054 .unwrap()
8055 .conversation,
8056 Some(ConversationCapabilities { read: expected })
8057 );
8058 }
8059 }
8060
8061 #[test]
8064 fn conversation_without_read_is_rejected() {
8065 let err =
8066 RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", " {}\n"))
8067 .unwrap_err();
8068 let msg = err.to_string();
8069 assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
8070 assert!(msg.contains("explicitly"), "was: {msg}");
8071 }
8072
8073 #[test]
8076 fn conversation_outside_a_hook_entry_is_rejected() {
8077 for runtime in ["tool", "driver", "skill"] {
8078 let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8079 runtime,
8080 " read: true\n",
8081 ))
8082 .unwrap_err();
8083 let msg = err.to_string();
8084 assert!(
8085 msg.contains("capabilities") && msg.contains("runtime: hook"),
8086 "{runtime} entry: error was: {msg}"
8087 );
8088 }
8089 }
8090
8091 #[test]
8094 fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
8095 let manifest = RuntimeManifest::from_yaml_str(
8096 "name: cap\nversion: 0.0.1\ncapabilities:\n conversation:\n read: true\n",
8097 )
8098 .expect("a capsule-wide block is accepted");
8099 assert_eq!(
8100 manifest.capabilities.unwrap().conversation,
8101 Some(ConversationCapabilities { read: true })
8102 );
8103 }
8104
8105 #[test]
8110 fn a_context_block_records_by_default() {
8111 let context = context_of("context:\n max_tokens: 1000\n").unwrap();
8112 assert!(context.record);
8113 assert_eq!(context.record_store, None);
8114 }
8115
8116 #[test]
8117 fn record_off_and_on_are_the_two_accepted_values() {
8118 assert!(!context_of("context:\n record: off\n").unwrap().record);
8119 assert!(context_of("context:\n record: on\n").unwrap().record);
8120
8121 let err = RuntimeManifest::from_yaml_str(
8122 "name: cap\nversion: 0.0.1\ncontext:\n record: sometimes\n",
8123 )
8124 .unwrap_err();
8125 let msg = err.to_string();
8126 assert!(msg.contains("context.record"), "was: {msg}");
8127 assert!(msg.contains("sometimes"), "was: {msg}");
8128 }
8129
8130 #[test]
8133 fn record_store_is_kept_verbatim_and_trimmed() {
8134 assert_eq!(
8135 context_of("context:\n record_store: ' shey '\n")
8136 .unwrap()
8137 .record_store,
8138 Some("shey".to_string())
8139 );
8140 assert_eq!(
8141 context_of("context:\n record: off\n record_store: shey\n")
8142 .unwrap()
8143 .record_store,
8144 Some("shey".to_string()),
8145 "a store declared beside record: off is kept and simply never used"
8146 );
8147 }
8148
8149 #[test]
8150 fn interpreter_runtime_rejects_missing_list_dir() {
8151 let msg = interpreter_runtime_reject(
8152 " - binary: python3\n dirs:\n - path: /usr/lib/python3.11\n",
8153 );
8154 assert!(
8155 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
8156 "{msg}"
8157 );
8158 assert!(msg.contains("explicitly"), "{msg}");
8159 }
8160
8161 #[test]
8162 fn interpreter_runtime_rejects_empty_dirs() {
8163 let msg = interpreter_runtime_reject(" - binary: python3\n dirs: []\n");
8164 assert!(
8165 msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
8166 "{msg}"
8167 );
8168 assert!(msg.contains("at least one"), "{msg}");
8169 }
8170
8171 #[test]
8172 fn staged_runtime_parses_accepted_shape() {
8173 let manifest = RuntimeManifest::from_yaml_str(
8174 r#"
8175name: cap
8176version: 0.0.1
8177artifacts: []
8178capabilities:
8179 containment: sealed
8180 shell:
8181 allow:
8182 - python3
8183 - bash
8184 staged_runtime:
8185 - binary: python3
8186 source_path: /opt/testbed/conda/envs/django__django
8187 pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
8188"#,
8189 )
8190 .unwrap();
8191
8192 let shell = manifest.capabilities.unwrap().shell.unwrap();
8193 assert_eq!(
8194 shell.staged_runtime,
8195 vec![StagedRuntimeGrant {
8196 binary: "python3".to_string(),
8197 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
8198 pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
8199 }]
8200 );
8201 assert!(shell.interpreter_runtime.is_empty());
8204 }
8205
8206 #[test]
8207 fn staged_runtime_defaults_to_empty_when_absent() {
8208 let manifest = RuntimeManifest::from_yaml_str(
8209 r#"
8210name: cap
8211version: 0.0.1
8212artifacts: []
8213capabilities:
8214 shell:
8215 allow:
8216 - python3
8217"#,
8218 )
8219 .unwrap();
8220
8221 let shell = manifest.capabilities.unwrap().shell.unwrap();
8222 assert!(shell.staged_runtime.is_empty());
8223 }
8224
8225 fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
8229 let yaml = format!(
8230 r#"
8231name: cap
8232version: 0.0.1
8233artifacts: []
8234capabilities:
8235 shell:
8236 allow:
8237 - python3
8238{extra} staged_runtime:
8239{grant_yaml}
8240"#,
8241 );
8242 RuntimeManifest::from_yaml_str(&yaml)
8243 .expect_err("grant should be rejected")
8244 .to_string()
8245 }
8246
8247 #[test]
8248 fn staged_runtime_rejects_binary_not_in_allow() {
8249 let msg = staged_runtime_reject(
8250 " - binary: ruby\n source_path: /opt/ruby\n pin: ruby-3.2.2\n",
8251 "",
8252 );
8253 assert!(
8254 msg.contains("capabilities.shell.staged_runtime[0].binary"),
8255 "{msg}"
8256 );
8257 assert!(msg.contains("ruby"), "{msg}");
8258 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
8259 }
8260
8261 #[test]
8262 fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
8263 let msg = staged_runtime_reject(
8264 " - binary: python3\n source_path: /opt/py\n pin: cpython-3.9.19\n",
8265 " interpreter_runtime:\n - binary: python3\n dirs:\n - path: /usr/lib/python3.9\n list_dir: true\n",
8266 );
8267 assert!(
8268 msg.contains("capabilities.shell.staged_runtime[0].binary"),
8269 "{msg}"
8270 );
8271 assert!(msg.contains("python3"), "{msg}");
8272 assert!(msg.contains("interpreter_runtime"), "{msg}");
8273 assert!(msg.contains("mutually exclusive"), "{msg}");
8274 }
8275
8276 #[test]
8277 fn staged_runtime_rejects_relative_source_path() {
8278 let msg = staged_runtime_reject(
8279 " - binary: python3\n source_path: opt/testbed/conda\n pin: cpython-3.9.19\n",
8280 "",
8281 );
8282 assert!(
8283 msg.contains("capabilities.shell.staged_runtime[0].source_path"),
8284 "{msg}"
8285 );
8286 assert!(msg.contains("absolute"), "{msg}");
8287 }
8288
8289 #[test]
8290 fn staged_runtime_rejects_missing_or_empty_pin() {
8291 for grant in [
8294 " - binary: python3\n source_path: /opt/testbed/conda\n",
8295 " - binary: python3\n source_path: /opt/testbed/conda\n pin: \" \"\n",
8296 ] {
8297 let msg = staged_runtime_reject(grant, "");
8298 assert!(
8299 msg.contains("capabilities.shell.staged_runtime[0].pin"),
8300 "{msg}"
8301 );
8302 assert!(msg.contains("never inferred"), "{msg}");
8303 }
8304 }
8305
8306 #[test]
8307 fn parses_inference_block() {
8308 let manifest = RuntimeManifest::from_yaml_str(
8309 r#"
8310name: cap
8311version: 0.0.1
8312artifacts:
8313 - name: murmur/driver-anthropic
8314 version: 0.1.0
8315 runtime: driver
8316 gateway:
8317 endpoint: http://127.0.0.1:8080
8318 api_key: literal-key
8319inference:
8320 transport: http
8321 model: test-model
8322 driver:
8323 artifact: murmur/driver-anthropic
8324 config:
8325 temperature: 0.2
8326 headers:
8327 x-test: true
8328"#,
8329 )
8330 .unwrap();
8331
8332 assert_eq!(
8333 manifest.artifacts[0].gateway,
8334 Some(ArtifactGateway {
8335 endpoint: "http://127.0.0.1:8080".to_string(),
8336 api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
8337 keyless: false,
8338 })
8339 );
8340 let inference = manifest.inference.expect("inference should exist");
8341 assert_eq!(inference.transport, "http");
8342 assert_eq!(inference.model, "test-model");
8343 let driver = inference.driver.as_ref().expect("driver should be present");
8344 assert_eq!(driver.artifact, "murmur/driver-anthropic");
8345 assert_eq!(
8346 driver.config,
8347 Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
8348 );
8349 assert!(inference.system_prompt.is_none());
8350 assert!(inference.system_prompt_file.is_none());
8351 }
8352
8353 fn tool_gateway_manifest(block: &str) -> String {
8356 format!(
8357 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/web-search\n version: 0.1.0\n runtime: tool\n gateway:{block}\n"
8358 )
8359 }
8360
8361 fn gateway_endpoint_error(endpoint: &str) -> String {
8362 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
8363 "\n endpoint: \"{endpoint}\""
8364 )))
8365 .unwrap_err()
8366 .to_string()
8367 }
8368
8369 #[test]
8370 fn gateway_endpoint_https_is_accepted() {
8371 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8372 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
8373 ))
8374 .unwrap();
8375 assert_eq!(
8376 manifest.artifacts[0].gateway,
8377 Some(ArtifactGateway {
8378 endpoint: "https://api.tavily.com".to_string(),
8379 api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
8380 keyless: false,
8381 })
8382 );
8383 }
8384
8385 #[test]
8386 fn gateway_endpoint_http_localhost_is_accepted() {
8387 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8388 "\n endpoint: http://localhost:11434\n keyless: true",
8389 ))
8390 .unwrap();
8391 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
8392 assert_eq!(gateway.endpoint, "http://localhost:11434");
8393 assert_eq!(gateway.api_key, None);
8394 assert!(gateway.keyless);
8395 }
8396
8397 #[test]
8398 fn gateway_endpoint_http_loopback_ip_is_accepted() {
8399 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8400 "\n endpoint: http://127.0.0.1:8080\n api_key: ${LOCAL_KEY}",
8401 ))
8402 .unwrap();
8403 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
8404 assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
8405 assert_eq!(
8406 gateway.api_key,
8407 Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
8408 );
8409 }
8410
8411 #[test]
8412 fn gateway_endpoint_without_scheme_is_refused() {
8413 let msg = gateway_endpoint_error("api.tavily.com");
8414 assert!(msg.contains("gateway.endpoint"), "{msg}");
8415 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
8416 assert!(msg.contains("does not parse as a URL"), "{msg}");
8417 }
8418
8419 #[test]
8420 fn gateway_endpoint_ftp_scheme_is_refused() {
8421 let msg = gateway_endpoint_error("ftp://example.com");
8422 assert!(msg.contains("gateway.endpoint"), "{msg}");
8423 assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
8424 }
8425
8426 #[test]
8427 fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
8428 let msg = gateway_endpoint_error("http://api.attacker.example.com");
8429 assert!(msg.contains("gateway.endpoint"), "{msg}");
8430 assert!(msg.contains("api.attacker.example.com"), "{msg}");
8431 assert!(msg.contains("non-loopback"), "{msg}");
8432 }
8433
8434 #[test]
8435 fn gateway_endpoint_with_a_query_is_refused() {
8436 let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
8437 assert!(msg.contains("gateway.endpoint"), "{msg}");
8438 assert!(msg.contains("carries a query"), "{msg}");
8439 }
8440
8441 #[test]
8442 fn gateway_endpoint_with_a_fragment_is_refused() {
8443 let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
8444 assert!(msg.contains("gateway.endpoint"), "{msg}");
8445 assert!(msg.contains("carries a fragment"), "{msg}");
8446 }
8447
8448 #[test]
8449 fn gateway_endpoint_with_userinfo_is_refused() {
8450 let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
8451 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
8452 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
8453 assert!(msg.contains("host 'api.example.com'"), "{msg}");
8454 assert!(msg.contains("gateway.api_key"), "{msg}");
8455 assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
8456 assert!(
8457 !msg.contains("sk-in-url"),
8458 "a password in the URL is never echoed: {msg}"
8459 );
8460 }
8461
8462 #[test]
8463 fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
8464 let msg = gateway_endpoint_error("https://user:sk-in-url@");
8465 assert!(msg.contains("does not parse as a URL"), "{msg}");
8466 assert!(
8467 !msg.contains("sk-in-url"),
8468 "a password in the URL is never echoed: {msg}"
8469 );
8470 }
8471
8472 #[test]
8473 fn gateway_endpoint_with_a_masquerading_host_is_refused() {
8474 let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
8475 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
8476 assert!(
8477 msg.contains("addresses host '127.0.0.1'"),
8478 "the message names the host the URL really addresses: {msg}"
8479 );
8480 }
8481
8482 #[test]
8483 fn gateway_endpoint_with_interpolation_is_refused() {
8484 let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
8485 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
8486 assert!(msg.contains("contains '${'"), "{msg}");
8487 assert!(msg.contains("not interpolated"), "{msg}");
8488 }
8489
8490 #[test]
8491 fn gateway_endpoint_http_unspecified_address_is_refused() {
8492 let msg = gateway_endpoint_error("http://0.0.0.0:8080");
8493 assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
8494 }
8495
8496 #[test]
8497 fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
8498 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8499 "\n endpoint: http://[::1]:8080\n api_key: ${LOCAL_KEY}",
8500 ))
8501 .unwrap();
8502 assert_eq!(
8503 manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
8504 "http://[::1]:8080"
8505 );
8506 }
8507
8508 fn gateway_credential_error(block: &str) -> RuntimeManifestError {
8510 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
8511 }
8512
8513 fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
8514 match &error {
8515 RuntimeManifestError::GatewayWithoutCredential {
8516 index,
8517 name,
8518 endpoint: refused,
8519 } => {
8520 assert_eq!(*index, 0);
8521 assert_eq!(name, "local/web-search");
8522 assert_eq!(refused, endpoint);
8523 }
8524 other => panic!("expected GatewayWithoutCredential, got {other:?}"),
8525 }
8526 let msg = error.to_string();
8527 assert!(
8528 msg.contains(&format!(
8529 "artifact 'local/web-search' declares gateway.endpoint '{endpoint}' but binds no \
8530 credential"
8531 )),
8532 "{msg}"
8533 );
8534 }
8535
8536 #[test]
8537 fn gateway_credential_absent_is_refused() {
8538 assert_without_credential(
8539 gateway_credential_error("\n endpoint: https://api.tavily.com"),
8540 "https://api.tavily.com",
8541 );
8542 }
8543
8544 #[test]
8545 fn gateway_credential_blank_is_refused() {
8546 for key in ["\"\"", "\" \"", "''"] {
8547 assert_without_credential(
8548 gateway_credential_error(&format!(
8549 "\n endpoint: https://api.tavily.com\n api_key: {key}"
8550 )),
8551 "https://api.tavily.com",
8552 );
8553 }
8554 }
8555
8556 #[test]
8557 fn gateway_credential_null_is_refused() {
8558 for key in ["~", "null", ""] {
8559 assert_without_credential(
8560 gateway_credential_error(&format!(
8561 "\n endpoint: https://api.tavily.com\n api_key: {key}"
8562 )),
8563 "https://api.tavily.com",
8564 );
8565 }
8566 }
8567
8568 #[test]
8569 fn gateway_credential_keyless_false_is_refused() {
8570 assert_without_credential(
8571 gateway_credential_error(
8572 "\n endpoint: https://api.tavily.com\n keyless: false",
8573 ),
8574 "https://api.tavily.com",
8575 );
8576 }
8577
8578 #[test]
8579 fn gateway_credential_keyless_is_accepted() {
8580 for (parse, mode) in [
8581 (
8582 RuntimeManifest::from_yaml_str as fn(&str) -> _,
8583 "with secrets",
8584 ),
8585 (
8586 RuntimeManifest::from_yaml_str_without_secrets,
8587 "without secrets",
8588 ),
8589 ] {
8590 let manifest = parse(&tool_gateway_manifest(
8591 "\n endpoint: https://api.example.com\n keyless: true",
8592 ))
8593 .unwrap();
8594 assert_eq!(
8595 manifest.artifacts[0].gateway,
8596 Some(ArtifactGateway {
8597 endpoint: "https://api.example.com".to_string(),
8598 api_key: None,
8599 keyless: true,
8600 }),
8601 "{mode}"
8602 );
8603 }
8604 }
8605
8606 #[test]
8607 fn gateway_credential_keyless_not_a_boolean_is_refused() {
8608 for value in ["\"true\"", "yes please", "1"] {
8609 let error = gateway_credential_error(&format!(
8610 "\n endpoint: https://api.tavily.com\n keyless: {value}"
8611 ));
8612 assert!(
8613 matches!(
8614 error,
8615 RuntimeManifestError::InvalidArtifact { index: 0, .. }
8616 ),
8617 "{value}: {error:?}"
8618 );
8619 assert!(
8620 error
8621 .to_string()
8622 .contains("gateway.keyless with a value that is not a boolean"),
8623 "{error}"
8624 );
8625 }
8626 }
8627
8628 #[test]
8629 fn gateway_credential_and_keyless_together_are_refused() {
8630 for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
8631 let error = gateway_credential_error(&format!(
8632 "\n endpoint: https://api.tavily.com\n api_key: {key}\n keyless: true"
8633 ));
8634 assert!(
8635 matches!(
8636 error,
8637 RuntimeManifestError::InvalidArtifact { index: 0, .. }
8638 ),
8639 "{error:?}"
8640 );
8641 assert!(
8642 error.to_string().contains(
8643 "artifact 'local/web-search' declares both gateway.api_key and gateway.keyless: \
8644 true; a gateway either presents a key or is keyless"
8645 ),
8646 "{error}"
8647 );
8648 }
8649 }
8650
8651 #[test]
8652 fn gateway_credential_absent_is_refused_without_secrets() {
8653 for block in [
8654 "\n endpoint: https://api.tavily.com",
8655 "\n endpoint: https://api.tavily.com\n api_key: \"\"",
8656 "\n endpoint: https://api.tavily.com\n keyless: false",
8657 ] {
8658 assert_without_credential(
8659 RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
8660 .unwrap_err(),
8661 "https://api.tavily.com",
8662 );
8663 }
8664 let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
8665 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
8666 ))
8667 .unwrap();
8668 let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
8669 assert_eq!(gateway.api_key, None, "the key reference is left unread");
8670 assert!(!gateway.keyless, "a keyed gateway is never keyless");
8671 }
8672
8673 #[test]
8676 fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
8677 for endpoint in [
8678 "http://127.0.0.1:11434",
8679 "http://localhost:11434",
8680 "http://[::1]:11434",
8681 "https://10.0.0.5",
8682 "https://192.168.1.10:8443",
8683 ] {
8684 assert_without_credential(
8685 gateway_credential_error(&format!("\n endpoint: {endpoint}")),
8686 endpoint,
8687 );
8688 }
8689 }
8690
8691 #[test]
8692 fn gateway_endpoint_missing_from_the_block_is_refused() {
8693 for block in [
8694 "\n api_key: ${TAVILY_API_KEY}",
8695 "",
8696 " {}",
8697 "\n endpoint: \" \"",
8698 ] {
8699 let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
8700 .unwrap_err()
8701 .to_string();
8702 assert!(
8703 msg.contains("without 'gateway.endpoint'"),
8704 "{block:?}: {msg}"
8705 );
8706 assert!(
8707 msg.contains("artifact 'local/web-search'"),
8708 "{block:?}: {msg}"
8709 );
8710 }
8711 }
8712
8713 #[test]
8714 fn gateway_block_unknown_key_is_reported() {
8715 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8716 "\n endpoint: https://api.tavily.com\n keyless: true\n api_kee: x",
8717 ))
8718 .unwrap();
8719 let unknown = manifest.unknown_keys;
8720 assert_eq!(unknown.len(), 1, "{unknown:?}");
8721 assert_eq!(unknown[0].key, "api_kee");
8722 assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
8723 }
8724
8725 #[test]
8726 fn gateway_on_skill_entry_is_refused() {
8727 let msg = RuntimeManifest::from_yaml_str(
8728 "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",
8729 )
8730 .unwrap_err()
8731 .to_string();
8732 assert!(
8733 msg.contains("artifact 'local/notes' declares 'gateway:'"),
8734 "{msg}"
8735 );
8736 assert!(msg.contains("runtime: skill"), "{msg}");
8737 }
8738
8739 #[test]
8740 fn gateway_on_hook_entry_is_accepted() {
8741 let manifest = RuntimeManifest::from_yaml_str(
8742 "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",
8743 )
8744 .unwrap();
8745 assert!(manifest.artifacts[0].gateway.is_some());
8746 }
8747
8748 fn http_driver_manifest(artifacts: &str) -> String {
8751 format!(
8752 "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur/driver-anthropic\n"
8753 )
8754 }
8755
8756 #[test]
8757 fn http_driver_entry_without_gateway_endpoint_is_refused() {
8758 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
8759 "\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver",
8760 ))
8761 .unwrap_err()
8762 .to_string();
8763 assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
8764 assert!(msg.contains("gateway.endpoint"), "{msg}");
8765 }
8766
8767 #[test]
8768 fn http_driver_absent_from_artifacts_is_refused() {
8769 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
8770 .unwrap_err()
8771 .to_string();
8772 assert!(msg.contains("inference.driver.artifact"), "{msg}");
8773 assert!(msg.contains("not declared in artifacts:"), "{msg}");
8774 }
8775
8776 #[test]
8777 fn gateway_on_unconfigured_driver_entry_is_refused() {
8778 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
8779 "\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",
8780 ))
8781 .unwrap_err()
8782 .to_string();
8783 assert!(msg.contains("'murmur/driver-openai'"), "{msg}");
8784 assert!(
8785 msg.contains("accepted only on the entry inference.driver.artifact names"),
8786 "{msg}"
8787 );
8788
8789 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";
8790 let msg = RuntimeManifest::from_yaml_str(no_inference)
8791 .unwrap_err()
8792 .to_string();
8793 assert!(msg.contains("not the configured inference driver"), "{msg}");
8794 }
8795
8796 #[test]
8797 fn gateway_on_driver_entry_under_process_transport_is_refused() {
8798 let msg = RuntimeManifest::from_yaml_str(
8799 "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",
8800 )
8801 .unwrap_err()
8802 .to_string();
8803 assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
8804 assert!(msg.contains("transport: process"), "{msg}");
8805 }
8806
8807 #[test]
8808 fn inference_api_key_is_refused_naming_gateway_api_key() {
8809 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";
8810 for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
8811 let manifest = http_driver_manifest(entry)
8812 .replace(" model:", &format!(" api_key: {value}\n model:"));
8813 let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
8814 let msg = err.to_string();
8815 assert!(msg.contains("'inference.api_key'"), "{msg}");
8816 assert!(msg.contains("was removed"), "{msg}");
8817 assert!(
8818 msg.contains(
8819 "set gateway.api_key on the artifacts: entry 'murmur/driver-anthropic'"
8820 ),
8821 "{msg}"
8822 );
8823 assert!(!msg.contains("sk-literal"), "{msg}");
8824 }
8825
8826 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n api_key: ${KEY}\n";
8827 let msg = RuntimeManifest::from_yaml_str(process)
8828 .unwrap_err()
8829 .to_string();
8830 assert!(msg.contains("'inference.api_key'"), "{msg}");
8831 assert!(
8832 msg.contains("on the artifacts: entry inference.driver.artifact names"),
8833 "{msg}"
8834 );
8835 assert!(
8836 RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
8837 "the refusal does not depend on resolving secrets"
8838 );
8839 }
8840
8841 #[test]
8842 fn inference_endpoint_is_refused_naming_gateway_endpoint() {
8843 let manifest = http_driver_manifest(
8844 "\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",
8845 )
8846 .replace(" model:", " endpoint: https://api.anthropic.com\n model:");
8847 let msg = RuntimeManifest::from_yaml_str(&manifest)
8848 .unwrap_err()
8849 .to_string();
8850 assert!(msg.contains("'inference.endpoint'"), "{msg}");
8851 assert!(
8852 msg.contains("set gateway.endpoint on the artifacts: entry 'murmur/driver-anthropic'"),
8853 "{msg}"
8854 );
8855
8856 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n endpoint: http://localhost:8080\n";
8857 let msg = RuntimeManifest::from_yaml_str(process)
8858 .unwrap_err()
8859 .to_string();
8860 assert!(msg.contains("'inference.endpoint'"), "{msg}");
8861 assert!(msg.contains("was removed"), "{msg}");
8862 }
8863
8864 #[test]
8865 fn without_secrets_leaves_gateway_api_key_unread() {
8866 let manifest = tool_gateway_manifest(
8867 "\n endpoint: https://api.tavily.com\n api_key: tvly-literal",
8868 );
8869 let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
8870 assert_eq!(
8871 resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
8872 Some(ApiKeyReference::Literal("tvly-literal".to_string()))
8873 );
8874 let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
8875 let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
8876 assert_eq!(gateway.endpoint, "https://api.tavily.com");
8877 assert_eq!(gateway.api_key, None);
8878 }
8879
8880 #[test]
8881 fn referenced_env_variables_reads_gateway_api_key() {
8882 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";
8883 let referenced = referenced_env_variables(manifest);
8884 assert_eq!(
8885 referenced,
8886 vec![
8887 ReferencedEnvVariable {
8888 field: "artifacts.murmur/driver-anthropic.gateway.api_key".to_string(),
8889 variable: "ANTHROPIC_API_KEY".to_string(),
8890 },
8891 ReferencedEnvVariable {
8892 field: "artifacts.local/web-search.gateway.api_key".to_string(),
8893 variable: "TAVILY_API_KEY".to_string(),
8894 },
8895 ]
8896 );
8897 assert!(referenced
8898 .iter()
8899 .all(ReferencedEnvVariable::is_gateway_credential));
8900 }
8901
8902 #[test]
8903 fn parses_inline_system_prompt() {
8904 let manifest = RuntimeManifest::from_yaml_str(
8905 r#"
8906name: cap
8907version: 0.0.1
8908artifacts:
8909 - name: murmur/driver-anthropic
8910 version: 0.1.0
8911 runtime: driver
8912 gateway:
8913 endpoint: http://127.0.0.1:8080
8914 keyless: true
8915inference:
8916 model: test-model
8917 system_prompt: "Always begin with CONFIRMED:"
8918 driver:
8919 artifact: murmur/driver-anthropic
8920"#,
8921 )
8922 .unwrap();
8923
8924 let inference = manifest.inference.unwrap();
8925 assert_eq!(
8926 inference.system_prompt,
8927 Some("Always begin with CONFIRMED:".to_string())
8928 );
8929 assert!(inference.system_prompt_file.is_none());
8930 }
8931
8932 #[test]
8933 fn parses_compaction_system_prompt() {
8934 let manifest = RuntimeManifest::from_yaml_str(
8935 r#"
8936name: cap
8937version: 0.0.1
8938artifacts:
8939 - name: murmur/driver-anthropic
8940 version: 0.1.0
8941 runtime: driver
8942 gateway:
8943 endpoint: http://127.0.0.1:8080
8944 keyless: true
8945inference:
8946 model: test-model
8947 compaction:
8948 model: compaction-model
8949 system_prompt: "task = X, currently editing Y, already tried Z."
8950 driver:
8951 artifact: murmur/driver-anthropic
8952"#,
8953 )
8954 .unwrap();
8955
8956 let compaction = manifest.inference.unwrap().compaction.unwrap();
8957 assert_eq!(
8958 compaction.system_prompt,
8959 Some("task = X, currently editing Y, already tried Z.".to_string())
8960 );
8961 assert_eq!(compaction.model, Some("compaction-model".to_string()));
8962 }
8963
8964 #[test]
8967 fn compaction_system_prompt_absent_stays_none() {
8968 let manifest = RuntimeManifest::from_yaml_str(
8969 r#"
8970name: cap
8971version: 0.0.1
8972artifacts:
8973 - name: murmur/driver-anthropic
8974 version: 0.1.0
8975 runtime: driver
8976 gateway:
8977 endpoint: http://127.0.0.1:8080
8978 keyless: true
8979inference:
8980 model: test-model
8981 compaction:
8982 model: compaction-model
8983 driver:
8984 artifact: murmur/driver-anthropic
8985"#,
8986 )
8987 .unwrap();
8988
8989 let compaction = manifest.inference.unwrap().compaction.unwrap();
8990 assert!(compaction.system_prompt.is_none());
8991 assert!(compaction.system_prompt_file.is_none());
8992 assert_eq!(compaction.model, Some("compaction-model".to_string()));
8993 }
8994
8995 #[test]
8999 fn parses_compaction_system_prompt_file() {
9000 let manifest = RuntimeManifest::from_yaml_str(
9001 r#"
9002name: cap
9003version: 0.0.1
9004artifacts:
9005 - name: murmur/driver-anthropic
9006 version: 0.1.0
9007 runtime: driver
9008 gateway:
9009 endpoint: http://127.0.0.1:8080
9010 keyless: true
9011inference:
9012 model: test-model
9013 compaction:
9014 model: compaction-model
9015 system_prompt_file: "compaction-instructions.md"
9016 driver:
9017 artifact: murmur/driver-anthropic
9018"#,
9019 )
9020 .unwrap();
9021
9022 let compaction = manifest.inference.unwrap().compaction.unwrap();
9023 assert_eq!(
9024 compaction.system_prompt_file,
9025 Some("compaction-instructions.md".to_string())
9026 );
9027 assert!(compaction.system_prompt.is_none());
9028 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9029 }
9030
9031 #[test]
9034 fn compaction_system_prompt_file_independent_of_model() {
9035 let manifest = RuntimeManifest::from_yaml_str(
9036 r#"
9037name: cap
9038version: 0.0.1
9039artifacts:
9040 - name: murmur/driver-anthropic
9041 version: 0.1.0
9042 runtime: driver
9043 gateway:
9044 endpoint: http://127.0.0.1:8080
9045 keyless: true
9046inference:
9047 model: test-model
9048 compaction:
9049 threshold: 0.5
9050 system_prompt_file: "compaction-instructions.md"
9051 driver:
9052 artifact: murmur/driver-anthropic
9053"#,
9054 )
9055 .unwrap();
9056
9057 let compaction = manifest.inference.unwrap().compaction.unwrap();
9058 assert_eq!(
9059 compaction.system_prompt_file,
9060 Some("compaction-instructions.md".to_string())
9061 );
9062 assert!(compaction.model.is_none());
9063 assert_eq!(compaction.threshold, Some(0.5));
9064 }
9065
9066 #[test]
9070 fn parses_compaction_dump_summaries() {
9071 let parse = |line: &str| {
9072 RuntimeManifest::from_yaml_str(&format!(
9073 r#"
9074name: cap
9075version: 0.0.1
9076artifacts:
9077 - name: murmur/driver-anthropic
9078 version: 0.1.0
9079 runtime: driver
9080 gateway:
9081 endpoint: http://127.0.0.1:8080
9082 keyless: true
9083inference:
9084 model: test-model
9085 compaction:
9086 model: compaction-model
9087{line} driver:
9088 artifact: murmur/driver-anthropic
9089"#
9090 ))
9091 .unwrap()
9092 .inference
9093 .unwrap()
9094 .compaction
9095 .unwrap()
9096 };
9097
9098 assert_eq!(
9099 parse(" dump_summaries: true\n").dump_summaries,
9100 Some(true)
9101 );
9102 assert_eq!(
9103 parse(" dump_summaries: false\n").dump_summaries,
9104 Some(false)
9105 );
9106 assert_eq!(parse("").dump_summaries, None);
9107 }
9108
9109 #[test]
9113 fn compaction_dump_summaries_does_not_interact_with_other_fields() {
9114 let compaction = RuntimeManifest::from_yaml_str(
9115 r#"
9116name: cap
9117version: 0.0.1
9118artifacts:
9119 - name: murmur/driver-anthropic
9120 version: 0.1.0
9121 runtime: driver
9122 gateway:
9123 endpoint: http://127.0.0.1:8080
9124 keyless: true
9125inference:
9126 model: test-model
9127 compaction:
9128 threshold: 0.5
9129 model: compaction-model
9130 system_prompt_file: "compaction-instructions.md"
9131 dump_summaries: true
9132 driver:
9133 artifact: murmur/driver-anthropic
9134"#,
9135 )
9136 .unwrap()
9137 .inference
9138 .unwrap()
9139 .compaction
9140 .unwrap();
9141
9142 assert_eq!(compaction.dump_summaries, Some(true));
9143 assert_eq!(compaction.threshold, Some(0.5));
9144 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9145 assert_eq!(
9146 compaction.system_prompt_file,
9147 Some("compaction-instructions.md".to_string())
9148 );
9149 assert!(compaction.system_prompt.is_none());
9150
9151 let err = RuntimeManifest::from_yaml_str(
9153 r#"
9154name: cap
9155version: 0.0.1
9156artifacts:
9157 - name: murmur/driver-anthropic
9158 version: 0.1.0
9159 runtime: driver
9160 gateway:
9161 endpoint: http://127.0.0.1:8080
9162 keyless: true
9163inference:
9164 model: test-model
9165 compaction:
9166 dump_summaries: true
9167 system_prompt: "inline"
9168 system_prompt_file: "compaction-instructions.md"
9169 driver:
9170 artifact: murmur/driver-anthropic
9171"#,
9172 )
9173 .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
9174 assert!(matches!(
9175 err,
9176 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9177 if field == "inference.compaction.system_prompt"
9178 ));
9179
9180 let err = RuntimeManifest::from_yaml_str(
9182 r#"
9183name: cap
9184version: 0.0.1
9185artifacts:
9186 - name: murmur/driver-anthropic
9187 version: 0.1.0
9188 runtime: driver
9189 gateway:
9190 endpoint: http://127.0.0.1:8080
9191 keyless: true
9192inference:
9193 model: test-model
9194 compaction:
9195 dump_summaries: true
9196 threshold: 1.5
9197 driver:
9198 artifact: murmur/driver-anthropic
9199"#,
9200 )
9201 .expect_err("dump_summaries must not suppress the threshold range check");
9202 assert!(matches!(
9203 err,
9204 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9205 if field == "inference.compaction.threshold"
9206 ));
9207 }
9208
9209 #[test]
9213 fn compaction_prompt_sources_are_mutually_exclusive() {
9214 let err = RuntimeManifest::from_yaml_str(
9215 r#"
9216name: cap
9217version: 0.0.1
9218artifacts:
9219 - name: murmur/driver-anthropic
9220 version: 0.1.0
9221 runtime: driver
9222 gateway:
9223 endpoint: http://127.0.0.1:8080
9224 keyless: true
9225inference:
9226 model: test-model
9227 compaction:
9228 system_prompt: "inline"
9229 system_prompt_file: "compaction-instructions.md"
9230 driver:
9231 artifact: murmur/driver-anthropic
9232"#,
9233 )
9234 .expect_err("both compaction prompt sources set must fail");
9235
9236 match err {
9237 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9238 assert_eq!(field, "inference.compaction.system_prompt");
9239 assert_eq!(
9240 message,
9241 "at most one of inference.compaction.system_prompt, \
9242 inference.compaction.system_prompt_file may be set"
9243 );
9244 }
9245 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
9246 }
9247 }
9248
9249 #[test]
9253 fn top_level_and_compaction_prompt_sources_do_not_collide() {
9254 let manifest = RuntimeManifest::from_yaml_str(
9255 r#"
9256name: cap
9257version: 0.0.1
9258artifacts:
9259 - name: murmur/driver-anthropic
9260 version: 0.1.0
9261 runtime: driver
9262 gateway:
9263 endpoint: http://127.0.0.1:8080
9264 keyless: true
9265inference:
9266 model: test-model
9267 system_prompt: "be helpful"
9268 compaction:
9269 system_prompt_file: "compaction-instructions.md"
9270 driver:
9271 artifact: murmur/driver-anthropic
9272"#,
9273 )
9274 .unwrap();
9275
9276 let inference = manifest.inference.unwrap();
9277 assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
9278 assert_eq!(
9279 inference.compaction.unwrap().system_prompt_file,
9280 Some("compaction-instructions.md".to_string())
9281 );
9282 }
9283
9284 #[test]
9285 fn parses_system_prompt_file() {
9286 let manifest = RuntimeManifest::from_yaml_str(
9287 r#"
9288name: cap
9289version: 0.0.1
9290artifacts:
9291 - name: murmur/driver-anthropic
9292 version: 0.1.0
9293 runtime: driver
9294 gateway:
9295 endpoint: http://127.0.0.1:8080
9296 keyless: true
9297inference:
9298 model: test-model
9299 system_prompt_file: "conventions.md"
9300 driver:
9301 artifact: murmur/driver-anthropic
9302"#,
9303 )
9304 .unwrap();
9305
9306 let inference = manifest.inference.unwrap();
9307 assert_eq!(
9308 inference.system_prompt_file,
9309 Some("conventions.md".to_string())
9310 );
9311 assert!(inference.system_prompt.is_none());
9312 }
9313
9314 #[test]
9315 fn rejects_both_inline_and_file_system_prompt() {
9316 let err = RuntimeManifest::from_yaml_str(
9317 r#"
9318name: cap
9319version: 0.0.1
9320artifacts:
9321 - name: murmur/driver-anthropic
9322 version: 0.1.0
9323 runtime: driver
9324 gateway:
9325 endpoint: http://127.0.0.1:8080
9326 keyless: true
9327inference:
9328 model: test-model
9329 system_prompt: "inline"
9330 system_prompt_file: "conventions.md"
9331 driver:
9332 artifact: murmur/driver-anthropic
9333"#,
9334 )
9335 .unwrap_err();
9336
9337 let msg = err.to_string();
9338 assert!(msg.contains("inference.system_prompt"));
9339 assert!(msg.contains("system_prompt_file"));
9340 }
9341
9342 #[test]
9345 fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
9346 let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
9347 let yaml = format!(
9348 "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"
9349 );
9350
9351 assert_eq!(
9352 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
9353 .gateway
9354 .clone()
9355 .unwrap()
9356 .api_key,
9357 Some(ApiKeyReference::Environment(key.to_string()))
9358 );
9359 assert_eq!(
9360 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
9361 .unwrap()
9362 .artifacts[0]
9363 .gateway
9364 .clone()
9365 .unwrap()
9366 .api_key,
9367 None
9368 );
9369 }
9370
9371 #[test]
9372 fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
9373 let literal = ["sk-", "ant-", "debug"].concat();
9374 let debug = format!(
9375 "{:?} {:?}",
9376 ApiKeyReference::Environment("SOME_KEY".to_string()),
9377 ApiKeyReference::Literal(literal.clone())
9378 );
9379 assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
9380 assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
9381 assert!(!debug.contains(&literal), "{debug}");
9382 }
9383
9384 #[test]
9385 fn parse_without_secrets_drops_a_literal_api_key() {
9386 let literal = ["sk-", "ant-", "literal"].concat();
9389 let yaml = format!(
9390 "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"
9391 );
9392
9393 assert_eq!(
9394 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
9395 .gateway
9396 .clone()
9397 .unwrap()
9398 .api_key,
9399 Some(ApiKeyReference::Literal(literal))
9400 );
9401 assert_eq!(
9402 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
9403 .unwrap()
9404 .artifacts[0]
9405 .gateway
9406 .clone()
9407 .unwrap()
9408 .api_key,
9409 None
9410 );
9411 }
9412
9413 #[test]
9416 fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
9417 let process_with_key = format!(
9418 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n model: test-model\n api_key: {}\n",
9419 ["sk-", "ant-", "secret"].concat()
9420 );
9421 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";
9422
9423 for yaml in [process_with_key.as_str(), http_without_model] {
9424 let resolving = RuntimeManifest::from_yaml_str(yaml)
9425 .unwrap_err()
9426 .to_string();
9427 let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
9428 .unwrap_err()
9429 .to_string();
9430 assert_eq!(resolving, without_secrets);
9431 }
9432 }
9433
9434 #[test]
9438 fn referenced_env_variables_reports_a_reference_and_nothing_else() {
9439 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";
9440 let referenced = referenced_env_variables(referencing);
9441 assert_eq!(referenced.len(), 1);
9442 assert_eq!(
9443 referenced[0].field,
9444 "artifacts.murmur/driver-anthropic.gateway.api_key"
9445 );
9446 assert!(referenced[0].is_gateway_credential());
9447 assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
9448
9449 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";
9450 assert!(referenced_env_variables(literal).is_empty());
9451
9452 let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
9453 assert!(referenced_env_variables(no_inference).is_empty());
9454
9455 let unparseable = "name: broken\nversion: [\n";
9456 assert!(referenced_env_variables(unparseable).is_empty());
9457 }
9458
9459 #[test]
9460 fn inference_requires_driver_artifact() {
9461 let err = RuntimeManifest::from_yaml_str(
9462 r#"
9463name: cap
9464version: 0.0.1
9465artifacts: []
9466inference:
9467 transport: http
9468 endpoint: http://127.0.0.1:8080
9469 model: test-model
9470"#,
9471 )
9472 .unwrap_err();
9473
9474 assert!(err.to_string().contains("inference.driver"));
9475 }
9476
9477 #[test]
9478 fn test_runtime_driver_deserializes() {
9479 let manifest = RuntimeManifest::from_yaml_str(
9480 r#"
9481name: cap
9482version: 0.0.1
9483artifacts:
9484 - name: local/my-provider
9485 version: 1.0.0
9486 runtime: driver
9487"#,
9488 )
9489 .unwrap();
9490
9491 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
9492 }
9493
9494 #[test]
9495 fn test_runtime_unknown_is_error() {
9496 let err = RuntimeManifest::from_yaml_str(
9497 r#"
9498name: cap
9499version: 0.0.1
9500artifacts:
9501 - name: local/foo
9502 version: 1.0.0
9503 runtime: unknown_type
9504"#,
9505 )
9506 .unwrap_err();
9507
9508 let msg = err.to_string();
9509 assert!(msg.contains("runtime"), "error should mention 'runtime'");
9510 assert!(
9511 msg.contains("unknown_type"),
9512 "error should identify the bad value"
9513 );
9514 }
9515
9516 #[test]
9517 fn test_runtime_all_variants_roundtrip() {
9518 for variant in [
9519 ArtifactRuntime::Tool,
9520 ArtifactRuntime::Driver,
9521 ArtifactRuntime::Hook,
9522 ArtifactRuntime::Skill,
9523 ] {
9524 let json = serde_json::to_string(&variant).unwrap();
9525 let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
9526 assert_eq!(variant, back);
9527 }
9528 }
9529
9530 #[test]
9531 fn skill_source_without_version_defaults_to_local() {
9532 let manifest = RuntimeManifest::from_yaml_str(
9533 r#"
9534name: cap
9535version: 0.0.1
9536artifacts:
9537 - name: my-skill
9538 source: ./skills/my-skill/skill.md
9539 runtime: skill
9540"#,
9541 )
9542 .unwrap();
9543
9544 assert_eq!(manifest.artifacts.len(), 1);
9545 let artifact = &manifest.artifacts[0];
9546 assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
9547 assert_eq!(
9548 artifact.source.as_deref(),
9549 Some("./skills/my-skill/skill.md")
9550 );
9551 assert_eq!(artifact.version, "local");
9552 }
9553
9554 #[test]
9555 fn skill_source_directory_path_is_accepted() {
9556 let manifest = RuntimeManifest::from_yaml_str(
9557 r#"
9558name: cap
9559version: 0.0.1
9560artifacts:
9561 - name: my-skill
9562 source: ./skills/my-skill/
9563 runtime: skill
9564"#,
9565 )
9566 .unwrap();
9567
9568 assert_eq!(
9569 manifest.artifacts[0].source.as_deref(),
9570 Some("./skills/my-skill/")
9571 );
9572 assert_eq!(manifest.artifacts[0].version, "local");
9573 }
9574
9575 #[test]
9576 fn skill_source_with_explicit_version_substitutes_local() {
9577 let manifest = RuntimeManifest::from_yaml_str(
9579 r#"
9580name: cap
9581version: 0.0.1
9582artifacts:
9583 - name: my-skill
9584 source: ./skills/my-skill/skill.md
9585 version: 9.9.9
9586 runtime: skill
9587"#,
9588 )
9589 .unwrap();
9590
9591 assert_eq!(manifest.artifacts[0].version, "local");
9592 }
9593
9594 #[test]
9595 fn local_source_true_allows_source_on_non_skill_runtime() {
9596 for runtime in ["tool", "driver", "hook"] {
9597 let yaml = format!(
9598 r#"
9599name: cap
9600version: 0.0.1
9601artifacts:
9602 - name: my-thing
9603 source: ./local/path
9604 local_source: true
9605 runtime: {runtime}
9606"#
9607 );
9608 let manifest = RuntimeManifest::from_yaml_str(&yaml)
9609 .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
9610 let artifact = &manifest.artifacts[0];
9611 assert!(artifact.local_source, "runtime {runtime}");
9612 assert_eq!(artifact.source.as_deref(), Some("./local/path"));
9613 assert_eq!(artifact.version, "local", "runtime {runtime}");
9614 }
9615 }
9616
9617 #[test]
9618 fn local_source_false_on_skill_with_source_is_error() {
9619 let err = RuntimeManifest::from_yaml_str(
9622 r#"
9623name: cap
9624version: 0.0.1
9625artifacts:
9626 - name: my-skill
9627 source: ./skills/my-skill
9628 local_source: false
9629 runtime: skill
9630"#,
9631 )
9632 .unwrap_err();
9633 let msg = err.to_string();
9634 assert!(msg.contains("'local_source: true'"), "error was: {msg}");
9635 assert!(msg.contains("my-skill"), "error was: {msg}");
9636 }
9637
9638 #[test]
9639 fn local_source_defaults_from_role_when_absent() {
9640 let manifest = RuntimeManifest::from_yaml_str(
9641 r#"
9642name: cap
9643version: 0.0.1
9644artifacts:
9645 - name: my-skill
9646 source: ./skills/my-skill
9647 runtime: skill
9648 - name: local/my-tool
9649 version: 1.0.0
9650 runtime: tool
9651"#,
9652 )
9653 .unwrap();
9654 assert!(manifest.artifacts[0].local_source, "skill defaults to true");
9655 assert!(
9656 !manifest.artifacts[1].local_source,
9657 "tool defaults to false"
9658 );
9659 }
9660
9661 #[test]
9662 fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
9663 let manifest = RuntimeManifest::from_yaml_str(
9664 r#"
9665name: cap
9666version: 0.0.1
9667artifacts:
9668 - name: local/my-tool
9669 version: 1.0.0
9670 runtime: tool
9671 prompt_payload: true
9672 - name: murmur/driver-anthropic
9673 version: 0.1.0
9674 runtime: driver
9675 gateway:
9676 endpoint: http://127.0.0.1:8080
9677 keyless: true
9678inference:
9679 model: test-model
9680 system_prompt_artifact: local/my-tool
9681 driver:
9682 artifact: murmur/driver-anthropic
9683"#,
9684 )
9685 .unwrap();
9686 assert!(manifest.artifacts[0].prompt_payload);
9687 }
9688
9689 #[test]
9690 fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
9691 let err = RuntimeManifest::from_yaml_str(
9692 r#"
9693name: cap
9694version: 0.0.1
9695artifacts:
9696 - name: local/my-skill
9697 version: 1.0.0
9698 runtime: skill
9699 prompt_payload: false
9700 - name: murmur/driver-anthropic
9701 version: 0.1.0
9702 runtime: driver
9703 gateway:
9704 endpoint: http://127.0.0.1:8080
9705 keyless: true
9706inference:
9707 model: test-model
9708 system_prompt_artifact: local/my-skill
9709 driver:
9710 artifact: murmur/driver-anthropic
9711"#,
9712 )
9713 .unwrap_err();
9714 let msg = err.to_string();
9715 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
9716 }
9717
9718 #[test]
9719 fn prompt_payload_defaults_from_role_when_absent() {
9720 let manifest = RuntimeManifest::from_yaml_str(
9721 r#"
9722name: cap
9723version: 0.0.1
9724artifacts:
9725 - name: local/my-skill
9726 version: 1.0.0
9727 runtime: skill
9728 - name: local/my-tool
9729 version: 1.0.0
9730 runtime: tool
9731"#,
9732 )
9733 .unwrap();
9734 assert!(
9735 manifest.artifacts[0].prompt_payload,
9736 "skill defaults to true"
9737 );
9738 assert!(
9739 !manifest.artifacts[1].prompt_payload,
9740 "tool defaults to false"
9741 );
9742 }
9743
9744 #[test]
9745 fn source_on_non_skill_runtime_is_error() {
9746 for runtime in ["tool", "driver", "hook"] {
9747 let yaml = format!(
9748 r#"
9749name: cap
9750version: 0.0.1
9751artifacts:
9752 - name: my-thing
9753 source: ./local/path
9754 runtime: {runtime}
9755"#
9756 );
9757 let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
9758 let msg = err.to_string();
9759 assert!(
9760 msg.contains("'local_source: true'"),
9761 "runtime {runtime}: error was: {msg}"
9762 );
9763 assert!(
9764 msg.contains("my-thing"),
9765 "runtime {runtime}: error was: {msg}"
9766 );
9767 }
9768 }
9769
9770 #[test]
9771 fn registry_skill_still_requires_version() {
9772 let err = RuntimeManifest::from_yaml_str(
9773 r#"
9774name: cap
9775version: 0.0.1
9776artifacts:
9777 - name: local/my-skill
9778 runtime: skill
9779"#,
9780 )
9781 .unwrap_err();
9782
9783 let msg = err.to_string();
9784 assert!(
9785 msg.contains("missing required field 'version'"),
9786 "error was: {msg}"
9787 );
9788 }
9789
9790 #[test]
9791 fn rejects_unknown_inference_transport() {
9792 let err = RuntimeManifest::from_yaml_str(
9793 r#"
9794name: cap
9795version: 0.0.1
9796artifacts: []
9797inference:
9798 transport: grpc
9799 model: test-model
9800 driver:
9801 artifact: murmur/driver-anthropic
9802"#,
9803 )
9804 .unwrap_err();
9805
9806 let msg = err.to_string();
9807 assert!(msg.contains("inference.transport"), "error was: {msg}");
9808 assert!(msg.contains("unknown value"), "error was: {msg}");
9809 assert!(msg.contains("grpc"), "error was: {msg}");
9810 }
9811
9812 #[test]
9815 fn process_transport_with_command_and_no_driver_is_refused() {
9816 let err = RuntimeManifest::from_yaml_str(
9817 r#"
9818name: cap
9819version: 0.0.1
9820artifacts: []
9821inference:
9822 transport: process
9823 command: some-cli
9824 model: some-model
9825 max_turns: 20
9826"#,
9827 )
9828 .unwrap_err();
9829
9830 let msg = err.to_string();
9831 assert!(
9832 msg.contains("inference.driver.artifact"),
9833 "error was: {msg}"
9834 );
9835 assert!(msg.contains("inference.command"), "error was: {msg}");
9836 assert!(
9837 msg.contains("murmur/driver-claude-code"),
9838 "error was: {msg}"
9839 );
9840 }
9841
9842 #[test]
9843 fn process_transport_with_neither_driver_nor_command_is_refused() {
9844 let err = RuntimeManifest::from_yaml_str(
9845 r#"
9846name: cap
9847version: 0.0.1
9848artifacts: []
9849inference:
9850 transport: process
9851 model: some-model
9852"#,
9853 )
9854 .unwrap_err();
9855
9856 let msg = err.to_string();
9857 assert!(
9858 msg.contains("inference.driver.artifact"),
9859 "error was: {msg}"
9860 );
9861 assert!(
9862 msg.contains("murmur/driver-claude-code"),
9863 "error was: {msg}"
9864 );
9865 }
9866
9867 #[test]
9868 fn process_transport_allows_absent_model() {
9869 let manifest =
9872 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
9873 .expect("process transport should parse without a model");
9874 let inference = manifest.inference.expect("inference present");
9875 assert_eq!(inference.transport, "process");
9876 assert!(inference.command.is_none());
9877 assert_eq!(
9878 inference.model, "",
9879 "absent model resolves to empty (harness default)"
9880 );
9881 }
9882
9883 fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
9884 format!(
9885 "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n transport: process\n \
9886 driver:\n artifact: local/fixture-process-driver\n{inference_extra}"
9887 )
9888 }
9889
9890 const PROCESS_DRIVER_ENTRY: &str =
9891 " - name: local/fixture-process-driver\n version: 0.1.0\n runtime: driver";
9892
9893 #[test]
9894 fn process_transport_accepts_driver() {
9895 let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9896 PROCESS_DRIVER_ENTRY,
9897 " config:\n profile: work\n command: /usr/local/bin/fixture-cli\n",
9898 ))
9899 .unwrap();
9900
9901 let inference = manifest.inference.expect("inference should exist");
9902 assert_eq!(inference.transport, "process");
9903 let driver = inference.driver.expect("driver should be parsed");
9904 assert_eq!(driver.artifact, "local/fixture-process-driver");
9905 assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
9906 assert_eq!(
9907 inference.command.as_deref(),
9908 Some("/usr/local/bin/fixture-cli")
9909 );
9910 }
9911
9912 #[test]
9913 fn process_transport_accepts_driver_without_command() {
9914 let manifest =
9915 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
9916 .unwrap();
9917
9918 let inference = manifest.inference.expect("inference should exist");
9919 assert_eq!(
9920 inference.driver.map(|d| d.artifact).as_deref(),
9921 Some("local/fixture-process-driver")
9922 );
9923 assert!(inference.command.is_none());
9924 assert_eq!(inference.model, "");
9925 }
9926
9927 #[test]
9928 fn process_driver_entry_must_be_declared() {
9929 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9930 " - name: local/other\n version: 0.1.0\n runtime: tool",
9931 "",
9932 ))
9933 .unwrap_err()
9934 .to_string();
9935 assert!(msg.contains("inference.driver.artifact"), "{msg}");
9936 assert!(
9937 msg.contains("artifact 'local/fixture-process-driver' is not declared in artifacts:"),
9938 "{msg}"
9939 );
9940 }
9941
9942 #[test]
9943 fn process_driver_entry_must_be_runtime_driver() {
9944 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9945 " - name: local/fixture-process-driver\n version: 0.1.0\n runtime: tool",
9946 "",
9947 ))
9948 .unwrap_err()
9949 .to_string();
9950 assert!(
9951 msg.contains("must be declared with runtime: driver"),
9952 "{msg}"
9953 );
9954 }
9955
9956 #[test]
9957 fn process_driver_entry_refuses_capabilities() {
9958 let entry =
9959 format!("{PROCESS_DRIVER_ENTRY}\n capabilities:\n env:\n allow: [HOME]");
9960 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
9961 .unwrap_err()
9962 .to_string();
9963 assert!(msg.contains("granted nothing"), "{msg}");
9964 assert!(msg.contains("inference.driver.config"), "{msg}");
9965 }
9966
9967 #[test]
9968 fn process_driver_entry_refuses_config() {
9969 let entry = format!("{PROCESS_DRIVER_ENTRY}\n config:\n profile: work");
9970 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
9971 .unwrap_err()
9972 .to_string();
9973 assert!(msg.contains("granted nothing"), "{msg}");
9974 assert!(msg.contains("remove capabilities:/config:"), "{msg}");
9975 }
9976
9977 #[test]
9978 fn process_transport_rejects_endpoint() {
9979 let err = RuntimeManifest::from_yaml_str(
9980 r#"
9981name: cap
9982version: 0.0.1
9983artifacts: []
9984inference:
9985 transport: process
9986 command: claude
9987 model: claude-haiku-4-5-20251001
9988 endpoint: http://localhost:8080
9989"#,
9990 )
9991 .unwrap_err();
9992
9993 let msg = err.to_string();
9994 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
9995 assert!(msg.contains("was removed"), "error was: {msg}");
9996 }
9997
9998 #[test]
9999 fn process_transport_rejects_api_key() {
10000 let key = ["sk-", "ant-", "secret"].concat();
10003 let err = RuntimeManifest::from_yaml_str(&format!(
10004 r#"
10005name: cap
10006version: 0.0.1
10007artifacts: []
10008inference:
10009 transport: process
10010 command: claude
10011 model: claude-haiku-4-5-20251001
10012 api_key: {key}
10013"#
10014 ))
10015 .unwrap_err();
10016
10017 let msg = err.to_string();
10018 assert!(msg.contains("inference.api_key"), "error was: {msg}");
10019 assert!(msg.contains("was removed"), "error was: {msg}");
10020 assert!(!msg.contains(&key), "error was: {msg}");
10021 }
10022
10023 #[test]
10024 fn process_transport_rejects_max_tokens() {
10025 let err = RuntimeManifest::from_yaml_str(
10028 r#"
10029name: cap
10030version: 0.0.1
10031artifacts: []
10032inference:
10033 transport: process
10034 command: claude
10035 model: claude-haiku-4-5-20251001
10036 max_tokens: 4096
10037"#,
10038 )
10039 .unwrap_err();
10040
10041 let msg = err.to_string();
10042 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
10043 assert!(
10044 msg.contains("not valid with transport: process"),
10045 "error was: {msg}"
10046 );
10047 }
10048
10049 #[test]
10053 fn process_transport_carries_max_session_tokens() {
10054 let manifest = RuntimeManifest::from_yaml_str(
10055 r#"
10056name: cap
10057version: 0.0.1
10058artifacts:
10059 - name: murmur/driver-claude-code
10060 version: 0.1.0
10061 runtime: driver
10062inference:
10063 transport: process
10064 driver:
10065 artifact: murmur/driver-claude-code
10066 model: claude-haiku-4-5-20251001
10067 max_session_tokens: 200000
10068"#,
10069 )
10070 .unwrap();
10071
10072 assert_eq!(
10073 manifest.inference.unwrap().max_session_tokens,
10074 Some(200_000)
10075 );
10076 }
10077
10078 #[test]
10079 fn process_transport_rejects_a_zero_max_session_tokens() {
10080 let err = RuntimeManifest::from_yaml_str(
10081 r#"
10082name: cap
10083version: 0.0.1
10084artifacts:
10085 - name: murmur/driver-claude-code
10086 version: 0.1.0
10087 runtime: driver
10088inference:
10089 transport: process
10090 driver:
10091 artifact: murmur/driver-claude-code
10092 model: claude-haiku-4-5-20251001
10093 max_session_tokens: 0
10094"#,
10095 )
10096 .unwrap_err();
10097
10098 let msg = err.to_string();
10099 assert!(
10100 msg.contains("inference.max_session_tokens"),
10101 "error was: {msg}"
10102 );
10103 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
10104 }
10105
10106 #[test]
10107 fn inference_max_session_tokens_round_trips() {
10108 let manifest = RuntimeManifest::from_yaml_str(
10109 r#"
10110name: cap
10111version: 0.0.1
10112artifacts:
10113 - name: murmur/driver-anthropic
10114 version: 0.1.0
10115 runtime: driver
10116 gateway:
10117 endpoint: https://api.anthropic.com
10118 api_key: test-key
10119inference:
10120 transport: http
10121 model: claude-opus-4-5
10122 max_session_tokens: 5000000000
10123 driver:
10124 artifact: murmur/driver-anthropic
10125"#,
10126 )
10127 .unwrap();
10128
10129 assert_eq!(
10130 manifest.inference.unwrap().max_session_tokens,
10131 Some(5_000_000_000)
10132 );
10133 }
10134
10135 #[test]
10136 fn inference_max_session_tokens_absent_is_none() {
10137 let manifest = RuntimeManifest::from_yaml_str(
10138 r#"
10139name: cap
10140version: 0.0.1
10141artifacts:
10142 - name: murmur/driver-anthropic
10143 version: 0.1.0
10144 runtime: driver
10145 gateway:
10146 endpoint: https://api.anthropic.com
10147 api_key: test-key
10148inference:
10149 transport: http
10150 model: claude-opus-4-5
10151 driver:
10152 artifact: murmur/driver-anthropic
10153"#,
10154 )
10155 .unwrap();
10156
10157 assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
10158 }
10159
10160 #[test]
10161 fn inference_max_session_tokens_zero_is_rejected() {
10162 let err = RuntimeManifest::from_yaml_str(
10163 r#"
10164name: cap
10165version: 0.0.1
10166artifacts:
10167 - name: murmur/driver-anthropic
10168 version: 0.1.0
10169 runtime: driver
10170 gateway:
10171 endpoint: https://api.anthropic.com
10172 api_key: test-key
10173inference:
10174 transport: http
10175 model: claude-opus-4-5
10176 max_session_tokens: 0
10177 driver:
10178 artifact: murmur/driver-anthropic
10179"#,
10180 )
10181 .unwrap_err();
10182
10183 let msg = err.to_string();
10184 assert!(
10185 msg.contains("inference.max_session_tokens"),
10186 "error was: {msg}"
10187 );
10188 assert!(msg.contains("greater than 0"), "error was: {msg}");
10189 }
10190
10191 fn http_manifest_with_inference(extra: &str) -> String {
10193 format!(
10194 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n \
10195 version: 0.1.0\n runtime: driver\n gateway:\n \
10196 endpoint: https://api.anthropic.com\n api_key: test-key\n\
10197 inference:\n transport: http\n model: claude-opus-4-5\n driver:\n \
10198 artifact: murmur/driver-anthropic\n{extra}"
10199 )
10200 }
10201
10202 #[test]
10203 fn inference_tool_refresh_absent_is_compaction() {
10204 let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
10205 assert_eq!(
10206 manifest.inference.unwrap().tool_refresh,
10207 ToolRefresh::Compaction
10208 );
10209 assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
10210 }
10211
10212 #[test]
10213 fn inference_tool_refresh_parses_both_values() {
10214 for (value, expected) in [
10215 ("compaction", ToolRefresh::Compaction),
10216 ("immediate", ToolRefresh::Immediate),
10217 ] {
10218 let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
10219 " tool_refresh: {value}\n"
10220 )))
10221 .unwrap();
10222 assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
10223 assert_eq!(expected.wire_name(), value);
10224 assert!(
10225 manifest.unknown_keys.is_empty(),
10226 "tool_refresh is a known key: {:?}",
10227 manifest.unknown_keys
10228 );
10229 }
10230 }
10231
10232 #[test]
10233 fn inference_tool_refresh_rejects_an_unknown_value() {
10234 let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
10235 " tool_refresh: sometimes\n",
10236 ))
10237 .unwrap_err();
10238 match err {
10239 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10240 assert_eq!(field, "inference.tool_refresh");
10241 assert_eq!(
10242 message,
10243 "must be one of: compaction, immediate; got 'sometimes'"
10244 );
10245 }
10246 other => panic!("unexpected error: {other:?}"),
10247 }
10248 }
10249
10250 #[test]
10253 fn process_transport_rejects_tool_refresh() {
10254 for value in ["compaction", "immediate"] {
10255 let err = RuntimeManifest::from_yaml_str(&format!(
10256 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n \
10257 command: claude\n model: claude-haiku-4-5-20251001\n tool_refresh: {value}\n"
10258 ))
10259 .unwrap_err();
10260 match err {
10261 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10262 assert_eq!(field, "inference.tool_refresh");
10263 assert_eq!(message, "is not valid with transport: process");
10264 }
10265 other => panic!("unexpected error for {value}: {other:?}"),
10266 }
10267 }
10268 }
10269
10270 #[test]
10271 fn inference_max_tokens_round_trips() {
10272 let manifest = RuntimeManifest::from_yaml_str(
10273 r#"
10274name: cap
10275version: 0.0.1
10276artifacts:
10277 - name: murmur/driver-anthropic
10278 version: 0.1.0
10279 runtime: driver
10280 gateway:
10281 endpoint: https://api.anthropic.com
10282 api_key: test-key
10283inference:
10284 transport: http
10285 model: claude-opus-4-5
10286 max_tokens: 4096
10287 driver:
10288 artifact: murmur/driver-anthropic
10289"#,
10290 )
10291 .unwrap();
10292
10293 let inference = manifest.inference.unwrap();
10294 assert_eq!(inference.max_tokens, Some(4096));
10295 }
10296
10297 #[test]
10298 fn inference_max_tokens_absent_is_none() {
10299 let manifest = RuntimeManifest::from_yaml_str(
10302 r#"
10303name: cap
10304version: 0.0.1
10305artifacts:
10306 - name: murmur/driver-anthropic
10307 version: 0.1.0
10308 runtime: driver
10309 gateway:
10310 endpoint: https://api.anthropic.com
10311 api_key: test-key
10312inference:
10313 transport: http
10314 model: claude-opus-4-5
10315 driver:
10316 artifact: murmur/driver-anthropic
10317"#,
10318 )
10319 .unwrap();
10320
10321 assert_eq!(manifest.inference.unwrap().max_tokens, None);
10322 }
10323
10324 #[test]
10325 fn inference_max_tokens_zero_is_rejected() {
10326 let err = RuntimeManifest::from_yaml_str(
10327 r#"
10328name: cap
10329version: 0.0.1
10330artifacts:
10331 - name: murmur/driver-anthropic
10332 version: 0.1.0
10333 runtime: driver
10334 gateway:
10335 endpoint: https://api.anthropic.com
10336 api_key: test-key
10337inference:
10338 transport: http
10339 model: claude-opus-4-5
10340 max_tokens: 0
10341 driver:
10342 artifact: murmur/driver-anthropic
10343"#,
10344 )
10345 .unwrap_err();
10346
10347 let msg = err.to_string();
10348 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
10349 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
10350 }
10351
10352 #[test]
10353 fn inference_max_tokens_large_value_is_accepted() {
10354 let manifest = RuntimeManifest::from_yaml_str(
10357 r#"
10358name: cap
10359version: 0.0.1
10360artifacts:
10361 - name: murmur/driver-anthropic
10362 version: 0.1.0
10363 runtime: driver
10364 gateway:
10365 endpoint: https://api.anthropic.com
10366 api_key: test-key
10367inference:
10368 transport: http
10369 model: claude-opus-4-5
10370 max_tokens: 999999
10371 driver:
10372 artifact: murmur/driver-anthropic
10373"#,
10374 )
10375 .unwrap();
10376
10377 assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
10378 }
10379
10380 #[test]
10381 fn inference_and_context_max_tokens_are_independent() {
10382 let manifest = RuntimeManifest::from_yaml_str(
10385 r#"
10386name: cap
10387version: 0.0.1
10388artifacts:
10389 - name: murmur/driver-anthropic
10390 version: 0.1.0
10391 runtime: driver
10392 gateway:
10393 endpoint: https://api.anthropic.com
10394 api_key: test-key
10395inference:
10396 transport: http
10397 model: claude-opus-4-5
10398 max_tokens: 4096
10399 driver:
10400 artifact: murmur/driver-anthropic
10401context:
10402 max_tokens: 200000
10403"#,
10404 )
10405 .unwrap();
10406
10407 assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
10408 assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
10409 }
10410
10411 #[test]
10412 fn hook_config_defaults_when_fields_absent() {
10413 let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
10414 assert_eq!(config.binding, HookBinding::All);
10415 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
10416 assert_eq!(config.commit_policy, HookCommitPolicy::None);
10417 }
10418
10419 #[test]
10420 fn hook_config_parses_all_fields() {
10421 let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
10422 let config = parse_hook_config_from_yaml(yaml).unwrap();
10423 assert_eq!(config.binding, HookBinding::OnCompaction);
10424 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
10425 assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
10426 }
10427
10428 #[test]
10429 fn hook_config_parses_on_task_start() {
10430 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
10431 let config = parse_hook_config_from_yaml(yaml).unwrap();
10432 assert_eq!(config.binding, HookBinding::OnTaskStart);
10433 }
10434
10435 #[test]
10436 fn hook_config_parses_on_task_end() {
10437 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
10438 let config = parse_hook_config_from_yaml(yaml).unwrap();
10439 assert_eq!(config.binding, HookBinding::OnTaskEnd);
10440 }
10441
10442 #[test]
10443 fn hook_config_unknown_binding_lists_task_events() {
10444 let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
10445 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10446 assert!(err.contains("on-task-start"), "error was: {err}");
10447 assert!(err.contains("on-task-end"), "error was: {err}");
10448 }
10449
10450 #[test]
10451 fn hook_config_async_with_commit_is_rejected() {
10452 let yaml =
10453 "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
10454 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10455 assert!(err.contains("async-with-commit"), "error was: {err}");
10456 }
10457
10458 #[test]
10459 fn hook_config_on_stage_async_is_rejected() {
10460 let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
10461 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10462 assert!(err.contains("on-stage"), "error was: {err}");
10463 assert!(err.contains("blocking"), "error was: {err}");
10464 }
10465
10466 #[test]
10467 fn hook_config_async_none_is_valid() {
10468 let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
10469 let config = parse_hook_config_from_yaml(yaml).unwrap();
10470 assert_eq!(config.execution_mode, HookExecutionMode::Async);
10471 assert_eq!(config.commit_policy, HookCommitPolicy::None);
10472 }
10473
10474 #[test]
10475 fn hook_config_parses_reopen_task_commit_policy() {
10476 let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
10477 let config = parse_hook_config_from_yaml(yaml).unwrap();
10478 assert_eq!(config.binding, HookBinding::OnTaskEnd);
10479 assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
10480 }
10481
10482 #[test]
10483 fn hook_config_unknown_commit_policy_lists_reopen_task() {
10484 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
10485 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10486 assert!(err.contains("reopen-task"), "error was: {err}");
10487 }
10488
10489 #[test]
10490 fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
10491 for binding in ["on-shell", "on-tool-call"] {
10492 let yaml =
10493 format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
10494 let config = parse_hook_config_from_yaml(&yaml).unwrap();
10495 assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
10496 }
10497 assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
10498 }
10499
10500 #[test]
10503 fn hook_config_deny_without_a_binding_is_rejected() {
10504 let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
10505 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10506 assert!(
10507 err.contains("requires an explicit binding"),
10508 "error was: {err}"
10509 );
10510 assert!(err.contains("on-shell"), "error was: {err}");
10511 assert!(err.contains("on-tool-call"), "error was: {err}");
10512 }
10513
10514 #[test]
10516 fn hook_config_deny_on_an_ungated_binding_is_rejected() {
10517 let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
10518 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10519 assert!(err.contains("is not valid for binding"), "error was: {err}");
10520 assert!(err.contains("replace-context"), "error was: {err}");
10521 }
10522
10523 #[test]
10525 fn hook_config_async_deny_is_rejected() {
10526 let yaml =
10527 "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
10528 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10529 assert!(err.contains("async-with-commit"), "error was: {err}");
10530 assert!(err.contains("deny"), "error was: {err}");
10531 }
10532
10533 #[test]
10534 fn hook_config_unknown_commit_policy_lists_deny() {
10535 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
10536 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10537 assert!(err.contains("deny"), "error was: {err}");
10538 }
10539
10540 #[test]
10543 fn hook_config_async_reopen_task_is_rejected() {
10544 let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
10545 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10546 assert!(err.contains("async-with-commit"), "error was: {err}");
10547 assert!(err.contains("reopen-task"), "error was: {err}");
10548 }
10549
10550 #[test]
10556 fn hook_config_non_committing_binding_rejects_any_commit_policy() {
10557 for binding in ["on-session-start", "on-session-end"] {
10558 let yaml = format!(
10559 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
10560 );
10561 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
10562 assert!(err.contains(binding), "error was: {err}");
10563 assert!(err.contains("replace-context"), "error was: {err}");
10564 assert!(
10565 err.contains("honors commit_policy 'none'"),
10566 "error was: {err}"
10567 );
10568 }
10569 }
10570
10571 #[test]
10574 fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
10575 for (binding, declared, honored) in [
10576 ("on-stage", "reopen-task", "write-manifests"),
10577 ("on-compaction", "write-manifests", "replace-context"),
10578 ("on-task-end", "replace-context", "reopen-task"),
10579 ("on-task-start", "replace-context", "seed-context"),
10580 ("on-shell", "replace-context", "deny"),
10581 ("on-tool-call", "replace-context", "deny"),
10582 ] {
10583 let yaml = format!(
10584 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
10585 );
10586 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
10587 assert!(err.contains(binding), "error was: {err}");
10588 assert!(
10589 err.contains(&format!("commit_policy '{declared}' is not valid")),
10590 "error was: {err}"
10591 );
10592 assert!(
10593 err.contains(&format!("honors commit_policy '{honored}'")),
10594 "error was: {err}"
10595 );
10596 }
10597 }
10598
10599 #[test]
10602 fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
10603 for declared in ["replace-context", "write-manifests", "reopen-task"] {
10604 let yaml = format!(
10605 "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
10606 );
10607 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
10608 assert!(err.contains("on-inference"), "error was: {err}");
10609 assert!(err.contains(declared), "error was: {err}");
10610 assert!(
10611 err.contains("honors commit_policy 'none'"),
10612 "error was: {err}"
10613 );
10614 assert!(err.contains("artifact"), "error was: {err}");
10615 }
10616 }
10617
10618 #[test]
10621 fn hook_config_matching_binding_and_commit_policy_is_valid() {
10622 for (binding, policy, expected) in [
10623 (
10624 "on-stage",
10625 "write-manifests",
10626 HookCommitPolicy::WriteManifests,
10627 ),
10628 (
10629 "on-compaction",
10630 "replace-context",
10631 HookCommitPolicy::ReplaceContext,
10632 ),
10633 ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
10634 ("on-shell", "deny", HookCommitPolicy::Deny),
10635 ("on-tool-call", "deny", HookCommitPolicy::Deny),
10636 ] {
10637 let yaml =
10638 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
10639 let config = parse_hook_config_from_yaml(&yaml).unwrap();
10640 assert_eq!(config.commit_policy, expected);
10641 }
10642 }
10643
10644 #[test]
10647 fn hook_config_commit_policy_none_is_valid_for_every_binding() {
10648 for binding in [
10649 "on-stage",
10650 "on-session-start",
10651 "on-task-start",
10652 "on-inference",
10653 "on-tool-call",
10654 "on-shell",
10655 "on-compaction",
10656 "on-task-end",
10657 "on-session-end",
10658 ] {
10659 let yaml =
10660 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
10661 let config = parse_hook_config_from_yaml(&yaml).unwrap();
10662 assert_eq!(config.commit_policy, HookCommitPolicy::None);
10663 }
10664 }
10665
10666 #[test]
10671 fn hook_config_omitted_binding_accepts_every_commit_policy() {
10672 for (policy, expected) in [
10673 ("replace-context", HookCommitPolicy::ReplaceContext),
10674 ("write-manifests", HookCommitPolicy::WriteManifests),
10675 ("reopen-task", HookCommitPolicy::ReopenTask),
10676 ("none", HookCommitPolicy::None),
10677 ] {
10678 let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
10679 let config = parse_hook_config_from_yaml(&yaml).unwrap();
10680 assert_eq!(config.binding, HookBinding::All);
10681 assert_eq!(config.commit_policy, expected);
10682 }
10683 }
10684
10685 #[test]
10690 fn hook_config_shipped_default_artifact_manifests_still_parse() {
10691 let shipped: [(&str, &str, HookConfig); 8] = [
10692 (
10693 "murmur-hook-compact",
10694 "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
10695 HookConfig {
10696 binding: HookBinding::OnCompaction,
10697 execution_mode: HookExecutionMode::Blocking,
10698 commit_policy: HookCommitPolicy::ReplaceContext,
10699 },
10700 ),
10701 (
10702 "murmur-hook-shell-desc",
10703 "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
10704 HookConfig {
10705 binding: HookBinding::OnStage,
10706 execution_mode: HookExecutionMode::Blocking,
10707 commit_policy: HookCommitPolicy::WriteManifests,
10708 },
10709 ),
10710 (
10711 "murmur-hook-regression-verifier",
10713 "execution_mode: blocking\ncommit_policy: reopen-task\n",
10714 HookConfig {
10715 binding: HookBinding::All,
10716 execution_mode: HookExecutionMode::Blocking,
10717 commit_policy: HookCommitPolicy::ReopenTask,
10718 },
10719 ),
10720 (
10721 "murmur-hook-memory",
10722 "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
10723 HookConfig {
10724 binding: HookBinding::OnTaskStart,
10725 execution_mode: HookExecutionMode::Blocking,
10726 commit_policy: HookCommitPolicy::SeedContext,
10727 },
10728 ),
10729 (
10730 "murmur-hook-debug",
10731 "execution_mode: async\ncommit_policy: none\n",
10732 HookConfig {
10733 binding: HookBinding::All,
10734 execution_mode: HookExecutionMode::Async,
10735 commit_policy: HookCommitPolicy::None,
10736 },
10737 ),
10738 (
10739 "murmur-hook-diff-summary",
10740 "execution_mode: blocking\ncommit_policy: none\n",
10741 HookConfig {
10742 binding: HookBinding::All,
10743 execution_mode: HookExecutionMode::Blocking,
10744 commit_policy: HookCommitPolicy::None,
10745 },
10746 ),
10747 ("murmur-hook-eval", "", HookConfig::default()),
10748 ("murmur-hook-grafana", "", HookConfig::default()),
10749 ];
10750
10751 for (name, fields, expected) in shipped {
10752 let yaml = format!("name: {name}\nruntime: hook\n{fields}");
10753 let config = parse_hook_config_from_yaml(&yaml)
10754 .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
10755 assert_eq!(config, expected, "shipped hook {name}");
10756 }
10757 }
10758
10759 #[test]
10763 fn commit_policy_for_binding_matches_the_declarable_pairs() {
10764 assert_eq!(
10765 commit_policy_for_binding(&HookBinding::OnStage),
10766 Some(HookCommitPolicy::WriteManifests)
10767 );
10768 assert_eq!(
10769 commit_policy_for_binding(&HookBinding::OnCompaction),
10770 Some(HookCommitPolicy::ReplaceContext)
10771 );
10772 assert_eq!(
10773 commit_policy_for_binding(&HookBinding::OnTaskEnd),
10774 Some(HookCommitPolicy::ReopenTask)
10775 );
10776 assert_eq!(
10777 commit_policy_for_binding(&HookBinding::OnTaskStart),
10778 Some(HookCommitPolicy::SeedContext)
10779 );
10780 assert_eq!(
10781 commit_policy_for_binding(&HookBinding::OnToolCall),
10782 Some(HookCommitPolicy::Deny)
10783 );
10784 assert_eq!(
10785 commit_policy_for_binding(&HookBinding::OnShell),
10786 Some(HookCommitPolicy::Deny)
10787 );
10788 for binding in [
10792 HookBinding::OnSessionStart,
10793 HookBinding::OnInference,
10794 HookBinding::OnSessionEnd,
10795 HookBinding::All,
10796 ] {
10797 assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
10798 }
10799 }
10800
10801 #[test]
10804 fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
10805 let config = parse_hook_config_from_yaml(
10806 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
10807 commit_policy: seed-context\n",
10808 )
10809 .expect("on-task-start + seed-context is the declarable pairing");
10810 assert_eq!(config.binding, HookBinding::OnTaskStart);
10811 assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
10812
10813 let error = parse_hook_config_from_yaml(
10814 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
10815 commit_policy: seed-context\n",
10816 )
10817 .expect_err("on-compaction cannot commit a seed");
10818 assert!(error.contains("on-compaction"), "{error}");
10819 assert!(error.contains("seed-context"), "{error}");
10820 }
10821
10822 #[test]
10825 fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
10826 let error = parse_hook_config_from_yaml(
10827 "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
10828 )
10829 .expect_err("a misspelled policy is rejected");
10830 assert!(error.contains("seed-context"), "{error}");
10831 }
10832
10833 fn context_of(yaml_block: &str) -> Option<ContextConfig> {
10836 RuntimeManifest::from_yaml_str(&format!(
10837 "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
10838 ))
10839 .expect("the manifest under test parses")
10840 .context
10841 }
10842
10843 #[test]
10844 fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
10845 let context = context_of(
10846 "context:\n max_tokens: 200000\n seed_budget: 0.10\n seed_overflow_margin: 0.10\n",
10847 )
10848 .expect("a declared context block parses to Some");
10849 assert_eq!(context.max_tokens, Some(200_000));
10850 assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
10851 assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
10852 }
10853
10854 #[test]
10855 fn seed_budget_and_overflow_margin_default_when_omitted() {
10856 let context = context_of("context:\n max_tokens: 200000\n")
10857 .expect("a declared context block parses to Some");
10858 assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
10859 assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
10860 }
10861
10862 #[test]
10865 fn seed_budget_defaults_do_not_create_a_context_block() {
10866 assert_eq!(context_of(""), None);
10867 }
10868
10869 #[test]
10870 fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
10871 for (block, field) in [
10872 ("context:\n seed_budget: 1.5\n", "context.seed_budget"),
10873 ("context:\n seed_budget: -0.1\n", "context.seed_budget"),
10874 (
10875 "context:\n seed_overflow_margin: 2.0\n",
10876 "context.seed_overflow_margin",
10877 ),
10878 ] {
10879 let error = RuntimeManifest::from_yaml_str(&format!(
10880 "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
10881 ))
10882 .expect_err("an out-of-range fraction is rejected");
10883 match error {
10884 RuntimeManifestError::InvalidInferenceConfig {
10885 field: got,
10886 ref message,
10887 } => {
10888 assert_eq!(got, field);
10889 assert!(message.contains("0.0 and 1.0"), "{message}");
10890 }
10891 other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
10892 }
10893 }
10894 }
10895
10896 #[test]
10897 fn parse_tool_implementation_defaults_to_wasm() {
10898 let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
10899 assert_eq!(impl_, ArtifactImplementation::Wasm);
10900 }
10901
10902 #[test]
10903 fn parse_tool_implementation_native() {
10904 let impl_ = parse_tool_implementation_from_yaml(
10905 "name: my-tool\nruntime: tool\nimplementation: native\n",
10906 );
10907 assert_eq!(impl_, ArtifactImplementation::Native);
10908 }
10909
10910 #[test]
10911 fn deploy_block_mur_version_parses() {
10912 let manifest = RuntimeManifest::from_yaml_str(
10913 "name: cap\nversion: 0.1.0\nartifacts: []\ndeploy:\n mur_version: \"0.4.5\"\n",
10914 )
10915 .unwrap();
10916 assert_eq!(manifest.deploy_mur_version(), Some("0.4.5"));
10917 assert_eq!(
10918 manifest.deploy,
10919 Some(DeployConfig {
10920 mur_version: Some("0.4.5".to_string())
10921 })
10922 );
10923 }
10924
10925 #[test]
10926 fn deploy_block_absent_is_none() {
10927 let manifest =
10928 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
10929 assert_eq!(manifest.deploy, None);
10930 assert_eq!(manifest.deploy_mur_version(), None);
10931 }
10932
10933 #[test]
10935 fn deploy_block_blank_mur_version_is_none() {
10936 for deploy in [
10937 "deploy:\n mur_version: \"\"\n",
10938 "deploy:\n mur_version: \" \"\n",
10939 ] {
10940 let manifest = RuntimeManifest::from_yaml_str(&format!(
10941 "name: cap\nversion: 0.1.0\nartifacts: []\n{deploy}"
10942 ))
10943 .unwrap();
10944 assert_eq!(manifest.deploy_mur_version(), None, "{deploy:?}");
10945 }
10946 let manifest =
10947 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\ndeploy: {}\n").unwrap();
10948 assert_eq!(manifest.deploy, Some(DeployConfig::default()));
10949 assert_eq!(manifest.deploy_mur_version(), None);
10950 }
10951
10952 #[test]
10953 fn deploy_block_misspelled_key_is_reported_under_deploy() {
10954 assert_eq!(
10955 unknown_keys_of("name: cap\nversion: 0.1.0\ndeploy:\n mur_versoin: \"0.4.5\"\n"),
10956 vec![(
10957 "mur_versoin".to_string(),
10958 "deploy".to_string(),
10959 Some("mur_version".to_string())
10960 )]
10961 );
10962 }
10963
10964 #[test]
10965 fn a_top_level_mur_version_is_refused_naming_deploy_mur_version() {
10966 for pin in [
10967 "mur_version: \"0.4.5\"\n",
10968 "mur_version: 0.4.0\n",
10969 "mur_version: \"\"\n",
10970 "mur_version:\n",
10971 ] {
10972 let error = RuntimeManifest::from_yaml_str(&format!(
10973 "name: cap\nversion: 0.1.0\nartifacts: []\n{pin}"
10974 ))
10975 .expect_err(pin);
10976 assert!(
10977 matches!(error, RuntimeManifestError::TopLevelMurVersion),
10978 "{pin:?}: {error:?}"
10979 );
10980 assert!(error.to_string().contains("deploy.mur_version"), "{error}");
10981 }
10982 assert_eq!(
10983 RuntimeManifestError::TopLevelMurVersion.to_string(),
10984 "murmur.yaml: 'mur_version' is not a top-level key — the mur release a deploy \
10985 installs is pinned as 'deploy.mur_version'"
10986 );
10987 }
10988
10989 #[test]
10990 fn a_top_level_mur_version_is_refused_beside_a_deploy_block() {
10991 let yaml = "name: cap\nversion: 0.1.0\nmur_version: \"0.4.5\"\ndeploy:\n mur_version: \"0.4.5\"\n";
10992 assert!(matches!(
10993 RuntimeManifest::from_yaml_str(yaml),
10994 Err(RuntimeManifestError::TopLevelMurVersion)
10995 ));
10996 assert!(matches!(
10997 RuntimeManifest::from_yaml_str_without_secrets(yaml),
10998 Err(RuntimeManifestError::TopLevelMurVersion)
10999 ));
11000 }
11001
11002 #[test]
11003 fn inference_max_turns_defaults_to_10() {
11004 let manifest = RuntimeManifest::from_yaml_str(
11005 "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",
11006 ).unwrap();
11007 assert_eq!(manifest.inference.unwrap().max_turns, 10);
11008 }
11009
11010 #[test]
11011 fn inference_max_turns_explicit_value() {
11012 let manifest = RuntimeManifest::from_yaml_str(
11013 "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",
11014 ).unwrap();
11015 assert_eq!(manifest.inference.unwrap().max_turns, 20);
11016 }
11017
11018 #[test]
11019 fn inference_max_turns_zero_is_rejected() {
11020 let err = RuntimeManifest::from_yaml_str(
11021 "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",
11022 ).unwrap_err();
11023 let msg = err.to_string();
11024 assert!(msg.contains("inference.max_turns"), "error was: {msg}");
11025 assert!(msg.contains("greater than 0"), "error was: {msg}");
11026 }
11027
11028 #[test]
11030 fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
11031 let manifest = RuntimeManifest::from_yaml_str(
11032 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 45\n",
11033 )
11034 .unwrap();
11035 let lifecycle = manifest.lifecycle.unwrap();
11036 assert_eq!(lifecycle.shell_grace_secs, 45);
11037 assert_eq!(
11039 lifecycle.task_acceptance,
11040 LifecycleConfig::default().task_acceptance
11041 );
11042 assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
11043 assert_eq!(
11044 lifecycle.queue_depth,
11045 LifecycleConfig::default().queue_depth
11046 );
11047 assert_eq!(lifecycle.input_timeout_secs, None);
11048 assert_eq!(
11049 lifecycle.conversation_mode,
11050 LifecycleConfig::default().conversation_mode
11051 );
11052 assert_eq!(
11053 lifecycle.max_task_reopens,
11054 LifecycleConfig::default().max_task_reopens
11055 );
11056 }
11057
11058 #[test]
11059 fn lifecycle_shell_grace_secs_defaults_to_10() {
11060 let manifest = RuntimeManifest::from_yaml_str(
11061 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
11062 )
11063 .unwrap();
11064 let lifecycle = manifest.lifecycle.unwrap();
11065 assert_eq!(lifecycle.shell_grace_secs, 10);
11066 assert_eq!(lifecycle.queue_depth, 3);
11067
11068 let without_block =
11069 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11070 assert_eq!(without_block.effective_lifecycle().shell_grace_secs, 10);
11071 }
11072
11073 #[test]
11076 fn lifecycle_delegation_deadline_secs_defaults_to_600() {
11077 let without_block =
11078 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11079 assert_eq!(
11080 without_block.effective_lifecycle().delegation_deadline_secs,
11081 600
11082 );
11083
11084 let without_key = RuntimeManifest::from_yaml_str(
11085 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
11086 )
11087 .unwrap();
11088 let lifecycle = without_key.lifecycle.unwrap();
11089 assert_eq!(lifecycle.delegation_deadline_secs, 600);
11090 assert_eq!(lifecycle.queue_depth, 3);
11091 }
11092
11093 #[test]
11094 fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
11095 let manifest = RuntimeManifest::from_yaml_str(
11096 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
11097 delegation_deadline_secs: 30\n",
11098 )
11099 .unwrap();
11100 let lifecycle = manifest.lifecycle.unwrap();
11101 assert_eq!(lifecycle.delegation_deadline_secs, 30);
11102 assert_eq!(
11104 lifecycle.shell_grace_secs,
11105 LifecycleConfig::default().shell_grace_secs
11106 );
11107 assert_eq!(
11108 lifecycle.max_task_reopens,
11109 LifecycleConfig::default().max_task_reopens
11110 );
11111 }
11112
11113 #[test]
11115 fn lifecycle_delegation_deadline_secs_is_a_known_key() {
11116 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
11117 let manifest = RuntimeManifest::from_yaml_str(
11118 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
11119 delegation_deadline_secs: 30\n delegation_deadline: 30\n",
11120 )
11121 .unwrap();
11122 let reported: Vec<(String, String)> = manifest
11123 .unknown_keys
11124 .iter()
11125 .map(|key| (key.key.clone(), key.block_path.clone()))
11126 .collect();
11127 assert_eq!(
11128 reported,
11129 vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
11130 "the declared key is known and only the misspelt neighbour is reported"
11131 );
11132 assert_eq!(
11133 manifest.lifecycle.unwrap().delegation_deadline_secs,
11134 30,
11135 "the misspelt neighbour does not disturb the declared value"
11136 );
11137 }
11138
11139 #[test]
11142 fn lifecycle_shell_grace_secs_zero_is_accepted() {
11143 let manifest = RuntimeManifest::from_yaml_str(
11144 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 0\n",
11145 )
11146 .unwrap();
11147 let lifecycle = manifest.lifecycle.unwrap();
11148 assert_eq!(lifecycle.shell_grace_secs, 0);
11149 assert_eq!(
11150 lifecycle.max_task_reopens,
11151 LifecycleConfig::default().max_task_reopens
11152 );
11153 }
11154
11155 #[test]
11156 fn lifecycle_max_task_reopens_defaults_to_1() {
11157 let manifest = RuntimeManifest::from_yaml_str(
11158 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n after_task: sleep\n",
11159 )
11160 .unwrap();
11161 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
11162 }
11163
11164 #[test]
11166 fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
11167 let manifest =
11168 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11169 assert!(manifest.lifecycle.is_none());
11170 assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
11171 }
11172
11173 #[test]
11174 fn lifecycle_max_task_reopens_explicit_value() {
11175 let manifest = RuntimeManifest::from_yaml_str(
11176 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 3\n",
11177 )
11178 .unwrap();
11179 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
11180 }
11181
11182 #[test]
11184 fn lifecycle_max_task_reopens_zero_is_accepted() {
11185 let manifest = RuntimeManifest::from_yaml_str(
11186 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 0\n",
11187 )
11188 .unwrap();
11189 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
11190 }
11191
11192 #[test]
11194 fn lifecycle_max_task_reopens_with_process_transport() {
11195 let manifest = RuntimeManifest::from_yaml_str(
11196 "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",
11197 ).unwrap();
11198 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
11199 }
11200
11201 #[test]
11202 fn inference_max_task_reopens_is_rejected_under_http_transport() {
11203 let err = RuntimeManifest::from_yaml_str(
11204 "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",
11205 ).unwrap_err();
11206 assert!(
11207 matches!(
11208 &err,
11209 RuntimeManifestError::InvalidInferenceConfig { field, .. }
11210 if field == "inference.max_task_reopens"
11211 ),
11212 "error was: {err:?}"
11213 );
11214 let msg = err.to_string();
11215 assert!(
11216 msg.contains("lifecycle.max_task_reopens"),
11217 "error was: {msg}"
11218 );
11219 }
11220
11221 #[test]
11222 fn inference_max_task_reopens_is_rejected_under_process_transport() {
11223 let err = RuntimeManifest::from_yaml_str(
11224 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n max_task_reopens: 2\n",
11225 ).unwrap_err();
11226 assert!(
11227 matches!(
11228 &err,
11229 RuntimeManifestError::InvalidInferenceConfig { field, .. }
11230 if field == "inference.max_task_reopens"
11231 ),
11232 "error was: {err:?}"
11233 );
11234 let msg = err.to_string();
11235 assert!(
11236 msg.contains("lifecycle.max_task_reopens"),
11237 "error was: {msg}"
11238 );
11239 }
11240
11241 #[test]
11243 fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
11244 let err = RuntimeManifest::from_yaml_str(
11245 "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",
11246 ).unwrap_err();
11247 assert!(
11248 matches!(
11249 &err,
11250 RuntimeManifestError::InvalidInferenceConfig { field, .. }
11251 if field == "inference.max_task_reopens"
11252 ),
11253 "error was: {err:?}"
11254 );
11255 }
11256
11257 #[test]
11258 fn skill_is_llm_visible() {
11259 assert!(ArtifactRuntime::Skill.is_llm_visible());
11260 }
11261
11262 #[test]
11263 fn driver_and_hook_are_not_llm_visible() {
11264 assert!(!ArtifactRuntime::Driver.is_llm_visible());
11265 assert!(!ArtifactRuntime::Hook.is_llm_visible());
11266 }
11267
11268 #[test]
11269 fn parses_system_prompt_artifact() {
11270 let manifest = RuntimeManifest::from_yaml_str(
11271 r#"
11272name: cap
11273version: 0.0.1
11274artifacts:
11275 - name: local/my-skill
11276 version: 1.0.0
11277 runtime: skill
11278 - name: murmur/driver-anthropic
11279 version: 0.1.0
11280 runtime: driver
11281 gateway:
11282 endpoint: http://127.0.0.1:8080
11283 keyless: true
11284inference:
11285 model: test-model
11286 system_prompt_artifact: local/my-skill
11287 driver:
11288 artifact: murmur/driver-anthropic
11289"#,
11290 )
11291 .unwrap();
11292
11293 let inference = manifest.inference.unwrap();
11294 assert_eq!(
11295 inference.system_prompt_artifact,
11296 Some("local/my-skill".to_string())
11297 );
11298 assert!(inference.system_prompt.is_none());
11299 assert!(inference.system_prompt_file.is_none());
11300 }
11301
11302 #[test]
11303 fn rejects_system_prompt_artifact_with_inline_prompt() {
11304 let err = RuntimeManifest::from_yaml_str(
11305 r#"
11306name: cap
11307version: 0.0.1
11308artifacts:
11309 - name: local/my-skill
11310 version: 1.0.0
11311 runtime: skill
11312 - name: murmur/driver-anthropic
11313 version: 0.1.0
11314 runtime: driver
11315 gateway:
11316 endpoint: http://127.0.0.1:8080
11317 keyless: true
11318inference:
11319 model: test-model
11320 system_prompt: "inline"
11321 system_prompt_artifact: local/my-skill
11322 driver:
11323 artifact: murmur/driver-anthropic
11324"#,
11325 )
11326 .unwrap_err();
11327
11328 let msg = err.to_string();
11329 assert!(msg.contains("at most one"), "error was: {msg}");
11330 }
11331
11332 #[test]
11333 fn rejects_system_prompt_artifact_naming_non_skill() {
11334 let err = RuntimeManifest::from_yaml_str(
11335 r#"
11336name: cap
11337version: 0.0.1
11338artifacts:
11339 - name: local/my-tool
11340 version: 1.0.0
11341 runtime: tool
11342 - name: murmur/driver-anthropic
11343 version: 0.1.0
11344 runtime: driver
11345 gateway:
11346 endpoint: http://127.0.0.1:8080
11347 keyless: true
11348inference:
11349 model: test-model
11350 system_prompt_artifact: local/my-tool
11351 driver:
11352 artifact: murmur/driver-anthropic
11353"#,
11354 )
11355 .unwrap_err();
11356
11357 let msg = err.to_string();
11358 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
11359 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
11360 }
11361
11362 #[test]
11363 fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
11364 let err = RuntimeManifest::from_yaml_str(
11365 r#"
11366name: cap
11367version: 0.0.1
11368artifacts:
11369 - name: murmur/driver-anthropic
11370 version: 0.1.0
11371 runtime: driver
11372 gateway:
11373 endpoint: http://127.0.0.1:8080
11374 keyless: true
11375inference:
11376 model: test-model
11377 system_prompt_artifact: missing-skill
11378 driver:
11379 artifact: murmur/driver-anthropic
11380"#,
11381 )
11382 .unwrap_err();
11383
11384 let msg = err.to_string();
11385 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
11386 assert!(msg.contains("not declared"), "error was: {msg}");
11387 }
11388
11389 #[test]
11390 fn rejects_all_three_system_prompt_sources() {
11391 let err = RuntimeManifest::from_yaml_str(
11392 r#"
11393name: cap
11394version: 0.0.1
11395artifacts:
11396 - name: local/my-skill
11397 version: 1.0.0
11398 runtime: skill
11399 - name: murmur/driver-anthropic
11400 version: 0.1.0
11401 runtime: driver
11402 gateway:
11403 endpoint: http://127.0.0.1:8080
11404 keyless: true
11405inference:
11406 model: test-model
11407 system_prompt: "inline"
11408 system_prompt_file: "conventions.md"
11409 system_prompt_artifact: local/my-skill
11410 driver:
11411 artifact: murmur/driver-anthropic
11412"#,
11413 )
11414 .unwrap_err();
11415
11416 let msg = err.to_string();
11417 assert!(msg.contains("at most one"), "error was: {msg}");
11418 }
11419
11420 #[test]
11423 fn hook_artifact_capabilities_parse_network_and_filesystem() {
11424 let manifest = RuntimeManifest::from_yaml_str(
11425 r#"
11426name: cap
11427version: 0.0.1
11428artifacts:
11429 - name: local/telemetry-hook
11430 version: 1.0.0
11431 runtime: hook
11432 capabilities:
11433 network:
11434 allow:
11435 - https://telemetry.example.com
11436 filesystem:
11437 scope: hook-state
11438"#,
11439 )
11440 .unwrap();
11441
11442 let caps = manifest.artifacts[0]
11443 .capabilities
11444 .as_ref()
11445 .expect("hook entry carries a capability grant");
11446 assert_eq!(
11447 caps.network.as_ref().unwrap().allow,
11448 vec!["https://telemetry.example.com".to_string()]
11449 );
11450 assert_eq!(
11451 caps.filesystem.as_ref().unwrap().scope,
11452 Some("hook-state".to_string())
11453 );
11454 }
11455
11456 #[test]
11459 fn hook_artifact_without_capabilities_is_none() {
11460 let manifest = RuntimeManifest::from_yaml_str(
11461 r#"
11462name: cap
11463version: 0.0.1
11464artifacts:
11465 - name: local/quiet-hook
11466 version: 1.0.0
11467 runtime: hook
11468"#,
11469 )
11470 .unwrap();
11471
11472 assert!(manifest.artifacts[0].capabilities.is_none());
11473 }
11474
11475 #[test]
11478 fn tool_and_driver_artifact_capabilities_parse() {
11479 for role in ["tool", "driver"] {
11480 let yaml = format!(
11481 r#"
11482name: cap
11483version: 0.0.1
11484artifacts:
11485 - name: local/scoped
11486 version: 1.0.0
11487 runtime: {role}
11488 capabilities:
11489 network:
11490 allow:
11491 - https://api.example.com
11492 filesystem:
11493 scope: cache
11494"#
11495 );
11496 let manifest = RuntimeManifest::from_yaml_str(&yaml)
11497 .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
11498
11499 let caps = manifest.artifacts[0]
11500 .capabilities
11501 .as_ref()
11502 .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
11503 assert_eq!(
11504 caps.network.as_ref().unwrap().allow,
11505 vec!["https://api.example.com".to_string()]
11506 );
11507 assert_eq!(
11508 caps.filesystem.as_ref().unwrap().scope,
11509 Some("cache".to_string())
11510 );
11511 }
11512 }
11513
11514 #[test]
11517 fn tool_artifact_without_capabilities_is_none() {
11518 let manifest = RuntimeManifest::from_yaml_str(
11519 r#"
11520name: cap
11521version: 0.0.1
11522artifacts:
11523 - name: local/plain-tool
11524 version: 1.0.0
11525 runtime: tool
11526"#,
11527 )
11528 .unwrap();
11529
11530 assert!(manifest.artifacts[0].capabilities.is_none());
11531 }
11532
11533 #[test]
11536 fn skill_artifact_capabilities_are_rejected() {
11537 let yaml = r#"
11538name: cap
11539version: 0.0.1
11540artifacts:
11541 - name: local/sneaky
11542 version: 1.0.0
11543 runtime: skill
11544 capabilities:
11545 network:
11546 allow:
11547 - https://evil.example.com
11548"#;
11549 let err = match RuntimeManifest::from_yaml_str(yaml) {
11550 Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
11551 Err(err) => err,
11552 };
11553 let msg = err.to_string();
11554 assert!(msg.contains("sneaky"), "error was: {msg}");
11555 assert!(
11556 msg.contains(
11557 "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
11558 entries"
11559 ),
11560 "error was: {msg}"
11561 );
11562 assert!(msg.contains("runtime: skill"), "error was: {msg}");
11563 }
11564
11565 #[test]
11570 fn artifact_config_is_carried_verbatim() {
11571 let manifest = RuntimeManifest::from_yaml_str(
11572 r#"
11573name: cap
11574version: 0.0.1
11575artifacts:
11576 - name: murmur/tool-corpus
11577 version: 0.1.0
11578 runtime: tool
11579 config:
11580 read_recent: { default: 20, max: 100 }
11581"#,
11582 )
11583 .unwrap();
11584
11585 let config = manifest.artifacts[0]
11586 .config
11587 .as_ref()
11588 .expect("a declared block parses");
11589 assert_eq!(
11590 config["read_recent"]["max"],
11591 serde_yaml::Value::Number(100.into())
11592 );
11593 }
11594
11595 #[test]
11597 fn an_undeclared_config_is_none() {
11598 let manifest = RuntimeManifest::from_yaml_str(
11599 r#"
11600name: cap
11601version: 0.0.1
11602artifacts:
11603 - name: local/git
11604 version: 1.0.0
11605 runtime: tool
11606"#,
11607 )
11608 .unwrap();
11609
11610 assert!(manifest.artifacts[0].config.is_none());
11611 }
11612
11613 #[test]
11617 fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
11618 let manifest = RuntimeManifest::from_yaml_str(
11619 r#"
11620name: cap
11621version: 0.0.1
11622artifacts:
11623 - name: local/git
11624 version: 1.0.0
11625 runtime: tool
11626 config:
11627"#,
11628 )
11629 .unwrap();
11630
11631 assert_eq!(
11632 manifest.artifacts[0].config,
11633 Some(serde_yaml::Value::Null),
11634 "an empty block must survive parsing as a declaration, not vanish into None"
11635 );
11636 }
11637
11638 #[test]
11641 fn skill_artifact_config_is_rejected() {
11642 let yaml = r#"
11643name: cap
11644version: 0.0.1
11645artifacts:
11646 - name: local/sneaky
11647 version: 1.0.0
11648 runtime: skill
11649 config:
11650 who: me
11651"#;
11652 let err = match RuntimeManifest::from_yaml_str(yaml) {
11653 Ok(_) => panic!("config: must be rejected for runtime: skill"),
11654 Err(err) => err,
11655 };
11656 assert!(matches!(
11657 err,
11658 RuntimeManifestError::InvalidArtifact { index: 0, .. }
11659 ));
11660 let msg = err.to_string();
11661 assert!(msg.contains("sneaky"), "error was: {msg}");
11662 assert!(msg.contains("runtime: skill"), "error was: {msg}");
11663 assert!(
11664 msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
11665 "error was: {msg}"
11666 );
11667 }
11668
11669 #[test]
11672 fn a_capsule_wide_config_block_is_rejected() {
11673 let yaml = r#"
11674name: cap
11675version: 0.0.1
11676config:
11677 who: capsule
11678artifacts:
11679 - name: local/git
11680 version: 1.0.0
11681 runtime: tool
11682"#;
11683 let err = match RuntimeManifest::from_yaml_str(yaml) {
11684 Ok(_) => panic!("a capsule-wide config block must be rejected"),
11685 Err(err) => err,
11686 };
11687 let msg = err.to_string();
11688 assert!(msg.contains("config"), "error was: {msg}");
11689 assert!(msg.contains("declared per artifact"), "error was: {msg}");
11690 }
11691
11692 #[test]
11696 fn hook_on_overflow_block_parses() {
11697 let manifest = RuntimeManifest::from_yaml_str(
11698 r#"
11699name: cap
11700version: 0.0.1
11701artifacts:
11702 - name: murmur/hook-grafana
11703 version: 1.0.0
11704 runtime: hook
11705 on_overflow: block
11706"#,
11707 )
11708 .unwrap();
11709
11710 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
11711 }
11712
11713 #[test]
11716 fn hook_on_overflow_defaults_to_drop() {
11717 let manifest = RuntimeManifest::from_yaml_str(
11718 r#"
11719name: cap
11720version: 0.0.1
11721artifacts:
11722 - name: murmur/hook-grafana
11723 version: 1.0.0
11724 runtime: hook
11725"#,
11726 )
11727 .unwrap();
11728
11729 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
11730 assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
11731 }
11732
11733 #[test]
11736 fn on_overflow_on_a_non_hook_artifact_is_rejected() {
11737 let yaml = r#"
11738name: cap
11739version: 0.0.1
11740artifacts:
11741 - name: local/git
11742 version: 1.0.0
11743 runtime: tool
11744 on_overflow: block
11745"#;
11746 let err = match RuntimeManifest::from_yaml_str(yaml) {
11747 Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
11748 Err(err) => err,
11749 };
11750 assert!(matches!(
11751 err,
11752 RuntimeManifestError::InvalidArtifact { index: 0, .. }
11753 ));
11754 let msg = err.to_string();
11755 assert!(msg.contains("git"), "error was: {msg}");
11756 assert!(
11757 msg.contains("only recognized on 'runtime: hook' entries"),
11758 "error was: {msg}"
11759 );
11760 }
11761
11762 #[test]
11764 fn unknown_on_overflow_value_is_rejected() {
11765 let yaml = r#"
11766name: cap
11767version: 0.0.1
11768artifacts:
11769 - name: murmur/hook-grafana
11770 version: 1.0.0
11771 runtime: hook
11772 on_overflow: spill
11773"#;
11774 let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
11775 let msg = err.to_string();
11776 assert!(msg.contains("spill"), "error was: {msg}");
11777 assert!(msg.contains("expected: drop, block"), "error was: {msg}");
11778 }
11779
11780 #[test]
11783 fn on_overflow_is_accepted_on_any_hook_entry() {
11784 let manifest = RuntimeManifest::from_yaml_str(
11785 r#"
11786name: cap
11787version: 0.0.1
11788artifacts:
11789 - name: murmur/hook-eval
11790 version: 1.0.0
11791 runtime: hook
11792 on_overflow: drop
11793 capabilities:
11794 network:
11795 allow:
11796 - https://telemetry.example.com
11797"#,
11798 )
11799 .unwrap();
11800
11801 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
11802 assert!(manifest.artifacts[0].capabilities.is_some());
11803 }
11804
11805 #[test]
11808 fn capsule_wide_and_hook_capabilities_coexist() {
11809 let manifest = RuntimeManifest::from_yaml_str(
11810 r#"
11811name: cap
11812version: 0.0.1
11813artifacts:
11814 - name: local/telemetry-hook
11815 version: 1.0.0
11816 runtime: hook
11817 capabilities:
11818 network:
11819 allow:
11820 - https://telemetry.example.com
11821capabilities:
11822 network:
11823 allow:
11824 - https://api.anthropic.com
11825"#,
11826 )
11827 .unwrap();
11828
11829 assert_eq!(
11830 manifest.capabilities.unwrap().network.unwrap().allow,
11831 vec!["https://api.anthropic.com".to_string()]
11832 );
11833 assert_eq!(
11834 manifest.artifacts[0]
11835 .capabilities
11836 .as_ref()
11837 .unwrap()
11838 .network
11839 .as_ref()
11840 .unwrap()
11841 .allow,
11842 vec!["https://telemetry.example.com".to_string()]
11843 );
11844 }
11845
11846 #[test]
11850 fn hook_own_manifest_capabilities_are_not_read_into_config() {
11851 let config = parse_hook_config_from_yaml(
11852 r#"
11853name: telemetry-hook
11854version: 1.0.0
11855binding: on-inference
11856execution_mode: blocking
11857capabilities:
11858 network:
11859 allow:
11860 - https://evil.example.com
11861 filesystem:
11862 scope: /
11863"#,
11864 )
11865 .expect("a self-declared capabilities block is ignored, not an error");
11866
11867 assert_eq!(
11868 config,
11869 HookConfig {
11870 binding: HookBinding::OnInference,
11871 execution_mode: HookExecutionMode::Blocking,
11872 commit_policy: HookCommitPolicy::None,
11873 }
11874 );
11875 }
11876
11877 #[test]
11880 fn containment_classes_order_weakest_to_strongest() {
11881 assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
11882 assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
11883 assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
11884 assert_eq!(
11885 ContainmentClass::ALL.iter().copied().max(),
11886 Some(ContainmentClass::Sealed)
11887 );
11888 }
11889
11890 #[test]
11891 fn containment_class_defaults_to_advisory() {
11892 assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
11893 }
11894
11895 #[test]
11896 fn containment_class_round_trips_through_string() {
11897 for class in ContainmentClass::ALL {
11898 assert_eq!(
11899 class.to_string().parse::<ContainmentClass>(),
11900 Ok(class),
11901 "{class} must survive Display -> FromStr"
11902 );
11903 }
11904 assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
11905 assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
11906 assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
11907 }
11908
11909 #[test]
11910 fn containment_class_rejects_unknown_names() {
11911 let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
11912 assert_eq!(err.value, "paranoid");
11913 assert_eq!(
11914 err.to_string(),
11915 "must be one of: advisory, scoped, sealed; got 'paranoid'"
11916 );
11917 assert!("Sealed".parse::<ContainmentClass>().is_err());
11919 }
11920
11921 #[test]
11922 fn containment_class_serializes_as_its_wire_name() {
11923 assert_eq!(
11924 serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
11925 "\"scoped\""
11926 );
11927 assert_eq!(
11928 serde_yaml::from_str::<ContainmentClass>("sealed").unwrap(),
11929 ContainmentClass::Sealed
11930 );
11931 }
11932
11933 #[test]
11934 fn effective_floor_is_advisory_when_nothing_is_declared() {
11935 assert_eq!(
11936 effective_containment_floor(None, None, None),
11937 ContainmentClass::Advisory
11938 );
11939 }
11940
11941 #[test]
11942 fn effective_floor_takes_the_strongest_across_every_presence_combination() {
11943 use ContainmentClass::{Advisory, Scoped, Sealed};
11944
11945 assert_eq!(
11947 effective_containment_floor(Some(Scoped), None, None),
11948 Scoped
11949 );
11950 assert_eq!(
11951 effective_containment_floor(None, Some(Scoped), None),
11952 Scoped
11953 );
11954 assert_eq!(
11955 effective_containment_floor(None, None, Some(Scoped)),
11956 Scoped
11957 );
11958 assert_eq!(
11959 effective_containment_floor(Some(Sealed), None, None),
11960 Sealed
11961 );
11962 assert_eq!(
11963 effective_containment_floor(None, Some(Sealed), None),
11964 Sealed
11965 );
11966 assert_eq!(
11967 effective_containment_floor(None, None, Some(Sealed)),
11968 Sealed
11969 );
11970
11971 assert_eq!(
11974 effective_containment_floor(Some(Advisory), Some(Sealed), None),
11975 Sealed
11976 );
11977 assert_eq!(
11978 effective_containment_floor(Some(Sealed), Some(Advisory), None),
11979 Sealed
11980 );
11981 assert_eq!(
11982 effective_containment_floor(Some(Advisory), None, Some(Scoped)),
11983 Scoped
11984 );
11985 assert_eq!(
11986 effective_containment_floor(Some(Scoped), None, Some(Advisory)),
11987 Scoped
11988 );
11989 assert_eq!(
11990 effective_containment_floor(None, Some(Advisory), Some(Scoped)),
11991 Scoped
11992 );
11993 assert_eq!(
11994 effective_containment_floor(None, Some(Scoped), Some(Advisory)),
11995 Scoped
11996 );
11997
11998 assert_eq!(
12000 effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
12001 Sealed
12002 );
12003 assert_eq!(
12004 effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
12005 Sealed
12006 );
12007 assert_eq!(
12008 effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
12009 Sealed
12010 );
12011 assert_eq!(
12012 effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
12013 Scoped
12014 );
12015 assert_eq!(
12016 effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
12017 Advisory
12018 );
12019 }
12020
12021 #[test]
12022 fn manifest_parses_each_declared_containment_class() {
12023 for class in ContainmentClass::ALL {
12024 let manifest = RuntimeManifest::from_yaml_str(&format!(
12025 r#"
12026name: cap
12027version: 0.0.1
12028capabilities:
12029 containment: {class}
12030"#
12031 ))
12032 .expect("a valid containment class parses");
12033
12034 assert_eq!(
12035 manifest.capabilities.unwrap().containment,
12036 Some(class),
12037 "capabilities.containment: {class}"
12038 );
12039 }
12040 }
12041
12042 #[test]
12043 fn manifest_without_containment_key_declares_nothing() {
12044 let manifest = RuntimeManifest::from_yaml_str(
12046 r#"
12047name: cap
12048version: 0.0.1
12049"#,
12050 )
12051 .unwrap();
12052 assert!(manifest.capabilities.is_none());
12053
12054 let manifest = RuntimeManifest::from_yaml_str(
12057 r#"
12058name: cap
12059version: 0.0.1
12060capabilities:
12061 network:
12062 allow:
12063 - https://api.example.com
12064"#,
12065 )
12066 .unwrap();
12067 assert_eq!(manifest.capabilities.unwrap().containment, None);
12068 }
12069
12070 #[test]
12071 fn manifest_rejects_an_unknown_containment_class() {
12072 let err = RuntimeManifest::from_yaml_str(
12073 r#"
12074name: cap
12075version: 0.0.1
12076capabilities:
12077 containment: paranoid
12078"#,
12079 )
12080 .expect_err("an unknown containment class is rejected at parse time");
12081
12082 match err {
12083 RuntimeManifestError::InvalidCapabilities { field, message } => {
12084 assert_eq!(field, "capabilities.containment");
12085 assert_eq!(
12086 message,
12087 "must be one of: advisory, scoped, sealed; got 'paranoid'"
12088 );
12089 }
12090 other => panic!("expected InvalidCapabilities, got {other:?}"),
12091 }
12092 }
12093
12094 fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
12097 RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
12098 }
12099
12100 fn export_error(block: &str) -> (String, String) {
12101 match exports_manifest(block).expect_err("this exports block must not parse") {
12102 RuntimeManifestError::InvalidExports { field, message } => (field, message),
12103 other => panic!("expected InvalidExports, got {other:?}"),
12104 }
12105 }
12106
12107 fn peer_fetch_error(block: &str) -> (String, String) {
12108 match exports_manifest(block).expect_err("this capabilities block must not parse") {
12109 RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
12110 other => panic!("expected InvalidCapabilities, got {other:?}"),
12111 }
12112 }
12113
12114 #[test]
12117 fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
12118 assert_eq!(parse_duration_secs("90").unwrap(), 90);
12119 assert_eq!(parse_duration_secs("30s").unwrap(), 30);
12120 assert_eq!(parse_duration_secs("15m").unwrap(), 900);
12121 assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
12122 assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
12123 assert_eq!(parse_duration_secs(" 2h ").unwrap(), 7200);
12124 assert_eq!(parse_duration_secs("0").unwrap(), 0);
12125 }
12126
12127 #[test]
12130 fn parse_duration_secs_rejects_every_other_spelling() {
12131 for input in [
12132 "",
12133 " ",
12134 "5 minutes",
12135 "30S",
12136 "1H",
12137 "5min",
12138 "1.5h",
12139 "-1",
12140 "m",
12141 "h",
12142 "1h30m",
12143 "0x10",
12144 "30 s",
12145 ] {
12146 let error = parse_duration_secs(input)
12147 .expect_err(&format!("'{input}' must not parse as a duration"));
12148 assert!(
12149 error.contains(DURATION_ACCEPTED_FORM),
12150 "'{input}' must be refused with the accepted form; got: {error}"
12151 );
12152 }
12153 }
12154
12155 #[test]
12156 fn parse_duration_secs_refuses_to_overflow() {
12157 assert!(parse_duration_secs("18446744073709551615h")
12158 .unwrap_err()
12159 .contains("overflows"));
12160 }
12161
12162 #[test]
12163 fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
12164 let manifest = exports_manifest("exports:\n peer_files:\n root: out/\n")
12165 .expect("a minimal peer_files block parses");
12166 let peer = manifest.exports.unwrap().peer_files.unwrap();
12167 assert_eq!(peer.root, "out/");
12168 assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
12169 assert_eq!(peer.max_bytes, 10_485_760);
12170 assert_eq!(peer.max_ttl_secs, None);
12173 assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
12174 assert_eq!(peer.effective_max_ttl_secs(), 3600);
12175 }
12176
12177 #[test]
12178 fn peer_files_accepts_every_duration_spelling() {
12179 for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
12180 let manifest = exports_manifest(&format!(
12181 "exports:\n peer_files:\n root: out/\n max_ttl: {declared}\n"
12182 ))
12183 .expect("a declared max_ttl parses");
12184 let peer = manifest.exports.unwrap().peer_files.unwrap();
12185 assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
12186 assert_eq!(peer.effective_max_ttl_secs(), expected);
12187 }
12188 }
12189
12190 #[test]
12193 fn the_two_export_blocks_are_independent() {
12194 let both = exports_manifest(
12195 "exports:\n files:\n root: out/\n mode: read-only\n \
12196 peer_files:\n root: out/handoff/\n",
12197 )
12198 .expect("both blocks parse together")
12199 .exports
12200 .unwrap();
12201 assert_eq!(both.files.unwrap().root, "out/");
12202 assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
12203
12204 let only_files =
12205 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
12206 .unwrap()
12207 .exports
12208 .unwrap();
12209 assert!(only_files.peer_files.is_none());
12210
12211 let only_peer = exports_manifest("exports:\n peer_files:\n root: out/\n")
12212 .unwrap()
12213 .exports
12214 .unwrap();
12215 assert!(only_peer.files.is_none());
12216
12217 let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
12218 assert!(neither.files.is_none() && neither.peer_files.is_none());
12219 }
12220
12221 #[test]
12222 fn peer_files_root_must_be_relative_and_inside_the_workdir() {
12223 for root in ["../out", "/etc", "out/../../etc"] {
12224 let (field, message) =
12225 export_error(&format!("exports:\n peer_files:\n root: {root}\n"));
12226 assert_eq!(field, "exports.peer_files.root", "root: {root}");
12227 assert!(
12228 message.contains(EXPORT_ROOT_ACCEPTED_FORM),
12229 "root: {root}; got: {message}"
12230 );
12231 }
12232 }
12233
12234 #[test]
12235 fn peer_files_root_is_required() {
12236 let (field, message) = export_error("exports:\n peer_files: {}\n");
12237 assert_eq!(field, "exports.peer_files.root");
12238 assert!(message.contains("is required"));
12239 assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
12240 }
12241
12242 #[test]
12243 fn peer_files_max_ttl_states_its_accepted_form() {
12244 for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
12245 let (field, message) = export_error(&format!(
12246 "exports:\n peer_files:\n root: out/\n max_ttl: \"{declared}\"\n"
12247 ));
12248 assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
12249 assert!(
12250 message.contains(DURATION_ACCEPTED_FORM),
12251 "max_ttl: {declared}; got: {message}"
12252 );
12253 }
12254 }
12255
12256 #[test]
12259 fn peer_files_max_ttl_rejects_zero() {
12260 let (field, message) =
12261 export_error("exports:\n peer_files:\n root: out/\n max_ttl: 0\n");
12262 assert_eq!(field, "exports.peer_files.max_ttl");
12263 assert!(message.contains("greater than zero"));
12264 }
12265
12266 #[test]
12267 fn peer_files_max_bytes_states_its_accepted_form() {
12268 let (field, message) =
12269 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 10MB\n");
12270 assert_eq!(field, "exports.peer_files.max_bytes");
12271 assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
12272 }
12273
12274 #[test]
12275 fn peer_files_max_bytes_rejects_zero() {
12276 let (field, message) =
12277 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 0\n");
12278 assert_eq!(field, "exports.peer_files.max_bytes");
12279 assert!(message.contains("greater than zero"));
12280 }
12281
12282 #[test]
12286 fn peer_tasks_consents_only_on_an_explicit_true() {
12287 let absent = exports_manifest("artifacts: []\n").expect("no exports block parses");
12288 assert!(absent.exports.is_none());
12289 assert!(!absent.accepts_peer_tasks());
12290
12291 let refused = exports_manifest("exports:\n peer_tasks:\n accept: false\n")
12292 .expect("an explicit refusal parses");
12293 assert_eq!(
12294 refused.exports.as_ref().unwrap().peer_tasks,
12295 Some(PeerTasksExport { accept: false })
12296 );
12297 assert!(!refused.accepts_peer_tasks());
12298 assert!(!refused.exports.as_ref().unwrap().accepts_peer_tasks());
12299
12300 let accepted = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
12301 .expect("consent parses");
12302 assert_eq!(
12303 accepted.exports.as_ref().unwrap().peer_tasks,
12304 Some(PeerTasksExport { accept: true })
12305 );
12306 assert!(accepted.accepts_peer_tasks());
12307 assert!(accepted.exports.as_ref().unwrap().accepts_peer_tasks());
12308 }
12309
12310 #[test]
12312 fn peer_tasks_alone_declares_no_file_plane() {
12313 let manifest = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
12314 .expect("a peer_tasks-only exports block parses");
12315 let exports = manifest.exports.unwrap();
12316 assert_eq!(exports.files, None);
12317 assert_eq!(exports.peer_files, None);
12318 }
12319
12320 #[test]
12322 fn a_file_export_does_not_consent_to_peer_tasks() {
12323 let manifest =
12324 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
12325 .expect("a files export parses");
12326 assert_eq!(manifest.exports.as_ref().unwrap().peer_tasks, None);
12327 assert!(!manifest.accepts_peer_tasks());
12328 }
12329
12330 #[test]
12333 fn peer_tasks_without_accept_is_refused() {
12334 let (field, message) = export_error("exports:\n peer_tasks: {}\n");
12335 assert_eq!(field, "exports.peer_tasks.accept");
12336 assert_eq!(
12337 message,
12338 "must be set explicitly to true or false — consent to peer tasks is never inferred"
12339 );
12340 }
12341
12342 #[test]
12344 fn peer_tasks_of_the_wrong_type_is_refused_naming_the_field() {
12345 for (block, field) in [
12346 (
12347 "exports:\n peer_tasks:\n accept: \"yes\"\n",
12348 "exports.peer_tasks.accept",
12349 ),
12350 (
12351 "exports:\n peer_tasks:\n accept: 1\n",
12352 "exports.peer_tasks.accept",
12353 ),
12354 ("exports:\n peer_tasks: true\n", "exports.peer_tasks"),
12355 ("exports:\n peer_tasks: [peer-a]\n", "exports.peer_tasks"),
12356 ] {
12357 let message = exports_manifest(block)
12358 .expect_err(&format!("{block:?} must not parse"))
12359 .to_string();
12360 assert!(message.contains(field), "{block:?}: {message}");
12361 }
12362 }
12363
12364 #[test]
12367 fn an_unknown_peer_tasks_key_is_reported_by_path() {
12368 let manifest =
12369 exports_manifest("exports:\n peer_tasks:\n accept: false\n allow: [peer-a]\n")
12370 .expect("an unknown key is a warning, not a parse error");
12371 assert!(
12372 manifest
12373 .unknown_keys
12374 .iter()
12375 .any(|key| key.block_path == "exports.peer_tasks" && key.key == "allow"),
12376 "{:?}",
12377 manifest.unknown_keys
12378 );
12379 assert!(!manifest.accepts_peer_tasks());
12380 }
12381
12382 #[test]
12385 fn peer_fetch_allow_carries_its_declared_destinations() {
12386 let manifest = exports_manifest(
12387 "capabilities:\n peer_fetch:\n allow:\n - localhost:41234\n \
12388 - reporting.internal\n - https://gateway.example.com\n",
12389 )
12390 .expect("a peer_fetch block parses");
12391 assert_eq!(
12392 manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
12393 vec![
12394 "localhost:41234".to_string(),
12395 "reporting.internal".to_string(),
12396 "https://gateway.example.com".to_string(),
12397 ]
12398 );
12399 }
12400
12401 #[test]
12404 fn peer_fetch_allow_must_not_be_empty() {
12405 let (field, message) = peer_fetch_error("capabilities:\n peer_fetch:\n allow: []\n");
12406 assert_eq!(field, "capabilities.peer_fetch.allow");
12407 assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
12408 }
12409
12410 #[test]
12411 fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
12412 for entry in [
12413 "http://[not a host",
12414 "\"\"",
12415 "localhost:notaport",
12416 "http://example.com/path",
12417 "ftp://example.com",
12418 ] {
12419 let (field, message) = peer_fetch_error(&format!(
12420 "capabilities:\n peer_fetch:\n allow:\n - {entry}\n"
12421 ));
12422 assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
12423 assert!(
12424 message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
12425 "entry: {entry}; got: {message}"
12426 );
12427 }
12428 }
12429
12430 #[test]
12433 fn peer_fetch_does_not_widen_network_allow() {
12434 let capabilities = exports_manifest(
12435 "capabilities:\n network:\n allow:\n - api.example.com\n \
12436 peer_fetch:\n allow:\n - localhost:41234\n",
12437 )
12438 .unwrap()
12439 .capabilities
12440 .unwrap();
12441 assert_eq!(
12442 capabilities.network.unwrap().allow,
12443 vec!["api.example.com".to_string()]
12444 );
12445 assert_eq!(
12446 capabilities.peer_fetch.unwrap().allow,
12447 vec!["localhost:41234".to_string()]
12448 );
12449 }
12450
12451 #[test]
12452 fn an_absent_peer_fetch_block_is_absent() {
12453 assert!(exports_manifest("capabilities: {}\n")
12454 .unwrap()
12455 .capabilities
12456 .unwrap()
12457 .peer_fetch
12458 .is_none());
12459 }
12460
12461 #[test]
12462 fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
12463 assert_eq!(parse_byte_size("0").unwrap(), 0);
12464 assert_eq!(parse_byte_size("4096").unwrap(), 4096);
12465 assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
12466 assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
12467 assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
12468 assert_eq!(parse_byte_size(" 10Mi ").unwrap(), 10 * 1024 * 1024);
12469 }
12470
12471 #[test]
12474 fn parse_byte_size_rejects_every_other_spelling() {
12475 for input in [
12476 "", " ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
12477 "1Ki1",
12478 ] {
12479 let error = parse_byte_size(input)
12480 .expect_err(&format!("'{input}' must not parse as a byte size"));
12481 assert!(
12482 error.contains(BYTE_SIZE_ACCEPTED_FORM),
12483 "'{input}' must be refused with the accepted form; got: {error}"
12484 );
12485 }
12486 }
12487
12488 #[test]
12489 fn parse_byte_size_refuses_to_overflow() {
12490 assert!(parse_byte_size("18446744073709551615Gi")
12491 .unwrap_err()
12492 .contains("overflows"));
12493 }
12494
12495 #[test]
12496 fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
12497 let manifest =
12498 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
12499 .expect("a minimal exports block parses");
12500 let files = manifest.exports.unwrap().files.unwrap();
12501 assert_eq!(files.root, "out/");
12502 assert_eq!(files.mode, ExportMode::ReadOnly);
12503 assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
12504 assert_eq!(files.max_bytes, 10_485_760);
12505 }
12506
12507 #[test]
12508 fn exports_files_accepts_both_byte_spellings() {
12509 for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
12510 let manifest = exports_manifest(&format!(
12511 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: {declared}\n"
12512 ))
12513 .expect("a declared max_bytes parses");
12514 assert_eq!(
12515 manifest.exports.unwrap().files.unwrap().max_bytes,
12516 expected,
12517 "max_bytes: {declared}"
12518 );
12519 }
12520 }
12521
12522 #[test]
12525 fn an_exports_block_without_files_declares_no_plane() {
12526 let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
12527 assert!(manifest.exports.unwrap().files.is_none());
12528 }
12529
12530 #[test]
12531 fn an_absent_exports_block_is_absent() {
12532 let manifest = exports_manifest("").expect("a manifest without exports parses");
12533 assert!(manifest.exports.is_none());
12534 }
12535
12536 #[test]
12537 fn exports_files_root_must_be_relative_and_inside_the_workdir() {
12538 for root in ["../out", "/etc", "out/../../etc", "''"] {
12539 let (field, message) = export_error(&format!(
12540 "exports:\n files:\n root: {root}\n mode: read-only\n"
12541 ));
12542 assert_eq!(field, "exports.files.root", "root: {root}");
12543 assert!(
12544 message.contains("must be a relative path inside the workdir"),
12545 "root: {root}; got: {message}"
12546 );
12547 }
12548 }
12549
12550 #[test]
12551 fn exports_files_root_is_required() {
12552 let (field, message) = export_error("exports:\n files:\n mode: read-only\n");
12553 assert_eq!(field, "exports.files.root");
12554 assert_eq!(
12555 message,
12556 "is required and must be a relative path inside the workdir"
12557 );
12558 }
12559
12560 #[test]
12561 fn exports_files_mode_is_required_and_read_only() {
12562 let (field, message) = export_error("exports:\n files:\n root: out/\n");
12563 assert_eq!(field, "exports.files.mode");
12564 assert_eq!(message, "is required and must be 'read-only'");
12565
12566 let (field, message) =
12567 export_error("exports:\n files:\n root: out/\n mode: read-write\n");
12568 assert_eq!(field, "exports.files.mode");
12569 assert_eq!(message, "'read-write' must be 'read-only'");
12570 }
12571
12572 #[test]
12573 fn exports_files_max_bytes_states_its_accepted_form() {
12574 let (field, message) = export_error(
12575 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 10MB\n",
12576 );
12577 assert_eq!(field, "exports.files.max_bytes");
12578 assert_eq!(
12579 message,
12580 "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
12581 );
12582 }
12583
12584 #[test]
12587 fn exports_files_max_bytes_rejects_zero() {
12588 let (field, message) = export_error(
12589 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 0\n",
12590 );
12591 assert_eq!(field, "exports.files.max_bytes");
12592 assert!(message.contains("must be greater than zero"), "{message}");
12593 }
12594
12595 #[test]
12597 fn exports_files_root_accepts_a_current_dir_prefix() {
12598 let manifest =
12599 exports_manifest("exports:\n files:\n root: ./out\n mode: read-only\n")
12600 .expect("./out is a legal root");
12601 assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
12602 }
12603
12604 fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
12607 RuntimeManifest::from_yaml_str(yaml)
12608 .expect("fixture must parse")
12609 .unknown_keys
12610 .into_iter()
12611 .map(|key| (key.key, key.block_path, key.nearest_known))
12612 .collect()
12613 }
12614
12615 #[test]
12618 fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
12619 assert_eq!(
12620 unknown_keys_of(
12621 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n read-only:\n - tests\n"
12622 ),
12623 vec![(
12624 "read-only".to_string(),
12625 "capabilities.filesystem".to_string(),
12626 Some("read_only".to_string())
12627 )]
12628 );
12629 }
12630
12631 #[test]
12635 fn a_manifest_of_recognized_keys_reports_nothing() {
12636 assert!(unknown_keys_of(
12637 r#"
12638name: cap
12639version: 0.1.0
12640deploy:
12641 mur_version: "0.2.0"
12642artifacts:
12643 - name: local/notes-tool
12644 version: 0.1.0
12645 runtime: tool
12646 capabilities:
12647 filesystem:
12648 scope: notes
12649 network:
12650 allow:
12651 - example.com:443
12652capabilities:
12653 filesystem:
12654 read_only:
12655 - tests
12656 - bench/fixtures
12657 workdir_exec: false
12658 shell:
12659 allow:
12660 - git
12661 - python3
12662 network:
12663 allow:
12664 - example.com:443
12665 unix_sockets: false
12666"#
12667 )
12668 .is_empty());
12669 }
12670
12671 #[test]
12674 fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
12675 assert_eq!(
12676 unknown_keys_of(
12677 "name: cap\nversion: 0.1.0\nartifacts:\n - name: local/t\n version: 0.1.0\n \
12678 runtime: tool\n capabilities:\n shell:\n allow:\n - git\n \
12679 allwo:\n - git\n"
12680 ),
12681 vec![(
12682 "allwo".to_string(),
12683 "artifacts[0].capabilities.shell".to_string(),
12684 Some("allow".to_string())
12685 )]
12686 );
12687 }
12688
12689 #[test]
12692 fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
12693 assert_eq!(
12694 unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
12695 vec![("quantum_teleport".to_string(), String::new(), None)]
12696 );
12697 }
12698
12699 #[test]
12704 fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
12705 assert!(unknown_keys_of(
12706 "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
12707 )
12708 .is_empty());
12709 }
12710
12711 #[test]
12713 fn every_build_side_key_is_recognized() {
12714 assert!(unknown_keys_of(
12715 "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
12716 wasm\nrequires_files:\n - tool.wasm\n - assets/logo.png\n"
12717 )
12718 .is_empty());
12719 }
12720
12721 #[test]
12724 fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
12725 assert_eq!(
12726 unknown_keys_of(
12727 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
12728 static\nrequires_files:\n - capsule.wasm\nquantum_teleport: true\n"
12729 ),
12730 vec![("quantum_teleport".to_string(), String::new(), None)]
12731 );
12732 }
12733
12734 #[test]
12738 fn the_build_side_keys_change_nothing_the_run_path_reads() {
12739 let body = "artifacts:\n - name: local/notes-tool\n version: 0.1.0\n runtime: tool\n\
12740 \x20 - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: \
12741 driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\n\
12742 capabilities:\n filesystem:\n read_only:\n - tests\n\
12743 inference:\n transport: http\n model: test-model\n driver:\n \
12744 artifact: murmur/driver-anthropic\n";
12745 let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
12746 .expect("fixture must parse");
12747 let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
12748 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n \
12749 any: shape\nrequires_files: not-a-list\n{body}"
12750 ))
12751 .expect("a build-side key of any shape never refuses a manifest");
12752
12753 assert!(with_build_keys.unknown_keys.is_empty());
12754 assert!(
12755 !plain.artifacts.is_empty()
12756 && plain.capabilities.is_some()
12757 && plain.inference.is_some(),
12758 "the fixture must populate the blocks the comparison below rests on, or it \
12759 compares absence against absence"
12760 );
12761 assert_eq!(with_build_keys.name, plain.name);
12762 assert_eq!(with_build_keys.version, plain.version);
12763 assert_eq!(with_build_keys.artifacts, plain.artifacts);
12764 assert_eq!(with_build_keys.capabilities, plain.capabilities);
12765 assert_eq!(with_build_keys.inference, plain.inference);
12766 }
12767
12768 #[test]
12771 fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
12772 let manifest = RuntimeManifest::from_yaml_str(
12773 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n scope: notes\n \
12774 read-only:\n - tests\n",
12775 )
12776 .expect("an unrecognized key never refuses a manifest");
12777 assert_eq!(
12778 manifest
12779 .capabilities
12780 .as_ref()
12781 .and_then(|caps| caps.filesystem.as_ref())
12782 .and_then(|fs| fs.scope.as_deref()),
12783 Some("notes")
12784 );
12785 assert_eq!(manifest.unknown_keys.len(), 1);
12786 }
12787
12788 #[test]
12790 fn nested_blocks_outside_capabilities_are_walked_too() {
12791 let keys = unknown_keys_of(
12792 "name: cap\nversion: 0.1.0\ncontext:\n max_tokns: 100\nexports:\n files:\n \
12793 root: out\n mode: read-only\n max_byts: 10\n",
12794 );
12795 assert_eq!(
12796 keys,
12797 vec![
12798 (
12799 "max_tokns".to_string(),
12800 "context".to_string(),
12801 Some("max_tokens".to_string())
12802 ),
12803 (
12804 "max_byts".to_string(),
12805 "exports.files".to_string(),
12806 Some("max_bytes".to_string())
12807 ),
12808 ]
12809 );
12810 }
12811
12812 #[test]
12819 fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
12820 let source = include_str!("runtime_manifest.rs");
12821 let lines: Vec<&str> = source.lines().collect();
12822
12823 let mut checked = 0usize;
12824 for (start, line) in lines.iter().enumerate() {
12825 let Some(name) = line
12826 .strip_prefix("struct Raw")
12827 .and_then(|rest| rest.strip_suffix(" {"))
12828 else {
12829 continue;
12830 };
12831 let name = format!("Raw{name}");
12832 let end = (start + 1..lines.len())
12833 .find(|index| lines[*index] == "}")
12834 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
12835 let body = &lines[start + 1..end];
12836
12837 assert!(
12838 body.contains(&" #[serde(flatten)]")
12839 && body.contains(&" unknown: UnknownKeys,"),
12840 "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
12841 so a key it does not recognize would be dropped instead of reported"
12842 );
12843
12844 let declared = serde_field_names(body);
12845 let known = known_keys_of(source, &name);
12846 assert_eq!(
12847 declared, known,
12848 "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
12849 suggester would propose the wrong set for that block"
12850 );
12851 checked += 1;
12852 }
12853 assert!(
12854 checked >= 30,
12855 "expected every Raw* struct to be scanned, found only {checked}"
12856 );
12857 }
12858
12859 #[test]
12866 fn every_raw_struct_descends_into_the_blocks_it_owns() {
12867 let source = include_str!("runtime_manifest.rs");
12868 let lines: Vec<&str> = source.lines().collect();
12869
12870 let mut structs = 0usize;
12871 let mut children = 0usize;
12872 for (start, line) in lines.iter().enumerate() {
12873 let Some(name) = line
12874 .strip_prefix("struct Raw")
12875 .and_then(|rest| rest.strip_suffix(" {"))
12876 else {
12877 continue;
12878 };
12879 let name = format!("Raw{name}");
12880 let end = (start + 1..lines.len())
12881 .find(|index| lines[*index] == "}")
12882 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
12883 let owned = block_typed_fields(&lines[start + 1..end]);
12884 structs += 1;
12885
12886 if owned.is_empty() {
12887 continue;
12888 }
12889 let block = raw_block_impl_of(source, &name);
12890 assert!(
12891 block.contains("fn walk_children"),
12892 "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
12893 `walk_children`, so every key written inside {} would go unreported",
12894 if owned.len() == 1 { "" } else { "s" },
12895 owned.join(", "),
12896 if owned.len() == 1 { "it" } else { "them" }
12897 );
12898 for field in &owned {
12899 assert!(
12900 mentions_field(&block, field),
12901 "{name}'s `walk_children` never touches `self.{field}`, so every key written \
12902 inside that block would go unreported — descend into it with \
12903 `collect_block(.., &child_path(path, \"{field}\"), out)`"
12904 );
12905 children += 1;
12906 }
12907 }
12908 assert!(
12909 structs >= 30,
12910 "expected every Raw* struct to be scanned, found only {structs}"
12911 );
12912 assert!(
12913 children >= 31,
12914 "expected every nested block field to be scanned, found only {children}"
12915 );
12916 }
12917
12918 fn mentions_field(impl_body: &str, field: &str) -> bool {
12922 let needle = format!("self.{field}");
12923 impl_body.match_indices(&needle).any(|(at, _)| {
12924 impl_body[at + needle.len()..]
12925 .chars()
12926 .next()
12927 .is_none_or(|next| !next.is_alphanumeric() && next != '_')
12928 })
12929 }
12930
12931 fn block_typed_fields(body: &[&str]) -> Vec<String> {
12935 let mut fields = Vec::new();
12936 for line in body {
12937 let trimmed = line.trim();
12938 if trimmed.starts_with("//") || trimmed.starts_with("#[") {
12939 continue;
12940 }
12941 let Some((field, declared)) = line
12942 .strip_prefix(" ")
12943 .and_then(|rest| rest.split_once(':'))
12944 else {
12945 continue;
12946 };
12947 if field.is_empty()
12948 || !field
12949 .chars()
12950 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
12951 {
12952 continue;
12953 }
12954 if declared.contains("Raw") {
12955 fields.push(field.to_string());
12956 }
12957 }
12958 fields
12959 }
12960
12961 fn raw_block_impl_of(source: &str, name: &str) -> String {
12964 let header = format!("impl RawBlock for {name} {{");
12965 let body = source
12966 .split_once(&header)
12967 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
12968 .1;
12969 body.split_once("\n}\n")
12970 .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
12971 .0
12972 .to_string()
12973 }
12974
12975 fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
12982 let raw: RawRuntimeManifest = serde_yaml::from_str(yaml).expect("fixture must parse");
12983 collect_unknown_keys(&raw)
12984 .into_iter()
12985 .map(|key| (key.key, key.block_path))
12986 .collect()
12987 }
12988
12989 #[test]
12993 fn every_block_reports_its_own_unknown_key_at_its_own_path() {
12994 let keys = raw_unknown_keys(
12995 r#"
12996name: every-block
12997version: 0.1.0
12998probe_root: 1
12999artifacts:
13000 - name: local/t
13001 version: 0.1.0
13002 runtime: tool
13003 probe_artifact: 1
13004 capabilities:
13005 probe_artifact_capabilities: 1
13006 shell:
13007 probe_artifact_shell: 1
13008capabilities:
13009 probe_capabilities: 1
13010 network:
13011 probe_network: 1
13012 peer_fetch:
13013 probe_peer_fetch: 1
13014 filesystem:
13015 probe_filesystem: 1
13016 shell:
13017 probe_shell: 1
13018 interpreter_runtime:
13019 - binary: python3
13020 probe_interpreter_runtime: 1
13021 dirs:
13022 - path: /usr/lib/python3
13023 probe_interpreter_runtime_dir: 1
13024 staged_runtime:
13025 - binary: rg
13026 probe_staged_runtime: 1
13027 spawn:
13028 probe_spawn: 1
13029 env:
13030 probe_env: 1
13031 limits:
13032 probe_limits: 1
13033 resources:
13034 probe_resources: 1
13035 state:
13036 probe_state: 1
13037 task_io:
13038 probe_task_io: 1
13039 conversation:
13040 probe_conversation: 1
13041 install:
13042 probe_install: 1
13043inference:
13044 probe_inference: 1
13045 driver:
13046 probe_driver: 1
13047 compaction:
13048 probe_compaction: 1
13049context:
13050 probe_context: 1
13051observability:
13052 probe_observability: 1
13053 eval:
13054 probe_eval: 1
13055 scorers:
13056 - type: exact_match
13057 probe_scorer: 1
13058trace:
13059 probe_trace: 1
13060network:
13061 probe_network_config: 1
13062lifecycle:
13063 probe_lifecycle: 1
13064exports:
13065 probe_exports: 1
13066 files:
13067 probe_files: 1
13068 peer_files:
13069 probe_peer_files: 1
13070control:
13071 probe_control: 1
13072"#,
13073 );
13074
13075 let expected: Vec<(String, String)> = [
13076 ("probe_root", ""),
13077 ("probe_artifact", "artifacts[0]"),
13078 ("probe_artifact_capabilities", "artifacts[0].capabilities"),
13079 ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
13080 ("probe_capabilities", "capabilities"),
13081 ("probe_network", "capabilities.network"),
13082 ("probe_peer_fetch", "capabilities.peer_fetch"),
13083 ("probe_filesystem", "capabilities.filesystem"),
13084 ("probe_shell", "capabilities.shell"),
13085 (
13086 "probe_interpreter_runtime",
13087 "capabilities.shell.interpreter_runtime[0]",
13088 ),
13089 (
13090 "probe_interpreter_runtime_dir",
13091 "capabilities.shell.interpreter_runtime[0].dirs[0]",
13092 ),
13093 (
13094 "probe_staged_runtime",
13095 "capabilities.shell.staged_runtime[0]",
13096 ),
13097 ("probe_spawn", "capabilities.spawn"),
13098 ("probe_env", "capabilities.env"),
13099 ("probe_limits", "capabilities.limits"),
13100 ("probe_resources", "capabilities.resources"),
13101 ("probe_state", "capabilities.state"),
13102 ("probe_task_io", "capabilities.task_io"),
13103 ("probe_conversation", "capabilities.conversation"),
13104 ("probe_install", "capabilities.install"),
13105 ("probe_inference", "inference"),
13106 ("probe_driver", "inference.driver"),
13107 ("probe_compaction", "inference.compaction"),
13108 ("probe_context", "context"),
13109 ("probe_observability", "observability"),
13110 ("probe_eval", "observability.eval"),
13111 ("probe_scorer", "observability.eval.scorers[0]"),
13112 ("probe_trace", "trace"),
13113 ("probe_network_config", "network"),
13114 ("probe_lifecycle", "lifecycle"),
13115 ("probe_exports", "exports"),
13116 ("probe_files", "exports.files"),
13117 ("probe_peer_files", "exports.peer_files"),
13118 ("probe_control", "control"),
13119 ]
13120 .into_iter()
13121 .map(|(key, path)| (key.to_string(), path.to_string()))
13122 .collect();
13123
13124 assert_eq!(keys, expected);
13125 }
13126
13127 fn serde_field_names(body: &[&str]) -> Vec<String> {
13131 let mut names = Vec::new();
13132 let mut rename: Option<String> = None;
13133 for line in body {
13134 let trimmed = line.trim();
13135 if trimmed.starts_with("#[") {
13136 if let Some(rest) = trimmed.split("rename = \"").nth(1) {
13137 if !trimmed.contains("rename_all") {
13138 rename = rest.split('"').next().map(str::to_string);
13139 }
13140 }
13141 continue;
13142 }
13143 if trimmed.starts_with("//") {
13144 continue;
13145 }
13146 let Some(field) = line
13147 .strip_prefix(" ")
13148 .and_then(|rest| rest.split(':').next())
13149 else {
13150 continue;
13151 };
13152 if field.is_empty()
13153 || !field
13154 .chars()
13155 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
13156 {
13157 continue;
13158 }
13159 if field == "unknown" {
13160 rename = None;
13161 continue;
13162 }
13163 names.push(rename.take().unwrap_or_else(|| field.to_string()));
13164 }
13165 names
13166 }
13167
13168 fn known_keys_of(source: &str, name: &str) -> Vec<String> {
13172 let header = format!("impl RawBlock for {name} {{");
13173 let body = source
13174 .split_once(&header)
13175 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
13176 .1;
13177 let array = body
13178 .split_once("KNOWN_KEYS: &'static [&'static str] = &[")
13179 .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
13180 .1
13181 .split_once("];")
13182 .expect("KNOWN_KEYS array is unterminated")
13183 .0;
13184 array
13185 .split('"')
13186 .skip(1)
13187 .step_by(2)
13188 .map(str::to_string)
13189 .collect()
13190 }
13191}
13192
13193#[cfg(test)]
13194mod control_tests {
13195 use super::*;
13196
13197 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";
13198
13199 fn agent(transport: &str, control: &str) -> String {
13202 let driver = if transport == "http" {
13203 " - 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"
13204 } else {
13205 " - name: local/drv\n version: 0.1.0\n runtime: driver\n"
13206 };
13207 format!(
13208 "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}"
13209 )
13210 }
13211
13212 fn refusal(yaml: &str) -> (String, String) {
13213 match RuntimeManifest::from_yaml_str(yaml) {
13214 Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
13215 other => panic!("expected InvalidControl, got {other:?}"),
13216 }
13217 }
13218
13219 #[test]
13220 fn control_absent_empty_and_empty_lists_load_as_none() {
13221 for control in [
13222 "",
13223 "control: {}\n",
13224 "control:\n",
13225 "control:\n settings: []\n secrets: []\n",
13226 ] {
13227 let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
13228 .unwrap_or_else(|err| panic!("{control:?}: {err}"));
13229 assert_eq!(manifest.control, None, "{control:?}");
13230 }
13231 }
13232
13233 #[test]
13234 fn control_parses_settings_and_secrets_in_manifest_order() {
13235 let yaml = agent(
13236 "http",
13237 "control:\n settings: [inference.max_tokens]\n secrets: [CARD_TOKEN]\n",
13238 );
13239 for manifest in [
13240 RuntimeManifest::from_yaml_str(&yaml).unwrap(),
13241 RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
13242 ] {
13243 let control = manifest.control.unwrap();
13244 assert_eq!(
13245 control.settings,
13246 vec![ControllableSetting::InferenceMaxTokens]
13247 );
13248 assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
13249 assert!(control.declares_secret("CARD_TOKEN"));
13250 assert!(!control.declares_secret("DRIVER_KEY"));
13251 }
13252 }
13253
13254 #[test]
13255 fn control_setting_wire_names_round_trip() {
13256 for setting in ControllableSetting::ALL {
13257 assert_eq!(
13258 ControllableSetting::from_wire_name(setting.wire_name()),
13259 Some(*setting)
13260 );
13261 }
13262 assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
13263 }
13264
13265 #[test]
13268 fn control_manifest_refusals_name_the_field() {
13269 let cases: &[(&str, &str, &str, &str)] = &[
13270 (
13271 "http",
13272 "control:\n settings: [inference.model]\n",
13273 "control.settings",
13274 "'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
13275 ),
13276 (
13277 "process",
13278 "control:\n settings: [inference.max_tokens]\n",
13279 "control.settings",
13280 "'inference.max_tokens' is controllable only under inference.transport: http",
13281 ),
13282 (
13283 "http",
13284 "control:\n settings: [inference.max_tokens, inference.max_tokens]\n",
13285 "control.settings",
13286 "'inference.max_tokens' is listed more than once",
13287 ),
13288 (
13289 "http",
13290 "control:\n secrets: [CARD_TOKEN, CARD_TOKEN]\n",
13291 "control.secrets",
13292 "'CARD_TOKEN' is listed more than once",
13293 ),
13294 (
13295 "http",
13296 "control:\n secrets: [card-token]\n",
13297 "control.secrets",
13298 "'card-token' is not a credential name",
13299 ),
13300 (
13301 "http",
13302 "control:\n secrets: [OTHER_TOKEN]\n",
13303 "control.secrets",
13304 "'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
13305 ),
13306 (
13307 "http",
13308 "control:\n secrets: [DRIVER_KEY]\n",
13309 "control.secrets",
13310 "'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.local/drv.gateway.api_key)",
13311 ),
13312 (
13313 "http",
13314 "control:\n settings: inference.max_tokens\n",
13315 "control.settings",
13316 "must be a list of names",
13317 ),
13318 (
13319 "http",
13320 "control:\n secrets: [1]\n",
13321 "control.secrets",
13322 "must be a list of names",
13323 ),
13324 ];
13325 for (transport, control, field, message) in cases {
13326 let (got_field, got_message) = refusal(&agent(transport, control));
13327 assert_eq!(&got_field, field, "{control:?}");
13328 assert!(
13329 got_message.starts_with(message),
13330 "{control:?}: {got_message}"
13331 );
13332 let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
13333 .unwrap_err()
13334 .to_string();
13335 assert!(
13336 rendered.contains(&format!("invalid control config for '{field}'")),
13337 "{rendered}"
13338 );
13339 }
13340 }
13341
13342 #[test]
13343 fn control_on_a_capsule_with_no_inference_is_refused() {
13344 let yaml = format!(
13345 "name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n secrets: [CARD_TOKEN]\n"
13346 );
13347 let (field, message) = refusal(&yaml);
13348 assert_eq!(field, "control");
13349 assert!(message.contains("no inference: block"), "{message}");
13350 }
13351
13352 #[test]
13353 fn control_unknown_key_is_reported_once_at_its_block() {
13354 let manifest = RuntimeManifest::from_yaml_str(&agent(
13355 "http",
13356 "control:\n settings: [inference.max_tokens]\n agent_may_call: true\n",
13357 ))
13358 .unwrap();
13359 let keys: Vec<(&str, &str)> = manifest
13360 .unknown_keys
13361 .iter()
13362 .map(|key| (key.key.as_str(), key.block_path.as_str()))
13363 .collect();
13364 assert_eq!(keys, vec![("agent_may_call", "control")]);
13365 assert_eq!(
13366 manifest.control.unwrap().settings,
13367 vec![ControllableSetting::InferenceMaxTokens]
13368 );
13369 }
13370}
13371
13372#[cfg(test)]
13373mod driver_choice_tests {
13374 use super::*;
13375
13376 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";
13377 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";
13378 const ALT_NO_GATEWAY: &str = " - name: local/alt\n version: 0.1.0\n runtime: driver\n";
13379 const TOOL: &str = " - name: local/card-api\n version: 0.1.0\n runtime: tool\n";
13380 const GPT: &str =
13381 " - name: gpt\n model: gpt-5\n driver:\n artifact: local/alt\n";
13382 const HAIKU: &str =
13383 " - name: haiku\n model: haiku\n driver:\n artifact: local/drv\n";
13384 const SWITCHABLE: &str = "control:\n settings: [inference.driver]\n";
13385
13386 fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
13389 let alternates = if alternates.is_empty() {
13390 String::new()
13391 } else {
13392 format!(" alternates:\n{alternates}")
13393 };
13394 format!(
13395 "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}"
13396 )
13397 }
13398
13399 fn refusal(yaml: &str) -> (String, String) {
13400 match RuntimeManifest::from_yaml_str(yaml) {
13401 Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
13402 | Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
13403 other => panic!("expected an inference or control refusal, got {other:?}"),
13404 }
13405 }
13406
13407 #[test]
13408 fn driver_choice_alternates_parse_in_manifest_order() {
13409 let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
13410 for manifest in [
13411 RuntimeManifest::from_yaml_str(&yaml).unwrap(),
13412 RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
13413 ] {
13414 let inference = manifest.inference.unwrap();
13415 assert_eq!(
13416 inference.alternates,
13417 vec![
13418 InferenceAlternate {
13419 name: "gpt".to_string(),
13420 model: "gpt-5".to_string(),
13421 driver: "local/alt".to_string(),
13422 },
13423 InferenceAlternate {
13424 name: "haiku".to_string(),
13425 model: "haiku".to_string(),
13426 driver: "local/drv".to_string(),
13427 },
13428 ]
13429 );
13430 let control = manifest.control.unwrap();
13431 assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
13432 assert!(control.agent_settings.is_empty());
13433 }
13434 }
13435
13436 #[test]
13437 fn driver_choice_absent_alternates_are_empty() {
13438 let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
13439 assert!(manifest.inference.unwrap().alternates.is_empty());
13440 }
13441
13442 #[test]
13443 fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
13444 let manifest = RuntimeManifest::from_yaml_str(&capsule(
13445 ALT,
13446 GPT,
13447 "control:\n agent_settings: [inference.driver]\n",
13448 ))
13449 .unwrap();
13450 let control = manifest.control.unwrap();
13451 assert!(control.settings.is_empty());
13452 assert_eq!(
13453 control.agent_settings,
13454 vec![ControllableSetting::InferenceDriver]
13455 );
13456 assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
13457 assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
13458 assert!(control.secrets.is_empty());
13459 }
13460
13461 #[test]
13462 fn driver_choice_wire_name_round_trips() {
13463 assert_eq!(
13464 ControllableSetting::from_wire_name("inference.driver"),
13465 Some(ControllableSetting::InferenceDriver)
13466 );
13467 assert_eq!(
13468 ControllableSetting::InferenceDriver.wire_name(),
13469 "inference.driver"
13470 );
13471 assert_eq!(
13472 ControllableSetting::AGENT_SETTABLE,
13473 &[ControllableSetting::InferenceDriver]
13474 );
13475 }
13476
13477 #[test]
13479 fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
13480 RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
13481 .expect("an alternate's driver carries its own gateway");
13482 match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
13483 Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
13484 assert!(message.contains("inference.alternates"), "{message}")
13485 }
13486 other => panic!("expected InvalidArtifact, got {other:?}"),
13487 }
13488 }
13489
13490 #[test]
13492 fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
13493 let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
13494 assert_eq!(
13495 manifest.inference.unwrap().alternates[0].driver,
13496 "local/drv"
13497 );
13498 }
13499
13500 #[test]
13501 fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
13502 let manifest = RuntimeManifest::from_yaml_str(&capsule(
13503 ALT,
13504 GPT,
13505 "control:\n settings: [inference.driver]\n secrets: [ALT_KEY]\n",
13506 ))
13507 .unwrap();
13508 assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
13509 let (field, message) = refusal(&capsule(
13510 ALT,
13511 GPT,
13512 "control:\n settings: [inference.driver]\n secrets: [DRIVER_KEY]\n",
13513 ));
13514 assert_eq!(field, "control.secrets");
13515 assert!(
13516 message.contains("keys the configured inference driver's gateway"),
13517 "{message}"
13518 );
13519 }
13520
13521 #[test]
13522 fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
13523 let alternate = " - name: gpt\n model: gpt-5\n temperature: 1\n driver:\n artifact: local/alt\n";
13524 let manifest =
13525 RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
13526 let keys: Vec<(&str, &str)> = manifest
13527 .unknown_keys
13528 .iter()
13529 .map(|key| (key.key.as_str(), key.block_path.as_str()))
13530 .collect();
13531 assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
13532 }
13533
13534 #[test]
13537 fn driver_choice_manifest_refusals_name_the_field() {
13538 let alternate = |name: &str, model: &str, driver: &str| {
13539 format!(" - name: {name}\n model: {model}\n driver:\n artifact: {driver}\n")
13540 };
13541 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";
13542 let cases: Vec<(String, &str, &str)> = vec![
13543 (
13544 format!("{process} alternates:\n{GPT}"),
13545 "inference.alternates",
13546 "is not valid with transport: process",
13547 ),
13548 (
13549 format!("{process}control:\n settings: [inference.driver]\n"),
13550 "control.settings",
13551 "'inference.driver' is controllable only under inference.transport: http",
13552 ),
13553 (
13554 capsule(ALT, &alternate("primary", "gpt-5", "local/alt"), SWITCHABLE),
13555 "inference.alternates[0].name",
13556 "'primary' is reserved",
13557 ),
13558 (
13559 capsule(
13560 ALT,
13561 &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt", "gpt-4", "local/alt")),
13562 SWITCHABLE,
13563 ),
13564 "inference.alternates[1].name",
13565 "'gpt' names more than one alternate",
13566 ),
13567 (
13568 capsule(ALT, &alternate("GPT", "gpt-5", "local/alt"), SWITCHABLE),
13569 "inference.alternates[0].name",
13570 "'GPT' is not a driver choice name",
13571 ),
13572 (
13573 capsule(ALT, &alternate("5gpt", "gpt-5", "local/alt"), SWITCHABLE),
13574 "inference.alternates[0].name",
13575 "'5gpt' is not a driver choice name",
13576 ),
13577 (
13578 capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "local/alt"), SWITCHABLE),
13579 "inference.alternates[0].name",
13580 "is not a driver choice name",
13581 ),
13582 (
13583 capsule(ALT, " - name: gpt\n model: \"\"\n driver:\n artifact: local/alt\n", SWITCHABLE),
13584 "inference.alternates[0].model",
13585 "missing required field",
13586 ),
13587 (
13588 capsule(ALT, " - name: gpt\n model: gpt-5\n", SWITCHABLE),
13589 "inference.alternates[0].driver.artifact",
13590 "missing required field",
13591 ),
13592 (
13593 capsule("", &alternate("gpt", "gpt-5", "local/nowhere"), SWITCHABLE),
13594 "inference.alternates[0].driver.artifact",
13595 "artifact 'local/nowhere' is not declared in artifacts:",
13596 ),
13597 (
13598 capsule(TOOL, &alternate("gpt", "gpt-5", "local/card-api"), SWITCHABLE),
13599 "inference.alternates[0].driver.artifact",
13600 "artifact 'local/card-api' is declared with runtime: tool",
13601 ),
13602 (
13603 capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "local/alt"), SWITCHABLE),
13604 "inference.alternates[0].driver.artifact",
13605 "artifact 'local/alt' declares no gateway.endpoint",
13606 ),
13607 (
13608 capsule(
13609 ALT,
13610 " - name: gpt\n model: gpt-5\n driver:\n artifact: local/alt\n config:\n thinking: enabled\n",
13611 SWITCHABLE,
13612 ),
13613 "inference.alternates[0].driver.config",
13614 "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",
13615 ),
13616 (
13617 capsule("", &alternate("same", "m", "local/drv"), SWITCHABLE),
13618 "inference.alternates[0]",
13619 "'same' is driver 'local/drv' with model 'm', which is the primary choice",
13620 ),
13621 (
13622 capsule(
13623 ALT,
13624 &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt-too", "gpt-5", "local/alt")),
13625 SWITCHABLE,
13626 ),
13627 "inference.alternates[1]",
13628 "'gpt-too' is driver 'local/alt' with model 'gpt-5', which alternate 'gpt' already is",
13629 ),
13630 (
13631 capsule(ALT, GPT, ""),
13632 "inference.alternates",
13633 "declares alternates that nothing could ever select",
13634 ),
13635 (
13636 capsule(ALT, GPT, "control:\n settings: [inference.max_tokens]\n"),
13637 "inference.alternates",
13638 "declares alternates that nothing could ever select",
13639 ),
13640 (
13641 capsule("", "", SWITCHABLE),
13642 "control.settings",
13643 "'inference.driver' selects among inference.alternates, and this capsule declares none",
13644 ),
13645 (
13646 capsule("", "", "control:\n agent_settings: [inference.driver]\n"),
13647 "control.agent_settings",
13648 "'inference.driver' selects among inference.alternates, and this capsule declares none",
13649 ),
13650 (
13651 capsule(ALT, GPT, "control:\n agent_settings: [inference.max_tokens]\n"),
13652 "control.agent_settings",
13653 "'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
13654 ),
13655 (
13656 capsule(
13657 ALT,
13658 GPT,
13659 "control:\n agent_settings: [inference.driver, inference.driver]\n",
13660 ),
13661 "control.agent_settings",
13662 "'inference.driver' is listed more than once",
13663 ),
13664 (
13665 capsule(ALT, GPT, "control:\n agent_settings: inference.driver\n"),
13666 "control.agent_settings",
13667 "must be a list of names",
13668 ),
13669 ];
13670 for (yaml, field, message) in &cases {
13671 let (got_field, got_message) = refusal(yaml);
13672 assert_eq!(&got_field, field, "{yaml}");
13673 assert!(got_message.contains(message), "{yaml}\n{got_message}");
13674 let rendered = RuntimeManifest::from_yaml_str(yaml)
13675 .unwrap_err()
13676 .to_string();
13677 assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
13678 }
13679 }
13680}
13681
13682#[cfg(test)]
13683mod network_authentication_tests {
13684 use super::*;
13685
13686 fn network_authentication_of(
13687 block: &str,
13688 ) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
13689 RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
13690 .map(|manifest| manifest.network.and_then(|network| network.authentication))
13691 }
13692
13693 fn network_authentication_refusal(block: &str) -> (String, String) {
13694 match network_authentication_of(block) {
13695 Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
13696 other => panic!("expected InvalidNetworkConfig, got {other:?}"),
13697 }
13698 }
13699
13700 #[test]
13701 fn network_authentication_with_credentials_parses_sorted_by_name() {
13702 let auth = network_authentication_of(
13703 "network:\n authentication:\n scheme: bearer\n credentials:\n \
13704 watcher:\n scopes: [GetTask, murmur.WatchSession]\n \
13705 reader:\n scopes: [resources/files]\n",
13706 )
13707 .unwrap()
13708 .expect("declared");
13709 assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
13710 assert_eq!(
13711 auth.credentials,
13712 vec![
13713 DoorCredential {
13714 name: "reader".to_string(),
13715 scopes: vec!["resources/files".to_string()],
13716 },
13717 DoorCredential {
13718 name: "watcher".to_string(),
13719 scopes: vec!["GetTask".to_string(), "murmur.WatchSession".to_string()],
13721 },
13722 ]
13723 );
13724 }
13725
13726 #[test]
13727 fn network_authentication_scheme_alone_parses() {
13728 let auth = network_authentication_of("network:\n authentication:\n scheme: bearer\n")
13729 .unwrap()
13730 .expect("declared");
13731 assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
13732 assert!(auth.credentials.is_empty());
13733 }
13734
13735 #[test]
13736 fn network_authentication_absent_is_a_public_door() {
13737 assert_eq!(network_authentication_of("").unwrap(), None);
13738 assert_eq!(
13739 network_authentication_of("network:\n internal_port: 8080\n").unwrap(),
13740 None
13741 );
13742 let manifest = RuntimeManifest::from_yaml_str(
13743 "name: cap\nversion: 0.1.0\nnetwork:\n internal_port: 8080\n",
13744 )
13745 .unwrap();
13746 assert_eq!(
13747 manifest.network,
13748 Some(NetworkConfig {
13749 internal_port: Some(8080),
13750 authentication: None,
13751 })
13752 );
13753 }
13754
13755 #[test]
13756 fn network_authentication_without_a_scheme_is_refused() {
13757 let (field, message) = network_authentication_refusal(
13758 "network:\n authentication:\n credentials:\n w:\n scopes: [GetTask]\n",
13759 );
13760 assert_eq!(field, "network.authentication.scheme");
13761 assert!(message.contains("required"), "{message}");
13762 }
13763
13764 #[test]
13765 fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
13766 for scheme in ["mtls", "Bearer", "basic"] {
13767 let (field, message) = network_authentication_refusal(&format!(
13768 "network:\n authentication:\n scheme: {scheme}\n"
13769 ));
13770 assert_eq!(field, "network.authentication.scheme");
13771 assert!(message.contains("bearer"), "{message}");
13772 assert!(message.contains(scheme), "{message}");
13773 }
13774 }
13775
13776 #[test]
13777 fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
13778 let long = format!("a{}", "b".repeat(32));
13779 for name in [
13780 "operator",
13781 "Watcher",
13782 "1st",
13783 "_x",
13784 "-x",
13785 "wat.cher",
13786 long.as_str(),
13787 ] {
13788 let (field, message) = network_authentication_refusal(&format!(
13789 "network:\n authentication:\n scheme: bearer\n credentials:\n \
13790 '{name}':\n scopes: [GetTask]\n"
13791 ));
13792 assert_eq!(field, "network.authentication.credentials", "{name}");
13793 assert!(message.contains(name), "{message}");
13794 }
13795 let longest = format!("a{}", "b".repeat(31));
13796 for name in ["w", "a-b_c9", longest.as_str()] {
13797 network_authentication_of(&format!(
13798 "network:\n authentication:\n scheme: bearer\n credentials:\n \
13799 {name}:\n scopes: [GetTask]\n"
13800 ))
13801 .unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
13802 }
13803 }
13804
13805 #[test]
13806 fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
13807 let credential = |body: &str| {
13808 format!(
13809 "network:\n authentication:\n scheme: bearer\n credentials:\n \
13810 watcher:\n{body}"
13811 )
13812 };
13813 let field = "network.authentication.credentials.watcher.scopes";
13814
13815 let (got, message) = network_authentication_refusal(&credential(" {}\n"));
13816 assert_eq!(got, field);
13817 assert!(message.contains("required"), "{message}");
13818
13819 let (got, message) = network_authentication_refusal(&credential(" scopes: []\n"));
13820 assert_eq!(got, field);
13821 assert!(message.contains("no scope"), "{message}");
13822
13823 let (got, message) =
13824 network_authentication_refusal(&credential(" scopes: [tasks/list]\n"));
13825 assert_eq!(got, field);
13826 assert!(message.contains("tasks/list"), "{message}");
13827 assert!(message.contains("resources/files"), "{message}");
13828
13829 let (got, message) =
13830 network_authentication_refusal(&credential(" scopes: [GetExtendedAgentCard]\n"));
13831 assert_eq!(got, field);
13832 assert!(message.contains("GetExtendedAgentCard"), "{message}");
13833
13834 let (got, message) = network_authentication_refusal(&credential(
13835 " scopes: [GetTask, murmur.WatchSession, GetTask]\n",
13836 ));
13837 assert_eq!(got, field);
13838 assert!(message.contains("more than once"), "{message}");
13839 }
13840
13841 #[test]
13842 fn network_authentication_refuses_a_retired_scope_naming_its_replacement() {
13843 let field = "network.authentication.credentials.watcher.scopes";
13844 for (retired, replacement) in [
13845 ("message/send", "SendMessage"),
13846 ("message/stream", "SendStreamingMessage"),
13847 ("tasks/get", "GetTask"),
13848 ("tasks/cancel", "CancelTask"),
13849 ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
13850 ("stream/watch", "murmur.WatchSession"),
13851 ("session/stop", "murmur.StopSession"),
13852 ] {
13853 let error = network_authentication_of(&format!(
13854 "network:\n authentication:\n scheme: bearer\n credentials:\n \
13855 watcher:\n scopes: [murmur.WatchSession, {retired}]\n"
13856 ))
13857 .unwrap_err();
13858 let rendered = error.to_string();
13859 match error {
13860 RuntimeManifestError::RetiredDoorScope {
13861 field: got_field,
13862 scope,
13863 replacement: got_replacement,
13864 } => {
13865 assert_eq!(got_field, field);
13866 assert_eq!(scope, retired);
13867 assert_eq!(got_replacement, replacement);
13868 }
13869 other => panic!("expected RetiredDoorScope for {retired}, got {other:?}"),
13870 }
13871 assert!(
13872 rendered.contains(&format!(
13873 "'{retired}' is a retired name of a door method, and the door answers only \
13874 the names its card lists"
13875 )),
13876 "{rendered}"
13877 );
13878 assert!(rendered.contains(field), "{rendered}");
13879 }
13880 }
13881
13882 #[test]
13883 fn network_authentication_accepts_the_v1_scopes() {
13884 let auth = network_authentication_of(
13885 "network:\n authentication:\n scheme: bearer\n credentials:\n \
13886 watcher:\n scopes: [GetTask, murmur.WatchSession, murmur.StopSession]\n",
13887 )
13888 .unwrap()
13889 .unwrap();
13890 assert_eq!(
13891 auth.credentials[0].scopes,
13892 ["GetTask", "murmur.WatchSession", "murmur.StopSession"]
13893 );
13894 }
13895
13896 #[test]
13899 fn door_scope_list_tasks_and_subscribe_to_task_load_on_a_credential() {
13900 let auth = network_authentication_of(
13901 "network:\n authentication:\n scheme: bearer\n credentials:\n \
13902 alice:\n scopes: [SendMessage, ListTasks, SubscribeToTask]\n",
13903 )
13904 .unwrap()
13905 .unwrap();
13906 assert_eq!(
13907 auth.credentials[0].scopes,
13908 ["SendMessage", "ListTasks", "SubscribeToTask"]
13909 );
13910 assert_eq!(
13911 DOOR_SCOPES,
13912 [
13913 "SendMessage",
13914 "SendStreamingMessage",
13915 "murmur.WatchSession",
13916 "GetTask",
13917 "ListTasks",
13918 "CancelTask",
13919 "SubscribeToTask",
13920 "murmur.StopSession",
13921 "resources/files",
13922 ]
13923 );
13924 }
13925
13926 #[test]
13927 fn network_authentication_accepts_every_door_scope() {
13928 let auth = network_authentication_of(&format!(
13929 "network:\n authentication:\n scheme: bearer\n credentials:\n \
13930 everything:\n scopes: [{}]\n",
13931 DOOR_SCOPES.join(", ")
13932 ))
13933 .unwrap()
13934 .unwrap();
13935 assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
13936 assert_eq!(OPERATOR_CREDENTIAL, "operator");
13937 }
13938
13939 #[test]
13940 fn network_authentication_is_refused_beside_spawn_allow() {
13941 let (field, message) = network_authentication_refusal(
13942 "network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
13943 allow: [local/worker]\n",
13944 );
13945 assert_eq!(field, "network.authentication");
13946 assert!(message.contains("capabilities.spawn.allow"), "{message}");
13947 assert!(message.contains("token"), "{message}");
13948 let error = RuntimeManifest::from_yaml_str(
13949 "name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n\
13950 capabilities:\n spawn:\n allow: [local/worker]\n",
13951 )
13952 .unwrap_err()
13953 .to_string();
13954 assert!(error.contains("network.authentication"), "{error}");
13955 assert!(error.contains("capabilities.spawn.allow"), "{error}");
13956
13957 network_authentication_of(
13959 "network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
13960 allow: []\n",
13961 )
13962 .unwrap();
13963 }
13964
13965 #[test]
13966 fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
13967 let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
13968 "name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n \
13969 schemes: x\n credentials:\n watcher:\n scopes: [GetTask]\n \
13970 scope: [GetTask]\n",
13971 )
13972 .unwrap()
13973 .unknown_keys
13974 .into_iter()
13975 .map(|key| (key.key, key.block_path, key.nearest_known))
13976 .collect();
13977 assert_eq!(
13978 keys,
13979 vec![
13980 (
13981 "schemes".to_string(),
13982 "network.authentication".to_string(),
13983 Some("scheme".to_string())
13984 ),
13985 (
13986 "scope".to_string(),
13987 "network.authentication.credentials.watcher".to_string(),
13988 Some("scopes".to_string())
13989 ),
13990 ]
13991 );
13992 }
13993}
13994
13995#[cfg(test)]
13996mod registry_block_tests {
13997 use super::*;
13998
13999 const REGISTRY_BLOCK: &str = "registry:\n default: local\n";
14000
14001 #[test]
14002 fn registry_block_is_refused_by_both_entry_points() {
14003 let input = format!("name: cap\nversion: 0.0.1\nartifacts: []\n{REGISTRY_BLOCK}");
14004 for result in [
14005 RuntimeManifest::from_yaml_str(&input),
14006 RuntimeManifest::from_yaml_str_without_secrets(&input),
14007 ] {
14008 let err = result.unwrap_err();
14009 assert!(
14010 matches!(err, RuntimeManifestError::RegistryBlock),
14011 "{err:?}"
14012 );
14013 assert_eq!(err.to_string(), REGISTRY_BLOCK_MESSAGE);
14014 }
14015 }
14016
14017 #[test]
14018 fn registry_block_is_refused_before_any_other_field() {
14019 let input = format!(
14022 "version: 0.0.1\nlifecycle:\n task_acceptance: queue\n queue_depth: 0\n\
14023 {REGISTRY_BLOCK}"
14024 );
14025 let err = RuntimeManifest::from_yaml_str(&input).unwrap_err();
14026 assert!(
14027 matches!(err, RuntimeManifestError::RegistryBlock),
14028 "{err:?}"
14029 );
14030 }
14031
14032 #[test]
14033 fn registry_block_refusal_applies_whatever_the_block_holds() {
14034 for block in ["registry: local\n", "registry: {}\n", "registry: []\n"] {
14035 let input = format!("name: cap\nversion: 0.0.1\n{block}");
14036 assert!(
14037 matches!(
14038 RuntimeManifest::from_yaml_str(&input),
14039 Err(RuntimeManifestError::RegistryBlock)
14040 ),
14041 "{block}"
14042 );
14043 }
14044 }
14045
14046 #[test]
14047 fn a_manifest_without_a_registry_block_parses_unchanged() {
14048 let manifest =
14049 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
14050 assert_eq!(manifest.name, "cap");
14051 assert_eq!(manifest.version, "0.0.1");
14052 }
14053}
14054
14055#[cfg(test)]
14056mod scoped_name_tests {
14057 use super::*;
14058
14059 fn name_refusal(yaml: &str) -> (String, String, Option<String>) {
14060 match RuntimeManifest::from_yaml_str(yaml) {
14061 Err(RuntimeManifestError::InvalidArtifactName {
14062 field,
14063 message,
14064 hint,
14065 }) => (field, message, hint),
14066 other => panic!("expected InvalidArtifactName, got {other:?}"),
14067 }
14068 }
14069
14070 #[test]
14071 fn scoped_name_a_bare_artifact_entry_is_refused_with_the_scoped_forms() {
14072 let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n - name: tool-editor\n \
14073 version: 0.1.0\n runtime: tool\n";
14074 let (field, message, hint) = name_refusal(yaml);
14075 assert_eq!(field, "artifacts[0].name");
14076 assert_eq!(
14077 message,
14078 "'tool-editor' is not a full artifact name: an artifact is named <scope>/<name>"
14079 );
14080 assert_eq!(
14081 hint.as_deref(),
14082 Some(
14083 "name it with its scope: 'murmur/tool-editor' for a Murmur default artifact, \
14084 '<owner>/tool-editor' for one published from github.com/<owner>, or \
14085 'local/tool-editor' for one installed from a .mur.zip file"
14086 )
14087 );
14088 let rendered = RuntimeManifest::from_yaml_str(yaml)
14089 .unwrap_err()
14090 .to_string();
14091 assert!(
14092 rendered.starts_with("murmur.yaml: invalid artifact name in 'artifacts[0].name': "),
14093 "{rendered}"
14094 );
14095 }
14096
14097 #[test]
14098 fn scoped_name_a_source_entry_keeps_its_bare_name() {
14099 let bare = "name: cap\nversion: 0.0.1\nartifacts:\n - name: style\n \
14100 source: ./skills/style\n runtime: skill\n";
14101 RuntimeManifest::from_yaml_str(bare).expect("a source: entry is named by its bare name");
14102
14103 let (field, message, hint) = name_refusal(&bare.replace("name: style", "name: ana/style"));
14104 assert_eq!(field, "artifacts[0].name");
14105 assert!(
14106 message
14107 .starts_with("'ana/style' declares 'source:', so it is named by its bare name; "),
14108 "{message}"
14109 );
14110 assert_eq!(hint.as_deref(), Some("drop the scope: name it 'style'"));
14111 }
14112
14113 #[test]
14114 fn scoped_name_spawn_allow_refuses_a_bare_entry_and_a_shared_bare_part() {
14115 let with_allow = |allow: &str| {
14116 format!("name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n allow: [{allow}]\n")
14117 };
14118 let manifest = RuntimeManifest::from_yaml_str(&with_allow("local/worker, ana/reviewer"))
14119 .expect("full names in two scopes with distinct bare parts parse");
14120 assert_eq!(
14121 manifest.capabilities.unwrap().spawn.unwrap().allow,
14122 vec!["local/worker".to_string(), "ana/reviewer".to_string()]
14123 );
14124
14125 let (field, message, hint) = name_refusal(&with_allow("worker"));
14126 assert_eq!(field, "capabilities.spawn.allow[0]");
14127 assert!(
14128 message.contains("'worker' is not a full artifact name"),
14129 "{message}"
14130 );
14131 assert!(hint.unwrap().contains("local/worker"));
14132
14133 let (field, message, hint) = name_refusal(&with_allow("local/worker, ana/worker"));
14134 assert_eq!(field, "capabilities.spawn.allow[1]");
14135 assert_eq!(
14136 message,
14137 "'local/worker' and 'ana/worker' share the name 'worker', which is how delegate-task \
14138 offers a capsule to the model"
14139 );
14140 assert_eq!(
14141 hint.as_deref(),
14142 Some("keep one of 'local/worker' and 'ana/worker' in capabilities.spawn.allow")
14143 );
14144 }
14145
14146 #[test]
14147 fn scoped_name_a_reference_by_bare_part_names_the_declared_entry() {
14148 let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n \
14149 version: 0.1.0\n runtime: driver\n gateway:\n \
14150 endpoint: https://api.anthropic.com\n api_key: k\ninference:\n \
14151 transport: http\n model: m\n driver:\n artifact: driver-anthropic\n";
14152 let message = RuntimeManifest::from_yaml_str(yaml)
14153 .unwrap_err()
14154 .to_string();
14155 assert!(
14156 message.contains(
14157 "artifact 'driver-anthropic' is not declared in artifacts: (the declared \
14158 'murmur/driver-anthropic' has that name; refer to it as \
14159 'murmur/driver-anthropic')"
14160 ),
14161 "{message}"
14162 );
14163 }
14164}