1use std::{fs, net::IpAddr, path::Path};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use url::Url;
6
7use crate::{
8 artifact_name::{ArtifactName, ArtifactNameError},
9 build::artifact_name_format_error,
10 manifest::{REGISTRY_BLOCK_KEY, REGISTRY_BLOCK_MESSAGE},
11 manifest_path::MANIFEST_FILENAME,
12 shell_words::split_shell_words,
13 trace_capture::{resolve_trace_capture, TraceCapture},
14 unknown_manifest_keys::{nearest_known_key, UnknownManifestKey},
15};
16
17#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum TaskAcceptance {
22 None,
23 #[default]
24 Single,
25 Queue,
26}
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum AfterTask {
31 #[default]
32 Exit,
33 Sleep,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ConversationMode {
39 #[default]
40 Stateless,
41 Threaded,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct LifecycleConfig {
46 pub task_acceptance: TaskAcceptance,
47 pub after_task: AfterTask,
48 pub queue_depth: usize,
49 pub input_timeout_secs: Option<u64>,
52 pub conversation_mode: ConversationMode,
56 pub max_task_reopens: u32,
62 pub shell_grace_secs: Option<u64>,
69 pub delegation_deadline_secs: u64,
83 pub deadline_notice_secs: Option<u64>,
88 pub keep_working: Option<KeepWorking>,
93 pub done_check: Option<DoneCheck>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct KeepWorking {
105 pub message: String,
107 pub limit: u32,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct DoneCheck {
115 pub command: String,
118 pub limit: u32,
121 pub timeout_secs: u64,
123}
124
125impl DoneCheck {
126 pub const DEFAULT_TIMEOUT_SECS: u64 = 600;
128
129 pub fn argv(&self) -> Vec<String> {
131 split_shell_words(&self.command)
132 }
133}
134
135impl LifecycleConfig {
136 pub fn can_receive_background_tasks(&self) -> bool {
144 self.task_acceptance == TaskAcceptance::Queue && self.after_task == AfterTask::Sleep
145 }
146
147 pub fn queue_has_room(&self) -> bool {
156 !(self.task_acceptance == TaskAcceptance::Queue && self.queue_depth == 0)
157 }
158
159 pub fn tasks_held_at_once(&self) -> usize {
167 match self.task_acceptance {
168 TaskAcceptance::None => 0,
169 TaskAcceptance::Single => 1,
170 TaskAcceptance::Queue => self.queue_depth + 1,
171 }
172 }
173}
174
175impl Default for LifecycleConfig {
176 fn default() -> Self {
177 Self {
178 task_acceptance: TaskAcceptance::Single,
179 after_task: AfterTask::Exit,
180 queue_depth: 1,
181 input_timeout_secs: None,
182 conversation_mode: ConversationMode::Stateless,
183 max_task_reopens: 1,
184 shell_grace_secs: None,
185 delegation_deadline_secs: 600,
186 deadline_notice_secs: None,
187 keep_working: None,
188 done_check: None,
189 }
190 }
191}
192
193#[derive(Debug, Clone, Default)]
196pub struct LifecycleOverride {
197 pub task_acceptance: Option<TaskAcceptance>,
198 pub after_task: Option<AfterTask>,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
218#[serde(rename_all = "lowercase")]
219pub enum ArtifactRuntime {
220 Tool,
221 Driver,
222 Hook,
223 Skill,
224}
225
226impl ArtifactRuntime {
227 pub fn is_llm_visible(&self) -> bool {
233 match self {
234 ArtifactRuntime::Tool => true,
235 ArtifactRuntime::Skill => true,
236 ArtifactRuntime::Driver => false,
237 ArtifactRuntime::Hook => false,
238 }
239 }
240
241 pub fn as_str(&self) -> &'static str {
242 match self {
243 ArtifactRuntime::Tool => "tool",
244 ArtifactRuntime::Driver => "driver",
245 ArtifactRuntime::Hook => "hook",
246 ArtifactRuntime::Skill => "skill",
247 }
248 }
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
258pub enum HookBinding {
259 OnStage,
261 OnSessionStart,
263 OnTaskStart,
265 OnInference,
267 OnToolCall,
269 OnShell,
271 OnCompaction,
273 OnTaskEnd,
275 OnSessionEnd,
277 All,
279}
280
281impl HookBinding {
282 #[must_use]
287 pub fn as_str(&self) -> &'static str {
288 match self {
289 Self::OnStage => "on-stage",
290 Self::OnSessionStart => "on-session-start",
291 Self::OnTaskStart => "on-task-start",
292 Self::OnInference => "on-inference",
293 Self::OnToolCall => "on-tool-call",
294 Self::OnShell => "on-shell",
295 Self::OnCompaction => "on-compaction",
296 Self::OnTaskEnd => "on-task-end",
297 Self::OnSessionEnd => "on-session-end",
298 Self::All => "all",
299 }
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
308pub enum HookExecutionMode {
309 Blocking,
311 Async,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq)]
320pub enum HookCommitPolicy {
321 None,
323 ReplaceContext,
325 WriteManifests,
328 ReopenTask,
331 SeedContext,
335 Deny,
343}
344
345impl HookCommitPolicy {
346 #[must_use]
348 pub fn as_str(&self) -> &'static str {
349 match self {
350 Self::None => "none",
351 Self::ReplaceContext => "replace-context",
352 Self::WriteManifests => "write-manifests",
353 Self::ReopenTask => "reopen-task",
354 Self::SeedContext => "seed-context",
355 Self::Deny => "deny",
356 }
357 }
358}
359
360#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
370#[serde(rename_all = "snake_case")]
371pub enum HookOverflowPolicy {
372 #[default]
376 Drop,
377 Block,
380}
381
382impl HookOverflowPolicy {
383 #[must_use]
385 pub fn as_str(&self) -> &'static str {
386 match self {
387 Self::Drop => "drop",
388 Self::Block => "block",
389 }
390 }
391}
392
393#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct HookConfig {
396 pub binding: HookBinding,
397 pub execution_mode: HookExecutionMode,
398 pub commit_policy: HookCommitPolicy,
399}
400
401impl Default for HookConfig {
402 fn default() -> Self {
403 Self {
404 binding: HookBinding::All,
405 execution_mode: HookExecutionMode::Blocking,
406 commit_policy: HookCommitPolicy::None,
407 }
408 }
409}
410
411#[must_use]
435pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
436 match binding {
437 HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
438 HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
439 HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
440 HookBinding::OnTaskStart => Some(HookCommitPolicy::SeedContext),
441 HookBinding::OnToolCall | HookBinding::OnShell => Some(HookCommitPolicy::Deny),
442 HookBinding::OnSessionStart
443 | HookBinding::OnInference
444 | HookBinding::OnSessionEnd
445 | HookBinding::All => None,
446 }
447}
448
449pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
451 let content =
452 fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
453 path: artifact_manifest_path.display().to_string(),
454 source,
455 })?;
456 parse_hook_config_from_yaml(&content).map_err(|msg| {
457 RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
458 })
459}
460
461pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
471 let v: serde_norway::Value =
472 serde_norway::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
473
474 let binding = v
475 .as_mapping()
476 .and_then(|m| m.get(serde_norway::Value::String("binding".to_string())))
477 .and_then(serde_norway::Value::as_str)
478 .map(|s| match s {
479 "on-stage" => Ok(HookBinding::OnStage),
480 "on-session-start" => Ok(HookBinding::OnSessionStart),
481 "on-task-start" => Ok(HookBinding::OnTaskStart),
482 "on-inference" => Ok(HookBinding::OnInference),
483 "on-tool-call" => Ok(HookBinding::OnToolCall),
484 "on-shell" => Ok(HookBinding::OnShell),
485 "on-compaction" => Ok(HookBinding::OnCompaction),
486 "on-task-end" => Ok(HookBinding::OnTaskEnd),
487 "on-session-end" => Ok(HookBinding::OnSessionEnd),
488 other => Err(format!(
489 "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
490 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
491 )),
492 })
493 .transpose()?
494 .unwrap_or(HookBinding::All);
495
496 let execution_mode = v
497 .as_mapping()
498 .and_then(|m| m.get(serde_norway::Value::String("execution_mode".to_string())))
499 .and_then(serde_norway::Value::as_str)
500 .map(|s| match s {
501 "blocking" => Ok(HookExecutionMode::Blocking),
502 "async" => Ok(HookExecutionMode::Async),
503 other => Err(format!(
504 "unknown execution_mode '{other}'; expected: blocking, async"
505 )),
506 })
507 .transpose()?
508 .unwrap_or(HookExecutionMode::Blocking);
509
510 let commit_policy = v
511 .as_mapping()
512 .and_then(|m| m.get(serde_norway::Value::String("commit_policy".to_string())))
513 .and_then(serde_norway::Value::as_str)
514 .map(|s| match s {
515 "none" => Ok(HookCommitPolicy::None),
516 "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
517 "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
518 "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
519 "seed-context" => Ok(HookCommitPolicy::SeedContext),
520 "deny" => Ok(HookCommitPolicy::Deny),
521 other => Err(format!(
522 "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
523 reopen-task, seed-context, deny"
524 )),
525 })
526 .transpose()?
527 .unwrap_or(HookCommitPolicy::None);
528
529 if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
531 return Err(format!(
532 "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
533 (got '{}')",
534 commit_policy.as_str()
535 ));
536 }
537 if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
538 return Err(
539 "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
540 'on-stage'"
541 .to_string(),
542 );
543 }
544 if binding == HookBinding::All && commit_policy == HookCommitPolicy::Deny {
548 return Err(
549 "commit_policy 'deny' requires an explicit binding: 'on-shell' or 'on-tool-call'; \
550 a hook with no binding: is dispatched at every event, including decision points \
551 it was not written to decide"
552 .to_string(),
553 );
554 }
555 if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
560 let honored = commit_policy_for_binding(&binding);
561 if honored.as_ref() != Some(&commit_policy) {
562 let note = if binding == HookBinding::OnInference {
563 " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
564 } else {
565 ""
566 };
567 return Err(format!(
568 "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
569 commit_policy '{}'{note}",
570 commit_policy.as_str(),
571 binding.as_str(),
572 binding.as_str(),
573 honored.as_ref().map_or("none", HookCommitPolicy::as_str)
574 ));
575 }
576 }
577
578 Ok(HookConfig {
579 binding,
580 execution_mode,
581 commit_policy,
582 })
583}
584
585#[derive(Debug, Clone, PartialEq, Eq)]
586pub struct RuntimeManifest {
587 pub name: String,
588 pub version: String,
589 pub artifacts: Vec<RuntimeArtifact>,
590 pub capabilities: Option<Capabilities>,
591 pub inference: Option<InferenceConfig>,
592 pub context: Option<ContextConfig>,
593 pub observability: Option<ObservabilityConfig>,
594 pub trace: Option<TraceConfig>,
595 pub network: Option<NetworkConfig>,
596 pub lifecycle: Option<LifecycleConfig>,
597 pub exports: Option<Exports>,
600 pub control: Option<ControlConfig>,
604 pub deploy: Option<DeployConfig>,
606 pub unknown_keys: Vec<UnknownManifestKey>,
616}
617
618impl RuntimeManifest {
619 pub fn effective_lifecycle(&self) -> LifecycleConfig {
620 self.lifecycle.clone().unwrap_or_default()
621 }
622
623 pub fn accepts_peer_tasks(&self) -> bool {
630 self.exports
631 .as_ref()
632 .is_some_and(Exports::accepts_peer_tasks)
633 }
634
635 pub fn deploy_mur_version(&self) -> Option<&str> {
638 self.deploy.as_ref()?.mur_version.as_deref()
639 }
640}
641
642#[derive(Debug, Clone, Default, PartialEq, Eq)]
644pub struct DeployConfig {
645 pub mur_version: Option<String>,
649}
650
651#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
658pub enum ControllableSetting {
659 InferenceMaxTokens,
662 InferenceDriver,
666}
667
668impl ControllableSetting {
669 pub const ALL: &'static [ControllableSetting] =
671 &[Self::InferenceMaxTokens, Self::InferenceDriver];
672
673 pub const AGENT_SETTABLE: &'static [ControllableSetting] = &[Self::InferenceDriver];
676
677 pub fn wire_name(self) -> &'static str {
679 match self {
680 Self::InferenceMaxTokens => "inference.max_tokens",
681 Self::InferenceDriver => "inference.driver",
682 }
683 }
684
685 pub fn from_wire_name(name: &str) -> Option<Self> {
687 Self::ALL
688 .iter()
689 .copied()
690 .find(|setting| setting.wire_name() == name)
691 }
692}
693
694#[derive(Debug, Clone, PartialEq, Eq, Default)]
699pub struct ControlConfig {
700 pub settings: Vec<ControllableSetting>,
702 pub secrets: Vec<String>,
706 pub agent_settings: Vec<ControllableSetting>,
710}
711
712impl ControlConfig {
713 pub fn declares_secret(&self, name: &str) -> bool {
715 self.secrets.iter().any(|secret| secret == name)
716 }
717
718 pub fn controller_may_set(&self, setting: ControllableSetting) -> bool {
720 self.settings.contains(&setting)
721 }
722
723 pub fn agent_may_set(&self, setting: ControllableSetting) -> bool {
725 self.agent_settings.contains(&setting)
726 }
727}
728
729#[derive(Debug, Clone, PartialEq, Eq)]
732pub struct NetworkConfig {
733 pub internal_port: Option<u16>,
735 pub authentication: Option<NetworkAuthentication>,
738}
739
740pub const DOOR_SCOPES: &[&str] = &[
744 "SendMessage",
745 "SendStreamingMessage",
746 "murmur.WatchSession",
747 "GetTask",
748 "ListTasks",
749 "CancelTask",
750 "SubscribeToTask",
751 "murmur.StopSession",
752 "resources/files",
753];
754
755pub const RETIRED_DOOR_SCOPES: &[(&str, &str)] = &[
764 ("message/send", "SendMessage"),
765 ("message/stream", "SendStreamingMessage"),
766 ("tasks/get", "GetTask"),
767 ("tasks/cancel", "CancelTask"),
768 ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
769 ("stream/watch", "murmur.WatchSession"),
770 ("session/stop", "murmur.StopSession"),
771];
772
773pub const OPERATOR_CREDENTIAL: &str = "operator";
776
777const MAX_CREDENTIAL_NAME_LEN: usize = 32;
779
780#[derive(Debug, Clone, Copy, PartialEq, Eq)]
784pub enum AuthenticationScheme {
785 Bearer,
787}
788
789impl AuthenticationScheme {
790 pub const ALL: &'static [AuthenticationScheme] = &[Self::Bearer];
792
793 pub fn wire_name(self) -> &'static str {
795 match self {
796 Self::Bearer => "bearer",
797 }
798 }
799}
800
801#[derive(Debug, Clone, PartialEq, Eq)]
804pub struct DoorCredential {
805 pub name: String,
807 pub scopes: Vec<String>,
809}
810
811#[derive(Debug, Clone, PartialEq, Eq)]
814pub struct NetworkAuthentication {
815 pub scheme: AuthenticationScheme,
816 pub credentials: Vec<DoorCredential>,
818}
819
820pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
824
825#[derive(Debug, Clone, PartialEq, Eq)]
833pub struct Exports {
834 pub files: Option<FileExport>,
837 pub peer_files: Option<PeerFilesExport>,
842 pub peer_tasks: Option<PeerTasksExport>,
846}
847
848impl Exports {
849 pub fn accepts_peer_tasks(&self) -> bool {
855 self.peer_tasks.is_some_and(|peer_tasks| peer_tasks.accept)
856 }
857}
858
859#[derive(Debug, Clone, Copy, PartialEq, Eq)]
870pub struct PeerTasksExport {
871 pub accept: bool,
873}
874
875#[derive(Debug, Clone, PartialEq, Eq)]
877pub struct FileExport {
878 pub root: String,
885 pub mode: ExportMode,
889 pub max_bytes: u64,
893}
894
895pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
898
899pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
908
909pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
911
912#[derive(Debug, Clone, PartialEq, Eq)]
919pub struct PeerFilesExport {
920 pub root: String,
924 pub max_ttl_secs: Option<u64>,
931 pub max_bytes: u64,
935}
936
937impl PeerFilesExport {
938 pub fn effective_max_ttl_secs(&self) -> u64 {
944 self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
945 }
946}
947
948#[derive(Debug, Clone, PartialEq, Eq)]
956pub struct PeerFetchCapabilities {
957 pub allow: Vec<String>,
961}
962
963pub const DURATION_ACCEPTED_FORM: &str =
966 "must be a duration: an integer, optionally suffixed s/m/h/d";
967
968pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
974 let trimmed = input.trim();
975 let (digits, multiplier) = match trimmed.strip_suffix('s') {
976 Some(digits) => (digits, 1u64),
977 None => match trimmed.strip_suffix('m') {
978 Some(digits) => (digits, 60),
979 None => match trimmed.strip_suffix('h') {
980 Some(digits) => (digits, 3600),
981 None => match trimmed.strip_suffix('d') {
982 Some(digits) => (digits, 86_400),
983 None => (trimmed, 1),
984 },
985 },
986 },
987 };
988 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
991 return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
992 }
993 digits
994 .parse::<u64>()
995 .ok()
996 .and_then(|value| value.checked_mul(multiplier))
997 .ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
998}
999
1000#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1001pub enum ExportMode {
1002 #[serde(rename = "read-only")]
1003 ReadOnly,
1004}
1005
1006impl ExportMode {
1007 pub fn as_str(self) -> &'static str {
1008 match self {
1009 ExportMode::ReadOnly => "read-only",
1010 }
1011 }
1012}
1013
1014impl std::fmt::Display for ExportMode {
1015 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1016 f.write_str(self.as_str())
1017 }
1018}
1019
1020pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
1023
1024pub fn parse_byte_size(input: &str) -> Result<u64, String> {
1030 let trimmed = input.trim();
1031 let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
1032 Some(digits) => (digits, 1024u64),
1033 None => match trimmed.strip_suffix("Mi") {
1034 Some(digits) => (digits, 1024 * 1024),
1035 None => match trimmed.strip_suffix("Gi") {
1036 Some(digits) => (digits, 1024 * 1024 * 1024),
1037 None => (trimmed, 1),
1038 },
1039 },
1040 };
1041 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
1044 return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
1045 }
1046 digits
1047 .parse::<u64>()
1048 .ok()
1049 .and_then(|value| value.checked_mul(multiplier))
1050 .ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
1051}
1052
1053#[derive(Debug, Clone, PartialEq, Eq)]
1054pub struct RuntimeArtifact {
1055 pub name: String,
1056 pub version: String,
1057 pub runtime: ArtifactRuntime,
1058 pub source: Option<String>,
1064 pub local_source: bool,
1069 pub prompt_payload: bool,
1074 pub capabilities: Option<Capabilities>,
1093 pub on_overflow: HookOverflowPolicy,
1098 pub config: Option<serde_norway::Value>,
1115 pub gateway: Option<ArtifactGateway>,
1126}
1127
1128impl RuntimeArtifact {
1129 #[must_use]
1133 pub fn tool_name(&self) -> &str {
1134 tool_name_of(&self.name)
1135 }
1136
1137 pub fn artifact_name(&self) -> Result<ArtifactName, ArtifactNameError> {
1139 ArtifactName::parse(&self.name)
1140 }
1141}
1142
1143#[must_use]
1146pub fn tool_name_of(name: &str) -> &str {
1147 name.split_once('/').map_or(name, |(_, bare)| bare)
1148}
1149
1150#[derive(Debug, Clone, PartialEq, Eq)]
1154pub struct ArtifactGateway {
1155 pub endpoint: String,
1158 pub api_key: Option<ApiKeyReference>,
1161 pub keyless: bool,
1165}
1166
1167#[derive(Debug, Clone, PartialEq, Eq)]
1168pub struct InferenceDriver {
1169 pub artifact: String,
1170 pub config: Option<String>,
1171}
1172
1173pub const PRIMARY_DRIVER_CHOICE: &str = "primary";
1176
1177#[derive(Debug, Clone, PartialEq, Eq)]
1183pub struct InferenceAlternate {
1184 pub name: String,
1186 pub model: String,
1188 pub driver: String,
1191}
1192
1193#[derive(Clone, PartialEq, Eq)]
1196pub enum ApiKeyReference {
1197 Environment(String),
1200 Literal(String),
1202}
1203
1204impl std::fmt::Debug for ApiKeyReference {
1205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1206 match self {
1207 Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
1208 Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
1209 }
1210 }
1211}
1212
1213#[derive(Debug, Clone, PartialEq, Eq)]
1214pub struct InferenceConfig {
1215 pub transport: String,
1216 pub model: String,
1217 pub driver: Option<InferenceDriver>,
1220 pub command: Option<String>,
1224 pub compaction: Option<CompactionConfig>,
1225 pub system_prompt: Option<String>,
1226 pub system_prompt_file: Option<String>,
1227 pub system_prompt_artifact: Option<String>,
1231 pub instruction_files: Option<Vec<String>>,
1237 pub max_turns: Option<u32>,
1240 pub max_tokens: Option<u32>,
1248 pub max_session_tokens: Option<u64>,
1256 pub tool_refresh: ToolRefresh,
1260 pub alternates: Vec<InferenceAlternate>,
1264 pub retry: InferenceRetry,
1268}
1269
1270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1273pub struct InferenceRetry {
1274 pub max_retries: u32,
1276 pub initial_delay_ms: u32,
1278 pub max_delay_ms: u32,
1281}
1282
1283impl InferenceRetry {
1284 pub const MAX_RETRIES: u32 = 20;
1286 pub const MAX_INITIAL_DELAY_MS: u32 = 60_000;
1288 pub const MAX_DELAY_MS: u32 = 3_600_000;
1290}
1291
1292impl Default for InferenceRetry {
1293 fn default() -> Self {
1294 Self {
1295 max_retries: 6,
1296 initial_delay_ms: 1_000,
1297 max_delay_ms: 60_000,
1298 }
1299 }
1300}
1301
1302#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1309pub enum ToolRefresh {
1310 #[default]
1313 Compaction,
1314 Immediate,
1316}
1317
1318impl ToolRefresh {
1319 pub const ALL: [ToolRefresh; 2] = [Self::Compaction, Self::Immediate];
1321
1322 #[must_use]
1324 pub fn wire_name(self) -> &'static str {
1325 match self {
1326 Self::Compaction => "compaction",
1327 Self::Immediate => "immediate",
1328 }
1329 }
1330}
1331
1332#[derive(Debug, Clone, PartialEq)]
1333pub struct CompactionConfig {
1334 pub threshold: Option<f32>,
1336 pub model: Option<String>,
1338 pub system_prompt: Option<String>,
1341 pub system_prompt_file: Option<String>,
1345 pub dump_summaries: Option<bool>,
1350 pub max_tokens: Option<u32>,
1355 pub driver_config: Option<String>,
1359}
1360
1361impl Eq for CompactionConfig {}
1363
1364pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
1368
1369pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
1373
1374#[derive(Debug, Clone, PartialEq)]
1375pub struct ContextConfig {
1376 pub max_tokens: Option<u32>,
1380 pub record: bool,
1384 pub record_store: Option<String>,
1388 pub seed_budget: f32,
1393 pub seed_overflow_margin: f32,
1398 pub retain: Option<ContextRetainConfig>,
1401 pub images: ContextImagesConfig,
1404}
1405
1406pub const DEFAULT_IMAGES_KEEP: u32 = 5;
1408pub const DEFAULT_IMAGES_MAX_BYTES: u64 = 5 * 1024 * 1024;
1410pub const DEFAULT_IMAGES_MAX_PER_TURN: u32 = 5;
1412
1413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1418pub struct ContextImagesConfig {
1419 pub keep: u32,
1423 pub max_bytes: u64,
1425 pub max_per_turn: u32,
1428}
1429
1430impl Default for ContextImagesConfig {
1431 fn default() -> Self {
1432 Self {
1433 keep: DEFAULT_IMAGES_KEEP,
1434 max_bytes: DEFAULT_IMAGES_MAX_BYTES,
1435 max_per_turn: DEFAULT_IMAGES_MAX_PER_TURN,
1436 }
1437 }
1438}
1439
1440impl Eq for ContextConfig {}
1442
1443#[derive(Debug, Clone, PartialEq, Eq)]
1444pub struct ObservabilityConfig {
1445 pub otel_endpoint: Option<String>,
1446 pub eval: Option<EvalConfig>,
1447}
1448
1449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1450pub struct TraceConfig {
1451 pub capture: TraceCapture,
1454 pub retain: Option<TraceRetainConfig>,
1459}
1460
1461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1467pub struct TraceRetainConfig {
1468 pub max_sessions: Option<u32>,
1472 pub max_age_secs: Option<u64>,
1475}
1476
1477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1483pub struct ContextRetainConfig {
1484 pub max_messages: Option<u32>,
1487 pub max_age_secs: Option<u64>,
1490}
1491
1492#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1493pub struct EvalConfig {
1494 #[serde(skip_serializing_if = "Option::is_none")]
1495 pub dataset_id: Option<String>,
1496 pub scorers: Vec<ScorerConfig>,
1497}
1498
1499#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1500#[serde(tag = "type", rename_all = "snake_case")]
1501pub enum ScorerConfig {
1502 ExitOk { name: String },
1503 MaxTurns { name: String, max: u32 },
1504 MaxTokens { name: String, max: u64 },
1505 ToolSequence { name: String, expected: Vec<String> },
1506 LlmJudge { name: String },
1507}
1508
1509#[derive(Debug, Clone, PartialEq, Eq)]
1510pub struct NetworkCapabilities {
1511 pub allow: Vec<String>,
1512 pub unix_sockets: bool,
1520}
1521
1522#[derive(Debug, Clone, PartialEq, Eq)]
1523pub struct FilesystemCapabilities {
1524 pub scope: Option<String>,
1525 pub workdir_exec: bool,
1540 pub read_only: Vec<String>,
1547 pub records: bool,
1552}
1553
1554#[derive(Debug, Clone, PartialEq, Eq)]
1559pub struct TaskIoCapabilities {
1560 pub read: bool,
1564}
1565
1566#[derive(Debug, Clone, PartialEq, Eq)]
1571pub struct ConversationCapabilities {
1572 pub read: bool,
1575}
1576
1577#[derive(Debug, Clone, PartialEq, Eq)]
1581pub struct PlanCapabilities {
1582 pub submit: bool,
1585}
1586
1587#[derive(Debug, Clone, PartialEq, Eq)]
1595pub struct HarnessCapabilities {
1596 pub paths: Vec<HarnessPathEntry>,
1598}
1599
1600#[derive(Debug, Clone, PartialEq, Eq)]
1604pub struct HarnessPathEntry {
1605 pub path: String,
1607 pub access: HarnessPathAccess,
1608}
1609
1610#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1613pub enum HarnessPathAccess {
1614 Read,
1615 ReadWrite,
1616}
1617
1618impl HarnessPathAccess {
1619 #[must_use]
1621 pub fn wire_name(self) -> &'static str {
1622 match self {
1623 Self::Read => "read",
1624 Self::ReadWrite => "read-write",
1625 }
1626 }
1627
1628 #[must_use]
1630 pub fn parse(value: &str) -> Option<Self> {
1631 match value {
1632 "read" => Some(Self::Read),
1633 "read-write" => Some(Self::ReadWrite),
1634 _ => None,
1635 }
1636 }
1637}
1638
1639#[derive(Debug, Clone, PartialEq, Eq)]
1646pub struct InstallCapabilities {
1647 pub skill: Vec<String>,
1649 pub tool: Vec<String>,
1651 pub from: InstallFrom,
1653}
1654
1655#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1658pub enum InstallFrom {
1659 #[default]
1661 Stores,
1662 StoresAndSources,
1665}
1666
1667impl InstallFrom {
1668 #[must_use]
1670 pub fn sources(self) -> bool {
1671 self == Self::StoresAndSources
1672 }
1673
1674 #[must_use]
1676 pub fn as_list(self) -> &'static str {
1677 match self {
1678 Self::Stores => "stores",
1679 Self::StoresAndSources => "stores, sources",
1680 }
1681 }
1682}
1683
1684#[derive(Debug, Clone, PartialEq, Eq)]
1685pub struct ShellCapabilities {
1686 pub allow: Vec<String>,
1687 pub strip_env: Option<Vec<String>>,
1688 pub baseline_env: Option<Vec<String>>,
1689 pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1696 pub staged_runtime: Vec<StagedRuntimeGrant>,
1702}
1703
1704#[derive(Debug, Clone, PartialEq, Eq)]
1709pub struct InterpreterRuntimeGrant {
1710 pub binary: String,
1714 pub dirs: Vec<InterpreterRuntimeDir>,
1717}
1718
1719#[derive(Debug, Clone, PartialEq, Eq)]
1721pub struct InterpreterRuntimeDir {
1722 pub path: String,
1724 pub list_dir: bool,
1729}
1730
1731#[derive(Debug, Clone, PartialEq, Eq)]
1748pub struct StagedRuntimeGrant {
1749 pub binary: String,
1753 pub source_path: String,
1757 pub pin: String,
1762}
1763
1764#[derive(Debug, Clone, PartialEq, Eq)]
1765pub struct SpawnCapabilities {
1766 pub allow: Vec<String>,
1767 pub workdir_snapshot: Vec<String>,
1772}
1773
1774#[derive(Debug, Clone, PartialEq, Eq, Default)]
1779pub struct EnvCapabilities {
1780 pub allow: Vec<String>,
1781}
1782
1783#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1791pub struct ResourceLimits {
1792 pub memory_bytes: Option<usize>,
1794 pub table_elements: Option<usize>,
1796 pub instances: Option<usize>,
1798 pub deadline_seconds: Option<u64>,
1800}
1801
1802#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1816pub struct ResourceCapabilities {
1817 pub max_processes: Option<u64>,
1822 pub max_open_files: Option<u64>,
1824 pub max_file_size_bytes: Option<u64>,
1826 pub cpu_seconds: Option<u64>,
1829 pub memory_bytes: Option<u64>,
1832 pub cgroup_memory_bytes: Option<u64>,
1834 pub cgroup_pids_max: Option<u64>,
1836 pub cgroup_cpu_percent: Option<u32>,
1838 pub cgroup_io_bytes_per_sec: Option<u64>,
1841 pub workdir_max_bytes: Option<u64>,
1843}
1844
1845#[derive(Debug, Clone, PartialEq, Eq, Default)]
1852pub struct StateCapabilities {
1853 pub store: Option<String>,
1861}
1862
1863#[derive(
1876 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1877)]
1878#[serde(rename_all = "snake_case")]
1879pub enum ContainmentClass {
1880 Unconfined,
1887 #[default]
1891 Advisory,
1892 Scoped,
1896 Sealed,
1904}
1905
1906impl ContainmentClass {
1907 pub fn as_str(self) -> &'static str {
1910 match self {
1911 Self::Unconfined => "unconfined",
1912 Self::Advisory => "advisory",
1913 Self::Scoped => "scoped",
1914 Self::Sealed => "sealed",
1915 }
1916 }
1917
1918 pub const ALL: [ContainmentClass; 4] =
1920 [Self::Unconfined, Self::Advisory, Self::Scoped, Self::Sealed];
1921
1922 pub const DECLARABLE: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1925}
1926
1927impl std::fmt::Display for ContainmentClass {
1928 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1929 f.write_str(self.as_str())
1930 }
1931}
1932
1933#[derive(Debug, Clone, PartialEq, Eq)]
1935pub struct ParseContainmentClassError {
1936 pub value: String,
1937}
1938
1939impl std::fmt::Display for ParseContainmentClassError {
1940 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1941 let names: Vec<&str> = ContainmentClass::DECLARABLE
1942 .iter()
1943 .map(|class| class.as_str())
1944 .collect();
1945 write!(
1946 f,
1947 "must be one of: {}; got '{}'",
1948 names.join(", "),
1949 self.value
1950 )
1951 }
1952}
1953
1954impl std::error::Error for ParseContainmentClassError {}
1955
1956impl std::str::FromStr for ContainmentClass {
1957 type Err = ParseContainmentClassError;
1958
1959 fn from_str(value: &str) -> Result<Self, Self::Err> {
1960 match value.trim() {
1961 "unconfined" => Ok(Self::Unconfined),
1962 "advisory" => Ok(Self::Advisory),
1963 "scoped" => Ok(Self::Scoped),
1964 "sealed" => Ok(Self::Sealed),
1965 other => Err(ParseContainmentClassError {
1966 value: other.to_string(),
1967 }),
1968 }
1969 }
1970}
1971
1972pub const UNCONFINED_NOT_DECLARABLE: &str = "unconfined is not a declarable floor; only the operator chooses it, with `mur run --unconfined` or `containment: unconfined` in ~/.murmur/config.yaml";
1974
1975pub fn effective_containment_floor(
1983 workspace_default: Option<ContainmentClass>,
1984 manifest_declared: Option<ContainmentClass>,
1985 cli_flag: Option<ContainmentClass>,
1986) -> ContainmentClass {
1987 [workspace_default, manifest_declared, cli_flag]
1988 .into_iter()
1989 .flatten()
1990 .max()
1991 .unwrap_or_default()
1992}
1993
1994#[derive(Debug, Clone, PartialEq, Eq)]
1995pub struct Capabilities {
1996 pub network: Option<NetworkCapabilities>,
1997 pub peer_fetch: Option<PeerFetchCapabilities>,
2000 pub filesystem: Option<FilesystemCapabilities>,
2001 pub shell: Option<ShellCapabilities>,
2002 pub spawn: Option<SpawnCapabilities>,
2003 pub env: Option<EnvCapabilities>,
2004 pub limits: Option<ResourceLimits>,
2005 pub resources: Option<ResourceCapabilities>,
2006 pub state: Option<StateCapabilities>,
2009 pub task_io: Option<TaskIoCapabilities>,
2012 pub conversation: Option<ConversationCapabilities>,
2015 pub plan: Option<PlanCapabilities>,
2018 pub install: Option<InstallCapabilities>,
2021 pub harness: Option<HarnessCapabilities>,
2025 pub containment: Option<ContainmentClass>,
2029}
2030
2031#[derive(Debug, Error)]
2032pub enum RuntimeManifestError {
2033 #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
2034 NotFound(String),
2035 #[error("{0}")]
2036 YamlSyntax(String),
2037 #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
2038 MissingField { field: String },
2039 #[error(
2040 "{}: invalid artifact declaration at index {index}: {message}",
2041 MANIFEST_FILENAME
2042 )]
2043 InvalidArtifact { index: usize, message: String },
2044 #[error(
2045 "{}: invalid inference config for '{field}': {message}",
2046 MANIFEST_FILENAME
2047 )]
2048 InvalidInferenceConfig { field: String, message: String },
2049 #[error(
2050 "{}: invalid capability config for '{field}': {message}",
2051 MANIFEST_FILENAME
2052 )]
2053 InvalidCapabilities { field: String, message: String },
2054 #[error(
2057 "{}: invalid capability config for 'capabilities.shell.allow[{index}]': '{}' {reason}; \
2058 an entry is a bare binary name",
2059 MANIFEST_FILENAME,
2060 entry.escape_debug()
2061 )]
2062 ShellAllowEntry {
2063 index: usize,
2064 entry: String,
2065 reason: &'static str,
2066 },
2067 #[error(
2068 "{}: invalid exports config for '{field}': {message}",
2069 MANIFEST_FILENAME
2070 )]
2071 InvalidExports { field: String, message: String },
2072 #[error(
2073 "{}: invalid control config for '{field}': {message}",
2074 MANIFEST_FILENAME
2075 )]
2076 InvalidControl { field: String, message: String },
2077 #[error(
2078 "{}: invalid network config for '{field}': {message}",
2079 MANIFEST_FILENAME
2080 )]
2081 InvalidNetworkConfig { field: String, message: String },
2082 #[error(
2085 "{}: invalid network config for '{field}': '{scope}' is a retired name of a door \
2086 method, and the door answers only the names its card lists",
2087 MANIFEST_FILENAME
2088 )]
2089 RetiredDoorScope {
2090 field: String,
2091 scope: String,
2092 replacement: &'static str,
2093 },
2094 #[error(
2097 "{}: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves task_acceptance: \
2098 queue no room for a waiting task",
2099 MANIFEST_FILENAME
2100 )]
2101 QueueWithoutRoom,
2102 #[error(
2105 "{}: 'mur_version' is not a top-level key — the mur release a deploy installs is pinned \
2106 as 'deploy.mur_version'",
2107 MANIFEST_FILENAME
2108 )]
2109 TopLevelMurVersion,
2110 #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
2111 InvalidTraceConfig { field: String, message: String },
2112 #[error(
2114 "{}: invalid lifecycle config for '{field}': {message}",
2115 MANIFEST_FILENAME
2116 )]
2117 InvalidLifecycleConfig { field: String, message: String },
2118 #[error("{}: invalid artifact name in '{field}': {message}", MANIFEST_FILENAME)]
2121 InvalidArtifactName {
2122 field: String,
2123 message: String,
2124 hint: Option<String>,
2125 },
2126 #[error("{}", REGISTRY_BLOCK_MESSAGE)]
2128 RegistryBlock,
2129 #[error(
2133 "{}: '{field}' cannot be declared in a manifest — host folders are lent by the operator, \
2134 never asked for by a capsule",
2135 MANIFEST_FILENAME
2136 )]
2137 HostPathsDeclared { field: String },
2138 #[error(
2142 "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
2143 gateway.endpoint '{endpoint}' but binds no credential",
2144 MANIFEST_FILENAME
2145 )]
2146 GatewayWithoutCredential {
2147 index: usize,
2148 name: String,
2149 endpoint: String,
2150 },
2151 #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
2152 Io {
2153 path: String,
2154 #[source]
2155 source: std::io::Error,
2156 },
2157}
2158
2159#[derive(Debug, Deserialize)]
2160struct RawRuntimeManifest {
2161 name: Option<String>,
2162 version: Option<String>,
2163 #[serde(default)]
2164 artifacts: Vec<RawArtifact>,
2165 #[serde(default)]
2166 capabilities: Option<RawCapabilities>,
2167 #[serde(default)]
2168 inference: Option<RawInferenceConfig>,
2169 #[serde(default)]
2170 context: Option<RawContextConfig>,
2171 #[serde(default)]
2172 observability: Option<RawObservabilityConfig>,
2173 #[serde(default)]
2174 trace: Option<RawTraceConfig>,
2175 #[serde(default)]
2176 network: Option<RawNetworkConfig>,
2177 #[serde(default)]
2178 lifecycle: Option<RawLifecycleConfig>,
2179 #[serde(default)]
2180 exports: Option<RawExports>,
2181 #[serde(default)]
2182 control: Option<RawControl>,
2183 #[serde(default)]
2184 deploy: Option<RawDeploy>,
2185 #[serde(default, deserialize_with = "deserialize_present")]
2189 mur_version: Option<serde_norway::Value>,
2190 #[serde(default, deserialize_with = "deserialize_present")]
2194 config: Option<serde_norway::Value>,
2195 #[serde(default)]
2200 #[allow(dead_code)]
2201 runtime: Option<serde_norway::Value>,
2202 #[serde(default)]
2205 #[allow(dead_code)]
2206 implementation: Option<serde_norway::Value>,
2207 #[serde(default)]
2211 #[allow(dead_code)]
2212 execution: Option<serde_norway::Value>,
2213 #[serde(default)]
2216 #[allow(dead_code)]
2217 requires_files: Option<serde_norway::Value>,
2218 #[serde(flatten)]
2219 unknown: UnknownKeys,
2220}
2221
2222#[derive(Debug, Deserialize)]
2223struct RawDeploy {
2224 #[serde(default)]
2225 mur_version: Option<String>,
2226 #[serde(flatten)]
2227 unknown: UnknownKeys,
2228}
2229
2230#[derive(Debug, Deserialize)]
2231struct RawControl {
2232 #[serde(default)]
2236 settings: Option<serde_norway::Value>,
2237 #[serde(default)]
2238 secrets: Option<serde_norway::Value>,
2239 #[serde(default)]
2240 agent_settings: Option<serde_norway::Value>,
2241 #[serde(flatten)]
2242 unknown: UnknownKeys,
2243}
2244
2245#[derive(Debug, Deserialize)]
2246struct RawExports {
2247 #[serde(default)]
2248 files: Option<RawFileExport>,
2249 #[serde(default)]
2250 peer_files: Option<RawPeerFilesExport>,
2251 #[serde(default)]
2252 peer_tasks: Option<RawPeerTasksExport>,
2253 #[serde(flatten)]
2254 unknown: UnknownKeys,
2255}
2256
2257#[derive(Debug, Deserialize)]
2258struct RawPeerTasksExport {
2259 #[serde(default)]
2262 accept: Option<bool>,
2263 #[serde(flatten)]
2264 unknown: UnknownKeys,
2265}
2266
2267#[derive(Debug, Deserialize)]
2268struct RawPeerFilesExport {
2269 #[serde(default)]
2270 root: Option<String>,
2271 #[serde(default)]
2275 max_ttl: Option<serde_norway::Value>,
2276 #[serde(default)]
2277 max_bytes: Option<serde_norway::Value>,
2278 #[serde(flatten)]
2279 unknown: UnknownKeys,
2280}
2281
2282#[derive(Debug, Deserialize)]
2283struct RawFileExport {
2284 #[serde(default)]
2285 root: Option<String>,
2286 #[serde(default)]
2287 mode: Option<String>,
2288 #[serde(default)]
2293 max_bytes: Option<serde_norway::Value>,
2294 #[serde(flatten)]
2295 unknown: UnknownKeys,
2296}
2297
2298#[derive(Debug, Deserialize)]
2299struct RawLifecycleConfig {
2300 #[serde(default)]
2301 task_acceptance: Option<TaskAcceptance>,
2302 #[serde(default)]
2303 after_task: Option<AfterTask>,
2304 #[serde(default)]
2305 queue_depth: Option<usize>,
2306 #[serde(default)]
2307 input_timeout_secs: Option<u64>,
2308 #[serde(default)]
2309 conversation: Option<ConversationMode>,
2310 #[serde(default)]
2311 max_task_reopens: Option<u32>,
2312 #[serde(default)]
2313 shell_grace_secs: Option<u64>,
2314 #[serde(default)]
2315 delegation_deadline_secs: Option<u64>,
2316 #[serde(default)]
2317 deadline_notice_secs: Option<u64>,
2318 #[serde(default)]
2319 keep_working: Option<RawKeepWorking>,
2320 #[serde(default)]
2321 done_check: Option<RawDoneCheck>,
2322 #[serde(flatten)]
2323 unknown: UnknownKeys,
2324}
2325
2326#[derive(Debug, Deserialize)]
2327struct RawDoneCheck {
2328 #[serde(default)]
2329 command: Option<String>,
2330 #[serde(default)]
2331 limit: Option<u32>,
2332 #[serde(default)]
2333 timeout_secs: Option<u64>,
2334 #[serde(flatten)]
2335 unknown: UnknownKeys,
2336}
2337
2338#[derive(Debug, Deserialize)]
2339struct RawKeepWorking {
2340 #[serde(default)]
2341 message: Option<String>,
2342 #[serde(default)]
2343 limit: Option<u32>,
2344 #[serde(flatten)]
2345 unknown: UnknownKeys,
2346}
2347
2348#[derive(Debug, Deserialize)]
2349struct RawNetworkConfig {
2350 internal_port: Option<u16>,
2351 #[serde(default)]
2352 authentication: Option<RawNetworkAuthentication>,
2353 #[serde(flatten)]
2354 unknown: UnknownKeys,
2355}
2356
2357#[derive(Debug, Deserialize)]
2358struct RawNetworkAuthentication {
2359 #[serde(default)]
2360 scheme: Option<String>,
2361 #[serde(default)]
2364 credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
2365 #[serde(flatten)]
2366 unknown: UnknownKeys,
2367}
2368
2369#[derive(Debug, Deserialize)]
2370struct RawDoorCredential {
2371 #[serde(default)]
2375 scopes: Option<serde_norway::Value>,
2376 #[serde(flatten)]
2377 unknown: UnknownKeys,
2378}
2379
2380#[derive(Debug, Deserialize)]
2381struct RawContextConfig {
2382 max_tokens: Option<u32>,
2383 #[serde(default)]
2386 record: Option<String>,
2387 #[serde(default)]
2388 record_store: Option<String>,
2389 #[serde(default)]
2390 seed_budget: Option<f32>,
2391 #[serde(default)]
2392 seed_overflow_margin: Option<f32>,
2393 #[serde(default, deserialize_with = "present_yaml_value")]
2396 retain: Option<serde_norway::Value>,
2397 #[serde(default)]
2398 images: Option<RawContextImages>,
2399 #[serde(flatten)]
2400 unknown: UnknownKeys,
2401}
2402
2403#[derive(Debug, Deserialize)]
2406struct RawContextImages {
2407 #[serde(default)]
2408 keep: Option<serde_norway::Value>,
2409 #[serde(default)]
2410 max_bytes: Option<serde_norway::Value>,
2411 #[serde(default)]
2412 max_per_turn: Option<serde_norway::Value>,
2413 #[serde(flatten)]
2414 unknown: UnknownKeys,
2415}
2416
2417#[derive(Debug, Deserialize)]
2418struct RawObservabilityConfig {
2419 #[serde(default)]
2420 otel_endpoint: Option<String>,
2421 #[serde(default)]
2422 eval: Option<RawEvalConfig>,
2423 #[serde(flatten)]
2424 unknown: UnknownKeys,
2425}
2426
2427#[derive(Debug, Deserialize)]
2428struct RawTraceConfig {
2429 #[serde(default)]
2433 capture: Option<String>,
2434 #[serde(default, deserialize_with = "present_yaml_value")]
2436 retain: Option<serde_norway::Value>,
2437 #[serde(flatten)]
2438 unknown: UnknownKeys,
2439}
2440
2441#[derive(Debug, Deserialize)]
2442struct RawEvalConfig {
2443 #[serde(default)]
2444 dataset_id: Option<String>,
2445 #[serde(default)]
2446 scorers: Vec<RawScorerConfig>,
2447 #[serde(flatten)]
2448 unknown: UnknownKeys,
2449}
2450
2451#[derive(Debug, Deserialize)]
2452struct RawScorerConfig {
2453 #[serde(rename = "type")]
2454 scorer_type: String,
2455 name: Option<String>,
2456 max: Option<u64>,
2457 expected: Option<Vec<String>>,
2458 #[serde(flatten)]
2459 unknown: UnknownKeys,
2460}
2461
2462#[derive(Debug, Deserialize)]
2463struct RawArtifact {
2464 name: Option<String>,
2465 version: Option<String>,
2466 runtime: Option<String>,
2467 #[serde(default)]
2468 source: Option<String>,
2469 #[serde(default)]
2470 local_source: Option<bool>,
2471 #[serde(default)]
2472 prompt_payload: Option<bool>,
2473 #[serde(default)]
2474 capabilities: Option<RawCapabilities>,
2475 #[serde(default)]
2476 on_overflow: Option<String>,
2477 #[serde(default, deserialize_with = "deserialize_present")]
2482 config: Option<serde_norway::Value>,
2483 #[serde(default, deserialize_with = "deserialize_present")]
2486 gateway: Option<Option<RawArtifactGateway>>,
2487 #[serde(flatten)]
2488 unknown: UnknownKeys,
2489}
2490
2491#[derive(Debug, Deserialize)]
2492struct RawArtifactGateway {
2493 #[serde(default)]
2494 endpoint: Option<String>,
2495 #[serde(default, deserialize_with = "deserialize_present")]
2498 api_key: Option<Option<String>>,
2499 #[serde(default)]
2501 keyless: Option<serde_norway::Value>,
2502 #[serde(flatten)]
2503 unknown: UnknownKeys,
2504}
2505
2506fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
2512where
2513 T: Deserialize<'de>,
2514 D: serde::Deserializer<'de>,
2515{
2516 T::deserialize(deserializer).map(Some)
2517}
2518
2519#[derive(Debug, Deserialize)]
2520struct RawCapabilities {
2521 #[serde(default)]
2522 network: Option<RawNetworkCapabilities>,
2523 #[serde(default)]
2524 peer_fetch: Option<RawPeerFetchCapabilities>,
2525 #[serde(default)]
2526 filesystem: Option<RawFilesystemCapabilities>,
2527 #[serde(default)]
2528 shell: Option<RawShellCapabilities>,
2529 #[serde(default)]
2530 spawn: Option<RawSpawnCapabilities>,
2531 #[serde(default)]
2532 env: Option<RawEnvCapabilities>,
2533 #[serde(default)]
2534 limits: Option<RawResourceLimits>,
2535 #[serde(default)]
2536 resources: Option<RawResourceCapabilities>,
2537 #[serde(default)]
2538 state: Option<RawStateCapabilities>,
2539 #[serde(default)]
2540 task_io: Option<RawTaskIoCapabilities>,
2541 #[serde(default)]
2542 conversation: Option<RawConversationCapabilities>,
2543 #[serde(default)]
2544 plan: Option<RawPlanCapabilities>,
2545 #[serde(default)]
2546 install: Option<RawInstallCapabilities>,
2547 #[serde(default)]
2548 harness: Option<RawHarnessCapabilities>,
2549 #[serde(default)]
2553 containment: Option<String>,
2554 #[serde(flatten)]
2555 unknown: UnknownKeys,
2556}
2557
2558#[derive(Debug, Deserialize)]
2559struct RawTaskIoCapabilities {
2560 #[serde(default)]
2563 read: Option<bool>,
2564 #[serde(flatten)]
2565 unknown: UnknownKeys,
2566}
2567
2568#[derive(Debug, Deserialize)]
2569struct RawConversationCapabilities {
2570 #[serde(default)]
2573 read: Option<bool>,
2574 #[serde(flatten)]
2575 unknown: UnknownKeys,
2576}
2577
2578#[derive(Debug, Deserialize)]
2579struct RawPlanCapabilities {
2580 #[serde(default)]
2583 submit: Option<bool>,
2584 #[serde(flatten)]
2585 unknown: UnknownKeys,
2586}
2587
2588#[derive(Debug, Deserialize)]
2589struct RawInstallCapabilities {
2590 #[serde(default)]
2591 skill: Vec<String>,
2592 #[serde(default)]
2593 tool: Vec<String>,
2594 #[serde(default)]
2595 from: Option<Vec<String>>,
2596 #[serde(flatten)]
2597 unknown: UnknownKeys,
2598}
2599
2600#[derive(Debug, Deserialize)]
2601struct RawHarnessCapabilities {
2602 #[serde(default)]
2603 paths: Vec<RawHarnessPathEntry>,
2604 #[serde(flatten)]
2605 unknown: UnknownKeys,
2606}
2607
2608#[derive(Debug, Deserialize)]
2609struct RawHarnessPathEntry {
2610 #[serde(default)]
2611 path: Option<String>,
2612 #[serde(default)]
2613 access: Option<String>,
2614 #[serde(flatten)]
2615 unknown: UnknownKeys,
2616}
2617
2618#[derive(Debug, Deserialize)]
2619struct RawSpawnCapabilities {
2620 #[serde(default)]
2621 allow: Vec<String>,
2622 #[serde(default)]
2623 workdir_snapshot: Vec<String>,
2624 #[serde(flatten)]
2625 unknown: UnknownKeys,
2626}
2627
2628#[derive(Debug, Deserialize)]
2629struct RawEnvCapabilities {
2630 #[serde(default)]
2631 allow: Vec<String>,
2632 #[serde(flatten)]
2633 unknown: UnknownKeys,
2634}
2635
2636#[derive(Debug, Deserialize)]
2637struct RawResourceLimits {
2638 #[serde(default)]
2639 memory_bytes: Option<usize>,
2640 #[serde(default)]
2641 table_elements: Option<usize>,
2642 #[serde(default)]
2643 instances: Option<usize>,
2644 #[serde(default)]
2645 deadline_seconds: Option<u64>,
2646 #[serde(flatten)]
2647 unknown: UnknownKeys,
2648}
2649
2650#[derive(Debug, Deserialize)]
2651struct RawStateCapabilities {
2652 #[serde(default)]
2653 store: Option<String>,
2654 #[serde(flatten)]
2655 unknown: UnknownKeys,
2656}
2657
2658#[derive(Debug, Deserialize)]
2659struct RawResourceCapabilities {
2660 #[serde(default)]
2661 max_processes: Option<u64>,
2662 #[serde(default)]
2663 max_open_files: Option<u64>,
2664 #[serde(default)]
2665 max_file_size_bytes: Option<u64>,
2666 #[serde(default)]
2667 cpu_seconds: Option<u64>,
2668 #[serde(default)]
2669 memory_bytes: Option<u64>,
2670 #[serde(default)]
2671 cgroup_memory_bytes: Option<u64>,
2672 #[serde(default)]
2673 cgroup_pids_max: Option<u64>,
2674 #[serde(default)]
2675 cgroup_cpu_percent: Option<u32>,
2676 #[serde(default)]
2677 cgroup_io_bytes_per_sec: Option<u64>,
2678 #[serde(default)]
2679 workdir_max_bytes: Option<u64>,
2680 #[serde(flatten)]
2681 unknown: UnknownKeys,
2682}
2683
2684#[derive(Debug, Deserialize)]
2685struct RawNetworkCapabilities {
2686 #[serde(default)]
2687 allow: Vec<String>,
2688 #[serde(default)]
2689 unix_sockets: bool,
2690 #[serde(flatten)]
2691 unknown: UnknownKeys,
2692}
2693
2694#[derive(Debug, Deserialize)]
2695struct RawPeerFetchCapabilities {
2696 #[serde(default)]
2697 allow: Vec<String>,
2698 #[serde(flatten)]
2699 unknown: UnknownKeys,
2700}
2701
2702#[derive(Debug, Deserialize)]
2703struct RawFilesystemCapabilities {
2704 #[serde(default)]
2705 scope: Option<String>,
2706 #[serde(default)]
2710 workdir_exec: bool,
2711 #[serde(default)]
2714 read_only: Vec<String>,
2715 #[serde(default)]
2717 records: bool,
2718 #[serde(flatten)]
2719 unknown: UnknownKeys,
2720}
2721
2722#[derive(Debug, Deserialize)]
2723struct RawShellCapabilities {
2724 #[serde(default)]
2725 allow: Vec<String>,
2726 #[serde(default)]
2727 strip_env: Option<Vec<String>>,
2728 #[serde(default)]
2729 baseline_env: Option<Vec<String>>,
2730 #[serde(default)]
2731 interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
2732 #[serde(default)]
2733 staged_runtime: Vec<RawStagedRuntimeGrant>,
2734 #[serde(flatten)]
2735 unknown: UnknownKeys,
2736}
2737
2738#[derive(Debug, Deserialize)]
2739struct RawInterpreterRuntimeGrant {
2740 #[serde(default)]
2741 binary: Option<String>,
2742 #[serde(default)]
2743 dirs: Vec<RawInterpreterRuntimeDir>,
2744 #[serde(flatten)]
2745 unknown: UnknownKeys,
2746}
2747
2748#[derive(Debug, Deserialize)]
2749struct RawInterpreterRuntimeDir {
2750 #[serde(default)]
2751 path: Option<String>,
2752 #[serde(default)]
2756 list_dir: Option<bool>,
2757 #[serde(flatten)]
2758 unknown: UnknownKeys,
2759}
2760
2761#[derive(Debug, Deserialize)]
2762struct RawStagedRuntimeGrant {
2763 #[serde(default)]
2764 binary: Option<String>,
2765 #[serde(default)]
2766 source_path: Option<String>,
2767 #[serde(default)]
2771 pin: Option<String>,
2772 #[serde(flatten)]
2773 unknown: UnknownKeys,
2774}
2775
2776#[derive(Debug, Deserialize)]
2777struct RawInferenceConfig {
2778 transport: Option<String>,
2779 #[serde(default, deserialize_with = "deserialize_present")]
2782 endpoint: Option<serde_norway::Value>,
2783 model: Option<String>,
2784 #[serde(default, deserialize_with = "deserialize_present")]
2787 api_key: Option<serde_norway::Value>,
2788 #[serde(default)]
2789 command: Option<String>,
2790 #[serde(default)]
2791 system_prompt: Option<String>,
2792 #[serde(default)]
2793 system_prompt_file: Option<String>,
2794 #[serde(default)]
2795 system_prompt_artifact: Option<String>,
2796 #[serde(default)]
2797 instruction_files: Option<Vec<String>>,
2798 #[serde(default)]
2799 driver: Option<RawInferenceDriver>,
2800 #[serde(default)]
2801 compaction: Option<RawCompactionConfig>,
2802 #[serde(default)]
2803 max_turns: Option<u32>,
2804 #[serde(default)]
2807 max_task_reopens: Option<u32>,
2808 #[serde(default)]
2809 max_tokens: Option<u32>,
2810 #[serde(default)]
2811 max_session_tokens: Option<u64>,
2812 #[serde(default)]
2813 tool_refresh: Option<String>,
2814 #[serde(default)]
2815 alternates: Option<Vec<RawInferenceAlternate>>,
2816 #[serde(default)]
2817 retry: Option<RawInferenceRetry>,
2818 #[serde(flatten)]
2819 unknown: UnknownKeys,
2820}
2821
2822#[derive(Debug, Deserialize)]
2823struct RawInferenceRetry {
2824 #[serde(default)]
2825 max_retries: Option<u32>,
2826 #[serde(default)]
2827 initial_delay_ms: Option<u32>,
2828 #[serde(default)]
2829 max_delay_ms: Option<u32>,
2830 #[serde(flatten)]
2831 unknown: UnknownKeys,
2832}
2833
2834#[derive(Debug, Deserialize)]
2835struct RawInferenceAlternate {
2836 #[serde(default)]
2837 name: Option<String>,
2838 #[serde(default)]
2839 model: Option<String>,
2840 #[serde(default)]
2841 driver: Option<RawInferenceDriver>,
2842 #[serde(flatten)]
2843 unknown: UnknownKeys,
2844}
2845
2846#[derive(Debug, Deserialize)]
2847struct RawCompactionConfig {
2848 threshold: Option<f32>,
2849 model: Option<String>,
2850 system_prompt: Option<String>,
2851 system_prompt_file: Option<String>,
2852 dump_summaries: Option<bool>,
2853 max_tokens: Option<u32>,
2854 #[serde(default)]
2855 driver_config: Option<serde_norway::Value>,
2856 #[serde(flatten)]
2857 unknown: UnknownKeys,
2858}
2859
2860#[derive(Debug, Deserialize)]
2861struct RawInferenceDriver {
2862 artifact: Option<String>,
2863 #[serde(default)]
2864 config: Option<serde_norway::Value>,
2865 #[serde(flatten)]
2866 unknown: UnknownKeys,
2867}
2868
2869type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2876
2877trait RawBlock {
2884 const KNOWN_KEYS: &'static [&'static str];
2888
2889 fn unknown_keys(&self) -> &UnknownKeys;
2890
2891 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2899 let _ = (path, out);
2900 }
2901}
2902
2903impl RawBlock for RawRuntimeManifest {
2904 const KNOWN_KEYS: &'static [&'static str] = &[
2905 "name",
2906 "version",
2907 "artifacts",
2908 "capabilities",
2909 "inference",
2910 "context",
2911 "observability",
2912 "trace",
2913 "network",
2914 "lifecycle",
2915 "exports",
2916 "control",
2917 "deploy",
2918 "mur_version",
2919 "config",
2920 "runtime",
2921 "implementation",
2922 "execution",
2923 "requires_files",
2924 ];
2925 fn unknown_keys(&self) -> &UnknownKeys {
2926 &self.unknown
2927 }
2928
2929 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2930 let artifacts = child_path(path, "artifacts");
2931 for (index, artifact) in self.artifacts.iter().enumerate() {
2932 collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2933 }
2934 if let Some(capabilities) = &self.capabilities {
2935 collect_block(capabilities, &child_path(path, "capabilities"), out);
2936 }
2937 if let Some(inference) = &self.inference {
2938 collect_block(inference, &child_path(path, "inference"), out);
2939 }
2940 if let Some(context) = &self.context {
2941 collect_block(context, &child_path(path, "context"), out);
2942 }
2943 if let Some(observability) = &self.observability {
2944 collect_block(observability, &child_path(path, "observability"), out);
2945 }
2946 if let Some(trace) = &self.trace {
2947 collect_block(trace, &child_path(path, "trace"), out);
2948 }
2949 if let Some(network) = &self.network {
2950 collect_block(network, &child_path(path, "network"), out);
2951 }
2952 if let Some(lifecycle) = &self.lifecycle {
2953 collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2954 }
2955 if let Some(exports) = &self.exports {
2956 collect_block(exports, &child_path(path, "exports"), out);
2957 }
2958 if let Some(control) = &self.control {
2959 collect_block(control, &child_path(path, "control"), out);
2960 }
2961 if let Some(deploy) = &self.deploy {
2962 collect_block(deploy, &child_path(path, "deploy"), out);
2963 }
2964 }
2965}
2966
2967impl RawBlock for RawDeploy {
2968 const KNOWN_KEYS: &'static [&'static str] = &["mur_version"];
2969 fn unknown_keys(&self) -> &UnknownKeys {
2970 &self.unknown
2971 }
2972}
2973
2974impl RawBlock for RawControl {
2975 const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
2976 fn unknown_keys(&self) -> &UnknownKeys {
2977 &self.unknown
2978 }
2979}
2980
2981impl RawBlock for RawExports {
2982 const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files", "peer_tasks"];
2983 fn unknown_keys(&self) -> &UnknownKeys {
2984 &self.unknown
2985 }
2986
2987 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2988 if let Some(files) = &self.files {
2989 collect_block(files, &child_path(path, "files"), out);
2990 }
2991 if let Some(peer_files) = &self.peer_files {
2992 collect_block(peer_files, &child_path(path, "peer_files"), out);
2993 }
2994 if let Some(peer_tasks) = &self.peer_tasks {
2995 collect_block(peer_tasks, &child_path(path, "peer_tasks"), out);
2996 }
2997 }
2998}
2999
3000impl RawBlock for RawPeerTasksExport {
3001 const KNOWN_KEYS: &'static [&'static str] = &["accept"];
3002 fn unknown_keys(&self) -> &UnknownKeys {
3003 &self.unknown
3004 }
3005}
3006
3007impl RawBlock for RawPeerFilesExport {
3008 const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
3009 fn unknown_keys(&self) -> &UnknownKeys {
3010 &self.unknown
3011 }
3012}
3013
3014impl RawBlock for RawFileExport {
3015 const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
3016 fn unknown_keys(&self) -> &UnknownKeys {
3017 &self.unknown
3018 }
3019}
3020
3021fn parse_keep_working(raw: RawKeepWorking) -> Result<KeepWorking, RuntimeManifestError> {
3024 let invalid = |field: &str, message: &str| RuntimeManifestError::InvalidLifecycleConfig {
3025 field: format!("lifecycle.keep_working.{field}"),
3026 message: message.to_string(),
3027 };
3028 let message = match raw.message {
3029 None => return Err(invalid("message", "is required")),
3030 Some(message) if message.trim().is_empty() => {
3031 return Err(invalid("message", "must not be blank"))
3032 }
3033 Some(message) => message,
3034 };
3035 let limit = match raw.limit {
3036 None => return Err(invalid("limit", "is required")),
3037 Some(0) => {
3038 return Err(invalid(
3039 "limit",
3040 "must be at least 1; omit lifecycle.keep_working to turn the option off",
3041 ))
3042 }
3043 Some(limit) => limit,
3044 };
3045 Ok(KeepWorking { message, limit })
3046}
3047
3048fn parse_done_check(
3052 raw: RawDoneCheck,
3053 shell_allow: &[String],
3054) -> Result<DoneCheck, RuntimeManifestError> {
3055 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidLifecycleConfig {
3056 field: format!("lifecycle.done_check.{field}"),
3057 message,
3058 };
3059 let command = match raw.command {
3060 None => return Err(invalid("command", "is required".into())),
3061 Some(command) if command.trim().is_empty() => {
3062 return Err(invalid("command", "must not be blank".into()))
3063 }
3064 Some(command) => command,
3065 };
3066 let Some(program) = split_shell_words(&command).into_iter().next() else {
3067 return Err(invalid("command", "must name a program".into()));
3068 };
3069 if !shell_allow.contains(&program) {
3070 return Err(invalid(
3071 "command",
3072 format!("runs '{program}', which is not in capabilities.shell.allow"),
3073 ));
3074 }
3075 let Some(limit) = raw.limit else {
3076 return Err(invalid("limit", "is required".into()));
3077 };
3078 let timeout_secs = match raw.timeout_secs {
3079 None => DoneCheck::DEFAULT_TIMEOUT_SECS,
3080 Some(0) => return Err(invalid("timeout_secs", "must be at least 1".into())),
3081 Some(secs) => secs,
3082 };
3083 Ok(DoneCheck {
3084 command,
3085 limit,
3086 timeout_secs,
3087 })
3088}
3089
3090impl RawBlock for RawLifecycleConfig {
3091 const KNOWN_KEYS: &'static [&'static str] = &[
3092 "task_acceptance",
3093 "after_task",
3094 "queue_depth",
3095 "input_timeout_secs",
3096 "conversation",
3097 "max_task_reopens",
3098 "shell_grace_secs",
3099 "delegation_deadline_secs",
3100 "deadline_notice_secs",
3101 "keep_working",
3102 "done_check",
3103 ];
3104 fn unknown_keys(&self) -> &UnknownKeys {
3105 &self.unknown
3106 }
3107
3108 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3109 if let Some(keep_working) = &self.keep_working {
3110 collect_block(keep_working, &child_path(path, "keep_working"), out);
3111 }
3112 if let Some(done_check) = &self.done_check {
3113 collect_block(done_check, &child_path(path, "done_check"), out);
3114 }
3115 }
3116}
3117
3118impl RawBlock for RawDoneCheck {
3119 const KNOWN_KEYS: &'static [&'static str] = &["command", "limit", "timeout_secs"];
3120 fn unknown_keys(&self) -> &UnknownKeys {
3121 &self.unknown
3122 }
3123}
3124
3125impl RawBlock for RawKeepWorking {
3126 const KNOWN_KEYS: &'static [&'static str] = &["message", "limit"];
3127 fn unknown_keys(&self) -> &UnknownKeys {
3128 &self.unknown
3129 }
3130}
3131
3132impl RawBlock for RawNetworkConfig {
3133 const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
3134 fn unknown_keys(&self) -> &UnknownKeys {
3135 &self.unknown
3136 }
3137
3138 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3139 if let Some(authentication) = &self.authentication {
3140 collect_block(authentication, &child_path(path, "authentication"), out);
3141 }
3142 }
3143}
3144
3145impl RawBlock for RawNetworkAuthentication {
3146 const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
3147 fn unknown_keys(&self) -> &UnknownKeys {
3148 &self.unknown
3149 }
3150
3151 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3152 let credentials_path = child_path(path, "credentials");
3153 for (name, credential) in self.credentials.iter().flatten() {
3154 collect_block(credential, &child_path(&credentials_path, name), out);
3155 }
3156 }
3157}
3158
3159impl RawBlock for RawDoorCredential {
3160 const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
3161 fn unknown_keys(&self) -> &UnknownKeys {
3162 &self.unknown
3163 }
3164}
3165
3166impl RawBlock for RawContextConfig {
3167 const KNOWN_KEYS: &'static [&'static str] = &[
3168 "max_tokens",
3169 "record",
3170 "record_store",
3171 "seed_budget",
3172 "seed_overflow_margin",
3173 "retain",
3174 "images",
3175 ];
3176 fn unknown_keys(&self) -> &UnknownKeys {
3177 &self.unknown
3178 }
3179
3180 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3181 if let Some(images) = &self.images {
3182 collect_block(images, &child_path(path, "images"), out);
3183 }
3184 }
3185}
3186
3187impl RawBlock for RawContextImages {
3188 const KNOWN_KEYS: &'static [&'static str] = &["keep", "max_bytes", "max_per_turn"];
3189 fn unknown_keys(&self) -> &UnknownKeys {
3190 &self.unknown
3191 }
3192}
3193
3194impl RawBlock for RawObservabilityConfig {
3195 const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
3196 fn unknown_keys(&self) -> &UnknownKeys {
3197 &self.unknown
3198 }
3199
3200 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3201 if let Some(eval) = &self.eval {
3202 collect_block(eval, &child_path(path, "eval"), out);
3203 }
3204 }
3205}
3206
3207impl RawBlock for RawTraceConfig {
3208 const KNOWN_KEYS: &'static [&'static str] = &["capture", "retain"];
3209 fn unknown_keys(&self) -> &UnknownKeys {
3210 &self.unknown
3211 }
3212}
3213
3214impl RawBlock for RawEvalConfig {
3215 const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
3216 fn unknown_keys(&self) -> &UnknownKeys {
3217 &self.unknown
3218 }
3219
3220 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3221 let scorers = child_path(path, "scorers");
3222 for (index, scorer) in self.scorers.iter().enumerate() {
3223 collect_block(scorer, &format!("{scorers}[{index}]"), out);
3224 }
3225 }
3226}
3227
3228impl RawBlock for RawScorerConfig {
3229 const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
3230 fn unknown_keys(&self) -> &UnknownKeys {
3231 &self.unknown
3232 }
3233}
3234
3235impl RawBlock for RawArtifact {
3236 const KNOWN_KEYS: &'static [&'static str] = &[
3237 "name",
3238 "version",
3239 "runtime",
3240 "source",
3241 "local_source",
3242 "prompt_payload",
3243 "capabilities",
3244 "on_overflow",
3245 "config",
3246 "gateway",
3247 ];
3248 fn unknown_keys(&self) -> &UnknownKeys {
3249 &self.unknown
3250 }
3251
3252 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3253 if let Some(capabilities) = &self.capabilities {
3254 collect_block(capabilities, &child_path(path, "capabilities"), out);
3255 }
3256 if let Some(Some(gateway)) = &self.gateway {
3257 collect_block(gateway, &child_path(path, "gateway"), out);
3258 }
3259 }
3260}
3261
3262impl RawBlock for RawArtifactGateway {
3263 const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
3264 fn unknown_keys(&self) -> &UnknownKeys {
3265 &self.unknown
3266 }
3267}
3268
3269impl RawBlock for RawCapabilities {
3270 const KNOWN_KEYS: &'static [&'static str] = &[
3271 "network",
3272 "peer_fetch",
3273 "filesystem",
3274 "shell",
3275 "spawn",
3276 "env",
3277 "limits",
3278 "resources",
3279 "state",
3280 "task_io",
3281 "conversation",
3282 "plan",
3283 "install",
3284 "harness",
3285 "containment",
3286 ];
3287 fn unknown_keys(&self) -> &UnknownKeys {
3288 &self.unknown
3289 }
3290
3291 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3295 if let Some(network) = &self.network {
3296 collect_block(network, &child_path(path, "network"), out);
3297 }
3298 if let Some(peer_fetch) = &self.peer_fetch {
3299 collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
3300 }
3301 if let Some(filesystem) = &self.filesystem {
3302 collect_block(filesystem, &child_path(path, "filesystem"), out);
3303 }
3304 if let Some(shell) = &self.shell {
3305 collect_block(shell, &child_path(path, "shell"), out);
3306 }
3307 if let Some(spawn) = &self.spawn {
3308 collect_block(spawn, &child_path(path, "spawn"), out);
3309 }
3310 if let Some(env) = &self.env {
3311 collect_block(env, &child_path(path, "env"), out);
3312 }
3313 if let Some(limits) = &self.limits {
3314 collect_block(limits, &child_path(path, "limits"), out);
3315 }
3316 if let Some(resources) = &self.resources {
3317 collect_block(resources, &child_path(path, "resources"), out);
3318 }
3319 if let Some(state) = &self.state {
3320 collect_block(state, &child_path(path, "state"), out);
3321 }
3322 if let Some(task_io) = &self.task_io {
3323 collect_block(task_io, &child_path(path, "task_io"), out);
3324 }
3325 if let Some(conversation) = &self.conversation {
3326 collect_block(conversation, &child_path(path, "conversation"), out);
3327 }
3328 if let Some(plan) = &self.plan {
3329 collect_block(plan, &child_path(path, "plan"), out);
3330 }
3331 if let Some(install) = &self.install {
3332 collect_block(install, &child_path(path, "install"), out);
3333 }
3334 if let Some(harness) = &self.harness {
3335 collect_block(harness, &child_path(path, "harness"), out);
3336 }
3337 }
3338}
3339
3340impl RawBlock for RawTaskIoCapabilities {
3341 const KNOWN_KEYS: &'static [&'static str] = &["read"];
3342 fn unknown_keys(&self) -> &UnknownKeys {
3343 &self.unknown
3344 }
3345}
3346
3347impl RawBlock for RawConversationCapabilities {
3348 const KNOWN_KEYS: &'static [&'static str] = &["read"];
3349 fn unknown_keys(&self) -> &UnknownKeys {
3350 &self.unknown
3351 }
3352}
3353
3354impl RawBlock for RawPlanCapabilities {
3355 const KNOWN_KEYS: &'static [&'static str] = &["submit"];
3356 fn unknown_keys(&self) -> &UnknownKeys {
3357 &self.unknown
3358 }
3359}
3360
3361impl RawBlock for RawInstallCapabilities {
3362 const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool", "from"];
3363 fn unknown_keys(&self) -> &UnknownKeys {
3364 &self.unknown
3365 }
3366}
3367
3368impl RawBlock for RawHarnessCapabilities {
3369 const KNOWN_KEYS: &'static [&'static str] = &["paths"];
3370 fn unknown_keys(&self) -> &UnknownKeys {
3371 &self.unknown
3372 }
3373 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3374 let paths = child_path(path, "paths");
3375 for (index, entry) in self.paths.iter().enumerate() {
3376 collect_block(entry, &format!("{paths}[{index}]"), out);
3377 }
3378 }
3379}
3380
3381impl RawBlock for RawHarnessPathEntry {
3382 const KNOWN_KEYS: &'static [&'static str] = &["path", "access"];
3383 fn unknown_keys(&self) -> &UnknownKeys {
3384 &self.unknown
3385 }
3386}
3387
3388impl RawBlock for RawSpawnCapabilities {
3389 const KNOWN_KEYS: &'static [&'static str] = &["allow", "workdir_snapshot"];
3390 fn unknown_keys(&self) -> &UnknownKeys {
3391 &self.unknown
3392 }
3393}
3394
3395impl RawBlock for RawEnvCapabilities {
3396 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3397 fn unknown_keys(&self) -> &UnknownKeys {
3398 &self.unknown
3399 }
3400}
3401
3402impl RawBlock for RawResourceLimits {
3403 const KNOWN_KEYS: &'static [&'static str] = &[
3404 "memory_bytes",
3405 "table_elements",
3406 "instances",
3407 "deadline_seconds",
3408 ];
3409 fn unknown_keys(&self) -> &UnknownKeys {
3410 &self.unknown
3411 }
3412}
3413
3414impl RawBlock for RawStateCapabilities {
3415 const KNOWN_KEYS: &'static [&'static str] = &["store"];
3416 fn unknown_keys(&self) -> &UnknownKeys {
3417 &self.unknown
3418 }
3419}
3420
3421impl RawBlock for RawResourceCapabilities {
3422 const KNOWN_KEYS: &'static [&'static str] = &[
3423 "max_processes",
3424 "max_open_files",
3425 "max_file_size_bytes",
3426 "cpu_seconds",
3427 "memory_bytes",
3428 "cgroup_memory_bytes",
3429 "cgroup_pids_max",
3430 "cgroup_cpu_percent",
3431 "cgroup_io_bytes_per_sec",
3432 "workdir_max_bytes",
3433 ];
3434 fn unknown_keys(&self) -> &UnknownKeys {
3435 &self.unknown
3436 }
3437}
3438
3439impl RawBlock for RawNetworkCapabilities {
3440 const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
3441 fn unknown_keys(&self) -> &UnknownKeys {
3442 &self.unknown
3443 }
3444}
3445
3446impl RawBlock for RawPeerFetchCapabilities {
3447 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3448 fn unknown_keys(&self) -> &UnknownKeys {
3449 &self.unknown
3450 }
3451}
3452
3453impl RawBlock for RawFilesystemCapabilities {
3454 const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only", "records"];
3455 fn unknown_keys(&self) -> &UnknownKeys {
3456 &self.unknown
3457 }
3458}
3459
3460impl RawBlock for RawShellCapabilities {
3461 const KNOWN_KEYS: &'static [&'static str] = &[
3462 "allow",
3463 "strip_env",
3464 "baseline_env",
3465 "interpreter_runtime",
3466 "staged_runtime",
3467 ];
3468 fn unknown_keys(&self) -> &UnknownKeys {
3469 &self.unknown
3470 }
3471
3472 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3473 let interpreter_runtime = child_path(path, "interpreter_runtime");
3474 for (index, grant) in self.interpreter_runtime.iter().enumerate() {
3475 collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
3476 }
3477 let staged_runtime = child_path(path, "staged_runtime");
3478 for (index, grant) in self.staged_runtime.iter().enumerate() {
3479 collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
3480 }
3481 }
3482}
3483
3484impl RawBlock for RawInterpreterRuntimeGrant {
3485 const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
3486 fn unknown_keys(&self) -> &UnknownKeys {
3487 &self.unknown
3488 }
3489
3490 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3491 let dirs = child_path(path, "dirs");
3492 for (index, dir) in self.dirs.iter().enumerate() {
3493 collect_block(dir, &format!("{dirs}[{index}]"), out);
3494 }
3495 }
3496}
3497
3498impl RawBlock for RawInterpreterRuntimeDir {
3499 const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
3500 fn unknown_keys(&self) -> &UnknownKeys {
3501 &self.unknown
3502 }
3503}
3504
3505impl RawBlock for RawStagedRuntimeGrant {
3506 const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
3507 fn unknown_keys(&self) -> &UnknownKeys {
3508 &self.unknown
3509 }
3510}
3511
3512impl RawBlock for RawInferenceConfig {
3513 const KNOWN_KEYS: &'static [&'static str] = &[
3514 "transport",
3515 "endpoint",
3516 "model",
3517 "api_key",
3518 "command",
3519 "system_prompt",
3520 "system_prompt_file",
3521 "system_prompt_artifact",
3522 "instruction_files",
3523 "driver",
3524 "compaction",
3525 "max_turns",
3526 "max_task_reopens",
3527 "max_tokens",
3528 "max_session_tokens",
3529 "tool_refresh",
3530 "alternates",
3531 "retry",
3532 ];
3533 fn unknown_keys(&self) -> &UnknownKeys {
3534 &self.unknown
3535 }
3536
3537 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3538 if let Some(driver) = &self.driver {
3539 collect_block(driver, &child_path(path, "driver"), out);
3540 }
3541 if let Some(compaction) = &self.compaction {
3542 collect_block(compaction, &child_path(path, "compaction"), out);
3543 }
3544 if let Some(retry) = &self.retry {
3545 collect_block(retry, &child_path(path, "retry"), out);
3546 }
3547 if let Some(alternates) = &self.alternates {
3548 let alternates_path = child_path(path, "alternates");
3549 for (index, alternate) in alternates.iter().enumerate() {
3550 collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
3551 }
3552 }
3553 }
3554}
3555
3556impl RawBlock for RawInferenceRetry {
3557 const KNOWN_KEYS: &'static [&'static str] =
3558 &["max_retries", "initial_delay_ms", "max_delay_ms"];
3559 fn unknown_keys(&self) -> &UnknownKeys {
3560 &self.unknown
3561 }
3562}
3563
3564impl RawBlock for RawInferenceAlternate {
3565 const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
3566 fn unknown_keys(&self) -> &UnknownKeys {
3567 &self.unknown
3568 }
3569
3570 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3571 if let Some(driver) = &self.driver {
3572 collect_block(driver, &child_path(path, "driver"), out);
3573 }
3574 }
3575}
3576
3577impl RawBlock for RawCompactionConfig {
3578 const KNOWN_KEYS: &'static [&'static str] = &[
3579 "threshold",
3580 "model",
3581 "system_prompt",
3582 "system_prompt_file",
3583 "dump_summaries",
3584 "max_tokens",
3585 "driver_config",
3586 ];
3587 fn unknown_keys(&self) -> &UnknownKeys {
3588 &self.unknown
3589 }
3590}
3591
3592impl RawBlock for RawInferenceDriver {
3593 const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
3594 fn unknown_keys(&self) -> &UnknownKeys {
3595 &self.unknown
3596 }
3597}
3598
3599fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
3606 let mut out = Vec::new();
3607 collect_block(raw, "", &mut out);
3608 out
3609}
3610
3611fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
3617 for key in block.unknown_keys().keys() {
3618 out.push(UnknownManifestKey {
3619 key: key.clone(),
3620 block_path: path.to_string(),
3621 nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
3622 });
3623 }
3624 block.walk_children(path, out);
3625}
3626
3627fn child_path(parent: &str, field: &str) -> String {
3631 if parent.is_empty() {
3632 return field.to_string();
3633 }
3634 format!("{parent}.{field}")
3635}
3636
3637pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
3644 fs::read_to_string(path).map_err(|source| {
3645 if source.kind() == std::io::ErrorKind::NotFound {
3646 return RuntimeManifestError::NotFound(path.display().to_string());
3647 }
3648
3649 RuntimeManifestError::Io {
3650 path: path.display().to_string(),
3651 source,
3652 }
3653 })
3654}
3655
3656#[must_use = "validated runtime manifest is required before run"]
3657pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
3658 let content = read_runtime_manifest_text(path)?;
3659
3660 RuntimeManifest::from_yaml_str(&content)
3661}
3662
3663#[derive(Debug, Clone, PartialEq, Eq)]
3665pub struct ReferencedEnvVariable {
3666 pub field: String,
3668 pub variable: String,
3670}
3671
3672impl ReferencedEnvVariable {
3673 pub fn is_gateway_credential(&self) -> bool {
3676 self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
3677 }
3678}
3679
3680#[derive(Deserialize)]
3688struct ReferencedManifestFields {
3689 #[serde(default)]
3690 artifacts: Vec<ReferencedArtifactFields>,
3691}
3692
3693#[derive(Deserialize)]
3694struct ReferencedArtifactFields {
3695 #[serde(default)]
3696 name: Option<String>,
3697 #[serde(default)]
3698 gateway: Option<ReferencedGatewayFields>,
3699}
3700
3701#[derive(Deserialize)]
3702struct ReferencedGatewayFields {
3703 #[serde(default)]
3704 api_key: Option<String>,
3705}
3706
3707pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
3715 let Ok(raw) = serde_norway::from_str::<ReferencedManifestFields>(manifest_yaml) else {
3716 return Vec::new();
3717 };
3718
3719 let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
3720 for artifact in raw.artifacts {
3721 let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
3722 continue;
3723 };
3724 if let Some(variable) = parse_env_reference(api_key.trim()) {
3725 let name = artifact.name.unwrap_or_default();
3726 referenced.push(ReferencedEnvVariable {
3727 field: format!("artifacts.{}.gateway.api_key", name.trim()),
3728 variable: variable.to_string(),
3729 });
3730 }
3731 }
3732
3733 referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
3734 referenced.dedup();
3735 referenced
3736}
3737
3738#[derive(Clone, Copy, PartialEq, Eq)]
3744enum SecretResolution {
3745 Resolve,
3747 Skip,
3750}
3751
3752impl RuntimeManifest {
3753 pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
3754 Self::parse(input, SecretResolution::Resolve)
3755 }
3756
3757 pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
3767 Self::parse(input, SecretResolution::Skip)
3768 }
3769
3770 fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
3771 let raw: RawRuntimeManifest = serde_norway::from_str(input).map_err(|err| {
3772 if let Some(location) = err.location() {
3773 RuntimeManifestError::YamlSyntax(format!(
3774 "{}: YAML syntax error at line {}, column {}: {}",
3775 MANIFEST_FILENAME,
3776 location.line(),
3777 location.column(),
3778 err
3779 ))
3780 } else {
3781 RuntimeManifestError::YamlSyntax(format!(
3782 "{MANIFEST_FILENAME}: YAML syntax error: {err}"
3783 ))
3784 }
3785 })?;
3786
3787 if raw.unknown.contains_key(REGISTRY_BLOCK_KEY) {
3789 return Err(RuntimeManifestError::RegistryBlock);
3790 }
3791 check_no_host_paths(&raw)?;
3793
3794 let unknown_keys = collect_unknown_keys(&raw);
3797
3798 if raw.mur_version.is_some() {
3799 return Err(RuntimeManifestError::TopLevelMurVersion);
3800 }
3801
3802 let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
3803 RuntimeManifestError::MissingField {
3804 field: "name".to_string(),
3805 }
3806 })?;
3807
3808 let version = raw
3809 .version
3810 .filter(|s| !s.trim().is_empty())
3811 .ok_or_else(|| RuntimeManifestError::MissingField {
3812 field: "version".to_string(),
3813 })?;
3814
3815 let gateway_references = raw_gateway_references(&raw.artifacts);
3818
3819 let artifacts = raw
3820 .artifacts
3821 .into_iter()
3822 .enumerate()
3823 .map(|(index, artifact)| {
3824 let name = artifact
3825 .name
3826 .filter(|s| !s.trim().is_empty())
3827 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3828 index,
3829 message: "missing required field 'name'".to_string(),
3830 })?;
3831
3832 let source = artifact.source.filter(|s| !s.trim().is_empty());
3833 check_entry_name(index, &name, source.is_some())?;
3834
3835 let runtime = match artifact.runtime.as_deref() {
3836 None | Some("tool") => ArtifactRuntime::Tool,
3837 Some("wasm") | Some("native") => {
3838 return Err(RuntimeManifestError::InvalidArtifact {
3839 index,
3840 message: format!(
3841 "use 'runtime: tool' for a tool (got '{}')",
3842 artifact.runtime.as_deref().unwrap_or("")
3843 ),
3844 });
3845 }
3846 Some("driver") => ArtifactRuntime::Driver,
3847 Some("hook") => ArtifactRuntime::Hook,
3848 Some("skill") => ArtifactRuntime::Skill,
3849 Some(other) => {
3850 return Err(RuntimeManifestError::InvalidArtifact {
3851 index,
3852 message: format!(
3853 "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
3854 ),
3855 });
3856 }
3857 };
3858
3859 let local_source = artifact
3863 .local_source
3864 .unwrap_or(runtime == ArtifactRuntime::Skill);
3865 let prompt_payload = artifact
3866 .prompt_payload
3867 .unwrap_or(runtime == ArtifactRuntime::Skill);
3868
3869 if source.is_some() && !local_source {
3871 return Err(RuntimeManifestError::InvalidArtifact {
3872 index,
3873 message: format!(
3874 "artifact '{name}' declares 'source:' but does not declare \
3875 'local_source: true' (runtime: {})",
3876 runtime.as_str()
3877 ),
3878 });
3879 }
3880
3881 let version = if source.is_some() {
3884 if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
3885 crate::warn_line(format_args!(
3886 "warning: artifact '{name}' declares both 'source:' and \
3887 'version: {explicit}'; version is ignored for local-source skills \
3888 (using 'local')"
3889 ));
3890 }
3891 "local".to_string()
3892 } else {
3893 artifact
3894 .version
3895 .filter(|s| !s.trim().is_empty())
3896 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3897 index,
3898 message: "missing required field 'version'".to_string(),
3899 })?
3900 };
3901
3902 let capabilities = match artifact.capabilities {
3908 None => None,
3909 Some(raw_caps) => {
3910 if raw_caps.install.is_some() {
3913 return Err(RuntimeManifestError::InvalidArtifact {
3914 index,
3915 message: format!(
3916 "artifact '{name}' declares 'capabilities.install:'; the \
3917 grant is capsule-wide and is only recognized in the \
3918 top-level capabilities block"
3919 ),
3920 });
3921 }
3922 if raw_caps.harness.is_some() {
3923 return Err(RuntimeManifestError::InvalidArtifact {
3924 index,
3925 message: format!(
3926 "artifact '{name}' declares 'capabilities.harness:'; the \
3927 grant is capsule-wide and reaches the process harness, so it \
3928 is only recognized in the top-level capabilities block"
3929 ),
3930 });
3931 }
3932 if runtime == ArtifactRuntime::Skill {
3933 return Err(RuntimeManifestError::InvalidArtifact {
3934 index,
3935 message: format!(
3936 "artifact '{name}' declares per-artifact 'capabilities:' but \
3937 has 'runtime: {}'; per-artifact capabilities are only \
3938 recognized on 'runtime: hook', 'runtime: tool', and \
3939 'runtime: driver' entries — use the capsule-wide top-level \
3940 'capabilities:' block instead",
3941 runtime.as_str()
3942 ),
3943 });
3944 }
3945 if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
3951 return Err(RuntimeManifestError::InvalidArtifact {
3952 index,
3953 message: format!(
3954 "artifact '{name}' declares 'capabilities.task_io:' but has \
3955 'runtime: {}'; the key grants the murmur:task-io/read host \
3956 import and is only recognized on 'runtime: hook' entries",
3957 runtime.as_str()
3958 ),
3959 });
3960 }
3961 if let Some(filesystem) = raw_caps
3965 .filesystem
3966 .as_ref()
3967 .filter(|filesystem| filesystem.records)
3968 {
3969 if runtime != ArtifactRuntime::Hook {
3970 return Err(RuntimeManifestError::InvalidArtifact {
3971 index,
3972 message: format!(
3973 "artifact '{name}' declares \
3974 'capabilities.filesystem.records: true' but has \
3975 'runtime: {}'; the key gives a hook the records \
3976 directory's hooks/ and is only recognized on \
3977 'runtime: hook' entries",
3978 runtime.as_str()
3979 ),
3980 });
3981 }
3982 if filesystem
3983 .scope
3984 .as_deref()
3985 .is_some_and(|scope| !scope.trim().is_empty())
3986 {
3987 return Err(RuntimeManifestError::InvalidArtifact {
3988 index,
3989 message: format!(
3990 "artifact '{name}' declares both \
3991 'capabilities.filesystem.records: true' and \
3992 'capabilities.filesystem.scope'; a hook's \".\" is \
3993 either the records directory's hooks/ or a workdir \
3994 scope — declare one"
3995 ),
3996 });
3997 }
3998 }
3999 if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
4003 return Err(RuntimeManifestError::InvalidArtifact {
4004 index,
4005 message: format!(
4006 "artifact '{name}' declares 'capabilities.conversation:' but \
4007 has 'runtime: {}'; the key grants the \
4008 murmur:conversation/read host import and is only recognized \
4009 on 'runtime: hook' entries",
4010 runtime.as_str()
4011 ),
4012 });
4013 }
4014 parse_capabilities(Some(raw_caps))?
4015 }
4016 };
4017
4018 let on_overflow = match artifact.on_overflow.as_deref() {
4024 None => HookOverflowPolicy::default(),
4025 Some(raw) => {
4026 if runtime != ArtifactRuntime::Hook {
4027 return Err(RuntimeManifestError::InvalidArtifact {
4028 index,
4029 message: format!(
4030 "artifact '{name}' declares 'on_overflow:' but has \
4031 'runtime: {}'; the key governs an async hook's job queue \
4032 and is only recognized on 'runtime: hook' entries",
4033 runtime.as_str()
4034 ),
4035 });
4036 }
4037 match raw {
4038 "drop" => HookOverflowPolicy::Drop,
4039 "block" => HookOverflowPolicy::Block,
4040 other => {
4041 return Err(RuntimeManifestError::InvalidArtifact {
4042 index,
4043 message: format!(
4044 "artifact '{name}' declares unknown on_overflow \
4045 '{other}'; expected: drop, block"
4046 ),
4047 });
4048 }
4049 }
4050 }
4051 };
4052
4053 if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
4060 return Err(RuntimeManifestError::InvalidArtifact {
4061 index,
4062 message: format!(
4063 "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
4064 is recognized only on 'runtime: hook', 'runtime: tool', and \
4065 'runtime: driver' entries",
4066 runtime.as_str()
4067 ),
4068 });
4069 }
4070
4071 let gateway = match artifact.gateway {
4072 None => None,
4073 Some(raw_gateway) => {
4074 if runtime == ArtifactRuntime::Skill {
4075 return Err(RuntimeManifestError::InvalidArtifact {
4076 index,
4077 message: format!(
4078 "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
4079 the key is recognized only on 'runtime: hook', \
4080 'runtime: tool', and 'runtime: driver' entries",
4081 runtime.as_str()
4082 ),
4083 });
4084 }
4085 Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
4086 }
4087 };
4088
4089 Ok(RuntimeArtifact {
4090 name,
4091 version,
4092 runtime,
4093 source,
4094 local_source,
4095 prompt_payload,
4096 capabilities,
4097 on_overflow,
4098 config: artifact.config,
4099 gateway,
4100 })
4101 })
4102 .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
4103
4104 if raw
4108 .capabilities
4109 .as_ref()
4110 .is_some_and(|caps| caps.task_io.is_some())
4111 {
4112 return Err(RuntimeManifestError::InvalidCapabilities {
4113 field: "capabilities.task_io".to_string(),
4114 message: "is recognized only on 'runtime: hook' artifact entries, not in the \
4115 capsule-wide capabilities block — the grant is per-hook"
4116 .to_string(),
4117 });
4118 }
4119 if raw
4122 .capabilities
4123 .as_ref()
4124 .and_then(|caps| caps.filesystem.as_ref())
4125 .is_some_and(|filesystem| filesystem.records)
4126 {
4127 return Err(RuntimeManifestError::InvalidCapabilities {
4128 field: "capabilities.filesystem.records".to_string(),
4129 message: "is recognized only on 'runtime: hook' artifact entries, not in the \
4130 capsule-wide capabilities block — the grant is per-hook"
4131 .to_string(),
4132 });
4133 }
4134 if raw.config.is_some() {
4139 return Err(RuntimeManifestError::InvalidCapabilities {
4140 field: "config".to_string(),
4141 message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
4142 'runtime: driver' entry that reads it — there is no capsule-wide \
4143 config block"
4144 .to_string(),
4145 });
4146 }
4147 let capabilities = parse_capabilities(raw.capabilities)?;
4148 let inference = parse_inference(raw.inference)?;
4149 refuse_install_under_process_transport(capabilities.as_ref(), inference.as_ref())?;
4150 validate_driver_gateways(&artifacts, inference.as_ref())?;
4151 validate_driver_choices(&artifacts, inference.as_ref())?;
4152 validate_process_driver_entry(&artifacts, inference.as_ref())?;
4153
4154 if let Some(ref sp_art) = inference
4157 .as_ref()
4158 .and_then(|i| i.system_prompt_artifact.clone())
4159 {
4160 let matching = artifacts.iter().find(|a| &a.name == sp_art);
4161 match matching {
4162 None => {
4163 return Err(RuntimeManifestError::InvalidInferenceConfig {
4164 field: "inference.system_prompt_artifact".to_string(),
4165 message: not_declared_message(&artifacts, sp_art),
4166 });
4167 }
4168 Some(a) if !a.prompt_payload => {
4169 return Err(RuntimeManifestError::InvalidInferenceConfig {
4170 field: "inference.system_prompt_artifact".to_string(),
4171 message: format!(
4172 "artifact '{sp_art}' (runtime: {}) does not declare \
4173 'prompt_payload: true', which system_prompt_artifact requires",
4174 a.runtime.as_str()
4175 ),
4176 });
4177 }
4178 Some(_) => {} }
4180 }
4181 let context = parse_context(raw.context)?;
4182 let observability = parse_observability(raw.observability);
4183 let trace = raw
4184 .trace
4185 .map(|raw_trace| {
4186 let capture = resolve_trace_capture(raw_trace.capture.as_deref())?;
4187 let retain = parse_trace_retain(raw_trace.retain)?;
4188 Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
4189 })
4190 .transpose()?;
4191 let network = raw
4192 .network
4193 .map(|n| {
4194 Ok::<_, RuntimeManifestError>(NetworkConfig {
4195 internal_port: n.internal_port,
4196 authentication: parse_network_authentication(n.authentication)?,
4197 })
4198 })
4199 .transpose()?;
4200 refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
4201 let lifecycle = raw
4202 .lifecycle
4203 .map(|raw_lc| {
4204 let defaults = LifecycleConfig::default();
4205 let lifecycle = LifecycleConfig {
4206 task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
4207 after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
4208 queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
4209 input_timeout_secs: raw_lc.input_timeout_secs,
4210 conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
4211 max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
4212 shell_grace_secs: raw_lc.shell_grace_secs,
4213 delegation_deadline_secs: raw_lc
4214 .delegation_deadline_secs
4215 .unwrap_or(defaults.delegation_deadline_secs),
4216 deadline_notice_secs: raw_lc.deadline_notice_secs,
4217 keep_working: raw_lc.keep_working.map(parse_keep_working).transpose()?,
4218 done_check: raw_lc
4219 .done_check
4220 .map(|raw| {
4221 let shell_allow = capabilities
4222 .as_ref()
4223 .and_then(|capabilities| capabilities.shell.as_ref())
4224 .map_or(&[][..], |shell| shell.allow.as_slice());
4225 parse_done_check(raw, shell_allow)
4226 })
4227 .transpose()?,
4228 };
4229 if lifecycle.queue_has_room() {
4230 Ok(lifecycle)
4231 } else {
4232 Err(RuntimeManifestError::QueueWithoutRoom)
4233 }
4234 })
4235 .transpose()?;
4236
4237 let exports = parse_exports(raw.exports)?;
4238 let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
4239 validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
4240
4241 Ok(Self {
4242 name,
4243 version,
4244 artifacts,
4245 capabilities,
4246 inference,
4247 context,
4248 observability,
4249 trace,
4250 network,
4251 lifecycle,
4252 exports,
4253 control,
4254 deploy: raw.deploy.map(|deploy| DeployConfig {
4255 mur_version: deploy.mur_version.filter(|s| !s.trim().is_empty()),
4256 }),
4257 unknown_keys,
4258 })
4259 }
4260}
4261
4262fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
4264 artifacts
4265 .iter()
4266 .filter_map(|artifact| {
4267 let name = artifact.name.as_deref()?.trim();
4268 let written = artifact
4269 .gateway
4270 .as_ref()?
4271 .as_ref()?
4272 .api_key
4273 .as_ref()?
4274 .as_ref()?;
4275 let variable = parse_env_reference(written.trim())?;
4276 Some((name.to_string(), variable.to_string()))
4277 })
4278 .collect()
4279}
4280
4281fn parse_control(
4288 raw: Option<RawControl>,
4289 gateway_references: &[(String, String)],
4290 inference: Option<&InferenceConfig>,
4291) -> Result<Option<ControlConfig>, RuntimeManifestError> {
4292 let Some(raw) = raw else {
4293 return Ok(None);
4294 };
4295 let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
4296 field: field.to_string(),
4297 message,
4298 };
4299 let settings_written = control_string_list("control.settings", raw.settings)?;
4300 let secrets_written = control_string_list("control.secrets", raw.secrets)?;
4301 let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
4302 if settings_written.is_empty()
4303 && secrets_written.is_empty()
4304 && agent_settings_written.is_empty()
4305 {
4306 return Ok(None);
4307 }
4308 let Some(inference) = inference else {
4309 return Err(refuse(
4310 "control",
4311 "is declared on a capsule with no inference: block; the control surface is served on \
4312 an agent capsule's A2A listener, and a script capsule opens none"
4313 .to_string(),
4314 ));
4315 };
4316
4317 let mut settings = Vec::new();
4318 for entry in settings_written {
4319 let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
4320 let controllable: Vec<&str> = ControllableSetting::ALL
4321 .iter()
4322 .map(|setting| setting.wire_name())
4323 .collect();
4324 return Err(refuse(
4325 "control.settings",
4326 format!(
4327 "'{entry}' is not a controllable setting; the controllable settings are: {}",
4328 controllable.join(", ")
4329 ),
4330 ));
4331 };
4332 if settings.contains(&setting) {
4333 return Err(refuse(
4334 "control.settings",
4335 format!("'{entry}' is listed more than once"),
4336 ));
4337 }
4338 match setting {
4339 ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
4340 return Err(refuse(
4341 "control.settings",
4342 format!(
4343 "'{entry}' is controllable only under inference.transport: http; a \
4344 transport: {} harness sets its own output cap",
4345 inference.transport
4346 ),
4347 ));
4348 }
4349 ControllableSetting::InferenceMaxTokens => {}
4350 ControllableSetting::InferenceDriver => {
4351 check_driver_setting(inference).map_err(|message| {
4352 refuse("control.settings", format!("'{entry}' {message}"))
4353 })?;
4354 }
4355 }
4356 settings.push(setting);
4357 }
4358
4359 let mut agent_settings = Vec::new();
4360 for entry in agent_settings_written {
4361 let Some(setting) = ControllableSetting::from_wire_name(&entry)
4362 .filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
4363 else {
4364 let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
4365 .iter()
4366 .map(|setting| setting.wire_name())
4367 .collect();
4368 return Err(refuse(
4369 "control.agent_settings",
4370 format!(
4371 "'{entry}' is not a setting the agent may change; control.agent_settings \
4372 accepts only: {}",
4373 settable.join(", ")
4374 ),
4375 ));
4376 };
4377 if agent_settings.contains(&setting) {
4378 return Err(refuse(
4379 "control.agent_settings",
4380 format!("'{entry}' is listed more than once"),
4381 ));
4382 }
4383 check_driver_setting(inference)
4384 .map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
4385 agent_settings.push(setting);
4386 }
4387
4388 let inference_driver = (inference.transport == "http")
4389 .then(|| {
4390 inference
4391 .driver
4392 .as_ref()
4393 .map(|driver| driver.artifact.as_str())
4394 })
4395 .flatten();
4396 let mut secrets: Vec<String> = Vec::new();
4397 for entry in secrets_written {
4398 if !is_valid_env_variable(&entry) {
4399 return Err(refuse(
4400 "control.secrets",
4401 format!(
4402 "'{entry}' is not a credential name; a name is uppercase letters, digits and \
4403 underscores, and does not start with a digit"
4404 ),
4405 ));
4406 }
4407 if secrets.contains(&entry) {
4408 return Err(refuse(
4409 "control.secrets",
4410 format!("'{entry}' is listed more than once"),
4411 ));
4412 }
4413 let backing: Vec<&str> = gateway_references
4414 .iter()
4415 .filter(|(_, variable)| *variable == entry)
4416 .map(|(artifact, _)| artifact.as_str())
4417 .collect();
4418 if backing.is_empty() {
4419 return Err(refuse(
4420 "control.secrets",
4421 format!(
4422 "'{entry}' is not referenced by any artifact's gateway.api_key as \
4423 ${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
4424 ),
4425 ));
4426 }
4427 if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
4428 return Err(refuse(
4429 "control.secrets",
4430 format!(
4431 "'{entry}' keys the configured inference driver's gateway \
4432 (artifacts.{driver}.gateway.api_key); the inference credential is resolved \
4433 at launch and cannot be supplied by a controller"
4434 ),
4435 ));
4436 }
4437 secrets.push(entry);
4438 }
4439
4440 Ok(Some(ControlConfig {
4441 settings,
4442 secrets,
4443 agent_settings,
4444 }))
4445}
4446
4447fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
4450 if inference.transport != "http" {
4451 return Err(format!(
4452 "is controllable only under inference.transport: http; a transport: {} harness owns \
4453 the model it runs",
4454 inference.transport
4455 ));
4456 }
4457 if inference.alternates.is_empty() {
4458 return Err(
4459 "selects among inference.alternates, and this capsule declares none; declare an \
4460 alternate to switch to"
4461 .to_string(),
4462 );
4463 }
4464 Ok(())
4465}
4466
4467fn control_string_list(
4469 field: &str,
4470 value: Option<serde_norway::Value>,
4471) -> Result<Vec<String>, RuntimeManifestError> {
4472 let refuse = || RuntimeManifestError::InvalidControl {
4473 field: field.to_string(),
4474 message: "must be a list of names".to_string(),
4475 };
4476 let items = match value {
4477 None | Some(serde_norway::Value::Null) => return Ok(Vec::new()),
4478 Some(serde_norway::Value::Sequence(items)) => items,
4479 Some(_) => return Err(refuse()),
4480 };
4481 items
4482 .into_iter()
4483 .map(|item| match item {
4484 serde_norway::Value::String(name) => Ok(name.trim().to_string()),
4485 _ => Err(refuse()),
4486 })
4487 .collect()
4488}
4489
4490fn parse_network_authentication(
4492 raw: Option<RawNetworkAuthentication>,
4493) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
4494 let Some(raw) = raw else {
4495 return Ok(None);
4496 };
4497 let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
4498 field: field.to_string(),
4499 message,
4500 };
4501 let schemes: Vec<&str> = AuthenticationScheme::ALL
4502 .iter()
4503 .map(|scheme| scheme.wire_name())
4504 .collect();
4505 let scheme = match raw.scheme.as_deref().map(str::trim) {
4506 None | Some("") => {
4507 return Err(refuse(
4508 "network.authentication.scheme",
4509 format!("is required; the schemes are: {}", schemes.join(", ")),
4510 ))
4511 }
4512 Some(written) => AuthenticationScheme::ALL
4513 .iter()
4514 .copied()
4515 .find(|scheme| scheme.wire_name() == written)
4516 .ok_or_else(|| {
4517 refuse(
4518 "network.authentication.scheme",
4519 format!(
4520 "'{written}' is not a door authentication scheme; the schemes are: {}",
4521 schemes.join(", ")
4522 ),
4523 )
4524 })?,
4525 };
4526
4527 let mut credentials = Vec::new();
4528 for (name, credential) in raw.credentials.unwrap_or_default() {
4529 if name == OPERATOR_CREDENTIAL {
4530 return Err(refuse(
4531 "network.authentication.credentials",
4532 format!(
4533 "'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
4534 token, which holds every scope"
4535 ),
4536 ));
4537 }
4538 if !is_valid_credential_name(&name) {
4539 return Err(refuse(
4540 "network.authentication.credentials",
4541 format!(
4542 "'{name}' is not a credential name; a name is a lowercase letter followed by \
4543 at most {} lowercase letters, digits, '_' or '-'",
4544 MAX_CREDENTIAL_NAME_LEN - 1
4545 ),
4546 ));
4547 }
4548 let field = format!("network.authentication.credentials.{name}.scopes");
4549 let scopes = door_scope_list(&field, credential.scopes)?;
4550 credentials.push(DoorCredential { name, scopes });
4551 }
4552
4553 Ok(Some(NetworkAuthentication {
4554 scheme,
4555 credentials,
4556 }))
4557}
4558
4559fn is_valid_credential_name(name: &str) -> bool {
4561 let mut chars = name.chars();
4562 chars.next().is_some_and(|first| first.is_ascii_lowercase())
4563 && name.len() <= MAX_CREDENTIAL_NAME_LEN
4564 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
4565}
4566
4567fn door_scope_list(
4569 field: &str,
4570 value: Option<serde_norway::Value>,
4571) -> Result<Vec<String>, RuntimeManifestError> {
4572 let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
4573 field: field.to_string(),
4574 message,
4575 };
4576 let items = match value {
4577 None | Some(serde_norway::Value::Null) => {
4578 return Err(refuse(
4579 "is required: a credential reaches only the scopes it lists".to_string(),
4580 ))
4581 }
4582 Some(serde_norway::Value::Sequence(items)) => items,
4583 Some(_) => return Err(refuse("must be a list of scopes".to_string())),
4584 };
4585 if items.is_empty() {
4586 return Err(refuse(
4587 "lists no scope; a credential must reach at least one".to_string(),
4588 ));
4589 }
4590 let mut scopes: Vec<String> = Vec::new();
4591 for item in items {
4592 let serde_norway::Value::String(scope) = item else {
4593 return Err(refuse("must be a list of scopes".to_string()));
4594 };
4595 let scope = scope.trim().to_string();
4596 if let Some(&(_, replacement)) = RETIRED_DOOR_SCOPES
4597 .iter()
4598 .find(|(retired, _)| *retired == scope)
4599 {
4600 return Err(RuntimeManifestError::RetiredDoorScope {
4601 field: field.to_string(),
4602 scope,
4603 replacement,
4604 });
4605 }
4606 if !DOOR_SCOPES.contains(&scope.as_str()) {
4607 return Err(refuse(format!(
4608 "'{scope}' is not a door scope; the scopes are: {}",
4609 DOOR_SCOPES.join(", ")
4610 )));
4611 }
4612 if scopes.contains(&scope) {
4613 return Err(refuse(format!("'{scope}' is listed more than once")));
4614 }
4615 scopes.push(scope);
4616 }
4617 Ok(scopes)
4618}
4619
4620fn refuse_authenticated_delegating_parent(
4625 network: Option<&NetworkConfig>,
4626 capabilities: Option<&Capabilities>,
4627) -> Result<(), RuntimeManifestError> {
4628 let authenticated = network.is_some_and(|network| network.authentication.is_some());
4629 let delegates = capabilities
4630 .and_then(|capabilities| capabilities.spawn.as_ref())
4631 .is_some_and(|spawn| !spawn.allow.is_empty());
4632 if authenticated && delegates {
4633 return Err(RuntimeManifestError::InvalidNetworkConfig {
4634 field: "network.authentication".to_string(),
4635 message: "cannot be declared together with capabilities.spawn.allow: a delegated \
4636 child posts its outcome to this door, and a delegated child cannot yet be \
4637 issued a token for this door"
4638 .to_string(),
4639 });
4640 }
4641 Ok(())
4642}
4643
4644fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
4651 let Some(raw_exports) = raw else {
4652 return Ok(None);
4653 };
4654 let files = raw_exports.files.map(parse_file_export).transpose()?;
4655 let peer_files = raw_exports
4656 .peer_files
4657 .map(parse_peer_files_export)
4658 .transpose()?;
4659 let peer_tasks = raw_exports
4660 .peer_tasks
4661 .map(|raw| {
4662 raw.accept
4663 .map(|accept| PeerTasksExport { accept })
4664 .ok_or_else(|| RuntimeManifestError::InvalidExports {
4665 field: "exports.peer_tasks.accept".to_string(),
4666 message: "must be set explicitly to true or false — consent to peer tasks is \
4667 never inferred"
4668 .to_string(),
4669 })
4670 })
4671 .transpose()?;
4672 Ok(Some(Exports {
4673 files,
4674 peer_files,
4675 peer_tasks,
4676 }))
4677}
4678
4679fn parse_peer_files_export(
4687 raw: RawPeerFilesExport,
4688) -> Result<PeerFilesExport, RuntimeManifestError> {
4689 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4690 field: field.to_string(),
4691 message,
4692 };
4693
4694 let root = raw
4695 .root
4696 .map(|root| root.trim().to_string())
4697 .filter(|root| !root.is_empty())
4698 .ok_or_else(|| {
4699 invalid(
4700 "exports.peer_files.root",
4701 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4702 )
4703 })?;
4704 check_export_root_shape("exports.peer_files.root", &root)?;
4705
4706 let max_ttl_secs = match raw.max_ttl {
4707 None => None,
4708 Some(value) => {
4709 let text = scalar_text(&value).ok_or_else(|| {
4710 invalid(
4711 "exports.peer_files.max_ttl",
4712 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
4713 )
4714 })?;
4715 let parsed = parse_duration_secs(&text)
4716 .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
4717 if parsed == 0 {
4718 return Err(invalid(
4719 "exports.peer_files.max_ttl",
4720 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
4721 ));
4722 }
4723 Some(parsed)
4724 }
4725 };
4726
4727 let max_bytes = match raw.max_bytes {
4728 None => DEFAULT_PEER_FILES_MAX_BYTES,
4729 Some(value) => {
4730 let text = scalar_text(&value).ok_or_else(|| {
4731 invalid(
4732 "exports.peer_files.max_bytes",
4733 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4734 )
4735 })?;
4736 let parsed = parse_byte_size(&text)
4737 .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
4738 if parsed == 0 {
4739 return Err(invalid(
4740 "exports.peer_files.max_bytes",
4741 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4742 ));
4743 }
4744 parsed
4745 }
4746 };
4747
4748 Ok(PeerFilesExport {
4749 root,
4750 max_ttl_secs,
4751 max_bytes,
4752 })
4753}
4754
4755fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_norway::Value>, D::Error>
4763where
4764 D: serde::Deserializer<'de>,
4765{
4766 serde_norway::Value::deserialize(deserializer).map(Some)
4767}
4768
4769fn scalar_text(value: &serde_norway::Value) -> Option<String> {
4770 match value {
4771 serde_norway::Value::String(text) => Some(text.clone()),
4772 serde_norway::Value::Number(number) => Some(number.to_string()),
4773 _ => None,
4774 }
4775}
4776
4777enum RelativePathRefusal {
4779 ParentDir,
4780 Absolute,
4781}
4782
4783fn relative_path_refusal(path: &str) -> Option<RelativePathRefusal> {
4786 Path::new(path)
4787 .components()
4788 .find_map(|component| match component {
4789 std::path::Component::Normal(_) | std::path::Component::CurDir => None,
4790 std::path::Component::ParentDir => Some(RelativePathRefusal::ParentDir),
4791 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
4792 Some(RelativePathRefusal::Absolute)
4793 }
4794 })
4795}
4796
4797fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
4801 let message = match relative_path_refusal(root) {
4802 None => return Ok(()),
4803 Some(RelativePathRefusal::ParentDir) => {
4804 format!("'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}")
4805 }
4806 Some(RelativePathRefusal::Absolute) => {
4807 format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}")
4808 }
4809 };
4810 Err(RuntimeManifestError::InvalidExports {
4811 field: field.to_string(),
4812 message,
4813 })
4814}
4815
4816const INSTRUCTION_FILE_ACCEPTED_FORM: &str = "must be a relative path inside the work folder";
4817
4818fn parse_instruction_files(entries: Vec<String>) -> Result<Vec<String>, RuntimeManifestError> {
4822 for (index, entry) in entries.iter().enumerate() {
4823 let problem = if entry.trim().is_empty() {
4824 Some("is empty")
4825 } else {
4826 match relative_path_refusal(entry) {
4827 None => None,
4828 Some(RelativePathRefusal::ParentDir) => Some("contains a '..' component"),
4829 Some(RelativePathRefusal::Absolute) => Some("is absolute"),
4830 }
4831 };
4832 if let Some(problem) = problem {
4833 return Err(RuntimeManifestError::InvalidInferenceConfig {
4834 field: format!("inference.instruction_files[{index}]"),
4835 message: format!("'{entry}' {problem}; {INSTRUCTION_FILE_ACCEPTED_FORM}"),
4836 });
4837 }
4838 }
4839 Ok(entries)
4840}
4841
4842fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
4843 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4844 field: field.to_string(),
4845 message,
4846 };
4847
4848 let root = raw
4849 .root
4850 .map(|root| root.trim().to_string())
4851 .filter(|root| !root.is_empty())
4852 .ok_or_else(|| {
4853 invalid(
4854 "exports.files.root",
4855 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4856 )
4857 })?;
4858 check_export_root_shape("exports.files.root", &root)?;
4859
4860 let mode = match raw.mode.as_deref().map(str::trim) {
4861 None | Some("") => {
4862 return Err(invalid(
4863 "exports.files.mode",
4864 "is required and must be 'read-only'".to_string(),
4865 ));
4866 }
4867 Some("read-only") => ExportMode::ReadOnly,
4868 Some(other) => {
4869 return Err(invalid(
4870 "exports.files.mode",
4871 format!("'{other}' must be 'read-only'"),
4872 ));
4873 }
4874 };
4875
4876 let max_bytes = match raw.max_bytes {
4877 None => DEFAULT_EXPORT_MAX_BYTES,
4878 Some(value) => {
4879 let Some(text) = scalar_text(&value) else {
4880 return Err(invalid(
4881 "exports.files.max_bytes",
4882 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4883 ));
4884 };
4885 let parsed = parse_byte_size(&text)
4886 .map_err(|message| invalid("exports.files.max_bytes", message))?;
4887 if parsed == 0 {
4888 return Err(invalid(
4889 "exports.files.max_bytes",
4890 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4891 ));
4892 }
4893 parsed
4894 }
4895 };
4896
4897 Ok(FileExport {
4898 root,
4899 mode,
4900 max_bytes,
4901 })
4902}
4903
4904const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
4907
4908pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
4910
4911fn check_network_destination_shape(entry: &str) -> Result<(), String> {
4918 let trimmed = entry.trim();
4919 if trimmed.is_empty() {
4920 return Err(format!(
4921 "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4922 ));
4923 }
4924
4925 if trimmed.contains("://") {
4926 let url = Url::parse(trimmed).map_err(|error| {
4927 format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
4928 })?;
4929 let scheme = url.scheme().to_ascii_lowercase();
4930 if scheme != "http" && scheme != "https" {
4931 return Err(format!(
4932 "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
4933 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4934 ));
4935 }
4936 if url.host_str().is_none_or(str::is_empty) {
4937 return Err(format!(
4938 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4939 ));
4940 }
4941 if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
4942 return Err(format!(
4943 "'{entry}' must not include a path, query or fragment; \
4944 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4945 ));
4946 }
4947 return Ok(());
4948 }
4949
4950 if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
4951 return Err(format!(
4952 "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4953 ));
4954 }
4955 if let Some((host, port)) = trimmed.rsplit_once(':') {
4957 if !host.ends_with(']') {
4958 if host.is_empty() {
4959 return Err(format!(
4960 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4961 ));
4962 }
4963 if port.parse::<u16>().is_err() {
4964 return Err(format!(
4965 "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4966 ));
4967 }
4968 }
4969 }
4970 Ok(())
4971}
4972
4973pub const HOST_PATHS_KEY: &str = "host_paths";
4976
4977fn check_no_host_paths(raw: &RawRuntimeManifest) -> Result<(), RuntimeManifestError> {
4981 fn in_capabilities(capabilities: Option<&RawCapabilities>, prefix: &str) -> Option<String> {
4982 let capabilities = capabilities?;
4983 if capabilities.unknown.contains_key(HOST_PATHS_KEY) {
4984 return Some(format!("{prefix}.{HOST_PATHS_KEY}"));
4985 }
4986 capabilities
4987 .filesystem
4988 .as_ref()
4989 .filter(|filesystem| filesystem.unknown.contains_key(HOST_PATHS_KEY))
4990 .map(|_| format!("{prefix}.filesystem.{HOST_PATHS_KEY}"))
4991 }
4992 let field = if raw.unknown.contains_key(HOST_PATHS_KEY) {
4993 Some(HOST_PATHS_KEY.to_string())
4994 } else {
4995 in_capabilities(raw.capabilities.as_ref(), "capabilities").or_else(|| {
4996 raw.artifacts
4997 .iter()
4998 .enumerate()
4999 .find_map(|(index, artifact)| {
5000 in_capabilities(
5001 artifact.capabilities.as_ref(),
5002 &format!("artifacts[{index}].capabilities"),
5003 )
5004 })
5005 })
5006 };
5007 match field {
5008 Some(field) => Err(RuntimeManifestError::HostPathsDeclared { field }),
5009 None => Ok(()),
5010 }
5011}
5012
5013fn parse_capabilities(
5014 raw: Option<RawCapabilities>,
5015) -> Result<Option<Capabilities>, RuntimeManifestError> {
5016 let Some(raw_caps) = raw else {
5017 return Ok(None);
5018 };
5019
5020 let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
5021 allow: raw_network.allow,
5022 unix_sockets: raw_network.unix_sockets,
5023 });
5024
5025 let peer_fetch = raw_caps
5026 .peer_fetch
5027 .map(|raw_peer_fetch| {
5028 if raw_peer_fetch.allow.is_empty() {
5029 return Err(RuntimeManifestError::InvalidCapabilities {
5030 field: "capabilities.peer_fetch.allow".to_string(),
5031 message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
5032 });
5033 }
5034 for entry in &raw_peer_fetch.allow {
5035 check_network_destination_shape(entry).map_err(|message| {
5036 RuntimeManifestError::InvalidCapabilities {
5037 field: "capabilities.peer_fetch.allow".to_string(),
5038 message,
5039 }
5040 })?;
5041 }
5042 Ok(PeerFetchCapabilities {
5043 allow: raw_peer_fetch.allow,
5044 })
5045 })
5046 .transpose()?;
5047
5048 let filesystem = raw_caps
5049 .filesystem
5050 .map(|raw_filesystem| FilesystemCapabilities {
5051 scope: raw_filesystem.scope.and_then(|scope| {
5052 let trimmed = scope.trim();
5053 (!trimmed.is_empty()).then(|| trimmed.to_string())
5054 }),
5055 workdir_exec: raw_filesystem.workdir_exec,
5060 read_only: raw_filesystem
5064 .read_only
5065 .into_iter()
5066 .filter_map(|entry| {
5067 let trimmed = entry.trim();
5068 (!trimmed.is_empty()).then(|| trimmed.to_string())
5069 })
5070 .collect(),
5071 records: raw_filesystem.records,
5073 });
5074
5075 let shell = raw_caps
5076 .shell
5077 .map(|raw_shell| {
5078 if raw_shell.allow.is_empty() {
5079 return Err(RuntimeManifestError::InvalidCapabilities {
5080 field: "capabilities.shell.allow".to_string(),
5081 message: "must contain at least one binary".to_string(),
5082 });
5083 }
5084 for (index, entry) in raw_shell.allow.iter().enumerate() {
5085 if let Some(reason) = shell_allow_entry_error(entry) {
5086 return Err(RuntimeManifestError::ShellAllowEntry {
5087 index,
5088 entry: entry.clone(),
5089 reason,
5090 });
5091 }
5092 }
5093
5094 let interpreter_runtime =
5095 parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
5096 let staged_runtime = parse_staged_runtime(
5099 &raw_shell.allow,
5100 &interpreter_runtime,
5101 raw_shell.staged_runtime,
5102 )?;
5103
5104 Ok(ShellCapabilities {
5105 allow: raw_shell.allow,
5106 strip_env: raw_shell.strip_env,
5107 baseline_env: raw_shell.baseline_env,
5108 interpreter_runtime,
5109 staged_runtime,
5110 })
5111 })
5112 .transpose()?;
5113
5114 let spawn = raw_caps
5115 .spawn
5116 .map(|raw_spawn| parse_spawn_allow(raw_spawn.allow, raw_spawn.workdir_snapshot))
5117 .transpose()?;
5118
5119 let env = raw_caps.env.map(|raw_env| EnvCapabilities {
5120 allow: raw_env.allow,
5121 });
5122
5123 let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
5124
5125 let resources = raw_caps
5126 .resources
5127 .map(parse_resource_capabilities)
5128 .transpose()?;
5129
5130 let state = raw_caps.state.map(parse_state_capabilities);
5131
5132 let task_io = raw_caps
5133 .task_io
5134 .map(|raw_task_io| {
5135 raw_task_io
5136 .read
5137 .map(|read| TaskIoCapabilities { read })
5138 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5139 field: "capabilities.task_io.read".to_string(),
5140 message: "must be set explicitly to true or false — a capability is never \
5141 inferred"
5142 .to_string(),
5143 })
5144 })
5145 .transpose()?;
5146
5147 let conversation = raw_caps
5148 .conversation
5149 .map(|raw_conversation| {
5150 raw_conversation
5151 .read
5152 .map(|read| ConversationCapabilities { read })
5153 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5154 field: "capabilities.conversation.read".to_string(),
5155 message: "must be set explicitly to true or false — a capability is never \
5156 inferred"
5157 .to_string(),
5158 })
5159 })
5160 .transpose()?;
5161
5162 let plan = raw_caps
5163 .plan
5164 .map(|raw_plan| {
5165 raw_plan
5166 .submit
5167 .map(|submit| PlanCapabilities { submit })
5168 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5169 field: "capabilities.plan.submit".to_string(),
5170 message: "must be set explicitly to true or false — a capability is never \
5171 inferred"
5172 .to_string(),
5173 })
5174 })
5175 .transpose()?;
5176
5177 let install = raw_caps.install.map(parse_install).transpose()?;
5178 let harness = raw_caps.harness.map(parse_harness).transpose()?;
5179
5180 let containment = raw_caps
5181 .containment
5182 .as_deref()
5183 .map(str::trim)
5184 .filter(|value| !value.is_empty())
5185 .map(|value| {
5186 let class = value.parse::<ContainmentClass>().map_err(|error| {
5187 RuntimeManifestError::InvalidCapabilities {
5188 field: "capabilities.containment".to_string(),
5189 message: error.to_string(),
5190 }
5191 })?;
5192 if class == ContainmentClass::Unconfined {
5193 return Err(RuntimeManifestError::InvalidCapabilities {
5194 field: "capabilities.containment".to_string(),
5195 message: UNCONFINED_NOT_DECLARABLE.to_string(),
5196 });
5197 }
5198 Ok(class)
5199 })
5200 .transpose()?;
5201
5202 Ok(Some(Capabilities {
5203 network,
5204 peer_fetch,
5205 filesystem,
5206 shell,
5207 spawn,
5208 env,
5209 limits,
5210 resources,
5211 state,
5212 task_io,
5213 conversation,
5214 plan,
5215 install,
5216 harness,
5217 containment,
5218 }))
5219}
5220
5221fn parse_harness(raw: RawHarnessCapabilities) -> Result<HarnessCapabilities, RuntimeManifestError> {
5226 let mut paths = Vec::with_capacity(raw.paths.len());
5227 for (index, entry) in raw.paths.into_iter().enumerate() {
5228 let base = format!("capabilities.harness.paths[{index}]");
5229 let path = entry.path.filter(|p| !p.trim().is_empty()).ok_or_else(|| {
5230 RuntimeManifestError::InvalidCapabilities {
5231 field: format!("{base}.path"),
5232 message: "must name a host directory (`~/…` or an absolute path)".to_string(),
5233 }
5234 })?;
5235 if let Some(reason) = harness_path_shape_error(&path) {
5236 return Err(RuntimeManifestError::InvalidCapabilities {
5237 field: format!("{base}.path"),
5238 message: format!("'{path}' {reason}"),
5239 });
5240 }
5241 let access = match entry.access.as_deref() {
5242 None => {
5243 return Err(RuntimeManifestError::InvalidCapabilities {
5244 field: format!("{base}.access"),
5245 message: "access is required: write `access: read` or `access: read-write`"
5246 .to_string(),
5247 })
5248 }
5249 Some(value) => HarnessPathAccess::parse(value).ok_or_else(|| {
5250 RuntimeManifestError::InvalidCapabilities {
5251 field: format!("{base}.access"),
5252 message: format!("access '{value}' is neither `read` nor `read-write`"),
5253 }
5254 })?,
5255 };
5256 paths.push(HarnessPathEntry { path, access });
5257 }
5258 Ok(HarnessCapabilities { paths })
5259}
5260
5261#[must_use]
5267pub fn harness_path_shape_error(path: &str) -> Option<&'static str> {
5268 let rest = if path == "~" || path == "~/" {
5269 return Some("names the whole home folder; name the folder the harness needs");
5270 } else if let Some(rest) = path.strip_prefix("~/") {
5271 rest
5272 } else if path == "/" {
5273 return Some("names /, the whole host filesystem; name the folder the harness needs");
5274 } else if let Some(rest) = path.strip_prefix('/') {
5275 rest
5276 } else {
5277 return Some(
5278 "is relative: write an absolute path or ~/… (~user and environment variables are not \
5279 expanded)",
5280 );
5281 };
5282 let rest = rest.strip_suffix('/').unwrap_or(rest);
5283 for segment in rest.split('/') {
5284 match segment {
5285 "" => return Some("has an empty path segment (//)"),
5286 "." | ".." => return Some("has a . or .. segment; write the folder's own path"),
5287 _ => {}
5288 }
5289 }
5290 None
5291}
5292
5293fn parse_spawn_allow(
5297 allow: Vec<String>,
5298 workdir_snapshot: Vec<String>,
5299) -> Result<SpawnCapabilities, RuntimeManifestError> {
5300 let mut parsed: Vec<ArtifactName> = Vec::with_capacity(allow.len());
5301 for (index, entry) in allow.iter().enumerate() {
5302 let name = ArtifactName::parse(entry).map_err(|error| {
5303 RuntimeManifestError::InvalidArtifactName {
5304 field: format!("capabilities.spawn.allow[{index}]"),
5305 message: error.to_string(),
5306 hint: error.hint(),
5307 }
5308 })?;
5309 if let Some(earlier) = parsed
5310 .iter()
5311 .find(|earlier| earlier.name() == name.name() && **earlier != name)
5312 {
5313 return Err(RuntimeManifestError::InvalidArtifactName {
5314 field: format!("capabilities.spawn.allow[{index}]"),
5315 message: format!(
5316 "'{earlier}' and '{name}' share the name '{}', which is how delegate-task \
5317 offers a capsule to the model",
5318 name.name()
5319 ),
5320 hint: Some(format!(
5321 "keep one of '{earlier}' and '{name}' in capabilities.spawn.allow"
5322 )),
5323 });
5324 }
5325 parsed.push(name);
5326 }
5327 let mut snapshotted: Vec<ArtifactName> = Vec::with_capacity(workdir_snapshot.len());
5328 for (index, entry) in workdir_snapshot.iter().enumerate() {
5329 let field = format!("capabilities.spawn.workdir_snapshot[{index}]");
5330 let name = ArtifactName::parse(entry).map_err(|error| {
5331 RuntimeManifestError::InvalidArtifactName {
5332 field: field.clone(),
5333 message: error.to_string(),
5334 hint: error.hint(),
5335 }
5336 })?;
5337 if snapshotted.contains(&name) {
5338 return Err(RuntimeManifestError::InvalidArtifactName {
5339 field,
5340 message: format!("'{name}' is listed more than once"),
5341 hint: Some(format!(
5342 "keep one '{name}' in capabilities.spawn.workdir_snapshot"
5343 )),
5344 });
5345 }
5346 if !parsed.contains(&name) {
5347 return Err(RuntimeManifestError::InvalidArtifactName {
5348 field,
5349 message: format!(
5350 "'{name}' is not in capabilities.spawn.allow, so this capsule cannot \
5351 delegate to it"
5352 ),
5353 hint: Some(format!(
5354 "add '{name}' to capabilities.spawn.allow, or remove it from \
5355 capabilities.spawn.workdir_snapshot"
5356 )),
5357 });
5358 }
5359 snapshotted.push(name);
5360 }
5361 Ok(SpawnCapabilities {
5362 allow,
5363 workdir_snapshot,
5364 })
5365}
5366
5367fn check_entry_name(
5370 index: usize,
5371 name: &str,
5372 has_source: bool,
5373) -> Result<(), RuntimeManifestError> {
5374 let field = format!("artifacts[{index}].name");
5375 if !has_source {
5376 return ArtifactName::parse(name).map(|_| ()).map_err(|error| {
5377 RuntimeManifestError::InvalidArtifactName {
5378 field,
5379 message: error.to_string(),
5380 hint: error.hint(),
5381 }
5382 });
5383 }
5384 if let Some((_, bare)) = name.split_once('/') {
5385 return Err(RuntimeManifestError::InvalidArtifactName {
5386 field,
5387 message: format!(
5388 "'{name}' declares 'source:', so it is named by its bare name; a scope says \
5389 where a resolved artifact comes from, and a source: entry is never resolved"
5390 ),
5391 hint: Some(format!("drop the scope: name it '{bare}'")),
5392 });
5393 }
5394 match artifact_name_format_error(name) {
5395 Some(reason) => Err(RuntimeManifestError::InvalidArtifactName {
5396 field,
5397 message: format!("'{name}' is not a valid artifact name: it {reason}"),
5398 hint: None,
5399 }),
5400 None => Ok(()),
5401 }
5402}
5403
5404fn not_declared_message(artifacts: &[RuntimeArtifact], name: &str) -> String {
5408 let mut same_bare = artifacts
5409 .iter()
5410 .filter(|artifact| artifact.name != name && artifact.tool_name() == tool_name_of(name));
5411 match (same_bare.next(), same_bare.next()) {
5412 (Some(only), None) => format!(
5413 "artifact '{name}' is not declared in artifacts: (the declared '{}' has that name; \
5414 refer to it as '{}')",
5415 only.name, only.name
5416 ),
5417 _ => format!("artifact '{name}' is not declared in artifacts:"),
5418 }
5419}
5420
5421fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
5424 if raw.skill.is_empty() && raw.tool.is_empty() {
5425 return Err(RuntimeManifestError::InvalidCapabilities {
5426 field: "capabilities.install".to_string(),
5427 message: "must name at least one 'skill' or 'tool' entry — a capability is never \
5428 inferred; omit the block to grant no install at all"
5429 .to_string(),
5430 });
5431 }
5432 for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
5433 for (index, entry) in entries.iter().enumerate() {
5434 crate::install_pattern::validate(entry).map_err(|error| {
5435 RuntimeManifestError::InvalidArtifactName {
5436 field: format!("capabilities.install.{key}[{index}]"),
5437 message: format!(
5438 "entry {entry:?} is invalid ({}); expected {}",
5439 error.reason,
5440 crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
5441 ),
5442 hint: error.hint,
5443 }
5444 })?;
5445 }
5446 }
5447 let from = match raw.from {
5448 None => InstallFrom::Stores,
5449 Some(list) => parse_install_from(&list)?,
5450 };
5451 Ok(InstallCapabilities {
5452 skill: raw.skill,
5453 tool: raw.tool,
5454 from,
5455 })
5456}
5457
5458fn refuse_install_under_process_transport(
5461 capabilities: Option<&Capabilities>,
5462 inference: Option<&InferenceConfig>,
5463) -> Result<(), RuntimeManifestError> {
5464 let installs = capabilities.is_some_and(|capabilities| capabilities.install.is_some());
5465 let process = inference.is_some_and(|inference| inference.transport == "process");
5466 if installs && process {
5467 return Err(RuntimeManifestError::InvalidCapabilities {
5468 field: "capabilities.install".to_string(),
5469 message: "is not valid with transport: process; the harness owns its tool list, so a \
5470 pulled artifact could never reach it"
5471 .to_string(),
5472 });
5473 }
5474 Ok(())
5475}
5476
5477const INSTALL_FROM_ACCEPTED: &str = "[stores] or [stores, sources]";
5479
5480fn parse_install_from(list: &[String]) -> Result<InstallFrom, RuntimeManifestError> {
5483 let refuse = |message: String| RuntimeManifestError::InvalidCapabilities {
5484 field: "capabilities.install.from".to_string(),
5485 message: format!("{message}; expected {INSTALL_FROM_ACCEPTED}"),
5486 };
5487 if list.is_empty() {
5488 return Err(refuse("is empty".to_string()));
5489 }
5490 let (mut stores, mut sources) = (false, false);
5491 for entry in list {
5492 let seen = match entry.as_str() {
5493 "stores" => &mut stores,
5494 "sources" => &mut sources,
5495 other => return Err(refuse(format!("names {other:?}, which is not a place"))),
5496 };
5497 if *seen {
5498 return Err(refuse(format!("names {entry:?} twice")));
5499 }
5500 *seen = true;
5501 }
5502 if !stores {
5503 return Err(refuse(
5504 "does not name stores, which every pull is answered from first".to_string(),
5505 ));
5506 }
5507 Ok(if sources {
5508 InstallFrom::StoresAndSources
5509 } else {
5510 InstallFrom::Stores
5511 })
5512}
5513
5514fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
5524 StateCapabilities {
5525 store: raw.store.map(|store| store.trim().to_string()),
5526 }
5527}
5528
5529fn shell_allow_entry_error(entry: &str) -> Option<&'static str> {
5533 if entry.is_empty() {
5534 Some("is empty")
5535 } else if entry.contains('\0') {
5536 Some("contains a NUL byte")
5537 } else if entry.contains('/') {
5538 Some("is a path")
5539 } else if entry == "." || entry == ".." {
5540 Some("names a directory")
5541 } else if entry.chars().any(char::is_whitespace) {
5542 Some("contains whitespace")
5543 } else {
5544 None
5545 }
5546}
5547
5548fn parse_interpreter_runtime(
5554 allow: &[String],
5555 raw_grants: Vec<RawInterpreterRuntimeGrant>,
5556) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
5557 let mut grants = Vec::with_capacity(raw_grants.len());
5558 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5559 let base = format!("capabilities.shell.interpreter_runtime[{index}]");
5560
5561 let binary = raw_grant
5562 .binary
5563 .filter(|b| !b.trim().is_empty())
5564 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5565 field: format!("{base}.binary"),
5566 message: "must name a binary".to_string(),
5567 })?;
5568
5569 if !allow.contains(&binary) {
5573 return Err(RuntimeManifestError::InvalidCapabilities {
5574 field: format!("{base}.binary"),
5575 message: format!(
5576 "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
5577 only narrow filesystem access for an already-allowlisted binary, never \
5578 grant exec"
5579 ),
5580 });
5581 }
5582
5583 if raw_grant.dirs.is_empty() {
5584 return Err(RuntimeManifestError::InvalidCapabilities {
5585 field: format!("{base}.dirs"),
5586 message: "must name at least one directory".to_string(),
5587 });
5588 }
5589
5590 let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
5591 for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
5592 let dir_base = format!("{base}.dirs[{dir_index}]");
5593
5594 let path = raw_dir
5595 .path
5596 .filter(|p| !p.trim().is_empty())
5597 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5598 field: format!("{dir_base}.path"),
5599 message: "must name a host directory".to_string(),
5600 })?;
5601 if !path.starts_with('/') {
5602 return Err(RuntimeManifestError::InvalidCapabilities {
5603 field: format!("{dir_base}.path"),
5604 message: format!(
5605 "'{path}' must be an absolute host path (start with '/') — these are \
5606 filesystem paths outside the workdir"
5607 ),
5608 });
5609 }
5610
5611 let list_dir =
5612 raw_dir
5613 .list_dir
5614 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5615 field: format!("{dir_base}.list_dir"),
5616 message: format!(
5617 "'{path}' must set list_dir explicitly to true or false — \
5618 enumerability is never inferred"
5619 ),
5620 })?;
5621
5622 dirs.push(InterpreterRuntimeDir { path, list_dir });
5623 }
5624
5625 grants.push(InterpreterRuntimeGrant { binary, dirs });
5626 }
5627
5628 Ok(grants)
5629}
5630
5631fn parse_staged_runtime(
5652 allow: &[String],
5653 interpreter_runtime: &[InterpreterRuntimeGrant],
5654 raw_grants: Vec<RawStagedRuntimeGrant>,
5655) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
5656 let mut grants = Vec::with_capacity(raw_grants.len());
5657 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5658 let base = format!("capabilities.shell.staged_runtime[{index}]");
5659
5660 let binary = raw_grant
5661 .binary
5662 .filter(|b| !b.trim().is_empty())
5663 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5664 field: format!("{base}.binary"),
5665 message: "must name a binary".to_string(),
5666 })?;
5667
5668 if !allow.contains(&binary) {
5669 return Err(RuntimeManifestError::InvalidCapabilities {
5670 field: format!("{base}.binary"),
5671 message: format!(
5672 "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
5673 stage the runtime tree behind an already-allowlisted binary, never grant exec"
5674 ),
5675 });
5676 }
5677
5678 if interpreter_runtime
5679 .iter()
5680 .any(|grant| grant.binary == binary)
5681 {
5682 return Err(RuntimeManifestError::InvalidCapabilities {
5683 field: format!("{base}.binary"),
5684 message: format!(
5685 "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
5686 are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
5687 tree into the capsule's own root, which is what makes widening the capsule's \
5688 host filesystem scope unnecessary rather than something to add to it. Remove \
5689 the interpreter_runtime grant for '{binary}'"
5690 ),
5691 });
5692 }
5693
5694 let source_path = raw_grant
5695 .source_path
5696 .filter(|p| !p.trim().is_empty())
5697 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5698 field: format!("{base}.source_path"),
5699 message: "must name the host directory holding the pinned runtime tree".to_string(),
5700 })?;
5701 if !source_path.starts_with('/') {
5702 return Err(RuntimeManifestError::InvalidCapabilities {
5703 field: format!("{base}.source_path"),
5704 message: format!(
5705 "'{source_path}' must be an absolute host path (start with '/') — \
5706 staged-runtime sources are host filesystem paths outside the workdir"
5707 ),
5708 });
5709 }
5710
5711 let pin = raw_grant
5712 .pin
5713 .map(|p| p.trim().to_string())
5714 .filter(|p| !p.is_empty())
5715 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5716 field: format!("{base}.pin"),
5717 message: format!(
5718 "'{source_path}' must declare a non-empty pin identifying which build this \
5719 tree is — it is never inferred, because it is the value a human compares \
5720 across two hosts to confirm the same runtime shipped to both"
5721 ),
5722 })?;
5723
5724 grants.push(StagedRuntimeGrant {
5725 binary,
5726 source_path,
5727 pin,
5728 });
5729 }
5730
5731 Ok(grants)
5732}
5733
5734fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
5739 let reject_zero_usize =
5740 |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
5741 match value {
5742 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5743 field: format!("capabilities.limits.{field}"),
5744 message: "must be greater than zero".to_string(),
5745 }),
5746 _ => Ok(()),
5747 }
5748 };
5749
5750 reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
5751 reject_zero_usize(raw.table_elements, "table_elements")?;
5752 reject_zero_usize(raw.instances, "instances")?;
5753 if raw.deadline_seconds == Some(0) {
5754 return Err(RuntimeManifestError::InvalidCapabilities {
5755 field: "capabilities.limits.deadline_seconds".to_string(),
5756 message: "must be greater than zero".to_string(),
5757 });
5758 }
5759
5760 Ok(ResourceLimits {
5761 memory_bytes: raw.memory_bytes,
5762 table_elements: raw.table_elements,
5763 instances: raw.instances,
5764 deadline_seconds: raw.deadline_seconds,
5765 })
5766}
5767
5768fn parse_resource_capabilities(
5774 raw: RawResourceCapabilities,
5775) -> Result<ResourceCapabilities, RuntimeManifestError> {
5776 let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
5777 match value {
5778 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5779 field: format!("capabilities.resources.{field}"),
5780 message: "must be greater than zero".to_string(),
5781 }),
5782 _ => Ok(()),
5783 }
5784 };
5785
5786 reject_zero(raw.max_processes, "max_processes")?;
5787 reject_zero(raw.max_open_files, "max_open_files")?;
5788 reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
5789 reject_zero(raw.cpu_seconds, "cpu_seconds")?;
5790 reject_zero(raw.memory_bytes, "memory_bytes")?;
5791 reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
5792 reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
5793 reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
5794 reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
5795 reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
5796
5797 Ok(ResourceCapabilities {
5798 max_processes: raw.max_processes,
5799 max_open_files: raw.max_open_files,
5800 max_file_size_bytes: raw.max_file_size_bytes,
5801 cpu_seconds: raw.cpu_seconds,
5802 memory_bytes: raw.memory_bytes,
5803 cgroup_memory_bytes: raw.cgroup_memory_bytes,
5804 cgroup_pids_max: raw.cgroup_pids_max,
5805 cgroup_cpu_percent: raw.cgroup_cpu_percent,
5806 cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
5807 workdir_max_bytes: raw.workdir_max_bytes,
5808 })
5809}
5810
5811const PROCESS_DRIVER_REQUIRED: &str =
5815 "missing required field; a transport: process capsule must name the process driver that \
5816 knows how to drive its harness. inference.command no longer selects a harness — it only \
5817 overrides the binary the driver names. Add inference.driver.artifact naming a process \
5818 driver, for example murmur/driver-claude-code";
5819
5820fn parse_inference(
5821 raw: Option<RawInferenceConfig>,
5822) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
5823 let Some(raw) = raw else {
5824 return Ok(None);
5825 };
5826
5827 let transport = raw
5828 .transport
5829 .map(|s| s.trim().to_string())
5830 .filter(|s| !s.is_empty())
5831 .unwrap_or_else(|| "http".to_string());
5832
5833 let compaction = parse_compaction(raw.compaction)?;
5834 let system_prompt = optional_trimmed_string(raw.system_prompt);
5835 let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
5836 let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
5837 let instruction_files = raw
5838 .instruction_files
5839 .map(parse_instruction_files)
5840 .transpose()?;
5841
5842 let prompt_sources_set = [
5843 system_prompt.is_some(),
5844 system_prompt_file.is_some(),
5845 system_prompt_artifact.is_some(),
5846 ]
5847 .iter()
5848 .filter(|&&b| b)
5849 .count();
5850 if prompt_sources_set > 1 {
5851 return Err(RuntimeManifestError::InvalidInferenceConfig {
5852 field: "inference.system_prompt".to_string(),
5853 message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
5854 may be set"
5855 .to_string(),
5856 });
5857 }
5858
5859 if raw.max_turns == Some(0) {
5860 return Err(RuntimeManifestError::InvalidInferenceConfig {
5861 field: "inference.max_turns".to_string(),
5862 message: "must be greater than 0".to_string(),
5863 });
5864 }
5865 let max_turns = raw.max_turns;
5866
5867 if raw.max_task_reopens.is_some() {
5871 return Err(RuntimeManifestError::InvalidInferenceConfig {
5872 field: "inference.max_task_reopens".to_string(),
5873 message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
5874 .to_string(),
5875 });
5876 }
5877
5878 for (present, removed, moved_to) in [
5882 (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
5883 (raw.api_key.is_some(), "api_key", "gateway.api_key"),
5884 ] {
5885 if !present {
5886 continue;
5887 }
5888 let entry = raw
5889 .driver
5890 .as_ref()
5891 .and_then(|driver| driver.artifact.as_deref())
5892 .map(str::trim)
5893 .filter(|name| !name.is_empty())
5894 .map(|name| format!("on the artifacts: entry '{name}'"))
5895 .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
5896 return Err(RuntimeManifestError::InvalidInferenceConfig {
5897 field: format!("inference.{removed}"),
5898 message: format!("was removed; set {moved_to} {entry} instead"),
5899 });
5900 }
5901
5902 match transport.as_str() {
5903 "http" => {
5904 let model = required_inference_field(raw.model, "model")?;
5905
5906 let driver_raw =
5907 raw.driver
5908 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5909 field: "inference.driver".to_string(),
5910 message: "missing required field".to_string(),
5911 })?;
5912 let driver = parse_inference_driver(driver_raw)?;
5913
5914 if raw.max_tokens == Some(0) {
5918 return Err(RuntimeManifestError::InvalidInferenceConfig {
5919 field: "inference.max_tokens".to_string(),
5920 message: "must be greater than 0".to_string(),
5921 });
5922 }
5923 if raw.max_session_tokens == Some(0) {
5924 return Err(RuntimeManifestError::InvalidInferenceConfig {
5925 field: "inference.max_session_tokens".to_string(),
5926 message: "must be greater than 0".to_string(),
5927 });
5928 }
5929 let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
5930 let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
5931 let retry = parse_inference_retry(raw.retry)?;
5932
5933 Ok(Some(InferenceConfig {
5934 transport,
5935 model,
5936 driver: Some(driver),
5937 command: None,
5938 compaction,
5939 system_prompt,
5940 system_prompt_file,
5941 system_prompt_artifact,
5942 instruction_files,
5943 max_turns,
5944 max_tokens: raw.max_tokens,
5945 max_session_tokens: raw.max_session_tokens,
5946 tool_refresh,
5947 alternates,
5948 retry,
5949 }))
5950 }
5951 "process" => {
5952 if raw.max_tokens.is_some() {
5955 return Err(RuntimeManifestError::InvalidInferenceConfig {
5956 field: "inference.max_tokens".to_string(),
5957 message: "is not valid with transport: process".to_string(),
5958 });
5959 }
5960 if raw.tool_refresh.is_some() {
5963 return Err(RuntimeManifestError::InvalidInferenceConfig {
5964 field: "inference.tool_refresh".to_string(),
5965 message: "is not valid with transport: process".to_string(),
5966 });
5967 }
5968 for (present, key) in [
5971 (
5972 compaction.as_ref().is_some_and(|c| c.max_tokens.is_some()),
5973 "max_tokens",
5974 ),
5975 (
5976 compaction
5977 .as_ref()
5978 .is_some_and(|c| c.driver_config.is_some()),
5979 "driver_config",
5980 ),
5981 ] {
5982 if present {
5983 return Err(RuntimeManifestError::InvalidInferenceConfig {
5984 field: format!("inference.compaction.{key}"),
5985 message: "is not valid with transport: process".to_string(),
5986 });
5987 }
5988 }
5989 if raw.alternates.is_some() {
5992 return Err(RuntimeManifestError::InvalidInferenceConfig {
5993 field: "inference.alternates".to_string(),
5994 message: "is not valid with transport: process; the harness owns the \
5995 conversation and the model it runs"
5996 .to_string(),
5997 });
5998 }
5999 if raw.retry.is_some() {
6000 return Err(RuntimeManifestError::InvalidInferenceConfig {
6001 field: "inference.retry".to_string(),
6002 message: "is not valid with transport: process; the harness retries its own \
6003 provider calls"
6004 .to_string(),
6005 });
6006 }
6007
6008 if raw.max_session_tokens == Some(0) {
6012 return Err(RuntimeManifestError::InvalidInferenceConfig {
6013 field: "inference.max_session_tokens".to_string(),
6014 message: "must be greater than 0".to_string(),
6015 });
6016 }
6017
6018 let driver = raw.driver.map(parse_inference_driver).transpose()?;
6019 let command = optional_trimmed_string(raw.command);
6020 if driver.is_none() {
6024 return Err(RuntimeManifestError::InvalidInferenceConfig {
6025 field: "inference.driver.artifact".to_string(),
6026 message: PROCESS_DRIVER_REQUIRED.to_string(),
6027 });
6028 }
6029 let model = optional_trimmed_string(raw.model).unwrap_or_default();
6033
6034 Ok(Some(InferenceConfig {
6035 transport,
6036 model,
6037 driver,
6038 command,
6039 compaction,
6040 system_prompt,
6041 system_prompt_file,
6042 system_prompt_artifact,
6043 instruction_files,
6044 max_turns,
6045 max_tokens: None,
6046 max_session_tokens: raw.max_session_tokens,
6047 tool_refresh: ToolRefresh::default(),
6048 alternates: Vec::new(),
6049 retry: InferenceRetry::default(),
6050 }))
6051 }
6052 other => Err(RuntimeManifestError::InvalidInferenceConfig {
6053 field: "inference.transport".to_string(),
6054 message: format!("unknown value '{other}'"),
6055 }),
6056 }
6057}
6058
6059fn parse_inference_retry(
6063 raw: Option<RawInferenceRetry>,
6064) -> Result<InferenceRetry, RuntimeManifestError> {
6065 let defaults = InferenceRetry::default();
6066 let Some(raw) = raw else {
6067 return Ok(defaults);
6068 };
6069 let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
6070 field: format!("inference.retry.{key}"),
6071 message,
6072 };
6073 let retry = InferenceRetry {
6074 max_retries: raw.max_retries.unwrap_or(defaults.max_retries),
6075 initial_delay_ms: raw.initial_delay_ms.unwrap_or(defaults.initial_delay_ms),
6076 max_delay_ms: raw.max_delay_ms.unwrap_or(defaults.max_delay_ms),
6077 };
6078 if retry.max_retries > InferenceRetry::MAX_RETRIES {
6079 return Err(refuse(
6080 "max_retries",
6081 format!("must be at most {}", InferenceRetry::MAX_RETRIES),
6082 ));
6083 }
6084 if !(1..=InferenceRetry::MAX_INITIAL_DELAY_MS).contains(&retry.initial_delay_ms) {
6085 return Err(refuse(
6086 "initial_delay_ms",
6087 format!(
6088 "must be between 1 and {}",
6089 InferenceRetry::MAX_INITIAL_DELAY_MS
6090 ),
6091 ));
6092 }
6093 if !(1..=InferenceRetry::MAX_DELAY_MS).contains(&retry.max_delay_ms) {
6094 return Err(refuse(
6095 "max_delay_ms",
6096 format!("must be between 1 and {}", InferenceRetry::MAX_DELAY_MS),
6097 ));
6098 }
6099 if retry.max_delay_ms < retry.initial_delay_ms {
6100 return Err(refuse(
6101 "max_delay_ms",
6102 format!(
6103 "must be at least inference.retry.initial_delay_ms ({})",
6104 retry.initial_delay_ms
6105 ),
6106 ));
6107 }
6108 Ok(retry)
6109}
6110
6111fn is_valid_driver_choice_name(name: &str) -> bool {
6114 let mut chars = name.chars();
6115 chars.next().is_some_and(|first| first.is_ascii_lowercase())
6116 && name.len() <= 63
6117 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
6118}
6119
6120fn parse_alternates(
6126 raw: Option<Vec<RawInferenceAlternate>>,
6127 primary_driver: &str,
6128 primary_model: &str,
6129) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
6130 let mut alternates: Vec<InferenceAlternate> = Vec::new();
6131 for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
6132 let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
6133 field: if key.is_empty() {
6134 format!("inference.alternates[{index}]")
6135 } else {
6136 format!("inference.alternates[{index}].{key}")
6137 },
6138 message,
6139 };
6140 let name = optional_trimmed_string(entry.name)
6141 .ok_or_else(|| refuse("name", "missing required field".to_string()))?;
6142 if name == PRIMARY_DRIVER_CHOICE {
6143 return Err(refuse(
6144 "name",
6145 format!(
6146 "'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
6147 inference.model; name the alternate something else"
6148 ),
6149 ));
6150 }
6151 if !is_valid_driver_choice_name(&name) {
6152 return Err(refuse(
6153 "name",
6154 format!(
6155 "'{name}' is not a driver choice name; a name is a lowercase letter followed \
6156 by up to 62 lowercase letters, digits and hyphens"
6157 ),
6158 ));
6159 }
6160 if alternates.iter().any(|alternate| alternate.name == name) {
6161 return Err(refuse(
6162 "name",
6163 format!("'{name}' names more than one alternate"),
6164 ));
6165 }
6166 let model = optional_trimmed_string(entry.model)
6167 .ok_or_else(|| refuse("model", "missing required field".to_string()))?;
6168 let Some(driver) = entry.driver else {
6169 return Err(refuse(
6170 "driver.artifact",
6171 "missing required field".to_string(),
6172 ));
6173 };
6174 if driver
6175 .config
6176 .as_ref()
6177 .is_some_and(|config| !matches!(config, serde_norway::Value::Null))
6178 {
6179 return Err(refuse(
6180 "driver.config",
6181 "is not valid on an alternate; inference.driver.config applies to every \
6182 declared driver, and per-driver settings belong in that driver's \
6183 artifacts[].config"
6184 .to_string(),
6185 ));
6186 }
6187 let driver = optional_trimmed_string(driver.artifact)
6188 .ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
6189 if driver == primary_driver && model == primary_model {
6190 return Err(refuse(
6191 "",
6192 format!(
6193 "'{name}' is driver '{driver}' with model '{model}', which is the primary \
6194 choice; an alternate names a different pair"
6195 ),
6196 ));
6197 }
6198 if let Some(twin) = alternates
6199 .iter()
6200 .find(|alternate| alternate.driver == driver && alternate.model == model)
6201 {
6202 return Err(refuse(
6203 "",
6204 format!(
6205 "'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
6206 already is; an alternate names a different pair",
6207 twin.name
6208 ),
6209 ));
6210 }
6211 alternates.push(InferenceAlternate {
6212 name,
6213 model,
6214 driver,
6215 });
6216 }
6217 Ok(alternates)
6218}
6219
6220fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
6222 let Some(value) = raw else {
6223 return Ok(ToolRefresh::default());
6224 };
6225 ToolRefresh::ALL
6226 .into_iter()
6227 .find(|refresh| refresh.wire_name() == value)
6228 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6229 field: "inference.tool_refresh".to_string(),
6230 message: format!(
6231 "must be one of: {}; got '{value}'",
6232 ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
6233 ),
6234 })
6235}
6236
6237fn parse_context(
6238 raw: Option<RawContextConfig>,
6239) -> Result<Option<ContextConfig>, RuntimeManifestError> {
6240 let Some(raw) = raw else {
6241 return Ok(None);
6242 };
6243
6244 if let Some(max_tokens) = raw.max_tokens {
6245 if max_tokens == 0 {
6246 return Err(RuntimeManifestError::InvalidInferenceConfig {
6247 field: "context.max_tokens".to_string(),
6248 message: "must be greater than 0".to_string(),
6249 });
6250 }
6251 }
6252
6253 let fraction = |field: &str, value: Option<f32>, default: f32| match value {
6254 None => Ok(default),
6255 Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
6256 Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
6257 field: field.to_string(),
6258 message: "must be between 0.0 and 1.0".to_string(),
6259 }),
6260 };
6261
6262 let record = match raw.record.as_deref().map(str::trim) {
6263 None => true,
6264 Some("on") => true,
6265 Some("off") => false,
6266 Some(other) => {
6267 return Err(RuntimeManifestError::InvalidInferenceConfig {
6268 field: "context.record".to_string(),
6269 message: format!("unknown value '{other}'; expected: on, off"),
6270 })
6271 }
6272 };
6273
6274 Ok(Some(ContextConfig {
6275 max_tokens: raw.max_tokens,
6276 record,
6277 record_store: raw.record_store.map(|store| store.trim().to_string()),
6278 seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
6279 seed_overflow_margin: fraction(
6280 "context.seed_overflow_margin",
6281 raw.seed_overflow_margin,
6282 DEFAULT_SEED_OVERFLOW_MARGIN,
6283 )?,
6284 retain: parse_context_retain(raw.retain)?,
6285 images: parse_context_images(raw.images)?,
6286 }))
6287}
6288
6289fn parse_context_images(
6291 raw: Option<RawContextImages>,
6292) -> Result<ContextImagesConfig, RuntimeManifestError> {
6293 let defaults = ContextImagesConfig::default();
6294 let Some(raw) = raw else {
6295 return Ok(defaults);
6296 };
6297 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
6298 field: field.to_string(),
6299 message,
6300 };
6301 let count = |field: &str, value: Option<serde_norway::Value>, default: u32| match value {
6302 None => Ok(default),
6303 Some(value) => value
6304 .as_u64()
6305 .and_then(|n| u32::try_from(n).ok())
6306 .filter(|n| *n >= 1)
6307 .ok_or_else(|| {
6308 let message = match scalar_text(&value) {
6309 Some(text) => format!("must be a whole number of at least 1, got '{text}'"),
6310 None => "must be a whole number of at least 1".to_string(),
6311 };
6312 invalid(field, message)
6313 }),
6314 };
6315 let max_bytes = match raw.max_bytes {
6316 None => defaults.max_bytes,
6317 Some(value) => {
6318 let field = "context.images.max_bytes";
6319 let text = scalar_text(&value)
6320 .ok_or_else(|| invalid(field, format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}")))?;
6321 let parsed = parse_byte_size(&text).map_err(|message| invalid(field, message))?;
6322 if parsed == 0 {
6323 return Err(invalid(
6324 field,
6325 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
6326 ));
6327 }
6328 parsed
6329 }
6330 };
6331 Ok(ContextImagesConfig {
6332 keep: count("context.images.keep", raw.keep, defaults.keep)?,
6333 max_bytes,
6334 max_per_turn: count(
6335 "context.images.max_per_turn",
6336 raw.max_per_turn,
6337 defaults.max_per_turn,
6338 )?,
6339 })
6340}
6341
6342const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
6344
6345const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
6347
6348const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
6351 keep everything, which is what no policy means";
6352
6353fn parse_retain_block(
6359 block: &str,
6360 count_key: &str,
6361 accepted: &[&str],
6362 raw: serde_norway::Value,
6363 invalid: &dyn Fn(String, String) -> RuntimeManifestError,
6364) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
6365 let mapping = match raw {
6366 serde_norway::Value::Mapping(mapping) => mapping,
6367 serde_norway::Value::Null => {
6369 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
6370 }
6371 other => {
6372 return Err(invalid(
6373 block.to_string(),
6374 format!(
6375 "must be a block of keys ({}), got '{other:?}'",
6376 accepted.join(", ")
6377 ),
6378 ))
6379 }
6380 };
6381 if mapping.is_empty() {
6382 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
6383 }
6384
6385 let mut count: Option<u32> = None;
6386 let mut max_age_secs: Option<u64> = None;
6387 for (key, value) in &mapping {
6388 let key = key.as_str().unwrap_or_default();
6389 let field = format!("{block}.{key}");
6390 if key == count_key {
6391 let parsed = value
6392 .as_u64()
6393 .and_then(|n| u32::try_from(n).ok())
6394 .ok_or_else(|| {
6395 invalid(
6396 field.clone(),
6397 "must be a whole number of at least 1".to_string(),
6398 )
6399 })?;
6400 if parsed == 0 {
6401 return Err(invalid(
6402 field,
6403 "must be at least 1 — a limit of zero would delete what retention exists \
6404 to keep; remove the key to leave it unbounded"
6405 .to_string(),
6406 ));
6407 }
6408 count = Some(parsed);
6409 } else if key == "max_age" {
6410 let text = scalar_text(value).ok_or_else(|| {
6411 invalid(
6412 field.clone(),
6413 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
6414 )
6415 })?;
6416 let parsed =
6417 parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
6418 if parsed == 0 {
6419 return Err(invalid(
6420 field,
6421 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
6422 ));
6423 }
6424 max_age_secs = Some(parsed);
6425 } else {
6426 return Err(invalid(
6427 block.to_string(),
6428 format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
6429 ));
6430 }
6431 }
6432 Ok((count, max_age_secs))
6433}
6434
6435fn parse_trace_retain(
6436 raw: Option<serde_norway::Value>,
6437) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
6438 let Some(raw) = raw else {
6439 return Ok(None);
6440 };
6441 let (max_sessions, max_age_secs) = parse_retain_block(
6442 "trace.retain",
6443 "max_sessions",
6444 &TRACE_RETAIN_KEYS,
6445 raw,
6446 &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
6447 )?;
6448 Ok(Some(TraceRetainConfig {
6449 max_sessions,
6450 max_age_secs,
6451 }))
6452}
6453
6454fn parse_context_retain(
6455 raw: Option<serde_norway::Value>,
6456) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
6457 let Some(raw) = raw else {
6458 return Ok(None);
6459 };
6460 let (max_messages, max_age_secs) = parse_retain_block(
6461 "context.retain",
6462 "max_messages",
6463 &CONTEXT_RETAIN_KEYS,
6464 raw,
6465 &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
6466 )?;
6467 Ok(Some(ContextRetainConfig {
6468 max_messages,
6469 max_age_secs,
6470 }))
6471}
6472
6473fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
6474 raw.map(|raw| ObservabilityConfig {
6475 otel_endpoint: raw
6476 .otel_endpoint
6477 .map(|value| value.trim().to_string())
6478 .filter(|value| !value.is_empty()),
6479 eval: raw.eval.map(parse_eval_config),
6480 })
6481}
6482
6483fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
6484 let scorers = raw
6485 .scorers
6486 .into_iter()
6487 .filter_map(|raw| {
6488 let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
6489 match raw.scorer_type.as_str() {
6490 "exit_ok" => Some(ScorerConfig::ExitOk { name }),
6491 "max_turns" => Some(ScorerConfig::MaxTurns {
6492 name,
6493 max: raw.max.unwrap_or(10) as u32,
6494 }),
6495 "max_tokens" => Some(ScorerConfig::MaxTokens {
6496 name,
6497 max: raw.max.unwrap_or(100_000),
6498 }),
6499 "tool_sequence" => Some(ScorerConfig::ToolSequence {
6500 name,
6501 expected: raw.expected.unwrap_or_default(),
6502 }),
6503 "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
6504 other => {
6505 crate::warn_line(format_args!(
6506 "[murmur-artifact] unknown scorer type '{other}' — skipping"
6507 ));
6508 None
6509 }
6510 }
6511 })
6512 .collect();
6513
6514 EvalConfig {
6515 dataset_id: raw.dataset_id,
6516 scorers,
6517 }
6518}
6519
6520fn parse_compaction(
6521 raw: Option<RawCompactionConfig>,
6522) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
6523 let Some(raw) = raw else {
6524 return Ok(None);
6525 };
6526
6527 if let Some(threshold) = raw.threshold {
6528 if threshold <= 0.0 || threshold > 1.0 {
6529 return Err(RuntimeManifestError::InvalidInferenceConfig {
6530 field: "inference.compaction.threshold".to_string(),
6531 message: format!("must be in (0.0, 1.0] but got {threshold}"),
6532 });
6533 }
6534 }
6535
6536 if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
6537 return Err(RuntimeManifestError::InvalidInferenceConfig {
6538 field: "inference.compaction.system_prompt".to_string(),
6539 message: "at most one of inference.compaction.system_prompt, \
6540 inference.compaction.system_prompt_file may be set"
6541 .to_string(),
6542 });
6543 }
6544
6545 if raw.max_tokens == Some(0) {
6546 return Err(RuntimeManifestError::InvalidInferenceConfig {
6547 field: "inference.compaction.max_tokens".to_string(),
6548 message: "must be greater than 0".to_string(),
6549 });
6550 }
6551 let driver_config =
6552 driver_config_json(raw.driver_config, "inference.compaction.driver_config")?;
6553
6554 Ok(Some(CompactionConfig {
6555 threshold: raw.threshold,
6556 model: raw.model,
6557 system_prompt: raw.system_prompt,
6558 system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
6559 dump_summaries: raw.dump_summaries,
6562 max_tokens: raw.max_tokens,
6563 driver_config,
6564 }))
6565}
6566
6567fn optional_trimmed_string(value: Option<String>) -> Option<String> {
6568 value
6569 .map(|s| s.trim().to_string())
6570 .filter(|s| !s.is_empty())
6571}
6572
6573fn required_inference_field(
6574 value: Option<String>,
6575 key: &str,
6576) -> Result<String, RuntimeManifestError> {
6577 value
6578 .map(|value| value.trim().to_string())
6579 .filter(|value| !value.is_empty())
6580 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6581 field: format!("inference.{key}"),
6582 message: "missing required field".to_string(),
6583 })
6584}
6585
6586fn parse_artifact_gateway(
6594 index: usize,
6595 name: &str,
6596 raw: Option<RawArtifactGateway>,
6597 secrets: SecretResolution,
6598) -> Result<ArtifactGateway, RuntimeManifestError> {
6599 let (endpoint, api_key, keyless) = match raw {
6600 Some(raw) => (
6601 optional_trimmed_string(raw.endpoint),
6602 raw.api_key,
6603 raw.keyless,
6604 ),
6605 None => (None, None, None),
6606 };
6607 let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
6608 index,
6609 message: format!(
6610 "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
6611 the upstream URL the runtime sends this artifact's keyed requests to"
6612 ),
6613 })?;
6614 validate_gateway_endpoint(name, &endpoint)
6615 .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
6616
6617 let keyless = match keyless {
6618 None | Some(serde_norway::Value::Null) | Some(serde_norway::Value::Bool(false)) => false,
6619 Some(serde_norway::Value::Bool(true)) => true,
6620 Some(_) => {
6621 return Err(RuntimeManifestError::InvalidArtifact {
6622 index,
6623 message: format!(
6624 "artifact '{name}' declares gateway.keyless with a value that is not a \
6625 boolean; write keyless: true for an upstream that takes no key"
6626 ),
6627 })
6628 }
6629 };
6630 if keyless && api_key.is_some() {
6631 return Err(RuntimeManifestError::InvalidArtifact {
6632 index,
6633 message: format!(
6634 "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
6635 gateway either presents a key or is keyless"
6636 ),
6637 });
6638 }
6639 if keyless {
6640 return Ok(ArtifactGateway {
6641 endpoint,
6642 api_key: None,
6643 keyless: true,
6644 });
6645 }
6646
6647 let written = api_key
6648 .flatten()
6649 .map(|value| value.trim().to_string())
6650 .filter(|value| !value.is_empty());
6651 let Some(written) = written else {
6652 return Err(RuntimeManifestError::GatewayWithoutCredential {
6653 index,
6654 name: name.to_string(),
6655 endpoint,
6656 });
6657 };
6658 let api_key = match secrets {
6659 SecretResolution::Skip => None,
6660 SecretResolution::Resolve => Some(match parse_env_reference(&written) {
6661 Some(variable) => ApiKeyReference::Environment(variable.to_string()),
6662 None => ApiKeyReference::Literal(written),
6663 }),
6664 };
6665
6666 Ok(ArtifactGateway {
6667 endpoint,
6668 api_key,
6669 keyless: false,
6670 })
6671}
6672
6673fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
6700 let shown = mask_endpoint_userinfo(endpoint);
6701 let refuse =
6702 |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
6703 if endpoint.contains("${") {
6704 return Err(refuse(
6705 "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
6706 literally"
6707 .to_string(),
6708 ));
6709 }
6710 let url = Url::parse(endpoint)
6711 .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
6712 let host = match url.scheme() {
6713 "http" | "https" => url
6714 .host_str()
6715 .filter(|host| !host.is_empty())
6716 .ok_or_else(|| refuse("which has no host".to_string()))?,
6717 other => {
6718 return Err(refuse(format!(
6719 "whose scheme '{other}' is unsupported — expected http or https"
6720 )))
6721 }
6722 };
6723 if !url.username().is_empty() || url.password().is_some() {
6724 return Err(refuse(format!(
6725 "which carries userinfo before '@' — the gateway never sends it, and this URL \
6726 addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
6727 the key through gateway.api_key"
6728 )));
6729 }
6730 if url.query().is_some() {
6731 return Err(refuse(
6732 "which carries a query — the gateway keeps each request's own query, so write the \
6733 upstream without one"
6734 .to_string(),
6735 ));
6736 }
6737 if url.fragment().is_some() {
6738 return Err(refuse(
6739 "which carries a fragment — write the upstream without one".to_string(),
6740 ));
6741 }
6742 if url.scheme() == "http" {
6743 let bare = host.trim_start_matches('[').trim_end_matches(']');
6744 let is_loopback = bare == "localhost"
6745 || bare
6746 .parse::<IpAddr>()
6747 .map(|ip| ip.is_loopback())
6748 .unwrap_or(false);
6749 if !is_loopback {
6750 return Err(refuse(format!(
6751 "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
6752 remote upstream, or http://localhost or a loopback IP literal for a local one"
6753 )));
6754 }
6755 }
6756 Ok(())
6757}
6758
6759fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
6763 let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
6764 let rest = &endpoint[authority_start..];
6765 let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
6766 match rest[..authority_len].rfind('@') {
6767 Some(at) => format!(
6768 "{}***{}",
6769 &endpoint[..authority_start],
6770 &endpoint[authority_start + at..]
6771 )
6772 .into(),
6773 None => endpoint.into(),
6774 }
6775}
6776
6777fn validate_driver_gateways(
6784 artifacts: &[RuntimeArtifact],
6785 inference: Option<&InferenceConfig>,
6786) -> Result<(), RuntimeManifestError> {
6787 let configured_driver = inference
6788 .filter(|inference| inference.transport == "http")
6789 .and_then(|inference| inference.driver.as_ref())
6790 .map(|driver| driver.artifact.as_str());
6791 let alternate_drivers: Vec<&str> = inference
6792 .map(|inference| {
6793 inference
6794 .alternates
6795 .iter()
6796 .map(|alternate| alternate.driver.as_str())
6797 .collect()
6798 })
6799 .unwrap_or_default();
6800 let under_process = inference.is_some_and(|inference| inference.transport == "process");
6801
6802 if let Some(driver) = configured_driver {
6805 if !artifacts.iter().any(|artifact| artifact.name == driver) {
6806 return Err(RuntimeManifestError::InvalidInferenceConfig {
6807 field: "inference.driver.artifact".to_string(),
6808 message: not_declared_message(artifacts, driver),
6809 });
6810 }
6811 }
6812
6813 for (index, artifact) in artifacts.iter().enumerate() {
6814 if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
6815 continue;
6816 }
6817 if under_process {
6818 return Err(RuntimeManifestError::InvalidArtifact {
6819 index,
6820 message: format!(
6821 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
6822 valid with transport: process — the CLI subprocess holds its own \
6823 credentials and a process driver is granted nothing",
6824 artifact.name
6825 ),
6826 });
6827 }
6828 if configured_driver != Some(artifact.name.as_str())
6829 && !alternate_drivers.contains(&artifact.name.as_str())
6830 {
6831 return Err(RuntimeManifestError::InvalidArtifact {
6832 index,
6833 message: format!(
6834 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
6835 the configured inference driver; a driver's gateway is accepted only on the \
6836 entry inference.driver.artifact names under transport: http, or on one an \
6837 inference.alternates entry names",
6838 artifact.name
6839 ),
6840 });
6841 }
6842 }
6843
6844 if let Some(driver) = configured_driver {
6845 let Some((index, entry)) = artifacts
6846 .iter()
6847 .enumerate()
6848 .find(|(_, artifact)| artifact.name == driver)
6849 else {
6850 return Err(RuntimeManifestError::InvalidInferenceConfig {
6851 field: "inference.driver.artifact".to_string(),
6852 message: not_declared_message(artifacts, driver),
6853 });
6854 };
6855 if entry.gateway.is_none() {
6856 return Err(RuntimeManifestError::InvalidArtifact {
6857 index,
6858 message: format!(
6859 "artifact '{driver}' is the transport: http inference driver but declares no \
6860 gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
6861 on its entry"
6862 ),
6863 });
6864 }
6865 }
6866 Ok(())
6867}
6868
6869fn validate_driver_choices(
6875 artifacts: &[RuntimeArtifact],
6876 inference: Option<&InferenceConfig>,
6877) -> Result<(), RuntimeManifestError> {
6878 let Some(inference) = inference else {
6879 return Ok(());
6880 };
6881 let primary_driver = inference
6882 .driver
6883 .as_ref()
6884 .map(|driver| driver.artifact.as_str());
6885 for (index, alternate) in inference.alternates.iter().enumerate() {
6886 let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
6887 field: format!("inference.alternates[{index}].driver.artifact"),
6888 message,
6889 };
6890 let driver = alternate.driver.as_str();
6891 let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
6892 return Err(refuse(not_declared_message(artifacts, driver)));
6893 };
6894 if entry.runtime != ArtifactRuntime::Driver {
6895 return Err(refuse(format!(
6896 "artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
6897 runtime: driver entry",
6898 entry.runtime.as_str()
6899 )));
6900 }
6901 if entry.gateway.is_none() && primary_driver != Some(driver) {
6902 return Err(refuse(format!(
6903 "artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
6904 its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
6905 URL>, api_key: ... }}' on its entry"
6906 )));
6907 }
6908 }
6909 Ok(())
6910}
6911
6912fn validate_alternates_selectable(
6915 inference: Option<&InferenceConfig>,
6916 control: Option<&ControlConfig>,
6917) -> Result<(), RuntimeManifestError> {
6918 if inference.is_none_or(|inference| inference.alternates.is_empty()) {
6919 return Ok(());
6920 }
6921 let selectable = control.is_some_and(|control| {
6922 control.controller_may_set(ControllableSetting::InferenceDriver)
6923 || control.agent_may_set(ControllableSetting::InferenceDriver)
6924 });
6925 if selectable {
6926 return Ok(());
6927 }
6928 Err(RuntimeManifestError::InvalidInferenceConfig {
6929 field: "inference.alternates".to_string(),
6930 message: "declares alternates that nothing could ever select; list inference.driver in \
6931 control.settings for a controller to switch, or in control.agent_settings for \
6932 the agent to"
6933 .to_string(),
6934 })
6935}
6936
6937fn validate_process_driver_entry(
6945 artifacts: &[RuntimeArtifact],
6946 inference: Option<&InferenceConfig>,
6947) -> Result<(), RuntimeManifestError> {
6948 let Some(driver) = inference
6949 .filter(|inference| inference.transport == "process")
6950 .and_then(|inference| inference.driver.as_ref())
6951 .map(|driver| driver.artifact.as_str())
6952 else {
6953 return Ok(());
6954 };
6955 let Some((index, entry)) = artifacts
6956 .iter()
6957 .enumerate()
6958 .find(|(_, artifact)| artifact.name == driver)
6959 else {
6960 return Err(RuntimeManifestError::InvalidInferenceConfig {
6961 field: "inference.driver.artifact".to_string(),
6962 message: not_declared_message(artifacts, driver),
6963 });
6964 };
6965 if entry.runtime != ArtifactRuntime::Driver {
6966 return Err(RuntimeManifestError::InvalidArtifact {
6967 index,
6968 message: format!(
6969 "artifact '{driver}' is the transport: process driver and must be declared with \
6970 runtime: driver"
6971 ),
6972 });
6973 }
6974 if entry.capabilities.is_some() || entry.config.is_some() {
6975 return Err(RuntimeManifestError::InvalidArtifact {
6976 index,
6977 message: format!(
6978 "artifact '{driver}' is the transport: process driver, which is granted nothing; \
6979 remove capabilities:/config: from its entry — driver settings go in \
6980 inference.driver.config"
6981 ),
6982 });
6983 }
6984 Ok(())
6985}
6986
6987fn parse_inference_driver(
6988 raw: RawInferenceDriver,
6989) -> Result<InferenceDriver, RuntimeManifestError> {
6990 let artifact = raw
6991 .artifact
6992 .map(|value| value.trim().to_string())
6993 .filter(|value| !value.is_empty())
6994 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6995 field: "inference.driver.artifact".to_string(),
6996 message: "missing required field".to_string(),
6997 })?;
6998
6999 let config = driver_config_json(raw.config, "inference.driver.config")?;
7000
7001 Ok(InferenceDriver { artifact, config })
7002}
7003
7004fn driver_config_json(
7007 raw: Option<serde_norway::Value>,
7008 field: &str,
7009) -> Result<Option<String>, RuntimeManifestError> {
7010 let Some(raw) = raw else {
7011 return Ok(None);
7012 };
7013
7014 if matches!(raw, serde_norway::Value::Null) {
7015 return Ok(None);
7016 }
7017
7018 if !matches!(raw, serde_norway::Value::Mapping(_)) {
7019 return Err(RuntimeManifestError::InvalidInferenceConfig {
7020 field: field.to_string(),
7021 message: "expected mapping/object".to_string(),
7022 });
7023 }
7024
7025 let value =
7026 serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
7027 field: field.to_string(),
7028 message: format!("failed to convert to JSON: {err}"),
7029 })?;
7030
7031 serde_json::to_string(&value).map(Some).map_err(|err| {
7032 RuntimeManifestError::InvalidInferenceConfig {
7033 field: field.to_string(),
7034 message: format!("failed to encode JSON: {err}"),
7035 }
7036 })
7037}
7038
7039fn parse_env_reference(value: &str) -> Option<&str> {
7040 if !value.starts_with("${") || !value.ends_with('}') {
7041 return None;
7042 }
7043
7044 let variable = &value[2..value.len() - 1];
7045 is_valid_env_variable(variable).then_some(variable)
7046}
7047
7048fn is_valid_env_variable(value: &str) -> bool {
7049 let mut chars = value.chars();
7050 let Some(first) = chars.next() else {
7051 return false;
7052 };
7053
7054 if !(first == '_' || first.is_ascii_uppercase()) {
7055 return false;
7056 }
7057
7058 chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
7059}
7060
7061#[cfg(test)]
7062mod tests {
7063 use super::*;
7064 use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
7065
7066 fn manifest_with_trace(block: &str) -> String {
7067 format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
7068 }
7069
7070 fn lifecycle(task_acceptance: TaskAcceptance, queue_depth: usize) -> LifecycleConfig {
7071 LifecycleConfig {
7072 task_acceptance,
7073 queue_depth,
7074 ..Default::default()
7075 }
7076 }
7077
7078 #[test]
7079 fn tasks_held_at_once_under_queue_is_one_running_plus_queue_depth_waiting() {
7080 assert_eq!(lifecycle(TaskAcceptance::Queue, 1).tasks_held_at_once(), 2);
7081 assert_eq!(lifecycle(TaskAcceptance::Queue, 9).tasks_held_at_once(), 10);
7082 }
7083
7084 #[test]
7085 fn queue_has_room_is_false_only_for_a_queue_at_depth_zero() {
7086 assert!(!lifecycle(TaskAcceptance::Queue, 0).queue_has_room());
7087 assert!(lifecycle(TaskAcceptance::Queue, 1).queue_has_room());
7088 assert!(lifecycle(TaskAcceptance::Single, 0).queue_has_room());
7089 assert!(lifecycle(TaskAcceptance::None, 0).queue_has_room());
7090 assert!(lifecycle(TaskAcceptance::Single, 3).queue_has_room());
7091 }
7092
7093 fn parse_lifecycle(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
7094 RuntimeManifest::from_yaml_str(&format!(
7095 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n{block}"
7096 ))
7097 }
7098
7099 #[test]
7100 fn queue_depth_zero_under_queue_is_refused_at_load() {
7101 let error = parse_lifecycle(" task_acceptance: queue\n queue_depth: 0\n").unwrap_err();
7102 assert!(
7103 matches!(error, RuntimeManifestError::QueueWithoutRoom),
7104 "{error:?}"
7105 );
7106 assert_eq!(
7107 error.to_string(),
7108 "murmur.yaml: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves \
7109 task_acceptance: queue no room for a waiting task"
7110 );
7111 }
7112
7113 #[test]
7115 fn queue_depth_zero_outside_queue_still_parses() {
7116 for block in [
7117 " task_acceptance: single\n queue_depth: 0\n",
7118 " task_acceptance: none\n queue_depth: 0\n",
7119 " queue_depth: 0\n",
7120 ] {
7121 let lifecycle = parse_lifecycle(block)
7122 .unwrap_or_else(|error| panic!("{block:?}: {error}"))
7123 .effective_lifecycle();
7124 assert_eq!(lifecycle.queue_depth, 0, "{block:?}");
7125 }
7126 let queue = parse_lifecycle(" task_acceptance: queue\n queue_depth: 1\n")
7127 .unwrap()
7128 .effective_lifecycle();
7129 assert_eq!(queue.task_acceptance, TaskAcceptance::Queue);
7130 assert_eq!(queue.queue_depth, 1);
7131 }
7132
7133 #[test]
7134 fn tasks_held_at_once_under_single_is_one_whatever_the_depth() {
7135 assert_eq!(lifecycle(TaskAcceptance::Single, 1).tasks_held_at_once(), 1);
7136 assert_eq!(lifecycle(TaskAcceptance::Single, 5).tasks_held_at_once(), 1);
7137 }
7138
7139 #[test]
7140 fn tasks_held_at_once_under_none_is_zero_whatever_the_depth() {
7141 assert_eq!(lifecycle(TaskAcceptance::None, 1).tasks_held_at_once(), 0);
7142 assert_eq!(lifecycle(TaskAcceptance::None, 5).tasks_held_at_once(), 0);
7143 }
7144
7145 #[test]
7148 fn tasks_held_at_once_of_the_default_lifecycle_and_default_depth() {
7149 assert_eq!(LifecycleConfig::default().tasks_held_at_once(), 1);
7150 let queue = RuntimeManifest::from_yaml_str(
7151 "name: cap\nversion: 0.0.1\nlifecycle:\n task_acceptance: queue\n",
7152 )
7153 .unwrap()
7154 .effective_lifecycle();
7155 assert_eq!(queue.queue_depth, 1);
7156 assert_eq!(queue.tasks_held_at_once(), 2);
7157 }
7158
7159 #[test]
7162 fn absent_trace_block_stays_absent_and_defaults_to_meta() {
7163 let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
7164 assert_eq!(manifest.trace, None);
7165 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
7166 }
7167
7168 #[test]
7169 fn each_capture_mode_parses_from_the_trace_block() {
7170 for (yaml, expected) in [
7171 ("none", TraceCapture::None),
7172 ("meta", TraceCapture::Meta),
7173 ("content", TraceCapture::Content),
7174 ] {
7175 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
7176 " capture: {yaml}\n"
7177 )))
7178 .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
7179 assert_eq!(
7180 manifest.trace,
7181 Some(TraceConfig {
7182 capture: expected,
7183 retain: None
7184 })
7185 );
7186 }
7187 }
7188
7189 #[test]
7195 fn no_retain_block_anywhere_parses_as_no_policy() {
7196 let manifest = RuntimeManifest::from_yaml_str(
7197 "name: cap\nversion: 0.0.1\ntrace:\n capture: content\ncontext:\n record: on\n",
7198 )
7199 .unwrap();
7200 assert_eq!(manifest.trace.unwrap().retain, None);
7201 assert_eq!(manifest.context.unwrap().retain, None);
7202 }
7203
7204 #[test]
7205 fn trace_retain_parses_both_keys_and_the_day_suffix() {
7206 let manifest = RuntimeManifest::from_yaml_str(
7207 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 50\n max_age: 14d\n",
7208 )
7209 .unwrap();
7210 assert_eq!(
7211 manifest.trace.unwrap().retain,
7212 Some(TraceRetainConfig {
7213 max_sessions: Some(50),
7214 max_age_secs: Some(14 * 86_400),
7215 })
7216 );
7217 }
7218
7219 fn context_images_of(block: &str) -> Result<ContextImagesConfig, RuntimeManifestError> {
7220 RuntimeManifest::from_yaml_str(&format!(
7221 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n{block}"
7222 ))
7223 .map(|manifest| manifest.context.unwrap().images)
7224 }
7225
7226 #[test]
7227 fn context_images_defaults_apply_without_the_block_and_to_each_absent_key() {
7228 let defaults = ContextImagesConfig {
7229 keep: 5,
7230 max_bytes: 5 * 1024 * 1024,
7231 max_per_turn: 5,
7232 };
7233 assert_eq!(ContextImagesConfig::default(), defaults);
7234 assert_eq!(context_images_of("").unwrap(), defaults);
7235 assert_eq!(
7236 context_images_of(" images:\n keep: 2\n").unwrap(),
7237 ContextImagesConfig {
7238 keep: 2,
7239 ..defaults
7240 }
7241 );
7242 }
7243
7244 #[test]
7245 fn context_images_parses_every_key() {
7246 assert_eq!(
7247 context_images_of(" images:\n keep: 1\n max_bytes: 1Ki\n max_per_turn: 9\n")
7248 .unwrap(),
7249 ContextImagesConfig {
7250 keep: 1,
7251 max_bytes: 1024,
7252 max_per_turn: 9,
7253 }
7254 );
7255 assert_eq!(
7256 context_images_of(" images:\n max_bytes: 4096\n")
7257 .unwrap()
7258 .max_bytes,
7259 4096
7260 );
7261 }
7262
7263 #[test]
7264 fn context_images_refuses_zero_and_a_decimal_byte_suffix() {
7265 for (block, field) in [
7266 (" images:\n keep: 0\n", "context.images.keep"),
7267 (
7268 " images:\n max_per_turn: 0\n",
7269 "context.images.max_per_turn",
7270 ),
7271 (" images:\n max_bytes: 0\n", "context.images.max_bytes"),
7272 (
7273 " images:\n max_bytes: 10MB\n",
7274 "context.images.max_bytes",
7275 ),
7276 (" images:\n keep: many\n", "context.images.keep"),
7277 ] {
7278 match context_images_of(block) {
7279 Err(RuntimeManifestError::InvalidInferenceConfig { field: got, .. }) => {
7280 assert_eq!(got, field, "{block}")
7281 }
7282 other => panic!("{block}: expected a refusal naming {field}, got {other:?}"),
7283 }
7284 }
7285 let error = context_images_of(" images:\n keep: 0\n").unwrap_err();
7286 assert!(
7287 error.to_string().contains("got '0'"),
7288 "the value is named as written: {error}"
7289 );
7290 }
7291
7292 #[test]
7293 fn context_images_unknown_key_is_suggested() {
7294 assert_eq!(
7295 unknown_keys_of("name: cap\nversion: 0.1.0\ncontext:\n images:\n kep: 3\n"),
7296 vec![(
7297 "kep".to_string(),
7298 "context.images".to_string(),
7299 Some("keep".to_string())
7300 )]
7301 );
7302 }
7303
7304 #[test]
7305 fn context_retain_parses_both_keys_and_either_alone() {
7306 let both = RuntimeManifest::from_yaml_str(
7307 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n max_messages: 2000\n max_age: 90d\n",
7308 )
7309 .unwrap();
7310 assert_eq!(
7311 both.context.unwrap().retain,
7312 Some(ContextRetainConfig {
7313 max_messages: Some(2000),
7314 max_age_secs: Some(90 * 86_400),
7315 })
7316 );
7317
7318 let count_only = RuntimeManifest::from_yaml_str(
7319 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 10\n",
7320 )
7321 .unwrap();
7322 assert_eq!(
7323 count_only.context.unwrap().retain,
7324 Some(ContextRetainConfig {
7325 max_messages: Some(10),
7326 max_age_secs: None,
7327 })
7328 );
7329 }
7330
7331 #[test]
7334 fn an_empty_retain_block_is_refused_naming_the_block() {
7335 for (yaml, expect_trace) in [
7336 (
7337 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain: {}\n",
7338 true,
7339 ),
7340 (
7341 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n",
7342 true,
7343 ),
7344 (
7345 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain: {}\n",
7346 false,
7347 ),
7348 (
7349 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n",
7350 false,
7351 ),
7352 ] {
7353 let err = RuntimeManifest::from_yaml_str(yaml)
7354 .expect_err("an empty retain block must be refused");
7355 let (field, message) = match (&err, expect_trace) {
7356 (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
7357 (field, message)
7358 }
7359 (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
7360 (field, message)
7361 }
7362 _ => panic!("wrong variant for {yaml:?}: {err:?}"),
7363 };
7364 assert_eq!(
7365 field,
7366 if expect_trace {
7367 "trace.retain"
7368 } else {
7369 "context.retain"
7370 }
7371 );
7372 assert!(message.contains("at least one key"), "{message}");
7373 }
7374 }
7375
7376 #[test]
7379 fn a_zero_retain_limit_is_refused_naming_the_key() {
7380 let trace = RuntimeManifest::from_yaml_str(
7381 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 0\n",
7382 )
7383 .expect_err("max_sessions: 0 must be refused");
7384 assert!(
7385 matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
7386 if field == "trace.retain.max_sessions"),
7387 "{trace:?}"
7388 );
7389
7390 let context = RuntimeManifest::from_yaml_str(
7391 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 0\n",
7392 )
7393 .expect_err("max_messages: 0 must be refused");
7394 assert!(
7395 matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
7396 if field == "context.retain.max_messages"),
7397 "{context:?}"
7398 );
7399
7400 let age = RuntimeManifest::from_yaml_str(
7401 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_age: 0\n",
7402 )
7403 .expect_err("max_age: 0 must be refused");
7404 assert!(
7405 matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
7406 if field == "trace.retain.max_age"),
7407 "{age:?}"
7408 );
7409 }
7410
7411 #[test]
7412 fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
7413 let err = RuntimeManifest::from_yaml_str(
7414 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_traces: 3\n",
7415 )
7416 .expect_err("an unknown key must be refused");
7417 match err {
7418 RuntimeManifestError::InvalidTraceConfig { field, message } => {
7419 assert_eq!(field, "trace.retain");
7420 assert!(message.contains("max_traces"), "{message}");
7421 assert!(message.contains("max_sessions"), "{message}");
7422 }
7423 other => panic!("expected InvalidTraceConfig, got {other:?}"),
7424 }
7425 }
7426
7427 #[test]
7428 fn an_unparseable_retain_duration_reports_the_accepted_form() {
7429 let err = RuntimeManifest::from_yaml_str(
7430 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_age: 5 weeks\n",
7431 )
7432 .expect_err("'5 weeks' must be refused");
7433 match err {
7434 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
7435 assert_eq!(field, "context.retain.max_age");
7436 assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
7437 }
7438 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
7439 }
7440 }
7441
7442 #[test]
7443 fn unparseable_capture_value_names_the_field_and_accepted_values() {
7444 let err = RuntimeManifest::from_yaml_str(&manifest_with_trace(" capture: verbose\n"))
7445 .expect_err("'verbose' is not a capture mode");
7446 let rendered = err.to_string();
7447 assert!(rendered.contains("trace.capture"), "{rendered}");
7448 for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
7449 assert!(rendered.contains(accepted), "{rendered}");
7450 }
7451 }
7452
7453 #[test]
7454 fn resources_block_is_optional_and_absent_stays_absent() {
7455 let manifest = RuntimeManifest::from_yaml_str(
7456 r#"
7457name: cap
7458version: 0.0.1
7459capabilities:
7460 shell:
7461 allow: [bash]
7462"#,
7463 )
7464 .unwrap();
7465
7466 let caps = manifest.capabilities.expect("shell block must parse");
7470 assert_eq!(caps.resources, None);
7471 }
7472
7473 #[test]
7474 fn resources_fields_parse_and_each_stays_independently_optional() {
7475 let manifest = RuntimeManifest::from_yaml_str(
7476 r#"
7477name: cap
7478version: 0.0.1
7479capabilities:
7480 resources:
7481 max_processes: 32
7482 max_open_files: 16
7483 max_file_size_bytes: 1048576
7484 cpu_seconds: 30
7485 memory_bytes: 268435456
7486 cgroup_memory_bytes: 536870912
7487 cgroup_pids_max: 64
7488 cgroup_cpu_percent: 50
7489 cgroup_io_bytes_per_sec: 1048576
7490 workdir_max_bytes: 2097152
7491"#,
7492 )
7493 .unwrap();
7494
7495 let resources = manifest
7496 .capabilities
7497 .and_then(|caps| caps.resources)
7498 .expect("resources block must parse");
7499 assert_eq!(resources.max_processes, Some(32));
7500 assert_eq!(resources.max_open_files, Some(16));
7501 assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
7502 assert_eq!(resources.cpu_seconds, Some(30));
7503 assert_eq!(resources.memory_bytes, Some(268_435_456));
7504 assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
7505 assert_eq!(resources.cgroup_pids_max, Some(64));
7506 assert_eq!(resources.cgroup_cpu_percent, Some(50));
7507 assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
7508 assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
7509 }
7510
7511 #[test]
7512 fn state_block_is_optional_and_absent_stays_absent() {
7513 let manifest = RuntimeManifest::from_yaml_str(
7514 r#"
7515name: cap
7516version: 0.0.1
7517artifacts:
7518 - name: local/notes-tool
7519 version: 1.0.0
7520 runtime: tool
7521 capabilities:
7522 filesystem:
7523 scope: cache
7524"#,
7525 )
7526 .unwrap();
7527
7528 let caps = manifest.artifacts[0]
7530 .capabilities
7531 .as_ref()
7532 .expect("filesystem block must parse");
7533 assert_eq!(caps.state, None);
7534 }
7535
7536 #[test]
7540 fn state_block_declared_empty_grants_the_capsule_named_store() {
7541 let manifest = RuntimeManifest::from_yaml_str(
7542 r#"
7543name: cap
7544version: 0.0.1
7545artifacts:
7546 - name: local/notes-tool
7547 version: 1.0.0
7548 runtime: tool
7549 capabilities:
7550 state: {}
7551"#,
7552 )
7553 .unwrap();
7554
7555 let state = manifest.artifacts[0]
7556 .capabilities
7557 .as_ref()
7558 .and_then(|caps| caps.state.as_ref())
7559 .expect("state block must parse");
7560 assert_eq!(state.store, None);
7561 }
7562
7563 #[test]
7564 fn state_store_name_parses_and_stays_independently_optional() {
7565 let manifest = RuntimeManifest::from_yaml_str(
7566 r#"
7567name: cap
7568version: 0.0.1
7569artifacts:
7570 - name: local/notes-tool
7571 version: 1.0.0
7572 runtime: tool
7573 capabilities:
7574 state:
7575 store: " shey "
7576 network:
7577 allow: [https://api.example.com]
7578"#,
7579 )
7580 .unwrap();
7581
7582 let caps = manifest.artifacts[0]
7583 .capabilities
7584 .as_ref()
7585 .expect("capabilities must parse");
7586 assert_eq!(
7589 caps.state.as_ref().and_then(|state| state.store.as_deref()),
7590 Some("shey")
7591 );
7592 assert_eq!(
7594 caps.network
7595 .as_ref()
7596 .map(|network| network.allow.as_slice()),
7597 Some(["https://api.example.com".to_string()].as_slice())
7598 );
7599 assert_eq!(caps.filesystem, None);
7600 }
7601
7602 #[test]
7606 fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
7607 let manifest = RuntimeManifest::from_yaml_str(
7608 r#"
7609name: cap
7610version: 0.0.1
7611artifacts:
7612 - name: local/notes-tool
7613 version: 1.0.0
7614 runtime: tool
7615 capabilities:
7616 state:
7617 store: ""
7618"#,
7619 )
7620 .unwrap();
7621
7622 assert_eq!(
7623 manifest.artifacts[0]
7624 .capabilities
7625 .as_ref()
7626 .and_then(|caps| caps.state.as_ref())
7627 .and_then(|state| state.store.as_deref()),
7628 Some("")
7629 );
7630 }
7631
7632 #[test]
7633 fn resources_partial_block_leaves_undeclared_fields_none() {
7634 let manifest = RuntimeManifest::from_yaml_str(
7635 r#"
7636name: cap
7637version: 0.0.1
7638capabilities:
7639 resources:
7640 max_open_files: 16
7641"#,
7642 )
7643 .unwrap();
7644
7645 let resources = manifest
7646 .capabilities
7647 .and_then(|caps| caps.resources)
7648 .expect("resources block must parse");
7649 assert_eq!(resources.max_open_files, Some(16));
7650 assert_eq!(resources.max_processes, None);
7651 assert_eq!(resources.workdir_max_bytes, None);
7652 }
7653
7654 #[test]
7658 fn resources_zero_is_rejected_on_every_field() {
7659 for field in [
7660 "max_processes",
7661 "max_open_files",
7662 "max_file_size_bytes",
7663 "cpu_seconds",
7664 "memory_bytes",
7665 "cgroup_memory_bytes",
7666 "cgroup_pids_max",
7667 "cgroup_cpu_percent",
7668 "cgroup_io_bytes_per_sec",
7669 "workdir_max_bytes",
7670 ] {
7671 let yaml =
7672 format!("name: cap\nversion: 0.0.1\ncapabilities:\n resources:\n {field}: 0\n");
7673 let error =
7674 RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
7675
7676 match error {
7677 RuntimeManifestError::InvalidCapabilities {
7678 field: reported,
7679 message,
7680 } => {
7681 assert_eq!(reported, format!("capabilities.resources.{field}"));
7682 assert_eq!(message, "must be greater than zero");
7683 }
7684 other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
7685 }
7686 }
7687 }
7688
7689 #[test]
7690 fn artifact_runtime_defaults_to_tool() {
7691 let manifest = RuntimeManifest::from_yaml_str(
7692 r#"
7693name: cap
7694version: 0.0.1
7695artifacts:
7696 - name: local/echo
7697 version: 1.2.3
7698"#,
7699 )
7700 .unwrap();
7701
7702 assert_eq!(manifest.artifacts.len(), 1);
7703 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7704 }
7705
7706 #[test]
7707 fn artifact_runtime_tool_explicit() {
7708 let manifest = RuntimeManifest::from_yaml_str(
7709 r#"
7710name: cap
7711version: 0.0.1
7712artifacts:
7713 - name: local/echo
7714 version: 1.2.3
7715 runtime: tool
7716"#,
7717 )
7718 .unwrap();
7719
7720 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7721 }
7722
7723 #[test]
7724 fn artifact_runtime_wasm_is_error() {
7725 let err = RuntimeManifest::from_yaml_str(
7726 r#"
7727name: cap
7728version: 0.0.1
7729artifacts:
7730 - name: local/echo
7731 version: 1.2.3
7732 runtime: wasm
7733"#,
7734 )
7735 .unwrap_err();
7736
7737 let msg = err.to_string();
7738 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7739 assert!(msg.contains("wasm"), "error was: {msg}");
7740 }
7741
7742 #[test]
7743 fn artifact_runtime_native_is_error() {
7744 let err = RuntimeManifest::from_yaml_str(
7745 r#"
7746name: cap
7747version: 0.0.1
7748artifacts:
7749 - name: local/echo
7750 version: 1.2.3
7751 runtime: native
7752"#,
7753 )
7754 .unwrap_err();
7755
7756 let msg = err.to_string();
7757 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7758 assert!(msg.contains("native"), "error was: {msg}");
7759 }
7760
7761 #[test]
7762 fn invalid_artifact_reports_index() {
7763 let err = RuntimeManifest::from_yaml_str(
7764 r#"
7765name: cap
7766version: 0.0.1
7767artifacts:
7768 - name: ""
7769 version: 1.2.3
7770"#,
7771 )
7772 .unwrap_err();
7773
7774 let msg = err.to_string();
7775 assert!(msg.contains("index 0"));
7776 assert!(msg.contains("missing required field 'name'"));
7777 }
7778
7779 #[test]
7780 fn capabilities_parse_when_all_fields_present() {
7781 let manifest = RuntimeManifest::from_yaml_str(
7782 r#"
7783name: cap
7784version: 0.0.1
7785artifacts:
7786 - name: local/echo
7787 version: 1.2.3
7788capabilities:
7789 network:
7790 allow:
7791 - https://api.anthropic.com
7792 - http://127.0.0.1:8080
7793 filesystem:
7794 scope: ./sandbox
7795"#,
7796 )
7797 .unwrap();
7798
7799 let capabilities = manifest.capabilities.expect("capabilities should exist");
7800 assert_eq!(
7801 capabilities.network.unwrap().allow,
7802 vec![
7803 "https://api.anthropic.com".to_string(),
7804 "http://127.0.0.1:8080".to_string()
7805 ]
7806 );
7807 assert_eq!(
7808 capabilities.filesystem.unwrap().scope,
7809 Some("./sandbox".to_string())
7810 );
7811 }
7812
7813 #[test]
7814 fn env_capabilities_parse_allowlist() {
7815 let manifest = RuntimeManifest::from_yaml_str(
7816 r#"
7817name: cap
7818version: 0.0.1
7819artifacts:
7820 - name: local/echo
7821 version: 1.2.3
7822capabilities:
7823 env:
7824 allow:
7825 - MY_APP_REGION
7826 - GITHUB_TOKEN
7827"#,
7828 )
7829 .unwrap();
7830
7831 let capabilities = manifest.capabilities.expect("capabilities should exist");
7832 assert_eq!(
7833 capabilities.env.unwrap().allow,
7834 vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
7835 );
7836 }
7837
7838 #[test]
7839 fn env_capabilities_absent_when_block_omitted() {
7840 let manifest = RuntimeManifest::from_yaml_str(
7841 r#"
7842name: cap
7843version: 0.0.1
7844artifacts:
7845 - name: local/echo
7846 version: 1.2.3
7847capabilities:
7848 network:
7849 allow:
7850 - https://api.anthropic.com
7851"#,
7852 )
7853 .unwrap();
7854
7855 assert!(manifest.capabilities.unwrap().env.is_none());
7856 }
7857
7858 #[test]
7861 fn env_capabilities_allow_empty_list_is_accepted() {
7862 let manifest = RuntimeManifest::from_yaml_str(
7863 r#"
7864name: cap
7865version: 0.0.1
7866artifacts:
7867 - name: local/echo
7868 version: 1.2.3
7869capabilities:
7870 env: {}
7871"#,
7872 )
7873 .unwrap();
7874
7875 assert_eq!(
7876 manifest.capabilities.unwrap().env,
7877 Some(EnvCapabilities { allow: vec![] })
7878 );
7879 }
7880
7881 #[test]
7882 fn missing_capabilities_block_is_backward_compatible() {
7883 let manifest = RuntimeManifest::from_yaml_str(
7884 r#"
7885name: cap
7886version: 0.0.1
7887artifacts:
7888 - name: local/echo
7889 version: 1.2.3
7890"#,
7891 )
7892 .unwrap();
7893
7894 assert!(manifest.capabilities.is_none());
7895 }
7896
7897 #[test]
7898 fn network_only_capabilities_parse() {
7899 let manifest = RuntimeManifest::from_yaml_str(
7900 r#"
7901name: cap
7902version: 0.0.1
7903artifacts:
7904 - name: local/echo
7905 version: 1.2.3
7906capabilities:
7907 network:
7908 allow:
7909 - https://allowed.example.com
7910"#,
7911 )
7912 .unwrap();
7913
7914 let capabilities = manifest.capabilities.unwrap();
7915 assert_eq!(
7916 capabilities.network.unwrap().allow,
7917 vec!["https://allowed.example.com".to_string()]
7918 );
7919 assert!(capabilities.filesystem.is_none());
7920 }
7921
7922 #[test]
7923 fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
7924 let manifest = RuntimeManifest::from_yaml_str(
7925 r#"
7926name: cap
7927version: 0.0.1
7928artifacts:
7929 - name: local/echo
7930 version: 1.2.3
7931capabilities:
7932 network: {}
7933"#,
7934 )
7935 .unwrap();
7936
7937 let capabilities = manifest.capabilities.unwrap();
7938 assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
7939 }
7940
7941 #[test]
7944 fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
7945 let manifest = RuntimeManifest::from_yaml_str(
7946 r#"
7947name: cap
7948version: 0.0.1
7949artifacts:
7950 - name: local/echo
7951 version: 1.2.3
7952capabilities:
7953 network:
7954 allow:
7955 - https://api.anthropic.com
7956"#,
7957 )
7958 .unwrap();
7959
7960 let network = manifest.capabilities.unwrap().network.unwrap();
7961 assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
7962 assert!(!network.unix_sockets);
7963 }
7964
7965 #[test]
7966 fn network_unix_sockets_parses_explicit_false() {
7967 let manifest = RuntimeManifest::from_yaml_str(
7968 r#"
7969name: cap
7970version: 0.0.1
7971artifacts:
7972 - name: local/echo
7973 version: 1.2.3
7974capabilities:
7975 network:
7976 unix_sockets: false
7977"#,
7978 )
7979 .unwrap();
7980
7981 assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
7982 }
7983
7984 #[test]
7985 fn network_unix_sockets_parses_explicit_true() {
7986 let manifest = RuntimeManifest::from_yaml_str(
7987 r#"
7988name: cap
7989version: 0.0.1
7990artifacts:
7991 - name: local/echo
7992 version: 1.2.3
7993capabilities:
7994 network:
7995 unix_sockets: true
7996"#,
7997 )
7998 .unwrap();
7999
8000 let network = manifest.capabilities.unwrap().network.unwrap();
8001 assert!(network.unix_sockets);
8002 assert_eq!(network.allow, Vec::<String>::new());
8004 }
8005
8006 #[test]
8010 fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
8011 let manifest = RuntimeManifest::from_yaml_str(
8012 r#"
8013name: cap
8014version: 0.0.1
8015capabilities:
8016 filesystem:
8017 read_only:
8018 - tests
8019 - " bench/fixtures "
8020 - ""
8021 - " "
8022"#,
8023 )
8024 .unwrap();
8025
8026 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
8027 assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
8028 }
8029
8030 #[test]
8032 fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
8033 let manifest = RuntimeManifest::from_yaml_str(
8034 r#"
8035name: cap
8036version: 0.0.1
8037capabilities:
8038 filesystem:
8039 scope: workdir
8040"#,
8041 )
8042 .unwrap();
8043
8044 assert!(manifest
8045 .capabilities
8046 .unwrap()
8047 .filesystem
8048 .unwrap()
8049 .read_only
8050 .is_empty());
8051 }
8052
8053 #[test]
8056 fn filesystem_read_only_does_not_judge_path_shape() {
8057 let manifest = RuntimeManifest::from_yaml_str(
8058 r#"
8059name: cap
8060version: 0.0.1
8061capabilities:
8062 filesystem:
8063 read_only:
8064 - /etc
8065 - ../outside
8066"#,
8067 )
8068 .expect("shape is judged by the runtime, not the parser");
8069
8070 assert_eq!(
8071 manifest.capabilities.unwrap().filesystem.unwrap().read_only,
8072 vec!["/etc", "../outside"]
8073 );
8074 }
8075
8076 #[test]
8080 fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
8081 let manifest = RuntimeManifest::from_yaml_str(
8082 r#"
8083name: cap
8084version: 0.0.1
8085artifacts:
8086 - name: local/echo
8087 version: 1.2.3
8088capabilities:
8089 filesystem:
8090 scope: workdir
8091"#,
8092 )
8093 .unwrap();
8094
8095 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
8096 assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
8097 assert!(!filesystem.workdir_exec);
8098 }
8099
8100 #[test]
8105 fn filesystem_block_stays_absent_when_undeclared() {
8106 let manifest = RuntimeManifest::from_yaml_str(
8107 r#"
8108name: cap
8109version: 0.0.1
8110artifacts:
8111 - name: local/echo
8112 version: 1.2.3
8113capabilities:
8114 network:
8115 allow:
8116 - https://api.anthropic.com
8117"#,
8118 )
8119 .unwrap();
8120
8121 assert!(manifest.capabilities.unwrap().filesystem.is_none());
8122 }
8123
8124 #[test]
8125 fn filesystem_workdir_exec_parses_explicit_false() {
8126 let manifest = RuntimeManifest::from_yaml_str(
8127 r#"
8128name: cap
8129version: 0.0.1
8130artifacts:
8131 - name: local/echo
8132 version: 1.2.3
8133capabilities:
8134 filesystem:
8135 workdir_exec: false
8136"#,
8137 )
8138 .unwrap();
8139
8140 assert!(
8141 !manifest
8142 .capabilities
8143 .unwrap()
8144 .filesystem
8145 .unwrap()
8146 .workdir_exec
8147 );
8148 }
8149
8150 #[test]
8151 fn filesystem_workdir_exec_parses_explicit_true() {
8152 let manifest = RuntimeManifest::from_yaml_str(
8153 r#"
8154name: cap
8155version: 0.0.1
8156artifacts:
8157 - name: local/echo
8158 version: 1.2.3
8159capabilities:
8160 filesystem:
8161 workdir_exec: true
8162"#,
8163 )
8164 .unwrap();
8165
8166 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
8167 assert!(filesystem.workdir_exec);
8168 assert_eq!(filesystem.scope, None);
8171 }
8172
8173 #[test]
8174 fn network_internal_port_parses() {
8175 let manifest = RuntimeManifest::from_yaml_str(
8176 r#"
8177name: worker
8178version: 0.1.0
8179artifacts: []
8180
8181network:
8182 internal_port: 58172
8183"#,
8184 )
8185 .unwrap();
8186
8187 assert_eq!(
8188 manifest.network.unwrap().internal_port,
8189 Some(58172),
8190 "network.internal_port should parse to Some(58172)"
8191 );
8192 }
8193
8194 #[test]
8195 fn network_internal_port_absent_when_omitted() {
8196 let manifest = RuntimeManifest::from_yaml_str(
8197 r#"
8198name: worker
8199version: 0.1.0
8200artifacts: []
8201"#,
8202 )
8203 .unwrap();
8204
8205 assert!(manifest.network.is_none());
8206 }
8207
8208 #[test]
8209 fn shell_capabilities_parse_with_allow_and_env_fields() {
8210 let manifest = RuntimeManifest::from_yaml_str(
8211 r#"
8212name: cap
8213version: 0.0.1
8214artifacts: []
8215capabilities:
8216 shell:
8217 allow:
8218 - bash
8219 - git
8220 strip_env:
8221 - "*_TOKEN"
8222 - SECRET_KEY
8223 baseline_env:
8224 - SSH_AUTH_SOCK
8225"#,
8226 )
8227 .unwrap();
8228
8229 let shell = manifest
8230 .capabilities
8231 .unwrap()
8232 .shell
8233 .expect("shell capabilities should be present");
8234 assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
8235 assert_eq!(
8236 shell.strip_env,
8237 Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
8238 );
8239 assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
8240 }
8241
8242 #[test]
8243 fn shell_capabilities_require_non_empty_allowlist() {
8244 let err = RuntimeManifest::from_yaml_str(
8245 r#"
8246name: cap
8247version: 0.0.1
8248artifacts: []
8249capabilities:
8250 shell:
8251 allow: []
8252"#,
8253 )
8254 .unwrap_err();
8255
8256 let msg = err.to_string();
8257 assert!(msg.contains("capabilities.shell.allow"));
8258 assert!(msg.contains("at least one"));
8259 }
8260
8261 fn shell_allow_error(entry: &str) -> String {
8262 let yaml = format!(
8263 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n shell:\n allow: [bash, {}]\n",
8264 serde_json::to_string(entry).unwrap()
8265 );
8266 RuntimeManifest::from_yaml_str(&yaml)
8267 .expect_err(&format!("{entry:?} must be refused"))
8268 .to_string()
8269 }
8270
8271 #[test]
8272 fn shell_allow_refuses_an_entry_that_is_not_a_bare_name() {
8273 for (entry, reason) in [
8274 ("/tmp/uv-tool/bin/uv", "is a path"),
8275 ("bin/uv", "is a path"),
8276 ("..", "names a directory"),
8277 (".", "names a directory"),
8278 ("", "is empty"),
8279 ("a b", "contains whitespace"),
8280 ("uv\t", "contains whitespace"),
8281 ("uv\0", "contains a NUL byte"),
8282 ] {
8283 let message = shell_allow_error(entry);
8284 assert!(
8285 message.contains("'capabilities.shell.allow[1]'"),
8286 "{entry:?}: {message}"
8287 );
8288 assert!(
8289 message.contains(&format!(
8290 "'{}' {reason}; an entry is a bare binary name",
8291 entry.escape_debug()
8292 )),
8293 "{entry:?}: {message}"
8294 );
8295 }
8296 }
8297
8298 #[test]
8299 fn shell_allow_accepts_bare_names() {
8300 let manifest = RuntimeManifest::from_yaml_str(
8301 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n shell:\n allow: [bash, python3.12, git-lfs, \"c++\"]\n",
8302 )
8303 .unwrap();
8304 assert_eq!(
8305 manifest.capabilities.unwrap().shell.unwrap().allow,
8306 ["bash", "python3.12", "git-lfs", "c++"]
8307 );
8308 }
8309
8310 #[test]
8311 fn interpreter_runtime_parses_accepted_shape() {
8312 let manifest = RuntimeManifest::from_yaml_str(
8313 r#"
8314name: cap
8315version: 0.0.1
8316artifacts: []
8317capabilities:
8318 shell:
8319 allow:
8320 - python3
8321 interpreter_runtime:
8322 - binary: python3
8323 dirs:
8324 - path: /usr/lib/python3.11
8325 list_dir: true
8326 - path: /usr/lib/python3.11/lib-dynload
8327 list_dir: false
8328"#,
8329 )
8330 .unwrap();
8331
8332 let shell = manifest.capabilities.unwrap().shell.unwrap();
8333 assert_eq!(
8334 shell.interpreter_runtime,
8335 vec![InterpreterRuntimeGrant {
8336 binary: "python3".to_string(),
8337 dirs: vec![
8338 InterpreterRuntimeDir {
8339 path: "/usr/lib/python3.11".to_string(),
8340 list_dir: true,
8341 },
8342 InterpreterRuntimeDir {
8343 path: "/usr/lib/python3.11/lib-dynload".to_string(),
8344 list_dir: false,
8345 },
8346 ],
8347 }]
8348 );
8349 }
8350
8351 #[test]
8352 fn interpreter_runtime_defaults_to_empty_when_absent() {
8353 let manifest = RuntimeManifest::from_yaml_str(
8354 r#"
8355name: cap
8356version: 0.0.1
8357artifacts: []
8358capabilities:
8359 shell:
8360 allow:
8361 - python3
8362"#,
8363 )
8364 .unwrap();
8365
8366 let shell = manifest.capabilities.unwrap().shell.unwrap();
8367 assert!(shell.interpreter_runtime.is_empty());
8368 }
8369
8370 fn interpreter_runtime_reject(grant_yaml: &str) -> String {
8373 let yaml = format!(
8374 r#"
8375name: cap
8376version: 0.0.1
8377artifacts: []
8378capabilities:
8379 shell:
8380 allow:
8381 - python3
8382 interpreter_runtime:
8383{grant_yaml}
8384"#,
8385 );
8386 RuntimeManifest::from_yaml_str(&yaml)
8387 .expect_err("grant should be rejected")
8388 .to_string()
8389 }
8390
8391 #[test]
8392 fn interpreter_runtime_rejects_binary_not_in_allow() {
8393 let msg = interpreter_runtime_reject(
8394 " - binary: ruby\n dirs:\n - path: /usr/lib/ruby\n list_dir: true\n",
8395 );
8396 assert!(
8397 msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
8398 "{msg}"
8399 );
8400 assert!(msg.contains("ruby"), "{msg}");
8401 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
8402 }
8403
8404 #[test]
8405 fn interpreter_runtime_rejects_relative_path() {
8406 let msg = interpreter_runtime_reject(
8407 " - binary: python3\n dirs:\n - path: usr/lib/python3.11\n list_dir: true\n",
8408 );
8409 assert!(
8410 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
8411 "{msg}"
8412 );
8413 assert!(msg.contains("absolute"), "{msg}");
8414 }
8415
8416 fn manifest_with_plan(block: &str) -> String {
8420 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n{block}")
8421 }
8422
8423 #[test]
8424 fn plan_submit_true_grants_the_capsule() {
8425 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n"))
8426 .expect("a plan block parses");
8427 assert_eq!(
8428 manifest.capabilities.unwrap().plan,
8429 Some(PlanCapabilities { submit: true })
8430 );
8431 }
8432
8433 #[test]
8436 fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
8437 let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: false\n"))
8438 .expect("an explicit denial parses");
8439 assert_eq!(
8440 explicit.capabilities.unwrap().plan,
8441 Some(PlanCapabilities { submit: false })
8442 );
8443
8444 let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
8445 .expect("no capabilities block parses");
8446 assert!(absent.capabilities.is_none());
8447 }
8448
8449 #[test]
8452 fn plan_without_submit_is_rejected() {
8453 let err = RuntimeManifest::from_yaml_str(&manifest_with_plan(" {}\n"))
8454 .expect_err("an empty plan block is refused");
8455 let msg = err.to_string();
8456 assert!(msg.contains("capabilities.plan.submit"), "{msg}");
8457 assert!(msg.contains("never"), "{msg}");
8458 }
8459
8460 #[test]
8462 fn an_unknown_plan_key_is_reported_by_path() {
8463 let manifest =
8464 RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n depth: 3\n"))
8465 .expect("an unknown key is a warning, not a parse error");
8466 assert!(
8467 manifest
8468 .unknown_keys
8469 .iter()
8470 .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
8471 "{:?}",
8472 manifest.unknown_keys
8473 );
8474 }
8475
8476 fn manifest_with_install(block: &str) -> String {
8480 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n install:\n{block}")
8481 }
8482
8483 #[test]
8484 fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
8485 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8486 " skill: [ana/code-review, \"ana/style-*\"]\n tool: [\"local/b-*\", local/a, \"murmur/*\", \"*\"]\n",
8487 ))
8488 .expect("an install block parses");
8489 assert_eq!(
8490 manifest.capabilities.unwrap().install,
8491 Some(InstallCapabilities {
8492 skill: vec!["ana/code-review".into(), "ana/style-*".into()],
8493 tool: vec![
8494 "local/b-*".into(),
8495 "local/a".into(),
8496 "murmur/*".into(),
8497 "*".into()
8498 ],
8499 from: InstallFrom::Stores,
8500 })
8501 );
8502 }
8503
8504 #[test]
8507 fn capabilities_install_accepts_either_list_alone() {
8508 let skill_only =
8509 RuntimeManifest::from_yaml_str(&manifest_with_install(" skill: [local/a]\n"))
8510 .expect("skill alone parses");
8511 assert_eq!(
8512 skill_only.capabilities.unwrap().install,
8513 Some(InstallCapabilities {
8514 skill: vec!["local/a".into()],
8515 tool: vec![],
8516 from: InstallFrom::Stores,
8517 })
8518 );
8519 let tool_only =
8520 RuntimeManifest::from_yaml_str(&manifest_with_install(" tool: [\"local/b-*\"]\n"))
8521 .expect("tool alone parses");
8522 assert_eq!(
8523 tool_only.capabilities.unwrap().install,
8524 Some(InstallCapabilities {
8525 skill: vec![],
8526 tool: vec!["local/b-*".into()],
8527 from: InstallFrom::Stores,
8528 })
8529 );
8530 }
8531
8532 fn manifest_with_harness_paths(entries: &str) -> String {
8533 format!(
8534 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n harness:\n paths:\n{entries}"
8535 )
8536 }
8537
8538 #[test]
8539 fn capabilities_harness_paths_parse_in_declaration_order() {
8540 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_harness_paths(
8541 " - { path: ~/.claude, access: read-write }\n - { path: /opt/harness/, access: read }\n",
8542 ))
8543 .expect("a harness block parses");
8544 assert_eq!(
8545 manifest.capabilities.unwrap().harness,
8546 Some(HarnessCapabilities {
8547 paths: vec![
8548 HarnessPathEntry {
8549 path: "~/.claude".into(),
8550 access: HarnessPathAccess::ReadWrite,
8551 },
8552 HarnessPathEntry {
8553 path: "/opt/harness/".into(),
8554 access: HarnessPathAccess::Read,
8555 },
8556 ],
8557 })
8558 );
8559 }
8560
8561 #[test]
8565 fn capabilities_harness_paths_refuse_every_wrong_shape_naming_the_entry() {
8566 for (path, expected) in [
8567 ("/", "names /, the whole host filesystem"),
8568 ("\"~\"", "names the whole home folder"),
8569 ("~/", "names the whole home folder"),
8570 ("relative/dir", "is relative"),
8571 ("./dir", "is relative"),
8572 ("~user/dir", "is relative"),
8573 ("$HOME/dir", "is relative"),
8574 ("/opt/../etc", "has a . or .. segment"),
8575 ("~/a/./b", "has a . or .. segment"),
8576 ("/opt//x", "has an empty path segment"),
8577 ] {
8578 let err = RuntimeManifest::from_yaml_str(&manifest_with_harness_paths(&format!(
8579 " - {{ path: {path}, access: read-write }}\n"
8580 )))
8581 .expect_err("a wrongly shaped harness path is refused");
8582 match &err {
8583 RuntimeManifestError::InvalidCapabilities { field, message } => {
8584 assert_eq!(field, "capabilities.harness.paths[0].path", "{path}");
8585 assert!(message.contains(expected), "{path}: {message}");
8586 }
8587 other => panic!("{path}: expected InvalidCapabilities, got {other:?}"),
8588 }
8589 }
8590 }
8591
8592 #[test]
8593 fn capabilities_harness_paths_require_a_known_access() {
8594 for (entry, expected) in [
8595 ("{ path: ~/x }", "access is required"),
8596 ("{ path: ~/x, access: write }", "access 'write' is neither"),
8597 ("{ access: read }", "must name a host directory"),
8598 ] {
8599 let err = RuntimeManifest::from_yaml_str(&manifest_with_harness_paths(&format!(
8600 " - {entry}\n"
8601 )))
8602 .expect_err("an incomplete entry is refused");
8603 assert!(err.to_string().contains(expected), "{entry}: {err}");
8604 }
8605 }
8606
8607 #[test]
8608 fn capabilities_harness_paths_report_an_unknown_key_inside_an_entry() {
8609 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_harness_paths(
8610 " - { path: ~/x, access: read, acess: read }\n",
8611 ))
8612 .expect("an unknown key is reported, not refused");
8613 assert!(
8614 manifest
8615 .unknown_keys
8616 .iter()
8617 .any(|key| key.block_path == "capabilities.harness.paths[0]" && key.key == "acess"),
8618 "{:?}",
8619 manifest.unknown_keys
8620 );
8621 }
8622
8623 #[test]
8624 fn capabilities_install_absent_is_none() {
8625 let manifest = RuntimeManifest::from_yaml_str(
8626 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n submit: true\n",
8627 )
8628 .expect("a capabilities block without install parses");
8629 assert_eq!(manifest.capabilities.unwrap().install, None);
8630 }
8631
8632 fn install_from(list: &str) -> Result<InstallFrom, RuntimeManifestError> {
8633 RuntimeManifest::from_yaml_str(&manifest_with_install(&format!(
8634 " tool: [\"murmur/tool-*\"]\n from: {list}\n"
8635 )))
8636 .map(|manifest| manifest.capabilities.unwrap().install.unwrap().from)
8637 }
8638
8639 #[test]
8640 fn install_from_absent_is_stores() {
8641 let manifest =
8642 RuntimeManifest::from_yaml_str(&manifest_with_install(" tool: [local/a]\n"))
8643 .unwrap();
8644 assert_eq!(
8645 manifest.capabilities.unwrap().install.unwrap().from,
8646 InstallFrom::Stores
8647 );
8648 }
8649
8650 #[test]
8651 fn install_from_accepts_stores_alone_and_with_sources_in_either_order() {
8652 assert_eq!(install_from("[stores]").unwrap(), InstallFrom::Stores);
8653 assert_eq!(
8654 install_from("[stores, sources]").unwrap(),
8655 InstallFrom::StoresAndSources
8656 );
8657 assert_eq!(
8658 install_from("[sources, stores]").unwrap(),
8659 InstallFrom::StoresAndSources
8660 );
8661 assert_eq!(InstallFrom::StoresAndSources.as_list(), "stores, sources");
8662 assert!(InstallFrom::StoresAndSources.sources());
8663 assert!(!InstallFrom::Stores.sources());
8664 }
8665
8666 #[test]
8667 fn install_from_refuses_empty_sourceless_duplicate_and_unknown_lists() {
8668 for (list, why) in [
8669 ("[]", "is empty"),
8670 ("[sources]", "does not name stores"),
8671 ("[stores, stores]", "names \"stores\" twice"),
8672 ("[github]", "names \"github\", which is not a place"),
8673 ] {
8674 match install_from(list) {
8675 Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8676 assert_eq!(field, "capabilities.install.from", "{list}");
8677 assert!(message.contains(why), "{list}: {message}");
8678 assert!(
8679 message.contains("expected [stores] or [stores, sources]"),
8680 "{list}: {message}"
8681 );
8682 }
8683 other => panic!("{list}: expected InvalidCapabilities, got {other:?}"),
8684 }
8685 }
8686 }
8687
8688 #[test]
8689 fn install_from_is_no_unknown_key() {
8690 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8691 " tool: [local/a]\n from: [stores]\n",
8692 ))
8693 .unwrap();
8694 assert!(
8695 manifest
8696 .unknown_keys
8697 .iter()
8698 .all(|key| key.block_path != "capabilities.install"),
8699 "{:?}",
8700 manifest.unknown_keys
8701 );
8702 }
8703
8704 #[test]
8705 fn install_from_capabilities_install_is_refused_under_transport_process() {
8706 let yaml = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n install:\n \
8707 tool: [\"murmur/tool-*\"]\ninference:\n transport: process\n \
8708 command: claude\n model: claude-haiku-4-5-20251001\n driver:\n \
8709 artifact: murmur/driver-claude-code\n";
8710 match RuntimeManifest::from_yaml_str(yaml) {
8711 Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8712 assert_eq!(field, "capabilities.install");
8713 assert_eq!(
8714 message,
8715 "is not valid with transport: process; the harness owns its tool list, so \
8716 a pulled artifact could never reach it"
8717 );
8718 }
8719 other => panic!("expected InvalidCapabilities, got {other:?}"),
8720 }
8721 }
8722
8723 #[test]
8726 fn capabilities_install_that_names_nothing_is_refused() {
8727 for block in [
8728 " {}\n",
8729 " skill: []\n",
8730 " skill: []\n tool: []\n",
8731 " driver: [x]\n",
8732 ] {
8733 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
8734 .expect_err("an install block naming nothing is refused");
8735 match &err {
8736 RuntimeManifestError::InvalidCapabilities { field, message } => {
8737 assert_eq!(field, "capabilities.install", "{block:?}");
8738 assert!(message.contains("at least one"), "{message}");
8739 assert!(message.contains("omit the block"), "{message}");
8740 }
8741 other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
8742 }
8743 }
8744 }
8745
8746 #[test]
8747 fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
8748 for entry in [
8749 "a*b", "*x", "**", "Foo", "-a", "a/b/c", " ", "*/x", "a/*x", "a/x*y", "A/x",
8750 ] {
8751 for key in ["skill", "tool"] {
8752 let block = format!(" {key}: [{entry:?}]\n");
8753 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8754 .expect_err("a bad entry is refused");
8755 match &err {
8756 RuntimeManifestError::InvalidArtifactName {
8757 field,
8758 message,
8759 hint,
8760 } => {
8761 assert_eq!(field, &format!("capabilities.install.{key}[0]"));
8762 assert!(message.contains(&format!("{entry:?}")), "{message}");
8763 assert!(
8764 message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
8765 "{message}"
8766 );
8767 assert_eq!(hint, &None, "{entry:?}");
8768 }
8769 other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8770 }
8771 }
8772 }
8773 }
8774
8775 #[test]
8777 fn capabilities_install_refuses_an_unscoped_entry_with_the_scoped_hint() {
8778 for (entry, scoped) in [
8779 ("tool-*", "local/tool-*"),
8780 ("mur*", "local/mur*"),
8781 ("tool-x", "local/tool-x"),
8782 ("murmur-tool-editor", "murmur/tool-editor"),
8783 ] {
8784 let block = format!(" tool: [\"local/ok\", {entry:?}]\n");
8785 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8786 .expect_err("an unscoped entry is refused");
8787 match &err {
8788 RuntimeManifestError::InvalidArtifactName {
8789 field,
8790 message,
8791 hint,
8792 } => {
8793 assert_eq!(field, "capabilities.install.tool[1]");
8794 assert_eq!(
8795 message,
8796 &format!(
8797 "entry {entry:?} is invalid ('{entry}' names no scope); expected {}",
8798 crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
8799 )
8800 );
8801 let hint = hint.as_deref().expect("an unscoped entry carries a hint");
8802 assert!(hint.contains(scoped), "{entry:?}: {hint}");
8803 }
8804 other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8805 }
8806 assert!(
8807 err.to_string().contains("'capabilities.install.tool[1]'"),
8808 "{err}"
8809 );
8810 }
8811 }
8812
8813 #[test]
8815 fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
8816 for runtime in ["tool", "hook", "driver", "skill"] {
8817 let yaml = format!(
8818 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/a\n version: 0.1.0\n \
8819 runtime: {runtime}\n capabilities:\n install:\n skill: [x]\n"
8820 );
8821 let err = RuntimeManifest::from_yaml_str(&yaml)
8822 .expect_err("a per-artifact install block is refused");
8823 match &err {
8824 RuntimeManifestError::InvalidArtifact { index, message } => {
8825 assert_eq!(*index, 0);
8826 assert!(message.contains("capabilities.install"), "{message}");
8827 assert!(message.contains("capsule-wide"), "{message}");
8828 }
8829 other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
8830 }
8831 }
8832 }
8833
8834 #[test]
8836 fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
8837 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8838 " skill: [local/a]\n driver: [x]\n",
8839 ))
8840 .expect("an unknown key beside a real entry is a warning, not a parse error");
8841 assert!(
8842 manifest
8843 .unknown_keys
8844 .iter()
8845 .any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
8846 "{:?}",
8847 manifest.unknown_keys
8848 );
8849 assert_eq!(
8850 manifest.capabilities.unwrap().install,
8851 Some(InstallCapabilities {
8852 skill: vec!["local/a".into()],
8853 tool: vec![],
8854 from: InstallFrom::Stores,
8855 })
8856 );
8857 }
8858
8859 fn manifest_with_task_io(runtime: &str, block: &str) -> String {
8863 format!(
8864 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/gate\n version: 1.2.3\n \
8865 runtime: {runtime}\n capabilities:\n task_io:\n{block}"
8866 )
8867 }
8868
8869 #[test]
8870 fn task_io_read_true_grants_the_hook() {
8871 let manifest =
8872 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: true\n"))
8873 .expect("task_io on a hook entry parses");
8874 assert_eq!(
8875 manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
8876 Some(TaskIoCapabilities { read: true })
8877 );
8878 }
8879
8880 #[test]
8883 fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
8884 let explicit =
8885 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: false\n"))
8886 .expect("an explicit denial parses");
8887 assert_eq!(
8888 explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
8889 Some(TaskIoCapabilities { read: false })
8890 );
8891
8892 let absent = RuntimeManifest::from_yaml_str(
8893 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/gate\n version: 1.2.3\n \
8894 runtime: hook\n",
8895 )
8896 .expect("no capabilities block parses");
8897 assert!(absent.artifacts[0].capabilities.is_none());
8898 }
8899
8900 #[test]
8903 fn task_io_without_read_is_rejected() {
8904 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " {}\n"))
8905 .unwrap_err();
8906 let msg = err.to_string();
8907 assert!(
8908 msg.contains("capabilities.task_io.read"),
8909 "error was: {msg}"
8910 );
8911 assert!(msg.contains("explicitly"), "error was: {msg}");
8912 }
8913
8914 #[test]
8917 fn task_io_outside_a_hook_entry_is_rejected() {
8918 for runtime in ["tool", "driver", "skill"] {
8919 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
8920 runtime,
8921 " read: true\n",
8922 ))
8923 .unwrap_err();
8924 let msg = err.to_string();
8925 assert!(
8926 msg.contains("capabilities") && msg.contains("runtime: hook"),
8927 "{runtime} entry: error was: {msg}"
8928 );
8929 }
8930 }
8931
8932 fn manifest_with_filesystem(runtime: &str, block: &str) -> String {
8936 format!(
8937 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/thing\n version: 0.1.0\n \
8938 runtime: {runtime}\n capabilities:\n filesystem:\n{block}"
8939 )
8940 }
8941
8942 #[test]
8944 fn filesystem_records_on_a_hook_entry_parses() {
8945 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8946 "hook",
8947 " records: true\n",
8948 ))
8949 .expect("records on a hook entry parses");
8950 let filesystem = manifest.artifacts[0]
8951 .capabilities
8952 .as_ref()
8953 .unwrap()
8954 .filesystem
8955 .as_ref()
8956 .unwrap();
8957 assert!(filesystem.records);
8958 assert_eq!(filesystem.scope, None);
8959
8960 let scoped =
8961 RuntimeManifest::from_yaml_str(&manifest_with_filesystem("hook", " scope: .\n"))
8962 .unwrap();
8963 let filesystem = scoped.artifacts[0]
8964 .capabilities
8965 .as_ref()
8966 .unwrap()
8967 .filesystem
8968 .as_ref()
8969 .unwrap();
8970 assert!(!filesystem.records);
8971 assert!(scoped.unknown_keys.is_empty(), "{:?}", scoped.unknown_keys);
8972 }
8973
8974 #[test]
8977 fn filesystem_records_is_refused_beside_a_scope_off_a_hook_and_capsule_wide() {
8978 let beside = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8979 "hook",
8980 " records: true\n scope: .\n",
8981 ))
8982 .unwrap_err()
8983 .to_string();
8984 assert!(
8985 beside.contains("capabilities.filesystem.records"),
8986 "{beside}"
8987 );
8988 assert!(beside.contains("capabilities.filesystem.scope"), "{beside}");
8989 for runtime in ["tool", "driver"] {
8990 let off_hook = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8991 runtime,
8992 " records: true\n",
8993 ))
8994 .unwrap_err()
8995 .to_string();
8996 assert!(
8997 off_hook.contains("capabilities.filesystem.records"),
8998 "{off_hook}"
8999 );
9000 assert!(off_hook.contains("runtime: hook"), "{off_hook}");
9001 }
9002 let capsule_wide = RuntimeManifest::from_yaml_str(
9003 "name: cap\nversion: 0.0.1\ncapabilities:\n filesystem:\n records: true\n",
9004 )
9005 .unwrap_err()
9006 .to_string();
9007 assert!(
9008 capsule_wide.contains("capabilities.filesystem.records"),
9009 "{capsule_wide}"
9010 );
9011 }
9012
9013 #[test]
9016 fn task_io_in_the_capsule_wide_block_is_rejected() {
9017 let err = RuntimeManifest::from_yaml_str(
9018 "name: cap\nversion: 0.0.1\ncapabilities:\n task_io:\n read: true\n",
9019 )
9020 .unwrap_err();
9021 let msg = err.to_string();
9022 assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
9023 assert!(msg.contains("runtime: hook"), "error was: {msg}");
9024 }
9025
9026 fn manifest_with_conversation(runtime: &str, block: &str) -> String {
9030 format!(
9031 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/memory\n version: 1.2.3\n \
9032 runtime: {runtime}\n capabilities:\n conversation:\n{block}"
9033 )
9034 }
9035
9036 #[test]
9037 fn conversation_read_lowers_onto_the_hook_entry() {
9038 for (declared, expected) in [("true", true), ("false", false)] {
9039 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
9040 "hook",
9041 &format!(" read: {declared}\n"),
9042 ))
9043 .expect("conversation on a hook entry parses");
9044 assert_eq!(
9045 manifest.artifacts[0]
9046 .capabilities
9047 .as_ref()
9048 .unwrap()
9049 .conversation,
9050 Some(ConversationCapabilities { read: expected })
9051 );
9052 }
9053 }
9054
9055 #[test]
9058 fn conversation_without_read_is_rejected() {
9059 let err =
9060 RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", " {}\n"))
9061 .unwrap_err();
9062 let msg = err.to_string();
9063 assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
9064 assert!(msg.contains("explicitly"), "was: {msg}");
9065 }
9066
9067 #[test]
9070 fn conversation_outside_a_hook_entry_is_rejected() {
9071 for runtime in ["tool", "driver", "skill"] {
9072 let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
9073 runtime,
9074 " read: true\n",
9075 ))
9076 .unwrap_err();
9077 let msg = err.to_string();
9078 assert!(
9079 msg.contains("capabilities") && msg.contains("runtime: hook"),
9080 "{runtime} entry: error was: {msg}"
9081 );
9082 }
9083 }
9084
9085 #[test]
9088 fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
9089 let manifest = RuntimeManifest::from_yaml_str(
9090 "name: cap\nversion: 0.0.1\ncapabilities:\n conversation:\n read: true\n",
9091 )
9092 .expect("a capsule-wide block is accepted");
9093 assert_eq!(
9094 manifest.capabilities.unwrap().conversation,
9095 Some(ConversationCapabilities { read: true })
9096 );
9097 }
9098
9099 #[test]
9104 fn a_context_block_records_by_default() {
9105 let context = context_of("context:\n max_tokens: 1000\n").unwrap();
9106 assert!(context.record);
9107 assert_eq!(context.record_store, None);
9108 }
9109
9110 #[test]
9111 fn record_off_and_on_are_the_two_accepted_values() {
9112 assert!(!context_of("context:\n record: off\n").unwrap().record);
9113 assert!(context_of("context:\n record: on\n").unwrap().record);
9114
9115 let err = RuntimeManifest::from_yaml_str(
9116 "name: cap\nversion: 0.0.1\ncontext:\n record: sometimes\n",
9117 )
9118 .unwrap_err();
9119 let msg = err.to_string();
9120 assert!(msg.contains("context.record"), "was: {msg}");
9121 assert!(msg.contains("sometimes"), "was: {msg}");
9122 }
9123
9124 #[test]
9127 fn record_store_is_kept_verbatim_and_trimmed() {
9128 assert_eq!(
9129 context_of("context:\n record_store: ' shey '\n")
9130 .unwrap()
9131 .record_store,
9132 Some("shey".to_string())
9133 );
9134 assert_eq!(
9135 context_of("context:\n record: off\n record_store: shey\n")
9136 .unwrap()
9137 .record_store,
9138 Some("shey".to_string()),
9139 "a store declared beside record: off is kept and simply never used"
9140 );
9141 }
9142
9143 #[test]
9144 fn interpreter_runtime_rejects_missing_list_dir() {
9145 let msg = interpreter_runtime_reject(
9146 " - binary: python3\n dirs:\n - path: /usr/lib/python3.11\n",
9147 );
9148 assert!(
9149 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
9150 "{msg}"
9151 );
9152 assert!(msg.contains("explicitly"), "{msg}");
9153 }
9154
9155 #[test]
9156 fn interpreter_runtime_rejects_empty_dirs() {
9157 let msg = interpreter_runtime_reject(" - binary: python3\n dirs: []\n");
9158 assert!(
9159 msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
9160 "{msg}"
9161 );
9162 assert!(msg.contains("at least one"), "{msg}");
9163 }
9164
9165 #[test]
9166 fn staged_runtime_parses_accepted_shape() {
9167 let manifest = RuntimeManifest::from_yaml_str(
9168 r#"
9169name: cap
9170version: 0.0.1
9171artifacts: []
9172capabilities:
9173 containment: sealed
9174 shell:
9175 allow:
9176 - python3
9177 - bash
9178 staged_runtime:
9179 - binary: python3
9180 source_path: /opt/testbed/conda/envs/django__django
9181 pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
9182"#,
9183 )
9184 .unwrap();
9185
9186 let shell = manifest.capabilities.unwrap().shell.unwrap();
9187 assert_eq!(
9188 shell.staged_runtime,
9189 vec![StagedRuntimeGrant {
9190 binary: "python3".to_string(),
9191 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
9192 pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
9193 }]
9194 );
9195 assert!(shell.interpreter_runtime.is_empty());
9198 }
9199
9200 #[test]
9201 fn staged_runtime_defaults_to_empty_when_absent() {
9202 let manifest = RuntimeManifest::from_yaml_str(
9203 r#"
9204name: cap
9205version: 0.0.1
9206artifacts: []
9207capabilities:
9208 shell:
9209 allow:
9210 - python3
9211"#,
9212 )
9213 .unwrap();
9214
9215 let shell = manifest.capabilities.unwrap().shell.unwrap();
9216 assert!(shell.staged_runtime.is_empty());
9217 }
9218
9219 fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
9223 let yaml = format!(
9224 r#"
9225name: cap
9226version: 0.0.1
9227artifacts: []
9228capabilities:
9229 shell:
9230 allow:
9231 - python3
9232{extra} staged_runtime:
9233{grant_yaml}
9234"#,
9235 );
9236 RuntimeManifest::from_yaml_str(&yaml)
9237 .expect_err("grant should be rejected")
9238 .to_string()
9239 }
9240
9241 #[test]
9242 fn staged_runtime_rejects_binary_not_in_allow() {
9243 let msg = staged_runtime_reject(
9244 " - binary: ruby\n source_path: /opt/ruby\n pin: ruby-3.2.2\n",
9245 "",
9246 );
9247 assert!(
9248 msg.contains("capabilities.shell.staged_runtime[0].binary"),
9249 "{msg}"
9250 );
9251 assert!(msg.contains("ruby"), "{msg}");
9252 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
9253 }
9254
9255 #[test]
9256 fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
9257 let msg = staged_runtime_reject(
9258 " - binary: python3\n source_path: /opt/py\n pin: cpython-3.9.19\n",
9259 " interpreter_runtime:\n - binary: python3\n dirs:\n - path: /usr/lib/python3.9\n list_dir: true\n",
9260 );
9261 assert!(
9262 msg.contains("capabilities.shell.staged_runtime[0].binary"),
9263 "{msg}"
9264 );
9265 assert!(msg.contains("python3"), "{msg}");
9266 assert!(msg.contains("interpreter_runtime"), "{msg}");
9267 assert!(msg.contains("mutually exclusive"), "{msg}");
9268 }
9269
9270 #[test]
9271 fn staged_runtime_rejects_relative_source_path() {
9272 let msg = staged_runtime_reject(
9273 " - binary: python3\n source_path: opt/testbed/conda\n pin: cpython-3.9.19\n",
9274 "",
9275 );
9276 assert!(
9277 msg.contains("capabilities.shell.staged_runtime[0].source_path"),
9278 "{msg}"
9279 );
9280 assert!(msg.contains("absolute"), "{msg}");
9281 }
9282
9283 #[test]
9284 fn staged_runtime_rejects_missing_or_empty_pin() {
9285 for grant in [
9288 " - binary: python3\n source_path: /opt/testbed/conda\n",
9289 " - binary: python3\n source_path: /opt/testbed/conda\n pin: \" \"\n",
9290 ] {
9291 let msg = staged_runtime_reject(grant, "");
9292 assert!(
9293 msg.contains("capabilities.shell.staged_runtime[0].pin"),
9294 "{msg}"
9295 );
9296 assert!(msg.contains("never inferred"), "{msg}");
9297 }
9298 }
9299
9300 #[test]
9301 fn parses_inference_block() {
9302 let manifest = RuntimeManifest::from_yaml_str(
9303 r#"
9304name: cap
9305version: 0.0.1
9306artifacts:
9307 - name: murmur/driver-anthropic
9308 version: 0.1.0
9309 runtime: driver
9310 gateway:
9311 endpoint: http://127.0.0.1:8080
9312 api_key: literal-key
9313inference:
9314 transport: http
9315 model: test-model
9316 driver:
9317 artifact: murmur/driver-anthropic
9318 config:
9319 temperature: 0.2
9320 headers:
9321 x-test: true
9322"#,
9323 )
9324 .unwrap();
9325
9326 assert_eq!(
9327 manifest.artifacts[0].gateway,
9328 Some(ArtifactGateway {
9329 endpoint: "http://127.0.0.1:8080".to_string(),
9330 api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
9331 keyless: false,
9332 })
9333 );
9334 let inference = manifest.inference.expect("inference should exist");
9335 assert_eq!(inference.transport, "http");
9336 assert_eq!(inference.model, "test-model");
9337 let driver = inference.driver.as_ref().expect("driver should be present");
9338 assert_eq!(driver.artifact, "murmur/driver-anthropic");
9339 assert_eq!(
9340 driver.config,
9341 Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
9342 );
9343 assert!(inference.system_prompt.is_none());
9344 assert!(inference.system_prompt_file.is_none());
9345 }
9346
9347 fn tool_gateway_manifest(block: &str) -> String {
9350 format!(
9351 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/web-search\n version: 0.1.0\n runtime: tool\n gateway:{block}\n"
9352 )
9353 }
9354
9355 fn gateway_endpoint_error(endpoint: &str) -> String {
9356 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
9357 "\n endpoint: \"{endpoint}\""
9358 )))
9359 .unwrap_err()
9360 .to_string()
9361 }
9362
9363 #[test]
9364 fn gateway_endpoint_https_is_accepted() {
9365 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9366 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
9367 ))
9368 .unwrap();
9369 assert_eq!(
9370 manifest.artifacts[0].gateway,
9371 Some(ArtifactGateway {
9372 endpoint: "https://api.tavily.com".to_string(),
9373 api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
9374 keyless: false,
9375 })
9376 );
9377 }
9378
9379 #[test]
9380 fn gateway_endpoint_http_localhost_is_accepted() {
9381 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9382 "\n endpoint: http://localhost:11434\n keyless: true",
9383 ))
9384 .unwrap();
9385 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
9386 assert_eq!(gateway.endpoint, "http://localhost:11434");
9387 assert_eq!(gateway.api_key, None);
9388 assert!(gateway.keyless);
9389 }
9390
9391 #[test]
9392 fn gateway_endpoint_http_loopback_ip_is_accepted() {
9393 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9394 "\n endpoint: http://127.0.0.1:8080\n api_key: ${LOCAL_KEY}",
9395 ))
9396 .unwrap();
9397 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
9398 assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
9399 assert_eq!(
9400 gateway.api_key,
9401 Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
9402 );
9403 }
9404
9405 #[test]
9406 fn gateway_endpoint_without_scheme_is_refused() {
9407 let msg = gateway_endpoint_error("api.tavily.com");
9408 assert!(msg.contains("gateway.endpoint"), "{msg}");
9409 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9410 assert!(msg.contains("does not parse as a URL"), "{msg}");
9411 }
9412
9413 #[test]
9414 fn gateway_endpoint_ftp_scheme_is_refused() {
9415 let msg = gateway_endpoint_error("ftp://example.com");
9416 assert!(msg.contains("gateway.endpoint"), "{msg}");
9417 assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
9418 }
9419
9420 #[test]
9421 fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
9422 let msg = gateway_endpoint_error("http://api.attacker.example.com");
9423 assert!(msg.contains("gateway.endpoint"), "{msg}");
9424 assert!(msg.contains("api.attacker.example.com"), "{msg}");
9425 assert!(msg.contains("non-loopback"), "{msg}");
9426 }
9427
9428 #[test]
9429 fn gateway_endpoint_with_a_query_is_refused() {
9430 let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
9431 assert!(msg.contains("gateway.endpoint"), "{msg}");
9432 assert!(msg.contains("carries a query"), "{msg}");
9433 }
9434
9435 #[test]
9436 fn gateway_endpoint_with_a_fragment_is_refused() {
9437 let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
9438 assert!(msg.contains("gateway.endpoint"), "{msg}");
9439 assert!(msg.contains("carries a fragment"), "{msg}");
9440 }
9441
9442 #[test]
9443 fn gateway_endpoint_with_userinfo_is_refused() {
9444 let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
9445 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9446 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
9447 assert!(msg.contains("host 'api.example.com'"), "{msg}");
9448 assert!(msg.contains("gateway.api_key"), "{msg}");
9449 assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
9450 assert!(
9451 !msg.contains("sk-in-url"),
9452 "a password in the URL is never echoed: {msg}"
9453 );
9454 }
9455
9456 #[test]
9457 fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
9458 let msg = gateway_endpoint_error("https://user:sk-in-url@");
9459 assert!(msg.contains("does not parse as a URL"), "{msg}");
9460 assert!(
9461 !msg.contains("sk-in-url"),
9462 "a password in the URL is never echoed: {msg}"
9463 );
9464 }
9465
9466 #[test]
9467 fn gateway_endpoint_with_a_masquerading_host_is_refused() {
9468 let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
9469 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
9470 assert!(
9471 msg.contains("addresses host '127.0.0.1'"),
9472 "the message names the host the URL really addresses: {msg}"
9473 );
9474 }
9475
9476 #[test]
9477 fn gateway_endpoint_with_interpolation_is_refused() {
9478 let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
9479 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9480 assert!(msg.contains("contains '${'"), "{msg}");
9481 assert!(msg.contains("not interpolated"), "{msg}");
9482 }
9483
9484 #[test]
9485 fn gateway_endpoint_http_unspecified_address_is_refused() {
9486 let msg = gateway_endpoint_error("http://0.0.0.0:8080");
9487 assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
9488 }
9489
9490 #[test]
9491 fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
9492 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9493 "\n endpoint: http://[::1]:8080\n api_key: ${LOCAL_KEY}",
9494 ))
9495 .unwrap();
9496 assert_eq!(
9497 manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
9498 "http://[::1]:8080"
9499 );
9500 }
9501
9502 fn gateway_credential_error(block: &str) -> RuntimeManifestError {
9504 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
9505 }
9506
9507 fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
9508 match &error {
9509 RuntimeManifestError::GatewayWithoutCredential {
9510 index,
9511 name,
9512 endpoint: refused,
9513 } => {
9514 assert_eq!(*index, 0);
9515 assert_eq!(name, "local/web-search");
9516 assert_eq!(refused, endpoint);
9517 }
9518 other => panic!("expected GatewayWithoutCredential, got {other:?}"),
9519 }
9520 let msg = error.to_string();
9521 assert!(
9522 msg.contains(&format!(
9523 "artifact 'local/web-search' declares gateway.endpoint '{endpoint}' but binds no \
9524 credential"
9525 )),
9526 "{msg}"
9527 );
9528 }
9529
9530 #[test]
9531 fn gateway_credential_absent_is_refused() {
9532 assert_without_credential(
9533 gateway_credential_error("\n endpoint: https://api.tavily.com"),
9534 "https://api.tavily.com",
9535 );
9536 }
9537
9538 #[test]
9539 fn gateway_credential_blank_is_refused() {
9540 for key in ["\"\"", "\" \"", "''"] {
9541 assert_without_credential(
9542 gateway_credential_error(&format!(
9543 "\n endpoint: https://api.tavily.com\n api_key: {key}"
9544 )),
9545 "https://api.tavily.com",
9546 );
9547 }
9548 }
9549
9550 #[test]
9551 fn gateway_credential_null_is_refused() {
9552 for key in ["~", "null", ""] {
9553 assert_without_credential(
9554 gateway_credential_error(&format!(
9555 "\n endpoint: https://api.tavily.com\n api_key: {key}"
9556 )),
9557 "https://api.tavily.com",
9558 );
9559 }
9560 }
9561
9562 #[test]
9563 fn gateway_credential_keyless_false_is_refused() {
9564 assert_without_credential(
9565 gateway_credential_error(
9566 "\n endpoint: https://api.tavily.com\n keyless: false",
9567 ),
9568 "https://api.tavily.com",
9569 );
9570 }
9571
9572 #[test]
9573 fn gateway_credential_keyless_is_accepted() {
9574 for (parse, mode) in [
9575 (
9576 RuntimeManifest::from_yaml_str as fn(&str) -> _,
9577 "with secrets",
9578 ),
9579 (
9580 RuntimeManifest::from_yaml_str_without_secrets,
9581 "without secrets",
9582 ),
9583 ] {
9584 let manifest = parse(&tool_gateway_manifest(
9585 "\n endpoint: https://api.example.com\n keyless: true",
9586 ))
9587 .unwrap();
9588 assert_eq!(
9589 manifest.artifacts[0].gateway,
9590 Some(ArtifactGateway {
9591 endpoint: "https://api.example.com".to_string(),
9592 api_key: None,
9593 keyless: true,
9594 }),
9595 "{mode}"
9596 );
9597 }
9598 }
9599
9600 #[test]
9601 fn gateway_credential_keyless_not_a_boolean_is_refused() {
9602 for value in ["\"true\"", "yes please", "1"] {
9603 let error = gateway_credential_error(&format!(
9604 "\n endpoint: https://api.tavily.com\n keyless: {value}"
9605 ));
9606 assert!(
9607 matches!(
9608 error,
9609 RuntimeManifestError::InvalidArtifact { index: 0, .. }
9610 ),
9611 "{value}: {error:?}"
9612 );
9613 assert!(
9614 error
9615 .to_string()
9616 .contains("gateway.keyless with a value that is not a boolean"),
9617 "{error}"
9618 );
9619 }
9620 }
9621
9622 #[test]
9623 fn gateway_credential_and_keyless_together_are_refused() {
9624 for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
9625 let error = gateway_credential_error(&format!(
9626 "\n endpoint: https://api.tavily.com\n api_key: {key}\n keyless: true"
9627 ));
9628 assert!(
9629 matches!(
9630 error,
9631 RuntimeManifestError::InvalidArtifact { index: 0, .. }
9632 ),
9633 "{error:?}"
9634 );
9635 assert!(
9636 error.to_string().contains(
9637 "artifact 'local/web-search' declares both gateway.api_key and gateway.keyless: \
9638 true; a gateway either presents a key or is keyless"
9639 ),
9640 "{error}"
9641 );
9642 }
9643 }
9644
9645 #[test]
9646 fn gateway_credential_absent_is_refused_without_secrets() {
9647 for block in [
9648 "\n endpoint: https://api.tavily.com",
9649 "\n endpoint: https://api.tavily.com\n api_key: \"\"",
9650 "\n endpoint: https://api.tavily.com\n keyless: false",
9651 ] {
9652 assert_without_credential(
9653 RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
9654 .unwrap_err(),
9655 "https://api.tavily.com",
9656 );
9657 }
9658 let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
9659 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
9660 ))
9661 .unwrap();
9662 let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
9663 assert_eq!(gateway.api_key, None, "the key reference is left unread");
9664 assert!(!gateway.keyless, "a keyed gateway is never keyless");
9665 }
9666
9667 #[test]
9670 fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
9671 for endpoint in [
9672 "http://127.0.0.1:11434",
9673 "http://localhost:11434",
9674 "http://[::1]:11434",
9675 "https://10.0.0.5",
9676 "https://192.168.1.10:8443",
9677 ] {
9678 assert_without_credential(
9679 gateway_credential_error(&format!("\n endpoint: {endpoint}")),
9680 endpoint,
9681 );
9682 }
9683 }
9684
9685 #[test]
9686 fn gateway_endpoint_missing_from_the_block_is_refused() {
9687 for block in [
9688 "\n api_key: ${TAVILY_API_KEY}",
9689 "",
9690 " {}",
9691 "\n endpoint: \" \"",
9692 ] {
9693 let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
9694 .unwrap_err()
9695 .to_string();
9696 assert!(
9697 msg.contains("without 'gateway.endpoint'"),
9698 "{block:?}: {msg}"
9699 );
9700 assert!(
9701 msg.contains("artifact 'local/web-search'"),
9702 "{block:?}: {msg}"
9703 );
9704 }
9705 }
9706
9707 #[test]
9708 fn gateway_block_unknown_key_is_reported() {
9709 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9710 "\n endpoint: https://api.tavily.com\n keyless: true\n api_kee: x",
9711 ))
9712 .unwrap();
9713 let unknown = manifest.unknown_keys;
9714 assert_eq!(unknown.len(), 1, "{unknown:?}");
9715 assert_eq!(unknown[0].key, "api_kee");
9716 assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
9717 }
9718
9719 #[test]
9720 fn gateway_on_skill_entry_is_refused() {
9721 let msg = RuntimeManifest::from_yaml_str(
9722 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/notes\n version: 0.1.0\n runtime: skill\n gateway:\n endpoint: https://api.tavily.com\n api_key: test-key\n",
9723 )
9724 .unwrap_err()
9725 .to_string();
9726 assert!(
9727 msg.contains("artifact 'local/notes' declares 'gateway:'"),
9728 "{msg}"
9729 );
9730 assert!(msg.contains("runtime: skill"), "{msg}");
9731 }
9732
9733 #[test]
9734 fn gateway_on_hook_entry_is_accepted() {
9735 let manifest = RuntimeManifest::from_yaml_str(
9736 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/grafana\n version: 0.1.0\n runtime: hook\n gateway:\n endpoint: https://grafana.example.com/otlp\n api_key: glc_literal\n",
9737 )
9738 .unwrap();
9739 assert!(manifest.artifacts[0].gateway.is_some());
9740 }
9741
9742 fn http_driver_manifest(artifacts: &str) -> String {
9745 format!(
9746 "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur/driver-anthropic\n"
9747 )
9748 }
9749
9750 #[test]
9751 fn http_driver_entry_without_gateway_endpoint_is_refused() {
9752 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9753 "\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver",
9754 ))
9755 .unwrap_err()
9756 .to_string();
9757 assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9758 assert!(msg.contains("gateway.endpoint"), "{msg}");
9759 }
9760
9761 #[test]
9762 fn http_driver_absent_from_artifacts_is_refused() {
9763 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
9764 .unwrap_err()
9765 .to_string();
9766 assert!(msg.contains("inference.driver.artifact"), "{msg}");
9767 assert!(msg.contains("not declared in artifacts:"), "{msg}");
9768 }
9769
9770 #[test]
9771 fn gateway_on_unconfigured_driver_entry_is_refused() {
9772 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9773 "\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",
9774 ))
9775 .unwrap_err()
9776 .to_string();
9777 assert!(msg.contains("'murmur/driver-openai'"), "{msg}");
9778 assert!(
9779 msg.contains("accepted only on the entry inference.driver.artifact names"),
9780 "{msg}"
9781 );
9782
9783 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";
9784 let msg = RuntimeManifest::from_yaml_str(no_inference)
9785 .unwrap_err()
9786 .to_string();
9787 assert!(msg.contains("not the configured inference driver"), "{msg}");
9788 }
9789
9790 #[test]
9791 fn gateway_on_driver_entry_under_process_transport_is_refused() {
9792 let msg = RuntimeManifest::from_yaml_str(
9793 "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",
9794 )
9795 .unwrap_err()
9796 .to_string();
9797 assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9798 assert!(msg.contains("transport: process"), "{msg}");
9799 }
9800
9801 #[test]
9802 fn inference_api_key_is_refused_naming_gateway_api_key() {
9803 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";
9804 for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
9805 let manifest = http_driver_manifest(entry)
9806 .replace(" model:", &format!(" api_key: {value}\n model:"));
9807 let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
9808 let msg = err.to_string();
9809 assert!(msg.contains("'inference.api_key'"), "{msg}");
9810 assert!(msg.contains("was removed"), "{msg}");
9811 assert!(
9812 msg.contains(
9813 "set gateway.api_key on the artifacts: entry 'murmur/driver-anthropic'"
9814 ),
9815 "{msg}"
9816 );
9817 assert!(!msg.contains("sk-literal"), "{msg}");
9818 }
9819
9820 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n api_key: ${KEY}\n";
9821 let msg = RuntimeManifest::from_yaml_str(process)
9822 .unwrap_err()
9823 .to_string();
9824 assert!(msg.contains("'inference.api_key'"), "{msg}");
9825 assert!(
9826 msg.contains("on the artifacts: entry inference.driver.artifact names"),
9827 "{msg}"
9828 );
9829 assert!(
9830 RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
9831 "the refusal does not depend on resolving secrets"
9832 );
9833 }
9834
9835 #[test]
9836 fn inference_endpoint_is_refused_naming_gateway_endpoint() {
9837 let manifest = http_driver_manifest(
9838 "\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",
9839 )
9840 .replace(" model:", " endpoint: https://api.anthropic.com\n model:");
9841 let msg = RuntimeManifest::from_yaml_str(&manifest)
9842 .unwrap_err()
9843 .to_string();
9844 assert!(msg.contains("'inference.endpoint'"), "{msg}");
9845 assert!(
9846 msg.contains("set gateway.endpoint on the artifacts: entry 'murmur/driver-anthropic'"),
9847 "{msg}"
9848 );
9849
9850 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n endpoint: http://localhost:8080\n";
9851 let msg = RuntimeManifest::from_yaml_str(process)
9852 .unwrap_err()
9853 .to_string();
9854 assert!(msg.contains("'inference.endpoint'"), "{msg}");
9855 assert!(msg.contains("was removed"), "{msg}");
9856 }
9857
9858 #[test]
9859 fn without_secrets_leaves_gateway_api_key_unread() {
9860 let manifest = tool_gateway_manifest(
9861 "\n endpoint: https://api.tavily.com\n api_key: tvly-literal",
9862 );
9863 let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
9864 assert_eq!(
9865 resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
9866 Some(ApiKeyReference::Literal("tvly-literal".to_string()))
9867 );
9868 let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
9869 let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
9870 assert_eq!(gateway.endpoint, "https://api.tavily.com");
9871 assert_eq!(gateway.api_key, None);
9872 }
9873
9874 #[test]
9875 fn referenced_env_variables_reads_gateway_api_key() {
9876 let manifest = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/web-search\n version: 0.1.0\n gateway:\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.anthropic.com\n api_key: ${ANTHROPIC_API_KEY}\n - name: local/literal\n version: 0.1.0\n gateway:\n endpoint: https://example.com\n api_key: not-a-reference\ninference:\n model: m\n driver:\n artifact: murmur/driver-anthropic\n";
9877 let referenced = referenced_env_variables(manifest);
9878 assert_eq!(
9879 referenced,
9880 vec![
9881 ReferencedEnvVariable {
9882 field: "artifacts.murmur/driver-anthropic.gateway.api_key".to_string(),
9883 variable: "ANTHROPIC_API_KEY".to_string(),
9884 },
9885 ReferencedEnvVariable {
9886 field: "artifacts.local/web-search.gateway.api_key".to_string(),
9887 variable: "TAVILY_API_KEY".to_string(),
9888 },
9889 ]
9890 );
9891 assert!(referenced
9892 .iter()
9893 .all(ReferencedEnvVariable::is_gateway_credential));
9894 }
9895
9896 #[test]
9897 fn parses_inline_system_prompt() {
9898 let manifest = RuntimeManifest::from_yaml_str(
9899 r#"
9900name: cap
9901version: 0.0.1
9902artifacts:
9903 - name: murmur/driver-anthropic
9904 version: 0.1.0
9905 runtime: driver
9906 gateway:
9907 endpoint: http://127.0.0.1:8080
9908 keyless: true
9909inference:
9910 model: test-model
9911 system_prompt: "Always begin with CONFIRMED:"
9912 driver:
9913 artifact: murmur/driver-anthropic
9914"#,
9915 )
9916 .unwrap();
9917
9918 let inference = manifest.inference.unwrap();
9919 assert_eq!(
9920 inference.system_prompt,
9921 Some("Always begin with CONFIRMED:".to_string())
9922 );
9923 assert!(inference.system_prompt_file.is_none());
9924 }
9925
9926 #[test]
9927 fn parses_compaction_system_prompt() {
9928 let manifest = RuntimeManifest::from_yaml_str(
9929 r#"
9930name: cap
9931version: 0.0.1
9932artifacts:
9933 - name: murmur/driver-anthropic
9934 version: 0.1.0
9935 runtime: driver
9936 gateway:
9937 endpoint: http://127.0.0.1:8080
9938 keyless: true
9939inference:
9940 model: test-model
9941 compaction:
9942 model: compaction-model
9943 system_prompt: "task = X, currently editing Y, already tried Z."
9944 driver:
9945 artifact: murmur/driver-anthropic
9946"#,
9947 )
9948 .unwrap();
9949
9950 let compaction = manifest.inference.unwrap().compaction.unwrap();
9951 assert_eq!(
9952 compaction.system_prompt,
9953 Some("task = X, currently editing Y, already tried Z.".to_string())
9954 );
9955 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9956 }
9957
9958 #[test]
9961 fn compaction_system_prompt_absent_stays_none() {
9962 let manifest = RuntimeManifest::from_yaml_str(
9963 r#"
9964name: cap
9965version: 0.0.1
9966artifacts:
9967 - name: murmur/driver-anthropic
9968 version: 0.1.0
9969 runtime: driver
9970 gateway:
9971 endpoint: http://127.0.0.1:8080
9972 keyless: true
9973inference:
9974 model: test-model
9975 compaction:
9976 model: compaction-model
9977 driver:
9978 artifact: murmur/driver-anthropic
9979"#,
9980 )
9981 .unwrap();
9982
9983 let compaction = manifest.inference.unwrap().compaction.unwrap();
9984 assert!(compaction.system_prompt.is_none());
9985 assert!(compaction.system_prompt_file.is_none());
9986 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9987 }
9988
9989 #[test]
9993 fn parses_compaction_system_prompt_file() {
9994 let manifest = RuntimeManifest::from_yaml_str(
9995 r#"
9996name: cap
9997version: 0.0.1
9998artifacts:
9999 - name: murmur/driver-anthropic
10000 version: 0.1.0
10001 runtime: driver
10002 gateway:
10003 endpoint: http://127.0.0.1:8080
10004 keyless: true
10005inference:
10006 model: test-model
10007 compaction:
10008 model: compaction-model
10009 system_prompt_file: "compaction-instructions.md"
10010 driver:
10011 artifact: murmur/driver-anthropic
10012"#,
10013 )
10014 .unwrap();
10015
10016 let compaction = manifest.inference.unwrap().compaction.unwrap();
10017 assert_eq!(
10018 compaction.system_prompt_file,
10019 Some("compaction-instructions.md".to_string())
10020 );
10021 assert!(compaction.system_prompt.is_none());
10022 assert_eq!(compaction.model, Some("compaction-model".to_string()));
10023 }
10024
10025 #[test]
10028 fn compaction_system_prompt_file_independent_of_model() {
10029 let manifest = RuntimeManifest::from_yaml_str(
10030 r#"
10031name: cap
10032version: 0.0.1
10033artifacts:
10034 - name: murmur/driver-anthropic
10035 version: 0.1.0
10036 runtime: driver
10037 gateway:
10038 endpoint: http://127.0.0.1:8080
10039 keyless: true
10040inference:
10041 model: test-model
10042 compaction:
10043 threshold: 0.5
10044 system_prompt_file: "compaction-instructions.md"
10045 driver:
10046 artifact: murmur/driver-anthropic
10047"#,
10048 )
10049 .unwrap();
10050
10051 let compaction = manifest.inference.unwrap().compaction.unwrap();
10052 assert_eq!(
10053 compaction.system_prompt_file,
10054 Some("compaction-instructions.md".to_string())
10055 );
10056 assert!(compaction.model.is_none());
10057 assert_eq!(compaction.threshold, Some(0.5));
10058 }
10059
10060 #[test]
10064 fn parses_compaction_dump_summaries() {
10065 let parse = |line: &str| {
10066 RuntimeManifest::from_yaml_str(&format!(
10067 r#"
10068name: cap
10069version: 0.0.1
10070artifacts:
10071 - name: murmur/driver-anthropic
10072 version: 0.1.0
10073 runtime: driver
10074 gateway:
10075 endpoint: http://127.0.0.1:8080
10076 keyless: true
10077inference:
10078 model: test-model
10079 compaction:
10080 model: compaction-model
10081{line} driver:
10082 artifact: murmur/driver-anthropic
10083"#
10084 ))
10085 .unwrap()
10086 .inference
10087 .unwrap()
10088 .compaction
10089 .unwrap()
10090 };
10091
10092 assert_eq!(
10093 parse(" dump_summaries: true\n").dump_summaries,
10094 Some(true)
10095 );
10096 assert_eq!(
10097 parse(" dump_summaries: false\n").dump_summaries,
10098 Some(false)
10099 );
10100 assert_eq!(parse("").dump_summaries, None);
10101 }
10102
10103 #[test]
10107 fn compaction_dump_summaries_does_not_interact_with_other_fields() {
10108 let compaction = RuntimeManifest::from_yaml_str(
10109 r#"
10110name: cap
10111version: 0.0.1
10112artifacts:
10113 - name: murmur/driver-anthropic
10114 version: 0.1.0
10115 runtime: driver
10116 gateway:
10117 endpoint: http://127.0.0.1:8080
10118 keyless: true
10119inference:
10120 model: test-model
10121 compaction:
10122 threshold: 0.5
10123 model: compaction-model
10124 system_prompt_file: "compaction-instructions.md"
10125 dump_summaries: true
10126 driver:
10127 artifact: murmur/driver-anthropic
10128"#,
10129 )
10130 .unwrap()
10131 .inference
10132 .unwrap()
10133 .compaction
10134 .unwrap();
10135
10136 assert_eq!(compaction.dump_summaries, Some(true));
10137 assert_eq!(compaction.threshold, Some(0.5));
10138 assert_eq!(compaction.model, Some("compaction-model".to_string()));
10139 assert_eq!(
10140 compaction.system_prompt_file,
10141 Some("compaction-instructions.md".to_string())
10142 );
10143 assert!(compaction.system_prompt.is_none());
10144
10145 let err = RuntimeManifest::from_yaml_str(
10147 r#"
10148name: cap
10149version: 0.0.1
10150artifacts:
10151 - name: murmur/driver-anthropic
10152 version: 0.1.0
10153 runtime: driver
10154 gateway:
10155 endpoint: http://127.0.0.1:8080
10156 keyless: true
10157inference:
10158 model: test-model
10159 compaction:
10160 dump_summaries: true
10161 system_prompt: "inline"
10162 system_prompt_file: "compaction-instructions.md"
10163 driver:
10164 artifact: murmur/driver-anthropic
10165"#,
10166 )
10167 .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
10168 assert!(matches!(
10169 err,
10170 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
10171 if field == "inference.compaction.system_prompt"
10172 ));
10173
10174 let err = RuntimeManifest::from_yaml_str(
10176 r#"
10177name: cap
10178version: 0.0.1
10179artifacts:
10180 - name: murmur/driver-anthropic
10181 version: 0.1.0
10182 runtime: driver
10183 gateway:
10184 endpoint: http://127.0.0.1:8080
10185 keyless: true
10186inference:
10187 model: test-model
10188 compaction:
10189 dump_summaries: true
10190 threshold: 1.5
10191 driver:
10192 artifact: murmur/driver-anthropic
10193"#,
10194 )
10195 .expect_err("dump_summaries must not suppress the threshold range check");
10196 assert!(matches!(
10197 err,
10198 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
10199 if field == "inference.compaction.threshold"
10200 ));
10201 }
10202
10203 fn compaction_call_manifest(lines: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
10204 RuntimeManifest::from_yaml_str(&format!(
10205 r#"
10206name: cap
10207version: 0.0.1
10208artifacts:
10209 - name: murmur/driver-anthropic
10210 version: 0.1.0
10211 runtime: driver
10212 gateway:
10213 endpoint: http://127.0.0.1:8080
10214 keyless: true
10215inference:
10216 model: test-model
10217 driver:
10218 artifact: murmur/driver-anthropic
10219 config:
10220 reasoning_effort: max
10221 compaction:
10222{lines}"#
10223 ))
10224 }
10225
10226 #[test]
10227 fn parses_compaction_max_tokens_and_driver_config() {
10228 let manifest = compaction_call_manifest(
10229 " max_tokens: 8000\n driver_config:\n reasoning_effort: low\n extra: [1, 2]\n",
10230 )
10231 .unwrap();
10232 assert!(
10233 manifest.unknown_keys.is_empty(),
10234 "neither key is unknown: {:?}",
10235 manifest.unknown_keys
10236 );
10237 let compaction = manifest.inference.unwrap().compaction.unwrap();
10238 assert_eq!(compaction.max_tokens, Some(8000));
10239 assert_eq!(
10240 compaction.driver_config.as_deref(),
10241 Some(r#"{"extra":[1,2],"reasoning_effort":"low"}"#)
10242 );
10243
10244 let compaction = compaction_call_manifest(" threshold: 0.5\n")
10245 .unwrap()
10246 .inference
10247 .unwrap()
10248 .compaction
10249 .unwrap();
10250 assert_eq!(compaction.max_tokens, None);
10251 assert_eq!(compaction.driver_config, None);
10252
10253 let compaction = compaction_call_manifest(" driver_config: {}\n")
10255 .unwrap()
10256 .inference
10257 .unwrap()
10258 .compaction
10259 .unwrap();
10260 assert_eq!(compaction.driver_config.as_deref(), Some("{}"));
10261 }
10262
10263 #[test]
10264 fn compaction_max_tokens_zero_is_refused() {
10265 match compaction_call_manifest(" max_tokens: 0\n").unwrap_err() {
10266 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10267 assert_eq!(field, "inference.compaction.max_tokens");
10268 assert_eq!(message, "must be greater than 0");
10269 }
10270 other => panic!("unexpected error: {other:?}"),
10271 }
10272 }
10273
10274 #[test]
10275 fn compaction_driver_config_must_be_a_mapping() {
10276 for value in ["low", "[reasoning_effort]", "3"] {
10277 match compaction_call_manifest(&format!(" driver_config: {value}\n")).unwrap_err() {
10278 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10279 assert_eq!(field, "inference.compaction.driver_config");
10280 assert_eq!(message, "expected mapping/object");
10281 }
10282 other => panic!("unexpected error for {value}: {other:?}"),
10283 }
10284 }
10285 }
10286
10287 #[test]
10290 fn process_transport_rejects_compaction_call_keys() {
10291 for (key, value) in [
10292 ("max_tokens", "8000"),
10293 ("driver_config", "{reasoning_effort: low}"),
10294 ] {
10295 let err = RuntimeManifest::from_yaml_str(&format!(
10296 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-claude-code\n \
10297 version: 0.1.0\n runtime: driver\ninference:\n transport: process\n \
10298 model: claude-haiku-4-5-20251001\n driver:\n artifact: murmur/driver-claude-code\n \
10299 compaction:\n {key}: {value}\n"
10300 ))
10301 .unwrap_err();
10302 match err {
10303 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10304 assert_eq!(field, format!("inference.compaction.{key}"));
10305 assert_eq!(message, "is not valid with transport: process");
10306 }
10307 other => panic!("unexpected error for {key}: {other:?}"),
10308 }
10309 }
10310 }
10311
10312 #[test]
10316 fn compaction_prompt_sources_are_mutually_exclusive() {
10317 let err = RuntimeManifest::from_yaml_str(
10318 r#"
10319name: cap
10320version: 0.0.1
10321artifacts:
10322 - name: murmur/driver-anthropic
10323 version: 0.1.0
10324 runtime: driver
10325 gateway:
10326 endpoint: http://127.0.0.1:8080
10327 keyless: true
10328inference:
10329 model: test-model
10330 compaction:
10331 system_prompt: "inline"
10332 system_prompt_file: "compaction-instructions.md"
10333 driver:
10334 artifact: murmur/driver-anthropic
10335"#,
10336 )
10337 .expect_err("both compaction prompt sources set must fail");
10338
10339 match err {
10340 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10341 assert_eq!(field, "inference.compaction.system_prompt");
10342 assert_eq!(
10343 message,
10344 "at most one of inference.compaction.system_prompt, \
10345 inference.compaction.system_prompt_file may be set"
10346 );
10347 }
10348 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
10349 }
10350 }
10351
10352 #[test]
10356 fn top_level_and_compaction_prompt_sources_do_not_collide() {
10357 let manifest = RuntimeManifest::from_yaml_str(
10358 r#"
10359name: cap
10360version: 0.0.1
10361artifacts:
10362 - name: murmur/driver-anthropic
10363 version: 0.1.0
10364 runtime: driver
10365 gateway:
10366 endpoint: http://127.0.0.1:8080
10367 keyless: true
10368inference:
10369 model: test-model
10370 system_prompt: "be helpful"
10371 compaction:
10372 system_prompt_file: "compaction-instructions.md"
10373 driver:
10374 artifact: murmur/driver-anthropic
10375"#,
10376 )
10377 .unwrap();
10378
10379 let inference = manifest.inference.unwrap();
10380 assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
10381 assert_eq!(
10382 inference.compaction.unwrap().system_prompt_file,
10383 Some("compaction-instructions.md".to_string())
10384 );
10385 }
10386
10387 #[test]
10388 fn parses_system_prompt_file() {
10389 let manifest = RuntimeManifest::from_yaml_str(
10390 r#"
10391name: cap
10392version: 0.0.1
10393artifacts:
10394 - name: murmur/driver-anthropic
10395 version: 0.1.0
10396 runtime: driver
10397 gateway:
10398 endpoint: http://127.0.0.1:8080
10399 keyless: true
10400inference:
10401 model: test-model
10402 system_prompt_file: "conventions.md"
10403 driver:
10404 artifact: murmur/driver-anthropic
10405"#,
10406 )
10407 .unwrap();
10408
10409 let inference = manifest.inference.unwrap();
10410 assert_eq!(
10411 inference.system_prompt_file,
10412 Some("conventions.md".to_string())
10413 );
10414 assert!(inference.system_prompt.is_none());
10415 }
10416
10417 #[test]
10418 fn rejects_both_inline_and_file_system_prompt() {
10419 let err = RuntimeManifest::from_yaml_str(
10420 r#"
10421name: cap
10422version: 0.0.1
10423artifacts:
10424 - name: murmur/driver-anthropic
10425 version: 0.1.0
10426 runtime: driver
10427 gateway:
10428 endpoint: http://127.0.0.1:8080
10429 keyless: true
10430inference:
10431 model: test-model
10432 system_prompt: "inline"
10433 system_prompt_file: "conventions.md"
10434 driver:
10435 artifact: murmur/driver-anthropic
10436"#,
10437 )
10438 .unwrap_err();
10439
10440 let msg = err.to_string();
10441 assert!(msg.contains("inference.system_prompt"));
10442 assert!(msg.contains("system_prompt_file"));
10443 }
10444
10445 fn instruction_files_manifest(transport: &str, entries: &str) -> String {
10446 let (artifact, inference) = if transport == "process" {
10447 (
10448 " - name: murmur/driver-claude-code\n version: 0.1.0\n runtime: driver\n",
10449 " transport: process\n driver:\n artifact: murmur/driver-claude-code\n",
10450 )
10451 } else {
10452 (
10453 " - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n \
10454 gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\n",
10455 " model: test-model\n driver:\n artifact: murmur/driver-anthropic\n",
10456 )
10457 };
10458 format!(
10459 "name: cap\nversion: 0.0.1\nartifacts:\n{artifact}inference:\n{inference} \
10460 instruction_files: {entries}\n"
10461 )
10462 }
10463
10464 #[test]
10465 fn instruction_files_absent_is_none() {
10466 let manifest = RuntimeManifest::from_yaml_str(
10467 r#"
10468name: cap
10469version: 0.0.1
10470artifacts:
10471 - name: murmur/driver-anthropic
10472 version: 0.1.0
10473 runtime: driver
10474 gateway:
10475 endpoint: http://127.0.0.1:8080
10476 keyless: true
10477inference:
10478 model: test-model
10479 driver:
10480 artifact: murmur/driver-anthropic
10481"#,
10482 )
10483 .unwrap();
10484 assert_eq!(manifest.inference.unwrap().instruction_files, None);
10485 }
10486
10487 #[test]
10490 fn instruction_files_are_kept_verbatim_in_order_on_both_transports() {
10491 for transport in ["http", "process"] {
10492 let manifest = RuntimeManifest::from_yaml_str(&instruction_files_manifest(
10493 transport,
10494 "[CLAUDE.md, ./AGENTS.md, docs/agents/RULES.md]",
10495 ))
10496 .unwrap();
10497 assert!(
10498 manifest.unknown_keys.is_empty(),
10499 "{transport}: {:?}",
10500 manifest.unknown_keys
10501 );
10502 assert_eq!(
10503 manifest.inference.unwrap().instruction_files,
10504 Some(vec![
10505 "CLAUDE.md".to_string(),
10506 "./AGENTS.md".to_string(),
10507 "docs/agents/RULES.md".to_string(),
10508 ]),
10509 "{transport}"
10510 );
10511 }
10512 }
10513
10514 #[test]
10515 fn instruction_files_empty_list_is_some_empty() {
10516 let manifest =
10517 RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", "[]")).unwrap();
10518 assert_eq!(manifest.inference.unwrap().instruction_files, Some(vec![]));
10519 }
10520
10521 #[test]
10524 fn instruction_files_refuse_entries_that_leave_the_work_folder() {
10525 for (entries, index, entry, problem) in [
10526 ("[/etc/passwd]", 0, "/etc/passwd", "is absolute"),
10527 (
10528 "[AGENTS.md, ../AGENTS.md]",
10529 1,
10530 "../AGENTS.md",
10531 "'..' component",
10532 ),
10533 ("[a/../../b]", 0, "a/../../b", "'..' component"),
10534 ("[\"\"]", 0, "", "is empty"),
10535 ("[AGENTS.md, \" \"]", 1, " ", "is empty"),
10536 ] {
10537 let err = RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", entries))
10538 .unwrap_err();
10539 let RuntimeManifestError::InvalidInferenceConfig { field, message } = &err else {
10540 panic!("{entries}: expected InvalidInferenceConfig, got {err:?}");
10541 };
10542 assert_eq!(field, &format!("inference.instruction_files[{index}]"));
10543 assert!(message.contains(&format!("'{entry}'")), "{message}");
10544 assert!(message.contains(problem), "{message}");
10545 assert!(
10546 message.contains("must be a relative path inside the work folder"),
10547 "{message}"
10548 );
10549 }
10550 }
10551
10552 #[test]
10555 fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
10556 let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
10557 let yaml = format!(
10558 "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"
10559 );
10560
10561 assert_eq!(
10562 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
10563 .gateway
10564 .clone()
10565 .unwrap()
10566 .api_key,
10567 Some(ApiKeyReference::Environment(key.to_string()))
10568 );
10569 assert_eq!(
10570 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
10571 .unwrap()
10572 .artifacts[0]
10573 .gateway
10574 .clone()
10575 .unwrap()
10576 .api_key,
10577 None
10578 );
10579 }
10580
10581 #[test]
10582 fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
10583 let literal = ["sk-", "ant-", "debug"].concat();
10584 let debug = format!(
10585 "{:?} {:?}",
10586 ApiKeyReference::Environment("SOME_KEY".to_string()),
10587 ApiKeyReference::Literal(literal.clone())
10588 );
10589 assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
10590 assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
10591 assert!(!debug.contains(&literal), "{debug}");
10592 }
10593
10594 #[test]
10595 fn parse_without_secrets_drops_a_literal_api_key() {
10596 let literal = ["sk-", "ant-", "literal"].concat();
10599 let yaml = format!(
10600 "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"
10601 );
10602
10603 assert_eq!(
10604 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
10605 .gateway
10606 .clone()
10607 .unwrap()
10608 .api_key,
10609 Some(ApiKeyReference::Literal(literal))
10610 );
10611 assert_eq!(
10612 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
10613 .unwrap()
10614 .artifacts[0]
10615 .gateway
10616 .clone()
10617 .unwrap()
10618 .api_key,
10619 None
10620 );
10621 }
10622
10623 #[test]
10626 fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
10627 let process_with_key = format!(
10628 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n model: test-model\n api_key: {}\n",
10629 ["sk-", "ant-", "secret"].concat()
10630 );
10631 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";
10632
10633 for yaml in [process_with_key.as_str(), http_without_model] {
10634 let resolving = RuntimeManifest::from_yaml_str(yaml)
10635 .unwrap_err()
10636 .to_string();
10637 let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
10638 .unwrap_err()
10639 .to_string();
10640 assert_eq!(resolving, without_secrets);
10641 }
10642 }
10643
10644 #[test]
10648 fn referenced_env_variables_reports_a_reference_and_nothing_else() {
10649 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";
10650 let referenced = referenced_env_variables(referencing);
10651 assert_eq!(referenced.len(), 1);
10652 assert_eq!(
10653 referenced[0].field,
10654 "artifacts.murmur/driver-anthropic.gateway.api_key"
10655 );
10656 assert!(referenced[0].is_gateway_credential());
10657 assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
10658
10659 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";
10660 assert!(referenced_env_variables(literal).is_empty());
10661
10662 let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
10663 assert!(referenced_env_variables(no_inference).is_empty());
10664
10665 let unparseable = "name: broken\nversion: [\n";
10666 assert!(referenced_env_variables(unparseable).is_empty());
10667 }
10668
10669 #[test]
10670 fn inference_requires_driver_artifact() {
10671 let err = RuntimeManifest::from_yaml_str(
10672 r#"
10673name: cap
10674version: 0.0.1
10675artifacts: []
10676inference:
10677 transport: http
10678 endpoint: http://127.0.0.1:8080
10679 model: test-model
10680"#,
10681 )
10682 .unwrap_err();
10683
10684 assert!(err.to_string().contains("inference.driver"));
10685 }
10686
10687 #[test]
10688 fn test_runtime_driver_deserializes() {
10689 let manifest = RuntimeManifest::from_yaml_str(
10690 r#"
10691name: cap
10692version: 0.0.1
10693artifacts:
10694 - name: local/my-provider
10695 version: 1.0.0
10696 runtime: driver
10697"#,
10698 )
10699 .unwrap();
10700
10701 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
10702 }
10703
10704 #[test]
10705 fn test_runtime_unknown_is_error() {
10706 let err = RuntimeManifest::from_yaml_str(
10707 r#"
10708name: cap
10709version: 0.0.1
10710artifacts:
10711 - name: local/foo
10712 version: 1.0.0
10713 runtime: unknown_type
10714"#,
10715 )
10716 .unwrap_err();
10717
10718 let msg = err.to_string();
10719 assert!(msg.contains("runtime"), "error should mention 'runtime'");
10720 assert!(
10721 msg.contains("unknown_type"),
10722 "error should identify the bad value"
10723 );
10724 }
10725
10726 #[test]
10727 fn test_runtime_all_variants_roundtrip() {
10728 for variant in [
10729 ArtifactRuntime::Tool,
10730 ArtifactRuntime::Driver,
10731 ArtifactRuntime::Hook,
10732 ArtifactRuntime::Skill,
10733 ] {
10734 let json = serde_json::to_string(&variant).unwrap();
10735 let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
10736 assert_eq!(variant, back);
10737 }
10738 }
10739
10740 #[test]
10741 fn skill_source_without_version_defaults_to_local() {
10742 let manifest = RuntimeManifest::from_yaml_str(
10743 r#"
10744name: cap
10745version: 0.0.1
10746artifacts:
10747 - name: my-skill
10748 source: ./skills/my-skill/SKILL.md
10749 runtime: skill
10750"#,
10751 )
10752 .unwrap();
10753
10754 assert_eq!(manifest.artifacts.len(), 1);
10755 let artifact = &manifest.artifacts[0];
10756 assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
10757 assert_eq!(
10758 artifact.source.as_deref(),
10759 Some("./skills/my-skill/SKILL.md")
10760 );
10761 assert_eq!(artifact.version, "local");
10762 }
10763
10764 #[test]
10765 fn skill_source_directory_path_is_accepted() {
10766 let manifest = RuntimeManifest::from_yaml_str(
10767 r#"
10768name: cap
10769version: 0.0.1
10770artifacts:
10771 - name: my-skill
10772 source: ./skills/my-skill/
10773 runtime: skill
10774"#,
10775 )
10776 .unwrap();
10777
10778 assert_eq!(
10779 manifest.artifacts[0].source.as_deref(),
10780 Some("./skills/my-skill/")
10781 );
10782 assert_eq!(manifest.artifacts[0].version, "local");
10783 }
10784
10785 #[test]
10786 fn skill_source_with_explicit_version_substitutes_local() {
10787 let manifest = RuntimeManifest::from_yaml_str(
10789 r#"
10790name: cap
10791version: 0.0.1
10792artifacts:
10793 - name: my-skill
10794 source: ./skills/my-skill/SKILL.md
10795 version: 9.9.9
10796 runtime: skill
10797"#,
10798 )
10799 .unwrap();
10800
10801 assert_eq!(manifest.artifacts[0].version, "local");
10802 }
10803
10804 #[test]
10805 fn local_source_true_allows_source_on_non_skill_runtime() {
10806 for runtime in ["tool", "driver", "hook"] {
10807 let yaml = format!(
10808 r#"
10809name: cap
10810version: 0.0.1
10811artifacts:
10812 - name: my-thing
10813 source: ./local/path
10814 local_source: true
10815 runtime: {runtime}
10816"#
10817 );
10818 let manifest = RuntimeManifest::from_yaml_str(&yaml)
10819 .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
10820 let artifact = &manifest.artifacts[0];
10821 assert!(artifact.local_source, "runtime {runtime}");
10822 assert_eq!(artifact.source.as_deref(), Some("./local/path"));
10823 assert_eq!(artifact.version, "local", "runtime {runtime}");
10824 }
10825 }
10826
10827 #[test]
10828 fn local_source_false_on_skill_with_source_is_error() {
10829 let err = RuntimeManifest::from_yaml_str(
10832 r#"
10833name: cap
10834version: 0.0.1
10835artifacts:
10836 - name: my-skill
10837 source: ./skills/my-skill
10838 local_source: false
10839 runtime: skill
10840"#,
10841 )
10842 .unwrap_err();
10843 let msg = err.to_string();
10844 assert!(msg.contains("'local_source: true'"), "error was: {msg}");
10845 assert!(msg.contains("my-skill"), "error was: {msg}");
10846 }
10847
10848 #[test]
10849 fn local_source_defaults_from_role_when_absent() {
10850 let manifest = RuntimeManifest::from_yaml_str(
10851 r#"
10852name: cap
10853version: 0.0.1
10854artifacts:
10855 - name: my-skill
10856 source: ./skills/my-skill
10857 runtime: skill
10858 - name: local/my-tool
10859 version: 1.0.0
10860 runtime: tool
10861"#,
10862 )
10863 .unwrap();
10864 assert!(manifest.artifacts[0].local_source, "skill defaults to true");
10865 assert!(
10866 !manifest.artifacts[1].local_source,
10867 "tool defaults to false"
10868 );
10869 }
10870
10871 #[test]
10872 fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
10873 let manifest = RuntimeManifest::from_yaml_str(
10874 r#"
10875name: cap
10876version: 0.0.1
10877artifacts:
10878 - name: local/my-tool
10879 version: 1.0.0
10880 runtime: tool
10881 prompt_payload: true
10882 - name: murmur/driver-anthropic
10883 version: 0.1.0
10884 runtime: driver
10885 gateway:
10886 endpoint: http://127.0.0.1:8080
10887 keyless: true
10888inference:
10889 model: test-model
10890 system_prompt_artifact: local/my-tool
10891 driver:
10892 artifact: murmur/driver-anthropic
10893"#,
10894 )
10895 .unwrap();
10896 assert!(manifest.artifacts[0].prompt_payload);
10897 }
10898
10899 #[test]
10900 fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
10901 let err = RuntimeManifest::from_yaml_str(
10902 r#"
10903name: cap
10904version: 0.0.1
10905artifacts:
10906 - name: local/my-skill
10907 version: 1.0.0
10908 runtime: skill
10909 prompt_payload: false
10910 - name: murmur/driver-anthropic
10911 version: 0.1.0
10912 runtime: driver
10913 gateway:
10914 endpoint: http://127.0.0.1:8080
10915 keyless: true
10916inference:
10917 model: test-model
10918 system_prompt_artifact: local/my-skill
10919 driver:
10920 artifact: murmur/driver-anthropic
10921"#,
10922 )
10923 .unwrap_err();
10924 let msg = err.to_string();
10925 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
10926 }
10927
10928 #[test]
10929 fn prompt_payload_defaults_from_role_when_absent() {
10930 let manifest = RuntimeManifest::from_yaml_str(
10931 r#"
10932name: cap
10933version: 0.0.1
10934artifacts:
10935 - name: local/my-skill
10936 version: 1.0.0
10937 runtime: skill
10938 - name: local/my-tool
10939 version: 1.0.0
10940 runtime: tool
10941"#,
10942 )
10943 .unwrap();
10944 assert!(
10945 manifest.artifacts[0].prompt_payload,
10946 "skill defaults to true"
10947 );
10948 assert!(
10949 !manifest.artifacts[1].prompt_payload,
10950 "tool defaults to false"
10951 );
10952 }
10953
10954 #[test]
10955 fn source_on_non_skill_runtime_is_error() {
10956 for runtime in ["tool", "driver", "hook"] {
10957 let yaml = format!(
10958 r#"
10959name: cap
10960version: 0.0.1
10961artifacts:
10962 - name: my-thing
10963 source: ./local/path
10964 runtime: {runtime}
10965"#
10966 );
10967 let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
10968 let msg = err.to_string();
10969 assert!(
10970 msg.contains("'local_source: true'"),
10971 "runtime {runtime}: error was: {msg}"
10972 );
10973 assert!(
10974 msg.contains("my-thing"),
10975 "runtime {runtime}: error was: {msg}"
10976 );
10977 }
10978 }
10979
10980 #[test]
10981 fn registry_skill_still_requires_version() {
10982 let err = RuntimeManifest::from_yaml_str(
10983 r#"
10984name: cap
10985version: 0.0.1
10986artifacts:
10987 - name: local/my-skill
10988 runtime: skill
10989"#,
10990 )
10991 .unwrap_err();
10992
10993 let msg = err.to_string();
10994 assert!(
10995 msg.contains("missing required field 'version'"),
10996 "error was: {msg}"
10997 );
10998 }
10999
11000 #[test]
11001 fn rejects_unknown_inference_transport() {
11002 let err = RuntimeManifest::from_yaml_str(
11003 r#"
11004name: cap
11005version: 0.0.1
11006artifacts: []
11007inference:
11008 transport: grpc
11009 model: test-model
11010 driver:
11011 artifact: murmur/driver-anthropic
11012"#,
11013 )
11014 .unwrap_err();
11015
11016 let msg = err.to_string();
11017 assert!(msg.contains("inference.transport"), "error was: {msg}");
11018 assert!(msg.contains("unknown value"), "error was: {msg}");
11019 assert!(msg.contains("grpc"), "error was: {msg}");
11020 }
11021
11022 #[test]
11025 fn process_transport_with_command_and_no_driver_is_refused() {
11026 let err = RuntimeManifest::from_yaml_str(
11027 r#"
11028name: cap
11029version: 0.0.1
11030artifacts: []
11031inference:
11032 transport: process
11033 command: some-cli
11034 model: some-model
11035 max_turns: 20
11036"#,
11037 )
11038 .unwrap_err();
11039
11040 let msg = err.to_string();
11041 assert!(
11042 msg.contains("inference.driver.artifact"),
11043 "error was: {msg}"
11044 );
11045 assert!(msg.contains("inference.command"), "error was: {msg}");
11046 assert!(
11047 msg.contains("murmur/driver-claude-code"),
11048 "error was: {msg}"
11049 );
11050 }
11051
11052 #[test]
11053 fn process_transport_with_neither_driver_nor_command_is_refused() {
11054 let err = RuntimeManifest::from_yaml_str(
11055 r#"
11056name: cap
11057version: 0.0.1
11058artifacts: []
11059inference:
11060 transport: process
11061 model: some-model
11062"#,
11063 )
11064 .unwrap_err();
11065
11066 let msg = err.to_string();
11067 assert!(
11068 msg.contains("inference.driver.artifact"),
11069 "error was: {msg}"
11070 );
11071 assert!(
11072 msg.contains("murmur/driver-claude-code"),
11073 "error was: {msg}"
11074 );
11075 }
11076
11077 #[test]
11078 fn process_transport_allows_absent_model() {
11079 let manifest =
11082 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
11083 .expect("process transport should parse without a model");
11084 let inference = manifest.inference.expect("inference present");
11085 assert_eq!(inference.transport, "process");
11086 assert!(inference.command.is_none());
11087 assert_eq!(
11088 inference.model, "",
11089 "absent model resolves to empty (harness default)"
11090 );
11091 }
11092
11093 fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
11094 format!(
11095 "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n transport: process\n \
11096 driver:\n artifact: local/fixture-process-driver\n{inference_extra}"
11097 )
11098 }
11099
11100 const PROCESS_DRIVER_ENTRY: &str =
11101 " - name: local/fixture-process-driver\n version: 0.1.0\n runtime: driver";
11102
11103 #[test]
11104 fn process_transport_accepts_driver() {
11105 let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
11106 PROCESS_DRIVER_ENTRY,
11107 " config:\n profile: work\n command: /usr/local/bin/fixture-cli\n",
11108 ))
11109 .unwrap();
11110
11111 let inference = manifest.inference.expect("inference should exist");
11112 assert_eq!(inference.transport, "process");
11113 let driver = inference.driver.expect("driver should be parsed");
11114 assert_eq!(driver.artifact, "local/fixture-process-driver");
11115 assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
11116 assert_eq!(
11117 inference.command.as_deref(),
11118 Some("/usr/local/bin/fixture-cli")
11119 );
11120 }
11121
11122 #[test]
11123 fn process_transport_accepts_driver_without_command() {
11124 let manifest =
11125 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
11126 .unwrap();
11127
11128 let inference = manifest.inference.expect("inference should exist");
11129 assert_eq!(
11130 inference.driver.map(|d| d.artifact).as_deref(),
11131 Some("local/fixture-process-driver")
11132 );
11133 assert!(inference.command.is_none());
11134 assert_eq!(inference.model, "");
11135 }
11136
11137 #[test]
11138 fn process_driver_entry_must_be_declared() {
11139 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
11140 " - name: local/other\n version: 0.1.0\n runtime: tool",
11141 "",
11142 ))
11143 .unwrap_err()
11144 .to_string();
11145 assert!(msg.contains("inference.driver.artifact"), "{msg}");
11146 assert!(
11147 msg.contains("artifact 'local/fixture-process-driver' is not declared in artifacts:"),
11148 "{msg}"
11149 );
11150 }
11151
11152 #[test]
11153 fn process_driver_entry_must_be_runtime_driver() {
11154 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
11155 " - name: local/fixture-process-driver\n version: 0.1.0\n runtime: tool",
11156 "",
11157 ))
11158 .unwrap_err()
11159 .to_string();
11160 assert!(
11161 msg.contains("must be declared with runtime: driver"),
11162 "{msg}"
11163 );
11164 }
11165
11166 #[test]
11167 fn process_driver_entry_refuses_capabilities() {
11168 let entry =
11169 format!("{PROCESS_DRIVER_ENTRY}\n capabilities:\n env:\n allow: [HOME]");
11170 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
11171 .unwrap_err()
11172 .to_string();
11173 assert!(msg.contains("granted nothing"), "{msg}");
11174 assert!(msg.contains("inference.driver.config"), "{msg}");
11175 }
11176
11177 #[test]
11178 fn process_driver_entry_refuses_config() {
11179 let entry = format!("{PROCESS_DRIVER_ENTRY}\n config:\n profile: work");
11180 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
11181 .unwrap_err()
11182 .to_string();
11183 assert!(msg.contains("granted nothing"), "{msg}");
11184 assert!(msg.contains("remove capabilities:/config:"), "{msg}");
11185 }
11186
11187 #[test]
11188 fn process_transport_rejects_endpoint() {
11189 let err = RuntimeManifest::from_yaml_str(
11190 r#"
11191name: cap
11192version: 0.0.1
11193artifacts: []
11194inference:
11195 transport: process
11196 command: claude
11197 model: claude-haiku-4-5-20251001
11198 endpoint: http://localhost:8080
11199"#,
11200 )
11201 .unwrap_err();
11202
11203 let msg = err.to_string();
11204 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
11205 assert!(msg.contains("was removed"), "error was: {msg}");
11206 }
11207
11208 #[test]
11209 fn process_transport_rejects_api_key() {
11210 let key = ["sk-", "ant-", "secret"].concat();
11213 let err = RuntimeManifest::from_yaml_str(&format!(
11214 r#"
11215name: cap
11216version: 0.0.1
11217artifacts: []
11218inference:
11219 transport: process
11220 command: claude
11221 model: claude-haiku-4-5-20251001
11222 api_key: {key}
11223"#
11224 ))
11225 .unwrap_err();
11226
11227 let msg = err.to_string();
11228 assert!(msg.contains("inference.api_key"), "error was: {msg}");
11229 assert!(msg.contains("was removed"), "error was: {msg}");
11230 assert!(!msg.contains(&key), "error was: {msg}");
11231 }
11232
11233 #[test]
11234 fn process_transport_rejects_max_tokens() {
11235 let err = RuntimeManifest::from_yaml_str(
11238 r#"
11239name: cap
11240version: 0.0.1
11241artifacts: []
11242inference:
11243 transport: process
11244 command: claude
11245 model: claude-haiku-4-5-20251001
11246 max_tokens: 4096
11247"#,
11248 )
11249 .unwrap_err();
11250
11251 let msg = err.to_string();
11252 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
11253 assert!(
11254 msg.contains("not valid with transport: process"),
11255 "error was: {msg}"
11256 );
11257 }
11258
11259 #[test]
11263 fn process_transport_carries_max_session_tokens() {
11264 let manifest = RuntimeManifest::from_yaml_str(
11265 r#"
11266name: cap
11267version: 0.0.1
11268artifacts:
11269 - name: murmur/driver-claude-code
11270 version: 0.1.0
11271 runtime: driver
11272inference:
11273 transport: process
11274 driver:
11275 artifact: murmur/driver-claude-code
11276 model: claude-haiku-4-5-20251001
11277 max_session_tokens: 200000
11278"#,
11279 )
11280 .unwrap();
11281
11282 assert_eq!(
11283 manifest.inference.unwrap().max_session_tokens,
11284 Some(200_000)
11285 );
11286 }
11287
11288 #[test]
11289 fn process_transport_rejects_a_zero_max_session_tokens() {
11290 let err = RuntimeManifest::from_yaml_str(
11291 r#"
11292name: cap
11293version: 0.0.1
11294artifacts:
11295 - name: murmur/driver-claude-code
11296 version: 0.1.0
11297 runtime: driver
11298inference:
11299 transport: process
11300 driver:
11301 artifact: murmur/driver-claude-code
11302 model: claude-haiku-4-5-20251001
11303 max_session_tokens: 0
11304"#,
11305 )
11306 .unwrap_err();
11307
11308 let msg = err.to_string();
11309 assert!(
11310 msg.contains("inference.max_session_tokens"),
11311 "error was: {msg}"
11312 );
11313 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
11314 }
11315
11316 #[test]
11317 fn inference_max_session_tokens_round_trips() {
11318 let manifest = RuntimeManifest::from_yaml_str(
11319 r#"
11320name: cap
11321version: 0.0.1
11322artifacts:
11323 - name: murmur/driver-anthropic
11324 version: 0.1.0
11325 runtime: driver
11326 gateway:
11327 endpoint: https://api.anthropic.com
11328 api_key: test-key
11329inference:
11330 transport: http
11331 model: claude-opus-4-5
11332 max_session_tokens: 5000000000
11333 driver:
11334 artifact: murmur/driver-anthropic
11335"#,
11336 )
11337 .unwrap();
11338
11339 assert_eq!(
11340 manifest.inference.unwrap().max_session_tokens,
11341 Some(5_000_000_000)
11342 );
11343 }
11344
11345 #[test]
11346 fn inference_max_session_tokens_absent_is_none() {
11347 let manifest = RuntimeManifest::from_yaml_str(
11348 r#"
11349name: cap
11350version: 0.0.1
11351artifacts:
11352 - name: murmur/driver-anthropic
11353 version: 0.1.0
11354 runtime: driver
11355 gateway:
11356 endpoint: https://api.anthropic.com
11357 api_key: test-key
11358inference:
11359 transport: http
11360 model: claude-opus-4-5
11361 driver:
11362 artifact: murmur/driver-anthropic
11363"#,
11364 )
11365 .unwrap();
11366
11367 assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
11368 }
11369
11370 #[test]
11371 fn inference_max_session_tokens_zero_is_rejected() {
11372 let err = RuntimeManifest::from_yaml_str(
11373 r#"
11374name: cap
11375version: 0.0.1
11376artifacts:
11377 - name: murmur/driver-anthropic
11378 version: 0.1.0
11379 runtime: driver
11380 gateway:
11381 endpoint: https://api.anthropic.com
11382 api_key: test-key
11383inference:
11384 transport: http
11385 model: claude-opus-4-5
11386 max_session_tokens: 0
11387 driver:
11388 artifact: murmur/driver-anthropic
11389"#,
11390 )
11391 .unwrap_err();
11392
11393 let msg = err.to_string();
11394 assert!(
11395 msg.contains("inference.max_session_tokens"),
11396 "error was: {msg}"
11397 );
11398 assert!(msg.contains("greater than 0"), "error was: {msg}");
11399 }
11400
11401 fn http_manifest_with_inference(extra: &str) -> String {
11403 format!(
11404 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n \
11405 version: 0.1.0\n runtime: driver\n gateway:\n \
11406 endpoint: https://api.anthropic.com\n api_key: test-key\n\
11407 inference:\n transport: http\n model: claude-opus-4-5\n driver:\n \
11408 artifact: murmur/driver-anthropic\n{extra}"
11409 )
11410 }
11411
11412 #[test]
11413 fn inference_tool_refresh_absent_is_compaction() {
11414 let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
11415 assert_eq!(
11416 manifest.inference.unwrap().tool_refresh,
11417 ToolRefresh::Compaction
11418 );
11419 assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
11420 }
11421
11422 #[test]
11423 fn inference_tool_refresh_parses_both_values() {
11424 for (value, expected) in [
11425 ("compaction", ToolRefresh::Compaction),
11426 ("immediate", ToolRefresh::Immediate),
11427 ] {
11428 let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
11429 " tool_refresh: {value}\n"
11430 )))
11431 .unwrap();
11432 assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
11433 assert_eq!(expected.wire_name(), value);
11434 assert!(
11435 manifest.unknown_keys.is_empty(),
11436 "tool_refresh is a known key: {:?}",
11437 manifest.unknown_keys
11438 );
11439 }
11440 }
11441
11442 #[test]
11443 fn inference_tool_refresh_rejects_an_unknown_value() {
11444 let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
11445 " tool_refresh: sometimes\n",
11446 ))
11447 .unwrap_err();
11448 match err {
11449 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
11450 assert_eq!(field, "inference.tool_refresh");
11451 assert_eq!(
11452 message,
11453 "must be one of: compaction, immediate; got 'sometimes'"
11454 );
11455 }
11456 other => panic!("unexpected error: {other:?}"),
11457 }
11458 }
11459
11460 #[test]
11463 fn process_transport_rejects_tool_refresh() {
11464 for value in ["compaction", "immediate"] {
11465 let err = RuntimeManifest::from_yaml_str(&format!(
11466 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n \
11467 command: claude\n model: claude-haiku-4-5-20251001\n tool_refresh: {value}\n"
11468 ))
11469 .unwrap_err();
11470 match err {
11471 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
11472 assert_eq!(field, "inference.tool_refresh");
11473 assert_eq!(message, "is not valid with transport: process");
11474 }
11475 other => panic!("unexpected error for {value}: {other:?}"),
11476 }
11477 }
11478 }
11479
11480 #[test]
11481 fn inference_max_tokens_round_trips() {
11482 let manifest = RuntimeManifest::from_yaml_str(
11483 r#"
11484name: cap
11485version: 0.0.1
11486artifacts:
11487 - name: murmur/driver-anthropic
11488 version: 0.1.0
11489 runtime: driver
11490 gateway:
11491 endpoint: https://api.anthropic.com
11492 api_key: test-key
11493inference:
11494 transport: http
11495 model: claude-opus-4-5
11496 max_tokens: 4096
11497 driver:
11498 artifact: murmur/driver-anthropic
11499"#,
11500 )
11501 .unwrap();
11502
11503 let inference = manifest.inference.unwrap();
11504 assert_eq!(inference.max_tokens, Some(4096));
11505 }
11506
11507 #[test]
11508 fn inference_max_tokens_absent_is_none() {
11509 let manifest = RuntimeManifest::from_yaml_str(
11512 r#"
11513name: cap
11514version: 0.0.1
11515artifacts:
11516 - name: murmur/driver-anthropic
11517 version: 0.1.0
11518 runtime: driver
11519 gateway:
11520 endpoint: https://api.anthropic.com
11521 api_key: test-key
11522inference:
11523 transport: http
11524 model: claude-opus-4-5
11525 driver:
11526 artifact: murmur/driver-anthropic
11527"#,
11528 )
11529 .unwrap();
11530
11531 assert_eq!(manifest.inference.unwrap().max_tokens, None);
11532 }
11533
11534 #[test]
11535 fn inference_max_tokens_zero_is_rejected() {
11536 let err = RuntimeManifest::from_yaml_str(
11537 r#"
11538name: cap
11539version: 0.0.1
11540artifacts:
11541 - name: murmur/driver-anthropic
11542 version: 0.1.0
11543 runtime: driver
11544 gateway:
11545 endpoint: https://api.anthropic.com
11546 api_key: test-key
11547inference:
11548 transport: http
11549 model: claude-opus-4-5
11550 max_tokens: 0
11551 driver:
11552 artifact: murmur/driver-anthropic
11553"#,
11554 )
11555 .unwrap_err();
11556
11557 let msg = err.to_string();
11558 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
11559 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
11560 }
11561
11562 #[test]
11563 fn inference_max_tokens_large_value_is_accepted() {
11564 let manifest = RuntimeManifest::from_yaml_str(
11567 r#"
11568name: cap
11569version: 0.0.1
11570artifacts:
11571 - name: murmur/driver-anthropic
11572 version: 0.1.0
11573 runtime: driver
11574 gateway:
11575 endpoint: https://api.anthropic.com
11576 api_key: test-key
11577inference:
11578 transport: http
11579 model: claude-opus-4-5
11580 max_tokens: 999999
11581 driver:
11582 artifact: murmur/driver-anthropic
11583"#,
11584 )
11585 .unwrap();
11586
11587 assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
11588 }
11589
11590 #[test]
11591 fn inference_and_context_max_tokens_are_independent() {
11592 let manifest = RuntimeManifest::from_yaml_str(
11595 r#"
11596name: cap
11597version: 0.0.1
11598artifacts:
11599 - name: murmur/driver-anthropic
11600 version: 0.1.0
11601 runtime: driver
11602 gateway:
11603 endpoint: https://api.anthropic.com
11604 api_key: test-key
11605inference:
11606 transport: http
11607 model: claude-opus-4-5
11608 max_tokens: 4096
11609 driver:
11610 artifact: murmur/driver-anthropic
11611context:
11612 max_tokens: 200000
11613"#,
11614 )
11615 .unwrap();
11616
11617 assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
11618 assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
11619 }
11620
11621 #[test]
11622 fn hook_config_defaults_when_fields_absent() {
11623 let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
11624 assert_eq!(config.binding, HookBinding::All);
11625 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
11626 assert_eq!(config.commit_policy, HookCommitPolicy::None);
11627 }
11628
11629 #[test]
11630 fn hook_config_parses_all_fields() {
11631 let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
11632 let config = parse_hook_config_from_yaml(yaml).unwrap();
11633 assert_eq!(config.binding, HookBinding::OnCompaction);
11634 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
11635 assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
11636 }
11637
11638 #[test]
11639 fn hook_config_parses_on_task_start() {
11640 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
11641 let config = parse_hook_config_from_yaml(yaml).unwrap();
11642 assert_eq!(config.binding, HookBinding::OnTaskStart);
11643 }
11644
11645 #[test]
11646 fn hook_config_parses_on_task_end() {
11647 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
11648 let config = parse_hook_config_from_yaml(yaml).unwrap();
11649 assert_eq!(config.binding, HookBinding::OnTaskEnd);
11650 }
11651
11652 #[test]
11653 fn hook_config_unknown_binding_lists_task_events() {
11654 let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
11655 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11656 assert!(err.contains("on-task-start"), "error was: {err}");
11657 assert!(err.contains("on-task-end"), "error was: {err}");
11658 }
11659
11660 #[test]
11661 fn hook_config_async_with_commit_is_rejected() {
11662 let yaml =
11663 "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
11664 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11665 assert!(err.contains("async-with-commit"), "error was: {err}");
11666 }
11667
11668 #[test]
11669 fn hook_config_on_stage_async_is_rejected() {
11670 let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
11671 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11672 assert!(err.contains("on-stage"), "error was: {err}");
11673 assert!(err.contains("blocking"), "error was: {err}");
11674 }
11675
11676 #[test]
11677 fn hook_config_async_none_is_valid() {
11678 let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
11679 let config = parse_hook_config_from_yaml(yaml).unwrap();
11680 assert_eq!(config.execution_mode, HookExecutionMode::Async);
11681 assert_eq!(config.commit_policy, HookCommitPolicy::None);
11682 }
11683
11684 #[test]
11685 fn hook_config_parses_reopen_task_commit_policy() {
11686 let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
11687 let config = parse_hook_config_from_yaml(yaml).unwrap();
11688 assert_eq!(config.binding, HookBinding::OnTaskEnd);
11689 assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
11690 }
11691
11692 #[test]
11693 fn hook_config_unknown_commit_policy_lists_reopen_task() {
11694 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11695 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11696 assert!(err.contains("reopen-task"), "error was: {err}");
11697 }
11698
11699 #[test]
11700 fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
11701 for binding in ["on-shell", "on-tool-call"] {
11702 let yaml =
11703 format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
11704 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11705 assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
11706 }
11707 assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
11708 }
11709
11710 #[test]
11713 fn hook_config_deny_without_a_binding_is_rejected() {
11714 let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
11715 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11716 assert!(
11717 err.contains("requires an explicit binding"),
11718 "error was: {err}"
11719 );
11720 assert!(err.contains("on-shell"), "error was: {err}");
11721 assert!(err.contains("on-tool-call"), "error was: {err}");
11722 }
11723
11724 #[test]
11726 fn hook_config_deny_on_an_ungated_binding_is_rejected() {
11727 let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
11728 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11729 assert!(err.contains("is not valid for binding"), "error was: {err}");
11730 assert!(err.contains("replace-context"), "error was: {err}");
11731 }
11732
11733 #[test]
11735 fn hook_config_async_deny_is_rejected() {
11736 let yaml =
11737 "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
11738 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11739 assert!(err.contains("async-with-commit"), "error was: {err}");
11740 assert!(err.contains("deny"), "error was: {err}");
11741 }
11742
11743 #[test]
11744 fn hook_config_unknown_commit_policy_lists_deny() {
11745 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11746 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11747 assert!(err.contains("deny"), "error was: {err}");
11748 }
11749
11750 #[test]
11753 fn hook_config_async_reopen_task_is_rejected() {
11754 let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
11755 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11756 assert!(err.contains("async-with-commit"), "error was: {err}");
11757 assert!(err.contains("reopen-task"), "error was: {err}");
11758 }
11759
11760 #[test]
11766 fn hook_config_non_committing_binding_rejects_any_commit_policy() {
11767 for binding in ["on-session-start", "on-session-end"] {
11768 let yaml = format!(
11769 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
11770 );
11771 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11772 assert!(err.contains(binding), "error was: {err}");
11773 assert!(err.contains("replace-context"), "error was: {err}");
11774 assert!(
11775 err.contains("honors commit_policy 'none'"),
11776 "error was: {err}"
11777 );
11778 }
11779 }
11780
11781 #[test]
11784 fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
11785 for (binding, declared, honored) in [
11786 ("on-stage", "reopen-task", "write-manifests"),
11787 ("on-compaction", "write-manifests", "replace-context"),
11788 ("on-task-end", "replace-context", "reopen-task"),
11789 ("on-task-start", "replace-context", "seed-context"),
11790 ("on-shell", "replace-context", "deny"),
11791 ("on-tool-call", "replace-context", "deny"),
11792 ] {
11793 let yaml = format!(
11794 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
11795 );
11796 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11797 assert!(err.contains(binding), "error was: {err}");
11798 assert!(
11799 err.contains(&format!("commit_policy '{declared}' is not valid")),
11800 "error was: {err}"
11801 );
11802 assert!(
11803 err.contains(&format!("honors commit_policy '{honored}'")),
11804 "error was: {err}"
11805 );
11806 }
11807 }
11808
11809 #[test]
11812 fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
11813 for declared in ["replace-context", "write-manifests", "reopen-task"] {
11814 let yaml = format!(
11815 "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
11816 );
11817 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11818 assert!(err.contains("on-inference"), "error was: {err}");
11819 assert!(err.contains(declared), "error was: {err}");
11820 assert!(
11821 err.contains("honors commit_policy 'none'"),
11822 "error was: {err}"
11823 );
11824 assert!(err.contains("artifact"), "error was: {err}");
11825 }
11826 }
11827
11828 #[test]
11831 fn hook_config_matching_binding_and_commit_policy_is_valid() {
11832 for (binding, policy, expected) in [
11833 (
11834 "on-stage",
11835 "write-manifests",
11836 HookCommitPolicy::WriteManifests,
11837 ),
11838 (
11839 "on-compaction",
11840 "replace-context",
11841 HookCommitPolicy::ReplaceContext,
11842 ),
11843 ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
11844 ("on-shell", "deny", HookCommitPolicy::Deny),
11845 ("on-tool-call", "deny", HookCommitPolicy::Deny),
11846 ] {
11847 let yaml =
11848 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
11849 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11850 assert_eq!(config.commit_policy, expected);
11851 }
11852 }
11853
11854 #[test]
11857 fn hook_config_commit_policy_none_is_valid_for_every_binding() {
11858 for binding in [
11859 "on-stage",
11860 "on-session-start",
11861 "on-task-start",
11862 "on-inference",
11863 "on-tool-call",
11864 "on-shell",
11865 "on-compaction",
11866 "on-task-end",
11867 "on-session-end",
11868 ] {
11869 let yaml =
11870 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
11871 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11872 assert_eq!(config.commit_policy, HookCommitPolicy::None);
11873 }
11874 }
11875
11876 #[test]
11881 fn hook_config_omitted_binding_accepts_every_commit_policy() {
11882 for (policy, expected) in [
11883 ("replace-context", HookCommitPolicy::ReplaceContext),
11884 ("write-manifests", HookCommitPolicy::WriteManifests),
11885 ("reopen-task", HookCommitPolicy::ReopenTask),
11886 ("none", HookCommitPolicy::None),
11887 ] {
11888 let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
11889 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11890 assert_eq!(config.binding, HookBinding::All);
11891 assert_eq!(config.commit_policy, expected);
11892 }
11893 }
11894
11895 #[test]
11900 fn hook_config_shipped_default_artifact_manifests_still_parse() {
11901 let shipped: [(&str, &str, HookConfig); 8] = [
11902 (
11903 "murmur-hook-compact",
11904 "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
11905 HookConfig {
11906 binding: HookBinding::OnCompaction,
11907 execution_mode: HookExecutionMode::Blocking,
11908 commit_policy: HookCommitPolicy::ReplaceContext,
11909 },
11910 ),
11911 (
11912 "murmur-hook-shell-desc",
11913 "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
11914 HookConfig {
11915 binding: HookBinding::OnStage,
11916 execution_mode: HookExecutionMode::Blocking,
11917 commit_policy: HookCommitPolicy::WriteManifests,
11918 },
11919 ),
11920 (
11921 "murmur-hook-regression-verifier",
11923 "execution_mode: blocking\ncommit_policy: reopen-task\n",
11924 HookConfig {
11925 binding: HookBinding::All,
11926 execution_mode: HookExecutionMode::Blocking,
11927 commit_policy: HookCommitPolicy::ReopenTask,
11928 },
11929 ),
11930 (
11931 "murmur-hook-memory",
11932 "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
11933 HookConfig {
11934 binding: HookBinding::OnTaskStart,
11935 execution_mode: HookExecutionMode::Blocking,
11936 commit_policy: HookCommitPolicy::SeedContext,
11937 },
11938 ),
11939 (
11940 "murmur-hook-debug",
11941 "execution_mode: async\ncommit_policy: none\n",
11942 HookConfig {
11943 binding: HookBinding::All,
11944 execution_mode: HookExecutionMode::Async,
11945 commit_policy: HookCommitPolicy::None,
11946 },
11947 ),
11948 (
11949 "murmur-hook-diff-summary",
11950 "execution_mode: blocking\ncommit_policy: none\n",
11951 HookConfig {
11952 binding: HookBinding::All,
11953 execution_mode: HookExecutionMode::Blocking,
11954 commit_policy: HookCommitPolicy::None,
11955 },
11956 ),
11957 ("murmur-hook-eval", "", HookConfig::default()),
11958 ("murmur-hook-grafana", "", HookConfig::default()),
11959 ];
11960
11961 for (name, fields, expected) in shipped {
11962 let yaml = format!("name: {name}\nruntime: hook\n{fields}");
11963 let config = parse_hook_config_from_yaml(&yaml)
11964 .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
11965 assert_eq!(config, expected, "shipped hook {name}");
11966 }
11967 }
11968
11969 #[test]
11973 fn commit_policy_for_binding_matches_the_declarable_pairs() {
11974 assert_eq!(
11975 commit_policy_for_binding(&HookBinding::OnStage),
11976 Some(HookCommitPolicy::WriteManifests)
11977 );
11978 assert_eq!(
11979 commit_policy_for_binding(&HookBinding::OnCompaction),
11980 Some(HookCommitPolicy::ReplaceContext)
11981 );
11982 assert_eq!(
11983 commit_policy_for_binding(&HookBinding::OnTaskEnd),
11984 Some(HookCommitPolicy::ReopenTask)
11985 );
11986 assert_eq!(
11987 commit_policy_for_binding(&HookBinding::OnTaskStart),
11988 Some(HookCommitPolicy::SeedContext)
11989 );
11990 assert_eq!(
11991 commit_policy_for_binding(&HookBinding::OnToolCall),
11992 Some(HookCommitPolicy::Deny)
11993 );
11994 assert_eq!(
11995 commit_policy_for_binding(&HookBinding::OnShell),
11996 Some(HookCommitPolicy::Deny)
11997 );
11998 for binding in [
12002 HookBinding::OnSessionStart,
12003 HookBinding::OnInference,
12004 HookBinding::OnSessionEnd,
12005 HookBinding::All,
12006 ] {
12007 assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
12008 }
12009 }
12010
12011 #[test]
12014 fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
12015 let config = parse_hook_config_from_yaml(
12016 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
12017 commit_policy: seed-context\n",
12018 )
12019 .expect("on-task-start + seed-context is the declarable pairing");
12020 assert_eq!(config.binding, HookBinding::OnTaskStart);
12021 assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
12022
12023 let error = parse_hook_config_from_yaml(
12024 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
12025 commit_policy: seed-context\n",
12026 )
12027 .expect_err("on-compaction cannot commit a seed");
12028 assert!(error.contains("on-compaction"), "{error}");
12029 assert!(error.contains("seed-context"), "{error}");
12030 }
12031
12032 #[test]
12035 fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
12036 let error = parse_hook_config_from_yaml(
12037 "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
12038 )
12039 .expect_err("a misspelled policy is rejected");
12040 assert!(error.contains("seed-context"), "{error}");
12041 }
12042
12043 fn context_of(yaml_block: &str) -> Option<ContextConfig> {
12046 RuntimeManifest::from_yaml_str(&format!(
12047 "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
12048 ))
12049 .expect("the manifest under test parses")
12050 .context
12051 }
12052
12053 #[test]
12054 fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
12055 let context = context_of(
12056 "context:\n max_tokens: 200000\n seed_budget: 0.10\n seed_overflow_margin: 0.10\n",
12057 )
12058 .expect("a declared context block parses to Some");
12059 assert_eq!(context.max_tokens, Some(200_000));
12060 assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
12061 assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
12062 }
12063
12064 #[test]
12065 fn seed_budget_and_overflow_margin_default_when_omitted() {
12066 let context = context_of("context:\n max_tokens: 200000\n")
12067 .expect("a declared context block parses to Some");
12068 assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
12069 assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
12070 }
12071
12072 #[test]
12075 fn seed_budget_defaults_do_not_create_a_context_block() {
12076 assert_eq!(context_of(""), None);
12077 }
12078
12079 #[test]
12080 fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
12081 for (block, field) in [
12082 ("context:\n seed_budget: 1.5\n", "context.seed_budget"),
12083 ("context:\n seed_budget: -0.1\n", "context.seed_budget"),
12084 (
12085 "context:\n seed_overflow_margin: 2.0\n",
12086 "context.seed_overflow_margin",
12087 ),
12088 ] {
12089 let error = RuntimeManifest::from_yaml_str(&format!(
12090 "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
12091 ))
12092 .expect_err("an out-of-range fraction is rejected");
12093 match error {
12094 RuntimeManifestError::InvalidInferenceConfig {
12095 field: got,
12096 ref message,
12097 } => {
12098 assert_eq!(got, field);
12099 assert!(message.contains("0.0 and 1.0"), "{message}");
12100 }
12101 other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
12102 }
12103 }
12104 }
12105
12106 #[test]
12107 fn deploy_block_mur_version_parses() {
12108 let manifest = RuntimeManifest::from_yaml_str(
12109 "name: cap\nversion: 0.1.0\nartifacts: []\ndeploy:\n mur_version: \"0.4.5\"\n",
12110 )
12111 .unwrap();
12112 assert_eq!(manifest.deploy_mur_version(), Some("0.4.5"));
12113 assert_eq!(
12114 manifest.deploy,
12115 Some(DeployConfig {
12116 mur_version: Some("0.4.5".to_string())
12117 })
12118 );
12119 }
12120
12121 #[test]
12122 fn deploy_block_absent_is_none() {
12123 let manifest =
12124 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
12125 assert_eq!(manifest.deploy, None);
12126 assert_eq!(manifest.deploy_mur_version(), None);
12127 }
12128
12129 #[test]
12131 fn deploy_block_blank_mur_version_is_none() {
12132 for deploy in [
12133 "deploy:\n mur_version: \"\"\n",
12134 "deploy:\n mur_version: \" \"\n",
12135 ] {
12136 let manifest = RuntimeManifest::from_yaml_str(&format!(
12137 "name: cap\nversion: 0.1.0\nartifacts: []\n{deploy}"
12138 ))
12139 .unwrap();
12140 assert_eq!(manifest.deploy_mur_version(), None, "{deploy:?}");
12141 }
12142 let manifest =
12143 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\ndeploy: {}\n").unwrap();
12144 assert_eq!(manifest.deploy, Some(DeployConfig::default()));
12145 assert_eq!(manifest.deploy_mur_version(), None);
12146 }
12147
12148 #[test]
12149 fn deploy_block_misspelled_key_is_reported_under_deploy() {
12150 assert_eq!(
12151 unknown_keys_of("name: cap\nversion: 0.1.0\ndeploy:\n mur_versoin: \"0.4.5\"\n"),
12152 vec![(
12153 "mur_versoin".to_string(),
12154 "deploy".to_string(),
12155 Some("mur_version".to_string())
12156 )]
12157 );
12158 }
12159
12160 #[test]
12161 fn a_top_level_mur_version_is_refused_naming_deploy_mur_version() {
12162 for pin in [
12163 "mur_version: \"0.4.5\"\n",
12164 "mur_version: 0.4.0\n",
12165 "mur_version: \"\"\n",
12166 "mur_version:\n",
12167 ] {
12168 let error = RuntimeManifest::from_yaml_str(&format!(
12169 "name: cap\nversion: 0.1.0\nartifacts: []\n{pin}"
12170 ))
12171 .expect_err(pin);
12172 assert!(
12173 matches!(error, RuntimeManifestError::TopLevelMurVersion),
12174 "{pin:?}: {error:?}"
12175 );
12176 assert!(error.to_string().contains("deploy.mur_version"), "{error}");
12177 }
12178 assert_eq!(
12179 RuntimeManifestError::TopLevelMurVersion.to_string(),
12180 "murmur.yaml: 'mur_version' is not a top-level key — the mur release a deploy \
12181 installs is pinned as 'deploy.mur_version'"
12182 );
12183 }
12184
12185 #[test]
12186 fn a_top_level_mur_version_is_refused_beside_a_deploy_block() {
12187 let yaml = "name: cap\nversion: 0.1.0\nmur_version: \"0.4.5\"\ndeploy:\n mur_version: \"0.4.5\"\n";
12188 assert!(matches!(
12189 RuntimeManifest::from_yaml_str(yaml),
12190 Err(RuntimeManifestError::TopLevelMurVersion)
12191 ));
12192 assert!(matches!(
12193 RuntimeManifest::from_yaml_str_without_secrets(yaml),
12194 Err(RuntimeManifestError::TopLevelMurVersion)
12195 ));
12196 }
12197
12198 #[test]
12199 fn inference_max_turns_absent_sets_no_cap() {
12200 let manifest = RuntimeManifest::from_yaml_str(
12201 "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",
12202 ).unwrap();
12203 assert_eq!(manifest.inference.unwrap().max_turns, None);
12204 }
12205
12206 #[test]
12207 fn inference_max_turns_explicit_value() {
12208 let manifest = RuntimeManifest::from_yaml_str(
12209 "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",
12210 ).unwrap();
12211 assert_eq!(manifest.inference.unwrap().max_turns, Some(20));
12212 }
12213
12214 #[test]
12215 fn inference_max_turns_zero_is_rejected() {
12216 let err = RuntimeManifest::from_yaml_str(
12217 "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",
12218 ).unwrap_err();
12219 let msg = err.to_string();
12220 assert!(msg.contains("inference.max_turns"), "error was: {msg}");
12221 assert!(msg.contains("greater than 0"), "error was: {msg}");
12222 }
12223
12224 #[test]
12226 fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
12227 let manifest = RuntimeManifest::from_yaml_str(
12228 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 45\n",
12229 )
12230 .unwrap();
12231 let lifecycle = manifest.lifecycle.unwrap();
12232 assert_eq!(lifecycle.shell_grace_secs, Some(45));
12233 assert_eq!(
12235 lifecycle.task_acceptance,
12236 LifecycleConfig::default().task_acceptance
12237 );
12238 assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
12239 assert_eq!(
12240 lifecycle.queue_depth,
12241 LifecycleConfig::default().queue_depth
12242 );
12243 assert_eq!(lifecycle.input_timeout_secs, None);
12244 assert_eq!(
12245 lifecycle.conversation_mode,
12246 LifecycleConfig::default().conversation_mode
12247 );
12248 assert_eq!(
12249 lifecycle.max_task_reopens,
12250 LifecycleConfig::default().max_task_reopens
12251 );
12252 }
12253
12254 #[test]
12256 fn lifecycle_shell_grace_secs_is_unset_when_not_declared() {
12257 let manifest = RuntimeManifest::from_yaml_str(
12258 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12259 )
12260 .unwrap();
12261 let lifecycle = manifest.lifecycle.unwrap();
12262 assert_eq!(lifecycle.shell_grace_secs, None);
12263 assert_eq!(lifecycle.queue_depth, 3);
12264
12265 let without_block =
12266 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12267 assert_eq!(without_block.effective_lifecycle().shell_grace_secs, None);
12268 assert_eq!(LifecycleConfig::default().shell_grace_secs, None);
12269 }
12270
12271 #[test]
12274 fn lifecycle_delegation_deadline_secs_defaults_to_600() {
12275 let without_block =
12276 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12277 assert_eq!(
12278 without_block.effective_lifecycle().delegation_deadline_secs,
12279 600
12280 );
12281
12282 let without_key = RuntimeManifest::from_yaml_str(
12283 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12284 )
12285 .unwrap();
12286 let lifecycle = without_key.lifecycle.unwrap();
12287 assert_eq!(lifecycle.delegation_deadline_secs, 600);
12288 assert_eq!(lifecycle.queue_depth, 3);
12289 }
12290
12291 #[test]
12292 fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
12293 let manifest = RuntimeManifest::from_yaml_str(
12294 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
12295 delegation_deadline_secs: 30\n",
12296 )
12297 .unwrap();
12298 let lifecycle = manifest.lifecycle.unwrap();
12299 assert_eq!(lifecycle.delegation_deadline_secs, 30);
12300 assert_eq!(
12302 lifecycle.shell_grace_secs,
12303 LifecycleConfig::default().shell_grace_secs
12304 );
12305 assert_eq!(
12306 lifecycle.max_task_reopens,
12307 LifecycleConfig::default().max_task_reopens
12308 );
12309 }
12310
12311 #[test]
12313 fn lifecycle_delegation_deadline_secs_is_a_known_key() {
12314 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
12315 let manifest = RuntimeManifest::from_yaml_str(
12316 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
12317 delegation_deadline_secs: 30\n delegation_deadline: 30\n",
12318 )
12319 .unwrap();
12320 let reported: Vec<(String, String)> = manifest
12321 .unknown_keys
12322 .iter()
12323 .map(|key| (key.key.clone(), key.block_path.clone()))
12324 .collect();
12325 assert_eq!(
12326 reported,
12327 vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
12328 "the declared key is known and only the misspelt neighbour is reported"
12329 );
12330 assert_eq!(
12331 manifest.lifecycle.unwrap().delegation_deadline_secs,
12332 30,
12333 "the misspelt neighbour does not disturb the declared value"
12334 );
12335 }
12336
12337 #[test]
12339 fn lifecycle_deadline_notice_secs_defaults_to_none() {
12340 let without_block =
12341 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12342 assert_eq!(
12343 without_block.effective_lifecycle().deadline_notice_secs,
12344 None
12345 );
12346 let without_key = RuntimeManifest::from_yaml_str(
12347 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12348 )
12349 .unwrap();
12350 assert_eq!(without_key.lifecycle.unwrap().deadline_notice_secs, None);
12351 }
12352
12353 #[test]
12355 fn lifecycle_deadline_notice_secs_takes_zero_and_positive_values() {
12356 for (declared, expected) in [("0", 0), ("90", 90)] {
12357 let manifest = RuntimeManifest::from_yaml_str(&format!(
12358 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
12359 deadline_notice_secs: {declared}\n"
12360 ))
12361 .unwrap();
12362 assert!(manifest.unknown_keys.is_empty());
12363 assert_eq!(
12364 manifest.lifecycle.unwrap().deadline_notice_secs,
12365 Some(expected)
12366 );
12367 }
12368 }
12369
12370 #[test]
12371 fn lifecycle_deadline_notice_secs_refuses_a_negative_or_non_integer_value() {
12372 for declared in ["-5", "1.5", "soon"] {
12373 let refused = RuntimeManifest::from_yaml_str(&format!(
12374 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
12375 deadline_notice_secs: {declared}\n"
12376 ));
12377 assert!(refused.is_err(), "{declared} is refused: {refused:?}");
12378 }
12379 }
12380
12381 #[test]
12382 fn lifecycle_deadline_notice_secs_is_a_known_key() {
12383 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"deadline_notice_secs"));
12384 }
12385
12386 fn lifecycle_with_keep_working(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
12387 RuntimeManifest::from_yaml_str(&format!(
12388 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n keep_working:\n{block}"
12389 ))
12390 }
12391
12392 fn refused_keep_working_field(block: &str) -> (String, String) {
12393 match lifecycle_with_keep_working(block) {
12394 Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
12395 (field, message)
12396 }
12397 other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
12398 }
12399 }
12400
12401 #[test]
12402 fn lifecycle_keep_working_is_off_when_absent() {
12403 let without_block =
12404 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12405 assert_eq!(without_block.effective_lifecycle().keep_working, None);
12406 let without_key = RuntimeManifest::from_yaml_str(
12407 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12408 )
12409 .unwrap();
12410 assert_eq!(without_key.lifecycle.unwrap().keep_working, None);
12411 }
12412
12413 #[test]
12414 fn lifecycle_keep_working_takes_the_declared_message_and_limit() {
12415 let manifest =
12416 lifecycle_with_keep_working(" message: \"go on\"\n limit: 3\n").unwrap();
12417 assert!(
12418 manifest.unknown_keys.is_empty(),
12419 "{:?}",
12420 manifest.unknown_keys
12421 );
12422 assert_eq!(
12423 manifest.lifecycle.unwrap().keep_working,
12424 Some(KeepWorking {
12425 message: "go on".into(),
12426 limit: 3
12427 })
12428 );
12429 }
12430
12431 #[test]
12433 fn lifecycle_keep_working_keeps_the_message_as_written() {
12434 let manifest =
12435 lifecycle_with_keep_working(" message: \" go on \\n\"\n limit: 1\n").unwrap();
12436 assert_eq!(
12437 manifest.lifecycle.unwrap().keep_working.unwrap().message,
12438 " go on \n"
12439 );
12440 }
12441
12442 #[test]
12443 fn lifecycle_keep_working_refuses_a_missing_or_blank_message() {
12444 for block in [
12445 " limit: 3\n",
12446 " message: \"\"\n limit: 3\n",
12447 " message: \" \\n\\t \"\n limit: 3\n",
12448 ] {
12449 let (field, _) = refused_keep_working_field(block);
12450 assert_eq!(field, "lifecycle.keep_working.message", "{block:?}");
12451 }
12452 }
12453
12454 #[test]
12455 fn lifecycle_keep_working_refuses_a_missing_limit() {
12456 let (field, message) = refused_keep_working_field(" message: go on\n");
12457 assert_eq!(field, "lifecycle.keep_working.limit");
12458 assert_eq!(message, "is required");
12459 }
12460
12461 #[test]
12462 fn lifecycle_keep_working_refuses_a_zero_limit_and_says_how_to_turn_it_off() {
12463 let (field, message) = refused_keep_working_field(" message: go on\n limit: 0\n");
12464 assert_eq!(field, "lifecycle.keep_working.limit");
12465 assert!(
12466 message.contains("omit lifecycle.keep_working"),
12467 "the refusal says how to turn the option off: {message}"
12468 );
12469 }
12470
12471 #[test]
12472 fn lifecycle_keep_working_reports_an_unknown_key_inside_the_block() {
12473 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"keep_working"));
12474 let manifest =
12475 lifecycle_with_keep_working(" message: go on\n limit: 2\n lmit: 3\n").unwrap();
12476 let reported: Vec<(String, String)> = manifest
12477 .unknown_keys
12478 .iter()
12479 .map(|key| (key.key.clone(), key.block_path.clone()))
12480 .collect();
12481 assert_eq!(
12482 reported,
12483 vec![("lmit".to_string(), "lifecycle.keep_working".to_string())]
12484 );
12485 }
12486
12487 fn lifecycle_with_done_check(
12488 allow: &str,
12489 block: &str,
12490 ) -> Result<RuntimeManifest, RuntimeManifestError> {
12491 RuntimeManifest::from_yaml_str(&format!(
12492 "name: cap\nversion: 0.0.1\nartifacts: []\n{allow}lifecycle:\n done_check:\n{block}"
12493 ))
12494 }
12495
12496 const ALLOW_BASH: &str = "capabilities:\n shell:\n allow: [bash]\n";
12497
12498 fn refused_done_check_field(allow: &str, block: &str) -> (String, String) {
12499 match lifecycle_with_done_check(allow, block) {
12500 Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
12501 (field, message)
12502 }
12503 other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
12504 }
12505 }
12506
12507 #[test]
12508 fn lifecycle_done_check_is_off_when_absent() {
12509 let without_block =
12510 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12511 assert_eq!(without_block.effective_lifecycle().done_check, None);
12512 let without_key = RuntimeManifest::from_yaml_str(
12513 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12514 )
12515 .unwrap();
12516 assert_eq!(without_key.lifecycle.unwrap().done_check, None);
12517 }
12518
12519 #[test]
12520 fn lifecycle_done_check_takes_the_command_and_limit_with_the_default_timeout() {
12521 let manifest = lifecycle_with_done_check(
12522 ALLOW_BASH,
12523 " command: \"bash -c 'true'\"\n limit: 2\n",
12524 )
12525 .unwrap();
12526 assert!(
12527 manifest.unknown_keys.is_empty(),
12528 "{:?}",
12529 manifest.unknown_keys
12530 );
12531 let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
12532 assert_eq!(
12533 done_check,
12534 DoneCheck {
12535 command: "bash -c 'true'".into(),
12536 limit: 2,
12537 timeout_secs: 600,
12538 }
12539 );
12540 assert_eq!(done_check.argv(), ["bash", "-c", "true"]);
12541 }
12542
12543 #[test]
12545 fn lifecycle_done_check_accepts_a_zero_limit_and_an_explicit_timeout() {
12546 let manifest = lifecycle_with_done_check(
12547 ALLOW_BASH,
12548 " command: bash -c 'exit 1'\n limit: 0\n timeout_secs: 5\n",
12549 )
12550 .unwrap();
12551 let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
12552 assert_eq!((done_check.limit, done_check.timeout_secs), (0, 5));
12553 }
12554
12555 #[test]
12556 fn lifecycle_done_check_refuses_each_invalid_field() {
12557 for (allow, block, field, message) in [
12558 (ALLOW_BASH, " limit: 1\n", "command", "is required"),
12559 (
12560 ALLOW_BASH,
12561 " command: \" \"\n limit: 1\n",
12562 "command",
12563 "must not be blank",
12564 ),
12565 (
12566 ALLOW_BASH,
12567 " command: \"''\"\n limit: 1\n",
12568 "command",
12569 "must name a program",
12570 ),
12571 (
12572 ALLOW_BASH,
12573 " command: cargo test\n limit: 1\n",
12574 "command",
12575 "runs 'cargo', which is not in capabilities.shell.allow",
12576 ),
12577 (
12578 "",
12579 " command: bash -c true\n limit: 1\n",
12580 "command",
12581 "runs 'bash', which is not in capabilities.shell.allow",
12582 ),
12583 (
12584 ALLOW_BASH,
12585 " command: bash -c true\n",
12586 "limit",
12587 "is required",
12588 ),
12589 (
12590 ALLOW_BASH,
12591 " command: bash -c true\n limit: 1\n timeout_secs: 0\n",
12592 "timeout_secs",
12593 "must be at least 1",
12594 ),
12595 ] {
12596 assert_eq!(
12597 refused_done_check_field(allow, block),
12598 (format!("lifecycle.done_check.{field}"), message.to_string()),
12599 "{block:?}"
12600 );
12601 }
12602 }
12603
12604 #[test]
12605 fn lifecycle_done_check_reports_an_unknown_key_inside_the_block() {
12606 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"done_check"));
12607 let manifest = lifecycle_with_done_check(
12608 ALLOW_BASH,
12609 " command: bash -c true\n limit: 2\n limt: 3\n",
12610 )
12611 .unwrap();
12612 let reported: Vec<(String, String)> = manifest
12613 .unknown_keys
12614 .iter()
12615 .map(|key| (key.key.clone(), key.block_path.clone()))
12616 .collect();
12617 assert_eq!(
12618 reported,
12619 vec![("limt".to_string(), "lifecycle.done_check".to_string())]
12620 );
12621 }
12622
12623 #[test]
12626 fn lifecycle_shell_grace_secs_zero_is_accepted() {
12627 let manifest = RuntimeManifest::from_yaml_str(
12628 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 0\n",
12629 )
12630 .unwrap();
12631 let lifecycle = manifest.lifecycle.unwrap();
12632 assert_eq!(lifecycle.shell_grace_secs, Some(0));
12633 assert_eq!(
12634 lifecycle.max_task_reopens,
12635 LifecycleConfig::default().max_task_reopens
12636 );
12637 }
12638
12639 #[test]
12640 fn lifecycle_max_task_reopens_defaults_to_1() {
12641 let manifest = RuntimeManifest::from_yaml_str(
12642 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n after_task: sleep\n",
12643 )
12644 .unwrap();
12645 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
12646 }
12647
12648 #[test]
12650 fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
12651 let manifest =
12652 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12653 assert!(manifest.lifecycle.is_none());
12654 assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
12655 }
12656
12657 #[test]
12658 fn lifecycle_max_task_reopens_explicit_value() {
12659 let manifest = RuntimeManifest::from_yaml_str(
12660 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 3\n",
12661 )
12662 .unwrap();
12663 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
12664 }
12665
12666 #[test]
12668 fn lifecycle_max_task_reopens_zero_is_accepted() {
12669 let manifest = RuntimeManifest::from_yaml_str(
12670 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 0\n",
12671 )
12672 .unwrap();
12673 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
12674 }
12675
12676 #[test]
12678 fn lifecycle_max_task_reopens_with_process_transport() {
12679 let manifest = RuntimeManifest::from_yaml_str(
12680 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/fixture-process-driver\n version: 0.1.0\n runtime: driver\ninference:\n transport: process\n driver:\n artifact: local/fixture-process-driver\nlifecycle:\n max_task_reopens: 2\n",
12681 ).unwrap();
12682 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
12683 }
12684
12685 #[test]
12686 fn inference_max_task_reopens_is_rejected_under_http_transport() {
12687 let err = RuntimeManifest::from_yaml_str(
12688 "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",
12689 ).unwrap_err();
12690 assert!(
12691 matches!(
12692 &err,
12693 RuntimeManifestError::InvalidInferenceConfig { field, .. }
12694 if field == "inference.max_task_reopens"
12695 ),
12696 "error was: {err:?}"
12697 );
12698 let msg = err.to_string();
12699 assert!(
12700 msg.contains("lifecycle.max_task_reopens"),
12701 "error was: {msg}"
12702 );
12703 }
12704
12705 #[test]
12706 fn inference_max_task_reopens_is_rejected_under_process_transport() {
12707 let err = RuntimeManifest::from_yaml_str(
12708 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n max_task_reopens: 2\n",
12709 ).unwrap_err();
12710 assert!(
12711 matches!(
12712 &err,
12713 RuntimeManifestError::InvalidInferenceConfig { field, .. }
12714 if field == "inference.max_task_reopens"
12715 ),
12716 "error was: {err:?}"
12717 );
12718 let msg = err.to_string();
12719 assert!(
12720 msg.contains("lifecycle.max_task_reopens"),
12721 "error was: {msg}"
12722 );
12723 }
12724
12725 #[test]
12727 fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
12728 let err = RuntimeManifest::from_yaml_str(
12729 "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",
12730 ).unwrap_err();
12731 assert!(
12732 matches!(
12733 &err,
12734 RuntimeManifestError::InvalidInferenceConfig { field, .. }
12735 if field == "inference.max_task_reopens"
12736 ),
12737 "error was: {err:?}"
12738 );
12739 }
12740
12741 #[test]
12742 fn skill_is_llm_visible() {
12743 assert!(ArtifactRuntime::Skill.is_llm_visible());
12744 }
12745
12746 #[test]
12747 fn driver_and_hook_are_not_llm_visible() {
12748 assert!(!ArtifactRuntime::Driver.is_llm_visible());
12749 assert!(!ArtifactRuntime::Hook.is_llm_visible());
12750 }
12751
12752 #[test]
12753 fn parses_system_prompt_artifact() {
12754 let manifest = RuntimeManifest::from_yaml_str(
12755 r#"
12756name: cap
12757version: 0.0.1
12758artifacts:
12759 - name: local/my-skill
12760 version: 1.0.0
12761 runtime: skill
12762 - name: murmur/driver-anthropic
12763 version: 0.1.0
12764 runtime: driver
12765 gateway:
12766 endpoint: http://127.0.0.1:8080
12767 keyless: true
12768inference:
12769 model: test-model
12770 system_prompt_artifact: local/my-skill
12771 driver:
12772 artifact: murmur/driver-anthropic
12773"#,
12774 )
12775 .unwrap();
12776
12777 let inference = manifest.inference.unwrap();
12778 assert_eq!(
12779 inference.system_prompt_artifact,
12780 Some("local/my-skill".to_string())
12781 );
12782 assert!(inference.system_prompt.is_none());
12783 assert!(inference.system_prompt_file.is_none());
12784 }
12785
12786 #[test]
12787 fn rejects_system_prompt_artifact_with_inline_prompt() {
12788 let err = RuntimeManifest::from_yaml_str(
12789 r#"
12790name: cap
12791version: 0.0.1
12792artifacts:
12793 - name: local/my-skill
12794 version: 1.0.0
12795 runtime: skill
12796 - name: murmur/driver-anthropic
12797 version: 0.1.0
12798 runtime: driver
12799 gateway:
12800 endpoint: http://127.0.0.1:8080
12801 keyless: true
12802inference:
12803 model: test-model
12804 system_prompt: "inline"
12805 system_prompt_artifact: local/my-skill
12806 driver:
12807 artifact: murmur/driver-anthropic
12808"#,
12809 )
12810 .unwrap_err();
12811
12812 let msg = err.to_string();
12813 assert!(msg.contains("at most one"), "error was: {msg}");
12814 }
12815
12816 #[test]
12817 fn rejects_system_prompt_artifact_naming_non_skill() {
12818 let err = RuntimeManifest::from_yaml_str(
12819 r#"
12820name: cap
12821version: 0.0.1
12822artifacts:
12823 - name: local/my-tool
12824 version: 1.0.0
12825 runtime: tool
12826 - name: murmur/driver-anthropic
12827 version: 0.1.0
12828 runtime: driver
12829 gateway:
12830 endpoint: http://127.0.0.1:8080
12831 keyless: true
12832inference:
12833 model: test-model
12834 system_prompt_artifact: local/my-tool
12835 driver:
12836 artifact: murmur/driver-anthropic
12837"#,
12838 )
12839 .unwrap_err();
12840
12841 let msg = err.to_string();
12842 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12843 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
12844 }
12845
12846 #[test]
12847 fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
12848 let err = RuntimeManifest::from_yaml_str(
12849 r#"
12850name: cap
12851version: 0.0.1
12852artifacts:
12853 - name: murmur/driver-anthropic
12854 version: 0.1.0
12855 runtime: driver
12856 gateway:
12857 endpoint: http://127.0.0.1:8080
12858 keyless: true
12859inference:
12860 model: test-model
12861 system_prompt_artifact: missing-skill
12862 driver:
12863 artifact: murmur/driver-anthropic
12864"#,
12865 )
12866 .unwrap_err();
12867
12868 let msg = err.to_string();
12869 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12870 assert!(msg.contains("not declared"), "error was: {msg}");
12871 }
12872
12873 #[test]
12874 fn rejects_all_three_system_prompt_sources() {
12875 let err = RuntimeManifest::from_yaml_str(
12876 r#"
12877name: cap
12878version: 0.0.1
12879artifacts:
12880 - name: local/my-skill
12881 version: 1.0.0
12882 runtime: skill
12883 - name: murmur/driver-anthropic
12884 version: 0.1.0
12885 runtime: driver
12886 gateway:
12887 endpoint: http://127.0.0.1:8080
12888 keyless: true
12889inference:
12890 model: test-model
12891 system_prompt: "inline"
12892 system_prompt_file: "conventions.md"
12893 system_prompt_artifact: local/my-skill
12894 driver:
12895 artifact: murmur/driver-anthropic
12896"#,
12897 )
12898 .unwrap_err();
12899
12900 let msg = err.to_string();
12901 assert!(msg.contains("at most one"), "error was: {msg}");
12902 }
12903
12904 #[test]
12907 fn hook_artifact_capabilities_parse_network_and_filesystem() {
12908 let manifest = RuntimeManifest::from_yaml_str(
12909 r#"
12910name: cap
12911version: 0.0.1
12912artifacts:
12913 - name: local/telemetry-hook
12914 version: 1.0.0
12915 runtime: hook
12916 capabilities:
12917 network:
12918 allow:
12919 - https://telemetry.example.com
12920 filesystem:
12921 scope: hook-state
12922"#,
12923 )
12924 .unwrap();
12925
12926 let caps = manifest.artifacts[0]
12927 .capabilities
12928 .as_ref()
12929 .expect("hook entry carries a capability grant");
12930 assert_eq!(
12931 caps.network.as_ref().unwrap().allow,
12932 vec!["https://telemetry.example.com".to_string()]
12933 );
12934 assert_eq!(
12935 caps.filesystem.as_ref().unwrap().scope,
12936 Some("hook-state".to_string())
12937 );
12938 }
12939
12940 #[test]
12943 fn hook_artifact_without_capabilities_is_none() {
12944 let manifest = RuntimeManifest::from_yaml_str(
12945 r#"
12946name: cap
12947version: 0.0.1
12948artifacts:
12949 - name: local/quiet-hook
12950 version: 1.0.0
12951 runtime: hook
12952"#,
12953 )
12954 .unwrap();
12955
12956 assert!(manifest.artifacts[0].capabilities.is_none());
12957 }
12958
12959 #[test]
12962 fn tool_and_driver_artifact_capabilities_parse() {
12963 for role in ["tool", "driver"] {
12964 let yaml = format!(
12965 r#"
12966name: cap
12967version: 0.0.1
12968artifacts:
12969 - name: local/scoped
12970 version: 1.0.0
12971 runtime: {role}
12972 capabilities:
12973 network:
12974 allow:
12975 - https://api.example.com
12976 filesystem:
12977 scope: cache
12978"#
12979 );
12980 let manifest = RuntimeManifest::from_yaml_str(&yaml)
12981 .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
12982
12983 let caps = manifest.artifacts[0]
12984 .capabilities
12985 .as_ref()
12986 .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
12987 assert_eq!(
12988 caps.network.as_ref().unwrap().allow,
12989 vec!["https://api.example.com".to_string()]
12990 );
12991 assert_eq!(
12992 caps.filesystem.as_ref().unwrap().scope,
12993 Some("cache".to_string())
12994 );
12995 }
12996 }
12997
12998 #[test]
13001 fn tool_artifact_without_capabilities_is_none() {
13002 let manifest = RuntimeManifest::from_yaml_str(
13003 r#"
13004name: cap
13005version: 0.0.1
13006artifacts:
13007 - name: local/plain-tool
13008 version: 1.0.0
13009 runtime: tool
13010"#,
13011 )
13012 .unwrap();
13013
13014 assert!(manifest.artifacts[0].capabilities.is_none());
13015 }
13016
13017 #[test]
13020 fn skill_artifact_capabilities_are_rejected() {
13021 let yaml = r#"
13022name: cap
13023version: 0.0.1
13024artifacts:
13025 - name: local/sneaky
13026 version: 1.0.0
13027 runtime: skill
13028 capabilities:
13029 network:
13030 allow:
13031 - https://evil.example.com
13032"#;
13033 let err = match RuntimeManifest::from_yaml_str(yaml) {
13034 Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
13035 Err(err) => err,
13036 };
13037 let msg = err.to_string();
13038 assert!(msg.contains("sneaky"), "error was: {msg}");
13039 assert!(
13040 msg.contains(
13041 "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
13042 entries"
13043 ),
13044 "error was: {msg}"
13045 );
13046 assert!(msg.contains("runtime: skill"), "error was: {msg}");
13047 }
13048
13049 #[test]
13054 fn artifact_config_is_carried_verbatim() {
13055 let manifest = RuntimeManifest::from_yaml_str(
13056 r#"
13057name: cap
13058version: 0.0.1
13059artifacts:
13060 - name: murmur/tool-corpus
13061 version: 0.1.0
13062 runtime: tool
13063 config:
13064 read_recent: { default: 20, max: 100 }
13065"#,
13066 )
13067 .unwrap();
13068
13069 let config = manifest.artifacts[0]
13070 .config
13071 .as_ref()
13072 .expect("a declared block parses");
13073 assert_eq!(
13074 config["read_recent"]["max"],
13075 serde_norway::Value::Number(100.into())
13076 );
13077 }
13078
13079 #[test]
13081 fn an_undeclared_config_is_none() {
13082 let manifest = RuntimeManifest::from_yaml_str(
13083 r#"
13084name: cap
13085version: 0.0.1
13086artifacts:
13087 - name: local/git
13088 version: 1.0.0
13089 runtime: tool
13090"#,
13091 )
13092 .unwrap();
13093
13094 assert!(manifest.artifacts[0].config.is_none());
13095 }
13096
13097 #[test]
13101 fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
13102 let manifest = RuntimeManifest::from_yaml_str(
13103 r#"
13104name: cap
13105version: 0.0.1
13106artifacts:
13107 - name: local/git
13108 version: 1.0.0
13109 runtime: tool
13110 config:
13111"#,
13112 )
13113 .unwrap();
13114
13115 assert_eq!(
13116 manifest.artifacts[0].config,
13117 Some(serde_norway::Value::Null),
13118 "an empty block must survive parsing as a declaration, not vanish into None"
13119 );
13120 }
13121
13122 #[test]
13125 fn skill_artifact_config_is_rejected() {
13126 let yaml = r#"
13127name: cap
13128version: 0.0.1
13129artifacts:
13130 - name: local/sneaky
13131 version: 1.0.0
13132 runtime: skill
13133 config:
13134 who: me
13135"#;
13136 let err = match RuntimeManifest::from_yaml_str(yaml) {
13137 Ok(_) => panic!("config: must be rejected for runtime: skill"),
13138 Err(err) => err,
13139 };
13140 assert!(matches!(
13141 err,
13142 RuntimeManifestError::InvalidArtifact { index: 0, .. }
13143 ));
13144 let msg = err.to_string();
13145 assert!(msg.contains("sneaky"), "error was: {msg}");
13146 assert!(msg.contains("runtime: skill"), "error was: {msg}");
13147 assert!(
13148 msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
13149 "error was: {msg}"
13150 );
13151 }
13152
13153 #[test]
13156 fn a_capsule_wide_config_block_is_rejected() {
13157 let yaml = r#"
13158name: cap
13159version: 0.0.1
13160config:
13161 who: capsule
13162artifacts:
13163 - name: local/git
13164 version: 1.0.0
13165 runtime: tool
13166"#;
13167 let err = match RuntimeManifest::from_yaml_str(yaml) {
13168 Ok(_) => panic!("a capsule-wide config block must be rejected"),
13169 Err(err) => err,
13170 };
13171 let msg = err.to_string();
13172 assert!(msg.contains("config"), "error was: {msg}");
13173 assert!(msg.contains("declared per artifact"), "error was: {msg}");
13174 }
13175
13176 #[test]
13180 fn hook_on_overflow_block_parses() {
13181 let manifest = RuntimeManifest::from_yaml_str(
13182 r#"
13183name: cap
13184version: 0.0.1
13185artifacts:
13186 - name: murmur/hook-grafana
13187 version: 1.0.0
13188 runtime: hook
13189 on_overflow: block
13190"#,
13191 )
13192 .unwrap();
13193
13194 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
13195 }
13196
13197 #[test]
13200 fn hook_on_overflow_defaults_to_drop() {
13201 let manifest = RuntimeManifest::from_yaml_str(
13202 r#"
13203name: cap
13204version: 0.0.1
13205artifacts:
13206 - name: murmur/hook-grafana
13207 version: 1.0.0
13208 runtime: hook
13209"#,
13210 )
13211 .unwrap();
13212
13213 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
13214 assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
13215 }
13216
13217 #[test]
13220 fn on_overflow_on_a_non_hook_artifact_is_rejected() {
13221 let yaml = r#"
13222name: cap
13223version: 0.0.1
13224artifacts:
13225 - name: local/git
13226 version: 1.0.0
13227 runtime: tool
13228 on_overflow: block
13229"#;
13230 let err = match RuntimeManifest::from_yaml_str(yaml) {
13231 Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
13232 Err(err) => err,
13233 };
13234 assert!(matches!(
13235 err,
13236 RuntimeManifestError::InvalidArtifact { index: 0, .. }
13237 ));
13238 let msg = err.to_string();
13239 assert!(msg.contains("git"), "error was: {msg}");
13240 assert!(
13241 msg.contains("only recognized on 'runtime: hook' entries"),
13242 "error was: {msg}"
13243 );
13244 }
13245
13246 #[test]
13248 fn unknown_on_overflow_value_is_rejected() {
13249 let yaml = r#"
13250name: cap
13251version: 0.0.1
13252artifacts:
13253 - name: murmur/hook-grafana
13254 version: 1.0.0
13255 runtime: hook
13256 on_overflow: spill
13257"#;
13258 let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
13259 let msg = err.to_string();
13260 assert!(msg.contains("spill"), "error was: {msg}");
13261 assert!(msg.contains("expected: drop, block"), "error was: {msg}");
13262 }
13263
13264 #[test]
13267 fn on_overflow_is_accepted_on_any_hook_entry() {
13268 let manifest = RuntimeManifest::from_yaml_str(
13269 r#"
13270name: cap
13271version: 0.0.1
13272artifacts:
13273 - name: murmur/hook-eval
13274 version: 1.0.0
13275 runtime: hook
13276 on_overflow: drop
13277 capabilities:
13278 network:
13279 allow:
13280 - https://telemetry.example.com
13281"#,
13282 )
13283 .unwrap();
13284
13285 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
13286 assert!(manifest.artifacts[0].capabilities.is_some());
13287 }
13288
13289 #[test]
13292 fn capsule_wide_and_hook_capabilities_coexist() {
13293 let manifest = RuntimeManifest::from_yaml_str(
13294 r#"
13295name: cap
13296version: 0.0.1
13297artifacts:
13298 - name: local/telemetry-hook
13299 version: 1.0.0
13300 runtime: hook
13301 capabilities:
13302 network:
13303 allow:
13304 - https://telemetry.example.com
13305capabilities:
13306 network:
13307 allow:
13308 - https://api.anthropic.com
13309"#,
13310 )
13311 .unwrap();
13312
13313 assert_eq!(
13314 manifest.capabilities.unwrap().network.unwrap().allow,
13315 vec!["https://api.anthropic.com".to_string()]
13316 );
13317 assert_eq!(
13318 manifest.artifacts[0]
13319 .capabilities
13320 .as_ref()
13321 .unwrap()
13322 .network
13323 .as_ref()
13324 .unwrap()
13325 .allow,
13326 vec!["https://telemetry.example.com".to_string()]
13327 );
13328 }
13329
13330 #[test]
13334 fn hook_own_manifest_capabilities_are_not_read_into_config() {
13335 let config = parse_hook_config_from_yaml(
13336 r#"
13337name: telemetry-hook
13338version: 1.0.0
13339binding: on-inference
13340execution_mode: blocking
13341capabilities:
13342 network:
13343 allow:
13344 - https://evil.example.com
13345 filesystem:
13346 scope: /
13347"#,
13348 )
13349 .expect("a self-declared capabilities block is ignored, not an error");
13350
13351 assert_eq!(
13352 config,
13353 HookConfig {
13354 binding: HookBinding::OnInference,
13355 execution_mode: HookExecutionMode::Blocking,
13356 commit_policy: HookCommitPolicy::None,
13357 }
13358 );
13359 }
13360
13361 #[test]
13364 fn containment_classes_order_weakest_to_strongest() {
13365 assert!(ContainmentClass::Unconfined < ContainmentClass::Advisory);
13366 assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
13367 assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
13368 assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
13369 assert_eq!(
13370 ContainmentClass::ALL.iter().copied().max(),
13371 Some(ContainmentClass::Sealed)
13372 );
13373 }
13374
13375 #[test]
13376 fn containment_class_defaults_to_advisory() {
13377 assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
13378 }
13379
13380 #[test]
13381 fn containment_class_round_trips_through_string() {
13382 for class in ContainmentClass::ALL {
13383 assert_eq!(
13384 class.to_string().parse::<ContainmentClass>(),
13385 Ok(class),
13386 "{class} must survive Display -> FromStr"
13387 );
13388 }
13389 assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
13390 assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
13391 assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
13392 assert_eq!(ContainmentClass::Unconfined.as_str(), "unconfined");
13393 assert_eq!(ContainmentClass::Unconfined.to_string(), "unconfined");
13394 assert_eq!(
13395 serde_json::to_string(&ContainmentClass::Unconfined).unwrap(),
13396 "\"unconfined\""
13397 );
13398 assert_eq!(
13399 serde_norway::from_str::<ContainmentClass>("unconfined").unwrap(),
13400 ContainmentClass::Unconfined
13401 );
13402 }
13403
13404 #[test]
13405 fn containment_declarable_classes_exclude_unconfined() {
13406 use ContainmentClass::{Advisory, Scoped, Sealed, Unconfined};
13407 assert_eq!(ContainmentClass::DECLARABLE, [Advisory, Scoped, Sealed]);
13408 assert_eq!(
13409 ContainmentClass::ALL,
13410 [Unconfined, Advisory, Scoped, Sealed]
13411 );
13412 let inputs: Vec<Option<ContainmentClass>> = std::iter::once(None)
13413 .chain(ContainmentClass::DECLARABLE.map(Some))
13414 .collect();
13415 for &workspace in &inputs {
13416 for &manifest in &inputs {
13417 for &cli in &inputs {
13418 assert_ne!(
13419 effective_containment_floor(workspace, manifest, cli),
13420 Unconfined
13421 );
13422 }
13423 }
13424 }
13425 }
13426
13427 #[test]
13428 fn containment_class_rejects_unknown_names() {
13429 let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
13430 assert_eq!(err.value, "paranoid");
13431 assert_eq!(
13432 err.to_string(),
13433 "must be one of: advisory, scoped, sealed; got 'paranoid'"
13434 );
13435 assert!("Sealed".parse::<ContainmentClass>().is_err());
13437 }
13438
13439 #[test]
13440 fn containment_class_serializes_as_its_wire_name() {
13441 assert_eq!(
13442 serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
13443 "\"scoped\""
13444 );
13445 assert_eq!(
13446 serde_norway::from_str::<ContainmentClass>("sealed").unwrap(),
13447 ContainmentClass::Sealed
13448 );
13449 }
13450
13451 #[test]
13452 fn effective_floor_is_advisory_when_nothing_is_declared() {
13453 assert_eq!(
13454 effective_containment_floor(None, None, None),
13455 ContainmentClass::Advisory
13456 );
13457 }
13458
13459 #[test]
13460 fn effective_floor_takes_the_strongest_across_every_presence_combination() {
13461 use ContainmentClass::{Advisory, Scoped, Sealed};
13462
13463 assert_eq!(
13465 effective_containment_floor(Some(Scoped), None, None),
13466 Scoped
13467 );
13468 assert_eq!(
13469 effective_containment_floor(None, Some(Scoped), None),
13470 Scoped
13471 );
13472 assert_eq!(
13473 effective_containment_floor(None, None, Some(Scoped)),
13474 Scoped
13475 );
13476 assert_eq!(
13477 effective_containment_floor(Some(Sealed), None, None),
13478 Sealed
13479 );
13480 assert_eq!(
13481 effective_containment_floor(None, Some(Sealed), None),
13482 Sealed
13483 );
13484 assert_eq!(
13485 effective_containment_floor(None, None, Some(Sealed)),
13486 Sealed
13487 );
13488
13489 assert_eq!(
13492 effective_containment_floor(Some(Advisory), Some(Sealed), None),
13493 Sealed
13494 );
13495 assert_eq!(
13496 effective_containment_floor(Some(Sealed), Some(Advisory), None),
13497 Sealed
13498 );
13499 assert_eq!(
13500 effective_containment_floor(Some(Advisory), None, Some(Scoped)),
13501 Scoped
13502 );
13503 assert_eq!(
13504 effective_containment_floor(Some(Scoped), None, Some(Advisory)),
13505 Scoped
13506 );
13507 assert_eq!(
13508 effective_containment_floor(None, Some(Advisory), Some(Scoped)),
13509 Scoped
13510 );
13511 assert_eq!(
13512 effective_containment_floor(None, Some(Scoped), Some(Advisory)),
13513 Scoped
13514 );
13515
13516 assert_eq!(
13518 effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
13519 Sealed
13520 );
13521 assert_eq!(
13522 effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
13523 Sealed
13524 );
13525 assert_eq!(
13526 effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
13527 Sealed
13528 );
13529 assert_eq!(
13530 effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
13531 Scoped
13532 );
13533 assert_eq!(
13534 effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
13535 Advisory
13536 );
13537 }
13538
13539 #[test]
13540 fn manifest_parses_each_declared_containment_class() {
13541 for class in ContainmentClass::DECLARABLE {
13542 let manifest = RuntimeManifest::from_yaml_str(&format!(
13543 r#"
13544name: cap
13545version: 0.0.1
13546capabilities:
13547 containment: {class}
13548"#
13549 ))
13550 .expect("a valid containment class parses");
13551
13552 assert_eq!(
13553 manifest.capabilities.unwrap().containment,
13554 Some(class),
13555 "capabilities.containment: {class}"
13556 );
13557 }
13558 }
13559
13560 #[test]
13561 fn manifest_without_containment_key_declares_nothing() {
13562 let manifest = RuntimeManifest::from_yaml_str(
13564 r#"
13565name: cap
13566version: 0.0.1
13567"#,
13568 )
13569 .unwrap();
13570 assert!(manifest.capabilities.is_none());
13571
13572 let manifest = RuntimeManifest::from_yaml_str(
13575 r#"
13576name: cap
13577version: 0.0.1
13578capabilities:
13579 network:
13580 allow:
13581 - https://api.example.com
13582"#,
13583 )
13584 .unwrap();
13585 assert_eq!(manifest.capabilities.unwrap().containment, None);
13586 }
13587
13588 #[test]
13589 fn manifest_rejects_an_unknown_containment_class() {
13590 let err = RuntimeManifest::from_yaml_str(
13591 r#"
13592name: cap
13593version: 0.0.1
13594capabilities:
13595 containment: paranoid
13596"#,
13597 )
13598 .expect_err("an unknown containment class is rejected at parse time");
13599
13600 match err {
13601 RuntimeManifestError::InvalidCapabilities { field, message } => {
13602 assert_eq!(field, "capabilities.containment");
13603 assert_eq!(
13604 message,
13605 "must be one of: advisory, scoped, sealed; got 'paranoid'"
13606 );
13607 }
13608 other => panic!("expected InvalidCapabilities, got {other:?}"),
13609 }
13610 }
13611
13612 #[test]
13613 fn manifest_refuses_an_unconfined_containment_class() {
13614 let err = RuntimeManifest::from_yaml_str(
13615 r#"
13616name: cap
13617version: 0.0.1
13618capabilities:
13619 containment: unconfined
13620"#,
13621 )
13622 .expect_err("a manifest cannot choose the unconfined stance");
13623
13624 match err {
13625 RuntimeManifestError::InvalidCapabilities { field, message } => {
13626 assert_eq!(field, "capabilities.containment");
13627 assert!(message.contains("mur run --unconfined"), "{message}");
13628 assert!(message.contains("~/.murmur/config.yaml"), "{message}");
13629 }
13630 other => panic!("expected InvalidCapabilities, got {other:?}"),
13631 }
13632 }
13633
13634 fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
13637 RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
13638 }
13639
13640 fn export_error(block: &str) -> (String, String) {
13641 match exports_manifest(block).expect_err("this exports block must not parse") {
13642 RuntimeManifestError::InvalidExports { field, message } => (field, message),
13643 other => panic!("expected InvalidExports, got {other:?}"),
13644 }
13645 }
13646
13647 fn peer_fetch_error(block: &str) -> (String, String) {
13648 match exports_manifest(block).expect_err("this capabilities block must not parse") {
13649 RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
13650 other => panic!("expected InvalidCapabilities, got {other:?}"),
13651 }
13652 }
13653
13654 #[test]
13657 fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
13658 assert_eq!(parse_duration_secs("90").unwrap(), 90);
13659 assert_eq!(parse_duration_secs("30s").unwrap(), 30);
13660 assert_eq!(parse_duration_secs("15m").unwrap(), 900);
13661 assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
13662 assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
13663 assert_eq!(parse_duration_secs(" 2h ").unwrap(), 7200);
13664 assert_eq!(parse_duration_secs("0").unwrap(), 0);
13665 }
13666
13667 #[test]
13670 fn parse_duration_secs_rejects_every_other_spelling() {
13671 for input in [
13672 "",
13673 " ",
13674 "5 minutes",
13675 "30S",
13676 "1H",
13677 "5min",
13678 "1.5h",
13679 "-1",
13680 "m",
13681 "h",
13682 "1h30m",
13683 "0x10",
13684 "30 s",
13685 ] {
13686 let error = parse_duration_secs(input)
13687 .expect_err(&format!("'{input}' must not parse as a duration"));
13688 assert!(
13689 error.contains(DURATION_ACCEPTED_FORM),
13690 "'{input}' must be refused with the accepted form; got: {error}"
13691 );
13692 }
13693 }
13694
13695 #[test]
13696 fn parse_duration_secs_refuses_to_overflow() {
13697 assert!(parse_duration_secs("18446744073709551615h")
13698 .unwrap_err()
13699 .contains("overflows"));
13700 }
13701
13702 #[test]
13703 fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
13704 let manifest = exports_manifest("exports:\n peer_files:\n root: out/\n")
13705 .expect("a minimal peer_files block parses");
13706 let peer = manifest.exports.unwrap().peer_files.unwrap();
13707 assert_eq!(peer.root, "out/");
13708 assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
13709 assert_eq!(peer.max_bytes, 10_485_760);
13710 assert_eq!(peer.max_ttl_secs, None);
13713 assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
13714 assert_eq!(peer.effective_max_ttl_secs(), 3600);
13715 }
13716
13717 #[test]
13718 fn peer_files_accepts_every_duration_spelling() {
13719 for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
13720 let manifest = exports_manifest(&format!(
13721 "exports:\n peer_files:\n root: out/\n max_ttl: {declared}\n"
13722 ))
13723 .expect("a declared max_ttl parses");
13724 let peer = manifest.exports.unwrap().peer_files.unwrap();
13725 assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
13726 assert_eq!(peer.effective_max_ttl_secs(), expected);
13727 }
13728 }
13729
13730 #[test]
13733 fn the_two_export_blocks_are_independent() {
13734 let both = exports_manifest(
13735 "exports:\n files:\n root: out/\n mode: read-only\n \
13736 peer_files:\n root: out/handoff/\n",
13737 )
13738 .expect("both blocks parse together")
13739 .exports
13740 .unwrap();
13741 assert_eq!(both.files.unwrap().root, "out/");
13742 assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
13743
13744 let only_files =
13745 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
13746 .unwrap()
13747 .exports
13748 .unwrap();
13749 assert!(only_files.peer_files.is_none());
13750
13751 let only_peer = exports_manifest("exports:\n peer_files:\n root: out/\n")
13752 .unwrap()
13753 .exports
13754 .unwrap();
13755 assert!(only_peer.files.is_none());
13756
13757 let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
13758 assert!(neither.files.is_none() && neither.peer_files.is_none());
13759 }
13760
13761 #[test]
13762 fn peer_files_root_must_be_relative_and_inside_the_workdir() {
13763 for root in ["../out", "/etc", "out/../../etc"] {
13764 let (field, message) =
13765 export_error(&format!("exports:\n peer_files:\n root: {root}\n"));
13766 assert_eq!(field, "exports.peer_files.root", "root: {root}");
13767 assert!(
13768 message.contains(EXPORT_ROOT_ACCEPTED_FORM),
13769 "root: {root}; got: {message}"
13770 );
13771 }
13772 }
13773
13774 #[test]
13775 fn peer_files_root_is_required() {
13776 let (field, message) = export_error("exports:\n peer_files: {}\n");
13777 assert_eq!(field, "exports.peer_files.root");
13778 assert!(message.contains("is required"));
13779 assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
13780 }
13781
13782 #[test]
13783 fn peer_files_max_ttl_states_its_accepted_form() {
13784 for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
13785 let (field, message) = export_error(&format!(
13786 "exports:\n peer_files:\n root: out/\n max_ttl: \"{declared}\"\n"
13787 ));
13788 assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
13789 assert!(
13790 message.contains(DURATION_ACCEPTED_FORM),
13791 "max_ttl: {declared}; got: {message}"
13792 );
13793 }
13794 }
13795
13796 #[test]
13799 fn peer_files_max_ttl_rejects_zero() {
13800 let (field, message) =
13801 export_error("exports:\n peer_files:\n root: out/\n max_ttl: 0\n");
13802 assert_eq!(field, "exports.peer_files.max_ttl");
13803 assert!(message.contains("greater than zero"));
13804 }
13805
13806 #[test]
13807 fn peer_files_max_bytes_states_its_accepted_form() {
13808 let (field, message) =
13809 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 10MB\n");
13810 assert_eq!(field, "exports.peer_files.max_bytes");
13811 assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
13812 }
13813
13814 #[test]
13815 fn peer_files_max_bytes_rejects_zero() {
13816 let (field, message) =
13817 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 0\n");
13818 assert_eq!(field, "exports.peer_files.max_bytes");
13819 assert!(message.contains("greater than zero"));
13820 }
13821
13822 #[test]
13826 fn peer_tasks_consents_only_on_an_explicit_true() {
13827 let absent = exports_manifest("artifacts: []\n").expect("no exports block parses");
13828 assert!(absent.exports.is_none());
13829 assert!(!absent.accepts_peer_tasks());
13830
13831 let refused = exports_manifest("exports:\n peer_tasks:\n accept: false\n")
13832 .expect("an explicit refusal parses");
13833 assert_eq!(
13834 refused.exports.as_ref().unwrap().peer_tasks,
13835 Some(PeerTasksExport { accept: false })
13836 );
13837 assert!(!refused.accepts_peer_tasks());
13838 assert!(!refused.exports.as_ref().unwrap().accepts_peer_tasks());
13839
13840 let accepted = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
13841 .expect("consent parses");
13842 assert_eq!(
13843 accepted.exports.as_ref().unwrap().peer_tasks,
13844 Some(PeerTasksExport { accept: true })
13845 );
13846 assert!(accepted.accepts_peer_tasks());
13847 assert!(accepted.exports.as_ref().unwrap().accepts_peer_tasks());
13848 }
13849
13850 #[test]
13852 fn peer_tasks_alone_declares_no_file_plane() {
13853 let manifest = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
13854 .expect("a peer_tasks-only exports block parses");
13855 let exports = manifest.exports.unwrap();
13856 assert_eq!(exports.files, None);
13857 assert_eq!(exports.peer_files, None);
13858 }
13859
13860 #[test]
13862 fn a_file_export_does_not_consent_to_peer_tasks() {
13863 let manifest =
13864 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
13865 .expect("a files export parses");
13866 assert_eq!(manifest.exports.as_ref().unwrap().peer_tasks, None);
13867 assert!(!manifest.accepts_peer_tasks());
13868 }
13869
13870 #[test]
13873 fn peer_tasks_without_accept_is_refused() {
13874 let (field, message) = export_error("exports:\n peer_tasks: {}\n");
13875 assert_eq!(field, "exports.peer_tasks.accept");
13876 assert_eq!(
13877 message,
13878 "must be set explicitly to true or false — consent to peer tasks is never inferred"
13879 );
13880 }
13881
13882 #[test]
13884 fn peer_tasks_of_the_wrong_type_is_refused_naming_the_field() {
13885 for (block, field) in [
13886 (
13887 "exports:\n peer_tasks:\n accept: \"yes\"\n",
13888 "exports.peer_tasks.accept",
13889 ),
13890 (
13891 "exports:\n peer_tasks:\n accept: 1\n",
13892 "exports.peer_tasks.accept",
13893 ),
13894 ("exports:\n peer_tasks: true\n", "exports.peer_tasks"),
13895 ("exports:\n peer_tasks: [peer-a]\n", "exports.peer_tasks"),
13896 ] {
13897 let message = exports_manifest(block)
13898 .expect_err(&format!("{block:?} must not parse"))
13899 .to_string();
13900 assert!(message.contains(field), "{block:?}: {message}");
13901 }
13902 }
13903
13904 #[test]
13907 fn an_unknown_peer_tasks_key_is_reported_by_path() {
13908 let manifest =
13909 exports_manifest("exports:\n peer_tasks:\n accept: false\n allow: [peer-a]\n")
13910 .expect("an unknown key is a warning, not a parse error");
13911 assert!(
13912 manifest
13913 .unknown_keys
13914 .iter()
13915 .any(|key| key.block_path == "exports.peer_tasks" && key.key == "allow"),
13916 "{:?}",
13917 manifest.unknown_keys
13918 );
13919 assert!(!manifest.accepts_peer_tasks());
13920 }
13921
13922 #[test]
13925 fn peer_fetch_allow_carries_its_declared_destinations() {
13926 let manifest = exports_manifest(
13927 "capabilities:\n peer_fetch:\n allow:\n - localhost:41234\n \
13928 - reporting.internal\n - https://gateway.example.com\n",
13929 )
13930 .expect("a peer_fetch block parses");
13931 assert_eq!(
13932 manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
13933 vec![
13934 "localhost:41234".to_string(),
13935 "reporting.internal".to_string(),
13936 "https://gateway.example.com".to_string(),
13937 ]
13938 );
13939 }
13940
13941 #[test]
13944 fn peer_fetch_allow_must_not_be_empty() {
13945 let (field, message) = peer_fetch_error("capabilities:\n peer_fetch:\n allow: []\n");
13946 assert_eq!(field, "capabilities.peer_fetch.allow");
13947 assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
13948 }
13949
13950 #[test]
13951 fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
13952 for entry in [
13953 "http://[not a host",
13954 "\"\"",
13955 "localhost:notaport",
13956 "http://example.com/path",
13957 "ftp://example.com",
13958 ] {
13959 let (field, message) = peer_fetch_error(&format!(
13960 "capabilities:\n peer_fetch:\n allow:\n - {entry}\n"
13961 ));
13962 assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
13963 assert!(
13964 message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
13965 "entry: {entry}; got: {message}"
13966 );
13967 }
13968 }
13969
13970 #[test]
13973 fn peer_fetch_does_not_widen_network_allow() {
13974 let capabilities = exports_manifest(
13975 "capabilities:\n network:\n allow:\n - api.example.com\n \
13976 peer_fetch:\n allow:\n - localhost:41234\n",
13977 )
13978 .unwrap()
13979 .capabilities
13980 .unwrap();
13981 assert_eq!(
13982 capabilities.network.unwrap().allow,
13983 vec!["api.example.com".to_string()]
13984 );
13985 assert_eq!(
13986 capabilities.peer_fetch.unwrap().allow,
13987 vec!["localhost:41234".to_string()]
13988 );
13989 }
13990
13991 #[test]
13992 fn an_absent_peer_fetch_block_is_absent() {
13993 assert!(exports_manifest("capabilities: {}\n")
13994 .unwrap()
13995 .capabilities
13996 .unwrap()
13997 .peer_fetch
13998 .is_none());
13999 }
14000
14001 #[test]
14002 fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
14003 assert_eq!(parse_byte_size("0").unwrap(), 0);
14004 assert_eq!(parse_byte_size("4096").unwrap(), 4096);
14005 assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
14006 assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
14007 assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
14008 assert_eq!(parse_byte_size(" 10Mi ").unwrap(), 10 * 1024 * 1024);
14009 }
14010
14011 #[test]
14014 fn parse_byte_size_rejects_every_other_spelling() {
14015 for input in [
14016 "", " ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
14017 "1Ki1",
14018 ] {
14019 let error = parse_byte_size(input)
14020 .expect_err(&format!("'{input}' must not parse as a byte size"));
14021 assert!(
14022 error.contains(BYTE_SIZE_ACCEPTED_FORM),
14023 "'{input}' must be refused with the accepted form; got: {error}"
14024 );
14025 }
14026 }
14027
14028 #[test]
14029 fn parse_byte_size_refuses_to_overflow() {
14030 assert!(parse_byte_size("18446744073709551615Gi")
14031 .unwrap_err()
14032 .contains("overflows"));
14033 }
14034
14035 #[test]
14036 fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
14037 let manifest =
14038 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
14039 .expect("a minimal exports block parses");
14040 let files = manifest.exports.unwrap().files.unwrap();
14041 assert_eq!(files.root, "out/");
14042 assert_eq!(files.mode, ExportMode::ReadOnly);
14043 assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
14044 assert_eq!(files.max_bytes, 10_485_760);
14045 }
14046
14047 #[test]
14048 fn exports_files_accepts_both_byte_spellings() {
14049 for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
14050 let manifest = exports_manifest(&format!(
14051 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: {declared}\n"
14052 ))
14053 .expect("a declared max_bytes parses");
14054 assert_eq!(
14055 manifest.exports.unwrap().files.unwrap().max_bytes,
14056 expected,
14057 "max_bytes: {declared}"
14058 );
14059 }
14060 }
14061
14062 #[test]
14065 fn an_exports_block_without_files_declares_no_plane() {
14066 let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
14067 assert!(manifest.exports.unwrap().files.is_none());
14068 }
14069
14070 #[test]
14071 fn an_absent_exports_block_is_absent() {
14072 let manifest = exports_manifest("").expect("a manifest without exports parses");
14073 assert!(manifest.exports.is_none());
14074 }
14075
14076 #[test]
14077 fn exports_files_root_must_be_relative_and_inside_the_workdir() {
14078 for root in ["../out", "/etc", "out/../../etc", "''"] {
14079 let (field, message) = export_error(&format!(
14080 "exports:\n files:\n root: {root}\n mode: read-only\n"
14081 ));
14082 assert_eq!(field, "exports.files.root", "root: {root}");
14083 assert!(
14084 message.contains("must be a relative path inside the workdir"),
14085 "root: {root}; got: {message}"
14086 );
14087 }
14088 }
14089
14090 #[test]
14091 fn exports_files_root_is_required() {
14092 let (field, message) = export_error("exports:\n files:\n mode: read-only\n");
14093 assert_eq!(field, "exports.files.root");
14094 assert_eq!(
14095 message,
14096 "is required and must be a relative path inside the workdir"
14097 );
14098 }
14099
14100 #[test]
14101 fn exports_files_mode_is_required_and_read_only() {
14102 let (field, message) = export_error("exports:\n files:\n root: out/\n");
14103 assert_eq!(field, "exports.files.mode");
14104 assert_eq!(message, "is required and must be 'read-only'");
14105
14106 let (field, message) =
14107 export_error("exports:\n files:\n root: out/\n mode: read-write\n");
14108 assert_eq!(field, "exports.files.mode");
14109 assert_eq!(message, "'read-write' must be 'read-only'");
14110 }
14111
14112 #[test]
14113 fn exports_files_max_bytes_states_its_accepted_form() {
14114 let (field, message) = export_error(
14115 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 10MB\n",
14116 );
14117 assert_eq!(field, "exports.files.max_bytes");
14118 assert_eq!(
14119 message,
14120 "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
14121 );
14122 }
14123
14124 #[test]
14127 fn exports_files_max_bytes_rejects_zero() {
14128 let (field, message) = export_error(
14129 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 0\n",
14130 );
14131 assert_eq!(field, "exports.files.max_bytes");
14132 assert!(message.contains("must be greater than zero"), "{message}");
14133 }
14134
14135 #[test]
14137 fn exports_files_root_accepts_a_current_dir_prefix() {
14138 let manifest =
14139 exports_manifest("exports:\n files:\n root: ./out\n mode: read-only\n")
14140 .expect("./out is a legal root");
14141 assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
14142 }
14143
14144 fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
14147 RuntimeManifest::from_yaml_str(yaml)
14148 .expect("fixture must parse")
14149 .unknown_keys
14150 .into_iter()
14151 .map(|key| (key.key, key.block_path, key.nearest_known))
14152 .collect()
14153 }
14154
14155 #[test]
14158 fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
14159 assert_eq!(
14160 unknown_keys_of(
14161 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n read-only:\n - tests\n"
14162 ),
14163 vec![(
14164 "read-only".to_string(),
14165 "capabilities.filesystem".to_string(),
14166 Some("read_only".to_string())
14167 )]
14168 );
14169 }
14170
14171 #[test]
14175 fn a_manifest_of_recognized_keys_reports_nothing() {
14176 assert!(unknown_keys_of(
14177 r#"
14178name: cap
14179version: 0.1.0
14180deploy:
14181 mur_version: "0.2.0"
14182artifacts:
14183 - name: local/notes-tool
14184 version: 0.1.0
14185 runtime: tool
14186 capabilities:
14187 filesystem:
14188 scope: notes
14189 network:
14190 allow:
14191 - example.com:443
14192capabilities:
14193 filesystem:
14194 read_only:
14195 - tests
14196 - bench/fixtures
14197 workdir_exec: false
14198 shell:
14199 allow:
14200 - git
14201 - python3
14202 network:
14203 allow:
14204 - example.com:443
14205 unix_sockets: false
14206"#
14207 )
14208 .is_empty());
14209 }
14210
14211 #[test]
14214 fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
14215 assert_eq!(
14216 unknown_keys_of(
14217 "name: cap\nversion: 0.1.0\nartifacts:\n - name: local/t\n version: 0.1.0\n \
14218 runtime: tool\n capabilities:\n shell:\n allow:\n - git\n \
14219 allwo:\n - git\n"
14220 ),
14221 vec![(
14222 "allwo".to_string(),
14223 "artifacts[0].capabilities.shell".to_string(),
14224 Some("allow".to_string())
14225 )]
14226 );
14227 }
14228
14229 #[test]
14232 fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
14233 assert_eq!(
14234 unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
14235 vec![("quantum_teleport".to_string(), String::new(), None)]
14236 );
14237 }
14238
14239 #[test]
14244 fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
14245 assert!(unknown_keys_of(
14246 "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
14247 )
14248 .is_empty());
14249 }
14250
14251 #[test]
14253 fn every_build_side_key_is_recognized() {
14254 assert!(unknown_keys_of(
14255 "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
14256 wasm\nrequires_files:\n - tool.wasm\n - assets/logo.png\n"
14257 )
14258 .is_empty());
14259 }
14260
14261 #[test]
14264 fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
14265 assert_eq!(
14266 unknown_keys_of(
14267 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
14268 static\nrequires_files:\n - capsule.wasm\nquantum_teleport: true\n"
14269 ),
14270 vec![("quantum_teleport".to_string(), String::new(), None)]
14271 );
14272 }
14273
14274 #[test]
14278 fn the_build_side_keys_change_nothing_the_run_path_reads() {
14279 let body = "artifacts:\n - name: local/notes-tool\n version: 0.1.0\n runtime: tool\n\
14280 \x20 - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: \
14281 driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\n\
14282 capabilities:\n filesystem:\n read_only:\n - tests\n\
14283 inference:\n transport: http\n model: test-model\n driver:\n \
14284 artifact: murmur/driver-anthropic\n";
14285 let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
14286 .expect("fixture must parse");
14287 let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
14288 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n \
14289 any: shape\nrequires_files: not-a-list\n{body}"
14290 ))
14291 .expect("a build-side key of any shape never refuses a manifest");
14292
14293 assert!(with_build_keys.unknown_keys.is_empty());
14294 assert!(
14295 !plain.artifacts.is_empty()
14296 && plain.capabilities.is_some()
14297 && plain.inference.is_some(),
14298 "the fixture must populate the blocks the comparison below rests on, or it \
14299 compares absence against absence"
14300 );
14301 assert_eq!(with_build_keys.name, plain.name);
14302 assert_eq!(with_build_keys.version, plain.version);
14303 assert_eq!(with_build_keys.artifacts, plain.artifacts);
14304 assert_eq!(with_build_keys.capabilities, plain.capabilities);
14305 assert_eq!(with_build_keys.inference, plain.inference);
14306 }
14307
14308 #[test]
14311 fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
14312 let manifest = RuntimeManifest::from_yaml_str(
14313 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n scope: notes\n \
14314 read-only:\n - tests\n",
14315 )
14316 .expect("an unrecognized key never refuses a manifest");
14317 assert_eq!(
14318 manifest
14319 .capabilities
14320 .as_ref()
14321 .and_then(|caps| caps.filesystem.as_ref())
14322 .and_then(|fs| fs.scope.as_deref()),
14323 Some("notes")
14324 );
14325 assert_eq!(manifest.unknown_keys.len(), 1);
14326 }
14327
14328 #[test]
14330 fn nested_blocks_outside_capabilities_are_walked_too() {
14331 let keys = unknown_keys_of(
14332 "name: cap\nversion: 0.1.0\ncontext:\n max_tokns: 100\nexports:\n files:\n \
14333 root: out\n mode: read-only\n max_byts: 10\n",
14334 );
14335 assert_eq!(
14336 keys,
14337 vec![
14338 (
14339 "max_tokns".to_string(),
14340 "context".to_string(),
14341 Some("max_tokens".to_string())
14342 ),
14343 (
14344 "max_byts".to_string(),
14345 "exports.files".to_string(),
14346 Some("max_bytes".to_string())
14347 ),
14348 ]
14349 );
14350 }
14351
14352 #[test]
14359 fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
14360 let source = include_str!("runtime_manifest.rs");
14361 let lines: Vec<&str> = source.lines().collect();
14362
14363 let mut checked = 0usize;
14364 for (start, line) in lines.iter().enumerate() {
14365 let Some(name) = line
14366 .strip_prefix("struct Raw")
14367 .and_then(|rest| rest.strip_suffix(" {"))
14368 else {
14369 continue;
14370 };
14371 let name = format!("Raw{name}");
14372 let end = (start + 1..lines.len())
14373 .find(|index| lines[*index] == "}")
14374 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
14375 let body = &lines[start + 1..end];
14376
14377 assert!(
14378 body.contains(&" #[serde(flatten)]")
14379 && body.contains(&" unknown: UnknownKeys,"),
14380 "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
14381 so a key it does not recognize would be dropped instead of reported"
14382 );
14383
14384 let declared = serde_field_names(body);
14385 let known = known_keys_of(source, &name);
14386 assert_eq!(
14387 declared, known,
14388 "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
14389 suggester would propose the wrong set for that block"
14390 );
14391 checked += 1;
14392 }
14393 assert!(
14394 checked >= 30,
14395 "expected every Raw* struct to be scanned, found only {checked}"
14396 );
14397 }
14398
14399 #[test]
14406 fn every_raw_struct_descends_into_the_blocks_it_owns() {
14407 let source = include_str!("runtime_manifest.rs");
14408 let lines: Vec<&str> = source.lines().collect();
14409
14410 let mut structs = 0usize;
14411 let mut children = 0usize;
14412 for (start, line) in lines.iter().enumerate() {
14413 let Some(name) = line
14414 .strip_prefix("struct Raw")
14415 .and_then(|rest| rest.strip_suffix(" {"))
14416 else {
14417 continue;
14418 };
14419 let name = format!("Raw{name}");
14420 let end = (start + 1..lines.len())
14421 .find(|index| lines[*index] == "}")
14422 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
14423 let owned = block_typed_fields(&lines[start + 1..end]);
14424 structs += 1;
14425
14426 if owned.is_empty() {
14427 continue;
14428 }
14429 let block = raw_block_impl_of(source, &name);
14430 assert!(
14431 block.contains("fn walk_children"),
14432 "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
14433 `walk_children`, so every key written inside {} would go unreported",
14434 if owned.len() == 1 { "" } else { "s" },
14435 owned.join(", "),
14436 if owned.len() == 1 { "it" } else { "them" }
14437 );
14438 for field in &owned {
14439 assert!(
14440 mentions_field(&block, field),
14441 "{name}'s `walk_children` never touches `self.{field}`, so every key written \
14442 inside that block would go unreported — descend into it with \
14443 `collect_block(.., &child_path(path, \"{field}\"), out)`"
14444 );
14445 children += 1;
14446 }
14447 }
14448 assert!(
14449 structs >= 30,
14450 "expected every Raw* struct to be scanned, found only {structs}"
14451 );
14452 assert!(
14453 children >= 31,
14454 "expected every nested block field to be scanned, found only {children}"
14455 );
14456 }
14457
14458 fn mentions_field(impl_body: &str, field: &str) -> bool {
14462 let needle = format!("self.{field}");
14463 impl_body.match_indices(&needle).any(|(at, _)| {
14464 impl_body[at + needle.len()..]
14465 .chars()
14466 .next()
14467 .is_none_or(|next| !next.is_alphanumeric() && next != '_')
14468 })
14469 }
14470
14471 fn block_typed_fields(body: &[&str]) -> Vec<String> {
14475 let mut fields = Vec::new();
14476 for line in body {
14477 let trimmed = line.trim();
14478 if trimmed.starts_with("//") || trimmed.starts_with("#[") {
14479 continue;
14480 }
14481 let Some((field, declared)) = line
14482 .strip_prefix(" ")
14483 .and_then(|rest| rest.split_once(':'))
14484 else {
14485 continue;
14486 };
14487 if field.is_empty()
14488 || !field
14489 .chars()
14490 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
14491 {
14492 continue;
14493 }
14494 if declared.contains("Raw") {
14495 fields.push(field.to_string());
14496 }
14497 }
14498 fields
14499 }
14500
14501 fn raw_block_impl_of(source: &str, name: &str) -> String {
14504 let header = format!("impl RawBlock for {name} {{");
14505 let body = source
14506 .split_once(&header)
14507 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
14508 .1;
14509 body.split_once("\n}\n")
14510 .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
14511 .0
14512 .to_string()
14513 }
14514
14515 fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
14522 let raw: RawRuntimeManifest = serde_norway::from_str(yaml).expect("fixture must parse");
14523 collect_unknown_keys(&raw)
14524 .into_iter()
14525 .map(|key| (key.key, key.block_path))
14526 .collect()
14527 }
14528
14529 #[test]
14530 fn spawn_workdir_snapshot_is_a_known_key_beside_a_sibling_probe() {
14531 let keys = raw_unknown_keys(
14532 "name: cap\nversion: 0.1.0\ncapabilities:\n spawn:\n allow: [local/r]\n \
14533 workdir_snapshot: [local/r]\n probe_spawn: 1\n",
14534 );
14535 assert_eq!(
14536 keys,
14537 vec![("probe_spawn".to_string(), "capabilities.spawn".to_string())]
14538 );
14539 }
14540
14541 #[test]
14545 fn every_block_reports_its_own_unknown_key_at_its_own_path() {
14546 let keys = raw_unknown_keys(
14547 r#"
14548name: every-block
14549version: 0.1.0
14550probe_root: 1
14551artifacts:
14552 - name: local/t
14553 version: 0.1.0
14554 runtime: tool
14555 probe_artifact: 1
14556 capabilities:
14557 probe_artifact_capabilities: 1
14558 shell:
14559 probe_artifact_shell: 1
14560capabilities:
14561 probe_capabilities: 1
14562 network:
14563 probe_network: 1
14564 peer_fetch:
14565 probe_peer_fetch: 1
14566 filesystem:
14567 probe_filesystem: 1
14568 shell:
14569 probe_shell: 1
14570 interpreter_runtime:
14571 - binary: python3
14572 probe_interpreter_runtime: 1
14573 dirs:
14574 - path: /usr/lib/python3
14575 probe_interpreter_runtime_dir: 1
14576 staged_runtime:
14577 - binary: rg
14578 probe_staged_runtime: 1
14579 spawn:
14580 probe_spawn: 1
14581 env:
14582 probe_env: 1
14583 limits:
14584 probe_limits: 1
14585 resources:
14586 probe_resources: 1
14587 state:
14588 probe_state: 1
14589 task_io:
14590 probe_task_io: 1
14591 conversation:
14592 probe_conversation: 1
14593 install:
14594 probe_install: 1
14595inference:
14596 probe_inference: 1
14597 driver:
14598 probe_driver: 1
14599 compaction:
14600 probe_compaction: 1
14601context:
14602 probe_context: 1
14603observability:
14604 probe_observability: 1
14605 eval:
14606 probe_eval: 1
14607 scorers:
14608 - type: exact_match
14609 probe_scorer: 1
14610trace:
14611 probe_trace: 1
14612network:
14613 probe_network_config: 1
14614lifecycle:
14615 probe_lifecycle: 1
14616exports:
14617 probe_exports: 1
14618 files:
14619 probe_files: 1
14620 peer_files:
14621 probe_peer_files: 1
14622control:
14623 probe_control: 1
14624"#,
14625 );
14626
14627 let expected: Vec<(String, String)> = [
14628 ("probe_root", ""),
14629 ("probe_artifact", "artifacts[0]"),
14630 ("probe_artifact_capabilities", "artifacts[0].capabilities"),
14631 ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
14632 ("probe_capabilities", "capabilities"),
14633 ("probe_network", "capabilities.network"),
14634 ("probe_peer_fetch", "capabilities.peer_fetch"),
14635 ("probe_filesystem", "capabilities.filesystem"),
14636 ("probe_shell", "capabilities.shell"),
14637 (
14638 "probe_interpreter_runtime",
14639 "capabilities.shell.interpreter_runtime[0]",
14640 ),
14641 (
14642 "probe_interpreter_runtime_dir",
14643 "capabilities.shell.interpreter_runtime[0].dirs[0]",
14644 ),
14645 (
14646 "probe_staged_runtime",
14647 "capabilities.shell.staged_runtime[0]",
14648 ),
14649 ("probe_spawn", "capabilities.spawn"),
14650 ("probe_env", "capabilities.env"),
14651 ("probe_limits", "capabilities.limits"),
14652 ("probe_resources", "capabilities.resources"),
14653 ("probe_state", "capabilities.state"),
14654 ("probe_task_io", "capabilities.task_io"),
14655 ("probe_conversation", "capabilities.conversation"),
14656 ("probe_install", "capabilities.install"),
14657 ("probe_inference", "inference"),
14658 ("probe_driver", "inference.driver"),
14659 ("probe_compaction", "inference.compaction"),
14660 ("probe_context", "context"),
14661 ("probe_observability", "observability"),
14662 ("probe_eval", "observability.eval"),
14663 ("probe_scorer", "observability.eval.scorers[0]"),
14664 ("probe_trace", "trace"),
14665 ("probe_network_config", "network"),
14666 ("probe_lifecycle", "lifecycle"),
14667 ("probe_exports", "exports"),
14668 ("probe_files", "exports.files"),
14669 ("probe_peer_files", "exports.peer_files"),
14670 ("probe_control", "control"),
14671 ]
14672 .into_iter()
14673 .map(|(key, path)| (key.to_string(), path.to_string()))
14674 .collect();
14675
14676 assert_eq!(keys, expected);
14677 }
14678
14679 fn serde_field_names(body: &[&str]) -> Vec<String> {
14683 let mut names = Vec::new();
14684 let mut rename: Option<String> = None;
14685 for line in body {
14686 let trimmed = line.trim();
14687 if trimmed.starts_with("#[") {
14688 if let Some(rest) = trimmed.split("rename = \"").nth(1) {
14689 if !trimmed.contains("rename_all") {
14690 rename = rest.split('"').next().map(str::to_string);
14691 }
14692 }
14693 continue;
14694 }
14695 if trimmed.starts_with("//") {
14696 continue;
14697 }
14698 let Some(field) = line
14699 .strip_prefix(" ")
14700 .and_then(|rest| rest.split(':').next())
14701 else {
14702 continue;
14703 };
14704 if field.is_empty()
14705 || !field
14706 .chars()
14707 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
14708 {
14709 continue;
14710 }
14711 if field == "unknown" {
14712 rename = None;
14713 continue;
14714 }
14715 names.push(rename.take().unwrap_or_else(|| field.to_string()));
14716 }
14717 names
14718 }
14719
14720 fn known_keys_of(source: &str, name: &str) -> Vec<String> {
14724 let header = format!("impl RawBlock for {name} {{");
14725 let body = source
14726 .split_once(&header)
14727 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
14728 .1;
14729 let array = body
14731 .split_once("KNOWN_KEYS: &'static [&'static str] =")
14732 .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
14733 .1
14734 .trim_start()
14735 .strip_prefix("&[")
14736 .unwrap_or_else(|| panic!("{name}'s KNOWN_KEYS is not an array literal"))
14737 .split_once("];")
14738 .expect("KNOWN_KEYS array is unterminated")
14739 .0;
14740 array
14741 .split('"')
14742 .skip(1)
14743 .step_by(2)
14744 .map(str::to_string)
14745 .collect()
14746 }
14747}
14748
14749#[cfg(test)]
14750mod control_tests {
14751 use super::*;
14752
14753 const TOOL_ENTRY: &str = " - name: local/card-api\n version: 0.1.0\n runtime: tool\n gateway:\n endpoint: https://cards.example.com\n api_key: ${CARD_TOKEN}\n";
14754
14755 fn agent(transport: &str, control: &str) -> String {
14758 let driver = if transport == "http" {
14759 " - name: local/drv\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.example.com\n api_key: ${DRIVER_KEY}\n"
14760 } else {
14761 " - name: local/drv\n version: 0.1.0\n runtime: driver\n"
14762 };
14763 format!(
14764 "name: cap\nversion: 0.0.1\nartifacts:\n{driver}{TOOL_ENTRY}inference:\n transport: {transport}\n model: m\n driver:\n artifact: local/drv\n{control}"
14765 )
14766 }
14767
14768 fn refusal(yaml: &str) -> (String, String) {
14769 match RuntimeManifest::from_yaml_str(yaml) {
14770 Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14771 other => panic!("expected InvalidControl, got {other:?}"),
14772 }
14773 }
14774
14775 #[test]
14776 fn control_absent_empty_and_empty_lists_load_as_none() {
14777 for control in [
14778 "",
14779 "control: {}\n",
14780 "control:\n",
14781 "control:\n settings: []\n secrets: []\n",
14782 ] {
14783 let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
14784 .unwrap_or_else(|err| panic!("{control:?}: {err}"));
14785 assert_eq!(manifest.control, None, "{control:?}");
14786 }
14787 }
14788
14789 #[test]
14790 fn control_parses_settings_and_secrets_in_manifest_order() {
14791 let yaml = agent(
14792 "http",
14793 "control:\n settings: [inference.max_tokens]\n secrets: [CARD_TOKEN]\n",
14794 );
14795 for manifest in [
14796 RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14797 RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14798 ] {
14799 let control = manifest.control.unwrap();
14800 assert_eq!(
14801 control.settings,
14802 vec![ControllableSetting::InferenceMaxTokens]
14803 );
14804 assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
14805 assert!(control.declares_secret("CARD_TOKEN"));
14806 assert!(!control.declares_secret("DRIVER_KEY"));
14807 }
14808 }
14809
14810 #[test]
14811 fn control_setting_wire_names_round_trip() {
14812 for setting in ControllableSetting::ALL {
14813 assert_eq!(
14814 ControllableSetting::from_wire_name(setting.wire_name()),
14815 Some(*setting)
14816 );
14817 }
14818 assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
14819 }
14820
14821 #[test]
14824 fn control_manifest_refusals_name_the_field() {
14825 let cases: &[(&str, &str, &str, &str)] = &[
14826 (
14827 "http",
14828 "control:\n settings: [inference.model]\n",
14829 "control.settings",
14830 "'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
14831 ),
14832 (
14833 "process",
14834 "control:\n settings: [inference.max_tokens]\n",
14835 "control.settings",
14836 "'inference.max_tokens' is controllable only under inference.transport: http",
14837 ),
14838 (
14839 "http",
14840 "control:\n settings: [inference.max_tokens, inference.max_tokens]\n",
14841 "control.settings",
14842 "'inference.max_tokens' is listed more than once",
14843 ),
14844 (
14845 "http",
14846 "control:\n secrets: [CARD_TOKEN, CARD_TOKEN]\n",
14847 "control.secrets",
14848 "'CARD_TOKEN' is listed more than once",
14849 ),
14850 (
14851 "http",
14852 "control:\n secrets: [card-token]\n",
14853 "control.secrets",
14854 "'card-token' is not a credential name",
14855 ),
14856 (
14857 "http",
14858 "control:\n secrets: [OTHER_TOKEN]\n",
14859 "control.secrets",
14860 "'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
14861 ),
14862 (
14863 "http",
14864 "control:\n secrets: [DRIVER_KEY]\n",
14865 "control.secrets",
14866 "'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.local/drv.gateway.api_key)",
14867 ),
14868 (
14869 "http",
14870 "control:\n settings: inference.max_tokens\n",
14871 "control.settings",
14872 "must be a list of names",
14873 ),
14874 (
14875 "http",
14876 "control:\n secrets: [1]\n",
14877 "control.secrets",
14878 "must be a list of names",
14879 ),
14880 ];
14881 for (transport, control, field, message) in cases {
14882 let (got_field, got_message) = refusal(&agent(transport, control));
14883 assert_eq!(&got_field, field, "{control:?}");
14884 assert!(
14885 got_message.starts_with(message),
14886 "{control:?}: {got_message}"
14887 );
14888 let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
14889 .unwrap_err()
14890 .to_string();
14891 assert!(
14892 rendered.contains(&format!("invalid control config for '{field}'")),
14893 "{rendered}"
14894 );
14895 }
14896 }
14897
14898 #[test]
14899 fn control_on_a_capsule_with_no_inference_is_refused() {
14900 let yaml = format!(
14901 "name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n secrets: [CARD_TOKEN]\n"
14902 );
14903 let (field, message) = refusal(&yaml);
14904 assert_eq!(field, "control");
14905 assert!(message.contains("no inference: block"), "{message}");
14906 }
14907
14908 #[test]
14909 fn control_unknown_key_is_reported_once_at_its_block() {
14910 let manifest = RuntimeManifest::from_yaml_str(&agent(
14911 "http",
14912 "control:\n settings: [inference.max_tokens]\n agent_may_call: true\n",
14913 ))
14914 .unwrap();
14915 let keys: Vec<(&str, &str)> = manifest
14916 .unknown_keys
14917 .iter()
14918 .map(|key| (key.key.as_str(), key.block_path.as_str()))
14919 .collect();
14920 assert_eq!(keys, vec![("agent_may_call", "control")]);
14921 assert_eq!(
14922 manifest.control.unwrap().settings,
14923 vec![ControllableSetting::InferenceMaxTokens]
14924 );
14925 }
14926}
14927
14928#[cfg(test)]
14929mod driver_choice_tests {
14930 use super::*;
14931
14932 const DRV: &str = " - name: local/drv\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.example.com\n api_key: ${DRIVER_KEY}\n";
14933 const ALT: &str = " - name: local/alt\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://alt.example.com\n api_key: ${ALT_KEY}\n";
14934 const ALT_NO_GATEWAY: &str = " - name: local/alt\n version: 0.1.0\n runtime: driver\n";
14935 const TOOL: &str = " - name: local/card-api\n version: 0.1.0\n runtime: tool\n";
14936 const GPT: &str =
14937 " - name: gpt\n model: gpt-5\n driver:\n artifact: local/alt\n";
14938 const HAIKU: &str =
14939 " - name: haiku\n model: haiku\n driver:\n artifact: local/drv\n";
14940 const SWITCHABLE: &str = "control:\n settings: [inference.driver]\n";
14941
14942 fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
14945 let alternates = if alternates.is_empty() {
14946 String::new()
14947 } else {
14948 format!(" alternates:\n{alternates}")
14949 };
14950 format!(
14951 "name: cap\nversion: 0.0.1\nartifacts:\n{DRV}{artifacts}inference:\n transport: http\n model: m\n driver:\n artifact: local/drv\n{alternates}{control}"
14952 )
14953 }
14954
14955 fn refusal(yaml: &str) -> (String, String) {
14956 match RuntimeManifest::from_yaml_str(yaml) {
14957 Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
14958 | Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14959 other => panic!("expected an inference or control refusal, got {other:?}"),
14960 }
14961 }
14962
14963 #[test]
14964 fn driver_choice_alternates_parse_in_manifest_order() {
14965 let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
14966 for manifest in [
14967 RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14968 RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14969 ] {
14970 let inference = manifest.inference.unwrap();
14971 assert_eq!(
14972 inference.alternates,
14973 vec![
14974 InferenceAlternate {
14975 name: "gpt".to_string(),
14976 model: "gpt-5".to_string(),
14977 driver: "local/alt".to_string(),
14978 },
14979 InferenceAlternate {
14980 name: "haiku".to_string(),
14981 model: "haiku".to_string(),
14982 driver: "local/drv".to_string(),
14983 },
14984 ]
14985 );
14986 let control = manifest.control.unwrap();
14987 assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
14988 assert!(control.agent_settings.is_empty());
14989 }
14990 }
14991
14992 #[test]
14993 fn driver_choice_absent_alternates_are_empty() {
14994 let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
14995 assert!(manifest.inference.unwrap().alternates.is_empty());
14996 }
14997
14998 #[test]
14999 fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
15000 let manifest = RuntimeManifest::from_yaml_str(&capsule(
15001 ALT,
15002 GPT,
15003 "control:\n agent_settings: [inference.driver]\n",
15004 ))
15005 .unwrap();
15006 let control = manifest.control.unwrap();
15007 assert!(control.settings.is_empty());
15008 assert_eq!(
15009 control.agent_settings,
15010 vec![ControllableSetting::InferenceDriver]
15011 );
15012 assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
15013 assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
15014 assert!(control.secrets.is_empty());
15015 }
15016
15017 #[test]
15018 fn driver_choice_wire_name_round_trips() {
15019 assert_eq!(
15020 ControllableSetting::from_wire_name("inference.driver"),
15021 Some(ControllableSetting::InferenceDriver)
15022 );
15023 assert_eq!(
15024 ControllableSetting::InferenceDriver.wire_name(),
15025 "inference.driver"
15026 );
15027 assert_eq!(
15028 ControllableSetting::AGENT_SETTABLE,
15029 &[ControllableSetting::InferenceDriver]
15030 );
15031 }
15032
15033 #[test]
15035 fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
15036 RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
15037 .expect("an alternate's driver carries its own gateway");
15038 match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
15039 Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
15040 assert!(message.contains("inference.alternates"), "{message}")
15041 }
15042 other => panic!("expected InvalidArtifact, got {other:?}"),
15043 }
15044 }
15045
15046 #[test]
15048 fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
15049 let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
15050 assert_eq!(
15051 manifest.inference.unwrap().alternates[0].driver,
15052 "local/drv"
15053 );
15054 }
15055
15056 #[test]
15057 fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
15058 let manifest = RuntimeManifest::from_yaml_str(&capsule(
15059 ALT,
15060 GPT,
15061 "control:\n settings: [inference.driver]\n secrets: [ALT_KEY]\n",
15062 ))
15063 .unwrap();
15064 assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
15065 let (field, message) = refusal(&capsule(
15066 ALT,
15067 GPT,
15068 "control:\n settings: [inference.driver]\n secrets: [DRIVER_KEY]\n",
15069 ));
15070 assert_eq!(field, "control.secrets");
15071 assert!(
15072 message.contains("keys the configured inference driver's gateway"),
15073 "{message}"
15074 );
15075 }
15076
15077 #[test]
15078 fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
15079 let alternate = " - name: gpt\n model: gpt-5\n temperature: 1\n driver:\n artifact: local/alt\n";
15080 let manifest =
15081 RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
15082 let keys: Vec<(&str, &str)> = manifest
15083 .unknown_keys
15084 .iter()
15085 .map(|key| (key.key.as_str(), key.block_path.as_str()))
15086 .collect();
15087 assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
15088 }
15089
15090 #[test]
15093 fn driver_choice_manifest_refusals_name_the_field() {
15094 let alternate = |name: &str, model: &str, driver: &str| {
15095 format!(" - name: {name}\n model: {model}\n driver:\n artifact: {driver}\n")
15096 };
15097 let process = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/drv\n version: 0.1.0\n runtime: driver\ninference:\n transport: process\n driver:\n artifact: local/drv\n";
15098 let cases: Vec<(String, &str, &str)> = vec![
15099 (
15100 format!("{process} alternates:\n{GPT}"),
15101 "inference.alternates",
15102 "is not valid with transport: process",
15103 ),
15104 (
15105 format!("{process}control:\n settings: [inference.driver]\n"),
15106 "control.settings",
15107 "'inference.driver' is controllable only under inference.transport: http",
15108 ),
15109 (
15110 capsule(ALT, &alternate("primary", "gpt-5", "local/alt"), SWITCHABLE),
15111 "inference.alternates[0].name",
15112 "'primary' is reserved",
15113 ),
15114 (
15115 capsule(
15116 ALT,
15117 &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt", "gpt-4", "local/alt")),
15118 SWITCHABLE,
15119 ),
15120 "inference.alternates[1].name",
15121 "'gpt' names more than one alternate",
15122 ),
15123 (
15124 capsule(ALT, &alternate("GPT", "gpt-5", "local/alt"), SWITCHABLE),
15125 "inference.alternates[0].name",
15126 "'GPT' is not a driver choice name",
15127 ),
15128 (
15129 capsule(ALT, &alternate("5gpt", "gpt-5", "local/alt"), SWITCHABLE),
15130 "inference.alternates[0].name",
15131 "'5gpt' is not a driver choice name",
15132 ),
15133 (
15134 capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "local/alt"), SWITCHABLE),
15135 "inference.alternates[0].name",
15136 "is not a driver choice name",
15137 ),
15138 (
15139 capsule(ALT, " - name: gpt\n model: \"\"\n driver:\n artifact: local/alt\n", SWITCHABLE),
15140 "inference.alternates[0].model",
15141 "missing required field",
15142 ),
15143 (
15144 capsule(ALT, " - name: gpt\n model: gpt-5\n", SWITCHABLE),
15145 "inference.alternates[0].driver.artifact",
15146 "missing required field",
15147 ),
15148 (
15149 capsule("", &alternate("gpt", "gpt-5", "local/nowhere"), SWITCHABLE),
15150 "inference.alternates[0].driver.artifact",
15151 "artifact 'local/nowhere' is not declared in artifacts:",
15152 ),
15153 (
15154 capsule(TOOL, &alternate("gpt", "gpt-5", "local/card-api"), SWITCHABLE),
15155 "inference.alternates[0].driver.artifact",
15156 "artifact 'local/card-api' is declared with runtime: tool",
15157 ),
15158 (
15159 capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "local/alt"), SWITCHABLE),
15160 "inference.alternates[0].driver.artifact",
15161 "artifact 'local/alt' declares no gateway.endpoint",
15162 ),
15163 (
15164 capsule(
15165 ALT,
15166 " - name: gpt\n model: gpt-5\n driver:\n artifact: local/alt\n config:\n thinking: enabled\n",
15167 SWITCHABLE,
15168 ),
15169 "inference.alternates[0].driver.config",
15170 "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",
15171 ),
15172 (
15173 capsule("", &alternate("same", "m", "local/drv"), SWITCHABLE),
15174 "inference.alternates[0]",
15175 "'same' is driver 'local/drv' with model 'm', which is the primary choice",
15176 ),
15177 (
15178 capsule(
15179 ALT,
15180 &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt-too", "gpt-5", "local/alt")),
15181 SWITCHABLE,
15182 ),
15183 "inference.alternates[1]",
15184 "'gpt-too' is driver 'local/alt' with model 'gpt-5', which alternate 'gpt' already is",
15185 ),
15186 (
15187 capsule(ALT, GPT, ""),
15188 "inference.alternates",
15189 "declares alternates that nothing could ever select",
15190 ),
15191 (
15192 capsule(ALT, GPT, "control:\n settings: [inference.max_tokens]\n"),
15193 "inference.alternates",
15194 "declares alternates that nothing could ever select",
15195 ),
15196 (
15197 capsule("", "", SWITCHABLE),
15198 "control.settings",
15199 "'inference.driver' selects among inference.alternates, and this capsule declares none",
15200 ),
15201 (
15202 capsule("", "", "control:\n agent_settings: [inference.driver]\n"),
15203 "control.agent_settings",
15204 "'inference.driver' selects among inference.alternates, and this capsule declares none",
15205 ),
15206 (
15207 capsule(ALT, GPT, "control:\n agent_settings: [inference.max_tokens]\n"),
15208 "control.agent_settings",
15209 "'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
15210 ),
15211 (
15212 capsule(
15213 ALT,
15214 GPT,
15215 "control:\n agent_settings: [inference.driver, inference.driver]\n",
15216 ),
15217 "control.agent_settings",
15218 "'inference.driver' is listed more than once",
15219 ),
15220 (
15221 capsule(ALT, GPT, "control:\n agent_settings: inference.driver\n"),
15222 "control.agent_settings",
15223 "must be a list of names",
15224 ),
15225 ];
15226 for (yaml, field, message) in &cases {
15227 let (got_field, got_message) = refusal(yaml);
15228 assert_eq!(&got_field, field, "{yaml}");
15229 assert!(got_message.contains(message), "{yaml}\n{got_message}");
15230 let rendered = RuntimeManifest::from_yaml_str(yaml)
15231 .unwrap_err()
15232 .to_string();
15233 assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
15234 }
15235 }
15236}
15237
15238#[cfg(test)]
15239mod network_authentication_tests {
15240 use super::*;
15241
15242 fn network_authentication_of(
15243 block: &str,
15244 ) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
15245 RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
15246 .map(|manifest| manifest.network.and_then(|network| network.authentication))
15247 }
15248
15249 fn network_authentication_refusal(block: &str) -> (String, String) {
15250 match network_authentication_of(block) {
15251 Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
15252 other => panic!("expected InvalidNetworkConfig, got {other:?}"),
15253 }
15254 }
15255
15256 #[test]
15257 fn network_authentication_with_credentials_parses_sorted_by_name() {
15258 let auth = network_authentication_of(
15259 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15260 watcher:\n scopes: [GetTask, murmur.WatchSession]\n \
15261 reader:\n scopes: [resources/files]\n",
15262 )
15263 .unwrap()
15264 .expect("declared");
15265 assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
15266 assert_eq!(
15267 auth.credentials,
15268 vec![
15269 DoorCredential {
15270 name: "reader".to_string(),
15271 scopes: vec!["resources/files".to_string()],
15272 },
15273 DoorCredential {
15274 name: "watcher".to_string(),
15275 scopes: vec!["GetTask".to_string(), "murmur.WatchSession".to_string()],
15277 },
15278 ]
15279 );
15280 }
15281
15282 #[test]
15283 fn network_authentication_scheme_alone_parses() {
15284 let auth = network_authentication_of("network:\n authentication:\n scheme: bearer\n")
15285 .unwrap()
15286 .expect("declared");
15287 assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
15288 assert!(auth.credentials.is_empty());
15289 }
15290
15291 #[test]
15292 fn network_authentication_absent_is_a_public_door() {
15293 assert_eq!(network_authentication_of("").unwrap(), None);
15294 assert_eq!(
15295 network_authentication_of("network:\n internal_port: 8080\n").unwrap(),
15296 None
15297 );
15298 let manifest = RuntimeManifest::from_yaml_str(
15299 "name: cap\nversion: 0.1.0\nnetwork:\n internal_port: 8080\n",
15300 )
15301 .unwrap();
15302 assert_eq!(
15303 manifest.network,
15304 Some(NetworkConfig {
15305 internal_port: Some(8080),
15306 authentication: None,
15307 })
15308 );
15309 }
15310
15311 #[test]
15312 fn network_authentication_without_a_scheme_is_refused() {
15313 let (field, message) = network_authentication_refusal(
15314 "network:\n authentication:\n credentials:\n w:\n scopes: [GetTask]\n",
15315 );
15316 assert_eq!(field, "network.authentication.scheme");
15317 assert!(message.contains("required"), "{message}");
15318 }
15319
15320 #[test]
15321 fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
15322 for scheme in ["mtls", "Bearer", "basic"] {
15323 let (field, message) = network_authentication_refusal(&format!(
15324 "network:\n authentication:\n scheme: {scheme}\n"
15325 ));
15326 assert_eq!(field, "network.authentication.scheme");
15327 assert!(message.contains("bearer"), "{message}");
15328 assert!(message.contains(scheme), "{message}");
15329 }
15330 }
15331
15332 #[test]
15333 fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
15334 let long = format!("a{}", "b".repeat(32));
15335 for name in [
15336 "operator",
15337 "Watcher",
15338 "1st",
15339 "_x",
15340 "-x",
15341 "wat.cher",
15342 long.as_str(),
15343 ] {
15344 let (field, message) = network_authentication_refusal(&format!(
15345 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15346 '{name}':\n scopes: [GetTask]\n"
15347 ));
15348 assert_eq!(field, "network.authentication.credentials", "{name}");
15349 assert!(message.contains(name), "{message}");
15350 }
15351 let longest = format!("a{}", "b".repeat(31));
15352 for name in ["w", "a-b_c9", longest.as_str()] {
15353 network_authentication_of(&format!(
15354 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15355 {name}:\n scopes: [GetTask]\n"
15356 ))
15357 .unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
15358 }
15359 }
15360
15361 #[test]
15362 fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
15363 let credential = |body: &str| {
15364 format!(
15365 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15366 watcher:\n{body}"
15367 )
15368 };
15369 let field = "network.authentication.credentials.watcher.scopes";
15370
15371 let (got, message) = network_authentication_refusal(&credential(" {}\n"));
15372 assert_eq!(got, field);
15373 assert!(message.contains("required"), "{message}");
15374
15375 let (got, message) = network_authentication_refusal(&credential(" scopes: []\n"));
15376 assert_eq!(got, field);
15377 assert!(message.contains("no scope"), "{message}");
15378
15379 let (got, message) =
15380 network_authentication_refusal(&credential(" scopes: [tasks/list]\n"));
15381 assert_eq!(got, field);
15382 assert!(message.contains("tasks/list"), "{message}");
15383 assert!(message.contains("resources/files"), "{message}");
15384
15385 let (got, message) =
15386 network_authentication_refusal(&credential(" scopes: [GetExtendedAgentCard]\n"));
15387 assert_eq!(got, field);
15388 assert!(message.contains("GetExtendedAgentCard"), "{message}");
15389
15390 let (got, message) = network_authentication_refusal(&credential(
15391 " scopes: [GetTask, murmur.WatchSession, GetTask]\n",
15392 ));
15393 assert_eq!(got, field);
15394 assert!(message.contains("more than once"), "{message}");
15395 }
15396
15397 #[test]
15398 fn network_authentication_refuses_a_retired_scope_naming_its_replacement() {
15399 let field = "network.authentication.credentials.watcher.scopes";
15400 for (retired, replacement) in [
15401 ("message/send", "SendMessage"),
15402 ("message/stream", "SendStreamingMessage"),
15403 ("tasks/get", "GetTask"),
15404 ("tasks/cancel", "CancelTask"),
15405 ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
15406 ("stream/watch", "murmur.WatchSession"),
15407 ("session/stop", "murmur.StopSession"),
15408 ] {
15409 let error = network_authentication_of(&format!(
15410 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15411 watcher:\n scopes: [murmur.WatchSession, {retired}]\n"
15412 ))
15413 .unwrap_err();
15414 let rendered = error.to_string();
15415 match error {
15416 RuntimeManifestError::RetiredDoorScope {
15417 field: got_field,
15418 scope,
15419 replacement: got_replacement,
15420 } => {
15421 assert_eq!(got_field, field);
15422 assert_eq!(scope, retired);
15423 assert_eq!(got_replacement, replacement);
15424 }
15425 other => panic!("expected RetiredDoorScope for {retired}, got {other:?}"),
15426 }
15427 assert!(
15428 rendered.contains(&format!(
15429 "'{retired}' is a retired name of a door method, and the door answers only \
15430 the names its card lists"
15431 )),
15432 "{rendered}"
15433 );
15434 assert!(rendered.contains(field), "{rendered}");
15435 }
15436 }
15437
15438 #[test]
15439 fn network_authentication_accepts_the_v1_scopes() {
15440 let auth = network_authentication_of(
15441 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15442 watcher:\n scopes: [GetTask, murmur.WatchSession, murmur.StopSession]\n",
15443 )
15444 .unwrap()
15445 .unwrap();
15446 assert_eq!(
15447 auth.credentials[0].scopes,
15448 ["GetTask", "murmur.WatchSession", "murmur.StopSession"]
15449 );
15450 }
15451
15452 #[test]
15455 fn door_scope_list_tasks_and_subscribe_to_task_load_on_a_credential() {
15456 let auth = network_authentication_of(
15457 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15458 alice:\n scopes: [SendMessage, ListTasks, SubscribeToTask]\n",
15459 )
15460 .unwrap()
15461 .unwrap();
15462 assert_eq!(
15463 auth.credentials[0].scopes,
15464 ["SendMessage", "ListTasks", "SubscribeToTask"]
15465 );
15466 assert_eq!(
15467 DOOR_SCOPES,
15468 [
15469 "SendMessage",
15470 "SendStreamingMessage",
15471 "murmur.WatchSession",
15472 "GetTask",
15473 "ListTasks",
15474 "CancelTask",
15475 "SubscribeToTask",
15476 "murmur.StopSession",
15477 "resources/files",
15478 ]
15479 );
15480 }
15481
15482 #[test]
15483 fn network_authentication_accepts_every_door_scope() {
15484 let auth = network_authentication_of(&format!(
15485 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15486 everything:\n scopes: [{}]\n",
15487 DOOR_SCOPES.join(", ")
15488 ))
15489 .unwrap()
15490 .unwrap();
15491 assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
15492 assert_eq!(OPERATOR_CREDENTIAL, "operator");
15493 }
15494
15495 #[test]
15496 fn network_authentication_is_refused_beside_spawn_allow() {
15497 let (field, message) = network_authentication_refusal(
15498 "network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
15499 allow: [local/worker]\n",
15500 );
15501 assert_eq!(field, "network.authentication");
15502 assert!(message.contains("capabilities.spawn.allow"), "{message}");
15503 assert!(message.contains("token"), "{message}");
15504 let error = RuntimeManifest::from_yaml_str(
15505 "name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n\
15506 capabilities:\n spawn:\n allow: [local/worker]\n",
15507 )
15508 .unwrap_err()
15509 .to_string();
15510 assert!(error.contains("network.authentication"), "{error}");
15511 assert!(error.contains("capabilities.spawn.allow"), "{error}");
15512
15513 network_authentication_of(
15515 "network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
15516 allow: []\n",
15517 )
15518 .unwrap();
15519 }
15520
15521 #[test]
15522 fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
15523 let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
15524 "name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n \
15525 schemes: x\n credentials:\n watcher:\n scopes: [GetTask]\n \
15526 scope: [GetTask]\n",
15527 )
15528 .unwrap()
15529 .unknown_keys
15530 .into_iter()
15531 .map(|key| (key.key, key.block_path, key.nearest_known))
15532 .collect();
15533 assert_eq!(
15534 keys,
15535 vec![
15536 (
15537 "schemes".to_string(),
15538 "network.authentication".to_string(),
15539 Some("scheme".to_string())
15540 ),
15541 (
15542 "scope".to_string(),
15543 "network.authentication.credentials.watcher".to_string(),
15544 Some("scopes".to_string())
15545 ),
15546 ]
15547 );
15548 }
15549}
15550
15551#[cfg(test)]
15552mod registry_block_tests {
15553 use super::*;
15554
15555 const REGISTRY_BLOCK: &str = "registry:\n default: local\n";
15556
15557 #[test]
15558 fn registry_block_is_refused_by_both_entry_points() {
15559 let input = format!("name: cap\nversion: 0.0.1\nartifacts: []\n{REGISTRY_BLOCK}");
15560 for result in [
15561 RuntimeManifest::from_yaml_str(&input),
15562 RuntimeManifest::from_yaml_str_without_secrets(&input),
15563 ] {
15564 let err = result.unwrap_err();
15565 assert!(
15566 matches!(err, RuntimeManifestError::RegistryBlock),
15567 "{err:?}"
15568 );
15569 assert_eq!(err.to_string(), REGISTRY_BLOCK_MESSAGE);
15570 }
15571 }
15572
15573 #[test]
15574 fn registry_block_is_refused_before_any_other_field() {
15575 let input = format!(
15578 "version: 0.0.1\nlifecycle:\n task_acceptance: queue\n queue_depth: 0\n\
15579 {REGISTRY_BLOCK}"
15580 );
15581 let err = RuntimeManifest::from_yaml_str(&input).unwrap_err();
15582 assert!(
15583 matches!(err, RuntimeManifestError::RegistryBlock),
15584 "{err:?}"
15585 );
15586 }
15587
15588 #[test]
15589 fn registry_block_refusal_applies_whatever_the_block_holds() {
15590 for block in ["registry: local\n", "registry: {}\n", "registry: []\n"] {
15591 let input = format!("name: cap\nversion: 0.0.1\n{block}");
15592 assert!(
15593 matches!(
15594 RuntimeManifest::from_yaml_str(&input),
15595 Err(RuntimeManifestError::RegistryBlock)
15596 ),
15597 "{block}"
15598 );
15599 }
15600 }
15601
15602 #[test]
15603 fn a_manifest_without_a_registry_block_parses_unchanged() {
15604 let manifest =
15605 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
15606 assert_eq!(manifest.name, "cap");
15607 assert_eq!(manifest.version, "0.0.1");
15608 }
15609}
15610
15611#[cfg(test)]
15612mod scoped_name_tests {
15613 use super::*;
15614
15615 fn name_refusal(yaml: &str) -> (String, String, Option<String>) {
15616 match RuntimeManifest::from_yaml_str(yaml) {
15617 Err(RuntimeManifestError::InvalidArtifactName {
15618 field,
15619 message,
15620 hint,
15621 }) => (field, message, hint),
15622 other => panic!("expected InvalidArtifactName, got {other:?}"),
15623 }
15624 }
15625
15626 #[test]
15627 fn scoped_name_a_bare_artifact_entry_is_refused_with_the_scoped_forms() {
15628 let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n - name: tool-editor\n \
15629 version: 0.1.0\n runtime: tool\n";
15630 let (field, message, hint) = name_refusal(yaml);
15631 assert_eq!(field, "artifacts[0].name");
15632 assert_eq!(
15633 message,
15634 "'tool-editor' is not a full artifact name: an artifact is named <scope>/<name>"
15635 );
15636 assert_eq!(
15637 hint.as_deref(),
15638 Some(
15639 "name it with its scope: 'murmur/tool-editor' for a Murmur default artifact, \
15640 '<owner>/tool-editor' for one published from github.com/<owner>, or \
15641 'local/tool-editor' for one installed from a .mur.zip file"
15642 )
15643 );
15644 let rendered = RuntimeManifest::from_yaml_str(yaml)
15645 .unwrap_err()
15646 .to_string();
15647 assert!(
15648 rendered.starts_with("murmur.yaml: invalid artifact name in 'artifacts[0].name': "),
15649 "{rendered}"
15650 );
15651 }
15652
15653 #[test]
15654 fn scoped_name_a_source_entry_keeps_its_bare_name() {
15655 let bare = "name: cap\nversion: 0.0.1\nartifacts:\n - name: style\n \
15656 source: ./skills/style\n runtime: skill\n";
15657 RuntimeManifest::from_yaml_str(bare).expect("a source: entry is named by its bare name");
15658
15659 let (field, message, hint) = name_refusal(&bare.replace("name: style", "name: ana/style"));
15660 assert_eq!(field, "artifacts[0].name");
15661 assert!(
15662 message
15663 .starts_with("'ana/style' declares 'source:', so it is named by its bare name; "),
15664 "{message}"
15665 );
15666 assert_eq!(hint.as_deref(), Some("drop the scope: name it 'style'"));
15667 }
15668
15669 #[test]
15670 fn scoped_name_spawn_allow_refuses_a_bare_entry_and_a_shared_bare_part() {
15671 let with_allow = |allow: &str| {
15672 format!("name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n allow: [{allow}]\n")
15673 };
15674 let manifest = RuntimeManifest::from_yaml_str(&with_allow("local/worker, ana/reviewer"))
15675 .expect("full names in two scopes with distinct bare parts parse");
15676 assert_eq!(
15677 manifest.capabilities.unwrap().spawn.unwrap().allow,
15678 vec!["local/worker".to_string(), "ana/reviewer".to_string()]
15679 );
15680
15681 let (field, message, hint) = name_refusal(&with_allow("worker"));
15682 assert_eq!(field, "capabilities.spawn.allow[0]");
15683 assert!(
15684 message.contains("'worker' is not a full artifact name"),
15685 "{message}"
15686 );
15687 assert!(hint.unwrap().contains("local/worker"));
15688
15689 let (field, message, hint) = name_refusal(&with_allow("local/worker, ana/worker"));
15690 assert_eq!(field, "capabilities.spawn.allow[1]");
15691 assert_eq!(
15692 message,
15693 "'local/worker' and 'ana/worker' share the name 'worker', which is how delegate-task \
15694 offers a capsule to the model"
15695 );
15696 assert_eq!(
15697 hint.as_deref(),
15698 Some("keep one of 'local/worker' and 'ana/worker' in capabilities.spawn.allow")
15699 );
15700 }
15701
15702 #[test]
15703 fn spawn_workdir_snapshot_lowers_beside_allow_and_is_empty_when_absent() {
15704 let yaml = "name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n \
15705 allow: [local/reviewer, local/worker]\n \
15706 workdir_snapshot: [local/reviewer]\n";
15707 let spawn = RuntimeManifest::from_yaml_str(yaml)
15708 .expect("a snapshot entry that is also in allow parses")
15709 .capabilities
15710 .unwrap()
15711 .spawn
15712 .unwrap();
15713 assert_eq!(
15714 spawn,
15715 SpawnCapabilities {
15716 allow: vec!["local/reviewer".to_string(), "local/worker".to_string()],
15717 workdir_snapshot: vec!["local/reviewer".to_string()],
15718 }
15719 );
15720
15721 let absent = "name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n \
15722 allow: [local/reviewer]\n";
15723 let spawn = RuntimeManifest::from_yaml_str(absent)
15724 .unwrap()
15725 .capabilities
15726 .unwrap()
15727 .spawn
15728 .unwrap();
15729 assert!(spawn.workdir_snapshot.is_empty());
15730 }
15731
15732 #[test]
15733 fn spawn_workdir_snapshot_refuses_an_entry_outside_allow_a_bare_name_and_a_duplicate() {
15734 let with = |snapshot: &str| {
15735 format!(
15736 "name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n \
15737 allow: [local/reviewer, local/worker]\n workdir_snapshot: [{snapshot}]\n"
15738 )
15739 };
15740
15741 let (field, message, hint) = name_refusal(&with("local/reviewer, local/other"));
15742 assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
15743 assert_eq!(
15744 message,
15745 "'local/other' is not in capabilities.spawn.allow, so this capsule cannot delegate \
15746 to it"
15747 );
15748 assert!(
15749 hint.as_deref()
15750 .is_some_and(|hint| hint.contains("capabilities.spawn.allow")),
15751 "{hint:?}"
15752 );
15753
15754 let (field, message, _) = name_refusal(&with("reviewer"));
15755 assert_eq!(field, "capabilities.spawn.workdir_snapshot[0]");
15756 assert!(
15757 message.contains("'reviewer' is not a full artifact name"),
15758 "{message}"
15759 );
15760
15761 let (field, message, hint) = name_refusal(&with("local/reviewer, local/reviewer"));
15762 assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
15763 assert_eq!(message, "'local/reviewer' is listed more than once");
15764 assert_eq!(
15765 hint.as_deref(),
15766 Some("keep one 'local/reviewer' in capabilities.spawn.workdir_snapshot")
15767 );
15768 }
15769
15770 #[test]
15771 fn scoped_name_a_reference_by_bare_part_names_the_declared_entry() {
15772 let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n \
15773 version: 0.1.0\n runtime: driver\n gateway:\n \
15774 endpoint: https://api.anthropic.com\n api_key: k\ninference:\n \
15775 transport: http\n model: m\n driver:\n artifact: driver-anthropic\n";
15776 let message = RuntimeManifest::from_yaml_str(yaml)
15777 .unwrap_err()
15778 .to_string();
15779 assert!(
15780 message.contains(
15781 "artifact 'driver-anthropic' is not declared in artifacts: (the declared \
15782 'murmur/driver-anthropic' has that name; refer to it as \
15783 'murmur/driver-anthropic')"
15784 ),
15785 "{message}"
15786 );
15787 }
15788}
15789
15790#[cfg(test)]
15791mod inference_retry_tests {
15792 use super::*;
15793
15794 const HTTP: &str = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/drv\n \
15795 version: 0.1.0\n runtime: driver\n gateway:\n endpoint: \
15796 http://127.0.0.1:8080\n api_key: sk-test\ninference:\n transport: \
15797 http\n model: m\n driver:\n artifact: local/drv\n";
15798 const PROCESS: &str = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/drv\n \
15799 version: 0.1.0\n runtime: driver\ninference:\n transport: \
15800 process\n driver:\n artifact: local/drv\n";
15801
15802 fn retry(yaml: &str) -> InferenceRetry {
15803 RuntimeManifest::from_yaml_str(yaml)
15804 .unwrap()
15805 .inference
15806 .unwrap()
15807 .retry
15808 }
15809
15810 fn refusal(yaml: &str) -> (String, String) {
15811 match RuntimeManifest::from_yaml_str(yaml) {
15812 Err(RuntimeManifestError::InvalidInferenceConfig { field, message }) => {
15813 (field, message)
15814 }
15815 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
15816 }
15817 }
15818
15819 #[test]
15820 fn inference_retry_defaults_when_absent_and_per_key_when_partial() {
15821 assert_eq!(retry(HTTP), InferenceRetry::default());
15822 assert_eq!(
15823 InferenceRetry::default(),
15824 InferenceRetry {
15825 max_retries: 6,
15826 initial_delay_ms: 1_000,
15827 max_delay_ms: 60_000,
15828 }
15829 );
15830 assert_eq!(
15831 retry(&format!("{HTTP} retry:\n max_retries: 2\n")),
15832 InferenceRetry {
15833 max_retries: 2,
15834 ..InferenceRetry::default()
15835 }
15836 );
15837 assert_eq!(
15838 retry(&format!(
15839 "{HTTP} retry:\n initial_delay_ms: 10\n max_delay_ms: 2000\n"
15840 )),
15841 InferenceRetry {
15842 max_retries: 6,
15843 initial_delay_ms: 10,
15844 max_delay_ms: 2000,
15845 }
15846 );
15847 assert_eq!(
15848 retry(&format!(
15849 "{HTTP} retry:\n max_retries: 0\n initial_delay_ms: 60000\n \
15850 max_delay_ms: 3600000\n"
15851 )),
15852 InferenceRetry {
15853 max_retries: 0,
15854 initial_delay_ms: 60_000,
15855 max_delay_ms: 3_600_000,
15856 }
15857 );
15858 assert_eq!(retry(PROCESS), InferenceRetry::default());
15859 }
15860
15861 #[test]
15862 fn inference_retry_bounds_are_refused_naming_the_field() {
15863 for (block, field) in [
15864 ("max_retries: 21", "inference.retry.max_retries"),
15865 ("initial_delay_ms: 0", "inference.retry.initial_delay_ms"),
15866 (
15867 "initial_delay_ms: 60001",
15868 "inference.retry.initial_delay_ms",
15869 ),
15870 ("max_delay_ms: 0", "inference.retry.max_delay_ms"),
15871 ("max_delay_ms: 3600001", "inference.retry.max_delay_ms"),
15872 (
15873 "initial_delay_ms: 5000\n max_delay_ms: 1000",
15874 "inference.retry.max_delay_ms",
15875 ),
15876 ] {
15877 let (got, message) = refusal(&format!("{HTTP} retry:\n {block}\n"));
15878 assert_eq!(got, field, "{block}: {message}");
15879 }
15880 let (_, message) = refusal(&format!(
15881 "{HTTP} retry:\n initial_delay_ms: 5000\n max_delay_ms: 1000\n"
15882 ));
15883 assert!(message.contains("initial_delay_ms"), "{message}");
15884 }
15885
15886 #[test]
15887 fn inference_retry_is_refused_under_transport_process() {
15888 let (field, message) = refusal(&format!("{PROCESS} retry:\n max_retries: 2\n"));
15889 assert_eq!(field, "inference.retry");
15890 assert_eq!(
15891 message,
15892 "is not valid with transport: process; the harness retries its own provider calls"
15893 );
15894 }
15895
15896 #[test]
15897 fn inference_retry_unknown_key_is_reported_at_its_block() {
15898 let manifest = RuntimeManifest::from_yaml_str(&format!(
15899 "{HTTP} retry:\n max_retries: 2\n jitter: full\n"
15900 ))
15901 .unwrap();
15902 let keys: Vec<(&str, &str)> = manifest
15903 .unknown_keys
15904 .iter()
15905 .map(|key| (key.key.as_str(), key.block_path.as_str()))
15906 .collect();
15907 assert_eq!(keys, vec![("jitter", "inference.retry")]);
15908 }
15909}
15910
15911#[cfg(test)]
15912mod host_paths_tests {
15913 use super::*;
15914
15915 const ENTRY: &str = " - path: ~/.cargo\n access: read-write\n";
15916
15917 fn refused_field(input: &str) -> String {
15919 let fields: Vec<String> = [
15920 RuntimeManifest::from_yaml_str(input),
15921 RuntimeManifest::from_yaml_str_without_secrets(input),
15922 ]
15923 .into_iter()
15924 .map(|result| match result {
15925 Err(RuntimeManifestError::HostPathsDeclared { field }) => field,
15926 other => panic!("expected HostPathsDeclared, got {other:?}"),
15927 })
15928 .collect();
15929 assert_eq!(
15930 fields[0], fields[1],
15931 "both entry points refuse the same field"
15932 );
15933 fields[0].clone()
15934 }
15935
15936 #[test]
15937 fn host_paths_is_refused_wherever_a_manifest_writes_it() {
15938 let top = format!("name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths:\n{ENTRY}");
15939 assert_eq!(refused_field(&top), "host_paths");
15940
15941 let capabilities = format!(
15942 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n host_paths:\n {}",
15943 ENTRY.replace('\n', "\n ").trim_end()
15944 );
15945 assert_eq!(refused_field(&capabilities), "capabilities.host_paths");
15946
15947 let filesystem = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n \
15948 filesystem:\n host_paths: []\n";
15949 assert_eq!(
15950 refused_field(filesystem),
15951 "capabilities.filesystem.host_paths"
15952 );
15953
15954 let artifact = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/tool-a\n \
15955 version: 0.1.0\n runtime: tool\n - name: murmur/tool-b\n \
15956 version: 0.1.0\n runtime: tool\n capabilities:\n \
15957 host_paths: []\n";
15958 assert_eq!(
15959 refused_field(artifact),
15960 "artifacts[1].capabilities.host_paths"
15961 );
15962 }
15963
15964 #[test]
15965 fn the_refusal_names_the_field_and_is_never_an_unknown_key_warning() {
15966 let input = "name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths: []\n";
15967 let error = RuntimeManifest::from_yaml_str(input).unwrap_err();
15968 assert!(error.to_string().contains("'host_paths'"), "{error}");
15969 assert!(error.to_string().contains("murmur.yaml"), "{error}");
15970 }
15971
15972 #[test]
15973 fn a_manifest_without_host_paths_parses_unchanged() {
15974 let input = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n filesystem:\n \
15975 workdir_exec: false\n";
15976 assert!(RuntimeManifest::from_yaml_str(input).is_ok());
15977 }
15978}