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