1use std::{fs, net::IpAddr, path::Path};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use url::Url;
6
7use crate::{
8 artifact_name::{ArtifactName, ArtifactNameError},
9 build::artifact_name_format_error,
10 manifest::{REGISTRY_BLOCK_KEY, REGISTRY_BLOCK_MESSAGE},
11 manifest_path::MANIFEST_FILENAME,
12 shell_words::split_shell_words,
13 trace_capture::{resolve_trace_capture, TraceCapture},
14 unknown_manifest_keys::{nearest_known_key, UnknownManifestKey},
15};
16
17#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum TaskAcceptance {
22 None,
23 #[default]
24 Single,
25 Queue,
26}
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum AfterTask {
31 #[default]
32 Exit,
33 Sleep,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ConversationMode {
39 #[default]
40 Stateless,
41 Threaded,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct LifecycleConfig {
46 pub task_acceptance: TaskAcceptance,
47 pub after_task: AfterTask,
48 pub queue_depth: usize,
49 pub input_timeout_secs: Option<u64>,
52 pub conversation_mode: ConversationMode,
56 pub max_task_reopens: u32,
62 pub shell_grace_secs: u64,
68 pub delegation_deadline_secs: u64,
82 pub deadline_notice_secs: Option<u64>,
87 pub keep_working: Option<KeepWorking>,
92 pub done_check: Option<DoneCheck>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct KeepWorking {
104 pub message: String,
106 pub limit: u32,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct DoneCheck {
114 pub command: String,
117 pub limit: u32,
120 pub timeout_secs: u64,
122}
123
124impl DoneCheck {
125 pub const DEFAULT_TIMEOUT_SECS: u64 = 600;
127
128 pub fn argv(&self) -> Vec<String> {
130 split_shell_words(&self.command)
131 }
132}
133
134impl LifecycleConfig {
135 pub fn can_receive_background_tasks(&self) -> bool {
143 self.task_acceptance == TaskAcceptance::Queue && self.after_task == AfterTask::Sleep
144 }
145
146 pub fn queue_has_room(&self) -> bool {
155 !(self.task_acceptance == TaskAcceptance::Queue && self.queue_depth == 0)
156 }
157
158 pub fn tasks_held_at_once(&self) -> usize {
166 match self.task_acceptance {
167 TaskAcceptance::None => 0,
168 TaskAcceptance::Single => 1,
169 TaskAcceptance::Queue => self.queue_depth + 1,
170 }
171 }
172}
173
174impl Default for LifecycleConfig {
175 fn default() -> Self {
176 Self {
177 task_acceptance: TaskAcceptance::Single,
178 after_task: AfterTask::Exit,
179 queue_depth: 1,
180 input_timeout_secs: None,
181 conversation_mode: ConversationMode::Stateless,
182 max_task_reopens: 1,
183 shell_grace_secs: 10,
184 delegation_deadline_secs: 600,
185 deadline_notice_secs: None,
186 keep_working: None,
187 done_check: None,
188 }
189 }
190}
191
192#[derive(Debug, Clone, Default)]
195pub struct LifecycleOverride {
196 pub task_acceptance: Option<TaskAcceptance>,
197 pub after_task: Option<AfterTask>,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
218#[serde(rename_all = "lowercase")]
219pub enum ArtifactRuntime {
220 Tool,
221 Driver,
222 Hook,
223 Skill,
224}
225
226impl ArtifactRuntime {
227 pub fn is_llm_visible(&self) -> bool {
233 match self {
234 ArtifactRuntime::Tool => true,
235 ArtifactRuntime::Skill => true,
236 ArtifactRuntime::Driver => false,
237 ArtifactRuntime::Hook => false,
238 }
239 }
240
241 pub fn as_str(&self) -> &'static str {
242 match self {
243 ArtifactRuntime::Tool => "tool",
244 ArtifactRuntime::Driver => "driver",
245 ArtifactRuntime::Hook => "hook",
246 ArtifactRuntime::Skill => "skill",
247 }
248 }
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum ArtifactImplementation {
257 Wasm,
258 Native,
259}
260
261pub fn read_tool_implementation(
264 artifact_manifest_path: &Path,
265) -> Result<ArtifactImplementation, RuntimeManifestError> {
266 let content =
267 fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
268 path: artifact_manifest_path.display().to_string(),
269 source,
270 })?;
271 Ok(parse_tool_implementation_from_yaml(&content))
272}
273
274pub fn parse_tool_implementation_from_yaml(yaml: &str) -> ArtifactImplementation {
277 serde_norway::from_str::<serde_norway::Value>(yaml)
278 .map(|v| {
279 v.as_mapping()
280 .and_then(|m| m.get(serde_norway::Value::String("implementation".to_string())))
281 .and_then(serde_norway::Value::as_str)
282 .map(|s| match s {
283 "native" => ArtifactImplementation::Native,
284 _ => ArtifactImplementation::Wasm,
285 })
286 .unwrap_or(ArtifactImplementation::Wasm)
287 })
288 .unwrap_or(ArtifactImplementation::Wasm)
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
298pub enum HookBinding {
299 OnStage,
301 OnSessionStart,
303 OnTaskStart,
305 OnInference,
307 OnToolCall,
309 OnShell,
311 OnCompaction,
313 OnTaskEnd,
315 OnSessionEnd,
317 All,
319}
320
321impl HookBinding {
322 #[must_use]
327 pub fn as_str(&self) -> &'static str {
328 match self {
329 Self::OnStage => "on-stage",
330 Self::OnSessionStart => "on-session-start",
331 Self::OnTaskStart => "on-task-start",
332 Self::OnInference => "on-inference",
333 Self::OnToolCall => "on-tool-call",
334 Self::OnShell => "on-shell",
335 Self::OnCompaction => "on-compaction",
336 Self::OnTaskEnd => "on-task-end",
337 Self::OnSessionEnd => "on-session-end",
338 Self::All => "all",
339 }
340 }
341}
342
343#[derive(Debug, Clone, PartialEq, Eq)]
348pub enum HookExecutionMode {
349 Blocking,
351 Async,
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
360pub enum HookCommitPolicy {
361 None,
363 ReplaceContext,
365 WriteManifests,
368 ReopenTask,
371 SeedContext,
375 Deny,
383}
384
385impl HookCommitPolicy {
386 #[must_use]
388 pub fn as_str(&self) -> &'static str {
389 match self {
390 Self::None => "none",
391 Self::ReplaceContext => "replace-context",
392 Self::WriteManifests => "write-manifests",
393 Self::ReopenTask => "reopen-task",
394 Self::SeedContext => "seed-context",
395 Self::Deny => "deny",
396 }
397 }
398}
399
400#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum HookOverflowPolicy {
412 #[default]
416 Drop,
417 Block,
420}
421
422impl HookOverflowPolicy {
423 #[must_use]
425 pub fn as_str(&self) -> &'static str {
426 match self {
427 Self::Drop => "drop",
428 Self::Block => "block",
429 }
430 }
431}
432
433#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct HookConfig {
436 pub binding: HookBinding,
437 pub execution_mode: HookExecutionMode,
438 pub commit_policy: HookCommitPolicy,
439}
440
441impl Default for HookConfig {
442 fn default() -> Self {
443 Self {
444 binding: HookBinding::All,
445 execution_mode: HookExecutionMode::Blocking,
446 commit_policy: HookCommitPolicy::None,
447 }
448 }
449}
450
451#[must_use]
475pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
476 match binding {
477 HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
478 HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
479 HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
480 HookBinding::OnTaskStart => Some(HookCommitPolicy::SeedContext),
481 HookBinding::OnToolCall | HookBinding::OnShell => Some(HookCommitPolicy::Deny),
482 HookBinding::OnSessionStart
483 | HookBinding::OnInference
484 | HookBinding::OnSessionEnd
485 | HookBinding::All => None,
486 }
487}
488
489pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
491 let content =
492 fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
493 path: artifact_manifest_path.display().to_string(),
494 source,
495 })?;
496 parse_hook_config_from_yaml(&content).map_err(|msg| {
497 RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
498 })
499}
500
501pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
511 let v: serde_norway::Value =
512 serde_norway::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
513
514 let binding = v
515 .as_mapping()
516 .and_then(|m| m.get(serde_norway::Value::String("binding".to_string())))
517 .and_then(serde_norway::Value::as_str)
518 .map(|s| match s {
519 "on-stage" => Ok(HookBinding::OnStage),
520 "on-session-start" => Ok(HookBinding::OnSessionStart),
521 "on-task-start" => Ok(HookBinding::OnTaskStart),
522 "on-inference" => Ok(HookBinding::OnInference),
523 "on-tool-call" => Ok(HookBinding::OnToolCall),
524 "on-shell" => Ok(HookBinding::OnShell),
525 "on-compaction" => Ok(HookBinding::OnCompaction),
526 "on-task-end" => Ok(HookBinding::OnTaskEnd),
527 "on-session-end" => Ok(HookBinding::OnSessionEnd),
528 other => Err(format!(
529 "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
530 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
531 )),
532 })
533 .transpose()?
534 .unwrap_or(HookBinding::All);
535
536 let execution_mode = v
537 .as_mapping()
538 .and_then(|m| m.get(serde_norway::Value::String("execution_mode".to_string())))
539 .and_then(serde_norway::Value::as_str)
540 .map(|s| match s {
541 "blocking" => Ok(HookExecutionMode::Blocking),
542 "async" => Ok(HookExecutionMode::Async),
543 other => Err(format!(
544 "unknown execution_mode '{other}'; expected: blocking, async"
545 )),
546 })
547 .transpose()?
548 .unwrap_or(HookExecutionMode::Blocking);
549
550 let commit_policy = v
551 .as_mapping()
552 .and_then(|m| m.get(serde_norway::Value::String("commit_policy".to_string())))
553 .and_then(serde_norway::Value::as_str)
554 .map(|s| match s {
555 "none" => Ok(HookCommitPolicy::None),
556 "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
557 "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
558 "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
559 "seed-context" => Ok(HookCommitPolicy::SeedContext),
560 "deny" => Ok(HookCommitPolicy::Deny),
561 other => Err(format!(
562 "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
563 reopen-task, seed-context, deny"
564 )),
565 })
566 .transpose()?
567 .unwrap_or(HookCommitPolicy::None);
568
569 if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
571 return Err(format!(
572 "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
573 (got '{}')",
574 commit_policy.as_str()
575 ));
576 }
577 if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
578 return Err(
579 "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
580 'on-stage'"
581 .to_string(),
582 );
583 }
584 if binding == HookBinding::All && commit_policy == HookCommitPolicy::Deny {
588 return Err(
589 "commit_policy 'deny' requires an explicit binding: 'on-shell' or 'on-tool-call'; \
590 a hook with no binding: is dispatched at every event, including decision points \
591 it was not written to decide"
592 .to_string(),
593 );
594 }
595 if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
600 let honored = commit_policy_for_binding(&binding);
601 if honored.as_ref() != Some(&commit_policy) {
602 let note = if binding == HookBinding::OnInference {
603 " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
604 } else {
605 ""
606 };
607 return Err(format!(
608 "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
609 commit_policy '{}'{note}",
610 commit_policy.as_str(),
611 binding.as_str(),
612 binding.as_str(),
613 honored.as_ref().map_or("none", HookCommitPolicy::as_str)
614 ));
615 }
616 }
617
618 Ok(HookConfig {
619 binding,
620 execution_mode,
621 commit_policy,
622 })
623}
624
625#[derive(Debug, Clone, PartialEq, Eq)]
626pub struct RuntimeManifest {
627 pub name: String,
628 pub version: String,
629 pub artifacts: Vec<RuntimeArtifact>,
630 pub capabilities: Option<Capabilities>,
631 pub inference: Option<InferenceConfig>,
632 pub context: Option<ContextConfig>,
633 pub observability: Option<ObservabilityConfig>,
634 pub trace: Option<TraceConfig>,
635 pub network: Option<NetworkConfig>,
636 pub lifecycle: Option<LifecycleConfig>,
637 pub exports: Option<Exports>,
640 pub control: Option<ControlConfig>,
644 pub deploy: Option<DeployConfig>,
646 pub unknown_keys: Vec<UnknownManifestKey>,
656}
657
658impl RuntimeManifest {
659 pub fn effective_lifecycle(&self) -> LifecycleConfig {
660 self.lifecycle.clone().unwrap_or_default()
661 }
662
663 pub fn accepts_peer_tasks(&self) -> bool {
670 self.exports
671 .as_ref()
672 .is_some_and(Exports::accepts_peer_tasks)
673 }
674
675 pub fn deploy_mur_version(&self) -> Option<&str> {
678 self.deploy.as_ref()?.mur_version.as_deref()
679 }
680}
681
682#[derive(Debug, Clone, Default, PartialEq, Eq)]
684pub struct DeployConfig {
685 pub mur_version: Option<String>,
689}
690
691#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
698pub enum ControllableSetting {
699 InferenceMaxTokens,
702 InferenceDriver,
706}
707
708impl ControllableSetting {
709 pub const ALL: &'static [ControllableSetting] =
711 &[Self::InferenceMaxTokens, Self::InferenceDriver];
712
713 pub const AGENT_SETTABLE: &'static [ControllableSetting] = &[Self::InferenceDriver];
716
717 pub fn wire_name(self) -> &'static str {
719 match self {
720 Self::InferenceMaxTokens => "inference.max_tokens",
721 Self::InferenceDriver => "inference.driver",
722 }
723 }
724
725 pub fn from_wire_name(name: &str) -> Option<Self> {
727 Self::ALL
728 .iter()
729 .copied()
730 .find(|setting| setting.wire_name() == name)
731 }
732}
733
734#[derive(Debug, Clone, PartialEq, Eq, Default)]
739pub struct ControlConfig {
740 pub settings: Vec<ControllableSetting>,
742 pub secrets: Vec<String>,
746 pub agent_settings: Vec<ControllableSetting>,
750}
751
752impl ControlConfig {
753 pub fn declares_secret(&self, name: &str) -> bool {
755 self.secrets.iter().any(|secret| secret == name)
756 }
757
758 pub fn controller_may_set(&self, setting: ControllableSetting) -> bool {
760 self.settings.contains(&setting)
761 }
762
763 pub fn agent_may_set(&self, setting: ControllableSetting) -> bool {
765 self.agent_settings.contains(&setting)
766 }
767}
768
769#[derive(Debug, Clone, PartialEq, Eq)]
772pub struct NetworkConfig {
773 pub internal_port: Option<u16>,
775 pub authentication: Option<NetworkAuthentication>,
778}
779
780pub const DOOR_SCOPES: &[&str] = &[
784 "SendMessage",
785 "SendStreamingMessage",
786 "murmur.WatchSession",
787 "GetTask",
788 "ListTasks",
789 "CancelTask",
790 "SubscribeToTask",
791 "murmur.StopSession",
792 "resources/files",
793];
794
795pub const RETIRED_DOOR_SCOPES: &[(&str, &str)] = &[
804 ("message/send", "SendMessage"),
805 ("message/stream", "SendStreamingMessage"),
806 ("tasks/get", "GetTask"),
807 ("tasks/cancel", "CancelTask"),
808 ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
809 ("stream/watch", "murmur.WatchSession"),
810 ("session/stop", "murmur.StopSession"),
811];
812
813pub const OPERATOR_CREDENTIAL: &str = "operator";
816
817const MAX_CREDENTIAL_NAME_LEN: usize = 32;
819
820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
824pub enum AuthenticationScheme {
825 Bearer,
827}
828
829impl AuthenticationScheme {
830 pub const ALL: &'static [AuthenticationScheme] = &[Self::Bearer];
832
833 pub fn wire_name(self) -> &'static str {
835 match self {
836 Self::Bearer => "bearer",
837 }
838 }
839}
840
841#[derive(Debug, Clone, PartialEq, Eq)]
844pub struct DoorCredential {
845 pub name: String,
847 pub scopes: Vec<String>,
849}
850
851#[derive(Debug, Clone, PartialEq, Eq)]
854pub struct NetworkAuthentication {
855 pub scheme: AuthenticationScheme,
856 pub credentials: Vec<DoorCredential>,
858}
859
860pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
864
865#[derive(Debug, Clone, PartialEq, Eq)]
873pub struct Exports {
874 pub files: Option<FileExport>,
877 pub peer_files: Option<PeerFilesExport>,
882 pub peer_tasks: Option<PeerTasksExport>,
886}
887
888impl Exports {
889 pub fn accepts_peer_tasks(&self) -> bool {
895 self.peer_tasks.is_some_and(|peer_tasks| peer_tasks.accept)
896 }
897}
898
899#[derive(Debug, Clone, Copy, PartialEq, Eq)]
910pub struct PeerTasksExport {
911 pub accept: bool,
913}
914
915#[derive(Debug, Clone, PartialEq, Eq)]
917pub struct FileExport {
918 pub root: String,
925 pub mode: ExportMode,
929 pub max_bytes: u64,
933}
934
935pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
938
939pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
948
949pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
951
952#[derive(Debug, Clone, PartialEq, Eq)]
959pub struct PeerFilesExport {
960 pub root: String,
964 pub max_ttl_secs: Option<u64>,
971 pub max_bytes: u64,
975}
976
977impl PeerFilesExport {
978 pub fn effective_max_ttl_secs(&self) -> u64 {
984 self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
985 }
986}
987
988#[derive(Debug, Clone, PartialEq, Eq)]
996pub struct PeerFetchCapabilities {
997 pub allow: Vec<String>,
1001}
1002
1003pub const DURATION_ACCEPTED_FORM: &str =
1006 "must be a duration: an integer, optionally suffixed s/m/h/d";
1007
1008pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
1014 let trimmed = input.trim();
1015 let (digits, multiplier) = match trimmed.strip_suffix('s') {
1016 Some(digits) => (digits, 1u64),
1017 None => match trimmed.strip_suffix('m') {
1018 Some(digits) => (digits, 60),
1019 None => match trimmed.strip_suffix('h') {
1020 Some(digits) => (digits, 3600),
1021 None => match trimmed.strip_suffix('d') {
1022 Some(digits) => (digits, 86_400),
1023 None => (trimmed, 1),
1024 },
1025 },
1026 },
1027 };
1028 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
1031 return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
1032 }
1033 digits
1034 .parse::<u64>()
1035 .ok()
1036 .and_then(|value| value.checked_mul(multiplier))
1037 .ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
1038}
1039
1040#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1041pub enum ExportMode {
1042 #[serde(rename = "read-only")]
1043 ReadOnly,
1044}
1045
1046impl ExportMode {
1047 pub fn as_str(self) -> &'static str {
1048 match self {
1049 ExportMode::ReadOnly => "read-only",
1050 }
1051 }
1052}
1053
1054impl std::fmt::Display for ExportMode {
1055 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1056 f.write_str(self.as_str())
1057 }
1058}
1059
1060pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
1063
1064pub fn parse_byte_size(input: &str) -> Result<u64, String> {
1070 let trimmed = input.trim();
1071 let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
1072 Some(digits) => (digits, 1024u64),
1073 None => match trimmed.strip_suffix("Mi") {
1074 Some(digits) => (digits, 1024 * 1024),
1075 None => match trimmed.strip_suffix("Gi") {
1076 Some(digits) => (digits, 1024 * 1024 * 1024),
1077 None => (trimmed, 1),
1078 },
1079 },
1080 };
1081 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
1084 return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
1085 }
1086 digits
1087 .parse::<u64>()
1088 .ok()
1089 .and_then(|value| value.checked_mul(multiplier))
1090 .ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
1091}
1092
1093#[derive(Debug, Clone, PartialEq, Eq)]
1094pub struct RuntimeArtifact {
1095 pub name: String,
1096 pub version: String,
1097 pub runtime: ArtifactRuntime,
1098 pub source: Option<String>,
1104 pub local_source: bool,
1109 pub prompt_payload: bool,
1114 pub capabilities: Option<Capabilities>,
1133 pub on_overflow: HookOverflowPolicy,
1138 pub config: Option<serde_norway::Value>,
1155 pub gateway: Option<ArtifactGateway>,
1166}
1167
1168impl RuntimeArtifact {
1169 #[must_use]
1173 pub fn tool_name(&self) -> &str {
1174 tool_name_of(&self.name)
1175 }
1176
1177 pub fn artifact_name(&self) -> Result<ArtifactName, ArtifactNameError> {
1179 ArtifactName::parse(&self.name)
1180 }
1181}
1182
1183#[must_use]
1186pub fn tool_name_of(name: &str) -> &str {
1187 name.split_once('/').map_or(name, |(_, bare)| bare)
1188}
1189
1190#[derive(Debug, Clone, PartialEq, Eq)]
1194pub struct ArtifactGateway {
1195 pub endpoint: String,
1198 pub api_key: Option<ApiKeyReference>,
1201 pub keyless: bool,
1205}
1206
1207#[derive(Debug, Clone, PartialEq, Eq)]
1208pub struct InferenceDriver {
1209 pub artifact: String,
1210 pub config: Option<String>,
1211}
1212
1213pub const PRIMARY_DRIVER_CHOICE: &str = "primary";
1216
1217#[derive(Debug, Clone, PartialEq, Eq)]
1223pub struct InferenceAlternate {
1224 pub name: String,
1226 pub model: String,
1228 pub driver: String,
1231}
1232
1233#[derive(Clone, PartialEq, Eq)]
1236pub enum ApiKeyReference {
1237 Environment(String),
1240 Literal(String),
1242}
1243
1244impl std::fmt::Debug for ApiKeyReference {
1245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1246 match self {
1247 Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
1248 Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
1249 }
1250 }
1251}
1252
1253#[derive(Debug, Clone, PartialEq, Eq)]
1254pub struct InferenceConfig {
1255 pub transport: String,
1256 pub model: String,
1257 pub driver: Option<InferenceDriver>,
1260 pub command: Option<String>,
1264 pub compaction: Option<CompactionConfig>,
1265 pub system_prompt: Option<String>,
1266 pub system_prompt_file: Option<String>,
1267 pub system_prompt_artifact: Option<String>,
1271 pub max_turns: Option<u32>,
1274 pub max_tokens: Option<u32>,
1282 pub max_session_tokens: Option<u64>,
1290 pub tool_refresh: ToolRefresh,
1294 pub alternates: Vec<InferenceAlternate>,
1298 pub retry: InferenceRetry,
1302}
1303
1304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1307pub struct InferenceRetry {
1308 pub max_retries: u32,
1310 pub initial_delay_ms: u32,
1312 pub max_delay_ms: u32,
1315}
1316
1317impl InferenceRetry {
1318 pub const MAX_RETRIES: u32 = 20;
1320 pub const MAX_INITIAL_DELAY_MS: u32 = 60_000;
1322 pub const MAX_DELAY_MS: u32 = 3_600_000;
1324}
1325
1326impl Default for InferenceRetry {
1327 fn default() -> Self {
1328 Self {
1329 max_retries: 6,
1330 initial_delay_ms: 1_000,
1331 max_delay_ms: 60_000,
1332 }
1333 }
1334}
1335
1336#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1343pub enum ToolRefresh {
1344 #[default]
1347 Compaction,
1348 Immediate,
1350}
1351
1352impl ToolRefresh {
1353 pub const ALL: [ToolRefresh; 2] = [Self::Compaction, Self::Immediate];
1355
1356 #[must_use]
1358 pub fn wire_name(self) -> &'static str {
1359 match self {
1360 Self::Compaction => "compaction",
1361 Self::Immediate => "immediate",
1362 }
1363 }
1364}
1365
1366#[derive(Debug, Clone, PartialEq)]
1367pub struct CompactionConfig {
1368 pub threshold: Option<f32>,
1370 pub model: Option<String>,
1372 pub system_prompt: Option<String>,
1375 pub system_prompt_file: Option<String>,
1379 pub dump_summaries: Option<bool>,
1384 pub max_tokens: Option<u32>,
1389 pub driver_config: Option<String>,
1393}
1394
1395impl Eq for CompactionConfig {}
1397
1398pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
1402
1403pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
1407
1408#[derive(Debug, Clone, PartialEq)]
1409pub struct ContextConfig {
1410 pub max_tokens: Option<u32>,
1414 pub record: bool,
1418 pub record_store: Option<String>,
1422 pub seed_budget: f32,
1427 pub seed_overflow_margin: f32,
1432 pub retain: Option<ContextRetainConfig>,
1435}
1436
1437impl Eq for ContextConfig {}
1439
1440#[derive(Debug, Clone, PartialEq, Eq)]
1441pub struct ObservabilityConfig {
1442 pub otel_endpoint: Option<String>,
1443 pub eval: Option<EvalConfig>,
1444}
1445
1446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1447pub struct TraceConfig {
1448 pub capture: TraceCapture,
1451 pub retain: Option<TraceRetainConfig>,
1456}
1457
1458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1464pub struct TraceRetainConfig {
1465 pub max_sessions: Option<u32>,
1469 pub max_age_secs: Option<u64>,
1472}
1473
1474#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1480pub struct ContextRetainConfig {
1481 pub max_messages: Option<u32>,
1484 pub max_age_secs: Option<u64>,
1487}
1488
1489#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1490pub struct EvalConfig {
1491 #[serde(skip_serializing_if = "Option::is_none")]
1492 pub dataset_id: Option<String>,
1493 pub scorers: Vec<ScorerConfig>,
1494}
1495
1496#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1497#[serde(tag = "type", rename_all = "snake_case")]
1498pub enum ScorerConfig {
1499 ExitOk { name: String },
1500 MaxTurns { name: String, max: u32 },
1501 MaxTokens { name: String, max: u64 },
1502 ToolSequence { name: String, expected: Vec<String> },
1503 LlmJudge { name: String },
1504}
1505
1506#[derive(Debug, Clone, PartialEq, Eq)]
1507pub struct NetworkCapabilities {
1508 pub allow: Vec<String>,
1509 pub unix_sockets: bool,
1517}
1518
1519#[derive(Debug, Clone, PartialEq, Eq)]
1520pub struct FilesystemCapabilities {
1521 pub scope: Option<String>,
1522 pub workdir_exec: bool,
1537 pub read_only: Vec<String>,
1544 pub records: bool,
1549}
1550
1551#[derive(Debug, Clone, PartialEq, Eq)]
1556pub struct TaskIoCapabilities {
1557 pub read: bool,
1561}
1562
1563#[derive(Debug, Clone, PartialEq, Eq)]
1568pub struct ConversationCapabilities {
1569 pub read: bool,
1572}
1573
1574#[derive(Debug, Clone, PartialEq, Eq)]
1578pub struct PlanCapabilities {
1579 pub submit: bool,
1582}
1583
1584#[derive(Debug, Clone, PartialEq, Eq)]
1591pub struct InstallCapabilities {
1592 pub skill: Vec<String>,
1594 pub tool: Vec<String>,
1596 pub from: InstallFrom,
1598}
1599
1600#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1603pub enum InstallFrom {
1604 #[default]
1606 Stores,
1607 StoresAndSources,
1610}
1611
1612impl InstallFrom {
1613 #[must_use]
1615 pub fn sources(self) -> bool {
1616 self == Self::StoresAndSources
1617 }
1618
1619 #[must_use]
1621 pub fn as_list(self) -> &'static str {
1622 match self {
1623 Self::Stores => "stores",
1624 Self::StoresAndSources => "stores, sources",
1625 }
1626 }
1627}
1628
1629#[derive(Debug, Clone, PartialEq, Eq)]
1630pub struct ShellCapabilities {
1631 pub allow: Vec<String>,
1632 pub strip_env: Option<Vec<String>>,
1633 pub baseline_env: Option<Vec<String>>,
1634 pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1641 pub staged_runtime: Vec<StagedRuntimeGrant>,
1647}
1648
1649#[derive(Debug, Clone, PartialEq, Eq)]
1654pub struct InterpreterRuntimeGrant {
1655 pub binary: String,
1659 pub dirs: Vec<InterpreterRuntimeDir>,
1662}
1663
1664#[derive(Debug, Clone, PartialEq, Eq)]
1666pub struct InterpreterRuntimeDir {
1667 pub path: String,
1669 pub list_dir: bool,
1674}
1675
1676#[derive(Debug, Clone, PartialEq, Eq)]
1693pub struct StagedRuntimeGrant {
1694 pub binary: String,
1698 pub source_path: String,
1702 pub pin: String,
1707}
1708
1709#[derive(Debug, Clone, PartialEq, Eq)]
1710pub struct SpawnCapabilities {
1711 pub allow: Vec<String>,
1712}
1713
1714#[derive(Debug, Clone, PartialEq, Eq, Default)]
1719pub struct EnvCapabilities {
1720 pub allow: Vec<String>,
1721}
1722
1723#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1731pub struct ResourceLimits {
1732 pub memory_bytes: Option<usize>,
1734 pub table_elements: Option<usize>,
1736 pub instances: Option<usize>,
1738 pub deadline_seconds: Option<u64>,
1740}
1741
1742#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1756pub struct ResourceCapabilities {
1757 pub max_processes: Option<u64>,
1762 pub max_open_files: Option<u64>,
1764 pub max_file_size_bytes: Option<u64>,
1766 pub cpu_seconds: Option<u64>,
1769 pub memory_bytes: Option<u64>,
1772 pub cgroup_memory_bytes: Option<u64>,
1774 pub cgroup_pids_max: Option<u64>,
1776 pub cgroup_cpu_percent: Option<u32>,
1778 pub cgroup_io_bytes_per_sec: Option<u64>,
1781 pub workdir_max_bytes: Option<u64>,
1783}
1784
1785#[derive(Debug, Clone, PartialEq, Eq, Default)]
1792pub struct StateCapabilities {
1793 pub store: Option<String>,
1801}
1802
1803#[derive(
1816 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1817)]
1818#[serde(rename_all = "snake_case")]
1819pub enum ContainmentClass {
1820 #[default]
1824 Advisory,
1825 Scoped,
1829 Sealed,
1837}
1838
1839impl ContainmentClass {
1840 pub fn as_str(self) -> &'static str {
1843 match self {
1844 Self::Advisory => "advisory",
1845 Self::Scoped => "scoped",
1846 Self::Sealed => "sealed",
1847 }
1848 }
1849
1850 pub const ALL: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1852}
1853
1854impl std::fmt::Display for ContainmentClass {
1855 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1856 f.write_str(self.as_str())
1857 }
1858}
1859
1860#[derive(Debug, Clone, PartialEq, Eq)]
1862pub struct ParseContainmentClassError {
1863 pub value: String,
1864}
1865
1866impl std::fmt::Display for ParseContainmentClassError {
1867 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1868 write!(
1869 f,
1870 "must be one of: advisory, scoped, sealed; got '{}'",
1871 self.value
1872 )
1873 }
1874}
1875
1876impl std::error::Error for ParseContainmentClassError {}
1877
1878impl std::str::FromStr for ContainmentClass {
1879 type Err = ParseContainmentClassError;
1880
1881 fn from_str(value: &str) -> Result<Self, Self::Err> {
1882 match value.trim() {
1883 "advisory" => Ok(Self::Advisory),
1884 "scoped" => Ok(Self::Scoped),
1885 "sealed" => Ok(Self::Sealed),
1886 other => Err(ParseContainmentClassError {
1887 value: other.to_string(),
1888 }),
1889 }
1890 }
1891}
1892
1893pub fn effective_containment_floor(
1899 workspace_default: Option<ContainmentClass>,
1900 manifest_declared: Option<ContainmentClass>,
1901 cli_flag: Option<ContainmentClass>,
1902) -> ContainmentClass {
1903 [workspace_default, manifest_declared, cli_flag]
1904 .into_iter()
1905 .flatten()
1906 .max()
1907 .unwrap_or_default()
1908}
1909
1910#[derive(Debug, Clone, PartialEq, Eq)]
1911pub struct Capabilities {
1912 pub network: Option<NetworkCapabilities>,
1913 pub peer_fetch: Option<PeerFetchCapabilities>,
1916 pub filesystem: Option<FilesystemCapabilities>,
1917 pub shell: Option<ShellCapabilities>,
1918 pub spawn: Option<SpawnCapabilities>,
1919 pub env: Option<EnvCapabilities>,
1920 pub limits: Option<ResourceLimits>,
1921 pub resources: Option<ResourceCapabilities>,
1922 pub state: Option<StateCapabilities>,
1925 pub task_io: Option<TaskIoCapabilities>,
1928 pub conversation: Option<ConversationCapabilities>,
1931 pub plan: Option<PlanCapabilities>,
1934 pub install: Option<InstallCapabilities>,
1937 pub containment: Option<ContainmentClass>,
1941}
1942
1943#[derive(Debug, Error)]
1944pub enum RuntimeManifestError {
1945 #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
1946 NotFound(String),
1947 #[error("{0}")]
1948 YamlSyntax(String),
1949 #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
1950 MissingField { field: String },
1951 #[error(
1952 "{}: invalid artifact declaration at index {index}: {message}",
1953 MANIFEST_FILENAME
1954 )]
1955 InvalidArtifact { index: usize, message: String },
1956 #[error(
1957 "{}: invalid inference config for '{field}': {message}",
1958 MANIFEST_FILENAME
1959 )]
1960 InvalidInferenceConfig { field: String, message: String },
1961 #[error(
1962 "{}: invalid capability config for '{field}': {message}",
1963 MANIFEST_FILENAME
1964 )]
1965 InvalidCapabilities { field: String, message: String },
1966 #[error(
1969 "{}: invalid capability config for 'capabilities.shell.allow[{index}]': '{}' {reason}; \
1970 an entry is a bare binary name",
1971 MANIFEST_FILENAME,
1972 entry.escape_debug()
1973 )]
1974 ShellAllowEntry {
1975 index: usize,
1976 entry: String,
1977 reason: &'static str,
1978 },
1979 #[error(
1980 "{}: invalid exports config for '{field}': {message}",
1981 MANIFEST_FILENAME
1982 )]
1983 InvalidExports { field: String, message: String },
1984 #[error(
1985 "{}: invalid control config for '{field}': {message}",
1986 MANIFEST_FILENAME
1987 )]
1988 InvalidControl { field: String, message: String },
1989 #[error(
1990 "{}: invalid network config for '{field}': {message}",
1991 MANIFEST_FILENAME
1992 )]
1993 InvalidNetworkConfig { field: String, message: String },
1994 #[error(
1997 "{}: invalid network config for '{field}': '{scope}' is a retired name of a door \
1998 method, and the door answers only the names its card lists",
1999 MANIFEST_FILENAME
2000 )]
2001 RetiredDoorScope {
2002 field: String,
2003 scope: String,
2004 replacement: &'static str,
2005 },
2006 #[error(
2009 "{}: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves task_acceptance: \
2010 queue no room for a waiting task",
2011 MANIFEST_FILENAME
2012 )]
2013 QueueWithoutRoom,
2014 #[error(
2017 "{}: 'mur_version' is not a top-level key — the mur release a deploy installs is pinned \
2018 as 'deploy.mur_version'",
2019 MANIFEST_FILENAME
2020 )]
2021 TopLevelMurVersion,
2022 #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
2023 InvalidTraceConfig { field: String, message: String },
2024 #[error(
2026 "{}: invalid lifecycle config for '{field}': {message}",
2027 MANIFEST_FILENAME
2028 )]
2029 InvalidLifecycleConfig { field: String, message: String },
2030 #[error("{}: invalid artifact name in '{field}': {message}", MANIFEST_FILENAME)]
2033 InvalidArtifactName {
2034 field: String,
2035 message: String,
2036 hint: Option<String>,
2037 },
2038 #[error("{}", REGISTRY_BLOCK_MESSAGE)]
2040 RegistryBlock,
2041 #[error(
2045 "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
2046 gateway.endpoint '{endpoint}' but binds no credential",
2047 MANIFEST_FILENAME
2048 )]
2049 GatewayWithoutCredential {
2050 index: usize,
2051 name: String,
2052 endpoint: String,
2053 },
2054 #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
2055 Io {
2056 path: String,
2057 #[source]
2058 source: std::io::Error,
2059 },
2060}
2061
2062#[derive(Debug, Deserialize)]
2063struct RawRuntimeManifest {
2064 name: Option<String>,
2065 version: Option<String>,
2066 #[serde(default)]
2067 artifacts: Vec<RawArtifact>,
2068 #[serde(default)]
2069 capabilities: Option<RawCapabilities>,
2070 #[serde(default)]
2071 inference: Option<RawInferenceConfig>,
2072 #[serde(default)]
2073 context: Option<RawContextConfig>,
2074 #[serde(default)]
2075 observability: Option<RawObservabilityConfig>,
2076 #[serde(default)]
2077 trace: Option<RawTraceConfig>,
2078 #[serde(default)]
2079 network: Option<RawNetworkConfig>,
2080 #[serde(default)]
2081 lifecycle: Option<RawLifecycleConfig>,
2082 #[serde(default)]
2083 exports: Option<RawExports>,
2084 #[serde(default)]
2085 control: Option<RawControl>,
2086 #[serde(default)]
2087 deploy: Option<RawDeploy>,
2088 #[serde(default, deserialize_with = "deserialize_present")]
2092 mur_version: Option<serde_norway::Value>,
2093 #[serde(default, deserialize_with = "deserialize_present")]
2097 config: Option<serde_norway::Value>,
2098 #[serde(default)]
2103 #[allow(dead_code)]
2104 runtime: Option<serde_norway::Value>,
2105 #[serde(default)]
2109 #[allow(dead_code)]
2110 implementation: Option<serde_norway::Value>,
2111 #[serde(default)]
2115 #[allow(dead_code)]
2116 execution: Option<serde_norway::Value>,
2117 #[serde(default)]
2120 #[allow(dead_code)]
2121 requires_files: Option<serde_norway::Value>,
2122 #[serde(flatten)]
2123 unknown: UnknownKeys,
2124}
2125
2126#[derive(Debug, Deserialize)]
2127struct RawDeploy {
2128 #[serde(default)]
2129 mur_version: Option<String>,
2130 #[serde(flatten)]
2131 unknown: UnknownKeys,
2132}
2133
2134#[derive(Debug, Deserialize)]
2135struct RawControl {
2136 #[serde(default)]
2140 settings: Option<serde_norway::Value>,
2141 #[serde(default)]
2142 secrets: Option<serde_norway::Value>,
2143 #[serde(default)]
2144 agent_settings: Option<serde_norway::Value>,
2145 #[serde(flatten)]
2146 unknown: UnknownKeys,
2147}
2148
2149#[derive(Debug, Deserialize)]
2150struct RawExports {
2151 #[serde(default)]
2152 files: Option<RawFileExport>,
2153 #[serde(default)]
2154 peer_files: Option<RawPeerFilesExport>,
2155 #[serde(default)]
2156 peer_tasks: Option<RawPeerTasksExport>,
2157 #[serde(flatten)]
2158 unknown: UnknownKeys,
2159}
2160
2161#[derive(Debug, Deserialize)]
2162struct RawPeerTasksExport {
2163 #[serde(default)]
2166 accept: Option<bool>,
2167 #[serde(flatten)]
2168 unknown: UnknownKeys,
2169}
2170
2171#[derive(Debug, Deserialize)]
2172struct RawPeerFilesExport {
2173 #[serde(default)]
2174 root: Option<String>,
2175 #[serde(default)]
2179 max_ttl: Option<serde_norway::Value>,
2180 #[serde(default)]
2181 max_bytes: Option<serde_norway::Value>,
2182 #[serde(flatten)]
2183 unknown: UnknownKeys,
2184}
2185
2186#[derive(Debug, Deserialize)]
2187struct RawFileExport {
2188 #[serde(default)]
2189 root: Option<String>,
2190 #[serde(default)]
2191 mode: Option<String>,
2192 #[serde(default)]
2197 max_bytes: Option<serde_norway::Value>,
2198 #[serde(flatten)]
2199 unknown: UnknownKeys,
2200}
2201
2202#[derive(Debug, Deserialize)]
2203struct RawLifecycleConfig {
2204 #[serde(default)]
2205 task_acceptance: Option<TaskAcceptance>,
2206 #[serde(default)]
2207 after_task: Option<AfterTask>,
2208 #[serde(default)]
2209 queue_depth: Option<usize>,
2210 #[serde(default)]
2211 input_timeout_secs: Option<u64>,
2212 #[serde(default)]
2213 conversation: Option<ConversationMode>,
2214 #[serde(default)]
2215 max_task_reopens: Option<u32>,
2216 #[serde(default)]
2217 shell_grace_secs: Option<u64>,
2218 #[serde(default)]
2219 delegation_deadline_secs: Option<u64>,
2220 #[serde(default)]
2221 deadline_notice_secs: Option<u64>,
2222 #[serde(default)]
2223 keep_working: Option<RawKeepWorking>,
2224 #[serde(default)]
2225 done_check: Option<RawDoneCheck>,
2226 #[serde(flatten)]
2227 unknown: UnknownKeys,
2228}
2229
2230#[derive(Debug, Deserialize)]
2231struct RawDoneCheck {
2232 #[serde(default)]
2233 command: Option<String>,
2234 #[serde(default)]
2235 limit: Option<u32>,
2236 #[serde(default)]
2237 timeout_secs: Option<u64>,
2238 #[serde(flatten)]
2239 unknown: UnknownKeys,
2240}
2241
2242#[derive(Debug, Deserialize)]
2243struct RawKeepWorking {
2244 #[serde(default)]
2245 message: Option<String>,
2246 #[serde(default)]
2247 limit: Option<u32>,
2248 #[serde(flatten)]
2249 unknown: UnknownKeys,
2250}
2251
2252#[derive(Debug, Deserialize)]
2253struct RawNetworkConfig {
2254 internal_port: Option<u16>,
2255 #[serde(default)]
2256 authentication: Option<RawNetworkAuthentication>,
2257 #[serde(flatten)]
2258 unknown: UnknownKeys,
2259}
2260
2261#[derive(Debug, Deserialize)]
2262struct RawNetworkAuthentication {
2263 #[serde(default)]
2264 scheme: Option<String>,
2265 #[serde(default)]
2268 credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
2269 #[serde(flatten)]
2270 unknown: UnknownKeys,
2271}
2272
2273#[derive(Debug, Deserialize)]
2274struct RawDoorCredential {
2275 #[serde(default)]
2279 scopes: Option<serde_norway::Value>,
2280 #[serde(flatten)]
2281 unknown: UnknownKeys,
2282}
2283
2284#[derive(Debug, Deserialize)]
2285struct RawContextConfig {
2286 max_tokens: Option<u32>,
2287 #[serde(default)]
2290 record: Option<String>,
2291 #[serde(default)]
2292 record_store: Option<String>,
2293 #[serde(default)]
2294 seed_budget: Option<f32>,
2295 #[serde(default)]
2296 seed_overflow_margin: Option<f32>,
2297 #[serde(default, deserialize_with = "present_yaml_value")]
2300 retain: Option<serde_norway::Value>,
2301 #[serde(flatten)]
2302 unknown: UnknownKeys,
2303}
2304
2305#[derive(Debug, Deserialize)]
2306struct RawObservabilityConfig {
2307 #[serde(default)]
2308 otel_endpoint: Option<String>,
2309 #[serde(default)]
2310 eval: Option<RawEvalConfig>,
2311 #[serde(flatten)]
2312 unknown: UnknownKeys,
2313}
2314
2315#[derive(Debug, Deserialize)]
2316struct RawTraceConfig {
2317 #[serde(default)]
2321 capture: Option<String>,
2322 #[serde(default, deserialize_with = "present_yaml_value")]
2324 retain: Option<serde_norway::Value>,
2325 #[serde(flatten)]
2326 unknown: UnknownKeys,
2327}
2328
2329#[derive(Debug, Deserialize)]
2330struct RawEvalConfig {
2331 #[serde(default)]
2332 dataset_id: Option<String>,
2333 #[serde(default)]
2334 scorers: Vec<RawScorerConfig>,
2335 #[serde(flatten)]
2336 unknown: UnknownKeys,
2337}
2338
2339#[derive(Debug, Deserialize)]
2340struct RawScorerConfig {
2341 #[serde(rename = "type")]
2342 scorer_type: String,
2343 name: Option<String>,
2344 max: Option<u64>,
2345 expected: Option<Vec<String>>,
2346 #[serde(flatten)]
2347 unknown: UnknownKeys,
2348}
2349
2350#[derive(Debug, Deserialize)]
2351struct RawArtifact {
2352 name: Option<String>,
2353 version: Option<String>,
2354 runtime: Option<String>,
2355 #[serde(default)]
2356 source: Option<String>,
2357 #[serde(default)]
2358 local_source: Option<bool>,
2359 #[serde(default)]
2360 prompt_payload: Option<bool>,
2361 #[serde(default)]
2362 capabilities: Option<RawCapabilities>,
2363 #[serde(default)]
2364 on_overflow: Option<String>,
2365 #[serde(default, deserialize_with = "deserialize_present")]
2370 config: Option<serde_norway::Value>,
2371 #[serde(default, deserialize_with = "deserialize_present")]
2374 gateway: Option<Option<RawArtifactGateway>>,
2375 #[serde(flatten)]
2376 unknown: UnknownKeys,
2377}
2378
2379#[derive(Debug, Deserialize)]
2380struct RawArtifactGateway {
2381 #[serde(default)]
2382 endpoint: Option<String>,
2383 #[serde(default, deserialize_with = "deserialize_present")]
2386 api_key: Option<Option<String>>,
2387 #[serde(default)]
2389 keyless: Option<serde_norway::Value>,
2390 #[serde(flatten)]
2391 unknown: UnknownKeys,
2392}
2393
2394fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
2400where
2401 T: Deserialize<'de>,
2402 D: serde::Deserializer<'de>,
2403{
2404 T::deserialize(deserializer).map(Some)
2405}
2406
2407#[derive(Debug, Deserialize)]
2408struct RawCapabilities {
2409 #[serde(default)]
2410 network: Option<RawNetworkCapabilities>,
2411 #[serde(default)]
2412 peer_fetch: Option<RawPeerFetchCapabilities>,
2413 #[serde(default)]
2414 filesystem: Option<RawFilesystemCapabilities>,
2415 #[serde(default)]
2416 shell: Option<RawShellCapabilities>,
2417 #[serde(default)]
2418 spawn: Option<RawSpawnCapabilities>,
2419 #[serde(default)]
2420 env: Option<RawEnvCapabilities>,
2421 #[serde(default)]
2422 limits: Option<RawResourceLimits>,
2423 #[serde(default)]
2424 resources: Option<RawResourceCapabilities>,
2425 #[serde(default)]
2426 state: Option<RawStateCapabilities>,
2427 #[serde(default)]
2428 task_io: Option<RawTaskIoCapabilities>,
2429 #[serde(default)]
2430 conversation: Option<RawConversationCapabilities>,
2431 #[serde(default)]
2432 plan: Option<RawPlanCapabilities>,
2433 #[serde(default)]
2434 install: Option<RawInstallCapabilities>,
2435 #[serde(default)]
2439 containment: Option<String>,
2440 #[serde(flatten)]
2441 unknown: UnknownKeys,
2442}
2443
2444#[derive(Debug, Deserialize)]
2445struct RawTaskIoCapabilities {
2446 #[serde(default)]
2449 read: Option<bool>,
2450 #[serde(flatten)]
2451 unknown: UnknownKeys,
2452}
2453
2454#[derive(Debug, Deserialize)]
2455struct RawConversationCapabilities {
2456 #[serde(default)]
2459 read: Option<bool>,
2460 #[serde(flatten)]
2461 unknown: UnknownKeys,
2462}
2463
2464#[derive(Debug, Deserialize)]
2465struct RawPlanCapabilities {
2466 #[serde(default)]
2469 submit: Option<bool>,
2470 #[serde(flatten)]
2471 unknown: UnknownKeys,
2472}
2473
2474#[derive(Debug, Deserialize)]
2475struct RawInstallCapabilities {
2476 #[serde(default)]
2477 skill: Vec<String>,
2478 #[serde(default)]
2479 tool: Vec<String>,
2480 #[serde(default)]
2481 from: Option<Vec<String>>,
2482 #[serde(flatten)]
2483 unknown: UnknownKeys,
2484}
2485
2486#[derive(Debug, Deserialize)]
2487struct RawSpawnCapabilities {
2488 #[serde(default)]
2489 allow: Vec<String>,
2490 #[serde(flatten)]
2491 unknown: UnknownKeys,
2492}
2493
2494#[derive(Debug, Deserialize)]
2495struct RawEnvCapabilities {
2496 #[serde(default)]
2497 allow: Vec<String>,
2498 #[serde(flatten)]
2499 unknown: UnknownKeys,
2500}
2501
2502#[derive(Debug, Deserialize)]
2503struct RawResourceLimits {
2504 #[serde(default)]
2505 memory_bytes: Option<usize>,
2506 #[serde(default)]
2507 table_elements: Option<usize>,
2508 #[serde(default)]
2509 instances: Option<usize>,
2510 #[serde(default)]
2511 deadline_seconds: Option<u64>,
2512 #[serde(flatten)]
2513 unknown: UnknownKeys,
2514}
2515
2516#[derive(Debug, Deserialize)]
2517struct RawStateCapabilities {
2518 #[serde(default)]
2519 store: Option<String>,
2520 #[serde(flatten)]
2521 unknown: UnknownKeys,
2522}
2523
2524#[derive(Debug, Deserialize)]
2525struct RawResourceCapabilities {
2526 #[serde(default)]
2527 max_processes: Option<u64>,
2528 #[serde(default)]
2529 max_open_files: Option<u64>,
2530 #[serde(default)]
2531 max_file_size_bytes: Option<u64>,
2532 #[serde(default)]
2533 cpu_seconds: Option<u64>,
2534 #[serde(default)]
2535 memory_bytes: Option<u64>,
2536 #[serde(default)]
2537 cgroup_memory_bytes: Option<u64>,
2538 #[serde(default)]
2539 cgroup_pids_max: Option<u64>,
2540 #[serde(default)]
2541 cgroup_cpu_percent: Option<u32>,
2542 #[serde(default)]
2543 cgroup_io_bytes_per_sec: Option<u64>,
2544 #[serde(default)]
2545 workdir_max_bytes: Option<u64>,
2546 #[serde(flatten)]
2547 unknown: UnknownKeys,
2548}
2549
2550#[derive(Debug, Deserialize)]
2551struct RawNetworkCapabilities {
2552 #[serde(default)]
2553 allow: Vec<String>,
2554 #[serde(default)]
2555 unix_sockets: bool,
2556 #[serde(flatten)]
2557 unknown: UnknownKeys,
2558}
2559
2560#[derive(Debug, Deserialize)]
2561struct RawPeerFetchCapabilities {
2562 #[serde(default)]
2563 allow: Vec<String>,
2564 #[serde(flatten)]
2565 unknown: UnknownKeys,
2566}
2567
2568#[derive(Debug, Deserialize)]
2569struct RawFilesystemCapabilities {
2570 #[serde(default)]
2571 scope: Option<String>,
2572 #[serde(default)]
2576 workdir_exec: bool,
2577 #[serde(default)]
2580 read_only: Vec<String>,
2581 #[serde(default)]
2583 records: bool,
2584 #[serde(flatten)]
2585 unknown: UnknownKeys,
2586}
2587
2588#[derive(Debug, Deserialize)]
2589struct RawShellCapabilities {
2590 #[serde(default)]
2591 allow: Vec<String>,
2592 #[serde(default)]
2593 strip_env: Option<Vec<String>>,
2594 #[serde(default)]
2595 baseline_env: Option<Vec<String>>,
2596 #[serde(default)]
2597 interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
2598 #[serde(default)]
2599 staged_runtime: Vec<RawStagedRuntimeGrant>,
2600 #[serde(flatten)]
2601 unknown: UnknownKeys,
2602}
2603
2604#[derive(Debug, Deserialize)]
2605struct RawInterpreterRuntimeGrant {
2606 #[serde(default)]
2607 binary: Option<String>,
2608 #[serde(default)]
2609 dirs: Vec<RawInterpreterRuntimeDir>,
2610 #[serde(flatten)]
2611 unknown: UnknownKeys,
2612}
2613
2614#[derive(Debug, Deserialize)]
2615struct RawInterpreterRuntimeDir {
2616 #[serde(default)]
2617 path: Option<String>,
2618 #[serde(default)]
2622 list_dir: Option<bool>,
2623 #[serde(flatten)]
2624 unknown: UnknownKeys,
2625}
2626
2627#[derive(Debug, Deserialize)]
2628struct RawStagedRuntimeGrant {
2629 #[serde(default)]
2630 binary: Option<String>,
2631 #[serde(default)]
2632 source_path: Option<String>,
2633 #[serde(default)]
2637 pin: Option<String>,
2638 #[serde(flatten)]
2639 unknown: UnknownKeys,
2640}
2641
2642#[derive(Debug, Deserialize)]
2643struct RawInferenceConfig {
2644 transport: Option<String>,
2645 #[serde(default, deserialize_with = "deserialize_present")]
2648 endpoint: Option<serde_norway::Value>,
2649 model: Option<String>,
2650 #[serde(default, deserialize_with = "deserialize_present")]
2653 api_key: Option<serde_norway::Value>,
2654 #[serde(default)]
2655 command: Option<String>,
2656 #[serde(default)]
2657 system_prompt: Option<String>,
2658 #[serde(default)]
2659 system_prompt_file: Option<String>,
2660 #[serde(default)]
2661 system_prompt_artifact: Option<String>,
2662 #[serde(default)]
2663 driver: Option<RawInferenceDriver>,
2664 #[serde(default)]
2665 compaction: Option<RawCompactionConfig>,
2666 #[serde(default)]
2667 max_turns: Option<u32>,
2668 #[serde(default)]
2671 max_task_reopens: Option<u32>,
2672 #[serde(default)]
2673 max_tokens: Option<u32>,
2674 #[serde(default)]
2675 max_session_tokens: Option<u64>,
2676 #[serde(default)]
2677 tool_refresh: Option<String>,
2678 #[serde(default)]
2679 alternates: Option<Vec<RawInferenceAlternate>>,
2680 #[serde(default)]
2681 retry: Option<RawInferenceRetry>,
2682 #[serde(flatten)]
2683 unknown: UnknownKeys,
2684}
2685
2686#[derive(Debug, Deserialize)]
2687struct RawInferenceRetry {
2688 #[serde(default)]
2689 max_retries: Option<u32>,
2690 #[serde(default)]
2691 initial_delay_ms: Option<u32>,
2692 #[serde(default)]
2693 max_delay_ms: Option<u32>,
2694 #[serde(flatten)]
2695 unknown: UnknownKeys,
2696}
2697
2698#[derive(Debug, Deserialize)]
2699struct RawInferenceAlternate {
2700 #[serde(default)]
2701 name: Option<String>,
2702 #[serde(default)]
2703 model: Option<String>,
2704 #[serde(default)]
2705 driver: Option<RawInferenceDriver>,
2706 #[serde(flatten)]
2707 unknown: UnknownKeys,
2708}
2709
2710#[derive(Debug, Deserialize)]
2711struct RawCompactionConfig {
2712 threshold: Option<f32>,
2713 model: Option<String>,
2714 system_prompt: Option<String>,
2715 system_prompt_file: Option<String>,
2716 dump_summaries: Option<bool>,
2717 max_tokens: Option<u32>,
2718 #[serde(default)]
2719 driver_config: Option<serde_norway::Value>,
2720 #[serde(flatten)]
2721 unknown: UnknownKeys,
2722}
2723
2724#[derive(Debug, Deserialize)]
2725struct RawInferenceDriver {
2726 artifact: Option<String>,
2727 #[serde(default)]
2728 config: Option<serde_norway::Value>,
2729 #[serde(flatten)]
2730 unknown: UnknownKeys,
2731}
2732
2733type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2740
2741trait RawBlock {
2748 const KNOWN_KEYS: &'static [&'static str];
2752
2753 fn unknown_keys(&self) -> &UnknownKeys;
2754
2755 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2763 let _ = (path, out);
2764 }
2765}
2766
2767impl RawBlock for RawRuntimeManifest {
2768 const KNOWN_KEYS: &'static [&'static str] = &[
2769 "name",
2770 "version",
2771 "artifacts",
2772 "capabilities",
2773 "inference",
2774 "context",
2775 "observability",
2776 "trace",
2777 "network",
2778 "lifecycle",
2779 "exports",
2780 "control",
2781 "deploy",
2782 "mur_version",
2783 "config",
2784 "runtime",
2785 "implementation",
2786 "execution",
2787 "requires_files",
2788 ];
2789 fn unknown_keys(&self) -> &UnknownKeys {
2790 &self.unknown
2791 }
2792
2793 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2794 let artifacts = child_path(path, "artifacts");
2795 for (index, artifact) in self.artifacts.iter().enumerate() {
2796 collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2797 }
2798 if let Some(capabilities) = &self.capabilities {
2799 collect_block(capabilities, &child_path(path, "capabilities"), out);
2800 }
2801 if let Some(inference) = &self.inference {
2802 collect_block(inference, &child_path(path, "inference"), out);
2803 }
2804 if let Some(context) = &self.context {
2805 collect_block(context, &child_path(path, "context"), out);
2806 }
2807 if let Some(observability) = &self.observability {
2808 collect_block(observability, &child_path(path, "observability"), out);
2809 }
2810 if let Some(trace) = &self.trace {
2811 collect_block(trace, &child_path(path, "trace"), out);
2812 }
2813 if let Some(network) = &self.network {
2814 collect_block(network, &child_path(path, "network"), out);
2815 }
2816 if let Some(lifecycle) = &self.lifecycle {
2817 collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2818 }
2819 if let Some(exports) = &self.exports {
2820 collect_block(exports, &child_path(path, "exports"), out);
2821 }
2822 if let Some(control) = &self.control {
2823 collect_block(control, &child_path(path, "control"), out);
2824 }
2825 if let Some(deploy) = &self.deploy {
2826 collect_block(deploy, &child_path(path, "deploy"), out);
2827 }
2828 }
2829}
2830
2831impl RawBlock for RawDeploy {
2832 const KNOWN_KEYS: &'static [&'static str] = &["mur_version"];
2833 fn unknown_keys(&self) -> &UnknownKeys {
2834 &self.unknown
2835 }
2836}
2837
2838impl RawBlock for RawControl {
2839 const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
2840 fn unknown_keys(&self) -> &UnknownKeys {
2841 &self.unknown
2842 }
2843}
2844
2845impl RawBlock for RawExports {
2846 const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files", "peer_tasks"];
2847 fn unknown_keys(&self) -> &UnknownKeys {
2848 &self.unknown
2849 }
2850
2851 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2852 if let Some(files) = &self.files {
2853 collect_block(files, &child_path(path, "files"), out);
2854 }
2855 if let Some(peer_files) = &self.peer_files {
2856 collect_block(peer_files, &child_path(path, "peer_files"), out);
2857 }
2858 if let Some(peer_tasks) = &self.peer_tasks {
2859 collect_block(peer_tasks, &child_path(path, "peer_tasks"), out);
2860 }
2861 }
2862}
2863
2864impl RawBlock for RawPeerTasksExport {
2865 const KNOWN_KEYS: &'static [&'static str] = &["accept"];
2866 fn unknown_keys(&self) -> &UnknownKeys {
2867 &self.unknown
2868 }
2869}
2870
2871impl RawBlock for RawPeerFilesExport {
2872 const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2873 fn unknown_keys(&self) -> &UnknownKeys {
2874 &self.unknown
2875 }
2876}
2877
2878impl RawBlock for RawFileExport {
2879 const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2880 fn unknown_keys(&self) -> &UnknownKeys {
2881 &self.unknown
2882 }
2883}
2884
2885fn parse_keep_working(raw: RawKeepWorking) -> Result<KeepWorking, RuntimeManifestError> {
2888 let invalid = |field: &str, message: &str| RuntimeManifestError::InvalidLifecycleConfig {
2889 field: format!("lifecycle.keep_working.{field}"),
2890 message: message.to_string(),
2891 };
2892 let message = match raw.message {
2893 None => return Err(invalid("message", "is required")),
2894 Some(message) if message.trim().is_empty() => {
2895 return Err(invalid("message", "must not be blank"))
2896 }
2897 Some(message) => message,
2898 };
2899 let limit = match raw.limit {
2900 None => return Err(invalid("limit", "is required")),
2901 Some(0) => {
2902 return Err(invalid(
2903 "limit",
2904 "must be at least 1; omit lifecycle.keep_working to turn the option off",
2905 ))
2906 }
2907 Some(limit) => limit,
2908 };
2909 Ok(KeepWorking { message, limit })
2910}
2911
2912fn parse_done_check(
2916 raw: RawDoneCheck,
2917 shell_allow: &[String],
2918) -> Result<DoneCheck, RuntimeManifestError> {
2919 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidLifecycleConfig {
2920 field: format!("lifecycle.done_check.{field}"),
2921 message,
2922 };
2923 let command = match raw.command {
2924 None => return Err(invalid("command", "is required".into())),
2925 Some(command) if command.trim().is_empty() => {
2926 return Err(invalid("command", "must not be blank".into()))
2927 }
2928 Some(command) => command,
2929 };
2930 let Some(program) = split_shell_words(&command).into_iter().next() else {
2931 return Err(invalid("command", "must name a program".into()));
2932 };
2933 if !shell_allow.contains(&program) {
2934 return Err(invalid(
2935 "command",
2936 format!("runs '{program}', which is not in capabilities.shell.allow"),
2937 ));
2938 }
2939 let Some(limit) = raw.limit else {
2940 return Err(invalid("limit", "is required".into()));
2941 };
2942 let timeout_secs = match raw.timeout_secs {
2943 None => DoneCheck::DEFAULT_TIMEOUT_SECS,
2944 Some(0) => return Err(invalid("timeout_secs", "must be at least 1".into())),
2945 Some(secs) => secs,
2946 };
2947 Ok(DoneCheck {
2948 command,
2949 limit,
2950 timeout_secs,
2951 })
2952}
2953
2954impl RawBlock for RawLifecycleConfig {
2955 const KNOWN_KEYS: &'static [&'static str] = &[
2956 "task_acceptance",
2957 "after_task",
2958 "queue_depth",
2959 "input_timeout_secs",
2960 "conversation",
2961 "max_task_reopens",
2962 "shell_grace_secs",
2963 "delegation_deadline_secs",
2964 "deadline_notice_secs",
2965 "keep_working",
2966 "done_check",
2967 ];
2968 fn unknown_keys(&self) -> &UnknownKeys {
2969 &self.unknown
2970 }
2971
2972 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2973 if let Some(keep_working) = &self.keep_working {
2974 collect_block(keep_working, &child_path(path, "keep_working"), out);
2975 }
2976 if let Some(done_check) = &self.done_check {
2977 collect_block(done_check, &child_path(path, "done_check"), out);
2978 }
2979 }
2980}
2981
2982impl RawBlock for RawDoneCheck {
2983 const KNOWN_KEYS: &'static [&'static str] = &["command", "limit", "timeout_secs"];
2984 fn unknown_keys(&self) -> &UnknownKeys {
2985 &self.unknown
2986 }
2987}
2988
2989impl RawBlock for RawKeepWorking {
2990 const KNOWN_KEYS: &'static [&'static str] = &["message", "limit"];
2991 fn unknown_keys(&self) -> &UnknownKeys {
2992 &self.unknown
2993 }
2994}
2995
2996impl RawBlock for RawNetworkConfig {
2997 const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
2998 fn unknown_keys(&self) -> &UnknownKeys {
2999 &self.unknown
3000 }
3001
3002 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3003 if let Some(authentication) = &self.authentication {
3004 collect_block(authentication, &child_path(path, "authentication"), out);
3005 }
3006 }
3007}
3008
3009impl RawBlock for RawNetworkAuthentication {
3010 const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
3011 fn unknown_keys(&self) -> &UnknownKeys {
3012 &self.unknown
3013 }
3014
3015 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3016 let credentials_path = child_path(path, "credentials");
3017 for (name, credential) in self.credentials.iter().flatten() {
3018 collect_block(credential, &child_path(&credentials_path, name), out);
3019 }
3020 }
3021}
3022
3023impl RawBlock for RawDoorCredential {
3024 const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
3025 fn unknown_keys(&self) -> &UnknownKeys {
3026 &self.unknown
3027 }
3028}
3029
3030impl RawBlock for RawContextConfig {
3031 const KNOWN_KEYS: &'static [&'static str] = &[
3032 "max_tokens",
3033 "record",
3034 "record_store",
3035 "seed_budget",
3036 "seed_overflow_margin",
3037 "retain",
3038 ];
3039 fn unknown_keys(&self) -> &UnknownKeys {
3040 &self.unknown
3041 }
3042}
3043
3044impl RawBlock for RawObservabilityConfig {
3045 const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
3046 fn unknown_keys(&self) -> &UnknownKeys {
3047 &self.unknown
3048 }
3049
3050 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3051 if let Some(eval) = &self.eval {
3052 collect_block(eval, &child_path(path, "eval"), out);
3053 }
3054 }
3055}
3056
3057impl RawBlock for RawTraceConfig {
3058 const KNOWN_KEYS: &'static [&'static str] = &["capture", "retain"];
3059 fn unknown_keys(&self) -> &UnknownKeys {
3060 &self.unknown
3061 }
3062}
3063
3064impl RawBlock for RawEvalConfig {
3065 const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
3066 fn unknown_keys(&self) -> &UnknownKeys {
3067 &self.unknown
3068 }
3069
3070 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3071 let scorers = child_path(path, "scorers");
3072 for (index, scorer) in self.scorers.iter().enumerate() {
3073 collect_block(scorer, &format!("{scorers}[{index}]"), out);
3074 }
3075 }
3076}
3077
3078impl RawBlock for RawScorerConfig {
3079 const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
3080 fn unknown_keys(&self) -> &UnknownKeys {
3081 &self.unknown
3082 }
3083}
3084
3085impl RawBlock for RawArtifact {
3086 const KNOWN_KEYS: &'static [&'static str] = &[
3087 "name",
3088 "version",
3089 "runtime",
3090 "source",
3091 "local_source",
3092 "prompt_payload",
3093 "capabilities",
3094 "on_overflow",
3095 "config",
3096 "gateway",
3097 ];
3098 fn unknown_keys(&self) -> &UnknownKeys {
3099 &self.unknown
3100 }
3101
3102 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3103 if let Some(capabilities) = &self.capabilities {
3104 collect_block(capabilities, &child_path(path, "capabilities"), out);
3105 }
3106 if let Some(Some(gateway)) = &self.gateway {
3107 collect_block(gateway, &child_path(path, "gateway"), out);
3108 }
3109 }
3110}
3111
3112impl RawBlock for RawArtifactGateway {
3113 const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
3114 fn unknown_keys(&self) -> &UnknownKeys {
3115 &self.unknown
3116 }
3117}
3118
3119impl RawBlock for RawCapabilities {
3120 const KNOWN_KEYS: &'static [&'static str] = &[
3121 "network",
3122 "peer_fetch",
3123 "filesystem",
3124 "shell",
3125 "spawn",
3126 "env",
3127 "limits",
3128 "resources",
3129 "state",
3130 "task_io",
3131 "conversation",
3132 "plan",
3133 "install",
3134 "containment",
3135 ];
3136 fn unknown_keys(&self) -> &UnknownKeys {
3137 &self.unknown
3138 }
3139
3140 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3144 if let Some(network) = &self.network {
3145 collect_block(network, &child_path(path, "network"), out);
3146 }
3147 if let Some(peer_fetch) = &self.peer_fetch {
3148 collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
3149 }
3150 if let Some(filesystem) = &self.filesystem {
3151 collect_block(filesystem, &child_path(path, "filesystem"), out);
3152 }
3153 if let Some(shell) = &self.shell {
3154 collect_block(shell, &child_path(path, "shell"), out);
3155 }
3156 if let Some(spawn) = &self.spawn {
3157 collect_block(spawn, &child_path(path, "spawn"), out);
3158 }
3159 if let Some(env) = &self.env {
3160 collect_block(env, &child_path(path, "env"), out);
3161 }
3162 if let Some(limits) = &self.limits {
3163 collect_block(limits, &child_path(path, "limits"), out);
3164 }
3165 if let Some(resources) = &self.resources {
3166 collect_block(resources, &child_path(path, "resources"), out);
3167 }
3168 if let Some(state) = &self.state {
3169 collect_block(state, &child_path(path, "state"), out);
3170 }
3171 if let Some(task_io) = &self.task_io {
3172 collect_block(task_io, &child_path(path, "task_io"), out);
3173 }
3174 if let Some(conversation) = &self.conversation {
3175 collect_block(conversation, &child_path(path, "conversation"), out);
3176 }
3177 if let Some(plan) = &self.plan {
3178 collect_block(plan, &child_path(path, "plan"), out);
3179 }
3180 if let Some(install) = &self.install {
3181 collect_block(install, &child_path(path, "install"), out);
3182 }
3183 }
3184}
3185
3186impl RawBlock for RawTaskIoCapabilities {
3187 const KNOWN_KEYS: &'static [&'static str] = &["read"];
3188 fn unknown_keys(&self) -> &UnknownKeys {
3189 &self.unknown
3190 }
3191}
3192
3193impl RawBlock for RawConversationCapabilities {
3194 const KNOWN_KEYS: &'static [&'static str] = &["read"];
3195 fn unknown_keys(&self) -> &UnknownKeys {
3196 &self.unknown
3197 }
3198}
3199
3200impl RawBlock for RawPlanCapabilities {
3201 const KNOWN_KEYS: &'static [&'static str] = &["submit"];
3202 fn unknown_keys(&self) -> &UnknownKeys {
3203 &self.unknown
3204 }
3205}
3206
3207impl RawBlock for RawInstallCapabilities {
3208 const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool", "from"];
3209 fn unknown_keys(&self) -> &UnknownKeys {
3210 &self.unknown
3211 }
3212}
3213
3214impl RawBlock for RawSpawnCapabilities {
3215 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3216 fn unknown_keys(&self) -> &UnknownKeys {
3217 &self.unknown
3218 }
3219}
3220
3221impl RawBlock for RawEnvCapabilities {
3222 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3223 fn unknown_keys(&self) -> &UnknownKeys {
3224 &self.unknown
3225 }
3226}
3227
3228impl RawBlock for RawResourceLimits {
3229 const KNOWN_KEYS: &'static [&'static str] = &[
3230 "memory_bytes",
3231 "table_elements",
3232 "instances",
3233 "deadline_seconds",
3234 ];
3235 fn unknown_keys(&self) -> &UnknownKeys {
3236 &self.unknown
3237 }
3238}
3239
3240impl RawBlock for RawStateCapabilities {
3241 const KNOWN_KEYS: &'static [&'static str] = &["store"];
3242 fn unknown_keys(&self) -> &UnknownKeys {
3243 &self.unknown
3244 }
3245}
3246
3247impl RawBlock for RawResourceCapabilities {
3248 const KNOWN_KEYS: &'static [&'static str] = &[
3249 "max_processes",
3250 "max_open_files",
3251 "max_file_size_bytes",
3252 "cpu_seconds",
3253 "memory_bytes",
3254 "cgroup_memory_bytes",
3255 "cgroup_pids_max",
3256 "cgroup_cpu_percent",
3257 "cgroup_io_bytes_per_sec",
3258 "workdir_max_bytes",
3259 ];
3260 fn unknown_keys(&self) -> &UnknownKeys {
3261 &self.unknown
3262 }
3263}
3264
3265impl RawBlock for RawNetworkCapabilities {
3266 const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
3267 fn unknown_keys(&self) -> &UnknownKeys {
3268 &self.unknown
3269 }
3270}
3271
3272impl RawBlock for RawPeerFetchCapabilities {
3273 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3274 fn unknown_keys(&self) -> &UnknownKeys {
3275 &self.unknown
3276 }
3277}
3278
3279impl RawBlock for RawFilesystemCapabilities {
3280 const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only", "records"];
3281 fn unknown_keys(&self) -> &UnknownKeys {
3282 &self.unknown
3283 }
3284}
3285
3286impl RawBlock for RawShellCapabilities {
3287 const KNOWN_KEYS: &'static [&'static str] = &[
3288 "allow",
3289 "strip_env",
3290 "baseline_env",
3291 "interpreter_runtime",
3292 "staged_runtime",
3293 ];
3294 fn unknown_keys(&self) -> &UnknownKeys {
3295 &self.unknown
3296 }
3297
3298 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3299 let interpreter_runtime = child_path(path, "interpreter_runtime");
3300 for (index, grant) in self.interpreter_runtime.iter().enumerate() {
3301 collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
3302 }
3303 let staged_runtime = child_path(path, "staged_runtime");
3304 for (index, grant) in self.staged_runtime.iter().enumerate() {
3305 collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
3306 }
3307 }
3308}
3309
3310impl RawBlock for RawInterpreterRuntimeGrant {
3311 const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
3312 fn unknown_keys(&self) -> &UnknownKeys {
3313 &self.unknown
3314 }
3315
3316 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3317 let dirs = child_path(path, "dirs");
3318 for (index, dir) in self.dirs.iter().enumerate() {
3319 collect_block(dir, &format!("{dirs}[{index}]"), out);
3320 }
3321 }
3322}
3323
3324impl RawBlock for RawInterpreterRuntimeDir {
3325 const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
3326 fn unknown_keys(&self) -> &UnknownKeys {
3327 &self.unknown
3328 }
3329}
3330
3331impl RawBlock for RawStagedRuntimeGrant {
3332 const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
3333 fn unknown_keys(&self) -> &UnknownKeys {
3334 &self.unknown
3335 }
3336}
3337
3338impl RawBlock for RawInferenceConfig {
3339 const KNOWN_KEYS: &'static [&'static str] = &[
3340 "transport",
3341 "endpoint",
3342 "model",
3343 "api_key",
3344 "command",
3345 "system_prompt",
3346 "system_prompt_file",
3347 "system_prompt_artifact",
3348 "driver",
3349 "compaction",
3350 "max_turns",
3351 "max_task_reopens",
3352 "max_tokens",
3353 "max_session_tokens",
3354 "tool_refresh",
3355 "alternates",
3356 "retry",
3357 ];
3358 fn unknown_keys(&self) -> &UnknownKeys {
3359 &self.unknown
3360 }
3361
3362 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3363 if let Some(driver) = &self.driver {
3364 collect_block(driver, &child_path(path, "driver"), out);
3365 }
3366 if let Some(compaction) = &self.compaction {
3367 collect_block(compaction, &child_path(path, "compaction"), out);
3368 }
3369 if let Some(retry) = &self.retry {
3370 collect_block(retry, &child_path(path, "retry"), out);
3371 }
3372 if let Some(alternates) = &self.alternates {
3373 let alternates_path = child_path(path, "alternates");
3374 for (index, alternate) in alternates.iter().enumerate() {
3375 collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
3376 }
3377 }
3378 }
3379}
3380
3381impl RawBlock for RawInferenceRetry {
3382 const KNOWN_KEYS: &'static [&'static str] =
3383 &["max_retries", "initial_delay_ms", "max_delay_ms"];
3384 fn unknown_keys(&self) -> &UnknownKeys {
3385 &self.unknown
3386 }
3387}
3388
3389impl RawBlock for RawInferenceAlternate {
3390 const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
3391 fn unknown_keys(&self) -> &UnknownKeys {
3392 &self.unknown
3393 }
3394
3395 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3396 if let Some(driver) = &self.driver {
3397 collect_block(driver, &child_path(path, "driver"), out);
3398 }
3399 }
3400}
3401
3402impl RawBlock for RawCompactionConfig {
3403 const KNOWN_KEYS: &'static [&'static str] = &[
3404 "threshold",
3405 "model",
3406 "system_prompt",
3407 "system_prompt_file",
3408 "dump_summaries",
3409 "max_tokens",
3410 "driver_config",
3411 ];
3412 fn unknown_keys(&self) -> &UnknownKeys {
3413 &self.unknown
3414 }
3415}
3416
3417impl RawBlock for RawInferenceDriver {
3418 const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
3419 fn unknown_keys(&self) -> &UnknownKeys {
3420 &self.unknown
3421 }
3422}
3423
3424fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
3431 let mut out = Vec::new();
3432 collect_block(raw, "", &mut out);
3433 out
3434}
3435
3436fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
3442 for key in block.unknown_keys().keys() {
3443 out.push(UnknownManifestKey {
3444 key: key.clone(),
3445 block_path: path.to_string(),
3446 nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
3447 });
3448 }
3449 block.walk_children(path, out);
3450}
3451
3452fn child_path(parent: &str, field: &str) -> String {
3456 if parent.is_empty() {
3457 return field.to_string();
3458 }
3459 format!("{parent}.{field}")
3460}
3461
3462pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
3469 fs::read_to_string(path).map_err(|source| {
3470 if source.kind() == std::io::ErrorKind::NotFound {
3471 return RuntimeManifestError::NotFound(path.display().to_string());
3472 }
3473
3474 RuntimeManifestError::Io {
3475 path: path.display().to_string(),
3476 source,
3477 }
3478 })
3479}
3480
3481#[must_use = "validated runtime manifest is required before run"]
3482pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
3483 let content = read_runtime_manifest_text(path)?;
3484
3485 RuntimeManifest::from_yaml_str(&content)
3486}
3487
3488#[derive(Debug, Clone, PartialEq, Eq)]
3490pub struct ReferencedEnvVariable {
3491 pub field: String,
3493 pub variable: String,
3495}
3496
3497impl ReferencedEnvVariable {
3498 pub fn is_gateway_credential(&self) -> bool {
3501 self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
3502 }
3503}
3504
3505#[derive(Deserialize)]
3513struct ReferencedManifestFields {
3514 #[serde(default)]
3515 artifacts: Vec<ReferencedArtifactFields>,
3516}
3517
3518#[derive(Deserialize)]
3519struct ReferencedArtifactFields {
3520 #[serde(default)]
3521 name: Option<String>,
3522 #[serde(default)]
3523 gateway: Option<ReferencedGatewayFields>,
3524}
3525
3526#[derive(Deserialize)]
3527struct ReferencedGatewayFields {
3528 #[serde(default)]
3529 api_key: Option<String>,
3530}
3531
3532pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
3540 let Ok(raw) = serde_norway::from_str::<ReferencedManifestFields>(manifest_yaml) else {
3541 return Vec::new();
3542 };
3543
3544 let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
3545 for artifact in raw.artifacts {
3546 let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
3547 continue;
3548 };
3549 if let Some(variable) = parse_env_reference(api_key.trim()) {
3550 let name = artifact.name.unwrap_or_default();
3551 referenced.push(ReferencedEnvVariable {
3552 field: format!("artifacts.{}.gateway.api_key", name.trim()),
3553 variable: variable.to_string(),
3554 });
3555 }
3556 }
3557
3558 referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
3559 referenced.dedup();
3560 referenced
3561}
3562
3563#[derive(Clone, Copy, PartialEq, Eq)]
3569enum SecretResolution {
3570 Resolve,
3572 Skip,
3575}
3576
3577impl RuntimeManifest {
3578 pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
3579 Self::parse(input, SecretResolution::Resolve)
3580 }
3581
3582 pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
3592 Self::parse(input, SecretResolution::Skip)
3593 }
3594
3595 fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
3596 let raw: RawRuntimeManifest = serde_norway::from_str(input).map_err(|err| {
3597 if let Some(location) = err.location() {
3598 RuntimeManifestError::YamlSyntax(format!(
3599 "{}: YAML syntax error at line {}, column {}: {}",
3600 MANIFEST_FILENAME,
3601 location.line(),
3602 location.column(),
3603 err
3604 ))
3605 } else {
3606 RuntimeManifestError::YamlSyntax(format!(
3607 "{MANIFEST_FILENAME}: YAML syntax error: {err}"
3608 ))
3609 }
3610 })?;
3611
3612 if raw.unknown.contains_key(REGISTRY_BLOCK_KEY) {
3614 return Err(RuntimeManifestError::RegistryBlock);
3615 }
3616
3617 let unknown_keys = collect_unknown_keys(&raw);
3620
3621 if raw.mur_version.is_some() {
3622 return Err(RuntimeManifestError::TopLevelMurVersion);
3623 }
3624
3625 let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
3626 RuntimeManifestError::MissingField {
3627 field: "name".to_string(),
3628 }
3629 })?;
3630
3631 let version = raw
3632 .version
3633 .filter(|s| !s.trim().is_empty())
3634 .ok_or_else(|| RuntimeManifestError::MissingField {
3635 field: "version".to_string(),
3636 })?;
3637
3638 let gateway_references = raw_gateway_references(&raw.artifacts);
3641
3642 let artifacts = raw
3643 .artifacts
3644 .into_iter()
3645 .enumerate()
3646 .map(|(index, artifact)| {
3647 let name = artifact
3648 .name
3649 .filter(|s| !s.trim().is_empty())
3650 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3651 index,
3652 message: "missing required field 'name'".to_string(),
3653 })?;
3654
3655 let source = artifact.source.filter(|s| !s.trim().is_empty());
3656 check_entry_name(index, &name, source.is_some())?;
3657
3658 let runtime = match artifact.runtime.as_deref() {
3659 None | Some("tool") => ArtifactRuntime::Tool,
3660 Some("wasm") | Some("native") => {
3661 return Err(RuntimeManifestError::InvalidArtifact {
3662 index,
3663 message: format!(
3664 "use 'runtime: tool'; implementation is declared in the \
3665 artifact's own manifest (got '{}')",
3666 artifact.runtime.as_deref().unwrap_or("")
3667 ),
3668 });
3669 }
3670 Some("driver") => ArtifactRuntime::Driver,
3671 Some("hook") => ArtifactRuntime::Hook,
3672 Some("skill") => ArtifactRuntime::Skill,
3673 Some(other) => {
3674 return Err(RuntimeManifestError::InvalidArtifact {
3675 index,
3676 message: format!(
3677 "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
3678 ),
3679 });
3680 }
3681 };
3682
3683 let local_source = artifact
3687 .local_source
3688 .unwrap_or(runtime == ArtifactRuntime::Skill);
3689 let prompt_payload = artifact
3690 .prompt_payload
3691 .unwrap_or(runtime == ArtifactRuntime::Skill);
3692
3693 if source.is_some() && !local_source {
3695 return Err(RuntimeManifestError::InvalidArtifact {
3696 index,
3697 message: format!(
3698 "artifact '{name}' declares 'source:' but does not declare \
3699 'local_source: true' (runtime: {})",
3700 runtime.as_str()
3701 ),
3702 });
3703 }
3704
3705 let version = if source.is_some() {
3708 if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
3709 crate::warn_line(format_args!(
3710 "warning: artifact '{name}' declares both 'source:' and \
3711 'version: {explicit}'; version is ignored for local-source skills \
3712 (using 'local')"
3713 ));
3714 }
3715 "local".to_string()
3716 } else {
3717 artifact
3718 .version
3719 .filter(|s| !s.trim().is_empty())
3720 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3721 index,
3722 message: "missing required field 'version'".to_string(),
3723 })?
3724 };
3725
3726 let capabilities = match artifact.capabilities {
3732 None => None,
3733 Some(raw_caps) => {
3734 if raw_caps.install.is_some() {
3737 return Err(RuntimeManifestError::InvalidArtifact {
3738 index,
3739 message: format!(
3740 "artifact '{name}' declares 'capabilities.install:'; the \
3741 grant is capsule-wide and is only recognized in the \
3742 top-level capabilities block"
3743 ),
3744 });
3745 }
3746 if runtime == ArtifactRuntime::Skill {
3747 return Err(RuntimeManifestError::InvalidArtifact {
3748 index,
3749 message: format!(
3750 "artifact '{name}' declares per-artifact 'capabilities:' but \
3751 has 'runtime: {}'; per-artifact capabilities are only \
3752 recognized on 'runtime: hook', 'runtime: tool', and \
3753 'runtime: driver' entries — use the capsule-wide top-level \
3754 'capabilities:' block instead",
3755 runtime.as_str()
3756 ),
3757 });
3758 }
3759 if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
3765 return Err(RuntimeManifestError::InvalidArtifact {
3766 index,
3767 message: format!(
3768 "artifact '{name}' declares 'capabilities.task_io:' but has \
3769 'runtime: {}'; the key grants the murmur:task-io/read host \
3770 import and is only recognized on 'runtime: hook' entries",
3771 runtime.as_str()
3772 ),
3773 });
3774 }
3775 if let Some(filesystem) = raw_caps
3779 .filesystem
3780 .as_ref()
3781 .filter(|filesystem| filesystem.records)
3782 {
3783 if runtime != ArtifactRuntime::Hook {
3784 return Err(RuntimeManifestError::InvalidArtifact {
3785 index,
3786 message: format!(
3787 "artifact '{name}' declares \
3788 'capabilities.filesystem.records: true' but has \
3789 'runtime: {}'; the key gives a hook the records \
3790 directory's hooks/ and is only recognized on \
3791 'runtime: hook' entries",
3792 runtime.as_str()
3793 ),
3794 });
3795 }
3796 if filesystem
3797 .scope
3798 .as_deref()
3799 .is_some_and(|scope| !scope.trim().is_empty())
3800 {
3801 return Err(RuntimeManifestError::InvalidArtifact {
3802 index,
3803 message: format!(
3804 "artifact '{name}' declares both \
3805 'capabilities.filesystem.records: true' and \
3806 'capabilities.filesystem.scope'; a hook's \".\" is \
3807 either the records directory's hooks/ or a workdir \
3808 scope — declare one"
3809 ),
3810 });
3811 }
3812 }
3813 if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
3817 return Err(RuntimeManifestError::InvalidArtifact {
3818 index,
3819 message: format!(
3820 "artifact '{name}' declares 'capabilities.conversation:' but \
3821 has 'runtime: {}'; the key grants the \
3822 murmur:conversation/read host import and is only recognized \
3823 on 'runtime: hook' entries",
3824 runtime.as_str()
3825 ),
3826 });
3827 }
3828 parse_capabilities(Some(raw_caps))?
3829 }
3830 };
3831
3832 let on_overflow = match artifact.on_overflow.as_deref() {
3838 None => HookOverflowPolicy::default(),
3839 Some(raw) => {
3840 if runtime != ArtifactRuntime::Hook {
3841 return Err(RuntimeManifestError::InvalidArtifact {
3842 index,
3843 message: format!(
3844 "artifact '{name}' declares 'on_overflow:' but has \
3845 'runtime: {}'; the key governs an async hook's job queue \
3846 and is only recognized on 'runtime: hook' entries",
3847 runtime.as_str()
3848 ),
3849 });
3850 }
3851 match raw {
3852 "drop" => HookOverflowPolicy::Drop,
3853 "block" => HookOverflowPolicy::Block,
3854 other => {
3855 return Err(RuntimeManifestError::InvalidArtifact {
3856 index,
3857 message: format!(
3858 "artifact '{name}' declares unknown on_overflow \
3859 '{other}'; expected: drop, block"
3860 ),
3861 });
3862 }
3863 }
3864 }
3865 };
3866
3867 if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
3874 return Err(RuntimeManifestError::InvalidArtifact {
3875 index,
3876 message: format!(
3877 "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
3878 is recognized only on 'runtime: hook', 'runtime: tool', and \
3879 'runtime: driver' entries",
3880 runtime.as_str()
3881 ),
3882 });
3883 }
3884
3885 let gateway = match artifact.gateway {
3886 None => None,
3887 Some(raw_gateway) => {
3888 if runtime == ArtifactRuntime::Skill {
3889 return Err(RuntimeManifestError::InvalidArtifact {
3890 index,
3891 message: format!(
3892 "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
3893 the key is recognized only on 'runtime: hook', \
3894 'runtime: tool', and 'runtime: driver' entries",
3895 runtime.as_str()
3896 ),
3897 });
3898 }
3899 Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
3900 }
3901 };
3902
3903 Ok(RuntimeArtifact {
3904 name,
3905 version,
3906 runtime,
3907 source,
3908 local_source,
3909 prompt_payload,
3910 capabilities,
3911 on_overflow,
3912 config: artifact.config,
3913 gateway,
3914 })
3915 })
3916 .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
3917
3918 if raw
3922 .capabilities
3923 .as_ref()
3924 .is_some_and(|caps| caps.task_io.is_some())
3925 {
3926 return Err(RuntimeManifestError::InvalidCapabilities {
3927 field: "capabilities.task_io".to_string(),
3928 message: "is recognized only on 'runtime: hook' artifact entries, not in the \
3929 capsule-wide capabilities block — the grant is per-hook"
3930 .to_string(),
3931 });
3932 }
3933 if raw
3936 .capabilities
3937 .as_ref()
3938 .and_then(|caps| caps.filesystem.as_ref())
3939 .is_some_and(|filesystem| filesystem.records)
3940 {
3941 return Err(RuntimeManifestError::InvalidCapabilities {
3942 field: "capabilities.filesystem.records".to_string(),
3943 message: "is recognized only on 'runtime: hook' artifact entries, not in the \
3944 capsule-wide capabilities block — the grant is per-hook"
3945 .to_string(),
3946 });
3947 }
3948 if raw.config.is_some() {
3953 return Err(RuntimeManifestError::InvalidCapabilities {
3954 field: "config".to_string(),
3955 message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
3956 'runtime: driver' entry that reads it — there is no capsule-wide \
3957 config block"
3958 .to_string(),
3959 });
3960 }
3961 let capabilities = parse_capabilities(raw.capabilities)?;
3962 let inference = parse_inference(raw.inference)?;
3963 refuse_install_under_process_transport(capabilities.as_ref(), inference.as_ref())?;
3964 validate_driver_gateways(&artifacts, inference.as_ref())?;
3965 validate_driver_choices(&artifacts, inference.as_ref())?;
3966 validate_process_driver_entry(&artifacts, inference.as_ref())?;
3967
3968 if let Some(ref sp_art) = inference
3971 .as_ref()
3972 .and_then(|i| i.system_prompt_artifact.clone())
3973 {
3974 let matching = artifacts.iter().find(|a| &a.name == sp_art);
3975 match matching {
3976 None => {
3977 return Err(RuntimeManifestError::InvalidInferenceConfig {
3978 field: "inference.system_prompt_artifact".to_string(),
3979 message: not_declared_message(&artifacts, sp_art),
3980 });
3981 }
3982 Some(a) if !a.prompt_payload => {
3983 return Err(RuntimeManifestError::InvalidInferenceConfig {
3984 field: "inference.system_prompt_artifact".to_string(),
3985 message: format!(
3986 "artifact '{sp_art}' (runtime: {}) does not declare \
3987 'prompt_payload: true', which system_prompt_artifact requires",
3988 a.runtime.as_str()
3989 ),
3990 });
3991 }
3992 Some(_) => {} }
3994 }
3995 let context = parse_context(raw.context)?;
3996 let observability = parse_observability(raw.observability);
3997 let trace = raw
3998 .trace
3999 .map(|raw_trace| {
4000 let capture = resolve_trace_capture(raw_trace.capture.as_deref())?;
4001 let retain = parse_trace_retain(raw_trace.retain)?;
4002 Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
4003 })
4004 .transpose()?;
4005 let network = raw
4006 .network
4007 .map(|n| {
4008 Ok::<_, RuntimeManifestError>(NetworkConfig {
4009 internal_port: n.internal_port,
4010 authentication: parse_network_authentication(n.authentication)?,
4011 })
4012 })
4013 .transpose()?;
4014 refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
4015 let lifecycle = raw
4016 .lifecycle
4017 .map(|raw_lc| {
4018 let defaults = LifecycleConfig::default();
4019 let lifecycle = LifecycleConfig {
4020 task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
4021 after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
4022 queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
4023 input_timeout_secs: raw_lc.input_timeout_secs,
4024 conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
4025 max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
4026 shell_grace_secs: raw_lc.shell_grace_secs.unwrap_or(defaults.shell_grace_secs),
4027 delegation_deadline_secs: raw_lc
4028 .delegation_deadline_secs
4029 .unwrap_or(defaults.delegation_deadline_secs),
4030 deadline_notice_secs: raw_lc.deadline_notice_secs,
4031 keep_working: raw_lc.keep_working.map(parse_keep_working).transpose()?,
4032 done_check: raw_lc
4033 .done_check
4034 .map(|raw| {
4035 let shell_allow = capabilities
4036 .as_ref()
4037 .and_then(|capabilities| capabilities.shell.as_ref())
4038 .map_or(&[][..], |shell| shell.allow.as_slice());
4039 parse_done_check(raw, shell_allow)
4040 })
4041 .transpose()?,
4042 };
4043 if lifecycle.queue_has_room() {
4044 Ok(lifecycle)
4045 } else {
4046 Err(RuntimeManifestError::QueueWithoutRoom)
4047 }
4048 })
4049 .transpose()?;
4050
4051 let exports = parse_exports(raw.exports)?;
4052 let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
4053 validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
4054
4055 Ok(Self {
4056 name,
4057 version,
4058 artifacts,
4059 capabilities,
4060 inference,
4061 context,
4062 observability,
4063 trace,
4064 network,
4065 lifecycle,
4066 exports,
4067 control,
4068 deploy: raw.deploy.map(|deploy| DeployConfig {
4069 mur_version: deploy.mur_version.filter(|s| !s.trim().is_empty()),
4070 }),
4071 unknown_keys,
4072 })
4073 }
4074}
4075
4076fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
4078 artifacts
4079 .iter()
4080 .filter_map(|artifact| {
4081 let name = artifact.name.as_deref()?.trim();
4082 let written = artifact
4083 .gateway
4084 .as_ref()?
4085 .as_ref()?
4086 .api_key
4087 .as_ref()?
4088 .as_ref()?;
4089 let variable = parse_env_reference(written.trim())?;
4090 Some((name.to_string(), variable.to_string()))
4091 })
4092 .collect()
4093}
4094
4095fn parse_control(
4102 raw: Option<RawControl>,
4103 gateway_references: &[(String, String)],
4104 inference: Option<&InferenceConfig>,
4105) -> Result<Option<ControlConfig>, RuntimeManifestError> {
4106 let Some(raw) = raw else {
4107 return Ok(None);
4108 };
4109 let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
4110 field: field.to_string(),
4111 message,
4112 };
4113 let settings_written = control_string_list("control.settings", raw.settings)?;
4114 let secrets_written = control_string_list("control.secrets", raw.secrets)?;
4115 let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
4116 if settings_written.is_empty()
4117 && secrets_written.is_empty()
4118 && agent_settings_written.is_empty()
4119 {
4120 return Ok(None);
4121 }
4122 let Some(inference) = inference else {
4123 return Err(refuse(
4124 "control",
4125 "is declared on a capsule with no inference: block; the control surface is served on \
4126 an agent capsule's A2A listener, and a script capsule opens none"
4127 .to_string(),
4128 ));
4129 };
4130
4131 let mut settings = Vec::new();
4132 for entry in settings_written {
4133 let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
4134 let controllable: Vec<&str> = ControllableSetting::ALL
4135 .iter()
4136 .map(|setting| setting.wire_name())
4137 .collect();
4138 return Err(refuse(
4139 "control.settings",
4140 format!(
4141 "'{entry}' is not a controllable setting; the controllable settings are: {}",
4142 controllable.join(", ")
4143 ),
4144 ));
4145 };
4146 if settings.contains(&setting) {
4147 return Err(refuse(
4148 "control.settings",
4149 format!("'{entry}' is listed more than once"),
4150 ));
4151 }
4152 match setting {
4153 ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
4154 return Err(refuse(
4155 "control.settings",
4156 format!(
4157 "'{entry}' is controllable only under inference.transport: http; a \
4158 transport: {} harness sets its own output cap",
4159 inference.transport
4160 ),
4161 ));
4162 }
4163 ControllableSetting::InferenceMaxTokens => {}
4164 ControllableSetting::InferenceDriver => {
4165 check_driver_setting(inference).map_err(|message| {
4166 refuse("control.settings", format!("'{entry}' {message}"))
4167 })?;
4168 }
4169 }
4170 settings.push(setting);
4171 }
4172
4173 let mut agent_settings = Vec::new();
4174 for entry in agent_settings_written {
4175 let Some(setting) = ControllableSetting::from_wire_name(&entry)
4176 .filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
4177 else {
4178 let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
4179 .iter()
4180 .map(|setting| setting.wire_name())
4181 .collect();
4182 return Err(refuse(
4183 "control.agent_settings",
4184 format!(
4185 "'{entry}' is not a setting the agent may change; control.agent_settings \
4186 accepts only: {}",
4187 settable.join(", ")
4188 ),
4189 ));
4190 };
4191 if agent_settings.contains(&setting) {
4192 return Err(refuse(
4193 "control.agent_settings",
4194 format!("'{entry}' is listed more than once"),
4195 ));
4196 }
4197 check_driver_setting(inference)
4198 .map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
4199 agent_settings.push(setting);
4200 }
4201
4202 let inference_driver = (inference.transport == "http")
4203 .then(|| {
4204 inference
4205 .driver
4206 .as_ref()
4207 .map(|driver| driver.artifact.as_str())
4208 })
4209 .flatten();
4210 let mut secrets: Vec<String> = Vec::new();
4211 for entry in secrets_written {
4212 if !is_valid_env_variable(&entry) {
4213 return Err(refuse(
4214 "control.secrets",
4215 format!(
4216 "'{entry}' is not a credential name; a name is uppercase letters, digits and \
4217 underscores, and does not start with a digit"
4218 ),
4219 ));
4220 }
4221 if secrets.contains(&entry) {
4222 return Err(refuse(
4223 "control.secrets",
4224 format!("'{entry}' is listed more than once"),
4225 ));
4226 }
4227 let backing: Vec<&str> = gateway_references
4228 .iter()
4229 .filter(|(_, variable)| *variable == entry)
4230 .map(|(artifact, _)| artifact.as_str())
4231 .collect();
4232 if backing.is_empty() {
4233 return Err(refuse(
4234 "control.secrets",
4235 format!(
4236 "'{entry}' is not referenced by any artifact's gateway.api_key as \
4237 ${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
4238 ),
4239 ));
4240 }
4241 if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
4242 return Err(refuse(
4243 "control.secrets",
4244 format!(
4245 "'{entry}' keys the configured inference driver's gateway \
4246 (artifacts.{driver}.gateway.api_key); the inference credential is resolved \
4247 at launch and cannot be supplied by a controller"
4248 ),
4249 ));
4250 }
4251 secrets.push(entry);
4252 }
4253
4254 Ok(Some(ControlConfig {
4255 settings,
4256 secrets,
4257 agent_settings,
4258 }))
4259}
4260
4261fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
4264 if inference.transport != "http" {
4265 return Err(format!(
4266 "is controllable only under inference.transport: http; a transport: {} harness owns \
4267 the model it runs",
4268 inference.transport
4269 ));
4270 }
4271 if inference.alternates.is_empty() {
4272 return Err(
4273 "selects among inference.alternates, and this capsule declares none; declare an \
4274 alternate to switch to"
4275 .to_string(),
4276 );
4277 }
4278 Ok(())
4279}
4280
4281fn control_string_list(
4283 field: &str,
4284 value: Option<serde_norway::Value>,
4285) -> Result<Vec<String>, RuntimeManifestError> {
4286 let refuse = || RuntimeManifestError::InvalidControl {
4287 field: field.to_string(),
4288 message: "must be a list of names".to_string(),
4289 };
4290 let items = match value {
4291 None | Some(serde_norway::Value::Null) => return Ok(Vec::new()),
4292 Some(serde_norway::Value::Sequence(items)) => items,
4293 Some(_) => return Err(refuse()),
4294 };
4295 items
4296 .into_iter()
4297 .map(|item| match item {
4298 serde_norway::Value::String(name) => Ok(name.trim().to_string()),
4299 _ => Err(refuse()),
4300 })
4301 .collect()
4302}
4303
4304fn parse_network_authentication(
4306 raw: Option<RawNetworkAuthentication>,
4307) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
4308 let Some(raw) = raw else {
4309 return Ok(None);
4310 };
4311 let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
4312 field: field.to_string(),
4313 message,
4314 };
4315 let schemes: Vec<&str> = AuthenticationScheme::ALL
4316 .iter()
4317 .map(|scheme| scheme.wire_name())
4318 .collect();
4319 let scheme = match raw.scheme.as_deref().map(str::trim) {
4320 None | Some("") => {
4321 return Err(refuse(
4322 "network.authentication.scheme",
4323 format!("is required; the schemes are: {}", schemes.join(", ")),
4324 ))
4325 }
4326 Some(written) => AuthenticationScheme::ALL
4327 .iter()
4328 .copied()
4329 .find(|scheme| scheme.wire_name() == written)
4330 .ok_or_else(|| {
4331 refuse(
4332 "network.authentication.scheme",
4333 format!(
4334 "'{written}' is not a door authentication scheme; the schemes are: {}",
4335 schemes.join(", ")
4336 ),
4337 )
4338 })?,
4339 };
4340
4341 let mut credentials = Vec::new();
4342 for (name, credential) in raw.credentials.unwrap_or_default() {
4343 if name == OPERATOR_CREDENTIAL {
4344 return Err(refuse(
4345 "network.authentication.credentials",
4346 format!(
4347 "'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
4348 token, which holds every scope"
4349 ),
4350 ));
4351 }
4352 if !is_valid_credential_name(&name) {
4353 return Err(refuse(
4354 "network.authentication.credentials",
4355 format!(
4356 "'{name}' is not a credential name; a name is a lowercase letter followed by \
4357 at most {} lowercase letters, digits, '_' or '-'",
4358 MAX_CREDENTIAL_NAME_LEN - 1
4359 ),
4360 ));
4361 }
4362 let field = format!("network.authentication.credentials.{name}.scopes");
4363 let scopes = door_scope_list(&field, credential.scopes)?;
4364 credentials.push(DoorCredential { name, scopes });
4365 }
4366
4367 Ok(Some(NetworkAuthentication {
4368 scheme,
4369 credentials,
4370 }))
4371}
4372
4373fn is_valid_credential_name(name: &str) -> bool {
4375 let mut chars = name.chars();
4376 chars.next().is_some_and(|first| first.is_ascii_lowercase())
4377 && name.len() <= MAX_CREDENTIAL_NAME_LEN
4378 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
4379}
4380
4381fn door_scope_list(
4383 field: &str,
4384 value: Option<serde_norway::Value>,
4385) -> Result<Vec<String>, RuntimeManifestError> {
4386 let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
4387 field: field.to_string(),
4388 message,
4389 };
4390 let items = match value {
4391 None | Some(serde_norway::Value::Null) => {
4392 return Err(refuse(
4393 "is required: a credential reaches only the scopes it lists".to_string(),
4394 ))
4395 }
4396 Some(serde_norway::Value::Sequence(items)) => items,
4397 Some(_) => return Err(refuse("must be a list of scopes".to_string())),
4398 };
4399 if items.is_empty() {
4400 return Err(refuse(
4401 "lists no scope; a credential must reach at least one".to_string(),
4402 ));
4403 }
4404 let mut scopes: Vec<String> = Vec::new();
4405 for item in items {
4406 let serde_norway::Value::String(scope) = item else {
4407 return Err(refuse("must be a list of scopes".to_string()));
4408 };
4409 let scope = scope.trim().to_string();
4410 if let Some(&(_, replacement)) = RETIRED_DOOR_SCOPES
4411 .iter()
4412 .find(|(retired, _)| *retired == scope)
4413 {
4414 return Err(RuntimeManifestError::RetiredDoorScope {
4415 field: field.to_string(),
4416 scope,
4417 replacement,
4418 });
4419 }
4420 if !DOOR_SCOPES.contains(&scope.as_str()) {
4421 return Err(refuse(format!(
4422 "'{scope}' is not a door scope; the scopes are: {}",
4423 DOOR_SCOPES.join(", ")
4424 )));
4425 }
4426 if scopes.contains(&scope) {
4427 return Err(refuse(format!("'{scope}' is listed more than once")));
4428 }
4429 scopes.push(scope);
4430 }
4431 Ok(scopes)
4432}
4433
4434fn refuse_authenticated_delegating_parent(
4439 network: Option<&NetworkConfig>,
4440 capabilities: Option<&Capabilities>,
4441) -> Result<(), RuntimeManifestError> {
4442 let authenticated = network.is_some_and(|network| network.authentication.is_some());
4443 let delegates = capabilities
4444 .and_then(|capabilities| capabilities.spawn.as_ref())
4445 .is_some_and(|spawn| !spawn.allow.is_empty());
4446 if authenticated && delegates {
4447 return Err(RuntimeManifestError::InvalidNetworkConfig {
4448 field: "network.authentication".to_string(),
4449 message: "cannot be declared together with capabilities.spawn.allow: a delegated \
4450 child posts its outcome to this door, and a delegated child cannot yet be \
4451 issued a token for this door"
4452 .to_string(),
4453 });
4454 }
4455 Ok(())
4456}
4457
4458fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
4465 let Some(raw_exports) = raw else {
4466 return Ok(None);
4467 };
4468 let files = raw_exports.files.map(parse_file_export).transpose()?;
4469 let peer_files = raw_exports
4470 .peer_files
4471 .map(parse_peer_files_export)
4472 .transpose()?;
4473 let peer_tasks = raw_exports
4474 .peer_tasks
4475 .map(|raw| {
4476 raw.accept
4477 .map(|accept| PeerTasksExport { accept })
4478 .ok_or_else(|| RuntimeManifestError::InvalidExports {
4479 field: "exports.peer_tasks.accept".to_string(),
4480 message: "must be set explicitly to true or false — consent to peer tasks is \
4481 never inferred"
4482 .to_string(),
4483 })
4484 })
4485 .transpose()?;
4486 Ok(Some(Exports {
4487 files,
4488 peer_files,
4489 peer_tasks,
4490 }))
4491}
4492
4493fn parse_peer_files_export(
4501 raw: RawPeerFilesExport,
4502) -> Result<PeerFilesExport, RuntimeManifestError> {
4503 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4504 field: field.to_string(),
4505 message,
4506 };
4507
4508 let root = raw
4509 .root
4510 .map(|root| root.trim().to_string())
4511 .filter(|root| !root.is_empty())
4512 .ok_or_else(|| {
4513 invalid(
4514 "exports.peer_files.root",
4515 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4516 )
4517 })?;
4518 check_export_root_shape("exports.peer_files.root", &root)?;
4519
4520 let max_ttl_secs = match raw.max_ttl {
4521 None => None,
4522 Some(value) => {
4523 let text = scalar_text(&value).ok_or_else(|| {
4524 invalid(
4525 "exports.peer_files.max_ttl",
4526 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
4527 )
4528 })?;
4529 let parsed = parse_duration_secs(&text)
4530 .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
4531 if parsed == 0 {
4532 return Err(invalid(
4533 "exports.peer_files.max_ttl",
4534 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
4535 ));
4536 }
4537 Some(parsed)
4538 }
4539 };
4540
4541 let max_bytes = match raw.max_bytes {
4542 None => DEFAULT_PEER_FILES_MAX_BYTES,
4543 Some(value) => {
4544 let text = scalar_text(&value).ok_or_else(|| {
4545 invalid(
4546 "exports.peer_files.max_bytes",
4547 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4548 )
4549 })?;
4550 let parsed = parse_byte_size(&text)
4551 .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
4552 if parsed == 0 {
4553 return Err(invalid(
4554 "exports.peer_files.max_bytes",
4555 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4556 ));
4557 }
4558 parsed
4559 }
4560 };
4561
4562 Ok(PeerFilesExport {
4563 root,
4564 max_ttl_secs,
4565 max_bytes,
4566 })
4567}
4568
4569fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_norway::Value>, D::Error>
4577where
4578 D: serde::Deserializer<'de>,
4579{
4580 serde_norway::Value::deserialize(deserializer).map(Some)
4581}
4582
4583fn scalar_text(value: &serde_norway::Value) -> Option<String> {
4584 match value {
4585 serde_norway::Value::String(text) => Some(text.clone()),
4586 serde_norway::Value::Number(number) => Some(number.to_string()),
4587 _ => None,
4588 }
4589}
4590
4591fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
4595 for component in Path::new(root).components() {
4596 match component {
4597 std::path::Component::Normal(_) | std::path::Component::CurDir => {}
4598 std::path::Component::ParentDir => {
4599 return Err(RuntimeManifestError::InvalidExports {
4600 field: field.to_string(),
4601 message: format!(
4602 "'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}"
4603 ),
4604 });
4605 }
4606 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
4607 return Err(RuntimeManifestError::InvalidExports {
4608 field: field.to_string(),
4609 message: format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}"),
4610 });
4611 }
4612 }
4613 }
4614 Ok(())
4615}
4616
4617fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
4618 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4619 field: field.to_string(),
4620 message,
4621 };
4622
4623 let root = raw
4624 .root
4625 .map(|root| root.trim().to_string())
4626 .filter(|root| !root.is_empty())
4627 .ok_or_else(|| {
4628 invalid(
4629 "exports.files.root",
4630 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4631 )
4632 })?;
4633 check_export_root_shape("exports.files.root", &root)?;
4634
4635 let mode = match raw.mode.as_deref().map(str::trim) {
4636 None | Some("") => {
4637 return Err(invalid(
4638 "exports.files.mode",
4639 "is required and must be 'read-only'".to_string(),
4640 ));
4641 }
4642 Some("read-only") => ExportMode::ReadOnly,
4643 Some(other) => {
4644 return Err(invalid(
4645 "exports.files.mode",
4646 format!("'{other}' must be 'read-only'"),
4647 ));
4648 }
4649 };
4650
4651 let max_bytes = match raw.max_bytes {
4652 None => DEFAULT_EXPORT_MAX_BYTES,
4653 Some(value) => {
4654 let Some(text) = scalar_text(&value) else {
4655 return Err(invalid(
4656 "exports.files.max_bytes",
4657 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4658 ));
4659 };
4660 let parsed = parse_byte_size(&text)
4661 .map_err(|message| invalid("exports.files.max_bytes", message))?;
4662 if parsed == 0 {
4663 return Err(invalid(
4664 "exports.files.max_bytes",
4665 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4666 ));
4667 }
4668 parsed
4669 }
4670 };
4671
4672 Ok(FileExport {
4673 root,
4674 mode,
4675 max_bytes,
4676 })
4677}
4678
4679const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
4682
4683pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
4685
4686fn check_network_destination_shape(entry: &str) -> Result<(), String> {
4693 let trimmed = entry.trim();
4694 if trimmed.is_empty() {
4695 return Err(format!(
4696 "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4697 ));
4698 }
4699
4700 if trimmed.contains("://") {
4701 let url = Url::parse(trimmed).map_err(|error| {
4702 format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
4703 })?;
4704 let scheme = url.scheme().to_ascii_lowercase();
4705 if scheme != "http" && scheme != "https" {
4706 return Err(format!(
4707 "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
4708 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4709 ));
4710 }
4711 if url.host_str().is_none_or(str::is_empty) {
4712 return Err(format!(
4713 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4714 ));
4715 }
4716 if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
4717 return Err(format!(
4718 "'{entry}' must not include a path, query or fragment; \
4719 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4720 ));
4721 }
4722 return Ok(());
4723 }
4724
4725 if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
4726 return Err(format!(
4727 "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4728 ));
4729 }
4730 if let Some((host, port)) = trimmed.rsplit_once(':') {
4732 if !host.ends_with(']') {
4733 if host.is_empty() {
4734 return Err(format!(
4735 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4736 ));
4737 }
4738 if port.parse::<u16>().is_err() {
4739 return Err(format!(
4740 "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4741 ));
4742 }
4743 }
4744 }
4745 Ok(())
4746}
4747
4748fn parse_capabilities(
4749 raw: Option<RawCapabilities>,
4750) -> Result<Option<Capabilities>, RuntimeManifestError> {
4751 let Some(raw_caps) = raw else {
4752 return Ok(None);
4753 };
4754
4755 let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
4756 allow: raw_network.allow,
4757 unix_sockets: raw_network.unix_sockets,
4758 });
4759
4760 let peer_fetch = raw_caps
4761 .peer_fetch
4762 .map(|raw_peer_fetch| {
4763 if raw_peer_fetch.allow.is_empty() {
4764 return Err(RuntimeManifestError::InvalidCapabilities {
4765 field: "capabilities.peer_fetch.allow".to_string(),
4766 message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
4767 });
4768 }
4769 for entry in &raw_peer_fetch.allow {
4770 check_network_destination_shape(entry).map_err(|message| {
4771 RuntimeManifestError::InvalidCapabilities {
4772 field: "capabilities.peer_fetch.allow".to_string(),
4773 message,
4774 }
4775 })?;
4776 }
4777 Ok(PeerFetchCapabilities {
4778 allow: raw_peer_fetch.allow,
4779 })
4780 })
4781 .transpose()?;
4782
4783 let filesystem = raw_caps
4784 .filesystem
4785 .map(|raw_filesystem| FilesystemCapabilities {
4786 scope: raw_filesystem.scope.and_then(|scope| {
4787 let trimmed = scope.trim();
4788 (!trimmed.is_empty()).then(|| trimmed.to_string())
4789 }),
4790 workdir_exec: raw_filesystem.workdir_exec,
4795 read_only: raw_filesystem
4799 .read_only
4800 .into_iter()
4801 .filter_map(|entry| {
4802 let trimmed = entry.trim();
4803 (!trimmed.is_empty()).then(|| trimmed.to_string())
4804 })
4805 .collect(),
4806 records: raw_filesystem.records,
4808 });
4809
4810 let shell = raw_caps
4811 .shell
4812 .map(|raw_shell| {
4813 if raw_shell.allow.is_empty() {
4814 return Err(RuntimeManifestError::InvalidCapabilities {
4815 field: "capabilities.shell.allow".to_string(),
4816 message: "must contain at least one binary".to_string(),
4817 });
4818 }
4819 for (index, entry) in raw_shell.allow.iter().enumerate() {
4820 if let Some(reason) = shell_allow_entry_error(entry) {
4821 return Err(RuntimeManifestError::ShellAllowEntry {
4822 index,
4823 entry: entry.clone(),
4824 reason,
4825 });
4826 }
4827 }
4828
4829 let interpreter_runtime =
4830 parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
4831 let staged_runtime = parse_staged_runtime(
4834 &raw_shell.allow,
4835 &interpreter_runtime,
4836 raw_shell.staged_runtime,
4837 )?;
4838
4839 Ok(ShellCapabilities {
4840 allow: raw_shell.allow,
4841 strip_env: raw_shell.strip_env,
4842 baseline_env: raw_shell.baseline_env,
4843 interpreter_runtime,
4844 staged_runtime,
4845 })
4846 })
4847 .transpose()?;
4848
4849 let spawn = raw_caps
4850 .spawn
4851 .map(|raw_spawn| parse_spawn_allow(raw_spawn.allow))
4852 .transpose()?;
4853
4854 let env = raw_caps.env.map(|raw_env| EnvCapabilities {
4855 allow: raw_env.allow,
4856 });
4857
4858 let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
4859
4860 let resources = raw_caps
4861 .resources
4862 .map(parse_resource_capabilities)
4863 .transpose()?;
4864
4865 let state = raw_caps.state.map(parse_state_capabilities);
4866
4867 let task_io = raw_caps
4868 .task_io
4869 .map(|raw_task_io| {
4870 raw_task_io
4871 .read
4872 .map(|read| TaskIoCapabilities { read })
4873 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4874 field: "capabilities.task_io.read".to_string(),
4875 message: "must be set explicitly to true or false — a capability is never \
4876 inferred"
4877 .to_string(),
4878 })
4879 })
4880 .transpose()?;
4881
4882 let conversation = raw_caps
4883 .conversation
4884 .map(|raw_conversation| {
4885 raw_conversation
4886 .read
4887 .map(|read| ConversationCapabilities { read })
4888 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4889 field: "capabilities.conversation.read".to_string(),
4890 message: "must be set explicitly to true or false — a capability is never \
4891 inferred"
4892 .to_string(),
4893 })
4894 })
4895 .transpose()?;
4896
4897 let plan = raw_caps
4898 .plan
4899 .map(|raw_plan| {
4900 raw_plan
4901 .submit
4902 .map(|submit| PlanCapabilities { submit })
4903 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4904 field: "capabilities.plan.submit".to_string(),
4905 message: "must be set explicitly to true or false — a capability is never \
4906 inferred"
4907 .to_string(),
4908 })
4909 })
4910 .transpose()?;
4911
4912 let install = raw_caps.install.map(parse_install).transpose()?;
4913
4914 let containment = raw_caps
4915 .containment
4916 .as_deref()
4917 .map(str::trim)
4918 .filter(|value| !value.is_empty())
4919 .map(|value| {
4920 value.parse::<ContainmentClass>().map_err(|error| {
4921 RuntimeManifestError::InvalidCapabilities {
4922 field: "capabilities.containment".to_string(),
4923 message: error.to_string(),
4924 }
4925 })
4926 })
4927 .transpose()?;
4928
4929 Ok(Some(Capabilities {
4930 network,
4931 peer_fetch,
4932 filesystem,
4933 shell,
4934 spawn,
4935 env,
4936 limits,
4937 resources,
4938 state,
4939 task_io,
4940 conversation,
4941 plan,
4942 install,
4943 containment,
4944 }))
4945}
4946
4947fn parse_spawn_allow(allow: Vec<String>) -> Result<SpawnCapabilities, RuntimeManifestError> {
4950 let mut parsed: Vec<ArtifactName> = Vec::with_capacity(allow.len());
4951 for (index, entry) in allow.iter().enumerate() {
4952 let name = ArtifactName::parse(entry).map_err(|error| {
4953 RuntimeManifestError::InvalidArtifactName {
4954 field: format!("capabilities.spawn.allow[{index}]"),
4955 message: error.to_string(),
4956 hint: error.hint(),
4957 }
4958 })?;
4959 if let Some(earlier) = parsed
4960 .iter()
4961 .find(|earlier| earlier.name() == name.name() && **earlier != name)
4962 {
4963 return Err(RuntimeManifestError::InvalidArtifactName {
4964 field: format!("capabilities.spawn.allow[{index}]"),
4965 message: format!(
4966 "'{earlier}' and '{name}' share the name '{}', which is how delegate-task \
4967 offers a capsule to the model",
4968 name.name()
4969 ),
4970 hint: Some(format!(
4971 "keep one of '{earlier}' and '{name}' in capabilities.spawn.allow"
4972 )),
4973 });
4974 }
4975 parsed.push(name);
4976 }
4977 Ok(SpawnCapabilities { allow })
4978}
4979
4980fn check_entry_name(
4983 index: usize,
4984 name: &str,
4985 has_source: bool,
4986) -> Result<(), RuntimeManifestError> {
4987 let field = format!("artifacts[{index}].name");
4988 if !has_source {
4989 return ArtifactName::parse(name).map(|_| ()).map_err(|error| {
4990 RuntimeManifestError::InvalidArtifactName {
4991 field,
4992 message: error.to_string(),
4993 hint: error.hint(),
4994 }
4995 });
4996 }
4997 if let Some((_, bare)) = name.split_once('/') {
4998 return Err(RuntimeManifestError::InvalidArtifactName {
4999 field,
5000 message: format!(
5001 "'{name}' declares 'source:', so it is named by its bare name; a scope says \
5002 where a resolved artifact comes from, and a source: entry is never resolved"
5003 ),
5004 hint: Some(format!("drop the scope: name it '{bare}'")),
5005 });
5006 }
5007 match artifact_name_format_error(name) {
5008 Some(reason) => Err(RuntimeManifestError::InvalidArtifactName {
5009 field,
5010 message: format!("'{name}' is not a valid artifact name: it {reason}"),
5011 hint: None,
5012 }),
5013 None => Ok(()),
5014 }
5015}
5016
5017fn not_declared_message(artifacts: &[RuntimeArtifact], name: &str) -> String {
5021 let mut same_bare = artifacts
5022 .iter()
5023 .filter(|artifact| artifact.name != name && artifact.tool_name() == tool_name_of(name));
5024 match (same_bare.next(), same_bare.next()) {
5025 (Some(only), None) => format!(
5026 "artifact '{name}' is not declared in artifacts: (the declared '{}' has that name; \
5027 refer to it as '{}')",
5028 only.name, only.name
5029 ),
5030 _ => format!("artifact '{name}' is not declared in artifacts:"),
5031 }
5032}
5033
5034fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
5037 if raw.skill.is_empty() && raw.tool.is_empty() {
5038 return Err(RuntimeManifestError::InvalidCapabilities {
5039 field: "capabilities.install".to_string(),
5040 message: "must name at least one 'skill' or 'tool' entry — a capability is never \
5041 inferred; omit the block to grant no install at all"
5042 .to_string(),
5043 });
5044 }
5045 for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
5046 for (index, entry) in entries.iter().enumerate() {
5047 crate::install_pattern::validate(entry).map_err(|error| {
5048 RuntimeManifestError::InvalidArtifactName {
5049 field: format!("capabilities.install.{key}[{index}]"),
5050 message: format!(
5051 "entry {entry:?} is invalid ({}); expected {}",
5052 error.reason,
5053 crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
5054 ),
5055 hint: error.hint,
5056 }
5057 })?;
5058 }
5059 }
5060 let from = match raw.from {
5061 None => InstallFrom::Stores,
5062 Some(list) => parse_install_from(&list)?,
5063 };
5064 Ok(InstallCapabilities {
5065 skill: raw.skill,
5066 tool: raw.tool,
5067 from,
5068 })
5069}
5070
5071fn refuse_install_under_process_transport(
5074 capabilities: Option<&Capabilities>,
5075 inference: Option<&InferenceConfig>,
5076) -> Result<(), RuntimeManifestError> {
5077 let installs = capabilities.is_some_and(|capabilities| capabilities.install.is_some());
5078 let process = inference.is_some_and(|inference| inference.transport == "process");
5079 if installs && process {
5080 return Err(RuntimeManifestError::InvalidCapabilities {
5081 field: "capabilities.install".to_string(),
5082 message: "is not valid with transport: process; the harness owns its tool list, so a \
5083 pulled artifact could never reach it"
5084 .to_string(),
5085 });
5086 }
5087 Ok(())
5088}
5089
5090const INSTALL_FROM_ACCEPTED: &str = "[stores] or [stores, sources]";
5092
5093fn parse_install_from(list: &[String]) -> Result<InstallFrom, RuntimeManifestError> {
5096 let refuse = |message: String| RuntimeManifestError::InvalidCapabilities {
5097 field: "capabilities.install.from".to_string(),
5098 message: format!("{message}; expected {INSTALL_FROM_ACCEPTED}"),
5099 };
5100 if list.is_empty() {
5101 return Err(refuse("is empty".to_string()));
5102 }
5103 let (mut stores, mut sources) = (false, false);
5104 for entry in list {
5105 let seen = match entry.as_str() {
5106 "stores" => &mut stores,
5107 "sources" => &mut sources,
5108 other => return Err(refuse(format!("names {other:?}, which is not a place"))),
5109 };
5110 if *seen {
5111 return Err(refuse(format!("names {entry:?} twice")));
5112 }
5113 *seen = true;
5114 }
5115 if !stores {
5116 return Err(refuse(
5117 "does not name stores, which every pull is answered from first".to_string(),
5118 ));
5119 }
5120 Ok(if sources {
5121 InstallFrom::StoresAndSources
5122 } else {
5123 InstallFrom::Stores
5124 })
5125}
5126
5127fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
5137 StateCapabilities {
5138 store: raw.store.map(|store| store.trim().to_string()),
5139 }
5140}
5141
5142fn shell_allow_entry_error(entry: &str) -> Option<&'static str> {
5146 if entry.is_empty() {
5147 Some("is empty")
5148 } else if entry.contains('\0') {
5149 Some("contains a NUL byte")
5150 } else if entry.contains('/') {
5151 Some("is a path")
5152 } else if entry == "." || entry == ".." {
5153 Some("names a directory")
5154 } else if entry.chars().any(char::is_whitespace) {
5155 Some("contains whitespace")
5156 } else {
5157 None
5158 }
5159}
5160
5161fn parse_interpreter_runtime(
5167 allow: &[String],
5168 raw_grants: Vec<RawInterpreterRuntimeGrant>,
5169) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
5170 let mut grants = Vec::with_capacity(raw_grants.len());
5171 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5172 let base = format!("capabilities.shell.interpreter_runtime[{index}]");
5173
5174 let binary = raw_grant
5175 .binary
5176 .filter(|b| !b.trim().is_empty())
5177 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5178 field: format!("{base}.binary"),
5179 message: "must name a binary".to_string(),
5180 })?;
5181
5182 if !allow.contains(&binary) {
5186 return Err(RuntimeManifestError::InvalidCapabilities {
5187 field: format!("{base}.binary"),
5188 message: format!(
5189 "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
5190 only narrow filesystem access for an already-allowlisted binary, never \
5191 grant exec"
5192 ),
5193 });
5194 }
5195
5196 if raw_grant.dirs.is_empty() {
5197 return Err(RuntimeManifestError::InvalidCapabilities {
5198 field: format!("{base}.dirs"),
5199 message: "must name at least one directory".to_string(),
5200 });
5201 }
5202
5203 let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
5204 for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
5205 let dir_base = format!("{base}.dirs[{dir_index}]");
5206
5207 let path = raw_dir
5208 .path
5209 .filter(|p| !p.trim().is_empty())
5210 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5211 field: format!("{dir_base}.path"),
5212 message: "must name a host directory".to_string(),
5213 })?;
5214 if !path.starts_with('/') {
5215 return Err(RuntimeManifestError::InvalidCapabilities {
5216 field: format!("{dir_base}.path"),
5217 message: format!(
5218 "'{path}' must be an absolute host path (start with '/') — these are \
5219 filesystem paths outside the workdir"
5220 ),
5221 });
5222 }
5223
5224 let list_dir =
5225 raw_dir
5226 .list_dir
5227 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5228 field: format!("{dir_base}.list_dir"),
5229 message: format!(
5230 "'{path}' must set list_dir explicitly to true or false — \
5231 enumerability is never inferred"
5232 ),
5233 })?;
5234
5235 dirs.push(InterpreterRuntimeDir { path, list_dir });
5236 }
5237
5238 grants.push(InterpreterRuntimeGrant { binary, dirs });
5239 }
5240
5241 Ok(grants)
5242}
5243
5244fn parse_staged_runtime(
5265 allow: &[String],
5266 interpreter_runtime: &[InterpreterRuntimeGrant],
5267 raw_grants: Vec<RawStagedRuntimeGrant>,
5268) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
5269 let mut grants = Vec::with_capacity(raw_grants.len());
5270 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5271 let base = format!("capabilities.shell.staged_runtime[{index}]");
5272
5273 let binary = raw_grant
5274 .binary
5275 .filter(|b| !b.trim().is_empty())
5276 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5277 field: format!("{base}.binary"),
5278 message: "must name a binary".to_string(),
5279 })?;
5280
5281 if !allow.contains(&binary) {
5282 return Err(RuntimeManifestError::InvalidCapabilities {
5283 field: format!("{base}.binary"),
5284 message: format!(
5285 "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
5286 stage the runtime tree behind an already-allowlisted binary, never grant exec"
5287 ),
5288 });
5289 }
5290
5291 if interpreter_runtime
5292 .iter()
5293 .any(|grant| grant.binary == binary)
5294 {
5295 return Err(RuntimeManifestError::InvalidCapabilities {
5296 field: format!("{base}.binary"),
5297 message: format!(
5298 "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
5299 are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
5300 tree into the capsule's own root, which is what makes widening the capsule's \
5301 host filesystem scope unnecessary rather than something to add to it. Remove \
5302 the interpreter_runtime grant for '{binary}'"
5303 ),
5304 });
5305 }
5306
5307 let source_path = raw_grant
5308 .source_path
5309 .filter(|p| !p.trim().is_empty())
5310 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5311 field: format!("{base}.source_path"),
5312 message: "must name the host directory holding the pinned runtime tree".to_string(),
5313 })?;
5314 if !source_path.starts_with('/') {
5315 return Err(RuntimeManifestError::InvalidCapabilities {
5316 field: format!("{base}.source_path"),
5317 message: format!(
5318 "'{source_path}' must be an absolute host path (start with '/') — \
5319 staged-runtime sources are host filesystem paths outside the workdir"
5320 ),
5321 });
5322 }
5323
5324 let pin = raw_grant
5325 .pin
5326 .map(|p| p.trim().to_string())
5327 .filter(|p| !p.is_empty())
5328 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5329 field: format!("{base}.pin"),
5330 message: format!(
5331 "'{source_path}' must declare a non-empty pin identifying which build this \
5332 tree is — it is never inferred, because it is the value a human compares \
5333 across two hosts to confirm the same runtime shipped to both"
5334 ),
5335 })?;
5336
5337 grants.push(StagedRuntimeGrant {
5338 binary,
5339 source_path,
5340 pin,
5341 });
5342 }
5343
5344 Ok(grants)
5345}
5346
5347fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
5352 let reject_zero_usize =
5353 |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
5354 match value {
5355 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5356 field: format!("capabilities.limits.{field}"),
5357 message: "must be greater than zero".to_string(),
5358 }),
5359 _ => Ok(()),
5360 }
5361 };
5362
5363 reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
5364 reject_zero_usize(raw.table_elements, "table_elements")?;
5365 reject_zero_usize(raw.instances, "instances")?;
5366 if raw.deadline_seconds == Some(0) {
5367 return Err(RuntimeManifestError::InvalidCapabilities {
5368 field: "capabilities.limits.deadline_seconds".to_string(),
5369 message: "must be greater than zero".to_string(),
5370 });
5371 }
5372
5373 Ok(ResourceLimits {
5374 memory_bytes: raw.memory_bytes,
5375 table_elements: raw.table_elements,
5376 instances: raw.instances,
5377 deadline_seconds: raw.deadline_seconds,
5378 })
5379}
5380
5381fn parse_resource_capabilities(
5387 raw: RawResourceCapabilities,
5388) -> Result<ResourceCapabilities, RuntimeManifestError> {
5389 let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
5390 match value {
5391 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5392 field: format!("capabilities.resources.{field}"),
5393 message: "must be greater than zero".to_string(),
5394 }),
5395 _ => Ok(()),
5396 }
5397 };
5398
5399 reject_zero(raw.max_processes, "max_processes")?;
5400 reject_zero(raw.max_open_files, "max_open_files")?;
5401 reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
5402 reject_zero(raw.cpu_seconds, "cpu_seconds")?;
5403 reject_zero(raw.memory_bytes, "memory_bytes")?;
5404 reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
5405 reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
5406 reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
5407 reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
5408 reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
5409
5410 Ok(ResourceCapabilities {
5411 max_processes: raw.max_processes,
5412 max_open_files: raw.max_open_files,
5413 max_file_size_bytes: raw.max_file_size_bytes,
5414 cpu_seconds: raw.cpu_seconds,
5415 memory_bytes: raw.memory_bytes,
5416 cgroup_memory_bytes: raw.cgroup_memory_bytes,
5417 cgroup_pids_max: raw.cgroup_pids_max,
5418 cgroup_cpu_percent: raw.cgroup_cpu_percent,
5419 cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
5420 workdir_max_bytes: raw.workdir_max_bytes,
5421 })
5422}
5423
5424const PROCESS_DRIVER_REQUIRED: &str =
5428 "missing required field; a transport: process capsule must name the process driver that \
5429 knows how to drive its harness. inference.command no longer selects a harness — it only \
5430 overrides the binary the driver names. Add inference.driver.artifact naming a process \
5431 driver, for example murmur/driver-claude-code";
5432
5433fn parse_inference(
5434 raw: Option<RawInferenceConfig>,
5435) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
5436 let Some(raw) = raw else {
5437 return Ok(None);
5438 };
5439
5440 let transport = raw
5441 .transport
5442 .map(|s| s.trim().to_string())
5443 .filter(|s| !s.is_empty())
5444 .unwrap_or_else(|| "http".to_string());
5445
5446 let compaction = parse_compaction(raw.compaction)?;
5447 let system_prompt = optional_trimmed_string(raw.system_prompt);
5448 let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
5449 let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
5450
5451 let prompt_sources_set = [
5452 system_prompt.is_some(),
5453 system_prompt_file.is_some(),
5454 system_prompt_artifact.is_some(),
5455 ]
5456 .iter()
5457 .filter(|&&b| b)
5458 .count();
5459 if prompt_sources_set > 1 {
5460 return Err(RuntimeManifestError::InvalidInferenceConfig {
5461 field: "inference.system_prompt".to_string(),
5462 message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
5463 may be set"
5464 .to_string(),
5465 });
5466 }
5467
5468 if raw.max_turns == Some(0) {
5469 return Err(RuntimeManifestError::InvalidInferenceConfig {
5470 field: "inference.max_turns".to_string(),
5471 message: "must be greater than 0".to_string(),
5472 });
5473 }
5474 let max_turns = raw.max_turns;
5475
5476 if raw.max_task_reopens.is_some() {
5480 return Err(RuntimeManifestError::InvalidInferenceConfig {
5481 field: "inference.max_task_reopens".to_string(),
5482 message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
5483 .to_string(),
5484 });
5485 }
5486
5487 for (present, removed, moved_to) in [
5491 (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
5492 (raw.api_key.is_some(), "api_key", "gateway.api_key"),
5493 ] {
5494 if !present {
5495 continue;
5496 }
5497 let entry = raw
5498 .driver
5499 .as_ref()
5500 .and_then(|driver| driver.artifact.as_deref())
5501 .map(str::trim)
5502 .filter(|name| !name.is_empty())
5503 .map(|name| format!("on the artifacts: entry '{name}'"))
5504 .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
5505 return Err(RuntimeManifestError::InvalidInferenceConfig {
5506 field: format!("inference.{removed}"),
5507 message: format!("was removed; set {moved_to} {entry} instead"),
5508 });
5509 }
5510
5511 match transport.as_str() {
5512 "http" => {
5513 let model = required_inference_field(raw.model, "model")?;
5514
5515 let driver_raw =
5516 raw.driver
5517 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5518 field: "inference.driver".to_string(),
5519 message: "missing required field".to_string(),
5520 })?;
5521 let driver = parse_inference_driver(driver_raw)?;
5522
5523 if raw.max_tokens == Some(0) {
5527 return Err(RuntimeManifestError::InvalidInferenceConfig {
5528 field: "inference.max_tokens".to_string(),
5529 message: "must be greater than 0".to_string(),
5530 });
5531 }
5532 if raw.max_session_tokens == Some(0) {
5533 return Err(RuntimeManifestError::InvalidInferenceConfig {
5534 field: "inference.max_session_tokens".to_string(),
5535 message: "must be greater than 0".to_string(),
5536 });
5537 }
5538 let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
5539 let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
5540 let retry = parse_inference_retry(raw.retry)?;
5541
5542 Ok(Some(InferenceConfig {
5543 transport,
5544 model,
5545 driver: Some(driver),
5546 command: None,
5547 compaction,
5548 system_prompt,
5549 system_prompt_file,
5550 system_prompt_artifact,
5551 max_turns,
5552 max_tokens: raw.max_tokens,
5553 max_session_tokens: raw.max_session_tokens,
5554 tool_refresh,
5555 alternates,
5556 retry,
5557 }))
5558 }
5559 "process" => {
5560 if raw.max_tokens.is_some() {
5563 return Err(RuntimeManifestError::InvalidInferenceConfig {
5564 field: "inference.max_tokens".to_string(),
5565 message: "is not valid with transport: process".to_string(),
5566 });
5567 }
5568 if raw.tool_refresh.is_some() {
5571 return Err(RuntimeManifestError::InvalidInferenceConfig {
5572 field: "inference.tool_refresh".to_string(),
5573 message: "is not valid with transport: process".to_string(),
5574 });
5575 }
5576 for (present, key) in [
5579 (
5580 compaction.as_ref().is_some_and(|c| c.max_tokens.is_some()),
5581 "max_tokens",
5582 ),
5583 (
5584 compaction
5585 .as_ref()
5586 .is_some_and(|c| c.driver_config.is_some()),
5587 "driver_config",
5588 ),
5589 ] {
5590 if present {
5591 return Err(RuntimeManifestError::InvalidInferenceConfig {
5592 field: format!("inference.compaction.{key}"),
5593 message: "is not valid with transport: process".to_string(),
5594 });
5595 }
5596 }
5597 if raw.alternates.is_some() {
5600 return Err(RuntimeManifestError::InvalidInferenceConfig {
5601 field: "inference.alternates".to_string(),
5602 message: "is not valid with transport: process; the harness owns the \
5603 conversation and the model it runs"
5604 .to_string(),
5605 });
5606 }
5607 if raw.retry.is_some() {
5608 return Err(RuntimeManifestError::InvalidInferenceConfig {
5609 field: "inference.retry".to_string(),
5610 message: "is not valid with transport: process; the harness retries its own \
5611 provider calls"
5612 .to_string(),
5613 });
5614 }
5615
5616 if raw.max_session_tokens == Some(0) {
5620 return Err(RuntimeManifestError::InvalidInferenceConfig {
5621 field: "inference.max_session_tokens".to_string(),
5622 message: "must be greater than 0".to_string(),
5623 });
5624 }
5625
5626 let driver = raw.driver.map(parse_inference_driver).transpose()?;
5627 let command = optional_trimmed_string(raw.command);
5628 if driver.is_none() {
5632 return Err(RuntimeManifestError::InvalidInferenceConfig {
5633 field: "inference.driver.artifact".to_string(),
5634 message: PROCESS_DRIVER_REQUIRED.to_string(),
5635 });
5636 }
5637 let model = optional_trimmed_string(raw.model).unwrap_or_default();
5641
5642 Ok(Some(InferenceConfig {
5643 transport,
5644 model,
5645 driver,
5646 command,
5647 compaction,
5648 system_prompt,
5649 system_prompt_file,
5650 system_prompt_artifact,
5651 max_turns,
5652 max_tokens: None,
5653 max_session_tokens: raw.max_session_tokens,
5654 tool_refresh: ToolRefresh::default(),
5655 alternates: Vec::new(),
5656 retry: InferenceRetry::default(),
5657 }))
5658 }
5659 other => Err(RuntimeManifestError::InvalidInferenceConfig {
5660 field: "inference.transport".to_string(),
5661 message: format!("unknown value '{other}'"),
5662 }),
5663 }
5664}
5665
5666fn parse_inference_retry(
5670 raw: Option<RawInferenceRetry>,
5671) -> Result<InferenceRetry, RuntimeManifestError> {
5672 let defaults = InferenceRetry::default();
5673 let Some(raw) = raw else {
5674 return Ok(defaults);
5675 };
5676 let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5677 field: format!("inference.retry.{key}"),
5678 message,
5679 };
5680 let retry = InferenceRetry {
5681 max_retries: raw.max_retries.unwrap_or(defaults.max_retries),
5682 initial_delay_ms: raw.initial_delay_ms.unwrap_or(defaults.initial_delay_ms),
5683 max_delay_ms: raw.max_delay_ms.unwrap_or(defaults.max_delay_ms),
5684 };
5685 if retry.max_retries > InferenceRetry::MAX_RETRIES {
5686 return Err(refuse(
5687 "max_retries",
5688 format!("must be at most {}", InferenceRetry::MAX_RETRIES),
5689 ));
5690 }
5691 if !(1..=InferenceRetry::MAX_INITIAL_DELAY_MS).contains(&retry.initial_delay_ms) {
5692 return Err(refuse(
5693 "initial_delay_ms",
5694 format!(
5695 "must be between 1 and {}",
5696 InferenceRetry::MAX_INITIAL_DELAY_MS
5697 ),
5698 ));
5699 }
5700 if !(1..=InferenceRetry::MAX_DELAY_MS).contains(&retry.max_delay_ms) {
5701 return Err(refuse(
5702 "max_delay_ms",
5703 format!("must be between 1 and {}", InferenceRetry::MAX_DELAY_MS),
5704 ));
5705 }
5706 if retry.max_delay_ms < retry.initial_delay_ms {
5707 return Err(refuse(
5708 "max_delay_ms",
5709 format!(
5710 "must be at least inference.retry.initial_delay_ms ({})",
5711 retry.initial_delay_ms
5712 ),
5713 ));
5714 }
5715 Ok(retry)
5716}
5717
5718fn is_valid_driver_choice_name(name: &str) -> bool {
5721 let mut chars = name.chars();
5722 chars.next().is_some_and(|first| first.is_ascii_lowercase())
5723 && name.len() <= 63
5724 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
5725}
5726
5727fn parse_alternates(
5733 raw: Option<Vec<RawInferenceAlternate>>,
5734 primary_driver: &str,
5735 primary_model: &str,
5736) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
5737 let mut alternates: Vec<InferenceAlternate> = Vec::new();
5738 for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
5739 let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5740 field: if key.is_empty() {
5741 format!("inference.alternates[{index}]")
5742 } else {
5743 format!("inference.alternates[{index}].{key}")
5744 },
5745 message,
5746 };
5747 let name = optional_trimmed_string(entry.name)
5748 .ok_or_else(|| refuse("name", "missing required field".to_string()))?;
5749 if name == PRIMARY_DRIVER_CHOICE {
5750 return Err(refuse(
5751 "name",
5752 format!(
5753 "'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
5754 inference.model; name the alternate something else"
5755 ),
5756 ));
5757 }
5758 if !is_valid_driver_choice_name(&name) {
5759 return Err(refuse(
5760 "name",
5761 format!(
5762 "'{name}' is not a driver choice name; a name is a lowercase letter followed \
5763 by up to 62 lowercase letters, digits and hyphens"
5764 ),
5765 ));
5766 }
5767 if alternates.iter().any(|alternate| alternate.name == name) {
5768 return Err(refuse(
5769 "name",
5770 format!("'{name}' names more than one alternate"),
5771 ));
5772 }
5773 let model = optional_trimmed_string(entry.model)
5774 .ok_or_else(|| refuse("model", "missing required field".to_string()))?;
5775 let Some(driver) = entry.driver else {
5776 return Err(refuse(
5777 "driver.artifact",
5778 "missing required field".to_string(),
5779 ));
5780 };
5781 if driver
5782 .config
5783 .as_ref()
5784 .is_some_and(|config| !matches!(config, serde_norway::Value::Null))
5785 {
5786 return Err(refuse(
5787 "driver.config",
5788 "is not valid on an alternate; inference.driver.config applies to every \
5789 declared driver, and per-driver settings belong in that driver's \
5790 artifacts[].config"
5791 .to_string(),
5792 ));
5793 }
5794 let driver = optional_trimmed_string(driver.artifact)
5795 .ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
5796 if driver == primary_driver && model == primary_model {
5797 return Err(refuse(
5798 "",
5799 format!(
5800 "'{name}' is driver '{driver}' with model '{model}', which is the primary \
5801 choice; an alternate names a different pair"
5802 ),
5803 ));
5804 }
5805 if let Some(twin) = alternates
5806 .iter()
5807 .find(|alternate| alternate.driver == driver && alternate.model == model)
5808 {
5809 return Err(refuse(
5810 "",
5811 format!(
5812 "'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
5813 already is; an alternate names a different pair",
5814 twin.name
5815 ),
5816 ));
5817 }
5818 alternates.push(InferenceAlternate {
5819 name,
5820 model,
5821 driver,
5822 });
5823 }
5824 Ok(alternates)
5825}
5826
5827fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
5829 let Some(value) = raw else {
5830 return Ok(ToolRefresh::default());
5831 };
5832 ToolRefresh::ALL
5833 .into_iter()
5834 .find(|refresh| refresh.wire_name() == value)
5835 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5836 field: "inference.tool_refresh".to_string(),
5837 message: format!(
5838 "must be one of: {}; got '{value}'",
5839 ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
5840 ),
5841 })
5842}
5843
5844fn parse_context(
5845 raw: Option<RawContextConfig>,
5846) -> Result<Option<ContextConfig>, RuntimeManifestError> {
5847 let Some(raw) = raw else {
5848 return Ok(None);
5849 };
5850
5851 if let Some(max_tokens) = raw.max_tokens {
5852 if max_tokens == 0 {
5853 return Err(RuntimeManifestError::InvalidInferenceConfig {
5854 field: "context.max_tokens".to_string(),
5855 message: "must be greater than 0".to_string(),
5856 });
5857 }
5858 }
5859
5860 let fraction = |field: &str, value: Option<f32>, default: f32| match value {
5861 None => Ok(default),
5862 Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
5863 Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
5864 field: field.to_string(),
5865 message: "must be between 0.0 and 1.0".to_string(),
5866 }),
5867 };
5868
5869 let record = match raw.record.as_deref().map(str::trim) {
5870 None => true,
5871 Some("on") => true,
5872 Some("off") => false,
5873 Some(other) => {
5874 return Err(RuntimeManifestError::InvalidInferenceConfig {
5875 field: "context.record".to_string(),
5876 message: format!("unknown value '{other}'; expected: on, off"),
5877 })
5878 }
5879 };
5880
5881 Ok(Some(ContextConfig {
5882 max_tokens: raw.max_tokens,
5883 record,
5884 record_store: raw.record_store.map(|store| store.trim().to_string()),
5885 seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
5886 seed_overflow_margin: fraction(
5887 "context.seed_overflow_margin",
5888 raw.seed_overflow_margin,
5889 DEFAULT_SEED_OVERFLOW_MARGIN,
5890 )?,
5891 retain: parse_context_retain(raw.retain)?,
5892 }))
5893}
5894
5895const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
5897
5898const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
5900
5901const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
5904 keep everything, which is what no policy means";
5905
5906fn parse_retain_block(
5912 block: &str,
5913 count_key: &str,
5914 accepted: &[&str],
5915 raw: serde_norway::Value,
5916 invalid: &dyn Fn(String, String) -> RuntimeManifestError,
5917) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
5918 let mapping = match raw {
5919 serde_norway::Value::Mapping(mapping) => mapping,
5920 serde_norway::Value::Null => {
5922 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
5923 }
5924 other => {
5925 return Err(invalid(
5926 block.to_string(),
5927 format!(
5928 "must be a block of keys ({}), got '{other:?}'",
5929 accepted.join(", ")
5930 ),
5931 ))
5932 }
5933 };
5934 if mapping.is_empty() {
5935 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
5936 }
5937
5938 let mut count: Option<u32> = None;
5939 let mut max_age_secs: Option<u64> = None;
5940 for (key, value) in &mapping {
5941 let key = key.as_str().unwrap_or_default();
5942 let field = format!("{block}.{key}");
5943 if key == count_key {
5944 let parsed = value
5945 .as_u64()
5946 .and_then(|n| u32::try_from(n).ok())
5947 .ok_or_else(|| {
5948 invalid(
5949 field.clone(),
5950 "must be a whole number of at least 1".to_string(),
5951 )
5952 })?;
5953 if parsed == 0 {
5954 return Err(invalid(
5955 field,
5956 "must be at least 1 — a limit of zero would delete what retention exists \
5957 to keep; remove the key to leave it unbounded"
5958 .to_string(),
5959 ));
5960 }
5961 count = Some(parsed);
5962 } else if key == "max_age" {
5963 let text = scalar_text(value).ok_or_else(|| {
5964 invalid(
5965 field.clone(),
5966 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
5967 )
5968 })?;
5969 let parsed =
5970 parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
5971 if parsed == 0 {
5972 return Err(invalid(
5973 field,
5974 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
5975 ));
5976 }
5977 max_age_secs = Some(parsed);
5978 } else {
5979 return Err(invalid(
5980 block.to_string(),
5981 format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
5982 ));
5983 }
5984 }
5985 Ok((count, max_age_secs))
5986}
5987
5988fn parse_trace_retain(
5989 raw: Option<serde_norway::Value>,
5990) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
5991 let Some(raw) = raw else {
5992 return Ok(None);
5993 };
5994 let (max_sessions, max_age_secs) = parse_retain_block(
5995 "trace.retain",
5996 "max_sessions",
5997 &TRACE_RETAIN_KEYS,
5998 raw,
5999 &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
6000 )?;
6001 Ok(Some(TraceRetainConfig {
6002 max_sessions,
6003 max_age_secs,
6004 }))
6005}
6006
6007fn parse_context_retain(
6008 raw: Option<serde_norway::Value>,
6009) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
6010 let Some(raw) = raw else {
6011 return Ok(None);
6012 };
6013 let (max_messages, max_age_secs) = parse_retain_block(
6014 "context.retain",
6015 "max_messages",
6016 &CONTEXT_RETAIN_KEYS,
6017 raw,
6018 &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
6019 )?;
6020 Ok(Some(ContextRetainConfig {
6021 max_messages,
6022 max_age_secs,
6023 }))
6024}
6025
6026fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
6027 raw.map(|raw| ObservabilityConfig {
6028 otel_endpoint: raw
6029 .otel_endpoint
6030 .map(|value| value.trim().to_string())
6031 .filter(|value| !value.is_empty()),
6032 eval: raw.eval.map(parse_eval_config),
6033 })
6034}
6035
6036fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
6037 let scorers = raw
6038 .scorers
6039 .into_iter()
6040 .filter_map(|raw| {
6041 let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
6042 match raw.scorer_type.as_str() {
6043 "exit_ok" => Some(ScorerConfig::ExitOk { name }),
6044 "max_turns" => Some(ScorerConfig::MaxTurns {
6045 name,
6046 max: raw.max.unwrap_or(10) as u32,
6047 }),
6048 "max_tokens" => Some(ScorerConfig::MaxTokens {
6049 name,
6050 max: raw.max.unwrap_or(100_000),
6051 }),
6052 "tool_sequence" => Some(ScorerConfig::ToolSequence {
6053 name,
6054 expected: raw.expected.unwrap_or_default(),
6055 }),
6056 "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
6057 other => {
6058 crate::warn_line(format_args!(
6059 "[murmur-artifact] unknown scorer type '{other}' — skipping"
6060 ));
6061 None
6062 }
6063 }
6064 })
6065 .collect();
6066
6067 EvalConfig {
6068 dataset_id: raw.dataset_id,
6069 scorers,
6070 }
6071}
6072
6073fn parse_compaction(
6074 raw: Option<RawCompactionConfig>,
6075) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
6076 let Some(raw) = raw else {
6077 return Ok(None);
6078 };
6079
6080 if let Some(threshold) = raw.threshold {
6081 if threshold <= 0.0 || threshold > 1.0 {
6082 return Err(RuntimeManifestError::InvalidInferenceConfig {
6083 field: "inference.compaction.threshold".to_string(),
6084 message: format!("must be in (0.0, 1.0] but got {threshold}"),
6085 });
6086 }
6087 }
6088
6089 if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
6090 return Err(RuntimeManifestError::InvalidInferenceConfig {
6091 field: "inference.compaction.system_prompt".to_string(),
6092 message: "at most one of inference.compaction.system_prompt, \
6093 inference.compaction.system_prompt_file may be set"
6094 .to_string(),
6095 });
6096 }
6097
6098 if raw.max_tokens == Some(0) {
6099 return Err(RuntimeManifestError::InvalidInferenceConfig {
6100 field: "inference.compaction.max_tokens".to_string(),
6101 message: "must be greater than 0".to_string(),
6102 });
6103 }
6104 let driver_config =
6105 driver_config_json(raw.driver_config, "inference.compaction.driver_config")?;
6106
6107 Ok(Some(CompactionConfig {
6108 threshold: raw.threshold,
6109 model: raw.model,
6110 system_prompt: raw.system_prompt,
6111 system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
6112 dump_summaries: raw.dump_summaries,
6115 max_tokens: raw.max_tokens,
6116 driver_config,
6117 }))
6118}
6119
6120fn optional_trimmed_string(value: Option<String>) -> Option<String> {
6121 value
6122 .map(|s| s.trim().to_string())
6123 .filter(|s| !s.is_empty())
6124}
6125
6126fn required_inference_field(
6127 value: Option<String>,
6128 key: &str,
6129) -> Result<String, RuntimeManifestError> {
6130 value
6131 .map(|value| value.trim().to_string())
6132 .filter(|value| !value.is_empty())
6133 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6134 field: format!("inference.{key}"),
6135 message: "missing required field".to_string(),
6136 })
6137}
6138
6139fn parse_artifact_gateway(
6147 index: usize,
6148 name: &str,
6149 raw: Option<RawArtifactGateway>,
6150 secrets: SecretResolution,
6151) -> Result<ArtifactGateway, RuntimeManifestError> {
6152 let (endpoint, api_key, keyless) = match raw {
6153 Some(raw) => (
6154 optional_trimmed_string(raw.endpoint),
6155 raw.api_key,
6156 raw.keyless,
6157 ),
6158 None => (None, None, None),
6159 };
6160 let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
6161 index,
6162 message: format!(
6163 "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
6164 the upstream URL the runtime sends this artifact's keyed requests to"
6165 ),
6166 })?;
6167 validate_gateway_endpoint(name, &endpoint)
6168 .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
6169
6170 let keyless = match keyless {
6171 None | Some(serde_norway::Value::Null) | Some(serde_norway::Value::Bool(false)) => false,
6172 Some(serde_norway::Value::Bool(true)) => true,
6173 Some(_) => {
6174 return Err(RuntimeManifestError::InvalidArtifact {
6175 index,
6176 message: format!(
6177 "artifact '{name}' declares gateway.keyless with a value that is not a \
6178 boolean; write keyless: true for an upstream that takes no key"
6179 ),
6180 })
6181 }
6182 };
6183 if keyless && api_key.is_some() {
6184 return Err(RuntimeManifestError::InvalidArtifact {
6185 index,
6186 message: format!(
6187 "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
6188 gateway either presents a key or is keyless"
6189 ),
6190 });
6191 }
6192 if keyless {
6193 return Ok(ArtifactGateway {
6194 endpoint,
6195 api_key: None,
6196 keyless: true,
6197 });
6198 }
6199
6200 let written = api_key
6201 .flatten()
6202 .map(|value| value.trim().to_string())
6203 .filter(|value| !value.is_empty());
6204 let Some(written) = written else {
6205 return Err(RuntimeManifestError::GatewayWithoutCredential {
6206 index,
6207 name: name.to_string(),
6208 endpoint,
6209 });
6210 };
6211 let api_key = match secrets {
6212 SecretResolution::Skip => None,
6213 SecretResolution::Resolve => Some(match parse_env_reference(&written) {
6214 Some(variable) => ApiKeyReference::Environment(variable.to_string()),
6215 None => ApiKeyReference::Literal(written),
6216 }),
6217 };
6218
6219 Ok(ArtifactGateway {
6220 endpoint,
6221 api_key,
6222 keyless: false,
6223 })
6224}
6225
6226fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
6253 let shown = mask_endpoint_userinfo(endpoint);
6254 let refuse =
6255 |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
6256 if endpoint.contains("${") {
6257 return Err(refuse(
6258 "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
6259 literally"
6260 .to_string(),
6261 ));
6262 }
6263 let url = Url::parse(endpoint)
6264 .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
6265 let host = match url.scheme() {
6266 "http" | "https" => url
6267 .host_str()
6268 .filter(|host| !host.is_empty())
6269 .ok_or_else(|| refuse("which has no host".to_string()))?,
6270 other => {
6271 return Err(refuse(format!(
6272 "whose scheme '{other}' is unsupported — expected http or https"
6273 )))
6274 }
6275 };
6276 if !url.username().is_empty() || url.password().is_some() {
6277 return Err(refuse(format!(
6278 "which carries userinfo before '@' — the gateway never sends it, and this URL \
6279 addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
6280 the key through gateway.api_key"
6281 )));
6282 }
6283 if url.query().is_some() {
6284 return Err(refuse(
6285 "which carries a query — the gateway keeps each request's own query, so write the \
6286 upstream without one"
6287 .to_string(),
6288 ));
6289 }
6290 if url.fragment().is_some() {
6291 return Err(refuse(
6292 "which carries a fragment — write the upstream without one".to_string(),
6293 ));
6294 }
6295 if url.scheme() == "http" {
6296 let bare = host.trim_start_matches('[').trim_end_matches(']');
6297 let is_loopback = bare == "localhost"
6298 || bare
6299 .parse::<IpAddr>()
6300 .map(|ip| ip.is_loopback())
6301 .unwrap_or(false);
6302 if !is_loopback {
6303 return Err(refuse(format!(
6304 "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
6305 remote upstream, or http://localhost or a loopback IP literal for a local one"
6306 )));
6307 }
6308 }
6309 Ok(())
6310}
6311
6312fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
6316 let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
6317 let rest = &endpoint[authority_start..];
6318 let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
6319 match rest[..authority_len].rfind('@') {
6320 Some(at) => format!(
6321 "{}***{}",
6322 &endpoint[..authority_start],
6323 &endpoint[authority_start + at..]
6324 )
6325 .into(),
6326 None => endpoint.into(),
6327 }
6328}
6329
6330fn validate_driver_gateways(
6337 artifacts: &[RuntimeArtifact],
6338 inference: Option<&InferenceConfig>,
6339) -> Result<(), RuntimeManifestError> {
6340 let configured_driver = inference
6341 .filter(|inference| inference.transport == "http")
6342 .and_then(|inference| inference.driver.as_ref())
6343 .map(|driver| driver.artifact.as_str());
6344 let alternate_drivers: Vec<&str> = inference
6345 .map(|inference| {
6346 inference
6347 .alternates
6348 .iter()
6349 .map(|alternate| alternate.driver.as_str())
6350 .collect()
6351 })
6352 .unwrap_or_default();
6353 let under_process = inference.is_some_and(|inference| inference.transport == "process");
6354
6355 if let Some(driver) = configured_driver {
6358 if !artifacts.iter().any(|artifact| artifact.name == driver) {
6359 return Err(RuntimeManifestError::InvalidInferenceConfig {
6360 field: "inference.driver.artifact".to_string(),
6361 message: not_declared_message(artifacts, driver),
6362 });
6363 }
6364 }
6365
6366 for (index, artifact) in artifacts.iter().enumerate() {
6367 if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
6368 continue;
6369 }
6370 if under_process {
6371 return Err(RuntimeManifestError::InvalidArtifact {
6372 index,
6373 message: format!(
6374 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
6375 valid with transport: process — the CLI subprocess holds its own \
6376 credentials and a process driver is granted nothing",
6377 artifact.name
6378 ),
6379 });
6380 }
6381 if configured_driver != Some(artifact.name.as_str())
6382 && !alternate_drivers.contains(&artifact.name.as_str())
6383 {
6384 return Err(RuntimeManifestError::InvalidArtifact {
6385 index,
6386 message: format!(
6387 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
6388 the configured inference driver; a driver's gateway is accepted only on the \
6389 entry inference.driver.artifact names under transport: http, or on one an \
6390 inference.alternates entry names",
6391 artifact.name
6392 ),
6393 });
6394 }
6395 }
6396
6397 if let Some(driver) = configured_driver {
6398 let Some((index, entry)) = artifacts
6399 .iter()
6400 .enumerate()
6401 .find(|(_, artifact)| artifact.name == driver)
6402 else {
6403 return Err(RuntimeManifestError::InvalidInferenceConfig {
6404 field: "inference.driver.artifact".to_string(),
6405 message: not_declared_message(artifacts, driver),
6406 });
6407 };
6408 if entry.gateway.is_none() {
6409 return Err(RuntimeManifestError::InvalidArtifact {
6410 index,
6411 message: format!(
6412 "artifact '{driver}' is the transport: http inference driver but declares no \
6413 gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
6414 on its entry"
6415 ),
6416 });
6417 }
6418 }
6419 Ok(())
6420}
6421
6422fn validate_driver_choices(
6428 artifacts: &[RuntimeArtifact],
6429 inference: Option<&InferenceConfig>,
6430) -> Result<(), RuntimeManifestError> {
6431 let Some(inference) = inference else {
6432 return Ok(());
6433 };
6434 let primary_driver = inference
6435 .driver
6436 .as_ref()
6437 .map(|driver| driver.artifact.as_str());
6438 for (index, alternate) in inference.alternates.iter().enumerate() {
6439 let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
6440 field: format!("inference.alternates[{index}].driver.artifact"),
6441 message,
6442 };
6443 let driver = alternate.driver.as_str();
6444 let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
6445 return Err(refuse(not_declared_message(artifacts, driver)));
6446 };
6447 if entry.runtime != ArtifactRuntime::Driver {
6448 return Err(refuse(format!(
6449 "artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
6450 runtime: driver entry",
6451 entry.runtime.as_str()
6452 )));
6453 }
6454 if entry.gateway.is_none() && primary_driver != Some(driver) {
6455 return Err(refuse(format!(
6456 "artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
6457 its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
6458 URL>, api_key: ... }}' on its entry"
6459 )));
6460 }
6461 }
6462 Ok(())
6463}
6464
6465fn validate_alternates_selectable(
6468 inference: Option<&InferenceConfig>,
6469 control: Option<&ControlConfig>,
6470) -> Result<(), RuntimeManifestError> {
6471 if inference.is_none_or(|inference| inference.alternates.is_empty()) {
6472 return Ok(());
6473 }
6474 let selectable = control.is_some_and(|control| {
6475 control.controller_may_set(ControllableSetting::InferenceDriver)
6476 || control.agent_may_set(ControllableSetting::InferenceDriver)
6477 });
6478 if selectable {
6479 return Ok(());
6480 }
6481 Err(RuntimeManifestError::InvalidInferenceConfig {
6482 field: "inference.alternates".to_string(),
6483 message: "declares alternates that nothing could ever select; list inference.driver in \
6484 control.settings for a controller to switch, or in control.agent_settings for \
6485 the agent to"
6486 .to_string(),
6487 })
6488}
6489
6490fn validate_process_driver_entry(
6498 artifacts: &[RuntimeArtifact],
6499 inference: Option<&InferenceConfig>,
6500) -> Result<(), RuntimeManifestError> {
6501 let Some(driver) = inference
6502 .filter(|inference| inference.transport == "process")
6503 .and_then(|inference| inference.driver.as_ref())
6504 .map(|driver| driver.artifact.as_str())
6505 else {
6506 return Ok(());
6507 };
6508 let Some((index, entry)) = artifacts
6509 .iter()
6510 .enumerate()
6511 .find(|(_, artifact)| artifact.name == driver)
6512 else {
6513 return Err(RuntimeManifestError::InvalidInferenceConfig {
6514 field: "inference.driver.artifact".to_string(),
6515 message: not_declared_message(artifacts, driver),
6516 });
6517 };
6518 if entry.runtime != ArtifactRuntime::Driver {
6519 return Err(RuntimeManifestError::InvalidArtifact {
6520 index,
6521 message: format!(
6522 "artifact '{driver}' is the transport: process driver and must be declared with \
6523 runtime: driver"
6524 ),
6525 });
6526 }
6527 if entry.capabilities.is_some() || entry.config.is_some() {
6528 return Err(RuntimeManifestError::InvalidArtifact {
6529 index,
6530 message: format!(
6531 "artifact '{driver}' is the transport: process driver, which is granted nothing; \
6532 remove capabilities:/config: from its entry — driver settings go in \
6533 inference.driver.config"
6534 ),
6535 });
6536 }
6537 Ok(())
6538}
6539
6540fn parse_inference_driver(
6541 raw: RawInferenceDriver,
6542) -> Result<InferenceDriver, RuntimeManifestError> {
6543 let artifact = raw
6544 .artifact
6545 .map(|value| value.trim().to_string())
6546 .filter(|value| !value.is_empty())
6547 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6548 field: "inference.driver.artifact".to_string(),
6549 message: "missing required field".to_string(),
6550 })?;
6551
6552 let config = driver_config_json(raw.config, "inference.driver.config")?;
6553
6554 Ok(InferenceDriver { artifact, config })
6555}
6556
6557fn driver_config_json(
6560 raw: Option<serde_norway::Value>,
6561 field: &str,
6562) -> Result<Option<String>, RuntimeManifestError> {
6563 let Some(raw) = raw else {
6564 return Ok(None);
6565 };
6566
6567 if matches!(raw, serde_norway::Value::Null) {
6568 return Ok(None);
6569 }
6570
6571 if !matches!(raw, serde_norway::Value::Mapping(_)) {
6572 return Err(RuntimeManifestError::InvalidInferenceConfig {
6573 field: field.to_string(),
6574 message: "expected mapping/object".to_string(),
6575 });
6576 }
6577
6578 let value =
6579 serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
6580 field: field.to_string(),
6581 message: format!("failed to convert to JSON: {err}"),
6582 })?;
6583
6584 serde_json::to_string(&value).map(Some).map_err(|err| {
6585 RuntimeManifestError::InvalidInferenceConfig {
6586 field: field.to_string(),
6587 message: format!("failed to encode JSON: {err}"),
6588 }
6589 })
6590}
6591
6592fn parse_env_reference(value: &str) -> Option<&str> {
6593 if !value.starts_with("${") || !value.ends_with('}') {
6594 return None;
6595 }
6596
6597 let variable = &value[2..value.len() - 1];
6598 is_valid_env_variable(variable).then_some(variable)
6599}
6600
6601fn is_valid_env_variable(value: &str) -> bool {
6602 let mut chars = value.chars();
6603 let Some(first) = chars.next() else {
6604 return false;
6605 };
6606
6607 if !(first == '_' || first.is_ascii_uppercase()) {
6608 return false;
6609 }
6610
6611 chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
6612}
6613
6614#[cfg(test)]
6615mod tests {
6616 use super::*;
6617 use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
6618
6619 fn manifest_with_trace(block: &str) -> String {
6620 format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
6621 }
6622
6623 fn lifecycle(task_acceptance: TaskAcceptance, queue_depth: usize) -> LifecycleConfig {
6624 LifecycleConfig {
6625 task_acceptance,
6626 queue_depth,
6627 ..Default::default()
6628 }
6629 }
6630
6631 #[test]
6632 fn tasks_held_at_once_under_queue_is_one_running_plus_queue_depth_waiting() {
6633 assert_eq!(lifecycle(TaskAcceptance::Queue, 1).tasks_held_at_once(), 2);
6634 assert_eq!(lifecycle(TaskAcceptance::Queue, 9).tasks_held_at_once(), 10);
6635 }
6636
6637 #[test]
6638 fn queue_has_room_is_false_only_for_a_queue_at_depth_zero() {
6639 assert!(!lifecycle(TaskAcceptance::Queue, 0).queue_has_room());
6640 assert!(lifecycle(TaskAcceptance::Queue, 1).queue_has_room());
6641 assert!(lifecycle(TaskAcceptance::Single, 0).queue_has_room());
6642 assert!(lifecycle(TaskAcceptance::None, 0).queue_has_room());
6643 assert!(lifecycle(TaskAcceptance::Single, 3).queue_has_room());
6644 }
6645
6646 fn parse_lifecycle(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
6647 RuntimeManifest::from_yaml_str(&format!(
6648 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n{block}"
6649 ))
6650 }
6651
6652 #[test]
6653 fn queue_depth_zero_under_queue_is_refused_at_load() {
6654 let error = parse_lifecycle(" task_acceptance: queue\n queue_depth: 0\n").unwrap_err();
6655 assert!(
6656 matches!(error, RuntimeManifestError::QueueWithoutRoom),
6657 "{error:?}"
6658 );
6659 assert_eq!(
6660 error.to_string(),
6661 "murmur.yaml: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves \
6662 task_acceptance: queue no room for a waiting task"
6663 );
6664 }
6665
6666 #[test]
6668 fn queue_depth_zero_outside_queue_still_parses() {
6669 for block in [
6670 " task_acceptance: single\n queue_depth: 0\n",
6671 " task_acceptance: none\n queue_depth: 0\n",
6672 " queue_depth: 0\n",
6673 ] {
6674 let lifecycle = parse_lifecycle(block)
6675 .unwrap_or_else(|error| panic!("{block:?}: {error}"))
6676 .effective_lifecycle();
6677 assert_eq!(lifecycle.queue_depth, 0, "{block:?}");
6678 }
6679 let queue = parse_lifecycle(" task_acceptance: queue\n queue_depth: 1\n")
6680 .unwrap()
6681 .effective_lifecycle();
6682 assert_eq!(queue.task_acceptance, TaskAcceptance::Queue);
6683 assert_eq!(queue.queue_depth, 1);
6684 }
6685
6686 #[test]
6687 fn tasks_held_at_once_under_single_is_one_whatever_the_depth() {
6688 assert_eq!(lifecycle(TaskAcceptance::Single, 1).tasks_held_at_once(), 1);
6689 assert_eq!(lifecycle(TaskAcceptance::Single, 5).tasks_held_at_once(), 1);
6690 }
6691
6692 #[test]
6693 fn tasks_held_at_once_under_none_is_zero_whatever_the_depth() {
6694 assert_eq!(lifecycle(TaskAcceptance::None, 1).tasks_held_at_once(), 0);
6695 assert_eq!(lifecycle(TaskAcceptance::None, 5).tasks_held_at_once(), 0);
6696 }
6697
6698 #[test]
6701 fn tasks_held_at_once_of_the_default_lifecycle_and_default_depth() {
6702 assert_eq!(LifecycleConfig::default().tasks_held_at_once(), 1);
6703 let queue = RuntimeManifest::from_yaml_str(
6704 "name: cap\nversion: 0.0.1\nlifecycle:\n task_acceptance: queue\n",
6705 )
6706 .unwrap()
6707 .effective_lifecycle();
6708 assert_eq!(queue.queue_depth, 1);
6709 assert_eq!(queue.tasks_held_at_once(), 2);
6710 }
6711
6712 #[test]
6715 fn absent_trace_block_stays_absent_and_defaults_to_meta() {
6716 let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
6717 assert_eq!(manifest.trace, None);
6718 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
6719 }
6720
6721 #[test]
6722 fn each_capture_mode_parses_from_the_trace_block() {
6723 for (yaml, expected) in [
6724 ("none", TraceCapture::None),
6725 ("meta", TraceCapture::Meta),
6726 ("content", TraceCapture::Content),
6727 ] {
6728 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
6729 " capture: {yaml}\n"
6730 )))
6731 .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
6732 assert_eq!(
6733 manifest.trace,
6734 Some(TraceConfig {
6735 capture: expected,
6736 retain: None
6737 })
6738 );
6739 }
6740 }
6741
6742 #[test]
6748 fn no_retain_block_anywhere_parses_as_no_policy() {
6749 let manifest = RuntimeManifest::from_yaml_str(
6750 "name: cap\nversion: 0.0.1\ntrace:\n capture: content\ncontext:\n record: on\n",
6751 )
6752 .unwrap();
6753 assert_eq!(manifest.trace.unwrap().retain, None);
6754 assert_eq!(manifest.context.unwrap().retain, None);
6755 }
6756
6757 #[test]
6758 fn trace_retain_parses_both_keys_and_the_day_suffix() {
6759 let manifest = RuntimeManifest::from_yaml_str(
6760 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 50\n max_age: 14d\n",
6761 )
6762 .unwrap();
6763 assert_eq!(
6764 manifest.trace.unwrap().retain,
6765 Some(TraceRetainConfig {
6766 max_sessions: Some(50),
6767 max_age_secs: Some(14 * 86_400),
6768 })
6769 );
6770 }
6771
6772 #[test]
6773 fn context_retain_parses_both_keys_and_either_alone() {
6774 let both = RuntimeManifest::from_yaml_str(
6775 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n max_messages: 2000\n max_age: 90d\n",
6776 )
6777 .unwrap();
6778 assert_eq!(
6779 both.context.unwrap().retain,
6780 Some(ContextRetainConfig {
6781 max_messages: Some(2000),
6782 max_age_secs: Some(90 * 86_400),
6783 })
6784 );
6785
6786 let count_only = RuntimeManifest::from_yaml_str(
6787 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 10\n",
6788 )
6789 .unwrap();
6790 assert_eq!(
6791 count_only.context.unwrap().retain,
6792 Some(ContextRetainConfig {
6793 max_messages: Some(10),
6794 max_age_secs: None,
6795 })
6796 );
6797 }
6798
6799 #[test]
6802 fn an_empty_retain_block_is_refused_naming_the_block() {
6803 for (yaml, expect_trace) in [
6804 (
6805 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain: {}\n",
6806 true,
6807 ),
6808 (
6809 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n",
6810 true,
6811 ),
6812 (
6813 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain: {}\n",
6814 false,
6815 ),
6816 (
6817 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n",
6818 false,
6819 ),
6820 ] {
6821 let err = RuntimeManifest::from_yaml_str(yaml)
6822 .expect_err("an empty retain block must be refused");
6823 let (field, message) = match (&err, expect_trace) {
6824 (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
6825 (field, message)
6826 }
6827 (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
6828 (field, message)
6829 }
6830 _ => panic!("wrong variant for {yaml:?}: {err:?}"),
6831 };
6832 assert_eq!(
6833 field,
6834 if expect_trace {
6835 "trace.retain"
6836 } else {
6837 "context.retain"
6838 }
6839 );
6840 assert!(message.contains("at least one key"), "{message}");
6841 }
6842 }
6843
6844 #[test]
6847 fn a_zero_retain_limit_is_refused_naming_the_key() {
6848 let trace = RuntimeManifest::from_yaml_str(
6849 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 0\n",
6850 )
6851 .expect_err("max_sessions: 0 must be refused");
6852 assert!(
6853 matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
6854 if field == "trace.retain.max_sessions"),
6855 "{trace:?}"
6856 );
6857
6858 let context = RuntimeManifest::from_yaml_str(
6859 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 0\n",
6860 )
6861 .expect_err("max_messages: 0 must be refused");
6862 assert!(
6863 matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
6864 if field == "context.retain.max_messages"),
6865 "{context:?}"
6866 );
6867
6868 let age = RuntimeManifest::from_yaml_str(
6869 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_age: 0\n",
6870 )
6871 .expect_err("max_age: 0 must be refused");
6872 assert!(
6873 matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
6874 if field == "trace.retain.max_age"),
6875 "{age:?}"
6876 );
6877 }
6878
6879 #[test]
6880 fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
6881 let err = RuntimeManifest::from_yaml_str(
6882 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_traces: 3\n",
6883 )
6884 .expect_err("an unknown key must be refused");
6885 match err {
6886 RuntimeManifestError::InvalidTraceConfig { field, message } => {
6887 assert_eq!(field, "trace.retain");
6888 assert!(message.contains("max_traces"), "{message}");
6889 assert!(message.contains("max_sessions"), "{message}");
6890 }
6891 other => panic!("expected InvalidTraceConfig, got {other:?}"),
6892 }
6893 }
6894
6895 #[test]
6896 fn an_unparseable_retain_duration_reports_the_accepted_form() {
6897 let err = RuntimeManifest::from_yaml_str(
6898 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_age: 5 weeks\n",
6899 )
6900 .expect_err("'5 weeks' must be refused");
6901 match err {
6902 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
6903 assert_eq!(field, "context.retain.max_age");
6904 assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
6905 }
6906 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
6907 }
6908 }
6909
6910 #[test]
6911 fn unparseable_capture_value_names_the_field_and_accepted_values() {
6912 let err = RuntimeManifest::from_yaml_str(&manifest_with_trace(" capture: verbose\n"))
6913 .expect_err("'verbose' is not a capture mode");
6914 let rendered = err.to_string();
6915 assert!(rendered.contains("trace.capture"), "{rendered}");
6916 for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
6917 assert!(rendered.contains(accepted), "{rendered}");
6918 }
6919 }
6920
6921 #[test]
6922 fn resources_block_is_optional_and_absent_stays_absent() {
6923 let manifest = RuntimeManifest::from_yaml_str(
6924 r#"
6925name: cap
6926version: 0.0.1
6927capabilities:
6928 shell:
6929 allow: [bash]
6930"#,
6931 )
6932 .unwrap();
6933
6934 let caps = manifest.capabilities.expect("shell block must parse");
6938 assert_eq!(caps.resources, None);
6939 }
6940
6941 #[test]
6942 fn resources_fields_parse_and_each_stays_independently_optional() {
6943 let manifest = RuntimeManifest::from_yaml_str(
6944 r#"
6945name: cap
6946version: 0.0.1
6947capabilities:
6948 resources:
6949 max_processes: 32
6950 max_open_files: 16
6951 max_file_size_bytes: 1048576
6952 cpu_seconds: 30
6953 memory_bytes: 268435456
6954 cgroup_memory_bytes: 536870912
6955 cgroup_pids_max: 64
6956 cgroup_cpu_percent: 50
6957 cgroup_io_bytes_per_sec: 1048576
6958 workdir_max_bytes: 2097152
6959"#,
6960 )
6961 .unwrap();
6962
6963 let resources = manifest
6964 .capabilities
6965 .and_then(|caps| caps.resources)
6966 .expect("resources block must parse");
6967 assert_eq!(resources.max_processes, Some(32));
6968 assert_eq!(resources.max_open_files, Some(16));
6969 assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
6970 assert_eq!(resources.cpu_seconds, Some(30));
6971 assert_eq!(resources.memory_bytes, Some(268_435_456));
6972 assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
6973 assert_eq!(resources.cgroup_pids_max, Some(64));
6974 assert_eq!(resources.cgroup_cpu_percent, Some(50));
6975 assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
6976 assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
6977 }
6978
6979 #[test]
6980 fn state_block_is_optional_and_absent_stays_absent() {
6981 let manifest = RuntimeManifest::from_yaml_str(
6982 r#"
6983name: cap
6984version: 0.0.1
6985artifacts:
6986 - name: local/notes-tool
6987 version: 1.0.0
6988 runtime: tool
6989 capabilities:
6990 filesystem:
6991 scope: cache
6992"#,
6993 )
6994 .unwrap();
6995
6996 let caps = manifest.artifacts[0]
6998 .capabilities
6999 .as_ref()
7000 .expect("filesystem block must parse");
7001 assert_eq!(caps.state, None);
7002 }
7003
7004 #[test]
7008 fn state_block_declared_empty_grants_the_capsule_named_store() {
7009 let manifest = RuntimeManifest::from_yaml_str(
7010 r#"
7011name: cap
7012version: 0.0.1
7013artifacts:
7014 - name: local/notes-tool
7015 version: 1.0.0
7016 runtime: tool
7017 capabilities:
7018 state: {}
7019"#,
7020 )
7021 .unwrap();
7022
7023 let state = manifest.artifacts[0]
7024 .capabilities
7025 .as_ref()
7026 .and_then(|caps| caps.state.as_ref())
7027 .expect("state block must parse");
7028 assert_eq!(state.store, None);
7029 }
7030
7031 #[test]
7032 fn state_store_name_parses_and_stays_independently_optional() {
7033 let manifest = RuntimeManifest::from_yaml_str(
7034 r#"
7035name: cap
7036version: 0.0.1
7037artifacts:
7038 - name: local/notes-tool
7039 version: 1.0.0
7040 runtime: tool
7041 capabilities:
7042 state:
7043 store: " shey "
7044 network:
7045 allow: [https://api.example.com]
7046"#,
7047 )
7048 .unwrap();
7049
7050 let caps = manifest.artifacts[0]
7051 .capabilities
7052 .as_ref()
7053 .expect("capabilities must parse");
7054 assert_eq!(
7057 caps.state.as_ref().and_then(|state| state.store.as_deref()),
7058 Some("shey")
7059 );
7060 assert_eq!(
7062 caps.network
7063 .as_ref()
7064 .map(|network| network.allow.as_slice()),
7065 Some(["https://api.example.com".to_string()].as_slice())
7066 );
7067 assert_eq!(caps.filesystem, None);
7068 }
7069
7070 #[test]
7074 fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
7075 let manifest = RuntimeManifest::from_yaml_str(
7076 r#"
7077name: cap
7078version: 0.0.1
7079artifacts:
7080 - name: local/notes-tool
7081 version: 1.0.0
7082 runtime: tool
7083 capabilities:
7084 state:
7085 store: ""
7086"#,
7087 )
7088 .unwrap();
7089
7090 assert_eq!(
7091 manifest.artifacts[0]
7092 .capabilities
7093 .as_ref()
7094 .and_then(|caps| caps.state.as_ref())
7095 .and_then(|state| state.store.as_deref()),
7096 Some("")
7097 );
7098 }
7099
7100 #[test]
7101 fn resources_partial_block_leaves_undeclared_fields_none() {
7102 let manifest = RuntimeManifest::from_yaml_str(
7103 r#"
7104name: cap
7105version: 0.0.1
7106capabilities:
7107 resources:
7108 max_open_files: 16
7109"#,
7110 )
7111 .unwrap();
7112
7113 let resources = manifest
7114 .capabilities
7115 .and_then(|caps| caps.resources)
7116 .expect("resources block must parse");
7117 assert_eq!(resources.max_open_files, Some(16));
7118 assert_eq!(resources.max_processes, None);
7119 assert_eq!(resources.workdir_max_bytes, None);
7120 }
7121
7122 #[test]
7126 fn resources_zero_is_rejected_on_every_field() {
7127 for field in [
7128 "max_processes",
7129 "max_open_files",
7130 "max_file_size_bytes",
7131 "cpu_seconds",
7132 "memory_bytes",
7133 "cgroup_memory_bytes",
7134 "cgroup_pids_max",
7135 "cgroup_cpu_percent",
7136 "cgroup_io_bytes_per_sec",
7137 "workdir_max_bytes",
7138 ] {
7139 let yaml =
7140 format!("name: cap\nversion: 0.0.1\ncapabilities:\n resources:\n {field}: 0\n");
7141 let error =
7142 RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
7143
7144 match error {
7145 RuntimeManifestError::InvalidCapabilities {
7146 field: reported,
7147 message,
7148 } => {
7149 assert_eq!(reported, format!("capabilities.resources.{field}"));
7150 assert_eq!(message, "must be greater than zero");
7151 }
7152 other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
7153 }
7154 }
7155 }
7156
7157 #[test]
7158 fn artifact_runtime_defaults_to_tool() {
7159 let manifest = RuntimeManifest::from_yaml_str(
7160 r#"
7161name: cap
7162version: 0.0.1
7163artifacts:
7164 - name: local/echo
7165 version: 1.2.3
7166"#,
7167 )
7168 .unwrap();
7169
7170 assert_eq!(manifest.artifacts.len(), 1);
7171 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7172 }
7173
7174 #[test]
7175 fn artifact_runtime_tool_explicit() {
7176 let manifest = RuntimeManifest::from_yaml_str(
7177 r#"
7178name: cap
7179version: 0.0.1
7180artifacts:
7181 - name: local/echo
7182 version: 1.2.3
7183 runtime: tool
7184"#,
7185 )
7186 .unwrap();
7187
7188 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7189 }
7190
7191 #[test]
7192 fn artifact_runtime_wasm_is_error() {
7193 let err = RuntimeManifest::from_yaml_str(
7194 r#"
7195name: cap
7196version: 0.0.1
7197artifacts:
7198 - name: local/echo
7199 version: 1.2.3
7200 runtime: wasm
7201"#,
7202 )
7203 .unwrap_err();
7204
7205 let msg = err.to_string();
7206 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7207 assert!(msg.contains("wasm"), "error was: {msg}");
7208 }
7209
7210 #[test]
7211 fn artifact_runtime_native_is_error() {
7212 let err = RuntimeManifest::from_yaml_str(
7213 r#"
7214name: cap
7215version: 0.0.1
7216artifacts:
7217 - name: local/echo
7218 version: 1.2.3
7219 runtime: native
7220"#,
7221 )
7222 .unwrap_err();
7223
7224 let msg = err.to_string();
7225 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7226 assert!(msg.contains("native"), "error was: {msg}");
7227 }
7228
7229 #[test]
7230 fn invalid_artifact_reports_index() {
7231 let err = RuntimeManifest::from_yaml_str(
7232 r#"
7233name: cap
7234version: 0.0.1
7235artifacts:
7236 - name: ""
7237 version: 1.2.3
7238"#,
7239 )
7240 .unwrap_err();
7241
7242 let msg = err.to_string();
7243 assert!(msg.contains("index 0"));
7244 assert!(msg.contains("missing required field 'name'"));
7245 }
7246
7247 #[test]
7248 fn capabilities_parse_when_all_fields_present() {
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 network:
7258 allow:
7259 - https://api.anthropic.com
7260 - http://127.0.0.1:8080
7261 filesystem:
7262 scope: ./sandbox
7263"#,
7264 )
7265 .unwrap();
7266
7267 let capabilities = manifest.capabilities.expect("capabilities should exist");
7268 assert_eq!(
7269 capabilities.network.unwrap().allow,
7270 vec![
7271 "https://api.anthropic.com".to_string(),
7272 "http://127.0.0.1:8080".to_string()
7273 ]
7274 );
7275 assert_eq!(
7276 capabilities.filesystem.unwrap().scope,
7277 Some("./sandbox".to_string())
7278 );
7279 }
7280
7281 #[test]
7282 fn env_capabilities_parse_allowlist() {
7283 let manifest = RuntimeManifest::from_yaml_str(
7284 r#"
7285name: cap
7286version: 0.0.1
7287artifacts:
7288 - name: local/echo
7289 version: 1.2.3
7290capabilities:
7291 env:
7292 allow:
7293 - MY_APP_REGION
7294 - GITHUB_TOKEN
7295"#,
7296 )
7297 .unwrap();
7298
7299 let capabilities = manifest.capabilities.expect("capabilities should exist");
7300 assert_eq!(
7301 capabilities.env.unwrap().allow,
7302 vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
7303 );
7304 }
7305
7306 #[test]
7307 fn env_capabilities_absent_when_block_omitted() {
7308 let manifest = RuntimeManifest::from_yaml_str(
7309 r#"
7310name: cap
7311version: 0.0.1
7312artifacts:
7313 - name: local/echo
7314 version: 1.2.3
7315capabilities:
7316 network:
7317 allow:
7318 - https://api.anthropic.com
7319"#,
7320 )
7321 .unwrap();
7322
7323 assert!(manifest.capabilities.unwrap().env.is_none());
7324 }
7325
7326 #[test]
7329 fn env_capabilities_allow_empty_list_is_accepted() {
7330 let manifest = RuntimeManifest::from_yaml_str(
7331 r#"
7332name: cap
7333version: 0.0.1
7334artifacts:
7335 - name: local/echo
7336 version: 1.2.3
7337capabilities:
7338 env: {}
7339"#,
7340 )
7341 .unwrap();
7342
7343 assert_eq!(
7344 manifest.capabilities.unwrap().env,
7345 Some(EnvCapabilities { allow: vec![] })
7346 );
7347 }
7348
7349 #[test]
7350 fn missing_capabilities_block_is_backward_compatible() {
7351 let manifest = RuntimeManifest::from_yaml_str(
7352 r#"
7353name: cap
7354version: 0.0.1
7355artifacts:
7356 - name: local/echo
7357 version: 1.2.3
7358"#,
7359 )
7360 .unwrap();
7361
7362 assert!(manifest.capabilities.is_none());
7363 }
7364
7365 #[test]
7366 fn network_only_capabilities_parse() {
7367 let manifest = RuntimeManifest::from_yaml_str(
7368 r#"
7369name: cap
7370version: 0.0.1
7371artifacts:
7372 - name: local/echo
7373 version: 1.2.3
7374capabilities:
7375 network:
7376 allow:
7377 - https://allowed.example.com
7378"#,
7379 )
7380 .unwrap();
7381
7382 let capabilities = manifest.capabilities.unwrap();
7383 assert_eq!(
7384 capabilities.network.unwrap().allow,
7385 vec!["https://allowed.example.com".to_string()]
7386 );
7387 assert!(capabilities.filesystem.is_none());
7388 }
7389
7390 #[test]
7391 fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
7392 let manifest = RuntimeManifest::from_yaml_str(
7393 r#"
7394name: cap
7395version: 0.0.1
7396artifacts:
7397 - name: local/echo
7398 version: 1.2.3
7399capabilities:
7400 network: {}
7401"#,
7402 )
7403 .unwrap();
7404
7405 let capabilities = manifest.capabilities.unwrap();
7406 assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
7407 }
7408
7409 #[test]
7412 fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
7413 let manifest = RuntimeManifest::from_yaml_str(
7414 r#"
7415name: cap
7416version: 0.0.1
7417artifacts:
7418 - name: local/echo
7419 version: 1.2.3
7420capabilities:
7421 network:
7422 allow:
7423 - https://api.anthropic.com
7424"#,
7425 )
7426 .unwrap();
7427
7428 let network = manifest.capabilities.unwrap().network.unwrap();
7429 assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
7430 assert!(!network.unix_sockets);
7431 }
7432
7433 #[test]
7434 fn network_unix_sockets_parses_explicit_false() {
7435 let manifest = RuntimeManifest::from_yaml_str(
7436 r#"
7437name: cap
7438version: 0.0.1
7439artifacts:
7440 - name: local/echo
7441 version: 1.2.3
7442capabilities:
7443 network:
7444 unix_sockets: false
7445"#,
7446 )
7447 .unwrap();
7448
7449 assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
7450 }
7451
7452 #[test]
7453 fn network_unix_sockets_parses_explicit_true() {
7454 let manifest = RuntimeManifest::from_yaml_str(
7455 r#"
7456name: cap
7457version: 0.0.1
7458artifacts:
7459 - name: local/echo
7460 version: 1.2.3
7461capabilities:
7462 network:
7463 unix_sockets: true
7464"#,
7465 )
7466 .unwrap();
7467
7468 let network = manifest.capabilities.unwrap().network.unwrap();
7469 assert!(network.unix_sockets);
7470 assert_eq!(network.allow, Vec::<String>::new());
7472 }
7473
7474 #[test]
7478 fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
7479 let manifest = RuntimeManifest::from_yaml_str(
7480 r#"
7481name: cap
7482version: 0.0.1
7483capabilities:
7484 filesystem:
7485 read_only:
7486 - tests
7487 - " bench/fixtures "
7488 - ""
7489 - " "
7490"#,
7491 )
7492 .unwrap();
7493
7494 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7495 assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
7496 }
7497
7498 #[test]
7500 fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
7501 let manifest = RuntimeManifest::from_yaml_str(
7502 r#"
7503name: cap
7504version: 0.0.1
7505capabilities:
7506 filesystem:
7507 scope: workdir
7508"#,
7509 )
7510 .unwrap();
7511
7512 assert!(manifest
7513 .capabilities
7514 .unwrap()
7515 .filesystem
7516 .unwrap()
7517 .read_only
7518 .is_empty());
7519 }
7520
7521 #[test]
7524 fn filesystem_read_only_does_not_judge_path_shape() {
7525 let manifest = RuntimeManifest::from_yaml_str(
7526 r#"
7527name: cap
7528version: 0.0.1
7529capabilities:
7530 filesystem:
7531 read_only:
7532 - /etc
7533 - ../outside
7534"#,
7535 )
7536 .expect("shape is judged by the runtime, not the parser");
7537
7538 assert_eq!(
7539 manifest.capabilities.unwrap().filesystem.unwrap().read_only,
7540 vec!["/etc", "../outside"]
7541 );
7542 }
7543
7544 #[test]
7548 fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
7549 let manifest = RuntimeManifest::from_yaml_str(
7550 r#"
7551name: cap
7552version: 0.0.1
7553artifacts:
7554 - name: local/echo
7555 version: 1.2.3
7556capabilities:
7557 filesystem:
7558 scope: workdir
7559"#,
7560 )
7561 .unwrap();
7562
7563 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7564 assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
7565 assert!(!filesystem.workdir_exec);
7566 }
7567
7568 #[test]
7573 fn filesystem_block_stays_absent_when_undeclared() {
7574 let manifest = RuntimeManifest::from_yaml_str(
7575 r#"
7576name: cap
7577version: 0.0.1
7578artifacts:
7579 - name: local/echo
7580 version: 1.2.3
7581capabilities:
7582 network:
7583 allow:
7584 - https://api.anthropic.com
7585"#,
7586 )
7587 .unwrap();
7588
7589 assert!(manifest.capabilities.unwrap().filesystem.is_none());
7590 }
7591
7592 #[test]
7593 fn filesystem_workdir_exec_parses_explicit_false() {
7594 let manifest = RuntimeManifest::from_yaml_str(
7595 r#"
7596name: cap
7597version: 0.0.1
7598artifacts:
7599 - name: local/echo
7600 version: 1.2.3
7601capabilities:
7602 filesystem:
7603 workdir_exec: false
7604"#,
7605 )
7606 .unwrap();
7607
7608 assert!(
7609 !manifest
7610 .capabilities
7611 .unwrap()
7612 .filesystem
7613 .unwrap()
7614 .workdir_exec
7615 );
7616 }
7617
7618 #[test]
7619 fn filesystem_workdir_exec_parses_explicit_true() {
7620 let manifest = RuntimeManifest::from_yaml_str(
7621 r#"
7622name: cap
7623version: 0.0.1
7624artifacts:
7625 - name: local/echo
7626 version: 1.2.3
7627capabilities:
7628 filesystem:
7629 workdir_exec: true
7630"#,
7631 )
7632 .unwrap();
7633
7634 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7635 assert!(filesystem.workdir_exec);
7636 assert_eq!(filesystem.scope, None);
7639 }
7640
7641 #[test]
7642 fn network_internal_port_parses() {
7643 let manifest = RuntimeManifest::from_yaml_str(
7644 r#"
7645name: worker
7646version: 0.1.0
7647artifacts: []
7648
7649network:
7650 internal_port: 58172
7651"#,
7652 )
7653 .unwrap();
7654
7655 assert_eq!(
7656 manifest.network.unwrap().internal_port,
7657 Some(58172),
7658 "network.internal_port should parse to Some(58172)"
7659 );
7660 }
7661
7662 #[test]
7663 fn network_internal_port_absent_when_omitted() {
7664 let manifest = RuntimeManifest::from_yaml_str(
7665 r#"
7666name: worker
7667version: 0.1.0
7668artifacts: []
7669"#,
7670 )
7671 .unwrap();
7672
7673 assert!(manifest.network.is_none());
7674 }
7675
7676 #[test]
7677 fn shell_capabilities_parse_with_allow_and_env_fields() {
7678 let manifest = RuntimeManifest::from_yaml_str(
7679 r#"
7680name: cap
7681version: 0.0.1
7682artifacts: []
7683capabilities:
7684 shell:
7685 allow:
7686 - bash
7687 - git
7688 strip_env:
7689 - "*_TOKEN"
7690 - SECRET_KEY
7691 baseline_env:
7692 - SSH_AUTH_SOCK
7693"#,
7694 )
7695 .unwrap();
7696
7697 let shell = manifest
7698 .capabilities
7699 .unwrap()
7700 .shell
7701 .expect("shell capabilities should be present");
7702 assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
7703 assert_eq!(
7704 shell.strip_env,
7705 Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
7706 );
7707 assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
7708 }
7709
7710 #[test]
7711 fn shell_capabilities_require_non_empty_allowlist() {
7712 let err = RuntimeManifest::from_yaml_str(
7713 r#"
7714name: cap
7715version: 0.0.1
7716artifacts: []
7717capabilities:
7718 shell:
7719 allow: []
7720"#,
7721 )
7722 .unwrap_err();
7723
7724 let msg = err.to_string();
7725 assert!(msg.contains("capabilities.shell.allow"));
7726 assert!(msg.contains("at least one"));
7727 }
7728
7729 fn shell_allow_error(entry: &str) -> String {
7730 let yaml = format!(
7731 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n shell:\n allow: [bash, {}]\n",
7732 serde_json::to_string(entry).unwrap()
7733 );
7734 RuntimeManifest::from_yaml_str(&yaml)
7735 .expect_err(&format!("{entry:?} must be refused"))
7736 .to_string()
7737 }
7738
7739 #[test]
7740 fn shell_allow_refuses_an_entry_that_is_not_a_bare_name() {
7741 for (entry, reason) in [
7742 ("/tmp/uv-tool/bin/uv", "is a path"),
7743 ("bin/uv", "is a path"),
7744 ("..", "names a directory"),
7745 (".", "names a directory"),
7746 ("", "is empty"),
7747 ("a b", "contains whitespace"),
7748 ("uv\t", "contains whitespace"),
7749 ("uv\0", "contains a NUL byte"),
7750 ] {
7751 let message = shell_allow_error(entry);
7752 assert!(
7753 message.contains("'capabilities.shell.allow[1]'"),
7754 "{entry:?}: {message}"
7755 );
7756 assert!(
7757 message.contains(&format!(
7758 "'{}' {reason}; an entry is a bare binary name",
7759 entry.escape_debug()
7760 )),
7761 "{entry:?}: {message}"
7762 );
7763 }
7764 }
7765
7766 #[test]
7767 fn shell_allow_accepts_bare_names() {
7768 let manifest = RuntimeManifest::from_yaml_str(
7769 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n shell:\n allow: [bash, python3.12, git-lfs, \"c++\"]\n",
7770 )
7771 .unwrap();
7772 assert_eq!(
7773 manifest.capabilities.unwrap().shell.unwrap().allow,
7774 ["bash", "python3.12", "git-lfs", "c++"]
7775 );
7776 }
7777
7778 #[test]
7779 fn interpreter_runtime_parses_accepted_shape() {
7780 let manifest = RuntimeManifest::from_yaml_str(
7781 r#"
7782name: cap
7783version: 0.0.1
7784artifacts: []
7785capabilities:
7786 shell:
7787 allow:
7788 - python3
7789 interpreter_runtime:
7790 - binary: python3
7791 dirs:
7792 - path: /usr/lib/python3.11
7793 list_dir: true
7794 - path: /usr/lib/python3.11/lib-dynload
7795 list_dir: false
7796"#,
7797 )
7798 .unwrap();
7799
7800 let shell = manifest.capabilities.unwrap().shell.unwrap();
7801 assert_eq!(
7802 shell.interpreter_runtime,
7803 vec![InterpreterRuntimeGrant {
7804 binary: "python3".to_string(),
7805 dirs: vec![
7806 InterpreterRuntimeDir {
7807 path: "/usr/lib/python3.11".to_string(),
7808 list_dir: true,
7809 },
7810 InterpreterRuntimeDir {
7811 path: "/usr/lib/python3.11/lib-dynload".to_string(),
7812 list_dir: false,
7813 },
7814 ],
7815 }]
7816 );
7817 }
7818
7819 #[test]
7820 fn interpreter_runtime_defaults_to_empty_when_absent() {
7821 let manifest = RuntimeManifest::from_yaml_str(
7822 r#"
7823name: cap
7824version: 0.0.1
7825artifacts: []
7826capabilities:
7827 shell:
7828 allow:
7829 - python3
7830"#,
7831 )
7832 .unwrap();
7833
7834 let shell = manifest.capabilities.unwrap().shell.unwrap();
7835 assert!(shell.interpreter_runtime.is_empty());
7836 }
7837
7838 fn interpreter_runtime_reject(grant_yaml: &str) -> String {
7841 let yaml = format!(
7842 r#"
7843name: cap
7844version: 0.0.1
7845artifacts: []
7846capabilities:
7847 shell:
7848 allow:
7849 - python3
7850 interpreter_runtime:
7851{grant_yaml}
7852"#,
7853 );
7854 RuntimeManifest::from_yaml_str(&yaml)
7855 .expect_err("grant should be rejected")
7856 .to_string()
7857 }
7858
7859 #[test]
7860 fn interpreter_runtime_rejects_binary_not_in_allow() {
7861 let msg = interpreter_runtime_reject(
7862 " - binary: ruby\n dirs:\n - path: /usr/lib/ruby\n list_dir: true\n",
7863 );
7864 assert!(
7865 msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
7866 "{msg}"
7867 );
7868 assert!(msg.contains("ruby"), "{msg}");
7869 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
7870 }
7871
7872 #[test]
7873 fn interpreter_runtime_rejects_relative_path() {
7874 let msg = interpreter_runtime_reject(
7875 " - binary: python3\n dirs:\n - path: usr/lib/python3.11\n list_dir: true\n",
7876 );
7877 assert!(
7878 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
7879 "{msg}"
7880 );
7881 assert!(msg.contains("absolute"), "{msg}");
7882 }
7883
7884 fn manifest_with_plan(block: &str) -> String {
7888 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n{block}")
7889 }
7890
7891 #[test]
7892 fn plan_submit_true_grants_the_capsule() {
7893 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n"))
7894 .expect("a plan block parses");
7895 assert_eq!(
7896 manifest.capabilities.unwrap().plan,
7897 Some(PlanCapabilities { submit: true })
7898 );
7899 }
7900
7901 #[test]
7904 fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
7905 let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: false\n"))
7906 .expect("an explicit denial parses");
7907 assert_eq!(
7908 explicit.capabilities.unwrap().plan,
7909 Some(PlanCapabilities { submit: false })
7910 );
7911
7912 let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
7913 .expect("no capabilities block parses");
7914 assert!(absent.capabilities.is_none());
7915 }
7916
7917 #[test]
7920 fn plan_without_submit_is_rejected() {
7921 let err = RuntimeManifest::from_yaml_str(&manifest_with_plan(" {}\n"))
7922 .expect_err("an empty plan block is refused");
7923 let msg = err.to_string();
7924 assert!(msg.contains("capabilities.plan.submit"), "{msg}");
7925 assert!(msg.contains("never"), "{msg}");
7926 }
7927
7928 #[test]
7930 fn an_unknown_plan_key_is_reported_by_path() {
7931 let manifest =
7932 RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n depth: 3\n"))
7933 .expect("an unknown key is a warning, not a parse error");
7934 assert!(
7935 manifest
7936 .unknown_keys
7937 .iter()
7938 .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
7939 "{:?}",
7940 manifest.unknown_keys
7941 );
7942 }
7943
7944 fn manifest_with_install(block: &str) -> String {
7948 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n install:\n{block}")
7949 }
7950
7951 #[test]
7952 fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
7953 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
7954 " skill: [ana/code-review, \"ana/style-*\"]\n tool: [\"local/b-*\", local/a, \"murmur/*\", \"*\"]\n",
7955 ))
7956 .expect("an install block parses");
7957 assert_eq!(
7958 manifest.capabilities.unwrap().install,
7959 Some(InstallCapabilities {
7960 skill: vec!["ana/code-review".into(), "ana/style-*".into()],
7961 tool: vec![
7962 "local/b-*".into(),
7963 "local/a".into(),
7964 "murmur/*".into(),
7965 "*".into()
7966 ],
7967 from: InstallFrom::Stores,
7968 })
7969 );
7970 }
7971
7972 #[test]
7975 fn capabilities_install_accepts_either_list_alone() {
7976 let skill_only =
7977 RuntimeManifest::from_yaml_str(&manifest_with_install(" skill: [local/a]\n"))
7978 .expect("skill alone parses");
7979 assert_eq!(
7980 skill_only.capabilities.unwrap().install,
7981 Some(InstallCapabilities {
7982 skill: vec!["local/a".into()],
7983 tool: vec![],
7984 from: InstallFrom::Stores,
7985 })
7986 );
7987 let tool_only =
7988 RuntimeManifest::from_yaml_str(&manifest_with_install(" tool: [\"local/b-*\"]\n"))
7989 .expect("tool alone parses");
7990 assert_eq!(
7991 tool_only.capabilities.unwrap().install,
7992 Some(InstallCapabilities {
7993 skill: vec![],
7994 tool: vec!["local/b-*".into()],
7995 from: InstallFrom::Stores,
7996 })
7997 );
7998 }
7999
8000 #[test]
8001 fn capabilities_install_absent_is_none() {
8002 let manifest = RuntimeManifest::from_yaml_str(
8003 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n submit: true\n",
8004 )
8005 .expect("a capabilities block without install parses");
8006 assert_eq!(manifest.capabilities.unwrap().install, None);
8007 }
8008
8009 fn install_from(list: &str) -> Result<InstallFrom, RuntimeManifestError> {
8010 RuntimeManifest::from_yaml_str(&manifest_with_install(&format!(
8011 " tool: [\"murmur/tool-*\"]\n from: {list}\n"
8012 )))
8013 .map(|manifest| manifest.capabilities.unwrap().install.unwrap().from)
8014 }
8015
8016 #[test]
8017 fn install_from_absent_is_stores() {
8018 let manifest =
8019 RuntimeManifest::from_yaml_str(&manifest_with_install(" tool: [local/a]\n"))
8020 .unwrap();
8021 assert_eq!(
8022 manifest.capabilities.unwrap().install.unwrap().from,
8023 InstallFrom::Stores
8024 );
8025 }
8026
8027 #[test]
8028 fn install_from_accepts_stores_alone_and_with_sources_in_either_order() {
8029 assert_eq!(install_from("[stores]").unwrap(), InstallFrom::Stores);
8030 assert_eq!(
8031 install_from("[stores, sources]").unwrap(),
8032 InstallFrom::StoresAndSources
8033 );
8034 assert_eq!(
8035 install_from("[sources, stores]").unwrap(),
8036 InstallFrom::StoresAndSources
8037 );
8038 assert_eq!(InstallFrom::StoresAndSources.as_list(), "stores, sources");
8039 assert!(InstallFrom::StoresAndSources.sources());
8040 assert!(!InstallFrom::Stores.sources());
8041 }
8042
8043 #[test]
8044 fn install_from_refuses_empty_sourceless_duplicate_and_unknown_lists() {
8045 for (list, why) in [
8046 ("[]", "is empty"),
8047 ("[sources]", "does not name stores"),
8048 ("[stores, stores]", "names \"stores\" twice"),
8049 ("[github]", "names \"github\", which is not a place"),
8050 ] {
8051 match install_from(list) {
8052 Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8053 assert_eq!(field, "capabilities.install.from", "{list}");
8054 assert!(message.contains(why), "{list}: {message}");
8055 assert!(
8056 message.contains("expected [stores] or [stores, sources]"),
8057 "{list}: {message}"
8058 );
8059 }
8060 other => panic!("{list}: expected InvalidCapabilities, got {other:?}"),
8061 }
8062 }
8063 }
8064
8065 #[test]
8066 fn install_from_is_no_unknown_key() {
8067 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8068 " tool: [local/a]\n from: [stores]\n",
8069 ))
8070 .unwrap();
8071 assert!(
8072 manifest
8073 .unknown_keys
8074 .iter()
8075 .all(|key| key.block_path != "capabilities.install"),
8076 "{:?}",
8077 manifest.unknown_keys
8078 );
8079 }
8080
8081 #[test]
8082 fn install_from_capabilities_install_is_refused_under_transport_process() {
8083 let yaml = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n install:\n \
8084 tool: [\"murmur/tool-*\"]\ninference:\n transport: process\n \
8085 command: claude\n model: claude-haiku-4-5-20251001\n driver:\n \
8086 artifact: murmur/driver-claude-code\n";
8087 match RuntimeManifest::from_yaml_str(yaml) {
8088 Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8089 assert_eq!(field, "capabilities.install");
8090 assert_eq!(
8091 message,
8092 "is not valid with transport: process; the harness owns its tool list, so \
8093 a pulled artifact could never reach it"
8094 );
8095 }
8096 other => panic!("expected InvalidCapabilities, got {other:?}"),
8097 }
8098 }
8099
8100 #[test]
8103 fn capabilities_install_that_names_nothing_is_refused() {
8104 for block in [
8105 " {}\n",
8106 " skill: []\n",
8107 " skill: []\n tool: []\n",
8108 " driver: [x]\n",
8109 ] {
8110 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
8111 .expect_err("an install block naming nothing is refused");
8112 match &err {
8113 RuntimeManifestError::InvalidCapabilities { field, message } => {
8114 assert_eq!(field, "capabilities.install", "{block:?}");
8115 assert!(message.contains("at least one"), "{message}");
8116 assert!(message.contains("omit the block"), "{message}");
8117 }
8118 other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
8119 }
8120 }
8121 }
8122
8123 #[test]
8124 fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
8125 for entry in [
8126 "a*b", "*x", "**", "Foo", "-a", "a/b/c", " ", "*/x", "a/*x", "a/x*y", "A/x",
8127 ] {
8128 for key in ["skill", "tool"] {
8129 let block = format!(" {key}: [{entry:?}]\n");
8130 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8131 .expect_err("a bad entry is refused");
8132 match &err {
8133 RuntimeManifestError::InvalidArtifactName {
8134 field,
8135 message,
8136 hint,
8137 } => {
8138 assert_eq!(field, &format!("capabilities.install.{key}[0]"));
8139 assert!(message.contains(&format!("{entry:?}")), "{message}");
8140 assert!(
8141 message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
8142 "{message}"
8143 );
8144 assert_eq!(hint, &None, "{entry:?}");
8145 }
8146 other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8147 }
8148 }
8149 }
8150 }
8151
8152 #[test]
8154 fn capabilities_install_refuses_an_unscoped_entry_with_the_scoped_hint() {
8155 for (entry, scoped) in [
8156 ("tool-*", "local/tool-*"),
8157 ("mur*", "local/mur*"),
8158 ("tool-x", "local/tool-x"),
8159 ("murmur-tool-editor", "murmur/tool-editor"),
8160 ] {
8161 let block = format!(" tool: [\"local/ok\", {entry:?}]\n");
8162 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8163 .expect_err("an unscoped entry is refused");
8164 match &err {
8165 RuntimeManifestError::InvalidArtifactName {
8166 field,
8167 message,
8168 hint,
8169 } => {
8170 assert_eq!(field, "capabilities.install.tool[1]");
8171 assert_eq!(
8172 message,
8173 &format!(
8174 "entry {entry:?} is invalid ('{entry}' names no scope); expected {}",
8175 crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
8176 )
8177 );
8178 let hint = hint.as_deref().expect("an unscoped entry carries a hint");
8179 assert!(hint.contains(scoped), "{entry:?}: {hint}");
8180 }
8181 other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8182 }
8183 assert!(
8184 err.to_string().contains("'capabilities.install.tool[1]'"),
8185 "{err}"
8186 );
8187 }
8188 }
8189
8190 #[test]
8192 fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
8193 for runtime in ["tool", "hook", "driver", "skill"] {
8194 let yaml = format!(
8195 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/a\n version: 0.1.0\n \
8196 runtime: {runtime}\n capabilities:\n install:\n skill: [x]\n"
8197 );
8198 let err = RuntimeManifest::from_yaml_str(&yaml)
8199 .expect_err("a per-artifact install block is refused");
8200 match &err {
8201 RuntimeManifestError::InvalidArtifact { index, message } => {
8202 assert_eq!(*index, 0);
8203 assert!(message.contains("capabilities.install"), "{message}");
8204 assert!(message.contains("capsule-wide"), "{message}");
8205 }
8206 other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
8207 }
8208 }
8209 }
8210
8211 #[test]
8213 fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
8214 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8215 " skill: [local/a]\n driver: [x]\n",
8216 ))
8217 .expect("an unknown key beside a real entry is a warning, not a parse error");
8218 assert!(
8219 manifest
8220 .unknown_keys
8221 .iter()
8222 .any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
8223 "{:?}",
8224 manifest.unknown_keys
8225 );
8226 assert_eq!(
8227 manifest.capabilities.unwrap().install,
8228 Some(InstallCapabilities {
8229 skill: vec!["local/a".into()],
8230 tool: vec![],
8231 from: InstallFrom::Stores,
8232 })
8233 );
8234 }
8235
8236 fn manifest_with_task_io(runtime: &str, block: &str) -> String {
8240 format!(
8241 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/gate\n version: 1.2.3\n \
8242 runtime: {runtime}\n capabilities:\n task_io:\n{block}"
8243 )
8244 }
8245
8246 #[test]
8247 fn task_io_read_true_grants_the_hook() {
8248 let manifest =
8249 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: true\n"))
8250 .expect("task_io on a hook entry parses");
8251 assert_eq!(
8252 manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
8253 Some(TaskIoCapabilities { read: true })
8254 );
8255 }
8256
8257 #[test]
8260 fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
8261 let explicit =
8262 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: false\n"))
8263 .expect("an explicit denial parses");
8264 assert_eq!(
8265 explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
8266 Some(TaskIoCapabilities { read: false })
8267 );
8268
8269 let absent = RuntimeManifest::from_yaml_str(
8270 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/gate\n version: 1.2.3\n \
8271 runtime: hook\n",
8272 )
8273 .expect("no capabilities block parses");
8274 assert!(absent.artifacts[0].capabilities.is_none());
8275 }
8276
8277 #[test]
8280 fn task_io_without_read_is_rejected() {
8281 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " {}\n"))
8282 .unwrap_err();
8283 let msg = err.to_string();
8284 assert!(
8285 msg.contains("capabilities.task_io.read"),
8286 "error was: {msg}"
8287 );
8288 assert!(msg.contains("explicitly"), "error was: {msg}");
8289 }
8290
8291 #[test]
8294 fn task_io_outside_a_hook_entry_is_rejected() {
8295 for runtime in ["tool", "driver", "skill"] {
8296 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
8297 runtime,
8298 " read: true\n",
8299 ))
8300 .unwrap_err();
8301 let msg = err.to_string();
8302 assert!(
8303 msg.contains("capabilities") && msg.contains("runtime: hook"),
8304 "{runtime} entry: error was: {msg}"
8305 );
8306 }
8307 }
8308
8309 fn manifest_with_filesystem(runtime: &str, block: &str) -> String {
8313 format!(
8314 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/thing\n version: 0.1.0\n \
8315 runtime: {runtime}\n capabilities:\n filesystem:\n{block}"
8316 )
8317 }
8318
8319 #[test]
8321 fn filesystem_records_on_a_hook_entry_parses() {
8322 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8323 "hook",
8324 " records: true\n",
8325 ))
8326 .expect("records on a hook entry parses");
8327 let filesystem = manifest.artifacts[0]
8328 .capabilities
8329 .as_ref()
8330 .unwrap()
8331 .filesystem
8332 .as_ref()
8333 .unwrap();
8334 assert!(filesystem.records);
8335 assert_eq!(filesystem.scope, None);
8336
8337 let scoped =
8338 RuntimeManifest::from_yaml_str(&manifest_with_filesystem("hook", " scope: .\n"))
8339 .unwrap();
8340 let filesystem = scoped.artifacts[0]
8341 .capabilities
8342 .as_ref()
8343 .unwrap()
8344 .filesystem
8345 .as_ref()
8346 .unwrap();
8347 assert!(!filesystem.records);
8348 assert!(scoped.unknown_keys.is_empty(), "{:?}", scoped.unknown_keys);
8349 }
8350
8351 #[test]
8354 fn filesystem_records_is_refused_beside_a_scope_off_a_hook_and_capsule_wide() {
8355 let beside = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8356 "hook",
8357 " records: true\n scope: .\n",
8358 ))
8359 .unwrap_err()
8360 .to_string();
8361 assert!(
8362 beside.contains("capabilities.filesystem.records"),
8363 "{beside}"
8364 );
8365 assert!(beside.contains("capabilities.filesystem.scope"), "{beside}");
8366 for runtime in ["tool", "driver"] {
8367 let off_hook = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8368 runtime,
8369 " records: true\n",
8370 ))
8371 .unwrap_err()
8372 .to_string();
8373 assert!(
8374 off_hook.contains("capabilities.filesystem.records"),
8375 "{off_hook}"
8376 );
8377 assert!(off_hook.contains("runtime: hook"), "{off_hook}");
8378 }
8379 let capsule_wide = RuntimeManifest::from_yaml_str(
8380 "name: cap\nversion: 0.0.1\ncapabilities:\n filesystem:\n records: true\n",
8381 )
8382 .unwrap_err()
8383 .to_string();
8384 assert!(
8385 capsule_wide.contains("capabilities.filesystem.records"),
8386 "{capsule_wide}"
8387 );
8388 }
8389
8390 #[test]
8393 fn task_io_in_the_capsule_wide_block_is_rejected() {
8394 let err = RuntimeManifest::from_yaml_str(
8395 "name: cap\nversion: 0.0.1\ncapabilities:\n task_io:\n read: true\n",
8396 )
8397 .unwrap_err();
8398 let msg = err.to_string();
8399 assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
8400 assert!(msg.contains("runtime: hook"), "error was: {msg}");
8401 }
8402
8403 fn manifest_with_conversation(runtime: &str, block: &str) -> String {
8407 format!(
8408 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/memory\n version: 1.2.3\n \
8409 runtime: {runtime}\n capabilities:\n conversation:\n{block}"
8410 )
8411 }
8412
8413 #[test]
8414 fn conversation_read_lowers_onto_the_hook_entry() {
8415 for (declared, expected) in [("true", true), ("false", false)] {
8416 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8417 "hook",
8418 &format!(" read: {declared}\n"),
8419 ))
8420 .expect("conversation on a hook entry parses");
8421 assert_eq!(
8422 manifest.artifacts[0]
8423 .capabilities
8424 .as_ref()
8425 .unwrap()
8426 .conversation,
8427 Some(ConversationCapabilities { read: expected })
8428 );
8429 }
8430 }
8431
8432 #[test]
8435 fn conversation_without_read_is_rejected() {
8436 let err =
8437 RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", " {}\n"))
8438 .unwrap_err();
8439 let msg = err.to_string();
8440 assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
8441 assert!(msg.contains("explicitly"), "was: {msg}");
8442 }
8443
8444 #[test]
8447 fn conversation_outside_a_hook_entry_is_rejected() {
8448 for runtime in ["tool", "driver", "skill"] {
8449 let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8450 runtime,
8451 " read: true\n",
8452 ))
8453 .unwrap_err();
8454 let msg = err.to_string();
8455 assert!(
8456 msg.contains("capabilities") && msg.contains("runtime: hook"),
8457 "{runtime} entry: error was: {msg}"
8458 );
8459 }
8460 }
8461
8462 #[test]
8465 fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
8466 let manifest = RuntimeManifest::from_yaml_str(
8467 "name: cap\nversion: 0.0.1\ncapabilities:\n conversation:\n read: true\n",
8468 )
8469 .expect("a capsule-wide block is accepted");
8470 assert_eq!(
8471 manifest.capabilities.unwrap().conversation,
8472 Some(ConversationCapabilities { read: true })
8473 );
8474 }
8475
8476 #[test]
8481 fn a_context_block_records_by_default() {
8482 let context = context_of("context:\n max_tokens: 1000\n").unwrap();
8483 assert!(context.record);
8484 assert_eq!(context.record_store, None);
8485 }
8486
8487 #[test]
8488 fn record_off_and_on_are_the_two_accepted_values() {
8489 assert!(!context_of("context:\n record: off\n").unwrap().record);
8490 assert!(context_of("context:\n record: on\n").unwrap().record);
8491
8492 let err = RuntimeManifest::from_yaml_str(
8493 "name: cap\nversion: 0.0.1\ncontext:\n record: sometimes\n",
8494 )
8495 .unwrap_err();
8496 let msg = err.to_string();
8497 assert!(msg.contains("context.record"), "was: {msg}");
8498 assert!(msg.contains("sometimes"), "was: {msg}");
8499 }
8500
8501 #[test]
8504 fn record_store_is_kept_verbatim_and_trimmed() {
8505 assert_eq!(
8506 context_of("context:\n record_store: ' shey '\n")
8507 .unwrap()
8508 .record_store,
8509 Some("shey".to_string())
8510 );
8511 assert_eq!(
8512 context_of("context:\n record: off\n record_store: shey\n")
8513 .unwrap()
8514 .record_store,
8515 Some("shey".to_string()),
8516 "a store declared beside record: off is kept and simply never used"
8517 );
8518 }
8519
8520 #[test]
8521 fn interpreter_runtime_rejects_missing_list_dir() {
8522 let msg = interpreter_runtime_reject(
8523 " - binary: python3\n dirs:\n - path: /usr/lib/python3.11\n",
8524 );
8525 assert!(
8526 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
8527 "{msg}"
8528 );
8529 assert!(msg.contains("explicitly"), "{msg}");
8530 }
8531
8532 #[test]
8533 fn interpreter_runtime_rejects_empty_dirs() {
8534 let msg = interpreter_runtime_reject(" - binary: python3\n dirs: []\n");
8535 assert!(
8536 msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
8537 "{msg}"
8538 );
8539 assert!(msg.contains("at least one"), "{msg}");
8540 }
8541
8542 #[test]
8543 fn staged_runtime_parses_accepted_shape() {
8544 let manifest = RuntimeManifest::from_yaml_str(
8545 r#"
8546name: cap
8547version: 0.0.1
8548artifacts: []
8549capabilities:
8550 containment: sealed
8551 shell:
8552 allow:
8553 - python3
8554 - bash
8555 staged_runtime:
8556 - binary: python3
8557 source_path: /opt/testbed/conda/envs/django__django
8558 pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
8559"#,
8560 )
8561 .unwrap();
8562
8563 let shell = manifest.capabilities.unwrap().shell.unwrap();
8564 assert_eq!(
8565 shell.staged_runtime,
8566 vec![StagedRuntimeGrant {
8567 binary: "python3".to_string(),
8568 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
8569 pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
8570 }]
8571 );
8572 assert!(shell.interpreter_runtime.is_empty());
8575 }
8576
8577 #[test]
8578 fn staged_runtime_defaults_to_empty_when_absent() {
8579 let manifest = RuntimeManifest::from_yaml_str(
8580 r#"
8581name: cap
8582version: 0.0.1
8583artifacts: []
8584capabilities:
8585 shell:
8586 allow:
8587 - python3
8588"#,
8589 )
8590 .unwrap();
8591
8592 let shell = manifest.capabilities.unwrap().shell.unwrap();
8593 assert!(shell.staged_runtime.is_empty());
8594 }
8595
8596 fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
8600 let yaml = format!(
8601 r#"
8602name: cap
8603version: 0.0.1
8604artifacts: []
8605capabilities:
8606 shell:
8607 allow:
8608 - python3
8609{extra} staged_runtime:
8610{grant_yaml}
8611"#,
8612 );
8613 RuntimeManifest::from_yaml_str(&yaml)
8614 .expect_err("grant should be rejected")
8615 .to_string()
8616 }
8617
8618 #[test]
8619 fn staged_runtime_rejects_binary_not_in_allow() {
8620 let msg = staged_runtime_reject(
8621 " - binary: ruby\n source_path: /opt/ruby\n pin: ruby-3.2.2\n",
8622 "",
8623 );
8624 assert!(
8625 msg.contains("capabilities.shell.staged_runtime[0].binary"),
8626 "{msg}"
8627 );
8628 assert!(msg.contains("ruby"), "{msg}");
8629 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
8630 }
8631
8632 #[test]
8633 fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
8634 let msg = staged_runtime_reject(
8635 " - binary: python3\n source_path: /opt/py\n pin: cpython-3.9.19\n",
8636 " interpreter_runtime:\n - binary: python3\n dirs:\n - path: /usr/lib/python3.9\n list_dir: true\n",
8637 );
8638 assert!(
8639 msg.contains("capabilities.shell.staged_runtime[0].binary"),
8640 "{msg}"
8641 );
8642 assert!(msg.contains("python3"), "{msg}");
8643 assert!(msg.contains("interpreter_runtime"), "{msg}");
8644 assert!(msg.contains("mutually exclusive"), "{msg}");
8645 }
8646
8647 #[test]
8648 fn staged_runtime_rejects_relative_source_path() {
8649 let msg = staged_runtime_reject(
8650 " - binary: python3\n source_path: opt/testbed/conda\n pin: cpython-3.9.19\n",
8651 "",
8652 );
8653 assert!(
8654 msg.contains("capabilities.shell.staged_runtime[0].source_path"),
8655 "{msg}"
8656 );
8657 assert!(msg.contains("absolute"), "{msg}");
8658 }
8659
8660 #[test]
8661 fn staged_runtime_rejects_missing_or_empty_pin() {
8662 for grant in [
8665 " - binary: python3\n source_path: /opt/testbed/conda\n",
8666 " - binary: python3\n source_path: /opt/testbed/conda\n pin: \" \"\n",
8667 ] {
8668 let msg = staged_runtime_reject(grant, "");
8669 assert!(
8670 msg.contains("capabilities.shell.staged_runtime[0].pin"),
8671 "{msg}"
8672 );
8673 assert!(msg.contains("never inferred"), "{msg}");
8674 }
8675 }
8676
8677 #[test]
8678 fn parses_inference_block() {
8679 let manifest = RuntimeManifest::from_yaml_str(
8680 r#"
8681name: cap
8682version: 0.0.1
8683artifacts:
8684 - name: murmur/driver-anthropic
8685 version: 0.1.0
8686 runtime: driver
8687 gateway:
8688 endpoint: http://127.0.0.1:8080
8689 api_key: literal-key
8690inference:
8691 transport: http
8692 model: test-model
8693 driver:
8694 artifact: murmur/driver-anthropic
8695 config:
8696 temperature: 0.2
8697 headers:
8698 x-test: true
8699"#,
8700 )
8701 .unwrap();
8702
8703 assert_eq!(
8704 manifest.artifacts[0].gateway,
8705 Some(ArtifactGateway {
8706 endpoint: "http://127.0.0.1:8080".to_string(),
8707 api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
8708 keyless: false,
8709 })
8710 );
8711 let inference = manifest.inference.expect("inference should exist");
8712 assert_eq!(inference.transport, "http");
8713 assert_eq!(inference.model, "test-model");
8714 let driver = inference.driver.as_ref().expect("driver should be present");
8715 assert_eq!(driver.artifact, "murmur/driver-anthropic");
8716 assert_eq!(
8717 driver.config,
8718 Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
8719 );
8720 assert!(inference.system_prompt.is_none());
8721 assert!(inference.system_prompt_file.is_none());
8722 }
8723
8724 fn tool_gateway_manifest(block: &str) -> String {
8727 format!(
8728 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/web-search\n version: 0.1.0\n runtime: tool\n gateway:{block}\n"
8729 )
8730 }
8731
8732 fn gateway_endpoint_error(endpoint: &str) -> String {
8733 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
8734 "\n endpoint: \"{endpoint}\""
8735 )))
8736 .unwrap_err()
8737 .to_string()
8738 }
8739
8740 #[test]
8741 fn gateway_endpoint_https_is_accepted() {
8742 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8743 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
8744 ))
8745 .unwrap();
8746 assert_eq!(
8747 manifest.artifacts[0].gateway,
8748 Some(ArtifactGateway {
8749 endpoint: "https://api.tavily.com".to_string(),
8750 api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
8751 keyless: false,
8752 })
8753 );
8754 }
8755
8756 #[test]
8757 fn gateway_endpoint_http_localhost_is_accepted() {
8758 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8759 "\n endpoint: http://localhost:11434\n keyless: true",
8760 ))
8761 .unwrap();
8762 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
8763 assert_eq!(gateway.endpoint, "http://localhost:11434");
8764 assert_eq!(gateway.api_key, None);
8765 assert!(gateway.keyless);
8766 }
8767
8768 #[test]
8769 fn gateway_endpoint_http_loopback_ip_is_accepted() {
8770 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8771 "\n endpoint: http://127.0.0.1:8080\n api_key: ${LOCAL_KEY}",
8772 ))
8773 .unwrap();
8774 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
8775 assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
8776 assert_eq!(
8777 gateway.api_key,
8778 Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
8779 );
8780 }
8781
8782 #[test]
8783 fn gateway_endpoint_without_scheme_is_refused() {
8784 let msg = gateway_endpoint_error("api.tavily.com");
8785 assert!(msg.contains("gateway.endpoint"), "{msg}");
8786 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
8787 assert!(msg.contains("does not parse as a URL"), "{msg}");
8788 }
8789
8790 #[test]
8791 fn gateway_endpoint_ftp_scheme_is_refused() {
8792 let msg = gateway_endpoint_error("ftp://example.com");
8793 assert!(msg.contains("gateway.endpoint"), "{msg}");
8794 assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
8795 }
8796
8797 #[test]
8798 fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
8799 let msg = gateway_endpoint_error("http://api.attacker.example.com");
8800 assert!(msg.contains("gateway.endpoint"), "{msg}");
8801 assert!(msg.contains("api.attacker.example.com"), "{msg}");
8802 assert!(msg.contains("non-loopback"), "{msg}");
8803 }
8804
8805 #[test]
8806 fn gateway_endpoint_with_a_query_is_refused() {
8807 let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
8808 assert!(msg.contains("gateway.endpoint"), "{msg}");
8809 assert!(msg.contains("carries a query"), "{msg}");
8810 }
8811
8812 #[test]
8813 fn gateway_endpoint_with_a_fragment_is_refused() {
8814 let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
8815 assert!(msg.contains("gateway.endpoint"), "{msg}");
8816 assert!(msg.contains("carries a fragment"), "{msg}");
8817 }
8818
8819 #[test]
8820 fn gateway_endpoint_with_userinfo_is_refused() {
8821 let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
8822 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
8823 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
8824 assert!(msg.contains("host 'api.example.com'"), "{msg}");
8825 assert!(msg.contains("gateway.api_key"), "{msg}");
8826 assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
8827 assert!(
8828 !msg.contains("sk-in-url"),
8829 "a password in the URL is never echoed: {msg}"
8830 );
8831 }
8832
8833 #[test]
8834 fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
8835 let msg = gateway_endpoint_error("https://user:sk-in-url@");
8836 assert!(msg.contains("does not parse as a URL"), "{msg}");
8837 assert!(
8838 !msg.contains("sk-in-url"),
8839 "a password in the URL is never echoed: {msg}"
8840 );
8841 }
8842
8843 #[test]
8844 fn gateway_endpoint_with_a_masquerading_host_is_refused() {
8845 let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
8846 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
8847 assert!(
8848 msg.contains("addresses host '127.0.0.1'"),
8849 "the message names the host the URL really addresses: {msg}"
8850 );
8851 }
8852
8853 #[test]
8854 fn gateway_endpoint_with_interpolation_is_refused() {
8855 let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
8856 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
8857 assert!(msg.contains("contains '${'"), "{msg}");
8858 assert!(msg.contains("not interpolated"), "{msg}");
8859 }
8860
8861 #[test]
8862 fn gateway_endpoint_http_unspecified_address_is_refused() {
8863 let msg = gateway_endpoint_error("http://0.0.0.0:8080");
8864 assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
8865 }
8866
8867 #[test]
8868 fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
8869 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8870 "\n endpoint: http://[::1]:8080\n api_key: ${LOCAL_KEY}",
8871 ))
8872 .unwrap();
8873 assert_eq!(
8874 manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
8875 "http://[::1]:8080"
8876 );
8877 }
8878
8879 fn gateway_credential_error(block: &str) -> RuntimeManifestError {
8881 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
8882 }
8883
8884 fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
8885 match &error {
8886 RuntimeManifestError::GatewayWithoutCredential {
8887 index,
8888 name,
8889 endpoint: refused,
8890 } => {
8891 assert_eq!(*index, 0);
8892 assert_eq!(name, "local/web-search");
8893 assert_eq!(refused, endpoint);
8894 }
8895 other => panic!("expected GatewayWithoutCredential, got {other:?}"),
8896 }
8897 let msg = error.to_string();
8898 assert!(
8899 msg.contains(&format!(
8900 "artifact 'local/web-search' declares gateway.endpoint '{endpoint}' but binds no \
8901 credential"
8902 )),
8903 "{msg}"
8904 );
8905 }
8906
8907 #[test]
8908 fn gateway_credential_absent_is_refused() {
8909 assert_without_credential(
8910 gateway_credential_error("\n endpoint: https://api.tavily.com"),
8911 "https://api.tavily.com",
8912 );
8913 }
8914
8915 #[test]
8916 fn gateway_credential_blank_is_refused() {
8917 for key in ["\"\"", "\" \"", "''"] {
8918 assert_without_credential(
8919 gateway_credential_error(&format!(
8920 "\n endpoint: https://api.tavily.com\n api_key: {key}"
8921 )),
8922 "https://api.tavily.com",
8923 );
8924 }
8925 }
8926
8927 #[test]
8928 fn gateway_credential_null_is_refused() {
8929 for key in ["~", "null", ""] {
8930 assert_without_credential(
8931 gateway_credential_error(&format!(
8932 "\n endpoint: https://api.tavily.com\n api_key: {key}"
8933 )),
8934 "https://api.tavily.com",
8935 );
8936 }
8937 }
8938
8939 #[test]
8940 fn gateway_credential_keyless_false_is_refused() {
8941 assert_without_credential(
8942 gateway_credential_error(
8943 "\n endpoint: https://api.tavily.com\n keyless: false",
8944 ),
8945 "https://api.tavily.com",
8946 );
8947 }
8948
8949 #[test]
8950 fn gateway_credential_keyless_is_accepted() {
8951 for (parse, mode) in [
8952 (
8953 RuntimeManifest::from_yaml_str as fn(&str) -> _,
8954 "with secrets",
8955 ),
8956 (
8957 RuntimeManifest::from_yaml_str_without_secrets,
8958 "without secrets",
8959 ),
8960 ] {
8961 let manifest = parse(&tool_gateway_manifest(
8962 "\n endpoint: https://api.example.com\n keyless: true",
8963 ))
8964 .unwrap();
8965 assert_eq!(
8966 manifest.artifacts[0].gateway,
8967 Some(ArtifactGateway {
8968 endpoint: "https://api.example.com".to_string(),
8969 api_key: None,
8970 keyless: true,
8971 }),
8972 "{mode}"
8973 );
8974 }
8975 }
8976
8977 #[test]
8978 fn gateway_credential_keyless_not_a_boolean_is_refused() {
8979 for value in ["\"true\"", "yes please", "1"] {
8980 let error = gateway_credential_error(&format!(
8981 "\n endpoint: https://api.tavily.com\n keyless: {value}"
8982 ));
8983 assert!(
8984 matches!(
8985 error,
8986 RuntimeManifestError::InvalidArtifact { index: 0, .. }
8987 ),
8988 "{value}: {error:?}"
8989 );
8990 assert!(
8991 error
8992 .to_string()
8993 .contains("gateway.keyless with a value that is not a boolean"),
8994 "{error}"
8995 );
8996 }
8997 }
8998
8999 #[test]
9000 fn gateway_credential_and_keyless_together_are_refused() {
9001 for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
9002 let error = gateway_credential_error(&format!(
9003 "\n endpoint: https://api.tavily.com\n api_key: {key}\n keyless: true"
9004 ));
9005 assert!(
9006 matches!(
9007 error,
9008 RuntimeManifestError::InvalidArtifact { index: 0, .. }
9009 ),
9010 "{error:?}"
9011 );
9012 assert!(
9013 error.to_string().contains(
9014 "artifact 'local/web-search' declares both gateway.api_key and gateway.keyless: \
9015 true; a gateway either presents a key or is keyless"
9016 ),
9017 "{error}"
9018 );
9019 }
9020 }
9021
9022 #[test]
9023 fn gateway_credential_absent_is_refused_without_secrets() {
9024 for block in [
9025 "\n endpoint: https://api.tavily.com",
9026 "\n endpoint: https://api.tavily.com\n api_key: \"\"",
9027 "\n endpoint: https://api.tavily.com\n keyless: false",
9028 ] {
9029 assert_without_credential(
9030 RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
9031 .unwrap_err(),
9032 "https://api.tavily.com",
9033 );
9034 }
9035 let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
9036 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
9037 ))
9038 .unwrap();
9039 let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
9040 assert_eq!(gateway.api_key, None, "the key reference is left unread");
9041 assert!(!gateway.keyless, "a keyed gateway is never keyless");
9042 }
9043
9044 #[test]
9047 fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
9048 for endpoint in [
9049 "http://127.0.0.1:11434",
9050 "http://localhost:11434",
9051 "http://[::1]:11434",
9052 "https://10.0.0.5",
9053 "https://192.168.1.10:8443",
9054 ] {
9055 assert_without_credential(
9056 gateway_credential_error(&format!("\n endpoint: {endpoint}")),
9057 endpoint,
9058 );
9059 }
9060 }
9061
9062 #[test]
9063 fn gateway_endpoint_missing_from_the_block_is_refused() {
9064 for block in [
9065 "\n api_key: ${TAVILY_API_KEY}",
9066 "",
9067 " {}",
9068 "\n endpoint: \" \"",
9069 ] {
9070 let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
9071 .unwrap_err()
9072 .to_string();
9073 assert!(
9074 msg.contains("without 'gateway.endpoint'"),
9075 "{block:?}: {msg}"
9076 );
9077 assert!(
9078 msg.contains("artifact 'local/web-search'"),
9079 "{block:?}: {msg}"
9080 );
9081 }
9082 }
9083
9084 #[test]
9085 fn gateway_block_unknown_key_is_reported() {
9086 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9087 "\n endpoint: https://api.tavily.com\n keyless: true\n api_kee: x",
9088 ))
9089 .unwrap();
9090 let unknown = manifest.unknown_keys;
9091 assert_eq!(unknown.len(), 1, "{unknown:?}");
9092 assert_eq!(unknown[0].key, "api_kee");
9093 assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
9094 }
9095
9096 #[test]
9097 fn gateway_on_skill_entry_is_refused() {
9098 let msg = RuntimeManifest::from_yaml_str(
9099 "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",
9100 )
9101 .unwrap_err()
9102 .to_string();
9103 assert!(
9104 msg.contains("artifact 'local/notes' declares 'gateway:'"),
9105 "{msg}"
9106 );
9107 assert!(msg.contains("runtime: skill"), "{msg}");
9108 }
9109
9110 #[test]
9111 fn gateway_on_hook_entry_is_accepted() {
9112 let manifest = RuntimeManifest::from_yaml_str(
9113 "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",
9114 )
9115 .unwrap();
9116 assert!(manifest.artifacts[0].gateway.is_some());
9117 }
9118
9119 fn http_driver_manifest(artifacts: &str) -> String {
9122 format!(
9123 "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur/driver-anthropic\n"
9124 )
9125 }
9126
9127 #[test]
9128 fn http_driver_entry_without_gateway_endpoint_is_refused() {
9129 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9130 "\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver",
9131 ))
9132 .unwrap_err()
9133 .to_string();
9134 assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9135 assert!(msg.contains("gateway.endpoint"), "{msg}");
9136 }
9137
9138 #[test]
9139 fn http_driver_absent_from_artifacts_is_refused() {
9140 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
9141 .unwrap_err()
9142 .to_string();
9143 assert!(msg.contains("inference.driver.artifact"), "{msg}");
9144 assert!(msg.contains("not declared in artifacts:"), "{msg}");
9145 }
9146
9147 #[test]
9148 fn gateway_on_unconfigured_driver_entry_is_refused() {
9149 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9150 "\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",
9151 ))
9152 .unwrap_err()
9153 .to_string();
9154 assert!(msg.contains("'murmur/driver-openai'"), "{msg}");
9155 assert!(
9156 msg.contains("accepted only on the entry inference.driver.artifact names"),
9157 "{msg}"
9158 );
9159
9160 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";
9161 let msg = RuntimeManifest::from_yaml_str(no_inference)
9162 .unwrap_err()
9163 .to_string();
9164 assert!(msg.contains("not the configured inference driver"), "{msg}");
9165 }
9166
9167 #[test]
9168 fn gateway_on_driver_entry_under_process_transport_is_refused() {
9169 let msg = RuntimeManifest::from_yaml_str(
9170 "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",
9171 )
9172 .unwrap_err()
9173 .to_string();
9174 assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9175 assert!(msg.contains("transport: process"), "{msg}");
9176 }
9177
9178 #[test]
9179 fn inference_api_key_is_refused_naming_gateway_api_key() {
9180 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";
9181 for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
9182 let manifest = http_driver_manifest(entry)
9183 .replace(" model:", &format!(" api_key: {value}\n model:"));
9184 let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
9185 let msg = err.to_string();
9186 assert!(msg.contains("'inference.api_key'"), "{msg}");
9187 assert!(msg.contains("was removed"), "{msg}");
9188 assert!(
9189 msg.contains(
9190 "set gateway.api_key on the artifacts: entry 'murmur/driver-anthropic'"
9191 ),
9192 "{msg}"
9193 );
9194 assert!(!msg.contains("sk-literal"), "{msg}");
9195 }
9196
9197 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n api_key: ${KEY}\n";
9198 let msg = RuntimeManifest::from_yaml_str(process)
9199 .unwrap_err()
9200 .to_string();
9201 assert!(msg.contains("'inference.api_key'"), "{msg}");
9202 assert!(
9203 msg.contains("on the artifacts: entry inference.driver.artifact names"),
9204 "{msg}"
9205 );
9206 assert!(
9207 RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
9208 "the refusal does not depend on resolving secrets"
9209 );
9210 }
9211
9212 #[test]
9213 fn inference_endpoint_is_refused_naming_gateway_endpoint() {
9214 let manifest = http_driver_manifest(
9215 "\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",
9216 )
9217 .replace(" model:", " endpoint: https://api.anthropic.com\n model:");
9218 let msg = RuntimeManifest::from_yaml_str(&manifest)
9219 .unwrap_err()
9220 .to_string();
9221 assert!(msg.contains("'inference.endpoint'"), "{msg}");
9222 assert!(
9223 msg.contains("set gateway.endpoint on the artifacts: entry 'murmur/driver-anthropic'"),
9224 "{msg}"
9225 );
9226
9227 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n endpoint: http://localhost:8080\n";
9228 let msg = RuntimeManifest::from_yaml_str(process)
9229 .unwrap_err()
9230 .to_string();
9231 assert!(msg.contains("'inference.endpoint'"), "{msg}");
9232 assert!(msg.contains("was removed"), "{msg}");
9233 }
9234
9235 #[test]
9236 fn without_secrets_leaves_gateway_api_key_unread() {
9237 let manifest = tool_gateway_manifest(
9238 "\n endpoint: https://api.tavily.com\n api_key: tvly-literal",
9239 );
9240 let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
9241 assert_eq!(
9242 resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
9243 Some(ApiKeyReference::Literal("tvly-literal".to_string()))
9244 );
9245 let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
9246 let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
9247 assert_eq!(gateway.endpoint, "https://api.tavily.com");
9248 assert_eq!(gateway.api_key, None);
9249 }
9250
9251 #[test]
9252 fn referenced_env_variables_reads_gateway_api_key() {
9253 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";
9254 let referenced = referenced_env_variables(manifest);
9255 assert_eq!(
9256 referenced,
9257 vec![
9258 ReferencedEnvVariable {
9259 field: "artifacts.murmur/driver-anthropic.gateway.api_key".to_string(),
9260 variable: "ANTHROPIC_API_KEY".to_string(),
9261 },
9262 ReferencedEnvVariable {
9263 field: "artifacts.local/web-search.gateway.api_key".to_string(),
9264 variable: "TAVILY_API_KEY".to_string(),
9265 },
9266 ]
9267 );
9268 assert!(referenced
9269 .iter()
9270 .all(ReferencedEnvVariable::is_gateway_credential));
9271 }
9272
9273 #[test]
9274 fn parses_inline_system_prompt() {
9275 let manifest = RuntimeManifest::from_yaml_str(
9276 r#"
9277name: cap
9278version: 0.0.1
9279artifacts:
9280 - name: murmur/driver-anthropic
9281 version: 0.1.0
9282 runtime: driver
9283 gateway:
9284 endpoint: http://127.0.0.1:8080
9285 keyless: true
9286inference:
9287 model: test-model
9288 system_prompt: "Always begin with CONFIRMED:"
9289 driver:
9290 artifact: murmur/driver-anthropic
9291"#,
9292 )
9293 .unwrap();
9294
9295 let inference = manifest.inference.unwrap();
9296 assert_eq!(
9297 inference.system_prompt,
9298 Some("Always begin with CONFIRMED:".to_string())
9299 );
9300 assert!(inference.system_prompt_file.is_none());
9301 }
9302
9303 #[test]
9304 fn parses_compaction_system_prompt() {
9305 let manifest = RuntimeManifest::from_yaml_str(
9306 r#"
9307name: cap
9308version: 0.0.1
9309artifacts:
9310 - name: murmur/driver-anthropic
9311 version: 0.1.0
9312 runtime: driver
9313 gateway:
9314 endpoint: http://127.0.0.1:8080
9315 keyless: true
9316inference:
9317 model: test-model
9318 compaction:
9319 model: compaction-model
9320 system_prompt: "task = X, currently editing Y, already tried Z."
9321 driver:
9322 artifact: murmur/driver-anthropic
9323"#,
9324 )
9325 .unwrap();
9326
9327 let compaction = manifest.inference.unwrap().compaction.unwrap();
9328 assert_eq!(
9329 compaction.system_prompt,
9330 Some("task = X, currently editing Y, already tried Z.".to_string())
9331 );
9332 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9333 }
9334
9335 #[test]
9338 fn compaction_system_prompt_absent_stays_none() {
9339 let manifest = RuntimeManifest::from_yaml_str(
9340 r#"
9341name: cap
9342version: 0.0.1
9343artifacts:
9344 - name: murmur/driver-anthropic
9345 version: 0.1.0
9346 runtime: driver
9347 gateway:
9348 endpoint: http://127.0.0.1:8080
9349 keyless: true
9350inference:
9351 model: test-model
9352 compaction:
9353 model: compaction-model
9354 driver:
9355 artifact: murmur/driver-anthropic
9356"#,
9357 )
9358 .unwrap();
9359
9360 let compaction = manifest.inference.unwrap().compaction.unwrap();
9361 assert!(compaction.system_prompt.is_none());
9362 assert!(compaction.system_prompt_file.is_none());
9363 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9364 }
9365
9366 #[test]
9370 fn parses_compaction_system_prompt_file() {
9371 let manifest = RuntimeManifest::from_yaml_str(
9372 r#"
9373name: cap
9374version: 0.0.1
9375artifacts:
9376 - name: murmur/driver-anthropic
9377 version: 0.1.0
9378 runtime: driver
9379 gateway:
9380 endpoint: http://127.0.0.1:8080
9381 keyless: true
9382inference:
9383 model: test-model
9384 compaction:
9385 model: compaction-model
9386 system_prompt_file: "compaction-instructions.md"
9387 driver:
9388 artifact: murmur/driver-anthropic
9389"#,
9390 )
9391 .unwrap();
9392
9393 let compaction = manifest.inference.unwrap().compaction.unwrap();
9394 assert_eq!(
9395 compaction.system_prompt_file,
9396 Some("compaction-instructions.md".to_string())
9397 );
9398 assert!(compaction.system_prompt.is_none());
9399 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9400 }
9401
9402 #[test]
9405 fn compaction_system_prompt_file_independent_of_model() {
9406 let manifest = RuntimeManifest::from_yaml_str(
9407 r#"
9408name: cap
9409version: 0.0.1
9410artifacts:
9411 - name: murmur/driver-anthropic
9412 version: 0.1.0
9413 runtime: driver
9414 gateway:
9415 endpoint: http://127.0.0.1:8080
9416 keyless: true
9417inference:
9418 model: test-model
9419 compaction:
9420 threshold: 0.5
9421 system_prompt_file: "compaction-instructions.md"
9422 driver:
9423 artifact: murmur/driver-anthropic
9424"#,
9425 )
9426 .unwrap();
9427
9428 let compaction = manifest.inference.unwrap().compaction.unwrap();
9429 assert_eq!(
9430 compaction.system_prompt_file,
9431 Some("compaction-instructions.md".to_string())
9432 );
9433 assert!(compaction.model.is_none());
9434 assert_eq!(compaction.threshold, Some(0.5));
9435 }
9436
9437 #[test]
9441 fn parses_compaction_dump_summaries() {
9442 let parse = |line: &str| {
9443 RuntimeManifest::from_yaml_str(&format!(
9444 r#"
9445name: cap
9446version: 0.0.1
9447artifacts:
9448 - name: murmur/driver-anthropic
9449 version: 0.1.0
9450 runtime: driver
9451 gateway:
9452 endpoint: http://127.0.0.1:8080
9453 keyless: true
9454inference:
9455 model: test-model
9456 compaction:
9457 model: compaction-model
9458{line} driver:
9459 artifact: murmur/driver-anthropic
9460"#
9461 ))
9462 .unwrap()
9463 .inference
9464 .unwrap()
9465 .compaction
9466 .unwrap()
9467 };
9468
9469 assert_eq!(
9470 parse(" dump_summaries: true\n").dump_summaries,
9471 Some(true)
9472 );
9473 assert_eq!(
9474 parse(" dump_summaries: false\n").dump_summaries,
9475 Some(false)
9476 );
9477 assert_eq!(parse("").dump_summaries, None);
9478 }
9479
9480 #[test]
9484 fn compaction_dump_summaries_does_not_interact_with_other_fields() {
9485 let compaction = RuntimeManifest::from_yaml_str(
9486 r#"
9487name: cap
9488version: 0.0.1
9489artifacts:
9490 - name: murmur/driver-anthropic
9491 version: 0.1.0
9492 runtime: driver
9493 gateway:
9494 endpoint: http://127.0.0.1:8080
9495 keyless: true
9496inference:
9497 model: test-model
9498 compaction:
9499 threshold: 0.5
9500 model: compaction-model
9501 system_prompt_file: "compaction-instructions.md"
9502 dump_summaries: true
9503 driver:
9504 artifact: murmur/driver-anthropic
9505"#,
9506 )
9507 .unwrap()
9508 .inference
9509 .unwrap()
9510 .compaction
9511 .unwrap();
9512
9513 assert_eq!(compaction.dump_summaries, Some(true));
9514 assert_eq!(compaction.threshold, Some(0.5));
9515 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9516 assert_eq!(
9517 compaction.system_prompt_file,
9518 Some("compaction-instructions.md".to_string())
9519 );
9520 assert!(compaction.system_prompt.is_none());
9521
9522 let err = RuntimeManifest::from_yaml_str(
9524 r#"
9525name: cap
9526version: 0.0.1
9527artifacts:
9528 - name: murmur/driver-anthropic
9529 version: 0.1.0
9530 runtime: driver
9531 gateway:
9532 endpoint: http://127.0.0.1:8080
9533 keyless: true
9534inference:
9535 model: test-model
9536 compaction:
9537 dump_summaries: true
9538 system_prompt: "inline"
9539 system_prompt_file: "compaction-instructions.md"
9540 driver:
9541 artifact: murmur/driver-anthropic
9542"#,
9543 )
9544 .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
9545 assert!(matches!(
9546 err,
9547 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9548 if field == "inference.compaction.system_prompt"
9549 ));
9550
9551 let err = RuntimeManifest::from_yaml_str(
9553 r#"
9554name: cap
9555version: 0.0.1
9556artifacts:
9557 - name: murmur/driver-anthropic
9558 version: 0.1.0
9559 runtime: driver
9560 gateway:
9561 endpoint: http://127.0.0.1:8080
9562 keyless: true
9563inference:
9564 model: test-model
9565 compaction:
9566 dump_summaries: true
9567 threshold: 1.5
9568 driver:
9569 artifact: murmur/driver-anthropic
9570"#,
9571 )
9572 .expect_err("dump_summaries must not suppress the threshold range check");
9573 assert!(matches!(
9574 err,
9575 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9576 if field == "inference.compaction.threshold"
9577 ));
9578 }
9579
9580 fn compaction_call_manifest(lines: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
9581 RuntimeManifest::from_yaml_str(&format!(
9582 r#"
9583name: cap
9584version: 0.0.1
9585artifacts:
9586 - name: murmur/driver-anthropic
9587 version: 0.1.0
9588 runtime: driver
9589 gateway:
9590 endpoint: http://127.0.0.1:8080
9591 keyless: true
9592inference:
9593 model: test-model
9594 driver:
9595 artifact: murmur/driver-anthropic
9596 config:
9597 reasoning_effort: max
9598 compaction:
9599{lines}"#
9600 ))
9601 }
9602
9603 #[test]
9604 fn parses_compaction_max_tokens_and_driver_config() {
9605 let manifest = compaction_call_manifest(
9606 " max_tokens: 8000\n driver_config:\n reasoning_effort: low\n extra: [1, 2]\n",
9607 )
9608 .unwrap();
9609 assert!(
9610 manifest.unknown_keys.is_empty(),
9611 "neither key is unknown: {:?}",
9612 manifest.unknown_keys
9613 );
9614 let compaction = manifest.inference.unwrap().compaction.unwrap();
9615 assert_eq!(compaction.max_tokens, Some(8000));
9616 assert_eq!(
9617 compaction.driver_config.as_deref(),
9618 Some(r#"{"extra":[1,2],"reasoning_effort":"low"}"#)
9619 );
9620
9621 let compaction = compaction_call_manifest(" threshold: 0.5\n")
9622 .unwrap()
9623 .inference
9624 .unwrap()
9625 .compaction
9626 .unwrap();
9627 assert_eq!(compaction.max_tokens, None);
9628 assert_eq!(compaction.driver_config, None);
9629
9630 let compaction = compaction_call_manifest(" driver_config: {}\n")
9632 .unwrap()
9633 .inference
9634 .unwrap()
9635 .compaction
9636 .unwrap();
9637 assert_eq!(compaction.driver_config.as_deref(), Some("{}"));
9638 }
9639
9640 #[test]
9641 fn compaction_max_tokens_zero_is_refused() {
9642 match compaction_call_manifest(" max_tokens: 0\n").unwrap_err() {
9643 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9644 assert_eq!(field, "inference.compaction.max_tokens");
9645 assert_eq!(message, "must be greater than 0");
9646 }
9647 other => panic!("unexpected error: {other:?}"),
9648 }
9649 }
9650
9651 #[test]
9652 fn compaction_driver_config_must_be_a_mapping() {
9653 for value in ["low", "[reasoning_effort]", "3"] {
9654 match compaction_call_manifest(&format!(" driver_config: {value}\n")).unwrap_err() {
9655 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9656 assert_eq!(field, "inference.compaction.driver_config");
9657 assert_eq!(message, "expected mapping/object");
9658 }
9659 other => panic!("unexpected error for {value}: {other:?}"),
9660 }
9661 }
9662 }
9663
9664 #[test]
9667 fn process_transport_rejects_compaction_call_keys() {
9668 for (key, value) in [
9669 ("max_tokens", "8000"),
9670 ("driver_config", "{reasoning_effort: low}"),
9671 ] {
9672 let err = RuntimeManifest::from_yaml_str(&format!(
9673 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-claude-code\n \
9674 version: 0.1.0\n runtime: driver\ninference:\n transport: process\n \
9675 model: claude-haiku-4-5-20251001\n driver:\n artifact: murmur/driver-claude-code\n \
9676 compaction:\n {key}: {value}\n"
9677 ))
9678 .unwrap_err();
9679 match err {
9680 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9681 assert_eq!(field, format!("inference.compaction.{key}"));
9682 assert_eq!(message, "is not valid with transport: process");
9683 }
9684 other => panic!("unexpected error for {key}: {other:?}"),
9685 }
9686 }
9687 }
9688
9689 #[test]
9693 fn compaction_prompt_sources_are_mutually_exclusive() {
9694 let err = RuntimeManifest::from_yaml_str(
9695 r#"
9696name: cap
9697version: 0.0.1
9698artifacts:
9699 - name: murmur/driver-anthropic
9700 version: 0.1.0
9701 runtime: driver
9702 gateway:
9703 endpoint: http://127.0.0.1:8080
9704 keyless: true
9705inference:
9706 model: test-model
9707 compaction:
9708 system_prompt: "inline"
9709 system_prompt_file: "compaction-instructions.md"
9710 driver:
9711 artifact: murmur/driver-anthropic
9712"#,
9713 )
9714 .expect_err("both compaction prompt sources set must fail");
9715
9716 match err {
9717 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9718 assert_eq!(field, "inference.compaction.system_prompt");
9719 assert_eq!(
9720 message,
9721 "at most one of inference.compaction.system_prompt, \
9722 inference.compaction.system_prompt_file may be set"
9723 );
9724 }
9725 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
9726 }
9727 }
9728
9729 #[test]
9733 fn top_level_and_compaction_prompt_sources_do_not_collide() {
9734 let manifest = RuntimeManifest::from_yaml_str(
9735 r#"
9736name: cap
9737version: 0.0.1
9738artifacts:
9739 - name: murmur/driver-anthropic
9740 version: 0.1.0
9741 runtime: driver
9742 gateway:
9743 endpoint: http://127.0.0.1:8080
9744 keyless: true
9745inference:
9746 model: test-model
9747 system_prompt: "be helpful"
9748 compaction:
9749 system_prompt_file: "compaction-instructions.md"
9750 driver:
9751 artifact: murmur/driver-anthropic
9752"#,
9753 )
9754 .unwrap();
9755
9756 let inference = manifest.inference.unwrap();
9757 assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
9758 assert_eq!(
9759 inference.compaction.unwrap().system_prompt_file,
9760 Some("compaction-instructions.md".to_string())
9761 );
9762 }
9763
9764 #[test]
9765 fn parses_system_prompt_file() {
9766 let manifest = RuntimeManifest::from_yaml_str(
9767 r#"
9768name: cap
9769version: 0.0.1
9770artifacts:
9771 - name: murmur/driver-anthropic
9772 version: 0.1.0
9773 runtime: driver
9774 gateway:
9775 endpoint: http://127.0.0.1:8080
9776 keyless: true
9777inference:
9778 model: test-model
9779 system_prompt_file: "conventions.md"
9780 driver:
9781 artifact: murmur/driver-anthropic
9782"#,
9783 )
9784 .unwrap();
9785
9786 let inference = manifest.inference.unwrap();
9787 assert_eq!(
9788 inference.system_prompt_file,
9789 Some("conventions.md".to_string())
9790 );
9791 assert!(inference.system_prompt.is_none());
9792 }
9793
9794 #[test]
9795 fn rejects_both_inline_and_file_system_prompt() {
9796 let err = RuntimeManifest::from_yaml_str(
9797 r#"
9798name: cap
9799version: 0.0.1
9800artifacts:
9801 - name: murmur/driver-anthropic
9802 version: 0.1.0
9803 runtime: driver
9804 gateway:
9805 endpoint: http://127.0.0.1:8080
9806 keyless: true
9807inference:
9808 model: test-model
9809 system_prompt: "inline"
9810 system_prompt_file: "conventions.md"
9811 driver:
9812 artifact: murmur/driver-anthropic
9813"#,
9814 )
9815 .unwrap_err();
9816
9817 let msg = err.to_string();
9818 assert!(msg.contains("inference.system_prompt"));
9819 assert!(msg.contains("system_prompt_file"));
9820 }
9821
9822 #[test]
9825 fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
9826 let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
9827 let yaml = format!(
9828 "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"
9829 );
9830
9831 assert_eq!(
9832 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
9833 .gateway
9834 .clone()
9835 .unwrap()
9836 .api_key,
9837 Some(ApiKeyReference::Environment(key.to_string()))
9838 );
9839 assert_eq!(
9840 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
9841 .unwrap()
9842 .artifacts[0]
9843 .gateway
9844 .clone()
9845 .unwrap()
9846 .api_key,
9847 None
9848 );
9849 }
9850
9851 #[test]
9852 fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
9853 let literal = ["sk-", "ant-", "debug"].concat();
9854 let debug = format!(
9855 "{:?} {:?}",
9856 ApiKeyReference::Environment("SOME_KEY".to_string()),
9857 ApiKeyReference::Literal(literal.clone())
9858 );
9859 assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
9860 assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
9861 assert!(!debug.contains(&literal), "{debug}");
9862 }
9863
9864 #[test]
9865 fn parse_without_secrets_drops_a_literal_api_key() {
9866 let literal = ["sk-", "ant-", "literal"].concat();
9869 let yaml = format!(
9870 "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"
9871 );
9872
9873 assert_eq!(
9874 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
9875 .gateway
9876 .clone()
9877 .unwrap()
9878 .api_key,
9879 Some(ApiKeyReference::Literal(literal))
9880 );
9881 assert_eq!(
9882 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
9883 .unwrap()
9884 .artifacts[0]
9885 .gateway
9886 .clone()
9887 .unwrap()
9888 .api_key,
9889 None
9890 );
9891 }
9892
9893 #[test]
9896 fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
9897 let process_with_key = format!(
9898 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n model: test-model\n api_key: {}\n",
9899 ["sk-", "ant-", "secret"].concat()
9900 );
9901 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";
9902
9903 for yaml in [process_with_key.as_str(), http_without_model] {
9904 let resolving = RuntimeManifest::from_yaml_str(yaml)
9905 .unwrap_err()
9906 .to_string();
9907 let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
9908 .unwrap_err()
9909 .to_string();
9910 assert_eq!(resolving, without_secrets);
9911 }
9912 }
9913
9914 #[test]
9918 fn referenced_env_variables_reports_a_reference_and_nothing_else() {
9919 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";
9920 let referenced = referenced_env_variables(referencing);
9921 assert_eq!(referenced.len(), 1);
9922 assert_eq!(
9923 referenced[0].field,
9924 "artifacts.murmur/driver-anthropic.gateway.api_key"
9925 );
9926 assert!(referenced[0].is_gateway_credential());
9927 assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
9928
9929 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";
9930 assert!(referenced_env_variables(literal).is_empty());
9931
9932 let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
9933 assert!(referenced_env_variables(no_inference).is_empty());
9934
9935 let unparseable = "name: broken\nversion: [\n";
9936 assert!(referenced_env_variables(unparseable).is_empty());
9937 }
9938
9939 #[test]
9940 fn inference_requires_driver_artifact() {
9941 let err = RuntimeManifest::from_yaml_str(
9942 r#"
9943name: cap
9944version: 0.0.1
9945artifacts: []
9946inference:
9947 transport: http
9948 endpoint: http://127.0.0.1:8080
9949 model: test-model
9950"#,
9951 )
9952 .unwrap_err();
9953
9954 assert!(err.to_string().contains("inference.driver"));
9955 }
9956
9957 #[test]
9958 fn test_runtime_driver_deserializes() {
9959 let manifest = RuntimeManifest::from_yaml_str(
9960 r#"
9961name: cap
9962version: 0.0.1
9963artifacts:
9964 - name: local/my-provider
9965 version: 1.0.0
9966 runtime: driver
9967"#,
9968 )
9969 .unwrap();
9970
9971 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
9972 }
9973
9974 #[test]
9975 fn test_runtime_unknown_is_error() {
9976 let err = RuntimeManifest::from_yaml_str(
9977 r#"
9978name: cap
9979version: 0.0.1
9980artifacts:
9981 - name: local/foo
9982 version: 1.0.0
9983 runtime: unknown_type
9984"#,
9985 )
9986 .unwrap_err();
9987
9988 let msg = err.to_string();
9989 assert!(msg.contains("runtime"), "error should mention 'runtime'");
9990 assert!(
9991 msg.contains("unknown_type"),
9992 "error should identify the bad value"
9993 );
9994 }
9995
9996 #[test]
9997 fn test_runtime_all_variants_roundtrip() {
9998 for variant in [
9999 ArtifactRuntime::Tool,
10000 ArtifactRuntime::Driver,
10001 ArtifactRuntime::Hook,
10002 ArtifactRuntime::Skill,
10003 ] {
10004 let json = serde_json::to_string(&variant).unwrap();
10005 let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
10006 assert_eq!(variant, back);
10007 }
10008 }
10009
10010 #[test]
10011 fn skill_source_without_version_defaults_to_local() {
10012 let manifest = RuntimeManifest::from_yaml_str(
10013 r#"
10014name: cap
10015version: 0.0.1
10016artifacts:
10017 - name: my-skill
10018 source: ./skills/my-skill/skill.md
10019 runtime: skill
10020"#,
10021 )
10022 .unwrap();
10023
10024 assert_eq!(manifest.artifacts.len(), 1);
10025 let artifact = &manifest.artifacts[0];
10026 assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
10027 assert_eq!(
10028 artifact.source.as_deref(),
10029 Some("./skills/my-skill/skill.md")
10030 );
10031 assert_eq!(artifact.version, "local");
10032 }
10033
10034 #[test]
10035 fn skill_source_directory_path_is_accepted() {
10036 let manifest = RuntimeManifest::from_yaml_str(
10037 r#"
10038name: cap
10039version: 0.0.1
10040artifacts:
10041 - name: my-skill
10042 source: ./skills/my-skill/
10043 runtime: skill
10044"#,
10045 )
10046 .unwrap();
10047
10048 assert_eq!(
10049 manifest.artifacts[0].source.as_deref(),
10050 Some("./skills/my-skill/")
10051 );
10052 assert_eq!(manifest.artifacts[0].version, "local");
10053 }
10054
10055 #[test]
10056 fn skill_source_with_explicit_version_substitutes_local() {
10057 let manifest = RuntimeManifest::from_yaml_str(
10059 r#"
10060name: cap
10061version: 0.0.1
10062artifacts:
10063 - name: my-skill
10064 source: ./skills/my-skill/skill.md
10065 version: 9.9.9
10066 runtime: skill
10067"#,
10068 )
10069 .unwrap();
10070
10071 assert_eq!(manifest.artifacts[0].version, "local");
10072 }
10073
10074 #[test]
10075 fn local_source_true_allows_source_on_non_skill_runtime() {
10076 for runtime in ["tool", "driver", "hook"] {
10077 let yaml = format!(
10078 r#"
10079name: cap
10080version: 0.0.1
10081artifacts:
10082 - name: my-thing
10083 source: ./local/path
10084 local_source: true
10085 runtime: {runtime}
10086"#
10087 );
10088 let manifest = RuntimeManifest::from_yaml_str(&yaml)
10089 .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
10090 let artifact = &manifest.artifacts[0];
10091 assert!(artifact.local_source, "runtime {runtime}");
10092 assert_eq!(artifact.source.as_deref(), Some("./local/path"));
10093 assert_eq!(artifact.version, "local", "runtime {runtime}");
10094 }
10095 }
10096
10097 #[test]
10098 fn local_source_false_on_skill_with_source_is_error() {
10099 let err = RuntimeManifest::from_yaml_str(
10102 r#"
10103name: cap
10104version: 0.0.1
10105artifacts:
10106 - name: my-skill
10107 source: ./skills/my-skill
10108 local_source: false
10109 runtime: skill
10110"#,
10111 )
10112 .unwrap_err();
10113 let msg = err.to_string();
10114 assert!(msg.contains("'local_source: true'"), "error was: {msg}");
10115 assert!(msg.contains("my-skill"), "error was: {msg}");
10116 }
10117
10118 #[test]
10119 fn local_source_defaults_from_role_when_absent() {
10120 let manifest = RuntimeManifest::from_yaml_str(
10121 r#"
10122name: cap
10123version: 0.0.1
10124artifacts:
10125 - name: my-skill
10126 source: ./skills/my-skill
10127 runtime: skill
10128 - name: local/my-tool
10129 version: 1.0.0
10130 runtime: tool
10131"#,
10132 )
10133 .unwrap();
10134 assert!(manifest.artifacts[0].local_source, "skill defaults to true");
10135 assert!(
10136 !manifest.artifacts[1].local_source,
10137 "tool defaults to false"
10138 );
10139 }
10140
10141 #[test]
10142 fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
10143 let manifest = RuntimeManifest::from_yaml_str(
10144 r#"
10145name: cap
10146version: 0.0.1
10147artifacts:
10148 - name: local/my-tool
10149 version: 1.0.0
10150 runtime: tool
10151 prompt_payload: true
10152 - name: murmur/driver-anthropic
10153 version: 0.1.0
10154 runtime: driver
10155 gateway:
10156 endpoint: http://127.0.0.1:8080
10157 keyless: true
10158inference:
10159 model: test-model
10160 system_prompt_artifact: local/my-tool
10161 driver:
10162 artifact: murmur/driver-anthropic
10163"#,
10164 )
10165 .unwrap();
10166 assert!(manifest.artifacts[0].prompt_payload);
10167 }
10168
10169 #[test]
10170 fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
10171 let err = RuntimeManifest::from_yaml_str(
10172 r#"
10173name: cap
10174version: 0.0.1
10175artifacts:
10176 - name: local/my-skill
10177 version: 1.0.0
10178 runtime: skill
10179 prompt_payload: false
10180 - name: murmur/driver-anthropic
10181 version: 0.1.0
10182 runtime: driver
10183 gateway:
10184 endpoint: http://127.0.0.1:8080
10185 keyless: true
10186inference:
10187 model: test-model
10188 system_prompt_artifact: local/my-skill
10189 driver:
10190 artifact: murmur/driver-anthropic
10191"#,
10192 )
10193 .unwrap_err();
10194 let msg = err.to_string();
10195 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
10196 }
10197
10198 #[test]
10199 fn prompt_payload_defaults_from_role_when_absent() {
10200 let manifest = RuntimeManifest::from_yaml_str(
10201 r#"
10202name: cap
10203version: 0.0.1
10204artifacts:
10205 - name: local/my-skill
10206 version: 1.0.0
10207 runtime: skill
10208 - name: local/my-tool
10209 version: 1.0.0
10210 runtime: tool
10211"#,
10212 )
10213 .unwrap();
10214 assert!(
10215 manifest.artifacts[0].prompt_payload,
10216 "skill defaults to true"
10217 );
10218 assert!(
10219 !manifest.artifacts[1].prompt_payload,
10220 "tool defaults to false"
10221 );
10222 }
10223
10224 #[test]
10225 fn source_on_non_skill_runtime_is_error() {
10226 for runtime in ["tool", "driver", "hook"] {
10227 let yaml = format!(
10228 r#"
10229name: cap
10230version: 0.0.1
10231artifacts:
10232 - name: my-thing
10233 source: ./local/path
10234 runtime: {runtime}
10235"#
10236 );
10237 let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
10238 let msg = err.to_string();
10239 assert!(
10240 msg.contains("'local_source: true'"),
10241 "runtime {runtime}: error was: {msg}"
10242 );
10243 assert!(
10244 msg.contains("my-thing"),
10245 "runtime {runtime}: error was: {msg}"
10246 );
10247 }
10248 }
10249
10250 #[test]
10251 fn registry_skill_still_requires_version() {
10252 let err = RuntimeManifest::from_yaml_str(
10253 r#"
10254name: cap
10255version: 0.0.1
10256artifacts:
10257 - name: local/my-skill
10258 runtime: skill
10259"#,
10260 )
10261 .unwrap_err();
10262
10263 let msg = err.to_string();
10264 assert!(
10265 msg.contains("missing required field 'version'"),
10266 "error was: {msg}"
10267 );
10268 }
10269
10270 #[test]
10271 fn rejects_unknown_inference_transport() {
10272 let err = RuntimeManifest::from_yaml_str(
10273 r#"
10274name: cap
10275version: 0.0.1
10276artifacts: []
10277inference:
10278 transport: grpc
10279 model: test-model
10280 driver:
10281 artifact: murmur/driver-anthropic
10282"#,
10283 )
10284 .unwrap_err();
10285
10286 let msg = err.to_string();
10287 assert!(msg.contains("inference.transport"), "error was: {msg}");
10288 assert!(msg.contains("unknown value"), "error was: {msg}");
10289 assert!(msg.contains("grpc"), "error was: {msg}");
10290 }
10291
10292 #[test]
10295 fn process_transport_with_command_and_no_driver_is_refused() {
10296 let err = RuntimeManifest::from_yaml_str(
10297 r#"
10298name: cap
10299version: 0.0.1
10300artifacts: []
10301inference:
10302 transport: process
10303 command: some-cli
10304 model: some-model
10305 max_turns: 20
10306"#,
10307 )
10308 .unwrap_err();
10309
10310 let msg = err.to_string();
10311 assert!(
10312 msg.contains("inference.driver.artifact"),
10313 "error was: {msg}"
10314 );
10315 assert!(msg.contains("inference.command"), "error was: {msg}");
10316 assert!(
10317 msg.contains("murmur/driver-claude-code"),
10318 "error was: {msg}"
10319 );
10320 }
10321
10322 #[test]
10323 fn process_transport_with_neither_driver_nor_command_is_refused() {
10324 let err = RuntimeManifest::from_yaml_str(
10325 r#"
10326name: cap
10327version: 0.0.1
10328artifacts: []
10329inference:
10330 transport: process
10331 model: some-model
10332"#,
10333 )
10334 .unwrap_err();
10335
10336 let msg = err.to_string();
10337 assert!(
10338 msg.contains("inference.driver.artifact"),
10339 "error was: {msg}"
10340 );
10341 assert!(
10342 msg.contains("murmur/driver-claude-code"),
10343 "error was: {msg}"
10344 );
10345 }
10346
10347 #[test]
10348 fn process_transport_allows_absent_model() {
10349 let manifest =
10352 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
10353 .expect("process transport should parse without a model");
10354 let inference = manifest.inference.expect("inference present");
10355 assert_eq!(inference.transport, "process");
10356 assert!(inference.command.is_none());
10357 assert_eq!(
10358 inference.model, "",
10359 "absent model resolves to empty (harness default)"
10360 );
10361 }
10362
10363 fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
10364 format!(
10365 "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n transport: process\n \
10366 driver:\n artifact: local/fixture-process-driver\n{inference_extra}"
10367 )
10368 }
10369
10370 const PROCESS_DRIVER_ENTRY: &str =
10371 " - name: local/fixture-process-driver\n version: 0.1.0\n runtime: driver";
10372
10373 #[test]
10374 fn process_transport_accepts_driver() {
10375 let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10376 PROCESS_DRIVER_ENTRY,
10377 " config:\n profile: work\n command: /usr/local/bin/fixture-cli\n",
10378 ))
10379 .unwrap();
10380
10381 let inference = manifest.inference.expect("inference should exist");
10382 assert_eq!(inference.transport, "process");
10383 let driver = inference.driver.expect("driver should be parsed");
10384 assert_eq!(driver.artifact, "local/fixture-process-driver");
10385 assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
10386 assert_eq!(
10387 inference.command.as_deref(),
10388 Some("/usr/local/bin/fixture-cli")
10389 );
10390 }
10391
10392 #[test]
10393 fn process_transport_accepts_driver_without_command() {
10394 let manifest =
10395 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
10396 .unwrap();
10397
10398 let inference = manifest.inference.expect("inference should exist");
10399 assert_eq!(
10400 inference.driver.map(|d| d.artifact).as_deref(),
10401 Some("local/fixture-process-driver")
10402 );
10403 assert!(inference.command.is_none());
10404 assert_eq!(inference.model, "");
10405 }
10406
10407 #[test]
10408 fn process_driver_entry_must_be_declared() {
10409 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10410 " - name: local/other\n version: 0.1.0\n runtime: tool",
10411 "",
10412 ))
10413 .unwrap_err()
10414 .to_string();
10415 assert!(msg.contains("inference.driver.artifact"), "{msg}");
10416 assert!(
10417 msg.contains("artifact 'local/fixture-process-driver' is not declared in artifacts:"),
10418 "{msg}"
10419 );
10420 }
10421
10422 #[test]
10423 fn process_driver_entry_must_be_runtime_driver() {
10424 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10425 " - name: local/fixture-process-driver\n version: 0.1.0\n runtime: tool",
10426 "",
10427 ))
10428 .unwrap_err()
10429 .to_string();
10430 assert!(
10431 msg.contains("must be declared with runtime: driver"),
10432 "{msg}"
10433 );
10434 }
10435
10436 #[test]
10437 fn process_driver_entry_refuses_capabilities() {
10438 let entry =
10439 format!("{PROCESS_DRIVER_ENTRY}\n capabilities:\n env:\n allow: [HOME]");
10440 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
10441 .unwrap_err()
10442 .to_string();
10443 assert!(msg.contains("granted nothing"), "{msg}");
10444 assert!(msg.contains("inference.driver.config"), "{msg}");
10445 }
10446
10447 #[test]
10448 fn process_driver_entry_refuses_config() {
10449 let entry = format!("{PROCESS_DRIVER_ENTRY}\n config:\n profile: work");
10450 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
10451 .unwrap_err()
10452 .to_string();
10453 assert!(msg.contains("granted nothing"), "{msg}");
10454 assert!(msg.contains("remove capabilities:/config:"), "{msg}");
10455 }
10456
10457 #[test]
10458 fn process_transport_rejects_endpoint() {
10459 let err = RuntimeManifest::from_yaml_str(
10460 r#"
10461name: cap
10462version: 0.0.1
10463artifacts: []
10464inference:
10465 transport: process
10466 command: claude
10467 model: claude-haiku-4-5-20251001
10468 endpoint: http://localhost:8080
10469"#,
10470 )
10471 .unwrap_err();
10472
10473 let msg = err.to_string();
10474 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
10475 assert!(msg.contains("was removed"), "error was: {msg}");
10476 }
10477
10478 #[test]
10479 fn process_transport_rejects_api_key() {
10480 let key = ["sk-", "ant-", "secret"].concat();
10483 let err = RuntimeManifest::from_yaml_str(&format!(
10484 r#"
10485name: cap
10486version: 0.0.1
10487artifacts: []
10488inference:
10489 transport: process
10490 command: claude
10491 model: claude-haiku-4-5-20251001
10492 api_key: {key}
10493"#
10494 ))
10495 .unwrap_err();
10496
10497 let msg = err.to_string();
10498 assert!(msg.contains("inference.api_key"), "error was: {msg}");
10499 assert!(msg.contains("was removed"), "error was: {msg}");
10500 assert!(!msg.contains(&key), "error was: {msg}");
10501 }
10502
10503 #[test]
10504 fn process_transport_rejects_max_tokens() {
10505 let err = RuntimeManifest::from_yaml_str(
10508 r#"
10509name: cap
10510version: 0.0.1
10511artifacts: []
10512inference:
10513 transport: process
10514 command: claude
10515 model: claude-haiku-4-5-20251001
10516 max_tokens: 4096
10517"#,
10518 )
10519 .unwrap_err();
10520
10521 let msg = err.to_string();
10522 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
10523 assert!(
10524 msg.contains("not valid with transport: process"),
10525 "error was: {msg}"
10526 );
10527 }
10528
10529 #[test]
10533 fn process_transport_carries_max_session_tokens() {
10534 let manifest = RuntimeManifest::from_yaml_str(
10535 r#"
10536name: cap
10537version: 0.0.1
10538artifacts:
10539 - name: murmur/driver-claude-code
10540 version: 0.1.0
10541 runtime: driver
10542inference:
10543 transport: process
10544 driver:
10545 artifact: murmur/driver-claude-code
10546 model: claude-haiku-4-5-20251001
10547 max_session_tokens: 200000
10548"#,
10549 )
10550 .unwrap();
10551
10552 assert_eq!(
10553 manifest.inference.unwrap().max_session_tokens,
10554 Some(200_000)
10555 );
10556 }
10557
10558 #[test]
10559 fn process_transport_rejects_a_zero_max_session_tokens() {
10560 let err = RuntimeManifest::from_yaml_str(
10561 r#"
10562name: cap
10563version: 0.0.1
10564artifacts:
10565 - name: murmur/driver-claude-code
10566 version: 0.1.0
10567 runtime: driver
10568inference:
10569 transport: process
10570 driver:
10571 artifact: murmur/driver-claude-code
10572 model: claude-haiku-4-5-20251001
10573 max_session_tokens: 0
10574"#,
10575 )
10576 .unwrap_err();
10577
10578 let msg = err.to_string();
10579 assert!(
10580 msg.contains("inference.max_session_tokens"),
10581 "error was: {msg}"
10582 );
10583 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
10584 }
10585
10586 #[test]
10587 fn inference_max_session_tokens_round_trips() {
10588 let manifest = RuntimeManifest::from_yaml_str(
10589 r#"
10590name: cap
10591version: 0.0.1
10592artifacts:
10593 - name: murmur/driver-anthropic
10594 version: 0.1.0
10595 runtime: driver
10596 gateway:
10597 endpoint: https://api.anthropic.com
10598 api_key: test-key
10599inference:
10600 transport: http
10601 model: claude-opus-4-5
10602 max_session_tokens: 5000000000
10603 driver:
10604 artifact: murmur/driver-anthropic
10605"#,
10606 )
10607 .unwrap();
10608
10609 assert_eq!(
10610 manifest.inference.unwrap().max_session_tokens,
10611 Some(5_000_000_000)
10612 );
10613 }
10614
10615 #[test]
10616 fn inference_max_session_tokens_absent_is_none() {
10617 let manifest = RuntimeManifest::from_yaml_str(
10618 r#"
10619name: cap
10620version: 0.0.1
10621artifacts:
10622 - name: murmur/driver-anthropic
10623 version: 0.1.0
10624 runtime: driver
10625 gateway:
10626 endpoint: https://api.anthropic.com
10627 api_key: test-key
10628inference:
10629 transport: http
10630 model: claude-opus-4-5
10631 driver:
10632 artifact: murmur/driver-anthropic
10633"#,
10634 )
10635 .unwrap();
10636
10637 assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
10638 }
10639
10640 #[test]
10641 fn inference_max_session_tokens_zero_is_rejected() {
10642 let err = RuntimeManifest::from_yaml_str(
10643 r#"
10644name: cap
10645version: 0.0.1
10646artifacts:
10647 - name: murmur/driver-anthropic
10648 version: 0.1.0
10649 runtime: driver
10650 gateway:
10651 endpoint: https://api.anthropic.com
10652 api_key: test-key
10653inference:
10654 transport: http
10655 model: claude-opus-4-5
10656 max_session_tokens: 0
10657 driver:
10658 artifact: murmur/driver-anthropic
10659"#,
10660 )
10661 .unwrap_err();
10662
10663 let msg = err.to_string();
10664 assert!(
10665 msg.contains("inference.max_session_tokens"),
10666 "error was: {msg}"
10667 );
10668 assert!(msg.contains("greater than 0"), "error was: {msg}");
10669 }
10670
10671 fn http_manifest_with_inference(extra: &str) -> String {
10673 format!(
10674 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n \
10675 version: 0.1.0\n runtime: driver\n gateway:\n \
10676 endpoint: https://api.anthropic.com\n api_key: test-key\n\
10677 inference:\n transport: http\n model: claude-opus-4-5\n driver:\n \
10678 artifact: murmur/driver-anthropic\n{extra}"
10679 )
10680 }
10681
10682 #[test]
10683 fn inference_tool_refresh_absent_is_compaction() {
10684 let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
10685 assert_eq!(
10686 manifest.inference.unwrap().tool_refresh,
10687 ToolRefresh::Compaction
10688 );
10689 assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
10690 }
10691
10692 #[test]
10693 fn inference_tool_refresh_parses_both_values() {
10694 for (value, expected) in [
10695 ("compaction", ToolRefresh::Compaction),
10696 ("immediate", ToolRefresh::Immediate),
10697 ] {
10698 let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
10699 " tool_refresh: {value}\n"
10700 )))
10701 .unwrap();
10702 assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
10703 assert_eq!(expected.wire_name(), value);
10704 assert!(
10705 manifest.unknown_keys.is_empty(),
10706 "tool_refresh is a known key: {:?}",
10707 manifest.unknown_keys
10708 );
10709 }
10710 }
10711
10712 #[test]
10713 fn inference_tool_refresh_rejects_an_unknown_value() {
10714 let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
10715 " tool_refresh: sometimes\n",
10716 ))
10717 .unwrap_err();
10718 match err {
10719 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10720 assert_eq!(field, "inference.tool_refresh");
10721 assert_eq!(
10722 message,
10723 "must be one of: compaction, immediate; got 'sometimes'"
10724 );
10725 }
10726 other => panic!("unexpected error: {other:?}"),
10727 }
10728 }
10729
10730 #[test]
10733 fn process_transport_rejects_tool_refresh() {
10734 for value in ["compaction", "immediate"] {
10735 let err = RuntimeManifest::from_yaml_str(&format!(
10736 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n \
10737 command: claude\n model: claude-haiku-4-5-20251001\n tool_refresh: {value}\n"
10738 ))
10739 .unwrap_err();
10740 match err {
10741 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10742 assert_eq!(field, "inference.tool_refresh");
10743 assert_eq!(message, "is not valid with transport: process");
10744 }
10745 other => panic!("unexpected error for {value}: {other:?}"),
10746 }
10747 }
10748 }
10749
10750 #[test]
10751 fn inference_max_tokens_round_trips() {
10752 let manifest = RuntimeManifest::from_yaml_str(
10753 r#"
10754name: cap
10755version: 0.0.1
10756artifacts:
10757 - name: murmur/driver-anthropic
10758 version: 0.1.0
10759 runtime: driver
10760 gateway:
10761 endpoint: https://api.anthropic.com
10762 api_key: test-key
10763inference:
10764 transport: http
10765 model: claude-opus-4-5
10766 max_tokens: 4096
10767 driver:
10768 artifact: murmur/driver-anthropic
10769"#,
10770 )
10771 .unwrap();
10772
10773 let inference = manifest.inference.unwrap();
10774 assert_eq!(inference.max_tokens, Some(4096));
10775 }
10776
10777 #[test]
10778 fn inference_max_tokens_absent_is_none() {
10779 let manifest = RuntimeManifest::from_yaml_str(
10782 r#"
10783name: cap
10784version: 0.0.1
10785artifacts:
10786 - name: murmur/driver-anthropic
10787 version: 0.1.0
10788 runtime: driver
10789 gateway:
10790 endpoint: https://api.anthropic.com
10791 api_key: test-key
10792inference:
10793 transport: http
10794 model: claude-opus-4-5
10795 driver:
10796 artifact: murmur/driver-anthropic
10797"#,
10798 )
10799 .unwrap();
10800
10801 assert_eq!(manifest.inference.unwrap().max_tokens, None);
10802 }
10803
10804 #[test]
10805 fn inference_max_tokens_zero_is_rejected() {
10806 let err = RuntimeManifest::from_yaml_str(
10807 r#"
10808name: cap
10809version: 0.0.1
10810artifacts:
10811 - name: murmur/driver-anthropic
10812 version: 0.1.0
10813 runtime: driver
10814 gateway:
10815 endpoint: https://api.anthropic.com
10816 api_key: test-key
10817inference:
10818 transport: http
10819 model: claude-opus-4-5
10820 max_tokens: 0
10821 driver:
10822 artifact: murmur/driver-anthropic
10823"#,
10824 )
10825 .unwrap_err();
10826
10827 let msg = err.to_string();
10828 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
10829 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
10830 }
10831
10832 #[test]
10833 fn inference_max_tokens_large_value_is_accepted() {
10834 let manifest = RuntimeManifest::from_yaml_str(
10837 r#"
10838name: cap
10839version: 0.0.1
10840artifacts:
10841 - name: murmur/driver-anthropic
10842 version: 0.1.0
10843 runtime: driver
10844 gateway:
10845 endpoint: https://api.anthropic.com
10846 api_key: test-key
10847inference:
10848 transport: http
10849 model: claude-opus-4-5
10850 max_tokens: 999999
10851 driver:
10852 artifact: murmur/driver-anthropic
10853"#,
10854 )
10855 .unwrap();
10856
10857 assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
10858 }
10859
10860 #[test]
10861 fn inference_and_context_max_tokens_are_independent() {
10862 let manifest = RuntimeManifest::from_yaml_str(
10865 r#"
10866name: cap
10867version: 0.0.1
10868artifacts:
10869 - name: murmur/driver-anthropic
10870 version: 0.1.0
10871 runtime: driver
10872 gateway:
10873 endpoint: https://api.anthropic.com
10874 api_key: test-key
10875inference:
10876 transport: http
10877 model: claude-opus-4-5
10878 max_tokens: 4096
10879 driver:
10880 artifact: murmur/driver-anthropic
10881context:
10882 max_tokens: 200000
10883"#,
10884 )
10885 .unwrap();
10886
10887 assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
10888 assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
10889 }
10890
10891 #[test]
10892 fn hook_config_defaults_when_fields_absent() {
10893 let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
10894 assert_eq!(config.binding, HookBinding::All);
10895 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
10896 assert_eq!(config.commit_policy, HookCommitPolicy::None);
10897 }
10898
10899 #[test]
10900 fn hook_config_parses_all_fields() {
10901 let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
10902 let config = parse_hook_config_from_yaml(yaml).unwrap();
10903 assert_eq!(config.binding, HookBinding::OnCompaction);
10904 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
10905 assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
10906 }
10907
10908 #[test]
10909 fn hook_config_parses_on_task_start() {
10910 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
10911 let config = parse_hook_config_from_yaml(yaml).unwrap();
10912 assert_eq!(config.binding, HookBinding::OnTaskStart);
10913 }
10914
10915 #[test]
10916 fn hook_config_parses_on_task_end() {
10917 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
10918 let config = parse_hook_config_from_yaml(yaml).unwrap();
10919 assert_eq!(config.binding, HookBinding::OnTaskEnd);
10920 }
10921
10922 #[test]
10923 fn hook_config_unknown_binding_lists_task_events() {
10924 let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
10925 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10926 assert!(err.contains("on-task-start"), "error was: {err}");
10927 assert!(err.contains("on-task-end"), "error was: {err}");
10928 }
10929
10930 #[test]
10931 fn hook_config_async_with_commit_is_rejected() {
10932 let yaml =
10933 "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
10934 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10935 assert!(err.contains("async-with-commit"), "error was: {err}");
10936 }
10937
10938 #[test]
10939 fn hook_config_on_stage_async_is_rejected() {
10940 let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
10941 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10942 assert!(err.contains("on-stage"), "error was: {err}");
10943 assert!(err.contains("blocking"), "error was: {err}");
10944 }
10945
10946 #[test]
10947 fn hook_config_async_none_is_valid() {
10948 let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
10949 let config = parse_hook_config_from_yaml(yaml).unwrap();
10950 assert_eq!(config.execution_mode, HookExecutionMode::Async);
10951 assert_eq!(config.commit_policy, HookCommitPolicy::None);
10952 }
10953
10954 #[test]
10955 fn hook_config_parses_reopen_task_commit_policy() {
10956 let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
10957 let config = parse_hook_config_from_yaml(yaml).unwrap();
10958 assert_eq!(config.binding, HookBinding::OnTaskEnd);
10959 assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
10960 }
10961
10962 #[test]
10963 fn hook_config_unknown_commit_policy_lists_reopen_task() {
10964 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
10965 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10966 assert!(err.contains("reopen-task"), "error was: {err}");
10967 }
10968
10969 #[test]
10970 fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
10971 for binding in ["on-shell", "on-tool-call"] {
10972 let yaml =
10973 format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
10974 let config = parse_hook_config_from_yaml(&yaml).unwrap();
10975 assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
10976 }
10977 assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
10978 }
10979
10980 #[test]
10983 fn hook_config_deny_without_a_binding_is_rejected() {
10984 let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
10985 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10986 assert!(
10987 err.contains("requires an explicit binding"),
10988 "error was: {err}"
10989 );
10990 assert!(err.contains("on-shell"), "error was: {err}");
10991 assert!(err.contains("on-tool-call"), "error was: {err}");
10992 }
10993
10994 #[test]
10996 fn hook_config_deny_on_an_ungated_binding_is_rejected() {
10997 let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
10998 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10999 assert!(err.contains("is not valid for binding"), "error was: {err}");
11000 assert!(err.contains("replace-context"), "error was: {err}");
11001 }
11002
11003 #[test]
11005 fn hook_config_async_deny_is_rejected() {
11006 let yaml =
11007 "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
11008 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11009 assert!(err.contains("async-with-commit"), "error was: {err}");
11010 assert!(err.contains("deny"), "error was: {err}");
11011 }
11012
11013 #[test]
11014 fn hook_config_unknown_commit_policy_lists_deny() {
11015 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11016 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11017 assert!(err.contains("deny"), "error was: {err}");
11018 }
11019
11020 #[test]
11023 fn hook_config_async_reopen_task_is_rejected() {
11024 let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
11025 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11026 assert!(err.contains("async-with-commit"), "error was: {err}");
11027 assert!(err.contains("reopen-task"), "error was: {err}");
11028 }
11029
11030 #[test]
11036 fn hook_config_non_committing_binding_rejects_any_commit_policy() {
11037 for binding in ["on-session-start", "on-session-end"] {
11038 let yaml = format!(
11039 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
11040 );
11041 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11042 assert!(err.contains(binding), "error was: {err}");
11043 assert!(err.contains("replace-context"), "error was: {err}");
11044 assert!(
11045 err.contains("honors commit_policy 'none'"),
11046 "error was: {err}"
11047 );
11048 }
11049 }
11050
11051 #[test]
11054 fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
11055 for (binding, declared, honored) in [
11056 ("on-stage", "reopen-task", "write-manifests"),
11057 ("on-compaction", "write-manifests", "replace-context"),
11058 ("on-task-end", "replace-context", "reopen-task"),
11059 ("on-task-start", "replace-context", "seed-context"),
11060 ("on-shell", "replace-context", "deny"),
11061 ("on-tool-call", "replace-context", "deny"),
11062 ] {
11063 let yaml = format!(
11064 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
11065 );
11066 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11067 assert!(err.contains(binding), "error was: {err}");
11068 assert!(
11069 err.contains(&format!("commit_policy '{declared}' is not valid")),
11070 "error was: {err}"
11071 );
11072 assert!(
11073 err.contains(&format!("honors commit_policy '{honored}'")),
11074 "error was: {err}"
11075 );
11076 }
11077 }
11078
11079 #[test]
11082 fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
11083 for declared in ["replace-context", "write-manifests", "reopen-task"] {
11084 let yaml = format!(
11085 "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
11086 );
11087 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11088 assert!(err.contains("on-inference"), "error was: {err}");
11089 assert!(err.contains(declared), "error was: {err}");
11090 assert!(
11091 err.contains("honors commit_policy 'none'"),
11092 "error was: {err}"
11093 );
11094 assert!(err.contains("artifact"), "error was: {err}");
11095 }
11096 }
11097
11098 #[test]
11101 fn hook_config_matching_binding_and_commit_policy_is_valid() {
11102 for (binding, policy, expected) in [
11103 (
11104 "on-stage",
11105 "write-manifests",
11106 HookCommitPolicy::WriteManifests,
11107 ),
11108 (
11109 "on-compaction",
11110 "replace-context",
11111 HookCommitPolicy::ReplaceContext,
11112 ),
11113 ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
11114 ("on-shell", "deny", HookCommitPolicy::Deny),
11115 ("on-tool-call", "deny", HookCommitPolicy::Deny),
11116 ] {
11117 let yaml =
11118 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
11119 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11120 assert_eq!(config.commit_policy, expected);
11121 }
11122 }
11123
11124 #[test]
11127 fn hook_config_commit_policy_none_is_valid_for_every_binding() {
11128 for binding in [
11129 "on-stage",
11130 "on-session-start",
11131 "on-task-start",
11132 "on-inference",
11133 "on-tool-call",
11134 "on-shell",
11135 "on-compaction",
11136 "on-task-end",
11137 "on-session-end",
11138 ] {
11139 let yaml =
11140 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
11141 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11142 assert_eq!(config.commit_policy, HookCommitPolicy::None);
11143 }
11144 }
11145
11146 #[test]
11151 fn hook_config_omitted_binding_accepts_every_commit_policy() {
11152 for (policy, expected) in [
11153 ("replace-context", HookCommitPolicy::ReplaceContext),
11154 ("write-manifests", HookCommitPolicy::WriteManifests),
11155 ("reopen-task", HookCommitPolicy::ReopenTask),
11156 ("none", HookCommitPolicy::None),
11157 ] {
11158 let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
11159 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11160 assert_eq!(config.binding, HookBinding::All);
11161 assert_eq!(config.commit_policy, expected);
11162 }
11163 }
11164
11165 #[test]
11170 fn hook_config_shipped_default_artifact_manifests_still_parse() {
11171 let shipped: [(&str, &str, HookConfig); 8] = [
11172 (
11173 "murmur-hook-compact",
11174 "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
11175 HookConfig {
11176 binding: HookBinding::OnCompaction,
11177 execution_mode: HookExecutionMode::Blocking,
11178 commit_policy: HookCommitPolicy::ReplaceContext,
11179 },
11180 ),
11181 (
11182 "murmur-hook-shell-desc",
11183 "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
11184 HookConfig {
11185 binding: HookBinding::OnStage,
11186 execution_mode: HookExecutionMode::Blocking,
11187 commit_policy: HookCommitPolicy::WriteManifests,
11188 },
11189 ),
11190 (
11191 "murmur-hook-regression-verifier",
11193 "execution_mode: blocking\ncommit_policy: reopen-task\n",
11194 HookConfig {
11195 binding: HookBinding::All,
11196 execution_mode: HookExecutionMode::Blocking,
11197 commit_policy: HookCommitPolicy::ReopenTask,
11198 },
11199 ),
11200 (
11201 "murmur-hook-memory",
11202 "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
11203 HookConfig {
11204 binding: HookBinding::OnTaskStart,
11205 execution_mode: HookExecutionMode::Blocking,
11206 commit_policy: HookCommitPolicy::SeedContext,
11207 },
11208 ),
11209 (
11210 "murmur-hook-debug",
11211 "execution_mode: async\ncommit_policy: none\n",
11212 HookConfig {
11213 binding: HookBinding::All,
11214 execution_mode: HookExecutionMode::Async,
11215 commit_policy: HookCommitPolicy::None,
11216 },
11217 ),
11218 (
11219 "murmur-hook-diff-summary",
11220 "execution_mode: blocking\ncommit_policy: none\n",
11221 HookConfig {
11222 binding: HookBinding::All,
11223 execution_mode: HookExecutionMode::Blocking,
11224 commit_policy: HookCommitPolicy::None,
11225 },
11226 ),
11227 ("murmur-hook-eval", "", HookConfig::default()),
11228 ("murmur-hook-grafana", "", HookConfig::default()),
11229 ];
11230
11231 for (name, fields, expected) in shipped {
11232 let yaml = format!("name: {name}\nruntime: hook\n{fields}");
11233 let config = parse_hook_config_from_yaml(&yaml)
11234 .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
11235 assert_eq!(config, expected, "shipped hook {name}");
11236 }
11237 }
11238
11239 #[test]
11243 fn commit_policy_for_binding_matches_the_declarable_pairs() {
11244 assert_eq!(
11245 commit_policy_for_binding(&HookBinding::OnStage),
11246 Some(HookCommitPolicy::WriteManifests)
11247 );
11248 assert_eq!(
11249 commit_policy_for_binding(&HookBinding::OnCompaction),
11250 Some(HookCommitPolicy::ReplaceContext)
11251 );
11252 assert_eq!(
11253 commit_policy_for_binding(&HookBinding::OnTaskEnd),
11254 Some(HookCommitPolicy::ReopenTask)
11255 );
11256 assert_eq!(
11257 commit_policy_for_binding(&HookBinding::OnTaskStart),
11258 Some(HookCommitPolicy::SeedContext)
11259 );
11260 assert_eq!(
11261 commit_policy_for_binding(&HookBinding::OnToolCall),
11262 Some(HookCommitPolicy::Deny)
11263 );
11264 assert_eq!(
11265 commit_policy_for_binding(&HookBinding::OnShell),
11266 Some(HookCommitPolicy::Deny)
11267 );
11268 for binding in [
11272 HookBinding::OnSessionStart,
11273 HookBinding::OnInference,
11274 HookBinding::OnSessionEnd,
11275 HookBinding::All,
11276 ] {
11277 assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
11278 }
11279 }
11280
11281 #[test]
11284 fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
11285 let config = parse_hook_config_from_yaml(
11286 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
11287 commit_policy: seed-context\n",
11288 )
11289 .expect("on-task-start + seed-context is the declarable pairing");
11290 assert_eq!(config.binding, HookBinding::OnTaskStart);
11291 assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
11292
11293 let error = parse_hook_config_from_yaml(
11294 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
11295 commit_policy: seed-context\n",
11296 )
11297 .expect_err("on-compaction cannot commit a seed");
11298 assert!(error.contains("on-compaction"), "{error}");
11299 assert!(error.contains("seed-context"), "{error}");
11300 }
11301
11302 #[test]
11305 fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
11306 let error = parse_hook_config_from_yaml(
11307 "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
11308 )
11309 .expect_err("a misspelled policy is rejected");
11310 assert!(error.contains("seed-context"), "{error}");
11311 }
11312
11313 fn context_of(yaml_block: &str) -> Option<ContextConfig> {
11316 RuntimeManifest::from_yaml_str(&format!(
11317 "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
11318 ))
11319 .expect("the manifest under test parses")
11320 .context
11321 }
11322
11323 #[test]
11324 fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
11325 let context = context_of(
11326 "context:\n max_tokens: 200000\n seed_budget: 0.10\n seed_overflow_margin: 0.10\n",
11327 )
11328 .expect("a declared context block parses to Some");
11329 assert_eq!(context.max_tokens, Some(200_000));
11330 assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
11331 assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
11332 }
11333
11334 #[test]
11335 fn seed_budget_and_overflow_margin_default_when_omitted() {
11336 let context = context_of("context:\n max_tokens: 200000\n")
11337 .expect("a declared context block parses to Some");
11338 assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
11339 assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
11340 }
11341
11342 #[test]
11345 fn seed_budget_defaults_do_not_create_a_context_block() {
11346 assert_eq!(context_of(""), None);
11347 }
11348
11349 #[test]
11350 fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
11351 for (block, field) in [
11352 ("context:\n seed_budget: 1.5\n", "context.seed_budget"),
11353 ("context:\n seed_budget: -0.1\n", "context.seed_budget"),
11354 (
11355 "context:\n seed_overflow_margin: 2.0\n",
11356 "context.seed_overflow_margin",
11357 ),
11358 ] {
11359 let error = RuntimeManifest::from_yaml_str(&format!(
11360 "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
11361 ))
11362 .expect_err("an out-of-range fraction is rejected");
11363 match error {
11364 RuntimeManifestError::InvalidInferenceConfig {
11365 field: got,
11366 ref message,
11367 } => {
11368 assert_eq!(got, field);
11369 assert!(message.contains("0.0 and 1.0"), "{message}");
11370 }
11371 other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
11372 }
11373 }
11374 }
11375
11376 #[test]
11377 fn parse_tool_implementation_defaults_to_wasm() {
11378 let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
11379 assert_eq!(impl_, ArtifactImplementation::Wasm);
11380 }
11381
11382 #[test]
11383 fn parse_tool_implementation_native() {
11384 let impl_ = parse_tool_implementation_from_yaml(
11385 "name: my-tool\nruntime: tool\nimplementation: native\n",
11386 );
11387 assert_eq!(impl_, ArtifactImplementation::Native);
11388 }
11389
11390 #[test]
11391 fn deploy_block_mur_version_parses() {
11392 let manifest = RuntimeManifest::from_yaml_str(
11393 "name: cap\nversion: 0.1.0\nartifacts: []\ndeploy:\n mur_version: \"0.4.5\"\n",
11394 )
11395 .unwrap();
11396 assert_eq!(manifest.deploy_mur_version(), Some("0.4.5"));
11397 assert_eq!(
11398 manifest.deploy,
11399 Some(DeployConfig {
11400 mur_version: Some("0.4.5".to_string())
11401 })
11402 );
11403 }
11404
11405 #[test]
11406 fn deploy_block_absent_is_none() {
11407 let manifest =
11408 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
11409 assert_eq!(manifest.deploy, None);
11410 assert_eq!(manifest.deploy_mur_version(), None);
11411 }
11412
11413 #[test]
11415 fn deploy_block_blank_mur_version_is_none() {
11416 for deploy in [
11417 "deploy:\n mur_version: \"\"\n",
11418 "deploy:\n mur_version: \" \"\n",
11419 ] {
11420 let manifest = RuntimeManifest::from_yaml_str(&format!(
11421 "name: cap\nversion: 0.1.0\nartifacts: []\n{deploy}"
11422 ))
11423 .unwrap();
11424 assert_eq!(manifest.deploy_mur_version(), None, "{deploy:?}");
11425 }
11426 let manifest =
11427 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\ndeploy: {}\n").unwrap();
11428 assert_eq!(manifest.deploy, Some(DeployConfig::default()));
11429 assert_eq!(manifest.deploy_mur_version(), None);
11430 }
11431
11432 #[test]
11433 fn deploy_block_misspelled_key_is_reported_under_deploy() {
11434 assert_eq!(
11435 unknown_keys_of("name: cap\nversion: 0.1.0\ndeploy:\n mur_versoin: \"0.4.5\"\n"),
11436 vec![(
11437 "mur_versoin".to_string(),
11438 "deploy".to_string(),
11439 Some("mur_version".to_string())
11440 )]
11441 );
11442 }
11443
11444 #[test]
11445 fn a_top_level_mur_version_is_refused_naming_deploy_mur_version() {
11446 for pin in [
11447 "mur_version: \"0.4.5\"\n",
11448 "mur_version: 0.4.0\n",
11449 "mur_version: \"\"\n",
11450 "mur_version:\n",
11451 ] {
11452 let error = RuntimeManifest::from_yaml_str(&format!(
11453 "name: cap\nversion: 0.1.0\nartifacts: []\n{pin}"
11454 ))
11455 .expect_err(pin);
11456 assert!(
11457 matches!(error, RuntimeManifestError::TopLevelMurVersion),
11458 "{pin:?}: {error:?}"
11459 );
11460 assert!(error.to_string().contains("deploy.mur_version"), "{error}");
11461 }
11462 assert_eq!(
11463 RuntimeManifestError::TopLevelMurVersion.to_string(),
11464 "murmur.yaml: 'mur_version' is not a top-level key — the mur release a deploy \
11465 installs is pinned as 'deploy.mur_version'"
11466 );
11467 }
11468
11469 #[test]
11470 fn a_top_level_mur_version_is_refused_beside_a_deploy_block() {
11471 let yaml = "name: cap\nversion: 0.1.0\nmur_version: \"0.4.5\"\ndeploy:\n mur_version: \"0.4.5\"\n";
11472 assert!(matches!(
11473 RuntimeManifest::from_yaml_str(yaml),
11474 Err(RuntimeManifestError::TopLevelMurVersion)
11475 ));
11476 assert!(matches!(
11477 RuntimeManifest::from_yaml_str_without_secrets(yaml),
11478 Err(RuntimeManifestError::TopLevelMurVersion)
11479 ));
11480 }
11481
11482 #[test]
11483 fn inference_max_turns_absent_sets_no_cap() {
11484 let manifest = RuntimeManifest::from_yaml_str(
11485 "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",
11486 ).unwrap();
11487 assert_eq!(manifest.inference.unwrap().max_turns, None);
11488 }
11489
11490 #[test]
11491 fn inference_max_turns_explicit_value() {
11492 let manifest = RuntimeManifest::from_yaml_str(
11493 "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",
11494 ).unwrap();
11495 assert_eq!(manifest.inference.unwrap().max_turns, Some(20));
11496 }
11497
11498 #[test]
11499 fn inference_max_turns_zero_is_rejected() {
11500 let err = RuntimeManifest::from_yaml_str(
11501 "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",
11502 ).unwrap_err();
11503 let msg = err.to_string();
11504 assert!(msg.contains("inference.max_turns"), "error was: {msg}");
11505 assert!(msg.contains("greater than 0"), "error was: {msg}");
11506 }
11507
11508 #[test]
11510 fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
11511 let manifest = RuntimeManifest::from_yaml_str(
11512 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 45\n",
11513 )
11514 .unwrap();
11515 let lifecycle = manifest.lifecycle.unwrap();
11516 assert_eq!(lifecycle.shell_grace_secs, 45);
11517 assert_eq!(
11519 lifecycle.task_acceptance,
11520 LifecycleConfig::default().task_acceptance
11521 );
11522 assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
11523 assert_eq!(
11524 lifecycle.queue_depth,
11525 LifecycleConfig::default().queue_depth
11526 );
11527 assert_eq!(lifecycle.input_timeout_secs, None);
11528 assert_eq!(
11529 lifecycle.conversation_mode,
11530 LifecycleConfig::default().conversation_mode
11531 );
11532 assert_eq!(
11533 lifecycle.max_task_reopens,
11534 LifecycleConfig::default().max_task_reopens
11535 );
11536 }
11537
11538 #[test]
11539 fn lifecycle_shell_grace_secs_defaults_to_10() {
11540 let manifest = RuntimeManifest::from_yaml_str(
11541 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
11542 )
11543 .unwrap();
11544 let lifecycle = manifest.lifecycle.unwrap();
11545 assert_eq!(lifecycle.shell_grace_secs, 10);
11546 assert_eq!(lifecycle.queue_depth, 3);
11547
11548 let without_block =
11549 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11550 assert_eq!(without_block.effective_lifecycle().shell_grace_secs, 10);
11551 }
11552
11553 #[test]
11556 fn lifecycle_delegation_deadline_secs_defaults_to_600() {
11557 let without_block =
11558 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11559 assert_eq!(
11560 without_block.effective_lifecycle().delegation_deadline_secs,
11561 600
11562 );
11563
11564 let without_key = RuntimeManifest::from_yaml_str(
11565 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
11566 )
11567 .unwrap();
11568 let lifecycle = without_key.lifecycle.unwrap();
11569 assert_eq!(lifecycle.delegation_deadline_secs, 600);
11570 assert_eq!(lifecycle.queue_depth, 3);
11571 }
11572
11573 #[test]
11574 fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
11575 let manifest = RuntimeManifest::from_yaml_str(
11576 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
11577 delegation_deadline_secs: 30\n",
11578 )
11579 .unwrap();
11580 let lifecycle = manifest.lifecycle.unwrap();
11581 assert_eq!(lifecycle.delegation_deadline_secs, 30);
11582 assert_eq!(
11584 lifecycle.shell_grace_secs,
11585 LifecycleConfig::default().shell_grace_secs
11586 );
11587 assert_eq!(
11588 lifecycle.max_task_reopens,
11589 LifecycleConfig::default().max_task_reopens
11590 );
11591 }
11592
11593 #[test]
11595 fn lifecycle_delegation_deadline_secs_is_a_known_key() {
11596 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
11597 let manifest = RuntimeManifest::from_yaml_str(
11598 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
11599 delegation_deadline_secs: 30\n delegation_deadline: 30\n",
11600 )
11601 .unwrap();
11602 let reported: Vec<(String, String)> = manifest
11603 .unknown_keys
11604 .iter()
11605 .map(|key| (key.key.clone(), key.block_path.clone()))
11606 .collect();
11607 assert_eq!(
11608 reported,
11609 vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
11610 "the declared key is known and only the misspelt neighbour is reported"
11611 );
11612 assert_eq!(
11613 manifest.lifecycle.unwrap().delegation_deadline_secs,
11614 30,
11615 "the misspelt neighbour does not disturb the declared value"
11616 );
11617 }
11618
11619 #[test]
11621 fn lifecycle_deadline_notice_secs_defaults_to_none() {
11622 let without_block =
11623 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11624 assert_eq!(
11625 without_block.effective_lifecycle().deadline_notice_secs,
11626 None
11627 );
11628 let without_key = RuntimeManifest::from_yaml_str(
11629 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
11630 )
11631 .unwrap();
11632 assert_eq!(without_key.lifecycle.unwrap().deadline_notice_secs, None);
11633 }
11634
11635 #[test]
11637 fn lifecycle_deadline_notice_secs_takes_zero_and_positive_values() {
11638 for (declared, expected) in [("0", 0), ("90", 90)] {
11639 let manifest = RuntimeManifest::from_yaml_str(&format!(
11640 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
11641 deadline_notice_secs: {declared}\n"
11642 ))
11643 .unwrap();
11644 assert!(manifest.unknown_keys.is_empty());
11645 assert_eq!(
11646 manifest.lifecycle.unwrap().deadline_notice_secs,
11647 Some(expected)
11648 );
11649 }
11650 }
11651
11652 #[test]
11653 fn lifecycle_deadline_notice_secs_refuses_a_negative_or_non_integer_value() {
11654 for declared in ["-5", "1.5", "soon"] {
11655 let refused = RuntimeManifest::from_yaml_str(&format!(
11656 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
11657 deadline_notice_secs: {declared}\n"
11658 ));
11659 assert!(refused.is_err(), "{declared} is refused: {refused:?}");
11660 }
11661 }
11662
11663 #[test]
11664 fn lifecycle_deadline_notice_secs_is_a_known_key() {
11665 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"deadline_notice_secs"));
11666 }
11667
11668 fn lifecycle_with_keep_working(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
11669 RuntimeManifest::from_yaml_str(&format!(
11670 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n keep_working:\n{block}"
11671 ))
11672 }
11673
11674 fn refused_keep_working_field(block: &str) -> (String, String) {
11675 match lifecycle_with_keep_working(block) {
11676 Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
11677 (field, message)
11678 }
11679 other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
11680 }
11681 }
11682
11683 #[test]
11684 fn lifecycle_keep_working_is_off_when_absent() {
11685 let without_block =
11686 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11687 assert_eq!(without_block.effective_lifecycle().keep_working, None);
11688 let without_key = RuntimeManifest::from_yaml_str(
11689 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
11690 )
11691 .unwrap();
11692 assert_eq!(without_key.lifecycle.unwrap().keep_working, None);
11693 }
11694
11695 #[test]
11696 fn lifecycle_keep_working_takes_the_declared_message_and_limit() {
11697 let manifest =
11698 lifecycle_with_keep_working(" message: \"go on\"\n limit: 3\n").unwrap();
11699 assert!(
11700 manifest.unknown_keys.is_empty(),
11701 "{:?}",
11702 manifest.unknown_keys
11703 );
11704 assert_eq!(
11705 manifest.lifecycle.unwrap().keep_working,
11706 Some(KeepWorking {
11707 message: "go on".into(),
11708 limit: 3
11709 })
11710 );
11711 }
11712
11713 #[test]
11715 fn lifecycle_keep_working_keeps_the_message_as_written() {
11716 let manifest =
11717 lifecycle_with_keep_working(" message: \" go on \\n\"\n limit: 1\n").unwrap();
11718 assert_eq!(
11719 manifest.lifecycle.unwrap().keep_working.unwrap().message,
11720 " go on \n"
11721 );
11722 }
11723
11724 #[test]
11725 fn lifecycle_keep_working_refuses_a_missing_or_blank_message() {
11726 for block in [
11727 " limit: 3\n",
11728 " message: \"\"\n limit: 3\n",
11729 " message: \" \\n\\t \"\n limit: 3\n",
11730 ] {
11731 let (field, _) = refused_keep_working_field(block);
11732 assert_eq!(field, "lifecycle.keep_working.message", "{block:?}");
11733 }
11734 }
11735
11736 #[test]
11737 fn lifecycle_keep_working_refuses_a_missing_limit() {
11738 let (field, message) = refused_keep_working_field(" message: go on\n");
11739 assert_eq!(field, "lifecycle.keep_working.limit");
11740 assert_eq!(message, "is required");
11741 }
11742
11743 #[test]
11744 fn lifecycle_keep_working_refuses_a_zero_limit_and_says_how_to_turn_it_off() {
11745 let (field, message) = refused_keep_working_field(" message: go on\n limit: 0\n");
11746 assert_eq!(field, "lifecycle.keep_working.limit");
11747 assert!(
11748 message.contains("omit lifecycle.keep_working"),
11749 "the refusal says how to turn the option off: {message}"
11750 );
11751 }
11752
11753 #[test]
11754 fn lifecycle_keep_working_reports_an_unknown_key_inside_the_block() {
11755 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"keep_working"));
11756 let manifest =
11757 lifecycle_with_keep_working(" message: go on\n limit: 2\n lmit: 3\n").unwrap();
11758 let reported: Vec<(String, String)> = manifest
11759 .unknown_keys
11760 .iter()
11761 .map(|key| (key.key.clone(), key.block_path.clone()))
11762 .collect();
11763 assert_eq!(
11764 reported,
11765 vec![("lmit".to_string(), "lifecycle.keep_working".to_string())]
11766 );
11767 }
11768
11769 fn lifecycle_with_done_check(
11770 allow: &str,
11771 block: &str,
11772 ) -> Result<RuntimeManifest, RuntimeManifestError> {
11773 RuntimeManifest::from_yaml_str(&format!(
11774 "name: cap\nversion: 0.0.1\nartifacts: []\n{allow}lifecycle:\n done_check:\n{block}"
11775 ))
11776 }
11777
11778 const ALLOW_BASH: &str = "capabilities:\n shell:\n allow: [bash]\n";
11779
11780 fn refused_done_check_field(allow: &str, block: &str) -> (String, String) {
11781 match lifecycle_with_done_check(allow, block) {
11782 Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
11783 (field, message)
11784 }
11785 other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
11786 }
11787 }
11788
11789 #[test]
11790 fn lifecycle_done_check_is_off_when_absent() {
11791 let without_block =
11792 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11793 assert_eq!(without_block.effective_lifecycle().done_check, None);
11794 let without_key = RuntimeManifest::from_yaml_str(
11795 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
11796 )
11797 .unwrap();
11798 assert_eq!(without_key.lifecycle.unwrap().done_check, None);
11799 }
11800
11801 #[test]
11802 fn lifecycle_done_check_takes_the_command_and_limit_with_the_default_timeout() {
11803 let manifest = lifecycle_with_done_check(
11804 ALLOW_BASH,
11805 " command: \"bash -c 'true'\"\n limit: 2\n",
11806 )
11807 .unwrap();
11808 assert!(
11809 manifest.unknown_keys.is_empty(),
11810 "{:?}",
11811 manifest.unknown_keys
11812 );
11813 let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
11814 assert_eq!(
11815 done_check,
11816 DoneCheck {
11817 command: "bash -c 'true'".into(),
11818 limit: 2,
11819 timeout_secs: 600,
11820 }
11821 );
11822 assert_eq!(done_check.argv(), ["bash", "-c", "true"]);
11823 }
11824
11825 #[test]
11827 fn lifecycle_done_check_accepts_a_zero_limit_and_an_explicit_timeout() {
11828 let manifest = lifecycle_with_done_check(
11829 ALLOW_BASH,
11830 " command: bash -c 'exit 1'\n limit: 0\n timeout_secs: 5\n",
11831 )
11832 .unwrap();
11833 let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
11834 assert_eq!((done_check.limit, done_check.timeout_secs), (0, 5));
11835 }
11836
11837 #[test]
11838 fn lifecycle_done_check_refuses_each_invalid_field() {
11839 for (allow, block, field, message) in [
11840 (ALLOW_BASH, " limit: 1\n", "command", "is required"),
11841 (
11842 ALLOW_BASH,
11843 " command: \" \"\n limit: 1\n",
11844 "command",
11845 "must not be blank",
11846 ),
11847 (
11848 ALLOW_BASH,
11849 " command: \"''\"\n limit: 1\n",
11850 "command",
11851 "must name a program",
11852 ),
11853 (
11854 ALLOW_BASH,
11855 " command: cargo test\n limit: 1\n",
11856 "command",
11857 "runs 'cargo', which is not in capabilities.shell.allow",
11858 ),
11859 (
11860 "",
11861 " command: bash -c true\n limit: 1\n",
11862 "command",
11863 "runs 'bash', which is not in capabilities.shell.allow",
11864 ),
11865 (
11866 ALLOW_BASH,
11867 " command: bash -c true\n",
11868 "limit",
11869 "is required",
11870 ),
11871 (
11872 ALLOW_BASH,
11873 " command: bash -c true\n limit: 1\n timeout_secs: 0\n",
11874 "timeout_secs",
11875 "must be at least 1",
11876 ),
11877 ] {
11878 assert_eq!(
11879 refused_done_check_field(allow, block),
11880 (format!("lifecycle.done_check.{field}"), message.to_string()),
11881 "{block:?}"
11882 );
11883 }
11884 }
11885
11886 #[test]
11887 fn lifecycle_done_check_reports_an_unknown_key_inside_the_block() {
11888 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"done_check"));
11889 let manifest = lifecycle_with_done_check(
11890 ALLOW_BASH,
11891 " command: bash -c true\n limit: 2\n limt: 3\n",
11892 )
11893 .unwrap();
11894 let reported: Vec<(String, String)> = manifest
11895 .unknown_keys
11896 .iter()
11897 .map(|key| (key.key.clone(), key.block_path.clone()))
11898 .collect();
11899 assert_eq!(
11900 reported,
11901 vec![("limt".to_string(), "lifecycle.done_check".to_string())]
11902 );
11903 }
11904
11905 #[test]
11908 fn lifecycle_shell_grace_secs_zero_is_accepted() {
11909 let manifest = RuntimeManifest::from_yaml_str(
11910 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 0\n",
11911 )
11912 .unwrap();
11913 let lifecycle = manifest.lifecycle.unwrap();
11914 assert_eq!(lifecycle.shell_grace_secs, 0);
11915 assert_eq!(
11916 lifecycle.max_task_reopens,
11917 LifecycleConfig::default().max_task_reopens
11918 );
11919 }
11920
11921 #[test]
11922 fn lifecycle_max_task_reopens_defaults_to_1() {
11923 let manifest = RuntimeManifest::from_yaml_str(
11924 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n after_task: sleep\n",
11925 )
11926 .unwrap();
11927 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
11928 }
11929
11930 #[test]
11932 fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
11933 let manifest =
11934 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11935 assert!(manifest.lifecycle.is_none());
11936 assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
11937 }
11938
11939 #[test]
11940 fn lifecycle_max_task_reopens_explicit_value() {
11941 let manifest = RuntimeManifest::from_yaml_str(
11942 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 3\n",
11943 )
11944 .unwrap();
11945 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
11946 }
11947
11948 #[test]
11950 fn lifecycle_max_task_reopens_zero_is_accepted() {
11951 let manifest = RuntimeManifest::from_yaml_str(
11952 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 0\n",
11953 )
11954 .unwrap();
11955 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
11956 }
11957
11958 #[test]
11960 fn lifecycle_max_task_reopens_with_process_transport() {
11961 let manifest = RuntimeManifest::from_yaml_str(
11962 "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",
11963 ).unwrap();
11964 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
11965 }
11966
11967 #[test]
11968 fn inference_max_task_reopens_is_rejected_under_http_transport() {
11969 let err = RuntimeManifest::from_yaml_str(
11970 "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",
11971 ).unwrap_err();
11972 assert!(
11973 matches!(
11974 &err,
11975 RuntimeManifestError::InvalidInferenceConfig { field, .. }
11976 if field == "inference.max_task_reopens"
11977 ),
11978 "error was: {err:?}"
11979 );
11980 let msg = err.to_string();
11981 assert!(
11982 msg.contains("lifecycle.max_task_reopens"),
11983 "error was: {msg}"
11984 );
11985 }
11986
11987 #[test]
11988 fn inference_max_task_reopens_is_rejected_under_process_transport() {
11989 let err = RuntimeManifest::from_yaml_str(
11990 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n max_task_reopens: 2\n",
11991 ).unwrap_err();
11992 assert!(
11993 matches!(
11994 &err,
11995 RuntimeManifestError::InvalidInferenceConfig { field, .. }
11996 if field == "inference.max_task_reopens"
11997 ),
11998 "error was: {err:?}"
11999 );
12000 let msg = err.to_string();
12001 assert!(
12002 msg.contains("lifecycle.max_task_reopens"),
12003 "error was: {msg}"
12004 );
12005 }
12006
12007 #[test]
12009 fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
12010 let err = RuntimeManifest::from_yaml_str(
12011 "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",
12012 ).unwrap_err();
12013 assert!(
12014 matches!(
12015 &err,
12016 RuntimeManifestError::InvalidInferenceConfig { field, .. }
12017 if field == "inference.max_task_reopens"
12018 ),
12019 "error was: {err:?}"
12020 );
12021 }
12022
12023 #[test]
12024 fn skill_is_llm_visible() {
12025 assert!(ArtifactRuntime::Skill.is_llm_visible());
12026 }
12027
12028 #[test]
12029 fn driver_and_hook_are_not_llm_visible() {
12030 assert!(!ArtifactRuntime::Driver.is_llm_visible());
12031 assert!(!ArtifactRuntime::Hook.is_llm_visible());
12032 }
12033
12034 #[test]
12035 fn parses_system_prompt_artifact() {
12036 let manifest = RuntimeManifest::from_yaml_str(
12037 r#"
12038name: cap
12039version: 0.0.1
12040artifacts:
12041 - name: local/my-skill
12042 version: 1.0.0
12043 runtime: skill
12044 - name: murmur/driver-anthropic
12045 version: 0.1.0
12046 runtime: driver
12047 gateway:
12048 endpoint: http://127.0.0.1:8080
12049 keyless: true
12050inference:
12051 model: test-model
12052 system_prompt_artifact: local/my-skill
12053 driver:
12054 artifact: murmur/driver-anthropic
12055"#,
12056 )
12057 .unwrap();
12058
12059 let inference = manifest.inference.unwrap();
12060 assert_eq!(
12061 inference.system_prompt_artifact,
12062 Some("local/my-skill".to_string())
12063 );
12064 assert!(inference.system_prompt.is_none());
12065 assert!(inference.system_prompt_file.is_none());
12066 }
12067
12068 #[test]
12069 fn rejects_system_prompt_artifact_with_inline_prompt() {
12070 let err = RuntimeManifest::from_yaml_str(
12071 r#"
12072name: cap
12073version: 0.0.1
12074artifacts:
12075 - name: local/my-skill
12076 version: 1.0.0
12077 runtime: skill
12078 - name: murmur/driver-anthropic
12079 version: 0.1.0
12080 runtime: driver
12081 gateway:
12082 endpoint: http://127.0.0.1:8080
12083 keyless: true
12084inference:
12085 model: test-model
12086 system_prompt: "inline"
12087 system_prompt_artifact: local/my-skill
12088 driver:
12089 artifact: murmur/driver-anthropic
12090"#,
12091 )
12092 .unwrap_err();
12093
12094 let msg = err.to_string();
12095 assert!(msg.contains("at most one"), "error was: {msg}");
12096 }
12097
12098 #[test]
12099 fn rejects_system_prompt_artifact_naming_non_skill() {
12100 let err = RuntimeManifest::from_yaml_str(
12101 r#"
12102name: cap
12103version: 0.0.1
12104artifacts:
12105 - name: local/my-tool
12106 version: 1.0.0
12107 runtime: tool
12108 - name: murmur/driver-anthropic
12109 version: 0.1.0
12110 runtime: driver
12111 gateway:
12112 endpoint: http://127.0.0.1:8080
12113 keyless: true
12114inference:
12115 model: test-model
12116 system_prompt_artifact: local/my-tool
12117 driver:
12118 artifact: murmur/driver-anthropic
12119"#,
12120 )
12121 .unwrap_err();
12122
12123 let msg = err.to_string();
12124 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12125 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
12126 }
12127
12128 #[test]
12129 fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
12130 let err = RuntimeManifest::from_yaml_str(
12131 r#"
12132name: cap
12133version: 0.0.1
12134artifacts:
12135 - name: murmur/driver-anthropic
12136 version: 0.1.0
12137 runtime: driver
12138 gateway:
12139 endpoint: http://127.0.0.1:8080
12140 keyless: true
12141inference:
12142 model: test-model
12143 system_prompt_artifact: missing-skill
12144 driver:
12145 artifact: murmur/driver-anthropic
12146"#,
12147 )
12148 .unwrap_err();
12149
12150 let msg = err.to_string();
12151 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12152 assert!(msg.contains("not declared"), "error was: {msg}");
12153 }
12154
12155 #[test]
12156 fn rejects_all_three_system_prompt_sources() {
12157 let err = RuntimeManifest::from_yaml_str(
12158 r#"
12159name: cap
12160version: 0.0.1
12161artifacts:
12162 - name: local/my-skill
12163 version: 1.0.0
12164 runtime: skill
12165 - name: murmur/driver-anthropic
12166 version: 0.1.0
12167 runtime: driver
12168 gateway:
12169 endpoint: http://127.0.0.1:8080
12170 keyless: true
12171inference:
12172 model: test-model
12173 system_prompt: "inline"
12174 system_prompt_file: "conventions.md"
12175 system_prompt_artifact: local/my-skill
12176 driver:
12177 artifact: murmur/driver-anthropic
12178"#,
12179 )
12180 .unwrap_err();
12181
12182 let msg = err.to_string();
12183 assert!(msg.contains("at most one"), "error was: {msg}");
12184 }
12185
12186 #[test]
12189 fn hook_artifact_capabilities_parse_network_and_filesystem() {
12190 let manifest = RuntimeManifest::from_yaml_str(
12191 r#"
12192name: cap
12193version: 0.0.1
12194artifacts:
12195 - name: local/telemetry-hook
12196 version: 1.0.0
12197 runtime: hook
12198 capabilities:
12199 network:
12200 allow:
12201 - https://telemetry.example.com
12202 filesystem:
12203 scope: hook-state
12204"#,
12205 )
12206 .unwrap();
12207
12208 let caps = manifest.artifacts[0]
12209 .capabilities
12210 .as_ref()
12211 .expect("hook entry carries a capability grant");
12212 assert_eq!(
12213 caps.network.as_ref().unwrap().allow,
12214 vec!["https://telemetry.example.com".to_string()]
12215 );
12216 assert_eq!(
12217 caps.filesystem.as_ref().unwrap().scope,
12218 Some("hook-state".to_string())
12219 );
12220 }
12221
12222 #[test]
12225 fn hook_artifact_without_capabilities_is_none() {
12226 let manifest = RuntimeManifest::from_yaml_str(
12227 r#"
12228name: cap
12229version: 0.0.1
12230artifacts:
12231 - name: local/quiet-hook
12232 version: 1.0.0
12233 runtime: hook
12234"#,
12235 )
12236 .unwrap();
12237
12238 assert!(manifest.artifacts[0].capabilities.is_none());
12239 }
12240
12241 #[test]
12244 fn tool_and_driver_artifact_capabilities_parse() {
12245 for role in ["tool", "driver"] {
12246 let yaml = format!(
12247 r#"
12248name: cap
12249version: 0.0.1
12250artifacts:
12251 - name: local/scoped
12252 version: 1.0.0
12253 runtime: {role}
12254 capabilities:
12255 network:
12256 allow:
12257 - https://api.example.com
12258 filesystem:
12259 scope: cache
12260"#
12261 );
12262 let manifest = RuntimeManifest::from_yaml_str(&yaml)
12263 .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
12264
12265 let caps = manifest.artifacts[0]
12266 .capabilities
12267 .as_ref()
12268 .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
12269 assert_eq!(
12270 caps.network.as_ref().unwrap().allow,
12271 vec!["https://api.example.com".to_string()]
12272 );
12273 assert_eq!(
12274 caps.filesystem.as_ref().unwrap().scope,
12275 Some("cache".to_string())
12276 );
12277 }
12278 }
12279
12280 #[test]
12283 fn tool_artifact_without_capabilities_is_none() {
12284 let manifest = RuntimeManifest::from_yaml_str(
12285 r#"
12286name: cap
12287version: 0.0.1
12288artifacts:
12289 - name: local/plain-tool
12290 version: 1.0.0
12291 runtime: tool
12292"#,
12293 )
12294 .unwrap();
12295
12296 assert!(manifest.artifacts[0].capabilities.is_none());
12297 }
12298
12299 #[test]
12302 fn skill_artifact_capabilities_are_rejected() {
12303 let yaml = r#"
12304name: cap
12305version: 0.0.1
12306artifacts:
12307 - name: local/sneaky
12308 version: 1.0.0
12309 runtime: skill
12310 capabilities:
12311 network:
12312 allow:
12313 - https://evil.example.com
12314"#;
12315 let err = match RuntimeManifest::from_yaml_str(yaml) {
12316 Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
12317 Err(err) => err,
12318 };
12319 let msg = err.to_string();
12320 assert!(msg.contains("sneaky"), "error was: {msg}");
12321 assert!(
12322 msg.contains(
12323 "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
12324 entries"
12325 ),
12326 "error was: {msg}"
12327 );
12328 assert!(msg.contains("runtime: skill"), "error was: {msg}");
12329 }
12330
12331 #[test]
12336 fn artifact_config_is_carried_verbatim() {
12337 let manifest = RuntimeManifest::from_yaml_str(
12338 r#"
12339name: cap
12340version: 0.0.1
12341artifacts:
12342 - name: murmur/tool-corpus
12343 version: 0.1.0
12344 runtime: tool
12345 config:
12346 read_recent: { default: 20, max: 100 }
12347"#,
12348 )
12349 .unwrap();
12350
12351 let config = manifest.artifacts[0]
12352 .config
12353 .as_ref()
12354 .expect("a declared block parses");
12355 assert_eq!(
12356 config["read_recent"]["max"],
12357 serde_norway::Value::Number(100.into())
12358 );
12359 }
12360
12361 #[test]
12363 fn an_undeclared_config_is_none() {
12364 let manifest = RuntimeManifest::from_yaml_str(
12365 r#"
12366name: cap
12367version: 0.0.1
12368artifacts:
12369 - name: local/git
12370 version: 1.0.0
12371 runtime: tool
12372"#,
12373 )
12374 .unwrap();
12375
12376 assert!(manifest.artifacts[0].config.is_none());
12377 }
12378
12379 #[test]
12383 fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
12384 let manifest = RuntimeManifest::from_yaml_str(
12385 r#"
12386name: cap
12387version: 0.0.1
12388artifacts:
12389 - name: local/git
12390 version: 1.0.0
12391 runtime: tool
12392 config:
12393"#,
12394 )
12395 .unwrap();
12396
12397 assert_eq!(
12398 manifest.artifacts[0].config,
12399 Some(serde_norway::Value::Null),
12400 "an empty block must survive parsing as a declaration, not vanish into None"
12401 );
12402 }
12403
12404 #[test]
12407 fn skill_artifact_config_is_rejected() {
12408 let yaml = r#"
12409name: cap
12410version: 0.0.1
12411artifacts:
12412 - name: local/sneaky
12413 version: 1.0.0
12414 runtime: skill
12415 config:
12416 who: me
12417"#;
12418 let err = match RuntimeManifest::from_yaml_str(yaml) {
12419 Ok(_) => panic!("config: must be rejected for runtime: skill"),
12420 Err(err) => err,
12421 };
12422 assert!(matches!(
12423 err,
12424 RuntimeManifestError::InvalidArtifact { index: 0, .. }
12425 ));
12426 let msg = err.to_string();
12427 assert!(msg.contains("sneaky"), "error was: {msg}");
12428 assert!(msg.contains("runtime: skill"), "error was: {msg}");
12429 assert!(
12430 msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
12431 "error was: {msg}"
12432 );
12433 }
12434
12435 #[test]
12438 fn a_capsule_wide_config_block_is_rejected() {
12439 let yaml = r#"
12440name: cap
12441version: 0.0.1
12442config:
12443 who: capsule
12444artifacts:
12445 - name: local/git
12446 version: 1.0.0
12447 runtime: tool
12448"#;
12449 let err = match RuntimeManifest::from_yaml_str(yaml) {
12450 Ok(_) => panic!("a capsule-wide config block must be rejected"),
12451 Err(err) => err,
12452 };
12453 let msg = err.to_string();
12454 assert!(msg.contains("config"), "error was: {msg}");
12455 assert!(msg.contains("declared per artifact"), "error was: {msg}");
12456 }
12457
12458 #[test]
12462 fn hook_on_overflow_block_parses() {
12463 let manifest = RuntimeManifest::from_yaml_str(
12464 r#"
12465name: cap
12466version: 0.0.1
12467artifacts:
12468 - name: murmur/hook-grafana
12469 version: 1.0.0
12470 runtime: hook
12471 on_overflow: block
12472"#,
12473 )
12474 .unwrap();
12475
12476 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
12477 }
12478
12479 #[test]
12482 fn hook_on_overflow_defaults_to_drop() {
12483 let manifest = RuntimeManifest::from_yaml_str(
12484 r#"
12485name: cap
12486version: 0.0.1
12487artifacts:
12488 - name: murmur/hook-grafana
12489 version: 1.0.0
12490 runtime: hook
12491"#,
12492 )
12493 .unwrap();
12494
12495 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
12496 assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
12497 }
12498
12499 #[test]
12502 fn on_overflow_on_a_non_hook_artifact_is_rejected() {
12503 let yaml = r#"
12504name: cap
12505version: 0.0.1
12506artifacts:
12507 - name: local/git
12508 version: 1.0.0
12509 runtime: tool
12510 on_overflow: block
12511"#;
12512 let err = match RuntimeManifest::from_yaml_str(yaml) {
12513 Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
12514 Err(err) => err,
12515 };
12516 assert!(matches!(
12517 err,
12518 RuntimeManifestError::InvalidArtifact { index: 0, .. }
12519 ));
12520 let msg = err.to_string();
12521 assert!(msg.contains("git"), "error was: {msg}");
12522 assert!(
12523 msg.contains("only recognized on 'runtime: hook' entries"),
12524 "error was: {msg}"
12525 );
12526 }
12527
12528 #[test]
12530 fn unknown_on_overflow_value_is_rejected() {
12531 let yaml = r#"
12532name: cap
12533version: 0.0.1
12534artifacts:
12535 - name: murmur/hook-grafana
12536 version: 1.0.0
12537 runtime: hook
12538 on_overflow: spill
12539"#;
12540 let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
12541 let msg = err.to_string();
12542 assert!(msg.contains("spill"), "error was: {msg}");
12543 assert!(msg.contains("expected: drop, block"), "error was: {msg}");
12544 }
12545
12546 #[test]
12549 fn on_overflow_is_accepted_on_any_hook_entry() {
12550 let manifest = RuntimeManifest::from_yaml_str(
12551 r#"
12552name: cap
12553version: 0.0.1
12554artifacts:
12555 - name: murmur/hook-eval
12556 version: 1.0.0
12557 runtime: hook
12558 on_overflow: drop
12559 capabilities:
12560 network:
12561 allow:
12562 - https://telemetry.example.com
12563"#,
12564 )
12565 .unwrap();
12566
12567 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
12568 assert!(manifest.artifacts[0].capabilities.is_some());
12569 }
12570
12571 #[test]
12574 fn capsule_wide_and_hook_capabilities_coexist() {
12575 let manifest = RuntimeManifest::from_yaml_str(
12576 r#"
12577name: cap
12578version: 0.0.1
12579artifacts:
12580 - name: local/telemetry-hook
12581 version: 1.0.0
12582 runtime: hook
12583 capabilities:
12584 network:
12585 allow:
12586 - https://telemetry.example.com
12587capabilities:
12588 network:
12589 allow:
12590 - https://api.anthropic.com
12591"#,
12592 )
12593 .unwrap();
12594
12595 assert_eq!(
12596 manifest.capabilities.unwrap().network.unwrap().allow,
12597 vec!["https://api.anthropic.com".to_string()]
12598 );
12599 assert_eq!(
12600 manifest.artifacts[0]
12601 .capabilities
12602 .as_ref()
12603 .unwrap()
12604 .network
12605 .as_ref()
12606 .unwrap()
12607 .allow,
12608 vec!["https://telemetry.example.com".to_string()]
12609 );
12610 }
12611
12612 #[test]
12616 fn hook_own_manifest_capabilities_are_not_read_into_config() {
12617 let config = parse_hook_config_from_yaml(
12618 r#"
12619name: telemetry-hook
12620version: 1.0.0
12621binding: on-inference
12622execution_mode: blocking
12623capabilities:
12624 network:
12625 allow:
12626 - https://evil.example.com
12627 filesystem:
12628 scope: /
12629"#,
12630 )
12631 .expect("a self-declared capabilities block is ignored, not an error");
12632
12633 assert_eq!(
12634 config,
12635 HookConfig {
12636 binding: HookBinding::OnInference,
12637 execution_mode: HookExecutionMode::Blocking,
12638 commit_policy: HookCommitPolicy::None,
12639 }
12640 );
12641 }
12642
12643 #[test]
12646 fn containment_classes_order_weakest_to_strongest() {
12647 assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
12648 assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
12649 assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
12650 assert_eq!(
12651 ContainmentClass::ALL.iter().copied().max(),
12652 Some(ContainmentClass::Sealed)
12653 );
12654 }
12655
12656 #[test]
12657 fn containment_class_defaults_to_advisory() {
12658 assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
12659 }
12660
12661 #[test]
12662 fn containment_class_round_trips_through_string() {
12663 for class in ContainmentClass::ALL {
12664 assert_eq!(
12665 class.to_string().parse::<ContainmentClass>(),
12666 Ok(class),
12667 "{class} must survive Display -> FromStr"
12668 );
12669 }
12670 assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
12671 assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
12672 assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
12673 }
12674
12675 #[test]
12676 fn containment_class_rejects_unknown_names() {
12677 let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
12678 assert_eq!(err.value, "paranoid");
12679 assert_eq!(
12680 err.to_string(),
12681 "must be one of: advisory, scoped, sealed; got 'paranoid'"
12682 );
12683 assert!("Sealed".parse::<ContainmentClass>().is_err());
12685 }
12686
12687 #[test]
12688 fn containment_class_serializes_as_its_wire_name() {
12689 assert_eq!(
12690 serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
12691 "\"scoped\""
12692 );
12693 assert_eq!(
12694 serde_norway::from_str::<ContainmentClass>("sealed").unwrap(),
12695 ContainmentClass::Sealed
12696 );
12697 }
12698
12699 #[test]
12700 fn effective_floor_is_advisory_when_nothing_is_declared() {
12701 assert_eq!(
12702 effective_containment_floor(None, None, None),
12703 ContainmentClass::Advisory
12704 );
12705 }
12706
12707 #[test]
12708 fn effective_floor_takes_the_strongest_across_every_presence_combination() {
12709 use ContainmentClass::{Advisory, Scoped, Sealed};
12710
12711 assert_eq!(
12713 effective_containment_floor(Some(Scoped), None, None),
12714 Scoped
12715 );
12716 assert_eq!(
12717 effective_containment_floor(None, Some(Scoped), None),
12718 Scoped
12719 );
12720 assert_eq!(
12721 effective_containment_floor(None, None, Some(Scoped)),
12722 Scoped
12723 );
12724 assert_eq!(
12725 effective_containment_floor(Some(Sealed), None, None),
12726 Sealed
12727 );
12728 assert_eq!(
12729 effective_containment_floor(None, Some(Sealed), None),
12730 Sealed
12731 );
12732 assert_eq!(
12733 effective_containment_floor(None, None, Some(Sealed)),
12734 Sealed
12735 );
12736
12737 assert_eq!(
12740 effective_containment_floor(Some(Advisory), Some(Sealed), None),
12741 Sealed
12742 );
12743 assert_eq!(
12744 effective_containment_floor(Some(Sealed), Some(Advisory), None),
12745 Sealed
12746 );
12747 assert_eq!(
12748 effective_containment_floor(Some(Advisory), None, Some(Scoped)),
12749 Scoped
12750 );
12751 assert_eq!(
12752 effective_containment_floor(Some(Scoped), None, Some(Advisory)),
12753 Scoped
12754 );
12755 assert_eq!(
12756 effective_containment_floor(None, Some(Advisory), Some(Scoped)),
12757 Scoped
12758 );
12759 assert_eq!(
12760 effective_containment_floor(None, Some(Scoped), Some(Advisory)),
12761 Scoped
12762 );
12763
12764 assert_eq!(
12766 effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
12767 Sealed
12768 );
12769 assert_eq!(
12770 effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
12771 Sealed
12772 );
12773 assert_eq!(
12774 effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
12775 Sealed
12776 );
12777 assert_eq!(
12778 effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
12779 Scoped
12780 );
12781 assert_eq!(
12782 effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
12783 Advisory
12784 );
12785 }
12786
12787 #[test]
12788 fn manifest_parses_each_declared_containment_class() {
12789 for class in ContainmentClass::ALL {
12790 let manifest = RuntimeManifest::from_yaml_str(&format!(
12791 r#"
12792name: cap
12793version: 0.0.1
12794capabilities:
12795 containment: {class}
12796"#
12797 ))
12798 .expect("a valid containment class parses");
12799
12800 assert_eq!(
12801 manifest.capabilities.unwrap().containment,
12802 Some(class),
12803 "capabilities.containment: {class}"
12804 );
12805 }
12806 }
12807
12808 #[test]
12809 fn manifest_without_containment_key_declares_nothing() {
12810 let manifest = RuntimeManifest::from_yaml_str(
12812 r#"
12813name: cap
12814version: 0.0.1
12815"#,
12816 )
12817 .unwrap();
12818 assert!(manifest.capabilities.is_none());
12819
12820 let manifest = RuntimeManifest::from_yaml_str(
12823 r#"
12824name: cap
12825version: 0.0.1
12826capabilities:
12827 network:
12828 allow:
12829 - https://api.example.com
12830"#,
12831 )
12832 .unwrap();
12833 assert_eq!(manifest.capabilities.unwrap().containment, None);
12834 }
12835
12836 #[test]
12837 fn manifest_rejects_an_unknown_containment_class() {
12838 let err = RuntimeManifest::from_yaml_str(
12839 r#"
12840name: cap
12841version: 0.0.1
12842capabilities:
12843 containment: paranoid
12844"#,
12845 )
12846 .expect_err("an unknown containment class is rejected at parse time");
12847
12848 match err {
12849 RuntimeManifestError::InvalidCapabilities { field, message } => {
12850 assert_eq!(field, "capabilities.containment");
12851 assert_eq!(
12852 message,
12853 "must be one of: advisory, scoped, sealed; got 'paranoid'"
12854 );
12855 }
12856 other => panic!("expected InvalidCapabilities, got {other:?}"),
12857 }
12858 }
12859
12860 fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
12863 RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
12864 }
12865
12866 fn export_error(block: &str) -> (String, String) {
12867 match exports_manifest(block).expect_err("this exports block must not parse") {
12868 RuntimeManifestError::InvalidExports { field, message } => (field, message),
12869 other => panic!("expected InvalidExports, got {other:?}"),
12870 }
12871 }
12872
12873 fn peer_fetch_error(block: &str) -> (String, String) {
12874 match exports_manifest(block).expect_err("this capabilities block must not parse") {
12875 RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
12876 other => panic!("expected InvalidCapabilities, got {other:?}"),
12877 }
12878 }
12879
12880 #[test]
12883 fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
12884 assert_eq!(parse_duration_secs("90").unwrap(), 90);
12885 assert_eq!(parse_duration_secs("30s").unwrap(), 30);
12886 assert_eq!(parse_duration_secs("15m").unwrap(), 900);
12887 assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
12888 assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
12889 assert_eq!(parse_duration_secs(" 2h ").unwrap(), 7200);
12890 assert_eq!(parse_duration_secs("0").unwrap(), 0);
12891 }
12892
12893 #[test]
12896 fn parse_duration_secs_rejects_every_other_spelling() {
12897 for input in [
12898 "",
12899 " ",
12900 "5 minutes",
12901 "30S",
12902 "1H",
12903 "5min",
12904 "1.5h",
12905 "-1",
12906 "m",
12907 "h",
12908 "1h30m",
12909 "0x10",
12910 "30 s",
12911 ] {
12912 let error = parse_duration_secs(input)
12913 .expect_err(&format!("'{input}' must not parse as a duration"));
12914 assert!(
12915 error.contains(DURATION_ACCEPTED_FORM),
12916 "'{input}' must be refused with the accepted form; got: {error}"
12917 );
12918 }
12919 }
12920
12921 #[test]
12922 fn parse_duration_secs_refuses_to_overflow() {
12923 assert!(parse_duration_secs("18446744073709551615h")
12924 .unwrap_err()
12925 .contains("overflows"));
12926 }
12927
12928 #[test]
12929 fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
12930 let manifest = exports_manifest("exports:\n peer_files:\n root: out/\n")
12931 .expect("a minimal peer_files block parses");
12932 let peer = manifest.exports.unwrap().peer_files.unwrap();
12933 assert_eq!(peer.root, "out/");
12934 assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
12935 assert_eq!(peer.max_bytes, 10_485_760);
12936 assert_eq!(peer.max_ttl_secs, None);
12939 assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
12940 assert_eq!(peer.effective_max_ttl_secs(), 3600);
12941 }
12942
12943 #[test]
12944 fn peer_files_accepts_every_duration_spelling() {
12945 for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
12946 let manifest = exports_manifest(&format!(
12947 "exports:\n peer_files:\n root: out/\n max_ttl: {declared}\n"
12948 ))
12949 .expect("a declared max_ttl parses");
12950 let peer = manifest.exports.unwrap().peer_files.unwrap();
12951 assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
12952 assert_eq!(peer.effective_max_ttl_secs(), expected);
12953 }
12954 }
12955
12956 #[test]
12959 fn the_two_export_blocks_are_independent() {
12960 let both = exports_manifest(
12961 "exports:\n files:\n root: out/\n mode: read-only\n \
12962 peer_files:\n root: out/handoff/\n",
12963 )
12964 .expect("both blocks parse together")
12965 .exports
12966 .unwrap();
12967 assert_eq!(both.files.unwrap().root, "out/");
12968 assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
12969
12970 let only_files =
12971 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
12972 .unwrap()
12973 .exports
12974 .unwrap();
12975 assert!(only_files.peer_files.is_none());
12976
12977 let only_peer = exports_manifest("exports:\n peer_files:\n root: out/\n")
12978 .unwrap()
12979 .exports
12980 .unwrap();
12981 assert!(only_peer.files.is_none());
12982
12983 let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
12984 assert!(neither.files.is_none() && neither.peer_files.is_none());
12985 }
12986
12987 #[test]
12988 fn peer_files_root_must_be_relative_and_inside_the_workdir() {
12989 for root in ["../out", "/etc", "out/../../etc"] {
12990 let (field, message) =
12991 export_error(&format!("exports:\n peer_files:\n root: {root}\n"));
12992 assert_eq!(field, "exports.peer_files.root", "root: {root}");
12993 assert!(
12994 message.contains(EXPORT_ROOT_ACCEPTED_FORM),
12995 "root: {root}; got: {message}"
12996 );
12997 }
12998 }
12999
13000 #[test]
13001 fn peer_files_root_is_required() {
13002 let (field, message) = export_error("exports:\n peer_files: {}\n");
13003 assert_eq!(field, "exports.peer_files.root");
13004 assert!(message.contains("is required"));
13005 assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
13006 }
13007
13008 #[test]
13009 fn peer_files_max_ttl_states_its_accepted_form() {
13010 for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
13011 let (field, message) = export_error(&format!(
13012 "exports:\n peer_files:\n root: out/\n max_ttl: \"{declared}\"\n"
13013 ));
13014 assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
13015 assert!(
13016 message.contains(DURATION_ACCEPTED_FORM),
13017 "max_ttl: {declared}; got: {message}"
13018 );
13019 }
13020 }
13021
13022 #[test]
13025 fn peer_files_max_ttl_rejects_zero() {
13026 let (field, message) =
13027 export_error("exports:\n peer_files:\n root: out/\n max_ttl: 0\n");
13028 assert_eq!(field, "exports.peer_files.max_ttl");
13029 assert!(message.contains("greater than zero"));
13030 }
13031
13032 #[test]
13033 fn peer_files_max_bytes_states_its_accepted_form() {
13034 let (field, message) =
13035 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 10MB\n");
13036 assert_eq!(field, "exports.peer_files.max_bytes");
13037 assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
13038 }
13039
13040 #[test]
13041 fn peer_files_max_bytes_rejects_zero() {
13042 let (field, message) =
13043 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 0\n");
13044 assert_eq!(field, "exports.peer_files.max_bytes");
13045 assert!(message.contains("greater than zero"));
13046 }
13047
13048 #[test]
13052 fn peer_tasks_consents_only_on_an_explicit_true() {
13053 let absent = exports_manifest("artifacts: []\n").expect("no exports block parses");
13054 assert!(absent.exports.is_none());
13055 assert!(!absent.accepts_peer_tasks());
13056
13057 let refused = exports_manifest("exports:\n peer_tasks:\n accept: false\n")
13058 .expect("an explicit refusal parses");
13059 assert_eq!(
13060 refused.exports.as_ref().unwrap().peer_tasks,
13061 Some(PeerTasksExport { accept: false })
13062 );
13063 assert!(!refused.accepts_peer_tasks());
13064 assert!(!refused.exports.as_ref().unwrap().accepts_peer_tasks());
13065
13066 let accepted = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
13067 .expect("consent parses");
13068 assert_eq!(
13069 accepted.exports.as_ref().unwrap().peer_tasks,
13070 Some(PeerTasksExport { accept: true })
13071 );
13072 assert!(accepted.accepts_peer_tasks());
13073 assert!(accepted.exports.as_ref().unwrap().accepts_peer_tasks());
13074 }
13075
13076 #[test]
13078 fn peer_tasks_alone_declares_no_file_plane() {
13079 let manifest = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
13080 .expect("a peer_tasks-only exports block parses");
13081 let exports = manifest.exports.unwrap();
13082 assert_eq!(exports.files, None);
13083 assert_eq!(exports.peer_files, None);
13084 }
13085
13086 #[test]
13088 fn a_file_export_does_not_consent_to_peer_tasks() {
13089 let manifest =
13090 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
13091 .expect("a files export parses");
13092 assert_eq!(manifest.exports.as_ref().unwrap().peer_tasks, None);
13093 assert!(!manifest.accepts_peer_tasks());
13094 }
13095
13096 #[test]
13099 fn peer_tasks_without_accept_is_refused() {
13100 let (field, message) = export_error("exports:\n peer_tasks: {}\n");
13101 assert_eq!(field, "exports.peer_tasks.accept");
13102 assert_eq!(
13103 message,
13104 "must be set explicitly to true or false — consent to peer tasks is never inferred"
13105 );
13106 }
13107
13108 #[test]
13110 fn peer_tasks_of_the_wrong_type_is_refused_naming_the_field() {
13111 for (block, field) in [
13112 (
13113 "exports:\n peer_tasks:\n accept: \"yes\"\n",
13114 "exports.peer_tasks.accept",
13115 ),
13116 (
13117 "exports:\n peer_tasks:\n accept: 1\n",
13118 "exports.peer_tasks.accept",
13119 ),
13120 ("exports:\n peer_tasks: true\n", "exports.peer_tasks"),
13121 ("exports:\n peer_tasks: [peer-a]\n", "exports.peer_tasks"),
13122 ] {
13123 let message = exports_manifest(block)
13124 .expect_err(&format!("{block:?} must not parse"))
13125 .to_string();
13126 assert!(message.contains(field), "{block:?}: {message}");
13127 }
13128 }
13129
13130 #[test]
13133 fn an_unknown_peer_tasks_key_is_reported_by_path() {
13134 let manifest =
13135 exports_manifest("exports:\n peer_tasks:\n accept: false\n allow: [peer-a]\n")
13136 .expect("an unknown key is a warning, not a parse error");
13137 assert!(
13138 manifest
13139 .unknown_keys
13140 .iter()
13141 .any(|key| key.block_path == "exports.peer_tasks" && key.key == "allow"),
13142 "{:?}",
13143 manifest.unknown_keys
13144 );
13145 assert!(!manifest.accepts_peer_tasks());
13146 }
13147
13148 #[test]
13151 fn peer_fetch_allow_carries_its_declared_destinations() {
13152 let manifest = exports_manifest(
13153 "capabilities:\n peer_fetch:\n allow:\n - localhost:41234\n \
13154 - reporting.internal\n - https://gateway.example.com\n",
13155 )
13156 .expect("a peer_fetch block parses");
13157 assert_eq!(
13158 manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
13159 vec![
13160 "localhost:41234".to_string(),
13161 "reporting.internal".to_string(),
13162 "https://gateway.example.com".to_string(),
13163 ]
13164 );
13165 }
13166
13167 #[test]
13170 fn peer_fetch_allow_must_not_be_empty() {
13171 let (field, message) = peer_fetch_error("capabilities:\n peer_fetch:\n allow: []\n");
13172 assert_eq!(field, "capabilities.peer_fetch.allow");
13173 assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
13174 }
13175
13176 #[test]
13177 fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
13178 for entry in [
13179 "http://[not a host",
13180 "\"\"",
13181 "localhost:notaport",
13182 "http://example.com/path",
13183 "ftp://example.com",
13184 ] {
13185 let (field, message) = peer_fetch_error(&format!(
13186 "capabilities:\n peer_fetch:\n allow:\n - {entry}\n"
13187 ));
13188 assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
13189 assert!(
13190 message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
13191 "entry: {entry}; got: {message}"
13192 );
13193 }
13194 }
13195
13196 #[test]
13199 fn peer_fetch_does_not_widen_network_allow() {
13200 let capabilities = exports_manifest(
13201 "capabilities:\n network:\n allow:\n - api.example.com\n \
13202 peer_fetch:\n allow:\n - localhost:41234\n",
13203 )
13204 .unwrap()
13205 .capabilities
13206 .unwrap();
13207 assert_eq!(
13208 capabilities.network.unwrap().allow,
13209 vec!["api.example.com".to_string()]
13210 );
13211 assert_eq!(
13212 capabilities.peer_fetch.unwrap().allow,
13213 vec!["localhost:41234".to_string()]
13214 );
13215 }
13216
13217 #[test]
13218 fn an_absent_peer_fetch_block_is_absent() {
13219 assert!(exports_manifest("capabilities: {}\n")
13220 .unwrap()
13221 .capabilities
13222 .unwrap()
13223 .peer_fetch
13224 .is_none());
13225 }
13226
13227 #[test]
13228 fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
13229 assert_eq!(parse_byte_size("0").unwrap(), 0);
13230 assert_eq!(parse_byte_size("4096").unwrap(), 4096);
13231 assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
13232 assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
13233 assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
13234 assert_eq!(parse_byte_size(" 10Mi ").unwrap(), 10 * 1024 * 1024);
13235 }
13236
13237 #[test]
13240 fn parse_byte_size_rejects_every_other_spelling() {
13241 for input in [
13242 "", " ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
13243 "1Ki1",
13244 ] {
13245 let error = parse_byte_size(input)
13246 .expect_err(&format!("'{input}' must not parse as a byte size"));
13247 assert!(
13248 error.contains(BYTE_SIZE_ACCEPTED_FORM),
13249 "'{input}' must be refused with the accepted form; got: {error}"
13250 );
13251 }
13252 }
13253
13254 #[test]
13255 fn parse_byte_size_refuses_to_overflow() {
13256 assert!(parse_byte_size("18446744073709551615Gi")
13257 .unwrap_err()
13258 .contains("overflows"));
13259 }
13260
13261 #[test]
13262 fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
13263 let manifest =
13264 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
13265 .expect("a minimal exports block parses");
13266 let files = manifest.exports.unwrap().files.unwrap();
13267 assert_eq!(files.root, "out/");
13268 assert_eq!(files.mode, ExportMode::ReadOnly);
13269 assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
13270 assert_eq!(files.max_bytes, 10_485_760);
13271 }
13272
13273 #[test]
13274 fn exports_files_accepts_both_byte_spellings() {
13275 for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
13276 let manifest = exports_manifest(&format!(
13277 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: {declared}\n"
13278 ))
13279 .expect("a declared max_bytes parses");
13280 assert_eq!(
13281 manifest.exports.unwrap().files.unwrap().max_bytes,
13282 expected,
13283 "max_bytes: {declared}"
13284 );
13285 }
13286 }
13287
13288 #[test]
13291 fn an_exports_block_without_files_declares_no_plane() {
13292 let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
13293 assert!(manifest.exports.unwrap().files.is_none());
13294 }
13295
13296 #[test]
13297 fn an_absent_exports_block_is_absent() {
13298 let manifest = exports_manifest("").expect("a manifest without exports parses");
13299 assert!(manifest.exports.is_none());
13300 }
13301
13302 #[test]
13303 fn exports_files_root_must_be_relative_and_inside_the_workdir() {
13304 for root in ["../out", "/etc", "out/../../etc", "''"] {
13305 let (field, message) = export_error(&format!(
13306 "exports:\n files:\n root: {root}\n mode: read-only\n"
13307 ));
13308 assert_eq!(field, "exports.files.root", "root: {root}");
13309 assert!(
13310 message.contains("must be a relative path inside the workdir"),
13311 "root: {root}; got: {message}"
13312 );
13313 }
13314 }
13315
13316 #[test]
13317 fn exports_files_root_is_required() {
13318 let (field, message) = export_error("exports:\n files:\n mode: read-only\n");
13319 assert_eq!(field, "exports.files.root");
13320 assert_eq!(
13321 message,
13322 "is required and must be a relative path inside the workdir"
13323 );
13324 }
13325
13326 #[test]
13327 fn exports_files_mode_is_required_and_read_only() {
13328 let (field, message) = export_error("exports:\n files:\n root: out/\n");
13329 assert_eq!(field, "exports.files.mode");
13330 assert_eq!(message, "is required and must be 'read-only'");
13331
13332 let (field, message) =
13333 export_error("exports:\n files:\n root: out/\n mode: read-write\n");
13334 assert_eq!(field, "exports.files.mode");
13335 assert_eq!(message, "'read-write' must be 'read-only'");
13336 }
13337
13338 #[test]
13339 fn exports_files_max_bytes_states_its_accepted_form() {
13340 let (field, message) = export_error(
13341 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 10MB\n",
13342 );
13343 assert_eq!(field, "exports.files.max_bytes");
13344 assert_eq!(
13345 message,
13346 "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
13347 );
13348 }
13349
13350 #[test]
13353 fn exports_files_max_bytes_rejects_zero() {
13354 let (field, message) = export_error(
13355 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 0\n",
13356 );
13357 assert_eq!(field, "exports.files.max_bytes");
13358 assert!(message.contains("must be greater than zero"), "{message}");
13359 }
13360
13361 #[test]
13363 fn exports_files_root_accepts_a_current_dir_prefix() {
13364 let manifest =
13365 exports_manifest("exports:\n files:\n root: ./out\n mode: read-only\n")
13366 .expect("./out is a legal root");
13367 assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
13368 }
13369
13370 fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
13373 RuntimeManifest::from_yaml_str(yaml)
13374 .expect("fixture must parse")
13375 .unknown_keys
13376 .into_iter()
13377 .map(|key| (key.key, key.block_path, key.nearest_known))
13378 .collect()
13379 }
13380
13381 #[test]
13384 fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
13385 assert_eq!(
13386 unknown_keys_of(
13387 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n read-only:\n - tests\n"
13388 ),
13389 vec![(
13390 "read-only".to_string(),
13391 "capabilities.filesystem".to_string(),
13392 Some("read_only".to_string())
13393 )]
13394 );
13395 }
13396
13397 #[test]
13401 fn a_manifest_of_recognized_keys_reports_nothing() {
13402 assert!(unknown_keys_of(
13403 r#"
13404name: cap
13405version: 0.1.0
13406deploy:
13407 mur_version: "0.2.0"
13408artifacts:
13409 - name: local/notes-tool
13410 version: 0.1.0
13411 runtime: tool
13412 capabilities:
13413 filesystem:
13414 scope: notes
13415 network:
13416 allow:
13417 - example.com:443
13418capabilities:
13419 filesystem:
13420 read_only:
13421 - tests
13422 - bench/fixtures
13423 workdir_exec: false
13424 shell:
13425 allow:
13426 - git
13427 - python3
13428 network:
13429 allow:
13430 - example.com:443
13431 unix_sockets: false
13432"#
13433 )
13434 .is_empty());
13435 }
13436
13437 #[test]
13440 fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
13441 assert_eq!(
13442 unknown_keys_of(
13443 "name: cap\nversion: 0.1.0\nartifacts:\n - name: local/t\n version: 0.1.0\n \
13444 runtime: tool\n capabilities:\n shell:\n allow:\n - git\n \
13445 allwo:\n - git\n"
13446 ),
13447 vec![(
13448 "allwo".to_string(),
13449 "artifacts[0].capabilities.shell".to_string(),
13450 Some("allow".to_string())
13451 )]
13452 );
13453 }
13454
13455 #[test]
13458 fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
13459 assert_eq!(
13460 unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
13461 vec![("quantum_teleport".to_string(), String::new(), None)]
13462 );
13463 }
13464
13465 #[test]
13470 fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
13471 assert!(unknown_keys_of(
13472 "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
13473 )
13474 .is_empty());
13475 }
13476
13477 #[test]
13479 fn every_build_side_key_is_recognized() {
13480 assert!(unknown_keys_of(
13481 "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
13482 wasm\nrequires_files:\n - tool.wasm\n - assets/logo.png\n"
13483 )
13484 .is_empty());
13485 }
13486
13487 #[test]
13490 fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
13491 assert_eq!(
13492 unknown_keys_of(
13493 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
13494 static\nrequires_files:\n - capsule.wasm\nquantum_teleport: true\n"
13495 ),
13496 vec![("quantum_teleport".to_string(), String::new(), None)]
13497 );
13498 }
13499
13500 #[test]
13504 fn the_build_side_keys_change_nothing_the_run_path_reads() {
13505 let body = "artifacts:\n - name: local/notes-tool\n version: 0.1.0\n runtime: tool\n\
13506 \x20 - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: \
13507 driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\n\
13508 capabilities:\n filesystem:\n read_only:\n - tests\n\
13509 inference:\n transport: http\n model: test-model\n driver:\n \
13510 artifact: murmur/driver-anthropic\n";
13511 let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
13512 .expect("fixture must parse");
13513 let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
13514 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n \
13515 any: shape\nrequires_files: not-a-list\n{body}"
13516 ))
13517 .expect("a build-side key of any shape never refuses a manifest");
13518
13519 assert!(with_build_keys.unknown_keys.is_empty());
13520 assert!(
13521 !plain.artifacts.is_empty()
13522 && plain.capabilities.is_some()
13523 && plain.inference.is_some(),
13524 "the fixture must populate the blocks the comparison below rests on, or it \
13525 compares absence against absence"
13526 );
13527 assert_eq!(with_build_keys.name, plain.name);
13528 assert_eq!(with_build_keys.version, plain.version);
13529 assert_eq!(with_build_keys.artifacts, plain.artifacts);
13530 assert_eq!(with_build_keys.capabilities, plain.capabilities);
13531 assert_eq!(with_build_keys.inference, plain.inference);
13532 }
13533
13534 #[test]
13537 fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
13538 let manifest = RuntimeManifest::from_yaml_str(
13539 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n scope: notes\n \
13540 read-only:\n - tests\n",
13541 )
13542 .expect("an unrecognized key never refuses a manifest");
13543 assert_eq!(
13544 manifest
13545 .capabilities
13546 .as_ref()
13547 .and_then(|caps| caps.filesystem.as_ref())
13548 .and_then(|fs| fs.scope.as_deref()),
13549 Some("notes")
13550 );
13551 assert_eq!(manifest.unknown_keys.len(), 1);
13552 }
13553
13554 #[test]
13556 fn nested_blocks_outside_capabilities_are_walked_too() {
13557 let keys = unknown_keys_of(
13558 "name: cap\nversion: 0.1.0\ncontext:\n max_tokns: 100\nexports:\n files:\n \
13559 root: out\n mode: read-only\n max_byts: 10\n",
13560 );
13561 assert_eq!(
13562 keys,
13563 vec![
13564 (
13565 "max_tokns".to_string(),
13566 "context".to_string(),
13567 Some("max_tokens".to_string())
13568 ),
13569 (
13570 "max_byts".to_string(),
13571 "exports.files".to_string(),
13572 Some("max_bytes".to_string())
13573 ),
13574 ]
13575 );
13576 }
13577
13578 #[test]
13585 fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
13586 let source = include_str!("runtime_manifest.rs");
13587 let lines: Vec<&str> = source.lines().collect();
13588
13589 let mut checked = 0usize;
13590 for (start, line) in lines.iter().enumerate() {
13591 let Some(name) = line
13592 .strip_prefix("struct Raw")
13593 .and_then(|rest| rest.strip_suffix(" {"))
13594 else {
13595 continue;
13596 };
13597 let name = format!("Raw{name}");
13598 let end = (start + 1..lines.len())
13599 .find(|index| lines[*index] == "}")
13600 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
13601 let body = &lines[start + 1..end];
13602
13603 assert!(
13604 body.contains(&" #[serde(flatten)]")
13605 && body.contains(&" unknown: UnknownKeys,"),
13606 "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
13607 so a key it does not recognize would be dropped instead of reported"
13608 );
13609
13610 let declared = serde_field_names(body);
13611 let known = known_keys_of(source, &name);
13612 assert_eq!(
13613 declared, known,
13614 "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
13615 suggester would propose the wrong set for that block"
13616 );
13617 checked += 1;
13618 }
13619 assert!(
13620 checked >= 30,
13621 "expected every Raw* struct to be scanned, found only {checked}"
13622 );
13623 }
13624
13625 #[test]
13632 fn every_raw_struct_descends_into_the_blocks_it_owns() {
13633 let source = include_str!("runtime_manifest.rs");
13634 let lines: Vec<&str> = source.lines().collect();
13635
13636 let mut structs = 0usize;
13637 let mut children = 0usize;
13638 for (start, line) in lines.iter().enumerate() {
13639 let Some(name) = line
13640 .strip_prefix("struct Raw")
13641 .and_then(|rest| rest.strip_suffix(" {"))
13642 else {
13643 continue;
13644 };
13645 let name = format!("Raw{name}");
13646 let end = (start + 1..lines.len())
13647 .find(|index| lines[*index] == "}")
13648 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
13649 let owned = block_typed_fields(&lines[start + 1..end]);
13650 structs += 1;
13651
13652 if owned.is_empty() {
13653 continue;
13654 }
13655 let block = raw_block_impl_of(source, &name);
13656 assert!(
13657 block.contains("fn walk_children"),
13658 "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
13659 `walk_children`, so every key written inside {} would go unreported",
13660 if owned.len() == 1 { "" } else { "s" },
13661 owned.join(", "),
13662 if owned.len() == 1 { "it" } else { "them" }
13663 );
13664 for field in &owned {
13665 assert!(
13666 mentions_field(&block, field),
13667 "{name}'s `walk_children` never touches `self.{field}`, so every key written \
13668 inside that block would go unreported — descend into it with \
13669 `collect_block(.., &child_path(path, \"{field}\"), out)`"
13670 );
13671 children += 1;
13672 }
13673 }
13674 assert!(
13675 structs >= 30,
13676 "expected every Raw* struct to be scanned, found only {structs}"
13677 );
13678 assert!(
13679 children >= 31,
13680 "expected every nested block field to be scanned, found only {children}"
13681 );
13682 }
13683
13684 fn mentions_field(impl_body: &str, field: &str) -> bool {
13688 let needle = format!("self.{field}");
13689 impl_body.match_indices(&needle).any(|(at, _)| {
13690 impl_body[at + needle.len()..]
13691 .chars()
13692 .next()
13693 .is_none_or(|next| !next.is_alphanumeric() && next != '_')
13694 })
13695 }
13696
13697 fn block_typed_fields(body: &[&str]) -> Vec<String> {
13701 let mut fields = Vec::new();
13702 for line in body {
13703 let trimmed = line.trim();
13704 if trimmed.starts_with("//") || trimmed.starts_with("#[") {
13705 continue;
13706 }
13707 let Some((field, declared)) = line
13708 .strip_prefix(" ")
13709 .and_then(|rest| rest.split_once(':'))
13710 else {
13711 continue;
13712 };
13713 if field.is_empty()
13714 || !field
13715 .chars()
13716 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
13717 {
13718 continue;
13719 }
13720 if declared.contains("Raw") {
13721 fields.push(field.to_string());
13722 }
13723 }
13724 fields
13725 }
13726
13727 fn raw_block_impl_of(source: &str, name: &str) -> String {
13730 let header = format!("impl RawBlock for {name} {{");
13731 let body = source
13732 .split_once(&header)
13733 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
13734 .1;
13735 body.split_once("\n}\n")
13736 .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
13737 .0
13738 .to_string()
13739 }
13740
13741 fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
13748 let raw: RawRuntimeManifest = serde_norway::from_str(yaml).expect("fixture must parse");
13749 collect_unknown_keys(&raw)
13750 .into_iter()
13751 .map(|key| (key.key, key.block_path))
13752 .collect()
13753 }
13754
13755 #[test]
13759 fn every_block_reports_its_own_unknown_key_at_its_own_path() {
13760 let keys = raw_unknown_keys(
13761 r#"
13762name: every-block
13763version: 0.1.0
13764probe_root: 1
13765artifacts:
13766 - name: local/t
13767 version: 0.1.0
13768 runtime: tool
13769 probe_artifact: 1
13770 capabilities:
13771 probe_artifact_capabilities: 1
13772 shell:
13773 probe_artifact_shell: 1
13774capabilities:
13775 probe_capabilities: 1
13776 network:
13777 probe_network: 1
13778 peer_fetch:
13779 probe_peer_fetch: 1
13780 filesystem:
13781 probe_filesystem: 1
13782 shell:
13783 probe_shell: 1
13784 interpreter_runtime:
13785 - binary: python3
13786 probe_interpreter_runtime: 1
13787 dirs:
13788 - path: /usr/lib/python3
13789 probe_interpreter_runtime_dir: 1
13790 staged_runtime:
13791 - binary: rg
13792 probe_staged_runtime: 1
13793 spawn:
13794 probe_spawn: 1
13795 env:
13796 probe_env: 1
13797 limits:
13798 probe_limits: 1
13799 resources:
13800 probe_resources: 1
13801 state:
13802 probe_state: 1
13803 task_io:
13804 probe_task_io: 1
13805 conversation:
13806 probe_conversation: 1
13807 install:
13808 probe_install: 1
13809inference:
13810 probe_inference: 1
13811 driver:
13812 probe_driver: 1
13813 compaction:
13814 probe_compaction: 1
13815context:
13816 probe_context: 1
13817observability:
13818 probe_observability: 1
13819 eval:
13820 probe_eval: 1
13821 scorers:
13822 - type: exact_match
13823 probe_scorer: 1
13824trace:
13825 probe_trace: 1
13826network:
13827 probe_network_config: 1
13828lifecycle:
13829 probe_lifecycle: 1
13830exports:
13831 probe_exports: 1
13832 files:
13833 probe_files: 1
13834 peer_files:
13835 probe_peer_files: 1
13836control:
13837 probe_control: 1
13838"#,
13839 );
13840
13841 let expected: Vec<(String, String)> = [
13842 ("probe_root", ""),
13843 ("probe_artifact", "artifacts[0]"),
13844 ("probe_artifact_capabilities", "artifacts[0].capabilities"),
13845 ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
13846 ("probe_capabilities", "capabilities"),
13847 ("probe_network", "capabilities.network"),
13848 ("probe_peer_fetch", "capabilities.peer_fetch"),
13849 ("probe_filesystem", "capabilities.filesystem"),
13850 ("probe_shell", "capabilities.shell"),
13851 (
13852 "probe_interpreter_runtime",
13853 "capabilities.shell.interpreter_runtime[0]",
13854 ),
13855 (
13856 "probe_interpreter_runtime_dir",
13857 "capabilities.shell.interpreter_runtime[0].dirs[0]",
13858 ),
13859 (
13860 "probe_staged_runtime",
13861 "capabilities.shell.staged_runtime[0]",
13862 ),
13863 ("probe_spawn", "capabilities.spawn"),
13864 ("probe_env", "capabilities.env"),
13865 ("probe_limits", "capabilities.limits"),
13866 ("probe_resources", "capabilities.resources"),
13867 ("probe_state", "capabilities.state"),
13868 ("probe_task_io", "capabilities.task_io"),
13869 ("probe_conversation", "capabilities.conversation"),
13870 ("probe_install", "capabilities.install"),
13871 ("probe_inference", "inference"),
13872 ("probe_driver", "inference.driver"),
13873 ("probe_compaction", "inference.compaction"),
13874 ("probe_context", "context"),
13875 ("probe_observability", "observability"),
13876 ("probe_eval", "observability.eval"),
13877 ("probe_scorer", "observability.eval.scorers[0]"),
13878 ("probe_trace", "trace"),
13879 ("probe_network_config", "network"),
13880 ("probe_lifecycle", "lifecycle"),
13881 ("probe_exports", "exports"),
13882 ("probe_files", "exports.files"),
13883 ("probe_peer_files", "exports.peer_files"),
13884 ("probe_control", "control"),
13885 ]
13886 .into_iter()
13887 .map(|(key, path)| (key.to_string(), path.to_string()))
13888 .collect();
13889
13890 assert_eq!(keys, expected);
13891 }
13892
13893 fn serde_field_names(body: &[&str]) -> Vec<String> {
13897 let mut names = Vec::new();
13898 let mut rename: Option<String> = None;
13899 for line in body {
13900 let trimmed = line.trim();
13901 if trimmed.starts_with("#[") {
13902 if let Some(rest) = trimmed.split("rename = \"").nth(1) {
13903 if !trimmed.contains("rename_all") {
13904 rename = rest.split('"').next().map(str::to_string);
13905 }
13906 }
13907 continue;
13908 }
13909 if trimmed.starts_with("//") {
13910 continue;
13911 }
13912 let Some(field) = line
13913 .strip_prefix(" ")
13914 .and_then(|rest| rest.split(':').next())
13915 else {
13916 continue;
13917 };
13918 if field.is_empty()
13919 || !field
13920 .chars()
13921 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
13922 {
13923 continue;
13924 }
13925 if field == "unknown" {
13926 rename = None;
13927 continue;
13928 }
13929 names.push(rename.take().unwrap_or_else(|| field.to_string()));
13930 }
13931 names
13932 }
13933
13934 fn known_keys_of(source: &str, name: &str) -> Vec<String> {
13938 let header = format!("impl RawBlock for {name} {{");
13939 let body = source
13940 .split_once(&header)
13941 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
13942 .1;
13943 let array = body
13945 .split_once("KNOWN_KEYS: &'static [&'static str] =")
13946 .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
13947 .1
13948 .trim_start()
13949 .strip_prefix("&[")
13950 .unwrap_or_else(|| panic!("{name}'s KNOWN_KEYS is not an array literal"))
13951 .split_once("];")
13952 .expect("KNOWN_KEYS array is unterminated")
13953 .0;
13954 array
13955 .split('"')
13956 .skip(1)
13957 .step_by(2)
13958 .map(str::to_string)
13959 .collect()
13960 }
13961}
13962
13963#[cfg(test)]
13964mod control_tests {
13965 use super::*;
13966
13967 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";
13968
13969 fn agent(transport: &str, control: &str) -> String {
13972 let driver = if transport == "http" {
13973 " - 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"
13974 } else {
13975 " - name: local/drv\n version: 0.1.0\n runtime: driver\n"
13976 };
13977 format!(
13978 "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}"
13979 )
13980 }
13981
13982 fn refusal(yaml: &str) -> (String, String) {
13983 match RuntimeManifest::from_yaml_str(yaml) {
13984 Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
13985 other => panic!("expected InvalidControl, got {other:?}"),
13986 }
13987 }
13988
13989 #[test]
13990 fn control_absent_empty_and_empty_lists_load_as_none() {
13991 for control in [
13992 "",
13993 "control: {}\n",
13994 "control:\n",
13995 "control:\n settings: []\n secrets: []\n",
13996 ] {
13997 let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
13998 .unwrap_or_else(|err| panic!("{control:?}: {err}"));
13999 assert_eq!(manifest.control, None, "{control:?}");
14000 }
14001 }
14002
14003 #[test]
14004 fn control_parses_settings_and_secrets_in_manifest_order() {
14005 let yaml = agent(
14006 "http",
14007 "control:\n settings: [inference.max_tokens]\n secrets: [CARD_TOKEN]\n",
14008 );
14009 for manifest in [
14010 RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14011 RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14012 ] {
14013 let control = manifest.control.unwrap();
14014 assert_eq!(
14015 control.settings,
14016 vec![ControllableSetting::InferenceMaxTokens]
14017 );
14018 assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
14019 assert!(control.declares_secret("CARD_TOKEN"));
14020 assert!(!control.declares_secret("DRIVER_KEY"));
14021 }
14022 }
14023
14024 #[test]
14025 fn control_setting_wire_names_round_trip() {
14026 for setting in ControllableSetting::ALL {
14027 assert_eq!(
14028 ControllableSetting::from_wire_name(setting.wire_name()),
14029 Some(*setting)
14030 );
14031 }
14032 assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
14033 }
14034
14035 #[test]
14038 fn control_manifest_refusals_name_the_field() {
14039 let cases: &[(&str, &str, &str, &str)] = &[
14040 (
14041 "http",
14042 "control:\n settings: [inference.model]\n",
14043 "control.settings",
14044 "'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
14045 ),
14046 (
14047 "process",
14048 "control:\n settings: [inference.max_tokens]\n",
14049 "control.settings",
14050 "'inference.max_tokens' is controllable only under inference.transport: http",
14051 ),
14052 (
14053 "http",
14054 "control:\n settings: [inference.max_tokens, inference.max_tokens]\n",
14055 "control.settings",
14056 "'inference.max_tokens' is listed more than once",
14057 ),
14058 (
14059 "http",
14060 "control:\n secrets: [CARD_TOKEN, CARD_TOKEN]\n",
14061 "control.secrets",
14062 "'CARD_TOKEN' is listed more than once",
14063 ),
14064 (
14065 "http",
14066 "control:\n secrets: [card-token]\n",
14067 "control.secrets",
14068 "'card-token' is not a credential name",
14069 ),
14070 (
14071 "http",
14072 "control:\n secrets: [OTHER_TOKEN]\n",
14073 "control.secrets",
14074 "'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
14075 ),
14076 (
14077 "http",
14078 "control:\n secrets: [DRIVER_KEY]\n",
14079 "control.secrets",
14080 "'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.local/drv.gateway.api_key)",
14081 ),
14082 (
14083 "http",
14084 "control:\n settings: inference.max_tokens\n",
14085 "control.settings",
14086 "must be a list of names",
14087 ),
14088 (
14089 "http",
14090 "control:\n secrets: [1]\n",
14091 "control.secrets",
14092 "must be a list of names",
14093 ),
14094 ];
14095 for (transport, control, field, message) in cases {
14096 let (got_field, got_message) = refusal(&agent(transport, control));
14097 assert_eq!(&got_field, field, "{control:?}");
14098 assert!(
14099 got_message.starts_with(message),
14100 "{control:?}: {got_message}"
14101 );
14102 let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
14103 .unwrap_err()
14104 .to_string();
14105 assert!(
14106 rendered.contains(&format!("invalid control config for '{field}'")),
14107 "{rendered}"
14108 );
14109 }
14110 }
14111
14112 #[test]
14113 fn control_on_a_capsule_with_no_inference_is_refused() {
14114 let yaml = format!(
14115 "name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n secrets: [CARD_TOKEN]\n"
14116 );
14117 let (field, message) = refusal(&yaml);
14118 assert_eq!(field, "control");
14119 assert!(message.contains("no inference: block"), "{message}");
14120 }
14121
14122 #[test]
14123 fn control_unknown_key_is_reported_once_at_its_block() {
14124 let manifest = RuntimeManifest::from_yaml_str(&agent(
14125 "http",
14126 "control:\n settings: [inference.max_tokens]\n agent_may_call: true\n",
14127 ))
14128 .unwrap();
14129 let keys: Vec<(&str, &str)> = manifest
14130 .unknown_keys
14131 .iter()
14132 .map(|key| (key.key.as_str(), key.block_path.as_str()))
14133 .collect();
14134 assert_eq!(keys, vec![("agent_may_call", "control")]);
14135 assert_eq!(
14136 manifest.control.unwrap().settings,
14137 vec![ControllableSetting::InferenceMaxTokens]
14138 );
14139 }
14140}
14141
14142#[cfg(test)]
14143mod driver_choice_tests {
14144 use super::*;
14145
14146 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";
14147 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";
14148 const ALT_NO_GATEWAY: &str = " - name: local/alt\n version: 0.1.0\n runtime: driver\n";
14149 const TOOL: &str = " - name: local/card-api\n version: 0.1.0\n runtime: tool\n";
14150 const GPT: &str =
14151 " - name: gpt\n model: gpt-5\n driver:\n artifact: local/alt\n";
14152 const HAIKU: &str =
14153 " - name: haiku\n model: haiku\n driver:\n artifact: local/drv\n";
14154 const SWITCHABLE: &str = "control:\n settings: [inference.driver]\n";
14155
14156 fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
14159 let alternates = if alternates.is_empty() {
14160 String::new()
14161 } else {
14162 format!(" alternates:\n{alternates}")
14163 };
14164 format!(
14165 "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}"
14166 )
14167 }
14168
14169 fn refusal(yaml: &str) -> (String, String) {
14170 match RuntimeManifest::from_yaml_str(yaml) {
14171 Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
14172 | Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14173 other => panic!("expected an inference or control refusal, got {other:?}"),
14174 }
14175 }
14176
14177 #[test]
14178 fn driver_choice_alternates_parse_in_manifest_order() {
14179 let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
14180 for manifest in [
14181 RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14182 RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14183 ] {
14184 let inference = manifest.inference.unwrap();
14185 assert_eq!(
14186 inference.alternates,
14187 vec![
14188 InferenceAlternate {
14189 name: "gpt".to_string(),
14190 model: "gpt-5".to_string(),
14191 driver: "local/alt".to_string(),
14192 },
14193 InferenceAlternate {
14194 name: "haiku".to_string(),
14195 model: "haiku".to_string(),
14196 driver: "local/drv".to_string(),
14197 },
14198 ]
14199 );
14200 let control = manifest.control.unwrap();
14201 assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
14202 assert!(control.agent_settings.is_empty());
14203 }
14204 }
14205
14206 #[test]
14207 fn driver_choice_absent_alternates_are_empty() {
14208 let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
14209 assert!(manifest.inference.unwrap().alternates.is_empty());
14210 }
14211
14212 #[test]
14213 fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
14214 let manifest = RuntimeManifest::from_yaml_str(&capsule(
14215 ALT,
14216 GPT,
14217 "control:\n agent_settings: [inference.driver]\n",
14218 ))
14219 .unwrap();
14220 let control = manifest.control.unwrap();
14221 assert!(control.settings.is_empty());
14222 assert_eq!(
14223 control.agent_settings,
14224 vec![ControllableSetting::InferenceDriver]
14225 );
14226 assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
14227 assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
14228 assert!(control.secrets.is_empty());
14229 }
14230
14231 #[test]
14232 fn driver_choice_wire_name_round_trips() {
14233 assert_eq!(
14234 ControllableSetting::from_wire_name("inference.driver"),
14235 Some(ControllableSetting::InferenceDriver)
14236 );
14237 assert_eq!(
14238 ControllableSetting::InferenceDriver.wire_name(),
14239 "inference.driver"
14240 );
14241 assert_eq!(
14242 ControllableSetting::AGENT_SETTABLE,
14243 &[ControllableSetting::InferenceDriver]
14244 );
14245 }
14246
14247 #[test]
14249 fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
14250 RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
14251 .expect("an alternate's driver carries its own gateway");
14252 match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
14253 Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
14254 assert!(message.contains("inference.alternates"), "{message}")
14255 }
14256 other => panic!("expected InvalidArtifact, got {other:?}"),
14257 }
14258 }
14259
14260 #[test]
14262 fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
14263 let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
14264 assert_eq!(
14265 manifest.inference.unwrap().alternates[0].driver,
14266 "local/drv"
14267 );
14268 }
14269
14270 #[test]
14271 fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
14272 let manifest = RuntimeManifest::from_yaml_str(&capsule(
14273 ALT,
14274 GPT,
14275 "control:\n settings: [inference.driver]\n secrets: [ALT_KEY]\n",
14276 ))
14277 .unwrap();
14278 assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
14279 let (field, message) = refusal(&capsule(
14280 ALT,
14281 GPT,
14282 "control:\n settings: [inference.driver]\n secrets: [DRIVER_KEY]\n",
14283 ));
14284 assert_eq!(field, "control.secrets");
14285 assert!(
14286 message.contains("keys the configured inference driver's gateway"),
14287 "{message}"
14288 );
14289 }
14290
14291 #[test]
14292 fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
14293 let alternate = " - name: gpt\n model: gpt-5\n temperature: 1\n driver:\n artifact: local/alt\n";
14294 let manifest =
14295 RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
14296 let keys: Vec<(&str, &str)> = manifest
14297 .unknown_keys
14298 .iter()
14299 .map(|key| (key.key.as_str(), key.block_path.as_str()))
14300 .collect();
14301 assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
14302 }
14303
14304 #[test]
14307 fn driver_choice_manifest_refusals_name_the_field() {
14308 let alternate = |name: &str, model: &str, driver: &str| {
14309 format!(" - name: {name}\n model: {model}\n driver:\n artifact: {driver}\n")
14310 };
14311 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";
14312 let cases: Vec<(String, &str, &str)> = vec![
14313 (
14314 format!("{process} alternates:\n{GPT}"),
14315 "inference.alternates",
14316 "is not valid with transport: process",
14317 ),
14318 (
14319 format!("{process}control:\n settings: [inference.driver]\n"),
14320 "control.settings",
14321 "'inference.driver' is controllable only under inference.transport: http",
14322 ),
14323 (
14324 capsule(ALT, &alternate("primary", "gpt-5", "local/alt"), SWITCHABLE),
14325 "inference.alternates[0].name",
14326 "'primary' is reserved",
14327 ),
14328 (
14329 capsule(
14330 ALT,
14331 &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt", "gpt-4", "local/alt")),
14332 SWITCHABLE,
14333 ),
14334 "inference.alternates[1].name",
14335 "'gpt' names more than one alternate",
14336 ),
14337 (
14338 capsule(ALT, &alternate("GPT", "gpt-5", "local/alt"), SWITCHABLE),
14339 "inference.alternates[0].name",
14340 "'GPT' is not a driver choice name",
14341 ),
14342 (
14343 capsule(ALT, &alternate("5gpt", "gpt-5", "local/alt"), SWITCHABLE),
14344 "inference.alternates[0].name",
14345 "'5gpt' is not a driver choice name",
14346 ),
14347 (
14348 capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "local/alt"), SWITCHABLE),
14349 "inference.alternates[0].name",
14350 "is not a driver choice name",
14351 ),
14352 (
14353 capsule(ALT, " - name: gpt\n model: \"\"\n driver:\n artifact: local/alt\n", SWITCHABLE),
14354 "inference.alternates[0].model",
14355 "missing required field",
14356 ),
14357 (
14358 capsule(ALT, " - name: gpt\n model: gpt-5\n", SWITCHABLE),
14359 "inference.alternates[0].driver.artifact",
14360 "missing required field",
14361 ),
14362 (
14363 capsule("", &alternate("gpt", "gpt-5", "local/nowhere"), SWITCHABLE),
14364 "inference.alternates[0].driver.artifact",
14365 "artifact 'local/nowhere' is not declared in artifacts:",
14366 ),
14367 (
14368 capsule(TOOL, &alternate("gpt", "gpt-5", "local/card-api"), SWITCHABLE),
14369 "inference.alternates[0].driver.artifact",
14370 "artifact 'local/card-api' is declared with runtime: tool",
14371 ),
14372 (
14373 capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "local/alt"), SWITCHABLE),
14374 "inference.alternates[0].driver.artifact",
14375 "artifact 'local/alt' declares no gateway.endpoint",
14376 ),
14377 (
14378 capsule(
14379 ALT,
14380 " - name: gpt\n model: gpt-5\n driver:\n artifact: local/alt\n config:\n thinking: enabled\n",
14381 SWITCHABLE,
14382 ),
14383 "inference.alternates[0].driver.config",
14384 "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",
14385 ),
14386 (
14387 capsule("", &alternate("same", "m", "local/drv"), SWITCHABLE),
14388 "inference.alternates[0]",
14389 "'same' is driver 'local/drv' with model 'm', which is the primary choice",
14390 ),
14391 (
14392 capsule(
14393 ALT,
14394 &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt-too", "gpt-5", "local/alt")),
14395 SWITCHABLE,
14396 ),
14397 "inference.alternates[1]",
14398 "'gpt-too' is driver 'local/alt' with model 'gpt-5', which alternate 'gpt' already is",
14399 ),
14400 (
14401 capsule(ALT, GPT, ""),
14402 "inference.alternates",
14403 "declares alternates that nothing could ever select",
14404 ),
14405 (
14406 capsule(ALT, GPT, "control:\n settings: [inference.max_tokens]\n"),
14407 "inference.alternates",
14408 "declares alternates that nothing could ever select",
14409 ),
14410 (
14411 capsule("", "", SWITCHABLE),
14412 "control.settings",
14413 "'inference.driver' selects among inference.alternates, and this capsule declares none",
14414 ),
14415 (
14416 capsule("", "", "control:\n agent_settings: [inference.driver]\n"),
14417 "control.agent_settings",
14418 "'inference.driver' selects among inference.alternates, and this capsule declares none",
14419 ),
14420 (
14421 capsule(ALT, GPT, "control:\n agent_settings: [inference.max_tokens]\n"),
14422 "control.agent_settings",
14423 "'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
14424 ),
14425 (
14426 capsule(
14427 ALT,
14428 GPT,
14429 "control:\n agent_settings: [inference.driver, inference.driver]\n",
14430 ),
14431 "control.agent_settings",
14432 "'inference.driver' is listed more than once",
14433 ),
14434 (
14435 capsule(ALT, GPT, "control:\n agent_settings: inference.driver\n"),
14436 "control.agent_settings",
14437 "must be a list of names",
14438 ),
14439 ];
14440 for (yaml, field, message) in &cases {
14441 let (got_field, got_message) = refusal(yaml);
14442 assert_eq!(&got_field, field, "{yaml}");
14443 assert!(got_message.contains(message), "{yaml}\n{got_message}");
14444 let rendered = RuntimeManifest::from_yaml_str(yaml)
14445 .unwrap_err()
14446 .to_string();
14447 assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
14448 }
14449 }
14450}
14451
14452#[cfg(test)]
14453mod network_authentication_tests {
14454 use super::*;
14455
14456 fn network_authentication_of(
14457 block: &str,
14458 ) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
14459 RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
14460 .map(|manifest| manifest.network.and_then(|network| network.authentication))
14461 }
14462
14463 fn network_authentication_refusal(block: &str) -> (String, String) {
14464 match network_authentication_of(block) {
14465 Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
14466 other => panic!("expected InvalidNetworkConfig, got {other:?}"),
14467 }
14468 }
14469
14470 #[test]
14471 fn network_authentication_with_credentials_parses_sorted_by_name() {
14472 let auth = network_authentication_of(
14473 "network:\n authentication:\n scheme: bearer\n credentials:\n \
14474 watcher:\n scopes: [GetTask, murmur.WatchSession]\n \
14475 reader:\n scopes: [resources/files]\n",
14476 )
14477 .unwrap()
14478 .expect("declared");
14479 assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
14480 assert_eq!(
14481 auth.credentials,
14482 vec![
14483 DoorCredential {
14484 name: "reader".to_string(),
14485 scopes: vec!["resources/files".to_string()],
14486 },
14487 DoorCredential {
14488 name: "watcher".to_string(),
14489 scopes: vec!["GetTask".to_string(), "murmur.WatchSession".to_string()],
14491 },
14492 ]
14493 );
14494 }
14495
14496 #[test]
14497 fn network_authentication_scheme_alone_parses() {
14498 let auth = network_authentication_of("network:\n authentication:\n scheme: bearer\n")
14499 .unwrap()
14500 .expect("declared");
14501 assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
14502 assert!(auth.credentials.is_empty());
14503 }
14504
14505 #[test]
14506 fn network_authentication_absent_is_a_public_door() {
14507 assert_eq!(network_authentication_of("").unwrap(), None);
14508 assert_eq!(
14509 network_authentication_of("network:\n internal_port: 8080\n").unwrap(),
14510 None
14511 );
14512 let manifest = RuntimeManifest::from_yaml_str(
14513 "name: cap\nversion: 0.1.0\nnetwork:\n internal_port: 8080\n",
14514 )
14515 .unwrap();
14516 assert_eq!(
14517 manifest.network,
14518 Some(NetworkConfig {
14519 internal_port: Some(8080),
14520 authentication: None,
14521 })
14522 );
14523 }
14524
14525 #[test]
14526 fn network_authentication_without_a_scheme_is_refused() {
14527 let (field, message) = network_authentication_refusal(
14528 "network:\n authentication:\n credentials:\n w:\n scopes: [GetTask]\n",
14529 );
14530 assert_eq!(field, "network.authentication.scheme");
14531 assert!(message.contains("required"), "{message}");
14532 }
14533
14534 #[test]
14535 fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
14536 for scheme in ["mtls", "Bearer", "basic"] {
14537 let (field, message) = network_authentication_refusal(&format!(
14538 "network:\n authentication:\n scheme: {scheme}\n"
14539 ));
14540 assert_eq!(field, "network.authentication.scheme");
14541 assert!(message.contains("bearer"), "{message}");
14542 assert!(message.contains(scheme), "{message}");
14543 }
14544 }
14545
14546 #[test]
14547 fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
14548 let long = format!("a{}", "b".repeat(32));
14549 for name in [
14550 "operator",
14551 "Watcher",
14552 "1st",
14553 "_x",
14554 "-x",
14555 "wat.cher",
14556 long.as_str(),
14557 ] {
14558 let (field, message) = network_authentication_refusal(&format!(
14559 "network:\n authentication:\n scheme: bearer\n credentials:\n \
14560 '{name}':\n scopes: [GetTask]\n"
14561 ));
14562 assert_eq!(field, "network.authentication.credentials", "{name}");
14563 assert!(message.contains(name), "{message}");
14564 }
14565 let longest = format!("a{}", "b".repeat(31));
14566 for name in ["w", "a-b_c9", longest.as_str()] {
14567 network_authentication_of(&format!(
14568 "network:\n authentication:\n scheme: bearer\n credentials:\n \
14569 {name}:\n scopes: [GetTask]\n"
14570 ))
14571 .unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
14572 }
14573 }
14574
14575 #[test]
14576 fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
14577 let credential = |body: &str| {
14578 format!(
14579 "network:\n authentication:\n scheme: bearer\n credentials:\n \
14580 watcher:\n{body}"
14581 )
14582 };
14583 let field = "network.authentication.credentials.watcher.scopes";
14584
14585 let (got, message) = network_authentication_refusal(&credential(" {}\n"));
14586 assert_eq!(got, field);
14587 assert!(message.contains("required"), "{message}");
14588
14589 let (got, message) = network_authentication_refusal(&credential(" scopes: []\n"));
14590 assert_eq!(got, field);
14591 assert!(message.contains("no scope"), "{message}");
14592
14593 let (got, message) =
14594 network_authentication_refusal(&credential(" scopes: [tasks/list]\n"));
14595 assert_eq!(got, field);
14596 assert!(message.contains("tasks/list"), "{message}");
14597 assert!(message.contains("resources/files"), "{message}");
14598
14599 let (got, message) =
14600 network_authentication_refusal(&credential(" scopes: [GetExtendedAgentCard]\n"));
14601 assert_eq!(got, field);
14602 assert!(message.contains("GetExtendedAgentCard"), "{message}");
14603
14604 let (got, message) = network_authentication_refusal(&credential(
14605 " scopes: [GetTask, murmur.WatchSession, GetTask]\n",
14606 ));
14607 assert_eq!(got, field);
14608 assert!(message.contains("more than once"), "{message}");
14609 }
14610
14611 #[test]
14612 fn network_authentication_refuses_a_retired_scope_naming_its_replacement() {
14613 let field = "network.authentication.credentials.watcher.scopes";
14614 for (retired, replacement) in [
14615 ("message/send", "SendMessage"),
14616 ("message/stream", "SendStreamingMessage"),
14617 ("tasks/get", "GetTask"),
14618 ("tasks/cancel", "CancelTask"),
14619 ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
14620 ("stream/watch", "murmur.WatchSession"),
14621 ("session/stop", "murmur.StopSession"),
14622 ] {
14623 let error = network_authentication_of(&format!(
14624 "network:\n authentication:\n scheme: bearer\n credentials:\n \
14625 watcher:\n scopes: [murmur.WatchSession, {retired}]\n"
14626 ))
14627 .unwrap_err();
14628 let rendered = error.to_string();
14629 match error {
14630 RuntimeManifestError::RetiredDoorScope {
14631 field: got_field,
14632 scope,
14633 replacement: got_replacement,
14634 } => {
14635 assert_eq!(got_field, field);
14636 assert_eq!(scope, retired);
14637 assert_eq!(got_replacement, replacement);
14638 }
14639 other => panic!("expected RetiredDoorScope for {retired}, got {other:?}"),
14640 }
14641 assert!(
14642 rendered.contains(&format!(
14643 "'{retired}' is a retired name of a door method, and the door answers only \
14644 the names its card lists"
14645 )),
14646 "{rendered}"
14647 );
14648 assert!(rendered.contains(field), "{rendered}");
14649 }
14650 }
14651
14652 #[test]
14653 fn network_authentication_accepts_the_v1_scopes() {
14654 let auth = network_authentication_of(
14655 "network:\n authentication:\n scheme: bearer\n credentials:\n \
14656 watcher:\n scopes: [GetTask, murmur.WatchSession, murmur.StopSession]\n",
14657 )
14658 .unwrap()
14659 .unwrap();
14660 assert_eq!(
14661 auth.credentials[0].scopes,
14662 ["GetTask", "murmur.WatchSession", "murmur.StopSession"]
14663 );
14664 }
14665
14666 #[test]
14669 fn door_scope_list_tasks_and_subscribe_to_task_load_on_a_credential() {
14670 let auth = network_authentication_of(
14671 "network:\n authentication:\n scheme: bearer\n credentials:\n \
14672 alice:\n scopes: [SendMessage, ListTasks, SubscribeToTask]\n",
14673 )
14674 .unwrap()
14675 .unwrap();
14676 assert_eq!(
14677 auth.credentials[0].scopes,
14678 ["SendMessage", "ListTasks", "SubscribeToTask"]
14679 );
14680 assert_eq!(
14681 DOOR_SCOPES,
14682 [
14683 "SendMessage",
14684 "SendStreamingMessage",
14685 "murmur.WatchSession",
14686 "GetTask",
14687 "ListTasks",
14688 "CancelTask",
14689 "SubscribeToTask",
14690 "murmur.StopSession",
14691 "resources/files",
14692 ]
14693 );
14694 }
14695
14696 #[test]
14697 fn network_authentication_accepts_every_door_scope() {
14698 let auth = network_authentication_of(&format!(
14699 "network:\n authentication:\n scheme: bearer\n credentials:\n \
14700 everything:\n scopes: [{}]\n",
14701 DOOR_SCOPES.join(", ")
14702 ))
14703 .unwrap()
14704 .unwrap();
14705 assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
14706 assert_eq!(OPERATOR_CREDENTIAL, "operator");
14707 }
14708
14709 #[test]
14710 fn network_authentication_is_refused_beside_spawn_allow() {
14711 let (field, message) = network_authentication_refusal(
14712 "network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
14713 allow: [local/worker]\n",
14714 );
14715 assert_eq!(field, "network.authentication");
14716 assert!(message.contains("capabilities.spawn.allow"), "{message}");
14717 assert!(message.contains("token"), "{message}");
14718 let error = RuntimeManifest::from_yaml_str(
14719 "name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n\
14720 capabilities:\n spawn:\n allow: [local/worker]\n",
14721 )
14722 .unwrap_err()
14723 .to_string();
14724 assert!(error.contains("network.authentication"), "{error}");
14725 assert!(error.contains("capabilities.spawn.allow"), "{error}");
14726
14727 network_authentication_of(
14729 "network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
14730 allow: []\n",
14731 )
14732 .unwrap();
14733 }
14734
14735 #[test]
14736 fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
14737 let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
14738 "name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n \
14739 schemes: x\n credentials:\n watcher:\n scopes: [GetTask]\n \
14740 scope: [GetTask]\n",
14741 )
14742 .unwrap()
14743 .unknown_keys
14744 .into_iter()
14745 .map(|key| (key.key, key.block_path, key.nearest_known))
14746 .collect();
14747 assert_eq!(
14748 keys,
14749 vec![
14750 (
14751 "schemes".to_string(),
14752 "network.authentication".to_string(),
14753 Some("scheme".to_string())
14754 ),
14755 (
14756 "scope".to_string(),
14757 "network.authentication.credentials.watcher".to_string(),
14758 Some("scopes".to_string())
14759 ),
14760 ]
14761 );
14762 }
14763}
14764
14765#[cfg(test)]
14766mod registry_block_tests {
14767 use super::*;
14768
14769 const REGISTRY_BLOCK: &str = "registry:\n default: local\n";
14770
14771 #[test]
14772 fn registry_block_is_refused_by_both_entry_points() {
14773 let input = format!("name: cap\nversion: 0.0.1\nartifacts: []\n{REGISTRY_BLOCK}");
14774 for result in [
14775 RuntimeManifest::from_yaml_str(&input),
14776 RuntimeManifest::from_yaml_str_without_secrets(&input),
14777 ] {
14778 let err = result.unwrap_err();
14779 assert!(
14780 matches!(err, RuntimeManifestError::RegistryBlock),
14781 "{err:?}"
14782 );
14783 assert_eq!(err.to_string(), REGISTRY_BLOCK_MESSAGE);
14784 }
14785 }
14786
14787 #[test]
14788 fn registry_block_is_refused_before_any_other_field() {
14789 let input = format!(
14792 "version: 0.0.1\nlifecycle:\n task_acceptance: queue\n queue_depth: 0\n\
14793 {REGISTRY_BLOCK}"
14794 );
14795 let err = RuntimeManifest::from_yaml_str(&input).unwrap_err();
14796 assert!(
14797 matches!(err, RuntimeManifestError::RegistryBlock),
14798 "{err:?}"
14799 );
14800 }
14801
14802 #[test]
14803 fn registry_block_refusal_applies_whatever_the_block_holds() {
14804 for block in ["registry: local\n", "registry: {}\n", "registry: []\n"] {
14805 let input = format!("name: cap\nversion: 0.0.1\n{block}");
14806 assert!(
14807 matches!(
14808 RuntimeManifest::from_yaml_str(&input),
14809 Err(RuntimeManifestError::RegistryBlock)
14810 ),
14811 "{block}"
14812 );
14813 }
14814 }
14815
14816 #[test]
14817 fn a_manifest_without_a_registry_block_parses_unchanged() {
14818 let manifest =
14819 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
14820 assert_eq!(manifest.name, "cap");
14821 assert_eq!(manifest.version, "0.0.1");
14822 }
14823}
14824
14825#[cfg(test)]
14826mod scoped_name_tests {
14827 use super::*;
14828
14829 fn name_refusal(yaml: &str) -> (String, String, Option<String>) {
14830 match RuntimeManifest::from_yaml_str(yaml) {
14831 Err(RuntimeManifestError::InvalidArtifactName {
14832 field,
14833 message,
14834 hint,
14835 }) => (field, message, hint),
14836 other => panic!("expected InvalidArtifactName, got {other:?}"),
14837 }
14838 }
14839
14840 #[test]
14841 fn scoped_name_a_bare_artifact_entry_is_refused_with_the_scoped_forms() {
14842 let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n - name: tool-editor\n \
14843 version: 0.1.0\n runtime: tool\n";
14844 let (field, message, hint) = name_refusal(yaml);
14845 assert_eq!(field, "artifacts[0].name");
14846 assert_eq!(
14847 message,
14848 "'tool-editor' is not a full artifact name: an artifact is named <scope>/<name>"
14849 );
14850 assert_eq!(
14851 hint.as_deref(),
14852 Some(
14853 "name it with its scope: 'murmur/tool-editor' for a Murmur default artifact, \
14854 '<owner>/tool-editor' for one published from github.com/<owner>, or \
14855 'local/tool-editor' for one installed from a .mur.zip file"
14856 )
14857 );
14858 let rendered = RuntimeManifest::from_yaml_str(yaml)
14859 .unwrap_err()
14860 .to_string();
14861 assert!(
14862 rendered.starts_with("murmur.yaml: invalid artifact name in 'artifacts[0].name': "),
14863 "{rendered}"
14864 );
14865 }
14866
14867 #[test]
14868 fn scoped_name_a_source_entry_keeps_its_bare_name() {
14869 let bare = "name: cap\nversion: 0.0.1\nartifacts:\n - name: style\n \
14870 source: ./skills/style\n runtime: skill\n";
14871 RuntimeManifest::from_yaml_str(bare).expect("a source: entry is named by its bare name");
14872
14873 let (field, message, hint) = name_refusal(&bare.replace("name: style", "name: ana/style"));
14874 assert_eq!(field, "artifacts[0].name");
14875 assert!(
14876 message
14877 .starts_with("'ana/style' declares 'source:', so it is named by its bare name; "),
14878 "{message}"
14879 );
14880 assert_eq!(hint.as_deref(), Some("drop the scope: name it 'style'"));
14881 }
14882
14883 #[test]
14884 fn scoped_name_spawn_allow_refuses_a_bare_entry_and_a_shared_bare_part() {
14885 let with_allow = |allow: &str| {
14886 format!("name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n allow: [{allow}]\n")
14887 };
14888 let manifest = RuntimeManifest::from_yaml_str(&with_allow("local/worker, ana/reviewer"))
14889 .expect("full names in two scopes with distinct bare parts parse");
14890 assert_eq!(
14891 manifest.capabilities.unwrap().spawn.unwrap().allow,
14892 vec!["local/worker".to_string(), "ana/reviewer".to_string()]
14893 );
14894
14895 let (field, message, hint) = name_refusal(&with_allow("worker"));
14896 assert_eq!(field, "capabilities.spawn.allow[0]");
14897 assert!(
14898 message.contains("'worker' is not a full artifact name"),
14899 "{message}"
14900 );
14901 assert!(hint.unwrap().contains("local/worker"));
14902
14903 let (field, message, hint) = name_refusal(&with_allow("local/worker, ana/worker"));
14904 assert_eq!(field, "capabilities.spawn.allow[1]");
14905 assert_eq!(
14906 message,
14907 "'local/worker' and 'ana/worker' share the name 'worker', which is how delegate-task \
14908 offers a capsule to the model"
14909 );
14910 assert_eq!(
14911 hint.as_deref(),
14912 Some("keep one of 'local/worker' and 'ana/worker' in capabilities.spawn.allow")
14913 );
14914 }
14915
14916 #[test]
14917 fn scoped_name_a_reference_by_bare_part_names_the_declared_entry() {
14918 let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n \
14919 version: 0.1.0\n runtime: driver\n gateway:\n \
14920 endpoint: https://api.anthropic.com\n api_key: k\ninference:\n \
14921 transport: http\n model: m\n driver:\n artifact: driver-anthropic\n";
14922 let message = RuntimeManifest::from_yaml_str(yaml)
14923 .unwrap_err()
14924 .to_string();
14925 assert!(
14926 message.contains(
14927 "artifact 'driver-anthropic' is not declared in artifacts: (the declared \
14928 'murmur/driver-anthropic' has that name; refer to it as \
14929 'murmur/driver-anthropic')"
14930 ),
14931 "{message}"
14932 );
14933 }
14934}
14935
14936#[cfg(test)]
14937mod inference_retry_tests {
14938 use super::*;
14939
14940 const HTTP: &str = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/drv\n \
14941 version: 0.1.0\n runtime: driver\n gateway:\n endpoint: \
14942 http://127.0.0.1:8080\n api_key: sk-test\ninference:\n transport: \
14943 http\n model: m\n driver:\n artifact: local/drv\n";
14944 const PROCESS: &str = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/drv\n \
14945 version: 0.1.0\n runtime: driver\ninference:\n transport: \
14946 process\n driver:\n artifact: local/drv\n";
14947
14948 fn retry(yaml: &str) -> InferenceRetry {
14949 RuntimeManifest::from_yaml_str(yaml)
14950 .unwrap()
14951 .inference
14952 .unwrap()
14953 .retry
14954 }
14955
14956 fn refusal(yaml: &str) -> (String, String) {
14957 match RuntimeManifest::from_yaml_str(yaml) {
14958 Err(RuntimeManifestError::InvalidInferenceConfig { field, message }) => {
14959 (field, message)
14960 }
14961 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
14962 }
14963 }
14964
14965 #[test]
14966 fn inference_retry_defaults_when_absent_and_per_key_when_partial() {
14967 assert_eq!(retry(HTTP), InferenceRetry::default());
14968 assert_eq!(
14969 InferenceRetry::default(),
14970 InferenceRetry {
14971 max_retries: 6,
14972 initial_delay_ms: 1_000,
14973 max_delay_ms: 60_000,
14974 }
14975 );
14976 assert_eq!(
14977 retry(&format!("{HTTP} retry:\n max_retries: 2\n")),
14978 InferenceRetry {
14979 max_retries: 2,
14980 ..InferenceRetry::default()
14981 }
14982 );
14983 assert_eq!(
14984 retry(&format!(
14985 "{HTTP} retry:\n initial_delay_ms: 10\n max_delay_ms: 2000\n"
14986 )),
14987 InferenceRetry {
14988 max_retries: 6,
14989 initial_delay_ms: 10,
14990 max_delay_ms: 2000,
14991 }
14992 );
14993 assert_eq!(
14994 retry(&format!(
14995 "{HTTP} retry:\n max_retries: 0\n initial_delay_ms: 60000\n \
14996 max_delay_ms: 3600000\n"
14997 )),
14998 InferenceRetry {
14999 max_retries: 0,
15000 initial_delay_ms: 60_000,
15001 max_delay_ms: 3_600_000,
15002 }
15003 );
15004 assert_eq!(retry(PROCESS), InferenceRetry::default());
15005 }
15006
15007 #[test]
15008 fn inference_retry_bounds_are_refused_naming_the_field() {
15009 for (block, field) in [
15010 ("max_retries: 21", "inference.retry.max_retries"),
15011 ("initial_delay_ms: 0", "inference.retry.initial_delay_ms"),
15012 (
15013 "initial_delay_ms: 60001",
15014 "inference.retry.initial_delay_ms",
15015 ),
15016 ("max_delay_ms: 0", "inference.retry.max_delay_ms"),
15017 ("max_delay_ms: 3600001", "inference.retry.max_delay_ms"),
15018 (
15019 "initial_delay_ms: 5000\n max_delay_ms: 1000",
15020 "inference.retry.max_delay_ms",
15021 ),
15022 ] {
15023 let (got, message) = refusal(&format!("{HTTP} retry:\n {block}\n"));
15024 assert_eq!(got, field, "{block}: {message}");
15025 }
15026 let (_, message) = refusal(&format!(
15027 "{HTTP} retry:\n initial_delay_ms: 5000\n max_delay_ms: 1000\n"
15028 ));
15029 assert!(message.contains("initial_delay_ms"), "{message}");
15030 }
15031
15032 #[test]
15033 fn inference_retry_is_refused_under_transport_process() {
15034 let (field, message) = refusal(&format!("{PROCESS} retry:\n max_retries: 2\n"));
15035 assert_eq!(field, "inference.retry");
15036 assert_eq!(
15037 message,
15038 "is not valid with transport: process; the harness retries its own provider calls"
15039 );
15040 }
15041
15042 #[test]
15043 fn inference_retry_unknown_key_is_reported_at_its_block() {
15044 let manifest = RuntimeManifest::from_yaml_str(&format!(
15045 "{HTTP} retry:\n max_retries: 2\n jitter: full\n"
15046 ))
15047 .unwrap();
15048 let keys: Vec<(&str, &str)> = manifest
15049 .unknown_keys
15050 .iter()
15051 .map(|key| (key.key.as_str(), key.block_path.as_str()))
15052 .collect();
15053 assert_eq!(keys, vec![("jitter", "inference.retry")]);
15054 }
15055}