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)]
1594pub struct InstallCapabilities {
1595 pub skill: Vec<String>,
1597 pub tool: Vec<String>,
1599 pub from: InstallFrom,
1601}
1602
1603#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1606pub enum InstallFrom {
1607 #[default]
1609 Stores,
1610 StoresAndSources,
1613}
1614
1615impl InstallFrom {
1616 #[must_use]
1618 pub fn sources(self) -> bool {
1619 self == Self::StoresAndSources
1620 }
1621
1622 #[must_use]
1624 pub fn as_list(self) -> &'static str {
1625 match self {
1626 Self::Stores => "stores",
1627 Self::StoresAndSources => "stores, sources",
1628 }
1629 }
1630}
1631
1632#[derive(Debug, Clone, PartialEq, Eq)]
1633pub struct ShellCapabilities {
1634 pub allow: Vec<String>,
1635 pub strip_env: Option<Vec<String>>,
1636 pub baseline_env: Option<Vec<String>>,
1637 pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1644 pub staged_runtime: Vec<StagedRuntimeGrant>,
1650}
1651
1652#[derive(Debug, Clone, PartialEq, Eq)]
1657pub struct InterpreterRuntimeGrant {
1658 pub binary: String,
1662 pub dirs: Vec<InterpreterRuntimeDir>,
1665}
1666
1667#[derive(Debug, Clone, PartialEq, Eq)]
1669pub struct InterpreterRuntimeDir {
1670 pub path: String,
1672 pub list_dir: bool,
1677}
1678
1679#[derive(Debug, Clone, PartialEq, Eq)]
1696pub struct StagedRuntimeGrant {
1697 pub binary: String,
1701 pub source_path: String,
1705 pub pin: String,
1710}
1711
1712#[derive(Debug, Clone, PartialEq, Eq)]
1713pub struct SpawnCapabilities {
1714 pub allow: Vec<String>,
1715 pub workdir_snapshot: Vec<String>,
1720}
1721
1722#[derive(Debug, Clone, PartialEq, Eq, Default)]
1727pub struct EnvCapabilities {
1728 pub allow: Vec<String>,
1729}
1730
1731#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1739pub struct ResourceLimits {
1740 pub memory_bytes: Option<usize>,
1742 pub table_elements: Option<usize>,
1744 pub instances: Option<usize>,
1746 pub deadline_seconds: Option<u64>,
1748}
1749
1750#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1764pub struct ResourceCapabilities {
1765 pub max_processes: Option<u64>,
1770 pub max_open_files: Option<u64>,
1772 pub max_file_size_bytes: Option<u64>,
1774 pub cpu_seconds: Option<u64>,
1777 pub memory_bytes: Option<u64>,
1780 pub cgroup_memory_bytes: Option<u64>,
1782 pub cgroup_pids_max: Option<u64>,
1784 pub cgroup_cpu_percent: Option<u32>,
1786 pub cgroup_io_bytes_per_sec: Option<u64>,
1789 pub workdir_max_bytes: Option<u64>,
1791}
1792
1793#[derive(Debug, Clone, PartialEq, Eq, Default)]
1800pub struct StateCapabilities {
1801 pub store: Option<String>,
1809}
1810
1811#[derive(
1824 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1825)]
1826#[serde(rename_all = "snake_case")]
1827pub enum ContainmentClass {
1828 Unconfined,
1835 #[default]
1839 Advisory,
1840 Scoped,
1844 Sealed,
1852}
1853
1854impl ContainmentClass {
1855 pub fn as_str(self) -> &'static str {
1858 match self {
1859 Self::Unconfined => "unconfined",
1860 Self::Advisory => "advisory",
1861 Self::Scoped => "scoped",
1862 Self::Sealed => "sealed",
1863 }
1864 }
1865
1866 pub const ALL: [ContainmentClass; 4] =
1868 [Self::Unconfined, Self::Advisory, Self::Scoped, Self::Sealed];
1869
1870 pub const DECLARABLE: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1873}
1874
1875impl std::fmt::Display for ContainmentClass {
1876 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1877 f.write_str(self.as_str())
1878 }
1879}
1880
1881#[derive(Debug, Clone, PartialEq, Eq)]
1883pub struct ParseContainmentClassError {
1884 pub value: String,
1885}
1886
1887impl std::fmt::Display for ParseContainmentClassError {
1888 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1889 let names: Vec<&str> = ContainmentClass::DECLARABLE
1890 .iter()
1891 .map(|class| class.as_str())
1892 .collect();
1893 write!(
1894 f,
1895 "must be one of: {}; got '{}'",
1896 names.join(", "),
1897 self.value
1898 )
1899 }
1900}
1901
1902impl std::error::Error for ParseContainmentClassError {}
1903
1904impl std::str::FromStr for ContainmentClass {
1905 type Err = ParseContainmentClassError;
1906
1907 fn from_str(value: &str) -> Result<Self, Self::Err> {
1908 match value.trim() {
1909 "unconfined" => Ok(Self::Unconfined),
1910 "advisory" => Ok(Self::Advisory),
1911 "scoped" => Ok(Self::Scoped),
1912 "sealed" => Ok(Self::Sealed),
1913 other => Err(ParseContainmentClassError {
1914 value: other.to_string(),
1915 }),
1916 }
1917 }
1918}
1919
1920pub 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";
1922
1923pub fn effective_containment_floor(
1931 workspace_default: Option<ContainmentClass>,
1932 manifest_declared: Option<ContainmentClass>,
1933 cli_flag: Option<ContainmentClass>,
1934) -> ContainmentClass {
1935 [workspace_default, manifest_declared, cli_flag]
1936 .into_iter()
1937 .flatten()
1938 .max()
1939 .unwrap_or_default()
1940}
1941
1942#[derive(Debug, Clone, PartialEq, Eq)]
1943pub struct Capabilities {
1944 pub network: Option<NetworkCapabilities>,
1945 pub peer_fetch: Option<PeerFetchCapabilities>,
1948 pub filesystem: Option<FilesystemCapabilities>,
1949 pub shell: Option<ShellCapabilities>,
1950 pub spawn: Option<SpawnCapabilities>,
1951 pub env: Option<EnvCapabilities>,
1952 pub limits: Option<ResourceLimits>,
1953 pub resources: Option<ResourceCapabilities>,
1954 pub state: Option<StateCapabilities>,
1957 pub task_io: Option<TaskIoCapabilities>,
1960 pub conversation: Option<ConversationCapabilities>,
1963 pub plan: Option<PlanCapabilities>,
1966 pub install: Option<InstallCapabilities>,
1969 pub containment: Option<ContainmentClass>,
1973}
1974
1975#[derive(Debug, Error)]
1976pub enum RuntimeManifestError {
1977 #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
1978 NotFound(String),
1979 #[error("{0}")]
1980 YamlSyntax(String),
1981 #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
1982 MissingField { field: String },
1983 #[error(
1984 "{}: invalid artifact declaration at index {index}: {message}",
1985 MANIFEST_FILENAME
1986 )]
1987 InvalidArtifact { index: usize, message: String },
1988 #[error(
1989 "{}: invalid inference config for '{field}': {message}",
1990 MANIFEST_FILENAME
1991 )]
1992 InvalidInferenceConfig { field: String, message: String },
1993 #[error(
1994 "{}: invalid capability config for '{field}': {message}",
1995 MANIFEST_FILENAME
1996 )]
1997 InvalidCapabilities { field: String, message: String },
1998 #[error(
2001 "{}: invalid capability config for 'capabilities.shell.allow[{index}]': '{}' {reason}; \
2002 an entry is a bare binary name",
2003 MANIFEST_FILENAME,
2004 entry.escape_debug()
2005 )]
2006 ShellAllowEntry {
2007 index: usize,
2008 entry: String,
2009 reason: &'static str,
2010 },
2011 #[error(
2012 "{}: invalid exports config for '{field}': {message}",
2013 MANIFEST_FILENAME
2014 )]
2015 InvalidExports { field: String, message: String },
2016 #[error(
2017 "{}: invalid control config for '{field}': {message}",
2018 MANIFEST_FILENAME
2019 )]
2020 InvalidControl { field: String, message: String },
2021 #[error(
2022 "{}: invalid network config for '{field}': {message}",
2023 MANIFEST_FILENAME
2024 )]
2025 InvalidNetworkConfig { field: String, message: String },
2026 #[error(
2029 "{}: invalid network config for '{field}': '{scope}' is a retired name of a door \
2030 method, and the door answers only the names its card lists",
2031 MANIFEST_FILENAME
2032 )]
2033 RetiredDoorScope {
2034 field: String,
2035 scope: String,
2036 replacement: &'static str,
2037 },
2038 #[error(
2041 "{}: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves task_acceptance: \
2042 queue no room for a waiting task",
2043 MANIFEST_FILENAME
2044 )]
2045 QueueWithoutRoom,
2046 #[error(
2049 "{}: 'mur_version' is not a top-level key — the mur release a deploy installs is pinned \
2050 as 'deploy.mur_version'",
2051 MANIFEST_FILENAME
2052 )]
2053 TopLevelMurVersion,
2054 #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
2055 InvalidTraceConfig { field: String, message: String },
2056 #[error(
2058 "{}: invalid lifecycle config for '{field}': {message}",
2059 MANIFEST_FILENAME
2060 )]
2061 InvalidLifecycleConfig { field: String, message: String },
2062 #[error("{}: invalid artifact name in '{field}': {message}", MANIFEST_FILENAME)]
2065 InvalidArtifactName {
2066 field: String,
2067 message: String,
2068 hint: Option<String>,
2069 },
2070 #[error("{}", REGISTRY_BLOCK_MESSAGE)]
2072 RegistryBlock,
2073 #[error(
2077 "{}: '{field}' cannot be declared in a manifest — host folders are lent by the operator, \
2078 never asked for by a capsule",
2079 MANIFEST_FILENAME
2080 )]
2081 HostPathsDeclared { field: String },
2082 #[error(
2086 "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
2087 gateway.endpoint '{endpoint}' but binds no credential",
2088 MANIFEST_FILENAME
2089 )]
2090 GatewayWithoutCredential {
2091 index: usize,
2092 name: String,
2093 endpoint: String,
2094 },
2095 #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
2096 Io {
2097 path: String,
2098 #[source]
2099 source: std::io::Error,
2100 },
2101}
2102
2103#[derive(Debug, Deserialize)]
2104struct RawRuntimeManifest {
2105 name: Option<String>,
2106 version: Option<String>,
2107 #[serde(default)]
2108 artifacts: Vec<RawArtifact>,
2109 #[serde(default)]
2110 capabilities: Option<RawCapabilities>,
2111 #[serde(default)]
2112 inference: Option<RawInferenceConfig>,
2113 #[serde(default)]
2114 context: Option<RawContextConfig>,
2115 #[serde(default)]
2116 observability: Option<RawObservabilityConfig>,
2117 #[serde(default)]
2118 trace: Option<RawTraceConfig>,
2119 #[serde(default)]
2120 network: Option<RawNetworkConfig>,
2121 #[serde(default)]
2122 lifecycle: Option<RawLifecycleConfig>,
2123 #[serde(default)]
2124 exports: Option<RawExports>,
2125 #[serde(default)]
2126 control: Option<RawControl>,
2127 #[serde(default)]
2128 deploy: Option<RawDeploy>,
2129 #[serde(default, deserialize_with = "deserialize_present")]
2133 mur_version: Option<serde_norway::Value>,
2134 #[serde(default, deserialize_with = "deserialize_present")]
2138 config: Option<serde_norway::Value>,
2139 #[serde(default)]
2144 #[allow(dead_code)]
2145 runtime: Option<serde_norway::Value>,
2146 #[serde(default)]
2149 #[allow(dead_code)]
2150 implementation: Option<serde_norway::Value>,
2151 #[serde(default)]
2155 #[allow(dead_code)]
2156 execution: Option<serde_norway::Value>,
2157 #[serde(default)]
2160 #[allow(dead_code)]
2161 requires_files: Option<serde_norway::Value>,
2162 #[serde(flatten)]
2163 unknown: UnknownKeys,
2164}
2165
2166#[derive(Debug, Deserialize)]
2167struct RawDeploy {
2168 #[serde(default)]
2169 mur_version: Option<String>,
2170 #[serde(flatten)]
2171 unknown: UnknownKeys,
2172}
2173
2174#[derive(Debug, Deserialize)]
2175struct RawControl {
2176 #[serde(default)]
2180 settings: Option<serde_norway::Value>,
2181 #[serde(default)]
2182 secrets: Option<serde_norway::Value>,
2183 #[serde(default)]
2184 agent_settings: Option<serde_norway::Value>,
2185 #[serde(flatten)]
2186 unknown: UnknownKeys,
2187}
2188
2189#[derive(Debug, Deserialize)]
2190struct RawExports {
2191 #[serde(default)]
2192 files: Option<RawFileExport>,
2193 #[serde(default)]
2194 peer_files: Option<RawPeerFilesExport>,
2195 #[serde(default)]
2196 peer_tasks: Option<RawPeerTasksExport>,
2197 #[serde(flatten)]
2198 unknown: UnknownKeys,
2199}
2200
2201#[derive(Debug, Deserialize)]
2202struct RawPeerTasksExport {
2203 #[serde(default)]
2206 accept: Option<bool>,
2207 #[serde(flatten)]
2208 unknown: UnknownKeys,
2209}
2210
2211#[derive(Debug, Deserialize)]
2212struct RawPeerFilesExport {
2213 #[serde(default)]
2214 root: Option<String>,
2215 #[serde(default)]
2219 max_ttl: Option<serde_norway::Value>,
2220 #[serde(default)]
2221 max_bytes: Option<serde_norway::Value>,
2222 #[serde(flatten)]
2223 unknown: UnknownKeys,
2224}
2225
2226#[derive(Debug, Deserialize)]
2227struct RawFileExport {
2228 #[serde(default)]
2229 root: Option<String>,
2230 #[serde(default)]
2231 mode: Option<String>,
2232 #[serde(default)]
2237 max_bytes: Option<serde_norway::Value>,
2238 #[serde(flatten)]
2239 unknown: UnknownKeys,
2240}
2241
2242#[derive(Debug, Deserialize)]
2243struct RawLifecycleConfig {
2244 #[serde(default)]
2245 task_acceptance: Option<TaskAcceptance>,
2246 #[serde(default)]
2247 after_task: Option<AfterTask>,
2248 #[serde(default)]
2249 queue_depth: Option<usize>,
2250 #[serde(default)]
2251 input_timeout_secs: Option<u64>,
2252 #[serde(default)]
2253 conversation: Option<ConversationMode>,
2254 #[serde(default)]
2255 max_task_reopens: Option<u32>,
2256 #[serde(default)]
2257 shell_grace_secs: Option<u64>,
2258 #[serde(default)]
2259 delegation_deadline_secs: Option<u64>,
2260 #[serde(default)]
2261 deadline_notice_secs: Option<u64>,
2262 #[serde(default)]
2263 keep_working: Option<RawKeepWorking>,
2264 #[serde(default)]
2265 done_check: Option<RawDoneCheck>,
2266 #[serde(flatten)]
2267 unknown: UnknownKeys,
2268}
2269
2270#[derive(Debug, Deserialize)]
2271struct RawDoneCheck {
2272 #[serde(default)]
2273 command: Option<String>,
2274 #[serde(default)]
2275 limit: Option<u32>,
2276 #[serde(default)]
2277 timeout_secs: Option<u64>,
2278 #[serde(flatten)]
2279 unknown: UnknownKeys,
2280}
2281
2282#[derive(Debug, Deserialize)]
2283struct RawKeepWorking {
2284 #[serde(default)]
2285 message: Option<String>,
2286 #[serde(default)]
2287 limit: Option<u32>,
2288 #[serde(flatten)]
2289 unknown: UnknownKeys,
2290}
2291
2292#[derive(Debug, Deserialize)]
2293struct RawNetworkConfig {
2294 internal_port: Option<u16>,
2295 #[serde(default)]
2296 authentication: Option<RawNetworkAuthentication>,
2297 #[serde(flatten)]
2298 unknown: UnknownKeys,
2299}
2300
2301#[derive(Debug, Deserialize)]
2302struct RawNetworkAuthentication {
2303 #[serde(default)]
2304 scheme: Option<String>,
2305 #[serde(default)]
2308 credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
2309 #[serde(flatten)]
2310 unknown: UnknownKeys,
2311}
2312
2313#[derive(Debug, Deserialize)]
2314struct RawDoorCredential {
2315 #[serde(default)]
2319 scopes: Option<serde_norway::Value>,
2320 #[serde(flatten)]
2321 unknown: UnknownKeys,
2322}
2323
2324#[derive(Debug, Deserialize)]
2325struct RawContextConfig {
2326 max_tokens: Option<u32>,
2327 #[serde(default)]
2330 record: Option<String>,
2331 #[serde(default)]
2332 record_store: Option<String>,
2333 #[serde(default)]
2334 seed_budget: Option<f32>,
2335 #[serde(default)]
2336 seed_overflow_margin: Option<f32>,
2337 #[serde(default, deserialize_with = "present_yaml_value")]
2340 retain: Option<serde_norway::Value>,
2341 #[serde(default)]
2342 images: Option<RawContextImages>,
2343 #[serde(flatten)]
2344 unknown: UnknownKeys,
2345}
2346
2347#[derive(Debug, Deserialize)]
2350struct RawContextImages {
2351 #[serde(default)]
2352 keep: Option<serde_norway::Value>,
2353 #[serde(default)]
2354 max_bytes: Option<serde_norway::Value>,
2355 #[serde(default)]
2356 max_per_turn: Option<serde_norway::Value>,
2357 #[serde(flatten)]
2358 unknown: UnknownKeys,
2359}
2360
2361#[derive(Debug, Deserialize)]
2362struct RawObservabilityConfig {
2363 #[serde(default)]
2364 otel_endpoint: Option<String>,
2365 #[serde(default)]
2366 eval: Option<RawEvalConfig>,
2367 #[serde(flatten)]
2368 unknown: UnknownKeys,
2369}
2370
2371#[derive(Debug, Deserialize)]
2372struct RawTraceConfig {
2373 #[serde(default)]
2377 capture: Option<String>,
2378 #[serde(default, deserialize_with = "present_yaml_value")]
2380 retain: Option<serde_norway::Value>,
2381 #[serde(flatten)]
2382 unknown: UnknownKeys,
2383}
2384
2385#[derive(Debug, Deserialize)]
2386struct RawEvalConfig {
2387 #[serde(default)]
2388 dataset_id: Option<String>,
2389 #[serde(default)]
2390 scorers: Vec<RawScorerConfig>,
2391 #[serde(flatten)]
2392 unknown: UnknownKeys,
2393}
2394
2395#[derive(Debug, Deserialize)]
2396struct RawScorerConfig {
2397 #[serde(rename = "type")]
2398 scorer_type: String,
2399 name: Option<String>,
2400 max: Option<u64>,
2401 expected: Option<Vec<String>>,
2402 #[serde(flatten)]
2403 unknown: UnknownKeys,
2404}
2405
2406#[derive(Debug, Deserialize)]
2407struct RawArtifact {
2408 name: Option<String>,
2409 version: Option<String>,
2410 runtime: Option<String>,
2411 #[serde(default)]
2412 source: Option<String>,
2413 #[serde(default)]
2414 local_source: Option<bool>,
2415 #[serde(default)]
2416 prompt_payload: Option<bool>,
2417 #[serde(default)]
2418 capabilities: Option<RawCapabilities>,
2419 #[serde(default)]
2420 on_overflow: Option<String>,
2421 #[serde(default, deserialize_with = "deserialize_present")]
2426 config: Option<serde_norway::Value>,
2427 #[serde(default, deserialize_with = "deserialize_present")]
2430 gateway: Option<Option<RawArtifactGateway>>,
2431 #[serde(flatten)]
2432 unknown: UnknownKeys,
2433}
2434
2435#[derive(Debug, Deserialize)]
2436struct RawArtifactGateway {
2437 #[serde(default)]
2438 endpoint: Option<String>,
2439 #[serde(default, deserialize_with = "deserialize_present")]
2442 api_key: Option<Option<String>>,
2443 #[serde(default)]
2445 keyless: Option<serde_norway::Value>,
2446 #[serde(flatten)]
2447 unknown: UnknownKeys,
2448}
2449
2450fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
2456where
2457 T: Deserialize<'de>,
2458 D: serde::Deserializer<'de>,
2459{
2460 T::deserialize(deserializer).map(Some)
2461}
2462
2463#[derive(Debug, Deserialize)]
2464struct RawCapabilities {
2465 #[serde(default)]
2466 network: Option<RawNetworkCapabilities>,
2467 #[serde(default)]
2468 peer_fetch: Option<RawPeerFetchCapabilities>,
2469 #[serde(default)]
2470 filesystem: Option<RawFilesystemCapabilities>,
2471 #[serde(default)]
2472 shell: Option<RawShellCapabilities>,
2473 #[serde(default)]
2474 spawn: Option<RawSpawnCapabilities>,
2475 #[serde(default)]
2476 env: Option<RawEnvCapabilities>,
2477 #[serde(default)]
2478 limits: Option<RawResourceLimits>,
2479 #[serde(default)]
2480 resources: Option<RawResourceCapabilities>,
2481 #[serde(default)]
2482 state: Option<RawStateCapabilities>,
2483 #[serde(default)]
2484 task_io: Option<RawTaskIoCapabilities>,
2485 #[serde(default)]
2486 conversation: Option<RawConversationCapabilities>,
2487 #[serde(default)]
2488 plan: Option<RawPlanCapabilities>,
2489 #[serde(default)]
2490 install: Option<RawInstallCapabilities>,
2491 #[serde(default)]
2495 containment: Option<String>,
2496 #[serde(flatten)]
2497 unknown: UnknownKeys,
2498}
2499
2500#[derive(Debug, Deserialize)]
2501struct RawTaskIoCapabilities {
2502 #[serde(default)]
2505 read: Option<bool>,
2506 #[serde(flatten)]
2507 unknown: UnknownKeys,
2508}
2509
2510#[derive(Debug, Deserialize)]
2511struct RawConversationCapabilities {
2512 #[serde(default)]
2515 read: Option<bool>,
2516 #[serde(flatten)]
2517 unknown: UnknownKeys,
2518}
2519
2520#[derive(Debug, Deserialize)]
2521struct RawPlanCapabilities {
2522 #[serde(default)]
2525 submit: Option<bool>,
2526 #[serde(flatten)]
2527 unknown: UnknownKeys,
2528}
2529
2530#[derive(Debug, Deserialize)]
2531struct RawInstallCapabilities {
2532 #[serde(default)]
2533 skill: Vec<String>,
2534 #[serde(default)]
2535 tool: Vec<String>,
2536 #[serde(default)]
2537 from: Option<Vec<String>>,
2538 #[serde(flatten)]
2539 unknown: UnknownKeys,
2540}
2541
2542#[derive(Debug, Deserialize)]
2543struct RawSpawnCapabilities {
2544 #[serde(default)]
2545 allow: Vec<String>,
2546 #[serde(default)]
2547 workdir_snapshot: Vec<String>,
2548 #[serde(flatten)]
2549 unknown: UnknownKeys,
2550}
2551
2552#[derive(Debug, Deserialize)]
2553struct RawEnvCapabilities {
2554 #[serde(default)]
2555 allow: Vec<String>,
2556 #[serde(flatten)]
2557 unknown: UnknownKeys,
2558}
2559
2560#[derive(Debug, Deserialize)]
2561struct RawResourceLimits {
2562 #[serde(default)]
2563 memory_bytes: Option<usize>,
2564 #[serde(default)]
2565 table_elements: Option<usize>,
2566 #[serde(default)]
2567 instances: Option<usize>,
2568 #[serde(default)]
2569 deadline_seconds: Option<u64>,
2570 #[serde(flatten)]
2571 unknown: UnknownKeys,
2572}
2573
2574#[derive(Debug, Deserialize)]
2575struct RawStateCapabilities {
2576 #[serde(default)]
2577 store: Option<String>,
2578 #[serde(flatten)]
2579 unknown: UnknownKeys,
2580}
2581
2582#[derive(Debug, Deserialize)]
2583struct RawResourceCapabilities {
2584 #[serde(default)]
2585 max_processes: Option<u64>,
2586 #[serde(default)]
2587 max_open_files: Option<u64>,
2588 #[serde(default)]
2589 max_file_size_bytes: Option<u64>,
2590 #[serde(default)]
2591 cpu_seconds: Option<u64>,
2592 #[serde(default)]
2593 memory_bytes: Option<u64>,
2594 #[serde(default)]
2595 cgroup_memory_bytes: Option<u64>,
2596 #[serde(default)]
2597 cgroup_pids_max: Option<u64>,
2598 #[serde(default)]
2599 cgroup_cpu_percent: Option<u32>,
2600 #[serde(default)]
2601 cgroup_io_bytes_per_sec: Option<u64>,
2602 #[serde(default)]
2603 workdir_max_bytes: Option<u64>,
2604 #[serde(flatten)]
2605 unknown: UnknownKeys,
2606}
2607
2608#[derive(Debug, Deserialize)]
2609struct RawNetworkCapabilities {
2610 #[serde(default)]
2611 allow: Vec<String>,
2612 #[serde(default)]
2613 unix_sockets: bool,
2614 #[serde(flatten)]
2615 unknown: UnknownKeys,
2616}
2617
2618#[derive(Debug, Deserialize)]
2619struct RawPeerFetchCapabilities {
2620 #[serde(default)]
2621 allow: Vec<String>,
2622 #[serde(flatten)]
2623 unknown: UnknownKeys,
2624}
2625
2626#[derive(Debug, Deserialize)]
2627struct RawFilesystemCapabilities {
2628 #[serde(default)]
2629 scope: Option<String>,
2630 #[serde(default)]
2634 workdir_exec: bool,
2635 #[serde(default)]
2638 read_only: Vec<String>,
2639 #[serde(default)]
2641 records: bool,
2642 #[serde(flatten)]
2643 unknown: UnknownKeys,
2644}
2645
2646#[derive(Debug, Deserialize)]
2647struct RawShellCapabilities {
2648 #[serde(default)]
2649 allow: Vec<String>,
2650 #[serde(default)]
2651 strip_env: Option<Vec<String>>,
2652 #[serde(default)]
2653 baseline_env: Option<Vec<String>>,
2654 #[serde(default)]
2655 interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
2656 #[serde(default)]
2657 staged_runtime: Vec<RawStagedRuntimeGrant>,
2658 #[serde(flatten)]
2659 unknown: UnknownKeys,
2660}
2661
2662#[derive(Debug, Deserialize)]
2663struct RawInterpreterRuntimeGrant {
2664 #[serde(default)]
2665 binary: Option<String>,
2666 #[serde(default)]
2667 dirs: Vec<RawInterpreterRuntimeDir>,
2668 #[serde(flatten)]
2669 unknown: UnknownKeys,
2670}
2671
2672#[derive(Debug, Deserialize)]
2673struct RawInterpreterRuntimeDir {
2674 #[serde(default)]
2675 path: Option<String>,
2676 #[serde(default)]
2680 list_dir: Option<bool>,
2681 #[serde(flatten)]
2682 unknown: UnknownKeys,
2683}
2684
2685#[derive(Debug, Deserialize)]
2686struct RawStagedRuntimeGrant {
2687 #[serde(default)]
2688 binary: Option<String>,
2689 #[serde(default)]
2690 source_path: Option<String>,
2691 #[serde(default)]
2695 pin: Option<String>,
2696 #[serde(flatten)]
2697 unknown: UnknownKeys,
2698}
2699
2700#[derive(Debug, Deserialize)]
2701struct RawInferenceConfig {
2702 transport: Option<String>,
2703 #[serde(default, deserialize_with = "deserialize_present")]
2706 endpoint: Option<serde_norway::Value>,
2707 model: Option<String>,
2708 #[serde(default, deserialize_with = "deserialize_present")]
2711 api_key: Option<serde_norway::Value>,
2712 #[serde(default)]
2713 command: Option<String>,
2714 #[serde(default)]
2715 system_prompt: Option<String>,
2716 #[serde(default)]
2717 system_prompt_file: Option<String>,
2718 #[serde(default)]
2719 system_prompt_artifact: Option<String>,
2720 #[serde(default)]
2721 instruction_files: Option<Vec<String>>,
2722 #[serde(default)]
2723 driver: Option<RawInferenceDriver>,
2724 #[serde(default)]
2725 compaction: Option<RawCompactionConfig>,
2726 #[serde(default)]
2727 max_turns: Option<u32>,
2728 #[serde(default)]
2731 max_task_reopens: Option<u32>,
2732 #[serde(default)]
2733 max_tokens: Option<u32>,
2734 #[serde(default)]
2735 max_session_tokens: Option<u64>,
2736 #[serde(default)]
2737 tool_refresh: Option<String>,
2738 #[serde(default)]
2739 alternates: Option<Vec<RawInferenceAlternate>>,
2740 #[serde(default)]
2741 retry: Option<RawInferenceRetry>,
2742 #[serde(flatten)]
2743 unknown: UnknownKeys,
2744}
2745
2746#[derive(Debug, Deserialize)]
2747struct RawInferenceRetry {
2748 #[serde(default)]
2749 max_retries: Option<u32>,
2750 #[serde(default)]
2751 initial_delay_ms: Option<u32>,
2752 #[serde(default)]
2753 max_delay_ms: Option<u32>,
2754 #[serde(flatten)]
2755 unknown: UnknownKeys,
2756}
2757
2758#[derive(Debug, Deserialize)]
2759struct RawInferenceAlternate {
2760 #[serde(default)]
2761 name: Option<String>,
2762 #[serde(default)]
2763 model: Option<String>,
2764 #[serde(default)]
2765 driver: Option<RawInferenceDriver>,
2766 #[serde(flatten)]
2767 unknown: UnknownKeys,
2768}
2769
2770#[derive(Debug, Deserialize)]
2771struct RawCompactionConfig {
2772 threshold: Option<f32>,
2773 model: Option<String>,
2774 system_prompt: Option<String>,
2775 system_prompt_file: Option<String>,
2776 dump_summaries: Option<bool>,
2777 max_tokens: Option<u32>,
2778 #[serde(default)]
2779 driver_config: Option<serde_norway::Value>,
2780 #[serde(flatten)]
2781 unknown: UnknownKeys,
2782}
2783
2784#[derive(Debug, Deserialize)]
2785struct RawInferenceDriver {
2786 artifact: Option<String>,
2787 #[serde(default)]
2788 config: Option<serde_norway::Value>,
2789 #[serde(flatten)]
2790 unknown: UnknownKeys,
2791}
2792
2793type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2800
2801trait RawBlock {
2808 const KNOWN_KEYS: &'static [&'static str];
2812
2813 fn unknown_keys(&self) -> &UnknownKeys;
2814
2815 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2823 let _ = (path, out);
2824 }
2825}
2826
2827impl RawBlock for RawRuntimeManifest {
2828 const KNOWN_KEYS: &'static [&'static str] = &[
2829 "name",
2830 "version",
2831 "artifacts",
2832 "capabilities",
2833 "inference",
2834 "context",
2835 "observability",
2836 "trace",
2837 "network",
2838 "lifecycle",
2839 "exports",
2840 "control",
2841 "deploy",
2842 "mur_version",
2843 "config",
2844 "runtime",
2845 "implementation",
2846 "execution",
2847 "requires_files",
2848 ];
2849 fn unknown_keys(&self) -> &UnknownKeys {
2850 &self.unknown
2851 }
2852
2853 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2854 let artifacts = child_path(path, "artifacts");
2855 for (index, artifact) in self.artifacts.iter().enumerate() {
2856 collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2857 }
2858 if let Some(capabilities) = &self.capabilities {
2859 collect_block(capabilities, &child_path(path, "capabilities"), out);
2860 }
2861 if let Some(inference) = &self.inference {
2862 collect_block(inference, &child_path(path, "inference"), out);
2863 }
2864 if let Some(context) = &self.context {
2865 collect_block(context, &child_path(path, "context"), out);
2866 }
2867 if let Some(observability) = &self.observability {
2868 collect_block(observability, &child_path(path, "observability"), out);
2869 }
2870 if let Some(trace) = &self.trace {
2871 collect_block(trace, &child_path(path, "trace"), out);
2872 }
2873 if let Some(network) = &self.network {
2874 collect_block(network, &child_path(path, "network"), out);
2875 }
2876 if let Some(lifecycle) = &self.lifecycle {
2877 collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2878 }
2879 if let Some(exports) = &self.exports {
2880 collect_block(exports, &child_path(path, "exports"), out);
2881 }
2882 if let Some(control) = &self.control {
2883 collect_block(control, &child_path(path, "control"), out);
2884 }
2885 if let Some(deploy) = &self.deploy {
2886 collect_block(deploy, &child_path(path, "deploy"), out);
2887 }
2888 }
2889}
2890
2891impl RawBlock for RawDeploy {
2892 const KNOWN_KEYS: &'static [&'static str] = &["mur_version"];
2893 fn unknown_keys(&self) -> &UnknownKeys {
2894 &self.unknown
2895 }
2896}
2897
2898impl RawBlock for RawControl {
2899 const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
2900 fn unknown_keys(&self) -> &UnknownKeys {
2901 &self.unknown
2902 }
2903}
2904
2905impl RawBlock for RawExports {
2906 const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files", "peer_tasks"];
2907 fn unknown_keys(&self) -> &UnknownKeys {
2908 &self.unknown
2909 }
2910
2911 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2912 if let Some(files) = &self.files {
2913 collect_block(files, &child_path(path, "files"), out);
2914 }
2915 if let Some(peer_files) = &self.peer_files {
2916 collect_block(peer_files, &child_path(path, "peer_files"), out);
2917 }
2918 if let Some(peer_tasks) = &self.peer_tasks {
2919 collect_block(peer_tasks, &child_path(path, "peer_tasks"), out);
2920 }
2921 }
2922}
2923
2924impl RawBlock for RawPeerTasksExport {
2925 const KNOWN_KEYS: &'static [&'static str] = &["accept"];
2926 fn unknown_keys(&self) -> &UnknownKeys {
2927 &self.unknown
2928 }
2929}
2930
2931impl RawBlock for RawPeerFilesExport {
2932 const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2933 fn unknown_keys(&self) -> &UnknownKeys {
2934 &self.unknown
2935 }
2936}
2937
2938impl RawBlock for RawFileExport {
2939 const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2940 fn unknown_keys(&self) -> &UnknownKeys {
2941 &self.unknown
2942 }
2943}
2944
2945fn parse_keep_working(raw: RawKeepWorking) -> Result<KeepWorking, RuntimeManifestError> {
2948 let invalid = |field: &str, message: &str| RuntimeManifestError::InvalidLifecycleConfig {
2949 field: format!("lifecycle.keep_working.{field}"),
2950 message: message.to_string(),
2951 };
2952 let message = match raw.message {
2953 None => return Err(invalid("message", "is required")),
2954 Some(message) if message.trim().is_empty() => {
2955 return Err(invalid("message", "must not be blank"))
2956 }
2957 Some(message) => message,
2958 };
2959 let limit = match raw.limit {
2960 None => return Err(invalid("limit", "is required")),
2961 Some(0) => {
2962 return Err(invalid(
2963 "limit",
2964 "must be at least 1; omit lifecycle.keep_working to turn the option off",
2965 ))
2966 }
2967 Some(limit) => limit,
2968 };
2969 Ok(KeepWorking { message, limit })
2970}
2971
2972fn parse_done_check(
2976 raw: RawDoneCheck,
2977 shell_allow: &[String],
2978) -> Result<DoneCheck, RuntimeManifestError> {
2979 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidLifecycleConfig {
2980 field: format!("lifecycle.done_check.{field}"),
2981 message,
2982 };
2983 let command = match raw.command {
2984 None => return Err(invalid("command", "is required".into())),
2985 Some(command) if command.trim().is_empty() => {
2986 return Err(invalid("command", "must not be blank".into()))
2987 }
2988 Some(command) => command,
2989 };
2990 let Some(program) = split_shell_words(&command).into_iter().next() else {
2991 return Err(invalid("command", "must name a program".into()));
2992 };
2993 if !shell_allow.contains(&program) {
2994 return Err(invalid(
2995 "command",
2996 format!("runs '{program}', which is not in capabilities.shell.allow"),
2997 ));
2998 }
2999 let Some(limit) = raw.limit else {
3000 return Err(invalid("limit", "is required".into()));
3001 };
3002 let timeout_secs = match raw.timeout_secs {
3003 None => DoneCheck::DEFAULT_TIMEOUT_SECS,
3004 Some(0) => return Err(invalid("timeout_secs", "must be at least 1".into())),
3005 Some(secs) => secs,
3006 };
3007 Ok(DoneCheck {
3008 command,
3009 limit,
3010 timeout_secs,
3011 })
3012}
3013
3014impl RawBlock for RawLifecycleConfig {
3015 const KNOWN_KEYS: &'static [&'static str] = &[
3016 "task_acceptance",
3017 "after_task",
3018 "queue_depth",
3019 "input_timeout_secs",
3020 "conversation",
3021 "max_task_reopens",
3022 "shell_grace_secs",
3023 "delegation_deadline_secs",
3024 "deadline_notice_secs",
3025 "keep_working",
3026 "done_check",
3027 ];
3028 fn unknown_keys(&self) -> &UnknownKeys {
3029 &self.unknown
3030 }
3031
3032 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3033 if let Some(keep_working) = &self.keep_working {
3034 collect_block(keep_working, &child_path(path, "keep_working"), out);
3035 }
3036 if let Some(done_check) = &self.done_check {
3037 collect_block(done_check, &child_path(path, "done_check"), out);
3038 }
3039 }
3040}
3041
3042impl RawBlock for RawDoneCheck {
3043 const KNOWN_KEYS: &'static [&'static str] = &["command", "limit", "timeout_secs"];
3044 fn unknown_keys(&self) -> &UnknownKeys {
3045 &self.unknown
3046 }
3047}
3048
3049impl RawBlock for RawKeepWorking {
3050 const KNOWN_KEYS: &'static [&'static str] = &["message", "limit"];
3051 fn unknown_keys(&self) -> &UnknownKeys {
3052 &self.unknown
3053 }
3054}
3055
3056impl RawBlock for RawNetworkConfig {
3057 const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
3058 fn unknown_keys(&self) -> &UnknownKeys {
3059 &self.unknown
3060 }
3061
3062 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3063 if let Some(authentication) = &self.authentication {
3064 collect_block(authentication, &child_path(path, "authentication"), out);
3065 }
3066 }
3067}
3068
3069impl RawBlock for RawNetworkAuthentication {
3070 const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
3071 fn unknown_keys(&self) -> &UnknownKeys {
3072 &self.unknown
3073 }
3074
3075 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3076 let credentials_path = child_path(path, "credentials");
3077 for (name, credential) in self.credentials.iter().flatten() {
3078 collect_block(credential, &child_path(&credentials_path, name), out);
3079 }
3080 }
3081}
3082
3083impl RawBlock for RawDoorCredential {
3084 const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
3085 fn unknown_keys(&self) -> &UnknownKeys {
3086 &self.unknown
3087 }
3088}
3089
3090impl RawBlock for RawContextConfig {
3091 const KNOWN_KEYS: &'static [&'static str] = &[
3092 "max_tokens",
3093 "record",
3094 "record_store",
3095 "seed_budget",
3096 "seed_overflow_margin",
3097 "retain",
3098 "images",
3099 ];
3100 fn unknown_keys(&self) -> &UnknownKeys {
3101 &self.unknown
3102 }
3103
3104 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3105 if let Some(images) = &self.images {
3106 collect_block(images, &child_path(path, "images"), out);
3107 }
3108 }
3109}
3110
3111impl RawBlock for RawContextImages {
3112 const KNOWN_KEYS: &'static [&'static str] = &["keep", "max_bytes", "max_per_turn"];
3113 fn unknown_keys(&self) -> &UnknownKeys {
3114 &self.unknown
3115 }
3116}
3117
3118impl RawBlock for RawObservabilityConfig {
3119 const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
3120 fn unknown_keys(&self) -> &UnknownKeys {
3121 &self.unknown
3122 }
3123
3124 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3125 if let Some(eval) = &self.eval {
3126 collect_block(eval, &child_path(path, "eval"), out);
3127 }
3128 }
3129}
3130
3131impl RawBlock for RawTraceConfig {
3132 const KNOWN_KEYS: &'static [&'static str] = &["capture", "retain"];
3133 fn unknown_keys(&self) -> &UnknownKeys {
3134 &self.unknown
3135 }
3136}
3137
3138impl RawBlock for RawEvalConfig {
3139 const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
3140 fn unknown_keys(&self) -> &UnknownKeys {
3141 &self.unknown
3142 }
3143
3144 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3145 let scorers = child_path(path, "scorers");
3146 for (index, scorer) in self.scorers.iter().enumerate() {
3147 collect_block(scorer, &format!("{scorers}[{index}]"), out);
3148 }
3149 }
3150}
3151
3152impl RawBlock for RawScorerConfig {
3153 const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
3154 fn unknown_keys(&self) -> &UnknownKeys {
3155 &self.unknown
3156 }
3157}
3158
3159impl RawBlock for RawArtifact {
3160 const KNOWN_KEYS: &'static [&'static str] = &[
3161 "name",
3162 "version",
3163 "runtime",
3164 "source",
3165 "local_source",
3166 "prompt_payload",
3167 "capabilities",
3168 "on_overflow",
3169 "config",
3170 "gateway",
3171 ];
3172 fn unknown_keys(&self) -> &UnknownKeys {
3173 &self.unknown
3174 }
3175
3176 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3177 if let Some(capabilities) = &self.capabilities {
3178 collect_block(capabilities, &child_path(path, "capabilities"), out);
3179 }
3180 if let Some(Some(gateway)) = &self.gateway {
3181 collect_block(gateway, &child_path(path, "gateway"), out);
3182 }
3183 }
3184}
3185
3186impl RawBlock for RawArtifactGateway {
3187 const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
3188 fn unknown_keys(&self) -> &UnknownKeys {
3189 &self.unknown
3190 }
3191}
3192
3193impl RawBlock for RawCapabilities {
3194 const KNOWN_KEYS: &'static [&'static str] = &[
3195 "network",
3196 "peer_fetch",
3197 "filesystem",
3198 "shell",
3199 "spawn",
3200 "env",
3201 "limits",
3202 "resources",
3203 "state",
3204 "task_io",
3205 "conversation",
3206 "plan",
3207 "install",
3208 "containment",
3209 ];
3210 fn unknown_keys(&self) -> &UnknownKeys {
3211 &self.unknown
3212 }
3213
3214 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3218 if let Some(network) = &self.network {
3219 collect_block(network, &child_path(path, "network"), out);
3220 }
3221 if let Some(peer_fetch) = &self.peer_fetch {
3222 collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
3223 }
3224 if let Some(filesystem) = &self.filesystem {
3225 collect_block(filesystem, &child_path(path, "filesystem"), out);
3226 }
3227 if let Some(shell) = &self.shell {
3228 collect_block(shell, &child_path(path, "shell"), out);
3229 }
3230 if let Some(spawn) = &self.spawn {
3231 collect_block(spawn, &child_path(path, "spawn"), out);
3232 }
3233 if let Some(env) = &self.env {
3234 collect_block(env, &child_path(path, "env"), out);
3235 }
3236 if let Some(limits) = &self.limits {
3237 collect_block(limits, &child_path(path, "limits"), out);
3238 }
3239 if let Some(resources) = &self.resources {
3240 collect_block(resources, &child_path(path, "resources"), out);
3241 }
3242 if let Some(state) = &self.state {
3243 collect_block(state, &child_path(path, "state"), out);
3244 }
3245 if let Some(task_io) = &self.task_io {
3246 collect_block(task_io, &child_path(path, "task_io"), out);
3247 }
3248 if let Some(conversation) = &self.conversation {
3249 collect_block(conversation, &child_path(path, "conversation"), out);
3250 }
3251 if let Some(plan) = &self.plan {
3252 collect_block(plan, &child_path(path, "plan"), out);
3253 }
3254 if let Some(install) = &self.install {
3255 collect_block(install, &child_path(path, "install"), out);
3256 }
3257 }
3258}
3259
3260impl RawBlock for RawTaskIoCapabilities {
3261 const KNOWN_KEYS: &'static [&'static str] = &["read"];
3262 fn unknown_keys(&self) -> &UnknownKeys {
3263 &self.unknown
3264 }
3265}
3266
3267impl RawBlock for RawConversationCapabilities {
3268 const KNOWN_KEYS: &'static [&'static str] = &["read"];
3269 fn unknown_keys(&self) -> &UnknownKeys {
3270 &self.unknown
3271 }
3272}
3273
3274impl RawBlock for RawPlanCapabilities {
3275 const KNOWN_KEYS: &'static [&'static str] = &["submit"];
3276 fn unknown_keys(&self) -> &UnknownKeys {
3277 &self.unknown
3278 }
3279}
3280
3281impl RawBlock for RawInstallCapabilities {
3282 const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool", "from"];
3283 fn unknown_keys(&self) -> &UnknownKeys {
3284 &self.unknown
3285 }
3286}
3287
3288impl RawBlock for RawSpawnCapabilities {
3289 const KNOWN_KEYS: &'static [&'static str] = &["allow", "workdir_snapshot"];
3290 fn unknown_keys(&self) -> &UnknownKeys {
3291 &self.unknown
3292 }
3293}
3294
3295impl RawBlock for RawEnvCapabilities {
3296 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3297 fn unknown_keys(&self) -> &UnknownKeys {
3298 &self.unknown
3299 }
3300}
3301
3302impl RawBlock for RawResourceLimits {
3303 const KNOWN_KEYS: &'static [&'static str] = &[
3304 "memory_bytes",
3305 "table_elements",
3306 "instances",
3307 "deadline_seconds",
3308 ];
3309 fn unknown_keys(&self) -> &UnknownKeys {
3310 &self.unknown
3311 }
3312}
3313
3314impl RawBlock for RawStateCapabilities {
3315 const KNOWN_KEYS: &'static [&'static str] = &["store"];
3316 fn unknown_keys(&self) -> &UnknownKeys {
3317 &self.unknown
3318 }
3319}
3320
3321impl RawBlock for RawResourceCapabilities {
3322 const KNOWN_KEYS: &'static [&'static str] = &[
3323 "max_processes",
3324 "max_open_files",
3325 "max_file_size_bytes",
3326 "cpu_seconds",
3327 "memory_bytes",
3328 "cgroup_memory_bytes",
3329 "cgroup_pids_max",
3330 "cgroup_cpu_percent",
3331 "cgroup_io_bytes_per_sec",
3332 "workdir_max_bytes",
3333 ];
3334 fn unknown_keys(&self) -> &UnknownKeys {
3335 &self.unknown
3336 }
3337}
3338
3339impl RawBlock for RawNetworkCapabilities {
3340 const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
3341 fn unknown_keys(&self) -> &UnknownKeys {
3342 &self.unknown
3343 }
3344}
3345
3346impl RawBlock for RawPeerFetchCapabilities {
3347 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3348 fn unknown_keys(&self) -> &UnknownKeys {
3349 &self.unknown
3350 }
3351}
3352
3353impl RawBlock for RawFilesystemCapabilities {
3354 const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only", "records"];
3355 fn unknown_keys(&self) -> &UnknownKeys {
3356 &self.unknown
3357 }
3358}
3359
3360impl RawBlock for RawShellCapabilities {
3361 const KNOWN_KEYS: &'static [&'static str] = &[
3362 "allow",
3363 "strip_env",
3364 "baseline_env",
3365 "interpreter_runtime",
3366 "staged_runtime",
3367 ];
3368 fn unknown_keys(&self) -> &UnknownKeys {
3369 &self.unknown
3370 }
3371
3372 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3373 let interpreter_runtime = child_path(path, "interpreter_runtime");
3374 for (index, grant) in self.interpreter_runtime.iter().enumerate() {
3375 collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
3376 }
3377 let staged_runtime = child_path(path, "staged_runtime");
3378 for (index, grant) in self.staged_runtime.iter().enumerate() {
3379 collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
3380 }
3381 }
3382}
3383
3384impl RawBlock for RawInterpreterRuntimeGrant {
3385 const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
3386 fn unknown_keys(&self) -> &UnknownKeys {
3387 &self.unknown
3388 }
3389
3390 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3391 let dirs = child_path(path, "dirs");
3392 for (index, dir) in self.dirs.iter().enumerate() {
3393 collect_block(dir, &format!("{dirs}[{index}]"), out);
3394 }
3395 }
3396}
3397
3398impl RawBlock for RawInterpreterRuntimeDir {
3399 const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
3400 fn unknown_keys(&self) -> &UnknownKeys {
3401 &self.unknown
3402 }
3403}
3404
3405impl RawBlock for RawStagedRuntimeGrant {
3406 const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
3407 fn unknown_keys(&self) -> &UnknownKeys {
3408 &self.unknown
3409 }
3410}
3411
3412impl RawBlock for RawInferenceConfig {
3413 const KNOWN_KEYS: &'static [&'static str] = &[
3414 "transport",
3415 "endpoint",
3416 "model",
3417 "api_key",
3418 "command",
3419 "system_prompt",
3420 "system_prompt_file",
3421 "system_prompt_artifact",
3422 "instruction_files",
3423 "driver",
3424 "compaction",
3425 "max_turns",
3426 "max_task_reopens",
3427 "max_tokens",
3428 "max_session_tokens",
3429 "tool_refresh",
3430 "alternates",
3431 "retry",
3432 ];
3433 fn unknown_keys(&self) -> &UnknownKeys {
3434 &self.unknown
3435 }
3436
3437 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3438 if let Some(driver) = &self.driver {
3439 collect_block(driver, &child_path(path, "driver"), out);
3440 }
3441 if let Some(compaction) = &self.compaction {
3442 collect_block(compaction, &child_path(path, "compaction"), out);
3443 }
3444 if let Some(retry) = &self.retry {
3445 collect_block(retry, &child_path(path, "retry"), out);
3446 }
3447 if let Some(alternates) = &self.alternates {
3448 let alternates_path = child_path(path, "alternates");
3449 for (index, alternate) in alternates.iter().enumerate() {
3450 collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
3451 }
3452 }
3453 }
3454}
3455
3456impl RawBlock for RawInferenceRetry {
3457 const KNOWN_KEYS: &'static [&'static str] =
3458 &["max_retries", "initial_delay_ms", "max_delay_ms"];
3459 fn unknown_keys(&self) -> &UnknownKeys {
3460 &self.unknown
3461 }
3462}
3463
3464impl RawBlock for RawInferenceAlternate {
3465 const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
3466 fn unknown_keys(&self) -> &UnknownKeys {
3467 &self.unknown
3468 }
3469
3470 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3471 if let Some(driver) = &self.driver {
3472 collect_block(driver, &child_path(path, "driver"), out);
3473 }
3474 }
3475}
3476
3477impl RawBlock for RawCompactionConfig {
3478 const KNOWN_KEYS: &'static [&'static str] = &[
3479 "threshold",
3480 "model",
3481 "system_prompt",
3482 "system_prompt_file",
3483 "dump_summaries",
3484 "max_tokens",
3485 "driver_config",
3486 ];
3487 fn unknown_keys(&self) -> &UnknownKeys {
3488 &self.unknown
3489 }
3490}
3491
3492impl RawBlock for RawInferenceDriver {
3493 const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
3494 fn unknown_keys(&self) -> &UnknownKeys {
3495 &self.unknown
3496 }
3497}
3498
3499fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
3506 let mut out = Vec::new();
3507 collect_block(raw, "", &mut out);
3508 out
3509}
3510
3511fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
3517 for key in block.unknown_keys().keys() {
3518 out.push(UnknownManifestKey {
3519 key: key.clone(),
3520 block_path: path.to_string(),
3521 nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
3522 });
3523 }
3524 block.walk_children(path, out);
3525}
3526
3527fn child_path(parent: &str, field: &str) -> String {
3531 if parent.is_empty() {
3532 return field.to_string();
3533 }
3534 format!("{parent}.{field}")
3535}
3536
3537pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
3544 fs::read_to_string(path).map_err(|source| {
3545 if source.kind() == std::io::ErrorKind::NotFound {
3546 return RuntimeManifestError::NotFound(path.display().to_string());
3547 }
3548
3549 RuntimeManifestError::Io {
3550 path: path.display().to_string(),
3551 source,
3552 }
3553 })
3554}
3555
3556#[must_use = "validated runtime manifest is required before run"]
3557pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
3558 let content = read_runtime_manifest_text(path)?;
3559
3560 RuntimeManifest::from_yaml_str(&content)
3561}
3562
3563#[derive(Debug, Clone, PartialEq, Eq)]
3565pub struct ReferencedEnvVariable {
3566 pub field: String,
3568 pub variable: String,
3570}
3571
3572impl ReferencedEnvVariable {
3573 pub fn is_gateway_credential(&self) -> bool {
3576 self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
3577 }
3578}
3579
3580#[derive(Deserialize)]
3588struct ReferencedManifestFields {
3589 #[serde(default)]
3590 artifacts: Vec<ReferencedArtifactFields>,
3591}
3592
3593#[derive(Deserialize)]
3594struct ReferencedArtifactFields {
3595 #[serde(default)]
3596 name: Option<String>,
3597 #[serde(default)]
3598 gateway: Option<ReferencedGatewayFields>,
3599}
3600
3601#[derive(Deserialize)]
3602struct ReferencedGatewayFields {
3603 #[serde(default)]
3604 api_key: Option<String>,
3605}
3606
3607pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
3615 let Ok(raw) = serde_norway::from_str::<ReferencedManifestFields>(manifest_yaml) else {
3616 return Vec::new();
3617 };
3618
3619 let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
3620 for artifact in raw.artifacts {
3621 let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
3622 continue;
3623 };
3624 if let Some(variable) = parse_env_reference(api_key.trim()) {
3625 let name = artifact.name.unwrap_or_default();
3626 referenced.push(ReferencedEnvVariable {
3627 field: format!("artifacts.{}.gateway.api_key", name.trim()),
3628 variable: variable.to_string(),
3629 });
3630 }
3631 }
3632
3633 referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
3634 referenced.dedup();
3635 referenced
3636}
3637
3638#[derive(Clone, Copy, PartialEq, Eq)]
3644enum SecretResolution {
3645 Resolve,
3647 Skip,
3650}
3651
3652impl RuntimeManifest {
3653 pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
3654 Self::parse(input, SecretResolution::Resolve)
3655 }
3656
3657 pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
3667 Self::parse(input, SecretResolution::Skip)
3668 }
3669
3670 fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
3671 let raw: RawRuntimeManifest = serde_norway::from_str(input).map_err(|err| {
3672 if let Some(location) = err.location() {
3673 RuntimeManifestError::YamlSyntax(format!(
3674 "{}: YAML syntax error at line {}, column {}: {}",
3675 MANIFEST_FILENAME,
3676 location.line(),
3677 location.column(),
3678 err
3679 ))
3680 } else {
3681 RuntimeManifestError::YamlSyntax(format!(
3682 "{MANIFEST_FILENAME}: YAML syntax error: {err}"
3683 ))
3684 }
3685 })?;
3686
3687 if raw.unknown.contains_key(REGISTRY_BLOCK_KEY) {
3689 return Err(RuntimeManifestError::RegistryBlock);
3690 }
3691 check_no_host_paths(&raw)?;
3693
3694 let unknown_keys = collect_unknown_keys(&raw);
3697
3698 if raw.mur_version.is_some() {
3699 return Err(RuntimeManifestError::TopLevelMurVersion);
3700 }
3701
3702 let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
3703 RuntimeManifestError::MissingField {
3704 field: "name".to_string(),
3705 }
3706 })?;
3707
3708 let version = raw
3709 .version
3710 .filter(|s| !s.trim().is_empty())
3711 .ok_or_else(|| RuntimeManifestError::MissingField {
3712 field: "version".to_string(),
3713 })?;
3714
3715 let gateway_references = raw_gateway_references(&raw.artifacts);
3718
3719 let artifacts = raw
3720 .artifacts
3721 .into_iter()
3722 .enumerate()
3723 .map(|(index, artifact)| {
3724 let name = artifact
3725 .name
3726 .filter(|s| !s.trim().is_empty())
3727 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3728 index,
3729 message: "missing required field 'name'".to_string(),
3730 })?;
3731
3732 let source = artifact.source.filter(|s| !s.trim().is_empty());
3733 check_entry_name(index, &name, source.is_some())?;
3734
3735 let runtime = match artifact.runtime.as_deref() {
3736 None | Some("tool") => ArtifactRuntime::Tool,
3737 Some("wasm") | Some("native") => {
3738 return Err(RuntimeManifestError::InvalidArtifact {
3739 index,
3740 message: format!(
3741 "use 'runtime: tool' for a tool (got '{}')",
3742 artifact.runtime.as_deref().unwrap_or("")
3743 ),
3744 });
3745 }
3746 Some("driver") => ArtifactRuntime::Driver,
3747 Some("hook") => ArtifactRuntime::Hook,
3748 Some("skill") => ArtifactRuntime::Skill,
3749 Some(other) => {
3750 return Err(RuntimeManifestError::InvalidArtifact {
3751 index,
3752 message: format!(
3753 "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
3754 ),
3755 });
3756 }
3757 };
3758
3759 let local_source = artifact
3763 .local_source
3764 .unwrap_or(runtime == ArtifactRuntime::Skill);
3765 let prompt_payload = artifact
3766 .prompt_payload
3767 .unwrap_or(runtime == ArtifactRuntime::Skill);
3768
3769 if source.is_some() && !local_source {
3771 return Err(RuntimeManifestError::InvalidArtifact {
3772 index,
3773 message: format!(
3774 "artifact '{name}' declares 'source:' but does not declare \
3775 'local_source: true' (runtime: {})",
3776 runtime.as_str()
3777 ),
3778 });
3779 }
3780
3781 let version = if source.is_some() {
3784 if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
3785 crate::warn_line(format_args!(
3786 "warning: artifact '{name}' declares both 'source:' and \
3787 'version: {explicit}'; version is ignored for local-source skills \
3788 (using 'local')"
3789 ));
3790 }
3791 "local".to_string()
3792 } else {
3793 artifact
3794 .version
3795 .filter(|s| !s.trim().is_empty())
3796 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3797 index,
3798 message: "missing required field 'version'".to_string(),
3799 })?
3800 };
3801
3802 let capabilities = match artifact.capabilities {
3808 None => None,
3809 Some(raw_caps) => {
3810 if raw_caps.install.is_some() {
3813 return Err(RuntimeManifestError::InvalidArtifact {
3814 index,
3815 message: format!(
3816 "artifact '{name}' declares 'capabilities.install:'; the \
3817 grant is capsule-wide and is only recognized in the \
3818 top-level capabilities block"
3819 ),
3820 });
3821 }
3822 if runtime == ArtifactRuntime::Skill {
3823 return Err(RuntimeManifestError::InvalidArtifact {
3824 index,
3825 message: format!(
3826 "artifact '{name}' declares per-artifact 'capabilities:' but \
3827 has 'runtime: {}'; per-artifact capabilities are only \
3828 recognized on 'runtime: hook', 'runtime: tool', and \
3829 'runtime: driver' entries — use the capsule-wide top-level \
3830 'capabilities:' block instead",
3831 runtime.as_str()
3832 ),
3833 });
3834 }
3835 if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
3841 return Err(RuntimeManifestError::InvalidArtifact {
3842 index,
3843 message: format!(
3844 "artifact '{name}' declares 'capabilities.task_io:' but has \
3845 'runtime: {}'; the key grants the murmur:task-io/read host \
3846 import and is only recognized on 'runtime: hook' entries",
3847 runtime.as_str()
3848 ),
3849 });
3850 }
3851 if let Some(filesystem) = raw_caps
3855 .filesystem
3856 .as_ref()
3857 .filter(|filesystem| filesystem.records)
3858 {
3859 if runtime != ArtifactRuntime::Hook {
3860 return Err(RuntimeManifestError::InvalidArtifact {
3861 index,
3862 message: format!(
3863 "artifact '{name}' declares \
3864 'capabilities.filesystem.records: true' but has \
3865 'runtime: {}'; the key gives a hook the records \
3866 directory's hooks/ and is only recognized on \
3867 'runtime: hook' entries",
3868 runtime.as_str()
3869 ),
3870 });
3871 }
3872 if filesystem
3873 .scope
3874 .as_deref()
3875 .is_some_and(|scope| !scope.trim().is_empty())
3876 {
3877 return Err(RuntimeManifestError::InvalidArtifact {
3878 index,
3879 message: format!(
3880 "artifact '{name}' declares both \
3881 'capabilities.filesystem.records: true' and \
3882 'capabilities.filesystem.scope'; a hook's \".\" is \
3883 either the records directory's hooks/ or a workdir \
3884 scope — declare one"
3885 ),
3886 });
3887 }
3888 }
3889 if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
3893 return Err(RuntimeManifestError::InvalidArtifact {
3894 index,
3895 message: format!(
3896 "artifact '{name}' declares 'capabilities.conversation:' but \
3897 has 'runtime: {}'; the key grants the \
3898 murmur:conversation/read host import and is only recognized \
3899 on 'runtime: hook' entries",
3900 runtime.as_str()
3901 ),
3902 });
3903 }
3904 parse_capabilities(Some(raw_caps))?
3905 }
3906 };
3907
3908 let on_overflow = match artifact.on_overflow.as_deref() {
3914 None => HookOverflowPolicy::default(),
3915 Some(raw) => {
3916 if runtime != ArtifactRuntime::Hook {
3917 return Err(RuntimeManifestError::InvalidArtifact {
3918 index,
3919 message: format!(
3920 "artifact '{name}' declares 'on_overflow:' but has \
3921 'runtime: {}'; the key governs an async hook's job queue \
3922 and is only recognized on 'runtime: hook' entries",
3923 runtime.as_str()
3924 ),
3925 });
3926 }
3927 match raw {
3928 "drop" => HookOverflowPolicy::Drop,
3929 "block" => HookOverflowPolicy::Block,
3930 other => {
3931 return Err(RuntimeManifestError::InvalidArtifact {
3932 index,
3933 message: format!(
3934 "artifact '{name}' declares unknown on_overflow \
3935 '{other}'; expected: drop, block"
3936 ),
3937 });
3938 }
3939 }
3940 }
3941 };
3942
3943 if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
3950 return Err(RuntimeManifestError::InvalidArtifact {
3951 index,
3952 message: format!(
3953 "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
3954 is recognized only on 'runtime: hook', 'runtime: tool', and \
3955 'runtime: driver' entries",
3956 runtime.as_str()
3957 ),
3958 });
3959 }
3960
3961 let gateway = match artifact.gateway {
3962 None => None,
3963 Some(raw_gateway) => {
3964 if runtime == ArtifactRuntime::Skill {
3965 return Err(RuntimeManifestError::InvalidArtifact {
3966 index,
3967 message: format!(
3968 "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
3969 the key is recognized only on 'runtime: hook', \
3970 'runtime: tool', and 'runtime: driver' entries",
3971 runtime.as_str()
3972 ),
3973 });
3974 }
3975 Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
3976 }
3977 };
3978
3979 Ok(RuntimeArtifact {
3980 name,
3981 version,
3982 runtime,
3983 source,
3984 local_source,
3985 prompt_payload,
3986 capabilities,
3987 on_overflow,
3988 config: artifact.config,
3989 gateway,
3990 })
3991 })
3992 .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
3993
3994 if raw
3998 .capabilities
3999 .as_ref()
4000 .is_some_and(|caps| caps.task_io.is_some())
4001 {
4002 return Err(RuntimeManifestError::InvalidCapabilities {
4003 field: "capabilities.task_io".to_string(),
4004 message: "is recognized only on 'runtime: hook' artifact entries, not in the \
4005 capsule-wide capabilities block — the grant is per-hook"
4006 .to_string(),
4007 });
4008 }
4009 if raw
4012 .capabilities
4013 .as_ref()
4014 .and_then(|caps| caps.filesystem.as_ref())
4015 .is_some_and(|filesystem| filesystem.records)
4016 {
4017 return Err(RuntimeManifestError::InvalidCapabilities {
4018 field: "capabilities.filesystem.records".to_string(),
4019 message: "is recognized only on 'runtime: hook' artifact entries, not in the \
4020 capsule-wide capabilities block — the grant is per-hook"
4021 .to_string(),
4022 });
4023 }
4024 if raw.config.is_some() {
4029 return Err(RuntimeManifestError::InvalidCapabilities {
4030 field: "config".to_string(),
4031 message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
4032 'runtime: driver' entry that reads it — there is no capsule-wide \
4033 config block"
4034 .to_string(),
4035 });
4036 }
4037 let capabilities = parse_capabilities(raw.capabilities)?;
4038 let inference = parse_inference(raw.inference)?;
4039 refuse_install_under_process_transport(capabilities.as_ref(), inference.as_ref())?;
4040 validate_driver_gateways(&artifacts, inference.as_ref())?;
4041 validate_driver_choices(&artifacts, inference.as_ref())?;
4042 validate_process_driver_entry(&artifacts, inference.as_ref())?;
4043
4044 if let Some(ref sp_art) = inference
4047 .as_ref()
4048 .and_then(|i| i.system_prompt_artifact.clone())
4049 {
4050 let matching = artifacts.iter().find(|a| &a.name == sp_art);
4051 match matching {
4052 None => {
4053 return Err(RuntimeManifestError::InvalidInferenceConfig {
4054 field: "inference.system_prompt_artifact".to_string(),
4055 message: not_declared_message(&artifacts, sp_art),
4056 });
4057 }
4058 Some(a) if !a.prompt_payload => {
4059 return Err(RuntimeManifestError::InvalidInferenceConfig {
4060 field: "inference.system_prompt_artifact".to_string(),
4061 message: format!(
4062 "artifact '{sp_art}' (runtime: {}) does not declare \
4063 'prompt_payload: true', which system_prompt_artifact requires",
4064 a.runtime.as_str()
4065 ),
4066 });
4067 }
4068 Some(_) => {} }
4070 }
4071 let context = parse_context(raw.context)?;
4072 let observability = parse_observability(raw.observability);
4073 let trace = raw
4074 .trace
4075 .map(|raw_trace| {
4076 let capture = resolve_trace_capture(raw_trace.capture.as_deref())?;
4077 let retain = parse_trace_retain(raw_trace.retain)?;
4078 Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
4079 })
4080 .transpose()?;
4081 let network = raw
4082 .network
4083 .map(|n| {
4084 Ok::<_, RuntimeManifestError>(NetworkConfig {
4085 internal_port: n.internal_port,
4086 authentication: parse_network_authentication(n.authentication)?,
4087 })
4088 })
4089 .transpose()?;
4090 refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
4091 let lifecycle = raw
4092 .lifecycle
4093 .map(|raw_lc| {
4094 let defaults = LifecycleConfig::default();
4095 let lifecycle = LifecycleConfig {
4096 task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
4097 after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
4098 queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
4099 input_timeout_secs: raw_lc.input_timeout_secs,
4100 conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
4101 max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
4102 shell_grace_secs: raw_lc.shell_grace_secs,
4103 delegation_deadline_secs: raw_lc
4104 .delegation_deadline_secs
4105 .unwrap_or(defaults.delegation_deadline_secs),
4106 deadline_notice_secs: raw_lc.deadline_notice_secs,
4107 keep_working: raw_lc.keep_working.map(parse_keep_working).transpose()?,
4108 done_check: raw_lc
4109 .done_check
4110 .map(|raw| {
4111 let shell_allow = capabilities
4112 .as_ref()
4113 .and_then(|capabilities| capabilities.shell.as_ref())
4114 .map_or(&[][..], |shell| shell.allow.as_slice());
4115 parse_done_check(raw, shell_allow)
4116 })
4117 .transpose()?,
4118 };
4119 if lifecycle.queue_has_room() {
4120 Ok(lifecycle)
4121 } else {
4122 Err(RuntimeManifestError::QueueWithoutRoom)
4123 }
4124 })
4125 .transpose()?;
4126
4127 let exports = parse_exports(raw.exports)?;
4128 let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
4129 validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
4130
4131 Ok(Self {
4132 name,
4133 version,
4134 artifacts,
4135 capabilities,
4136 inference,
4137 context,
4138 observability,
4139 trace,
4140 network,
4141 lifecycle,
4142 exports,
4143 control,
4144 deploy: raw.deploy.map(|deploy| DeployConfig {
4145 mur_version: deploy.mur_version.filter(|s| !s.trim().is_empty()),
4146 }),
4147 unknown_keys,
4148 })
4149 }
4150}
4151
4152fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
4154 artifacts
4155 .iter()
4156 .filter_map(|artifact| {
4157 let name = artifact.name.as_deref()?.trim();
4158 let written = artifact
4159 .gateway
4160 .as_ref()?
4161 .as_ref()?
4162 .api_key
4163 .as_ref()?
4164 .as_ref()?;
4165 let variable = parse_env_reference(written.trim())?;
4166 Some((name.to_string(), variable.to_string()))
4167 })
4168 .collect()
4169}
4170
4171fn parse_control(
4178 raw: Option<RawControl>,
4179 gateway_references: &[(String, String)],
4180 inference: Option<&InferenceConfig>,
4181) -> Result<Option<ControlConfig>, RuntimeManifestError> {
4182 let Some(raw) = raw else {
4183 return Ok(None);
4184 };
4185 let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
4186 field: field.to_string(),
4187 message,
4188 };
4189 let settings_written = control_string_list("control.settings", raw.settings)?;
4190 let secrets_written = control_string_list("control.secrets", raw.secrets)?;
4191 let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
4192 if settings_written.is_empty()
4193 && secrets_written.is_empty()
4194 && agent_settings_written.is_empty()
4195 {
4196 return Ok(None);
4197 }
4198 let Some(inference) = inference else {
4199 return Err(refuse(
4200 "control",
4201 "is declared on a capsule with no inference: block; the control surface is served on \
4202 an agent capsule's A2A listener, and a script capsule opens none"
4203 .to_string(),
4204 ));
4205 };
4206
4207 let mut settings = Vec::new();
4208 for entry in settings_written {
4209 let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
4210 let controllable: Vec<&str> = ControllableSetting::ALL
4211 .iter()
4212 .map(|setting| setting.wire_name())
4213 .collect();
4214 return Err(refuse(
4215 "control.settings",
4216 format!(
4217 "'{entry}' is not a controllable setting; the controllable settings are: {}",
4218 controllable.join(", ")
4219 ),
4220 ));
4221 };
4222 if settings.contains(&setting) {
4223 return Err(refuse(
4224 "control.settings",
4225 format!("'{entry}' is listed more than once"),
4226 ));
4227 }
4228 match setting {
4229 ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
4230 return Err(refuse(
4231 "control.settings",
4232 format!(
4233 "'{entry}' is controllable only under inference.transport: http; a \
4234 transport: {} harness sets its own output cap",
4235 inference.transport
4236 ),
4237 ));
4238 }
4239 ControllableSetting::InferenceMaxTokens => {}
4240 ControllableSetting::InferenceDriver => {
4241 check_driver_setting(inference).map_err(|message| {
4242 refuse("control.settings", format!("'{entry}' {message}"))
4243 })?;
4244 }
4245 }
4246 settings.push(setting);
4247 }
4248
4249 let mut agent_settings = Vec::new();
4250 for entry in agent_settings_written {
4251 let Some(setting) = ControllableSetting::from_wire_name(&entry)
4252 .filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
4253 else {
4254 let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
4255 .iter()
4256 .map(|setting| setting.wire_name())
4257 .collect();
4258 return Err(refuse(
4259 "control.agent_settings",
4260 format!(
4261 "'{entry}' is not a setting the agent may change; control.agent_settings \
4262 accepts only: {}",
4263 settable.join(", ")
4264 ),
4265 ));
4266 };
4267 if agent_settings.contains(&setting) {
4268 return Err(refuse(
4269 "control.agent_settings",
4270 format!("'{entry}' is listed more than once"),
4271 ));
4272 }
4273 check_driver_setting(inference)
4274 .map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
4275 agent_settings.push(setting);
4276 }
4277
4278 let inference_driver = (inference.transport == "http")
4279 .then(|| {
4280 inference
4281 .driver
4282 .as_ref()
4283 .map(|driver| driver.artifact.as_str())
4284 })
4285 .flatten();
4286 let mut secrets: Vec<String> = Vec::new();
4287 for entry in secrets_written {
4288 if !is_valid_env_variable(&entry) {
4289 return Err(refuse(
4290 "control.secrets",
4291 format!(
4292 "'{entry}' is not a credential name; a name is uppercase letters, digits and \
4293 underscores, and does not start with a digit"
4294 ),
4295 ));
4296 }
4297 if secrets.contains(&entry) {
4298 return Err(refuse(
4299 "control.secrets",
4300 format!("'{entry}' is listed more than once"),
4301 ));
4302 }
4303 let backing: Vec<&str> = gateway_references
4304 .iter()
4305 .filter(|(_, variable)| *variable == entry)
4306 .map(|(artifact, _)| artifact.as_str())
4307 .collect();
4308 if backing.is_empty() {
4309 return Err(refuse(
4310 "control.secrets",
4311 format!(
4312 "'{entry}' is not referenced by any artifact's gateway.api_key as \
4313 ${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
4314 ),
4315 ));
4316 }
4317 if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
4318 return Err(refuse(
4319 "control.secrets",
4320 format!(
4321 "'{entry}' keys the configured inference driver's gateway \
4322 (artifacts.{driver}.gateway.api_key); the inference credential is resolved \
4323 at launch and cannot be supplied by a controller"
4324 ),
4325 ));
4326 }
4327 secrets.push(entry);
4328 }
4329
4330 Ok(Some(ControlConfig {
4331 settings,
4332 secrets,
4333 agent_settings,
4334 }))
4335}
4336
4337fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
4340 if inference.transport != "http" {
4341 return Err(format!(
4342 "is controllable only under inference.transport: http; a transport: {} harness owns \
4343 the model it runs",
4344 inference.transport
4345 ));
4346 }
4347 if inference.alternates.is_empty() {
4348 return Err(
4349 "selects among inference.alternates, and this capsule declares none; declare an \
4350 alternate to switch to"
4351 .to_string(),
4352 );
4353 }
4354 Ok(())
4355}
4356
4357fn control_string_list(
4359 field: &str,
4360 value: Option<serde_norway::Value>,
4361) -> Result<Vec<String>, RuntimeManifestError> {
4362 let refuse = || RuntimeManifestError::InvalidControl {
4363 field: field.to_string(),
4364 message: "must be a list of names".to_string(),
4365 };
4366 let items = match value {
4367 None | Some(serde_norway::Value::Null) => return Ok(Vec::new()),
4368 Some(serde_norway::Value::Sequence(items)) => items,
4369 Some(_) => return Err(refuse()),
4370 };
4371 items
4372 .into_iter()
4373 .map(|item| match item {
4374 serde_norway::Value::String(name) => Ok(name.trim().to_string()),
4375 _ => Err(refuse()),
4376 })
4377 .collect()
4378}
4379
4380fn parse_network_authentication(
4382 raw: Option<RawNetworkAuthentication>,
4383) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
4384 let Some(raw) = raw else {
4385 return Ok(None);
4386 };
4387 let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
4388 field: field.to_string(),
4389 message,
4390 };
4391 let schemes: Vec<&str> = AuthenticationScheme::ALL
4392 .iter()
4393 .map(|scheme| scheme.wire_name())
4394 .collect();
4395 let scheme = match raw.scheme.as_deref().map(str::trim) {
4396 None | Some("") => {
4397 return Err(refuse(
4398 "network.authentication.scheme",
4399 format!("is required; the schemes are: {}", schemes.join(", ")),
4400 ))
4401 }
4402 Some(written) => AuthenticationScheme::ALL
4403 .iter()
4404 .copied()
4405 .find(|scheme| scheme.wire_name() == written)
4406 .ok_or_else(|| {
4407 refuse(
4408 "network.authentication.scheme",
4409 format!(
4410 "'{written}' is not a door authentication scheme; the schemes are: {}",
4411 schemes.join(", ")
4412 ),
4413 )
4414 })?,
4415 };
4416
4417 let mut credentials = Vec::new();
4418 for (name, credential) in raw.credentials.unwrap_or_default() {
4419 if name == OPERATOR_CREDENTIAL {
4420 return Err(refuse(
4421 "network.authentication.credentials",
4422 format!(
4423 "'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
4424 token, which holds every scope"
4425 ),
4426 ));
4427 }
4428 if !is_valid_credential_name(&name) {
4429 return Err(refuse(
4430 "network.authentication.credentials",
4431 format!(
4432 "'{name}' is not a credential name; a name is a lowercase letter followed by \
4433 at most {} lowercase letters, digits, '_' or '-'",
4434 MAX_CREDENTIAL_NAME_LEN - 1
4435 ),
4436 ));
4437 }
4438 let field = format!("network.authentication.credentials.{name}.scopes");
4439 let scopes = door_scope_list(&field, credential.scopes)?;
4440 credentials.push(DoorCredential { name, scopes });
4441 }
4442
4443 Ok(Some(NetworkAuthentication {
4444 scheme,
4445 credentials,
4446 }))
4447}
4448
4449fn is_valid_credential_name(name: &str) -> bool {
4451 let mut chars = name.chars();
4452 chars.next().is_some_and(|first| first.is_ascii_lowercase())
4453 && name.len() <= MAX_CREDENTIAL_NAME_LEN
4454 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
4455}
4456
4457fn door_scope_list(
4459 field: &str,
4460 value: Option<serde_norway::Value>,
4461) -> Result<Vec<String>, RuntimeManifestError> {
4462 let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
4463 field: field.to_string(),
4464 message,
4465 };
4466 let items = match value {
4467 None | Some(serde_norway::Value::Null) => {
4468 return Err(refuse(
4469 "is required: a credential reaches only the scopes it lists".to_string(),
4470 ))
4471 }
4472 Some(serde_norway::Value::Sequence(items)) => items,
4473 Some(_) => return Err(refuse("must be a list of scopes".to_string())),
4474 };
4475 if items.is_empty() {
4476 return Err(refuse(
4477 "lists no scope; a credential must reach at least one".to_string(),
4478 ));
4479 }
4480 let mut scopes: Vec<String> = Vec::new();
4481 for item in items {
4482 let serde_norway::Value::String(scope) = item else {
4483 return Err(refuse("must be a list of scopes".to_string()));
4484 };
4485 let scope = scope.trim().to_string();
4486 if let Some(&(_, replacement)) = RETIRED_DOOR_SCOPES
4487 .iter()
4488 .find(|(retired, _)| *retired == scope)
4489 {
4490 return Err(RuntimeManifestError::RetiredDoorScope {
4491 field: field.to_string(),
4492 scope,
4493 replacement,
4494 });
4495 }
4496 if !DOOR_SCOPES.contains(&scope.as_str()) {
4497 return Err(refuse(format!(
4498 "'{scope}' is not a door scope; the scopes are: {}",
4499 DOOR_SCOPES.join(", ")
4500 )));
4501 }
4502 if scopes.contains(&scope) {
4503 return Err(refuse(format!("'{scope}' is listed more than once")));
4504 }
4505 scopes.push(scope);
4506 }
4507 Ok(scopes)
4508}
4509
4510fn refuse_authenticated_delegating_parent(
4515 network: Option<&NetworkConfig>,
4516 capabilities: Option<&Capabilities>,
4517) -> Result<(), RuntimeManifestError> {
4518 let authenticated = network.is_some_and(|network| network.authentication.is_some());
4519 let delegates = capabilities
4520 .and_then(|capabilities| capabilities.spawn.as_ref())
4521 .is_some_and(|spawn| !spawn.allow.is_empty());
4522 if authenticated && delegates {
4523 return Err(RuntimeManifestError::InvalidNetworkConfig {
4524 field: "network.authentication".to_string(),
4525 message: "cannot be declared together with capabilities.spawn.allow: a delegated \
4526 child posts its outcome to this door, and a delegated child cannot yet be \
4527 issued a token for this door"
4528 .to_string(),
4529 });
4530 }
4531 Ok(())
4532}
4533
4534fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
4541 let Some(raw_exports) = raw else {
4542 return Ok(None);
4543 };
4544 let files = raw_exports.files.map(parse_file_export).transpose()?;
4545 let peer_files = raw_exports
4546 .peer_files
4547 .map(parse_peer_files_export)
4548 .transpose()?;
4549 let peer_tasks = raw_exports
4550 .peer_tasks
4551 .map(|raw| {
4552 raw.accept
4553 .map(|accept| PeerTasksExport { accept })
4554 .ok_or_else(|| RuntimeManifestError::InvalidExports {
4555 field: "exports.peer_tasks.accept".to_string(),
4556 message: "must be set explicitly to true or false — consent to peer tasks is \
4557 never inferred"
4558 .to_string(),
4559 })
4560 })
4561 .transpose()?;
4562 Ok(Some(Exports {
4563 files,
4564 peer_files,
4565 peer_tasks,
4566 }))
4567}
4568
4569fn parse_peer_files_export(
4577 raw: RawPeerFilesExport,
4578) -> Result<PeerFilesExport, RuntimeManifestError> {
4579 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4580 field: field.to_string(),
4581 message,
4582 };
4583
4584 let root = raw
4585 .root
4586 .map(|root| root.trim().to_string())
4587 .filter(|root| !root.is_empty())
4588 .ok_or_else(|| {
4589 invalid(
4590 "exports.peer_files.root",
4591 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4592 )
4593 })?;
4594 check_export_root_shape("exports.peer_files.root", &root)?;
4595
4596 let max_ttl_secs = match raw.max_ttl {
4597 None => None,
4598 Some(value) => {
4599 let text = scalar_text(&value).ok_or_else(|| {
4600 invalid(
4601 "exports.peer_files.max_ttl",
4602 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
4603 )
4604 })?;
4605 let parsed = parse_duration_secs(&text)
4606 .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
4607 if parsed == 0 {
4608 return Err(invalid(
4609 "exports.peer_files.max_ttl",
4610 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
4611 ));
4612 }
4613 Some(parsed)
4614 }
4615 };
4616
4617 let max_bytes = match raw.max_bytes {
4618 None => DEFAULT_PEER_FILES_MAX_BYTES,
4619 Some(value) => {
4620 let text = scalar_text(&value).ok_or_else(|| {
4621 invalid(
4622 "exports.peer_files.max_bytes",
4623 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4624 )
4625 })?;
4626 let parsed = parse_byte_size(&text)
4627 .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
4628 if parsed == 0 {
4629 return Err(invalid(
4630 "exports.peer_files.max_bytes",
4631 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4632 ));
4633 }
4634 parsed
4635 }
4636 };
4637
4638 Ok(PeerFilesExport {
4639 root,
4640 max_ttl_secs,
4641 max_bytes,
4642 })
4643}
4644
4645fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_norway::Value>, D::Error>
4653where
4654 D: serde::Deserializer<'de>,
4655{
4656 serde_norway::Value::deserialize(deserializer).map(Some)
4657}
4658
4659fn scalar_text(value: &serde_norway::Value) -> Option<String> {
4660 match value {
4661 serde_norway::Value::String(text) => Some(text.clone()),
4662 serde_norway::Value::Number(number) => Some(number.to_string()),
4663 _ => None,
4664 }
4665}
4666
4667enum RelativePathRefusal {
4669 ParentDir,
4670 Absolute,
4671}
4672
4673fn relative_path_refusal(path: &str) -> Option<RelativePathRefusal> {
4676 Path::new(path)
4677 .components()
4678 .find_map(|component| match component {
4679 std::path::Component::Normal(_) | std::path::Component::CurDir => None,
4680 std::path::Component::ParentDir => Some(RelativePathRefusal::ParentDir),
4681 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
4682 Some(RelativePathRefusal::Absolute)
4683 }
4684 })
4685}
4686
4687fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
4691 let message = match relative_path_refusal(root) {
4692 None => return Ok(()),
4693 Some(RelativePathRefusal::ParentDir) => {
4694 format!("'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}")
4695 }
4696 Some(RelativePathRefusal::Absolute) => {
4697 format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}")
4698 }
4699 };
4700 Err(RuntimeManifestError::InvalidExports {
4701 field: field.to_string(),
4702 message,
4703 })
4704}
4705
4706const INSTRUCTION_FILE_ACCEPTED_FORM: &str = "must be a relative path inside the work folder";
4707
4708fn parse_instruction_files(entries: Vec<String>) -> Result<Vec<String>, RuntimeManifestError> {
4712 for (index, entry) in entries.iter().enumerate() {
4713 let problem = if entry.trim().is_empty() {
4714 Some("is empty")
4715 } else {
4716 match relative_path_refusal(entry) {
4717 None => None,
4718 Some(RelativePathRefusal::ParentDir) => Some("contains a '..' component"),
4719 Some(RelativePathRefusal::Absolute) => Some("is absolute"),
4720 }
4721 };
4722 if let Some(problem) = problem {
4723 return Err(RuntimeManifestError::InvalidInferenceConfig {
4724 field: format!("inference.instruction_files[{index}]"),
4725 message: format!("'{entry}' {problem}; {INSTRUCTION_FILE_ACCEPTED_FORM}"),
4726 });
4727 }
4728 }
4729 Ok(entries)
4730}
4731
4732fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
4733 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4734 field: field.to_string(),
4735 message,
4736 };
4737
4738 let root = raw
4739 .root
4740 .map(|root| root.trim().to_string())
4741 .filter(|root| !root.is_empty())
4742 .ok_or_else(|| {
4743 invalid(
4744 "exports.files.root",
4745 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4746 )
4747 })?;
4748 check_export_root_shape("exports.files.root", &root)?;
4749
4750 let mode = match raw.mode.as_deref().map(str::trim) {
4751 None | Some("") => {
4752 return Err(invalid(
4753 "exports.files.mode",
4754 "is required and must be 'read-only'".to_string(),
4755 ));
4756 }
4757 Some("read-only") => ExportMode::ReadOnly,
4758 Some(other) => {
4759 return Err(invalid(
4760 "exports.files.mode",
4761 format!("'{other}' must be 'read-only'"),
4762 ));
4763 }
4764 };
4765
4766 let max_bytes = match raw.max_bytes {
4767 None => DEFAULT_EXPORT_MAX_BYTES,
4768 Some(value) => {
4769 let Some(text) = scalar_text(&value) else {
4770 return Err(invalid(
4771 "exports.files.max_bytes",
4772 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4773 ));
4774 };
4775 let parsed = parse_byte_size(&text)
4776 .map_err(|message| invalid("exports.files.max_bytes", message))?;
4777 if parsed == 0 {
4778 return Err(invalid(
4779 "exports.files.max_bytes",
4780 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4781 ));
4782 }
4783 parsed
4784 }
4785 };
4786
4787 Ok(FileExport {
4788 root,
4789 mode,
4790 max_bytes,
4791 })
4792}
4793
4794const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
4797
4798pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
4800
4801fn check_network_destination_shape(entry: &str) -> Result<(), String> {
4808 let trimmed = entry.trim();
4809 if trimmed.is_empty() {
4810 return Err(format!(
4811 "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4812 ));
4813 }
4814
4815 if trimmed.contains("://") {
4816 let url = Url::parse(trimmed).map_err(|error| {
4817 format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
4818 })?;
4819 let scheme = url.scheme().to_ascii_lowercase();
4820 if scheme != "http" && scheme != "https" {
4821 return Err(format!(
4822 "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
4823 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4824 ));
4825 }
4826 if url.host_str().is_none_or(str::is_empty) {
4827 return Err(format!(
4828 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4829 ));
4830 }
4831 if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
4832 return Err(format!(
4833 "'{entry}' must not include a path, query or fragment; \
4834 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4835 ));
4836 }
4837 return Ok(());
4838 }
4839
4840 if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
4841 return Err(format!(
4842 "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4843 ));
4844 }
4845 if let Some((host, port)) = trimmed.rsplit_once(':') {
4847 if !host.ends_with(']') {
4848 if host.is_empty() {
4849 return Err(format!(
4850 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4851 ));
4852 }
4853 if port.parse::<u16>().is_err() {
4854 return Err(format!(
4855 "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4856 ));
4857 }
4858 }
4859 }
4860 Ok(())
4861}
4862
4863pub const HOST_PATHS_KEY: &str = "host_paths";
4866
4867fn check_no_host_paths(raw: &RawRuntimeManifest) -> Result<(), RuntimeManifestError> {
4871 fn in_capabilities(capabilities: Option<&RawCapabilities>, prefix: &str) -> Option<String> {
4872 let capabilities = capabilities?;
4873 if capabilities.unknown.contains_key(HOST_PATHS_KEY) {
4874 return Some(format!("{prefix}.{HOST_PATHS_KEY}"));
4875 }
4876 capabilities
4877 .filesystem
4878 .as_ref()
4879 .filter(|filesystem| filesystem.unknown.contains_key(HOST_PATHS_KEY))
4880 .map(|_| format!("{prefix}.filesystem.{HOST_PATHS_KEY}"))
4881 }
4882 let field = if raw.unknown.contains_key(HOST_PATHS_KEY) {
4883 Some(HOST_PATHS_KEY.to_string())
4884 } else {
4885 in_capabilities(raw.capabilities.as_ref(), "capabilities").or_else(|| {
4886 raw.artifacts
4887 .iter()
4888 .enumerate()
4889 .find_map(|(index, artifact)| {
4890 in_capabilities(
4891 artifact.capabilities.as_ref(),
4892 &format!("artifacts[{index}].capabilities"),
4893 )
4894 })
4895 })
4896 };
4897 match field {
4898 Some(field) => Err(RuntimeManifestError::HostPathsDeclared { field }),
4899 None => Ok(()),
4900 }
4901}
4902
4903fn parse_capabilities(
4904 raw: Option<RawCapabilities>,
4905) -> Result<Option<Capabilities>, RuntimeManifestError> {
4906 let Some(raw_caps) = raw else {
4907 return Ok(None);
4908 };
4909
4910 let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
4911 allow: raw_network.allow,
4912 unix_sockets: raw_network.unix_sockets,
4913 });
4914
4915 let peer_fetch = raw_caps
4916 .peer_fetch
4917 .map(|raw_peer_fetch| {
4918 if raw_peer_fetch.allow.is_empty() {
4919 return Err(RuntimeManifestError::InvalidCapabilities {
4920 field: "capabilities.peer_fetch.allow".to_string(),
4921 message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
4922 });
4923 }
4924 for entry in &raw_peer_fetch.allow {
4925 check_network_destination_shape(entry).map_err(|message| {
4926 RuntimeManifestError::InvalidCapabilities {
4927 field: "capabilities.peer_fetch.allow".to_string(),
4928 message,
4929 }
4930 })?;
4931 }
4932 Ok(PeerFetchCapabilities {
4933 allow: raw_peer_fetch.allow,
4934 })
4935 })
4936 .transpose()?;
4937
4938 let filesystem = raw_caps
4939 .filesystem
4940 .map(|raw_filesystem| FilesystemCapabilities {
4941 scope: raw_filesystem.scope.and_then(|scope| {
4942 let trimmed = scope.trim();
4943 (!trimmed.is_empty()).then(|| trimmed.to_string())
4944 }),
4945 workdir_exec: raw_filesystem.workdir_exec,
4950 read_only: raw_filesystem
4954 .read_only
4955 .into_iter()
4956 .filter_map(|entry| {
4957 let trimmed = entry.trim();
4958 (!trimmed.is_empty()).then(|| trimmed.to_string())
4959 })
4960 .collect(),
4961 records: raw_filesystem.records,
4963 });
4964
4965 let shell = raw_caps
4966 .shell
4967 .map(|raw_shell| {
4968 if raw_shell.allow.is_empty() {
4969 return Err(RuntimeManifestError::InvalidCapabilities {
4970 field: "capabilities.shell.allow".to_string(),
4971 message: "must contain at least one binary".to_string(),
4972 });
4973 }
4974 for (index, entry) in raw_shell.allow.iter().enumerate() {
4975 if let Some(reason) = shell_allow_entry_error(entry) {
4976 return Err(RuntimeManifestError::ShellAllowEntry {
4977 index,
4978 entry: entry.clone(),
4979 reason,
4980 });
4981 }
4982 }
4983
4984 let interpreter_runtime =
4985 parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
4986 let staged_runtime = parse_staged_runtime(
4989 &raw_shell.allow,
4990 &interpreter_runtime,
4991 raw_shell.staged_runtime,
4992 )?;
4993
4994 Ok(ShellCapabilities {
4995 allow: raw_shell.allow,
4996 strip_env: raw_shell.strip_env,
4997 baseline_env: raw_shell.baseline_env,
4998 interpreter_runtime,
4999 staged_runtime,
5000 })
5001 })
5002 .transpose()?;
5003
5004 let spawn = raw_caps
5005 .spawn
5006 .map(|raw_spawn| parse_spawn_allow(raw_spawn.allow, raw_spawn.workdir_snapshot))
5007 .transpose()?;
5008
5009 let env = raw_caps.env.map(|raw_env| EnvCapabilities {
5010 allow: raw_env.allow,
5011 });
5012
5013 let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
5014
5015 let resources = raw_caps
5016 .resources
5017 .map(parse_resource_capabilities)
5018 .transpose()?;
5019
5020 let state = raw_caps.state.map(parse_state_capabilities);
5021
5022 let task_io = raw_caps
5023 .task_io
5024 .map(|raw_task_io| {
5025 raw_task_io
5026 .read
5027 .map(|read| TaskIoCapabilities { read })
5028 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5029 field: "capabilities.task_io.read".to_string(),
5030 message: "must be set explicitly to true or false — a capability is never \
5031 inferred"
5032 .to_string(),
5033 })
5034 })
5035 .transpose()?;
5036
5037 let conversation = raw_caps
5038 .conversation
5039 .map(|raw_conversation| {
5040 raw_conversation
5041 .read
5042 .map(|read| ConversationCapabilities { read })
5043 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5044 field: "capabilities.conversation.read".to_string(),
5045 message: "must be set explicitly to true or false — a capability is never \
5046 inferred"
5047 .to_string(),
5048 })
5049 })
5050 .transpose()?;
5051
5052 let plan = raw_caps
5053 .plan
5054 .map(|raw_plan| {
5055 raw_plan
5056 .submit
5057 .map(|submit| PlanCapabilities { submit })
5058 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5059 field: "capabilities.plan.submit".to_string(),
5060 message: "must be set explicitly to true or false — a capability is never \
5061 inferred"
5062 .to_string(),
5063 })
5064 })
5065 .transpose()?;
5066
5067 let install = raw_caps.install.map(parse_install).transpose()?;
5068
5069 let containment = raw_caps
5070 .containment
5071 .as_deref()
5072 .map(str::trim)
5073 .filter(|value| !value.is_empty())
5074 .map(|value| {
5075 let class = value.parse::<ContainmentClass>().map_err(|error| {
5076 RuntimeManifestError::InvalidCapabilities {
5077 field: "capabilities.containment".to_string(),
5078 message: error.to_string(),
5079 }
5080 })?;
5081 if class == ContainmentClass::Unconfined {
5082 return Err(RuntimeManifestError::InvalidCapabilities {
5083 field: "capabilities.containment".to_string(),
5084 message: UNCONFINED_NOT_DECLARABLE.to_string(),
5085 });
5086 }
5087 Ok(class)
5088 })
5089 .transpose()?;
5090
5091 Ok(Some(Capabilities {
5092 network,
5093 peer_fetch,
5094 filesystem,
5095 shell,
5096 spawn,
5097 env,
5098 limits,
5099 resources,
5100 state,
5101 task_io,
5102 conversation,
5103 plan,
5104 install,
5105 containment,
5106 }))
5107}
5108
5109fn parse_spawn_allow(
5113 allow: Vec<String>,
5114 workdir_snapshot: Vec<String>,
5115) -> Result<SpawnCapabilities, RuntimeManifestError> {
5116 let mut parsed: Vec<ArtifactName> = Vec::with_capacity(allow.len());
5117 for (index, entry) in allow.iter().enumerate() {
5118 let name = ArtifactName::parse(entry).map_err(|error| {
5119 RuntimeManifestError::InvalidArtifactName {
5120 field: format!("capabilities.spawn.allow[{index}]"),
5121 message: error.to_string(),
5122 hint: error.hint(),
5123 }
5124 })?;
5125 if let Some(earlier) = parsed
5126 .iter()
5127 .find(|earlier| earlier.name() == name.name() && **earlier != name)
5128 {
5129 return Err(RuntimeManifestError::InvalidArtifactName {
5130 field: format!("capabilities.spawn.allow[{index}]"),
5131 message: format!(
5132 "'{earlier}' and '{name}' share the name '{}', which is how delegate-task \
5133 offers a capsule to the model",
5134 name.name()
5135 ),
5136 hint: Some(format!(
5137 "keep one of '{earlier}' and '{name}' in capabilities.spawn.allow"
5138 )),
5139 });
5140 }
5141 parsed.push(name);
5142 }
5143 let mut snapshotted: Vec<ArtifactName> = Vec::with_capacity(workdir_snapshot.len());
5144 for (index, entry) in workdir_snapshot.iter().enumerate() {
5145 let field = format!("capabilities.spawn.workdir_snapshot[{index}]");
5146 let name = ArtifactName::parse(entry).map_err(|error| {
5147 RuntimeManifestError::InvalidArtifactName {
5148 field: field.clone(),
5149 message: error.to_string(),
5150 hint: error.hint(),
5151 }
5152 })?;
5153 if snapshotted.contains(&name) {
5154 return Err(RuntimeManifestError::InvalidArtifactName {
5155 field,
5156 message: format!("'{name}' is listed more than once"),
5157 hint: Some(format!(
5158 "keep one '{name}' in capabilities.spawn.workdir_snapshot"
5159 )),
5160 });
5161 }
5162 if !parsed.contains(&name) {
5163 return Err(RuntimeManifestError::InvalidArtifactName {
5164 field,
5165 message: format!(
5166 "'{name}' is not in capabilities.spawn.allow, so this capsule cannot \
5167 delegate to it"
5168 ),
5169 hint: Some(format!(
5170 "add '{name}' to capabilities.spawn.allow, or remove it from \
5171 capabilities.spawn.workdir_snapshot"
5172 )),
5173 });
5174 }
5175 snapshotted.push(name);
5176 }
5177 Ok(SpawnCapabilities {
5178 allow,
5179 workdir_snapshot,
5180 })
5181}
5182
5183fn check_entry_name(
5186 index: usize,
5187 name: &str,
5188 has_source: bool,
5189) -> Result<(), RuntimeManifestError> {
5190 let field = format!("artifacts[{index}].name");
5191 if !has_source {
5192 return ArtifactName::parse(name).map(|_| ()).map_err(|error| {
5193 RuntimeManifestError::InvalidArtifactName {
5194 field,
5195 message: error.to_string(),
5196 hint: error.hint(),
5197 }
5198 });
5199 }
5200 if let Some((_, bare)) = name.split_once('/') {
5201 return Err(RuntimeManifestError::InvalidArtifactName {
5202 field,
5203 message: format!(
5204 "'{name}' declares 'source:', so it is named by its bare name; a scope says \
5205 where a resolved artifact comes from, and a source: entry is never resolved"
5206 ),
5207 hint: Some(format!("drop the scope: name it '{bare}'")),
5208 });
5209 }
5210 match artifact_name_format_error(name) {
5211 Some(reason) => Err(RuntimeManifestError::InvalidArtifactName {
5212 field,
5213 message: format!("'{name}' is not a valid artifact name: it {reason}"),
5214 hint: None,
5215 }),
5216 None => Ok(()),
5217 }
5218}
5219
5220fn not_declared_message(artifacts: &[RuntimeArtifact], name: &str) -> String {
5224 let mut same_bare = artifacts
5225 .iter()
5226 .filter(|artifact| artifact.name != name && artifact.tool_name() == tool_name_of(name));
5227 match (same_bare.next(), same_bare.next()) {
5228 (Some(only), None) => format!(
5229 "artifact '{name}' is not declared in artifacts: (the declared '{}' has that name; \
5230 refer to it as '{}')",
5231 only.name, only.name
5232 ),
5233 _ => format!("artifact '{name}' is not declared in artifacts:"),
5234 }
5235}
5236
5237fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
5240 if raw.skill.is_empty() && raw.tool.is_empty() {
5241 return Err(RuntimeManifestError::InvalidCapabilities {
5242 field: "capabilities.install".to_string(),
5243 message: "must name at least one 'skill' or 'tool' entry — a capability is never \
5244 inferred; omit the block to grant no install at all"
5245 .to_string(),
5246 });
5247 }
5248 for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
5249 for (index, entry) in entries.iter().enumerate() {
5250 crate::install_pattern::validate(entry).map_err(|error| {
5251 RuntimeManifestError::InvalidArtifactName {
5252 field: format!("capabilities.install.{key}[{index}]"),
5253 message: format!(
5254 "entry {entry:?} is invalid ({}); expected {}",
5255 error.reason,
5256 crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
5257 ),
5258 hint: error.hint,
5259 }
5260 })?;
5261 }
5262 }
5263 let from = match raw.from {
5264 None => InstallFrom::Stores,
5265 Some(list) => parse_install_from(&list)?,
5266 };
5267 Ok(InstallCapabilities {
5268 skill: raw.skill,
5269 tool: raw.tool,
5270 from,
5271 })
5272}
5273
5274fn refuse_install_under_process_transport(
5277 capabilities: Option<&Capabilities>,
5278 inference: Option<&InferenceConfig>,
5279) -> Result<(), RuntimeManifestError> {
5280 let installs = capabilities.is_some_and(|capabilities| capabilities.install.is_some());
5281 let process = inference.is_some_and(|inference| inference.transport == "process");
5282 if installs && process {
5283 return Err(RuntimeManifestError::InvalidCapabilities {
5284 field: "capabilities.install".to_string(),
5285 message: "is not valid with transport: process; the harness owns its tool list, so a \
5286 pulled artifact could never reach it"
5287 .to_string(),
5288 });
5289 }
5290 Ok(())
5291}
5292
5293const INSTALL_FROM_ACCEPTED: &str = "[stores] or [stores, sources]";
5295
5296fn parse_install_from(list: &[String]) -> Result<InstallFrom, RuntimeManifestError> {
5299 let refuse = |message: String| RuntimeManifestError::InvalidCapabilities {
5300 field: "capabilities.install.from".to_string(),
5301 message: format!("{message}; expected {INSTALL_FROM_ACCEPTED}"),
5302 };
5303 if list.is_empty() {
5304 return Err(refuse("is empty".to_string()));
5305 }
5306 let (mut stores, mut sources) = (false, false);
5307 for entry in list {
5308 let seen = match entry.as_str() {
5309 "stores" => &mut stores,
5310 "sources" => &mut sources,
5311 other => return Err(refuse(format!("names {other:?}, which is not a place"))),
5312 };
5313 if *seen {
5314 return Err(refuse(format!("names {entry:?} twice")));
5315 }
5316 *seen = true;
5317 }
5318 if !stores {
5319 return Err(refuse(
5320 "does not name stores, which every pull is answered from first".to_string(),
5321 ));
5322 }
5323 Ok(if sources {
5324 InstallFrom::StoresAndSources
5325 } else {
5326 InstallFrom::Stores
5327 })
5328}
5329
5330fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
5340 StateCapabilities {
5341 store: raw.store.map(|store| store.trim().to_string()),
5342 }
5343}
5344
5345fn shell_allow_entry_error(entry: &str) -> Option<&'static str> {
5349 if entry.is_empty() {
5350 Some("is empty")
5351 } else if entry.contains('\0') {
5352 Some("contains a NUL byte")
5353 } else if entry.contains('/') {
5354 Some("is a path")
5355 } else if entry == "." || entry == ".." {
5356 Some("names a directory")
5357 } else if entry.chars().any(char::is_whitespace) {
5358 Some("contains whitespace")
5359 } else {
5360 None
5361 }
5362}
5363
5364fn parse_interpreter_runtime(
5370 allow: &[String],
5371 raw_grants: Vec<RawInterpreterRuntimeGrant>,
5372) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
5373 let mut grants = Vec::with_capacity(raw_grants.len());
5374 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5375 let base = format!("capabilities.shell.interpreter_runtime[{index}]");
5376
5377 let binary = raw_grant
5378 .binary
5379 .filter(|b| !b.trim().is_empty())
5380 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5381 field: format!("{base}.binary"),
5382 message: "must name a binary".to_string(),
5383 })?;
5384
5385 if !allow.contains(&binary) {
5389 return Err(RuntimeManifestError::InvalidCapabilities {
5390 field: format!("{base}.binary"),
5391 message: format!(
5392 "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
5393 only narrow filesystem access for an already-allowlisted binary, never \
5394 grant exec"
5395 ),
5396 });
5397 }
5398
5399 if raw_grant.dirs.is_empty() {
5400 return Err(RuntimeManifestError::InvalidCapabilities {
5401 field: format!("{base}.dirs"),
5402 message: "must name at least one directory".to_string(),
5403 });
5404 }
5405
5406 let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
5407 for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
5408 let dir_base = format!("{base}.dirs[{dir_index}]");
5409
5410 let path = raw_dir
5411 .path
5412 .filter(|p| !p.trim().is_empty())
5413 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5414 field: format!("{dir_base}.path"),
5415 message: "must name a host directory".to_string(),
5416 })?;
5417 if !path.starts_with('/') {
5418 return Err(RuntimeManifestError::InvalidCapabilities {
5419 field: format!("{dir_base}.path"),
5420 message: format!(
5421 "'{path}' must be an absolute host path (start with '/') — these are \
5422 filesystem paths outside the workdir"
5423 ),
5424 });
5425 }
5426
5427 let list_dir =
5428 raw_dir
5429 .list_dir
5430 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5431 field: format!("{dir_base}.list_dir"),
5432 message: format!(
5433 "'{path}' must set list_dir explicitly to true or false — \
5434 enumerability is never inferred"
5435 ),
5436 })?;
5437
5438 dirs.push(InterpreterRuntimeDir { path, list_dir });
5439 }
5440
5441 grants.push(InterpreterRuntimeGrant { binary, dirs });
5442 }
5443
5444 Ok(grants)
5445}
5446
5447fn parse_staged_runtime(
5468 allow: &[String],
5469 interpreter_runtime: &[InterpreterRuntimeGrant],
5470 raw_grants: Vec<RawStagedRuntimeGrant>,
5471) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
5472 let mut grants = Vec::with_capacity(raw_grants.len());
5473 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5474 let base = format!("capabilities.shell.staged_runtime[{index}]");
5475
5476 let binary = raw_grant
5477 .binary
5478 .filter(|b| !b.trim().is_empty())
5479 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5480 field: format!("{base}.binary"),
5481 message: "must name a binary".to_string(),
5482 })?;
5483
5484 if !allow.contains(&binary) {
5485 return Err(RuntimeManifestError::InvalidCapabilities {
5486 field: format!("{base}.binary"),
5487 message: format!(
5488 "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
5489 stage the runtime tree behind an already-allowlisted binary, never grant exec"
5490 ),
5491 });
5492 }
5493
5494 if interpreter_runtime
5495 .iter()
5496 .any(|grant| grant.binary == binary)
5497 {
5498 return Err(RuntimeManifestError::InvalidCapabilities {
5499 field: format!("{base}.binary"),
5500 message: format!(
5501 "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
5502 are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
5503 tree into the capsule's own root, which is what makes widening the capsule's \
5504 host filesystem scope unnecessary rather than something to add to it. Remove \
5505 the interpreter_runtime grant for '{binary}'"
5506 ),
5507 });
5508 }
5509
5510 let source_path = raw_grant
5511 .source_path
5512 .filter(|p| !p.trim().is_empty())
5513 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5514 field: format!("{base}.source_path"),
5515 message: "must name the host directory holding the pinned runtime tree".to_string(),
5516 })?;
5517 if !source_path.starts_with('/') {
5518 return Err(RuntimeManifestError::InvalidCapabilities {
5519 field: format!("{base}.source_path"),
5520 message: format!(
5521 "'{source_path}' must be an absolute host path (start with '/') — \
5522 staged-runtime sources are host filesystem paths outside the workdir"
5523 ),
5524 });
5525 }
5526
5527 let pin = raw_grant
5528 .pin
5529 .map(|p| p.trim().to_string())
5530 .filter(|p| !p.is_empty())
5531 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5532 field: format!("{base}.pin"),
5533 message: format!(
5534 "'{source_path}' must declare a non-empty pin identifying which build this \
5535 tree is — it is never inferred, because it is the value a human compares \
5536 across two hosts to confirm the same runtime shipped to both"
5537 ),
5538 })?;
5539
5540 grants.push(StagedRuntimeGrant {
5541 binary,
5542 source_path,
5543 pin,
5544 });
5545 }
5546
5547 Ok(grants)
5548}
5549
5550fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
5555 let reject_zero_usize =
5556 |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
5557 match value {
5558 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5559 field: format!("capabilities.limits.{field}"),
5560 message: "must be greater than zero".to_string(),
5561 }),
5562 _ => Ok(()),
5563 }
5564 };
5565
5566 reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
5567 reject_zero_usize(raw.table_elements, "table_elements")?;
5568 reject_zero_usize(raw.instances, "instances")?;
5569 if raw.deadline_seconds == Some(0) {
5570 return Err(RuntimeManifestError::InvalidCapabilities {
5571 field: "capabilities.limits.deadline_seconds".to_string(),
5572 message: "must be greater than zero".to_string(),
5573 });
5574 }
5575
5576 Ok(ResourceLimits {
5577 memory_bytes: raw.memory_bytes,
5578 table_elements: raw.table_elements,
5579 instances: raw.instances,
5580 deadline_seconds: raw.deadline_seconds,
5581 })
5582}
5583
5584fn parse_resource_capabilities(
5590 raw: RawResourceCapabilities,
5591) -> Result<ResourceCapabilities, RuntimeManifestError> {
5592 let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
5593 match value {
5594 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5595 field: format!("capabilities.resources.{field}"),
5596 message: "must be greater than zero".to_string(),
5597 }),
5598 _ => Ok(()),
5599 }
5600 };
5601
5602 reject_zero(raw.max_processes, "max_processes")?;
5603 reject_zero(raw.max_open_files, "max_open_files")?;
5604 reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
5605 reject_zero(raw.cpu_seconds, "cpu_seconds")?;
5606 reject_zero(raw.memory_bytes, "memory_bytes")?;
5607 reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
5608 reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
5609 reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
5610 reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
5611 reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
5612
5613 Ok(ResourceCapabilities {
5614 max_processes: raw.max_processes,
5615 max_open_files: raw.max_open_files,
5616 max_file_size_bytes: raw.max_file_size_bytes,
5617 cpu_seconds: raw.cpu_seconds,
5618 memory_bytes: raw.memory_bytes,
5619 cgroup_memory_bytes: raw.cgroup_memory_bytes,
5620 cgroup_pids_max: raw.cgroup_pids_max,
5621 cgroup_cpu_percent: raw.cgroup_cpu_percent,
5622 cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
5623 workdir_max_bytes: raw.workdir_max_bytes,
5624 })
5625}
5626
5627const PROCESS_DRIVER_REQUIRED: &str =
5631 "missing required field; a transport: process capsule must name the process driver that \
5632 knows how to drive its harness. inference.command no longer selects a harness — it only \
5633 overrides the binary the driver names. Add inference.driver.artifact naming a process \
5634 driver, for example murmur/driver-claude-code";
5635
5636fn parse_inference(
5637 raw: Option<RawInferenceConfig>,
5638) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
5639 let Some(raw) = raw else {
5640 return Ok(None);
5641 };
5642
5643 let transport = raw
5644 .transport
5645 .map(|s| s.trim().to_string())
5646 .filter(|s| !s.is_empty())
5647 .unwrap_or_else(|| "http".to_string());
5648
5649 let compaction = parse_compaction(raw.compaction)?;
5650 let system_prompt = optional_trimmed_string(raw.system_prompt);
5651 let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
5652 let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
5653 let instruction_files = raw
5654 .instruction_files
5655 .map(parse_instruction_files)
5656 .transpose()?;
5657
5658 let prompt_sources_set = [
5659 system_prompt.is_some(),
5660 system_prompt_file.is_some(),
5661 system_prompt_artifact.is_some(),
5662 ]
5663 .iter()
5664 .filter(|&&b| b)
5665 .count();
5666 if prompt_sources_set > 1 {
5667 return Err(RuntimeManifestError::InvalidInferenceConfig {
5668 field: "inference.system_prompt".to_string(),
5669 message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
5670 may be set"
5671 .to_string(),
5672 });
5673 }
5674
5675 if raw.max_turns == Some(0) {
5676 return Err(RuntimeManifestError::InvalidInferenceConfig {
5677 field: "inference.max_turns".to_string(),
5678 message: "must be greater than 0".to_string(),
5679 });
5680 }
5681 let max_turns = raw.max_turns;
5682
5683 if raw.max_task_reopens.is_some() {
5687 return Err(RuntimeManifestError::InvalidInferenceConfig {
5688 field: "inference.max_task_reopens".to_string(),
5689 message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
5690 .to_string(),
5691 });
5692 }
5693
5694 for (present, removed, moved_to) in [
5698 (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
5699 (raw.api_key.is_some(), "api_key", "gateway.api_key"),
5700 ] {
5701 if !present {
5702 continue;
5703 }
5704 let entry = raw
5705 .driver
5706 .as_ref()
5707 .and_then(|driver| driver.artifact.as_deref())
5708 .map(str::trim)
5709 .filter(|name| !name.is_empty())
5710 .map(|name| format!("on the artifacts: entry '{name}'"))
5711 .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
5712 return Err(RuntimeManifestError::InvalidInferenceConfig {
5713 field: format!("inference.{removed}"),
5714 message: format!("was removed; set {moved_to} {entry} instead"),
5715 });
5716 }
5717
5718 match transport.as_str() {
5719 "http" => {
5720 let model = required_inference_field(raw.model, "model")?;
5721
5722 let driver_raw =
5723 raw.driver
5724 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5725 field: "inference.driver".to_string(),
5726 message: "missing required field".to_string(),
5727 })?;
5728 let driver = parse_inference_driver(driver_raw)?;
5729
5730 if raw.max_tokens == Some(0) {
5734 return Err(RuntimeManifestError::InvalidInferenceConfig {
5735 field: "inference.max_tokens".to_string(),
5736 message: "must be greater than 0".to_string(),
5737 });
5738 }
5739 if raw.max_session_tokens == Some(0) {
5740 return Err(RuntimeManifestError::InvalidInferenceConfig {
5741 field: "inference.max_session_tokens".to_string(),
5742 message: "must be greater than 0".to_string(),
5743 });
5744 }
5745 let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
5746 let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
5747 let retry = parse_inference_retry(raw.retry)?;
5748
5749 Ok(Some(InferenceConfig {
5750 transport,
5751 model,
5752 driver: Some(driver),
5753 command: None,
5754 compaction,
5755 system_prompt,
5756 system_prompt_file,
5757 system_prompt_artifact,
5758 instruction_files,
5759 max_turns,
5760 max_tokens: raw.max_tokens,
5761 max_session_tokens: raw.max_session_tokens,
5762 tool_refresh,
5763 alternates,
5764 retry,
5765 }))
5766 }
5767 "process" => {
5768 if raw.max_tokens.is_some() {
5771 return Err(RuntimeManifestError::InvalidInferenceConfig {
5772 field: "inference.max_tokens".to_string(),
5773 message: "is not valid with transport: process".to_string(),
5774 });
5775 }
5776 if raw.tool_refresh.is_some() {
5779 return Err(RuntimeManifestError::InvalidInferenceConfig {
5780 field: "inference.tool_refresh".to_string(),
5781 message: "is not valid with transport: process".to_string(),
5782 });
5783 }
5784 for (present, key) in [
5787 (
5788 compaction.as_ref().is_some_and(|c| c.max_tokens.is_some()),
5789 "max_tokens",
5790 ),
5791 (
5792 compaction
5793 .as_ref()
5794 .is_some_and(|c| c.driver_config.is_some()),
5795 "driver_config",
5796 ),
5797 ] {
5798 if present {
5799 return Err(RuntimeManifestError::InvalidInferenceConfig {
5800 field: format!("inference.compaction.{key}"),
5801 message: "is not valid with transport: process".to_string(),
5802 });
5803 }
5804 }
5805 if raw.alternates.is_some() {
5808 return Err(RuntimeManifestError::InvalidInferenceConfig {
5809 field: "inference.alternates".to_string(),
5810 message: "is not valid with transport: process; the harness owns the \
5811 conversation and the model it runs"
5812 .to_string(),
5813 });
5814 }
5815 if raw.retry.is_some() {
5816 return Err(RuntimeManifestError::InvalidInferenceConfig {
5817 field: "inference.retry".to_string(),
5818 message: "is not valid with transport: process; the harness retries its own \
5819 provider calls"
5820 .to_string(),
5821 });
5822 }
5823
5824 if raw.max_session_tokens == Some(0) {
5828 return Err(RuntimeManifestError::InvalidInferenceConfig {
5829 field: "inference.max_session_tokens".to_string(),
5830 message: "must be greater than 0".to_string(),
5831 });
5832 }
5833
5834 let driver = raw.driver.map(parse_inference_driver).transpose()?;
5835 let command = optional_trimmed_string(raw.command);
5836 if driver.is_none() {
5840 return Err(RuntimeManifestError::InvalidInferenceConfig {
5841 field: "inference.driver.artifact".to_string(),
5842 message: PROCESS_DRIVER_REQUIRED.to_string(),
5843 });
5844 }
5845 let model = optional_trimmed_string(raw.model).unwrap_or_default();
5849
5850 Ok(Some(InferenceConfig {
5851 transport,
5852 model,
5853 driver,
5854 command,
5855 compaction,
5856 system_prompt,
5857 system_prompt_file,
5858 system_prompt_artifact,
5859 instruction_files,
5860 max_turns,
5861 max_tokens: None,
5862 max_session_tokens: raw.max_session_tokens,
5863 tool_refresh: ToolRefresh::default(),
5864 alternates: Vec::new(),
5865 retry: InferenceRetry::default(),
5866 }))
5867 }
5868 other => Err(RuntimeManifestError::InvalidInferenceConfig {
5869 field: "inference.transport".to_string(),
5870 message: format!("unknown value '{other}'"),
5871 }),
5872 }
5873}
5874
5875fn parse_inference_retry(
5879 raw: Option<RawInferenceRetry>,
5880) -> Result<InferenceRetry, RuntimeManifestError> {
5881 let defaults = InferenceRetry::default();
5882 let Some(raw) = raw else {
5883 return Ok(defaults);
5884 };
5885 let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5886 field: format!("inference.retry.{key}"),
5887 message,
5888 };
5889 let retry = InferenceRetry {
5890 max_retries: raw.max_retries.unwrap_or(defaults.max_retries),
5891 initial_delay_ms: raw.initial_delay_ms.unwrap_or(defaults.initial_delay_ms),
5892 max_delay_ms: raw.max_delay_ms.unwrap_or(defaults.max_delay_ms),
5893 };
5894 if retry.max_retries > InferenceRetry::MAX_RETRIES {
5895 return Err(refuse(
5896 "max_retries",
5897 format!("must be at most {}", InferenceRetry::MAX_RETRIES),
5898 ));
5899 }
5900 if !(1..=InferenceRetry::MAX_INITIAL_DELAY_MS).contains(&retry.initial_delay_ms) {
5901 return Err(refuse(
5902 "initial_delay_ms",
5903 format!(
5904 "must be between 1 and {}",
5905 InferenceRetry::MAX_INITIAL_DELAY_MS
5906 ),
5907 ));
5908 }
5909 if !(1..=InferenceRetry::MAX_DELAY_MS).contains(&retry.max_delay_ms) {
5910 return Err(refuse(
5911 "max_delay_ms",
5912 format!("must be between 1 and {}", InferenceRetry::MAX_DELAY_MS),
5913 ));
5914 }
5915 if retry.max_delay_ms < retry.initial_delay_ms {
5916 return Err(refuse(
5917 "max_delay_ms",
5918 format!(
5919 "must be at least inference.retry.initial_delay_ms ({})",
5920 retry.initial_delay_ms
5921 ),
5922 ));
5923 }
5924 Ok(retry)
5925}
5926
5927fn is_valid_driver_choice_name(name: &str) -> bool {
5930 let mut chars = name.chars();
5931 chars.next().is_some_and(|first| first.is_ascii_lowercase())
5932 && name.len() <= 63
5933 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
5934}
5935
5936fn parse_alternates(
5942 raw: Option<Vec<RawInferenceAlternate>>,
5943 primary_driver: &str,
5944 primary_model: &str,
5945) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
5946 let mut alternates: Vec<InferenceAlternate> = Vec::new();
5947 for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
5948 let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5949 field: if key.is_empty() {
5950 format!("inference.alternates[{index}]")
5951 } else {
5952 format!("inference.alternates[{index}].{key}")
5953 },
5954 message,
5955 };
5956 let name = optional_trimmed_string(entry.name)
5957 .ok_or_else(|| refuse("name", "missing required field".to_string()))?;
5958 if name == PRIMARY_DRIVER_CHOICE {
5959 return Err(refuse(
5960 "name",
5961 format!(
5962 "'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
5963 inference.model; name the alternate something else"
5964 ),
5965 ));
5966 }
5967 if !is_valid_driver_choice_name(&name) {
5968 return Err(refuse(
5969 "name",
5970 format!(
5971 "'{name}' is not a driver choice name; a name is a lowercase letter followed \
5972 by up to 62 lowercase letters, digits and hyphens"
5973 ),
5974 ));
5975 }
5976 if alternates.iter().any(|alternate| alternate.name == name) {
5977 return Err(refuse(
5978 "name",
5979 format!("'{name}' names more than one alternate"),
5980 ));
5981 }
5982 let model = optional_trimmed_string(entry.model)
5983 .ok_or_else(|| refuse("model", "missing required field".to_string()))?;
5984 let Some(driver) = entry.driver else {
5985 return Err(refuse(
5986 "driver.artifact",
5987 "missing required field".to_string(),
5988 ));
5989 };
5990 if driver
5991 .config
5992 .as_ref()
5993 .is_some_and(|config| !matches!(config, serde_norway::Value::Null))
5994 {
5995 return Err(refuse(
5996 "driver.config",
5997 "is not valid on an alternate; inference.driver.config applies to every \
5998 declared driver, and per-driver settings belong in that driver's \
5999 artifacts[].config"
6000 .to_string(),
6001 ));
6002 }
6003 let driver = optional_trimmed_string(driver.artifact)
6004 .ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
6005 if driver == primary_driver && model == primary_model {
6006 return Err(refuse(
6007 "",
6008 format!(
6009 "'{name}' is driver '{driver}' with model '{model}', which is the primary \
6010 choice; an alternate names a different pair"
6011 ),
6012 ));
6013 }
6014 if let Some(twin) = alternates
6015 .iter()
6016 .find(|alternate| alternate.driver == driver && alternate.model == model)
6017 {
6018 return Err(refuse(
6019 "",
6020 format!(
6021 "'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
6022 already is; an alternate names a different pair",
6023 twin.name
6024 ),
6025 ));
6026 }
6027 alternates.push(InferenceAlternate {
6028 name,
6029 model,
6030 driver,
6031 });
6032 }
6033 Ok(alternates)
6034}
6035
6036fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
6038 let Some(value) = raw else {
6039 return Ok(ToolRefresh::default());
6040 };
6041 ToolRefresh::ALL
6042 .into_iter()
6043 .find(|refresh| refresh.wire_name() == value)
6044 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6045 field: "inference.tool_refresh".to_string(),
6046 message: format!(
6047 "must be one of: {}; got '{value}'",
6048 ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
6049 ),
6050 })
6051}
6052
6053fn parse_context(
6054 raw: Option<RawContextConfig>,
6055) -> Result<Option<ContextConfig>, RuntimeManifestError> {
6056 let Some(raw) = raw else {
6057 return Ok(None);
6058 };
6059
6060 if let Some(max_tokens) = raw.max_tokens {
6061 if max_tokens == 0 {
6062 return Err(RuntimeManifestError::InvalidInferenceConfig {
6063 field: "context.max_tokens".to_string(),
6064 message: "must be greater than 0".to_string(),
6065 });
6066 }
6067 }
6068
6069 let fraction = |field: &str, value: Option<f32>, default: f32| match value {
6070 None => Ok(default),
6071 Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
6072 Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
6073 field: field.to_string(),
6074 message: "must be between 0.0 and 1.0".to_string(),
6075 }),
6076 };
6077
6078 let record = match raw.record.as_deref().map(str::trim) {
6079 None => true,
6080 Some("on") => true,
6081 Some("off") => false,
6082 Some(other) => {
6083 return Err(RuntimeManifestError::InvalidInferenceConfig {
6084 field: "context.record".to_string(),
6085 message: format!("unknown value '{other}'; expected: on, off"),
6086 })
6087 }
6088 };
6089
6090 Ok(Some(ContextConfig {
6091 max_tokens: raw.max_tokens,
6092 record,
6093 record_store: raw.record_store.map(|store| store.trim().to_string()),
6094 seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
6095 seed_overflow_margin: fraction(
6096 "context.seed_overflow_margin",
6097 raw.seed_overflow_margin,
6098 DEFAULT_SEED_OVERFLOW_MARGIN,
6099 )?,
6100 retain: parse_context_retain(raw.retain)?,
6101 images: parse_context_images(raw.images)?,
6102 }))
6103}
6104
6105fn parse_context_images(
6107 raw: Option<RawContextImages>,
6108) -> Result<ContextImagesConfig, RuntimeManifestError> {
6109 let defaults = ContextImagesConfig::default();
6110 let Some(raw) = raw else {
6111 return Ok(defaults);
6112 };
6113 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
6114 field: field.to_string(),
6115 message,
6116 };
6117 let count = |field: &str, value: Option<serde_norway::Value>, default: u32| match value {
6118 None => Ok(default),
6119 Some(value) => value
6120 .as_u64()
6121 .and_then(|n| u32::try_from(n).ok())
6122 .filter(|n| *n >= 1)
6123 .ok_or_else(|| {
6124 let message = match scalar_text(&value) {
6125 Some(text) => format!("must be a whole number of at least 1, got '{text}'"),
6126 None => "must be a whole number of at least 1".to_string(),
6127 };
6128 invalid(field, message)
6129 }),
6130 };
6131 let max_bytes = match raw.max_bytes {
6132 None => defaults.max_bytes,
6133 Some(value) => {
6134 let field = "context.images.max_bytes";
6135 let text = scalar_text(&value)
6136 .ok_or_else(|| invalid(field, format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}")))?;
6137 let parsed = parse_byte_size(&text).map_err(|message| invalid(field, message))?;
6138 if parsed == 0 {
6139 return Err(invalid(
6140 field,
6141 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
6142 ));
6143 }
6144 parsed
6145 }
6146 };
6147 Ok(ContextImagesConfig {
6148 keep: count("context.images.keep", raw.keep, defaults.keep)?,
6149 max_bytes,
6150 max_per_turn: count(
6151 "context.images.max_per_turn",
6152 raw.max_per_turn,
6153 defaults.max_per_turn,
6154 )?,
6155 })
6156}
6157
6158const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
6160
6161const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
6163
6164const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
6167 keep everything, which is what no policy means";
6168
6169fn parse_retain_block(
6175 block: &str,
6176 count_key: &str,
6177 accepted: &[&str],
6178 raw: serde_norway::Value,
6179 invalid: &dyn Fn(String, String) -> RuntimeManifestError,
6180) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
6181 let mapping = match raw {
6182 serde_norway::Value::Mapping(mapping) => mapping,
6183 serde_norway::Value::Null => {
6185 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
6186 }
6187 other => {
6188 return Err(invalid(
6189 block.to_string(),
6190 format!(
6191 "must be a block of keys ({}), got '{other:?}'",
6192 accepted.join(", ")
6193 ),
6194 ))
6195 }
6196 };
6197 if mapping.is_empty() {
6198 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
6199 }
6200
6201 let mut count: Option<u32> = None;
6202 let mut max_age_secs: Option<u64> = None;
6203 for (key, value) in &mapping {
6204 let key = key.as_str().unwrap_or_default();
6205 let field = format!("{block}.{key}");
6206 if key == count_key {
6207 let parsed = value
6208 .as_u64()
6209 .and_then(|n| u32::try_from(n).ok())
6210 .ok_or_else(|| {
6211 invalid(
6212 field.clone(),
6213 "must be a whole number of at least 1".to_string(),
6214 )
6215 })?;
6216 if parsed == 0 {
6217 return Err(invalid(
6218 field,
6219 "must be at least 1 — a limit of zero would delete what retention exists \
6220 to keep; remove the key to leave it unbounded"
6221 .to_string(),
6222 ));
6223 }
6224 count = Some(parsed);
6225 } else if key == "max_age" {
6226 let text = scalar_text(value).ok_or_else(|| {
6227 invalid(
6228 field.clone(),
6229 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
6230 )
6231 })?;
6232 let parsed =
6233 parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
6234 if parsed == 0 {
6235 return Err(invalid(
6236 field,
6237 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
6238 ));
6239 }
6240 max_age_secs = Some(parsed);
6241 } else {
6242 return Err(invalid(
6243 block.to_string(),
6244 format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
6245 ));
6246 }
6247 }
6248 Ok((count, max_age_secs))
6249}
6250
6251fn parse_trace_retain(
6252 raw: Option<serde_norway::Value>,
6253) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
6254 let Some(raw) = raw else {
6255 return Ok(None);
6256 };
6257 let (max_sessions, max_age_secs) = parse_retain_block(
6258 "trace.retain",
6259 "max_sessions",
6260 &TRACE_RETAIN_KEYS,
6261 raw,
6262 &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
6263 )?;
6264 Ok(Some(TraceRetainConfig {
6265 max_sessions,
6266 max_age_secs,
6267 }))
6268}
6269
6270fn parse_context_retain(
6271 raw: Option<serde_norway::Value>,
6272) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
6273 let Some(raw) = raw else {
6274 return Ok(None);
6275 };
6276 let (max_messages, max_age_secs) = parse_retain_block(
6277 "context.retain",
6278 "max_messages",
6279 &CONTEXT_RETAIN_KEYS,
6280 raw,
6281 &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
6282 )?;
6283 Ok(Some(ContextRetainConfig {
6284 max_messages,
6285 max_age_secs,
6286 }))
6287}
6288
6289fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
6290 raw.map(|raw| ObservabilityConfig {
6291 otel_endpoint: raw
6292 .otel_endpoint
6293 .map(|value| value.trim().to_string())
6294 .filter(|value| !value.is_empty()),
6295 eval: raw.eval.map(parse_eval_config),
6296 })
6297}
6298
6299fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
6300 let scorers = raw
6301 .scorers
6302 .into_iter()
6303 .filter_map(|raw| {
6304 let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
6305 match raw.scorer_type.as_str() {
6306 "exit_ok" => Some(ScorerConfig::ExitOk { name }),
6307 "max_turns" => Some(ScorerConfig::MaxTurns {
6308 name,
6309 max: raw.max.unwrap_or(10) as u32,
6310 }),
6311 "max_tokens" => Some(ScorerConfig::MaxTokens {
6312 name,
6313 max: raw.max.unwrap_or(100_000),
6314 }),
6315 "tool_sequence" => Some(ScorerConfig::ToolSequence {
6316 name,
6317 expected: raw.expected.unwrap_or_default(),
6318 }),
6319 "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
6320 other => {
6321 crate::warn_line(format_args!(
6322 "[murmur-artifact] unknown scorer type '{other}' — skipping"
6323 ));
6324 None
6325 }
6326 }
6327 })
6328 .collect();
6329
6330 EvalConfig {
6331 dataset_id: raw.dataset_id,
6332 scorers,
6333 }
6334}
6335
6336fn parse_compaction(
6337 raw: Option<RawCompactionConfig>,
6338) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
6339 let Some(raw) = raw else {
6340 return Ok(None);
6341 };
6342
6343 if let Some(threshold) = raw.threshold {
6344 if threshold <= 0.0 || threshold > 1.0 {
6345 return Err(RuntimeManifestError::InvalidInferenceConfig {
6346 field: "inference.compaction.threshold".to_string(),
6347 message: format!("must be in (0.0, 1.0] but got {threshold}"),
6348 });
6349 }
6350 }
6351
6352 if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
6353 return Err(RuntimeManifestError::InvalidInferenceConfig {
6354 field: "inference.compaction.system_prompt".to_string(),
6355 message: "at most one of inference.compaction.system_prompt, \
6356 inference.compaction.system_prompt_file may be set"
6357 .to_string(),
6358 });
6359 }
6360
6361 if raw.max_tokens == Some(0) {
6362 return Err(RuntimeManifestError::InvalidInferenceConfig {
6363 field: "inference.compaction.max_tokens".to_string(),
6364 message: "must be greater than 0".to_string(),
6365 });
6366 }
6367 let driver_config =
6368 driver_config_json(raw.driver_config, "inference.compaction.driver_config")?;
6369
6370 Ok(Some(CompactionConfig {
6371 threshold: raw.threshold,
6372 model: raw.model,
6373 system_prompt: raw.system_prompt,
6374 system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
6375 dump_summaries: raw.dump_summaries,
6378 max_tokens: raw.max_tokens,
6379 driver_config,
6380 }))
6381}
6382
6383fn optional_trimmed_string(value: Option<String>) -> Option<String> {
6384 value
6385 .map(|s| s.trim().to_string())
6386 .filter(|s| !s.is_empty())
6387}
6388
6389fn required_inference_field(
6390 value: Option<String>,
6391 key: &str,
6392) -> Result<String, RuntimeManifestError> {
6393 value
6394 .map(|value| value.trim().to_string())
6395 .filter(|value| !value.is_empty())
6396 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6397 field: format!("inference.{key}"),
6398 message: "missing required field".to_string(),
6399 })
6400}
6401
6402fn parse_artifact_gateway(
6410 index: usize,
6411 name: &str,
6412 raw: Option<RawArtifactGateway>,
6413 secrets: SecretResolution,
6414) -> Result<ArtifactGateway, RuntimeManifestError> {
6415 let (endpoint, api_key, keyless) = match raw {
6416 Some(raw) => (
6417 optional_trimmed_string(raw.endpoint),
6418 raw.api_key,
6419 raw.keyless,
6420 ),
6421 None => (None, None, None),
6422 };
6423 let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
6424 index,
6425 message: format!(
6426 "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
6427 the upstream URL the runtime sends this artifact's keyed requests to"
6428 ),
6429 })?;
6430 validate_gateway_endpoint(name, &endpoint)
6431 .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
6432
6433 let keyless = match keyless {
6434 None | Some(serde_norway::Value::Null) | Some(serde_norway::Value::Bool(false)) => false,
6435 Some(serde_norway::Value::Bool(true)) => true,
6436 Some(_) => {
6437 return Err(RuntimeManifestError::InvalidArtifact {
6438 index,
6439 message: format!(
6440 "artifact '{name}' declares gateway.keyless with a value that is not a \
6441 boolean; write keyless: true for an upstream that takes no key"
6442 ),
6443 })
6444 }
6445 };
6446 if keyless && api_key.is_some() {
6447 return Err(RuntimeManifestError::InvalidArtifact {
6448 index,
6449 message: format!(
6450 "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
6451 gateway either presents a key or is keyless"
6452 ),
6453 });
6454 }
6455 if keyless {
6456 return Ok(ArtifactGateway {
6457 endpoint,
6458 api_key: None,
6459 keyless: true,
6460 });
6461 }
6462
6463 let written = api_key
6464 .flatten()
6465 .map(|value| value.trim().to_string())
6466 .filter(|value| !value.is_empty());
6467 let Some(written) = written else {
6468 return Err(RuntimeManifestError::GatewayWithoutCredential {
6469 index,
6470 name: name.to_string(),
6471 endpoint,
6472 });
6473 };
6474 let api_key = match secrets {
6475 SecretResolution::Skip => None,
6476 SecretResolution::Resolve => Some(match parse_env_reference(&written) {
6477 Some(variable) => ApiKeyReference::Environment(variable.to_string()),
6478 None => ApiKeyReference::Literal(written),
6479 }),
6480 };
6481
6482 Ok(ArtifactGateway {
6483 endpoint,
6484 api_key,
6485 keyless: false,
6486 })
6487}
6488
6489fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
6516 let shown = mask_endpoint_userinfo(endpoint);
6517 let refuse =
6518 |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
6519 if endpoint.contains("${") {
6520 return Err(refuse(
6521 "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
6522 literally"
6523 .to_string(),
6524 ));
6525 }
6526 let url = Url::parse(endpoint)
6527 .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
6528 let host = match url.scheme() {
6529 "http" | "https" => url
6530 .host_str()
6531 .filter(|host| !host.is_empty())
6532 .ok_or_else(|| refuse("which has no host".to_string()))?,
6533 other => {
6534 return Err(refuse(format!(
6535 "whose scheme '{other}' is unsupported — expected http or https"
6536 )))
6537 }
6538 };
6539 if !url.username().is_empty() || url.password().is_some() {
6540 return Err(refuse(format!(
6541 "which carries userinfo before '@' — the gateway never sends it, and this URL \
6542 addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
6543 the key through gateway.api_key"
6544 )));
6545 }
6546 if url.query().is_some() {
6547 return Err(refuse(
6548 "which carries a query — the gateway keeps each request's own query, so write the \
6549 upstream without one"
6550 .to_string(),
6551 ));
6552 }
6553 if url.fragment().is_some() {
6554 return Err(refuse(
6555 "which carries a fragment — write the upstream without one".to_string(),
6556 ));
6557 }
6558 if url.scheme() == "http" {
6559 let bare = host.trim_start_matches('[').trim_end_matches(']');
6560 let is_loopback = bare == "localhost"
6561 || bare
6562 .parse::<IpAddr>()
6563 .map(|ip| ip.is_loopback())
6564 .unwrap_or(false);
6565 if !is_loopback {
6566 return Err(refuse(format!(
6567 "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
6568 remote upstream, or http://localhost or a loopback IP literal for a local one"
6569 )));
6570 }
6571 }
6572 Ok(())
6573}
6574
6575fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
6579 let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
6580 let rest = &endpoint[authority_start..];
6581 let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
6582 match rest[..authority_len].rfind('@') {
6583 Some(at) => format!(
6584 "{}***{}",
6585 &endpoint[..authority_start],
6586 &endpoint[authority_start + at..]
6587 )
6588 .into(),
6589 None => endpoint.into(),
6590 }
6591}
6592
6593fn validate_driver_gateways(
6600 artifacts: &[RuntimeArtifact],
6601 inference: Option<&InferenceConfig>,
6602) -> Result<(), RuntimeManifestError> {
6603 let configured_driver = inference
6604 .filter(|inference| inference.transport == "http")
6605 .and_then(|inference| inference.driver.as_ref())
6606 .map(|driver| driver.artifact.as_str());
6607 let alternate_drivers: Vec<&str> = inference
6608 .map(|inference| {
6609 inference
6610 .alternates
6611 .iter()
6612 .map(|alternate| alternate.driver.as_str())
6613 .collect()
6614 })
6615 .unwrap_or_default();
6616 let under_process = inference.is_some_and(|inference| inference.transport == "process");
6617
6618 if let Some(driver) = configured_driver {
6621 if !artifacts.iter().any(|artifact| artifact.name == driver) {
6622 return Err(RuntimeManifestError::InvalidInferenceConfig {
6623 field: "inference.driver.artifact".to_string(),
6624 message: not_declared_message(artifacts, driver),
6625 });
6626 }
6627 }
6628
6629 for (index, artifact) in artifacts.iter().enumerate() {
6630 if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
6631 continue;
6632 }
6633 if under_process {
6634 return Err(RuntimeManifestError::InvalidArtifact {
6635 index,
6636 message: format!(
6637 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
6638 valid with transport: process — the CLI subprocess holds its own \
6639 credentials and a process driver is granted nothing",
6640 artifact.name
6641 ),
6642 });
6643 }
6644 if configured_driver != Some(artifact.name.as_str())
6645 && !alternate_drivers.contains(&artifact.name.as_str())
6646 {
6647 return Err(RuntimeManifestError::InvalidArtifact {
6648 index,
6649 message: format!(
6650 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
6651 the configured inference driver; a driver's gateway is accepted only on the \
6652 entry inference.driver.artifact names under transport: http, or on one an \
6653 inference.alternates entry names",
6654 artifact.name
6655 ),
6656 });
6657 }
6658 }
6659
6660 if let Some(driver) = configured_driver {
6661 let Some((index, entry)) = artifacts
6662 .iter()
6663 .enumerate()
6664 .find(|(_, artifact)| artifact.name == driver)
6665 else {
6666 return Err(RuntimeManifestError::InvalidInferenceConfig {
6667 field: "inference.driver.artifact".to_string(),
6668 message: not_declared_message(artifacts, driver),
6669 });
6670 };
6671 if entry.gateway.is_none() {
6672 return Err(RuntimeManifestError::InvalidArtifact {
6673 index,
6674 message: format!(
6675 "artifact '{driver}' is the transport: http inference driver but declares no \
6676 gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
6677 on its entry"
6678 ),
6679 });
6680 }
6681 }
6682 Ok(())
6683}
6684
6685fn validate_driver_choices(
6691 artifacts: &[RuntimeArtifact],
6692 inference: Option<&InferenceConfig>,
6693) -> Result<(), RuntimeManifestError> {
6694 let Some(inference) = inference else {
6695 return Ok(());
6696 };
6697 let primary_driver = inference
6698 .driver
6699 .as_ref()
6700 .map(|driver| driver.artifact.as_str());
6701 for (index, alternate) in inference.alternates.iter().enumerate() {
6702 let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
6703 field: format!("inference.alternates[{index}].driver.artifact"),
6704 message,
6705 };
6706 let driver = alternate.driver.as_str();
6707 let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
6708 return Err(refuse(not_declared_message(artifacts, driver)));
6709 };
6710 if entry.runtime != ArtifactRuntime::Driver {
6711 return Err(refuse(format!(
6712 "artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
6713 runtime: driver entry",
6714 entry.runtime.as_str()
6715 )));
6716 }
6717 if entry.gateway.is_none() && primary_driver != Some(driver) {
6718 return Err(refuse(format!(
6719 "artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
6720 its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
6721 URL>, api_key: ... }}' on its entry"
6722 )));
6723 }
6724 }
6725 Ok(())
6726}
6727
6728fn validate_alternates_selectable(
6731 inference: Option<&InferenceConfig>,
6732 control: Option<&ControlConfig>,
6733) -> Result<(), RuntimeManifestError> {
6734 if inference.is_none_or(|inference| inference.alternates.is_empty()) {
6735 return Ok(());
6736 }
6737 let selectable = control.is_some_and(|control| {
6738 control.controller_may_set(ControllableSetting::InferenceDriver)
6739 || control.agent_may_set(ControllableSetting::InferenceDriver)
6740 });
6741 if selectable {
6742 return Ok(());
6743 }
6744 Err(RuntimeManifestError::InvalidInferenceConfig {
6745 field: "inference.alternates".to_string(),
6746 message: "declares alternates that nothing could ever select; list inference.driver in \
6747 control.settings for a controller to switch, or in control.agent_settings for \
6748 the agent to"
6749 .to_string(),
6750 })
6751}
6752
6753fn validate_process_driver_entry(
6761 artifacts: &[RuntimeArtifact],
6762 inference: Option<&InferenceConfig>,
6763) -> Result<(), RuntimeManifestError> {
6764 let Some(driver) = inference
6765 .filter(|inference| inference.transport == "process")
6766 .and_then(|inference| inference.driver.as_ref())
6767 .map(|driver| driver.artifact.as_str())
6768 else {
6769 return Ok(());
6770 };
6771 let Some((index, entry)) = artifacts
6772 .iter()
6773 .enumerate()
6774 .find(|(_, artifact)| artifact.name == driver)
6775 else {
6776 return Err(RuntimeManifestError::InvalidInferenceConfig {
6777 field: "inference.driver.artifact".to_string(),
6778 message: not_declared_message(artifacts, driver),
6779 });
6780 };
6781 if entry.runtime != ArtifactRuntime::Driver {
6782 return Err(RuntimeManifestError::InvalidArtifact {
6783 index,
6784 message: format!(
6785 "artifact '{driver}' is the transport: process driver and must be declared with \
6786 runtime: driver"
6787 ),
6788 });
6789 }
6790 if entry.capabilities.is_some() || entry.config.is_some() {
6791 return Err(RuntimeManifestError::InvalidArtifact {
6792 index,
6793 message: format!(
6794 "artifact '{driver}' is the transport: process driver, which is granted nothing; \
6795 remove capabilities:/config: from its entry — driver settings go in \
6796 inference.driver.config"
6797 ),
6798 });
6799 }
6800 Ok(())
6801}
6802
6803fn parse_inference_driver(
6804 raw: RawInferenceDriver,
6805) -> Result<InferenceDriver, RuntimeManifestError> {
6806 let artifact = raw
6807 .artifact
6808 .map(|value| value.trim().to_string())
6809 .filter(|value| !value.is_empty())
6810 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6811 field: "inference.driver.artifact".to_string(),
6812 message: "missing required field".to_string(),
6813 })?;
6814
6815 let config = driver_config_json(raw.config, "inference.driver.config")?;
6816
6817 Ok(InferenceDriver { artifact, config })
6818}
6819
6820fn driver_config_json(
6823 raw: Option<serde_norway::Value>,
6824 field: &str,
6825) -> Result<Option<String>, RuntimeManifestError> {
6826 let Some(raw) = raw else {
6827 return Ok(None);
6828 };
6829
6830 if matches!(raw, serde_norway::Value::Null) {
6831 return Ok(None);
6832 }
6833
6834 if !matches!(raw, serde_norway::Value::Mapping(_)) {
6835 return Err(RuntimeManifestError::InvalidInferenceConfig {
6836 field: field.to_string(),
6837 message: "expected mapping/object".to_string(),
6838 });
6839 }
6840
6841 let value =
6842 serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
6843 field: field.to_string(),
6844 message: format!("failed to convert to JSON: {err}"),
6845 })?;
6846
6847 serde_json::to_string(&value).map(Some).map_err(|err| {
6848 RuntimeManifestError::InvalidInferenceConfig {
6849 field: field.to_string(),
6850 message: format!("failed to encode JSON: {err}"),
6851 }
6852 })
6853}
6854
6855fn parse_env_reference(value: &str) -> Option<&str> {
6856 if !value.starts_with("${") || !value.ends_with('}') {
6857 return None;
6858 }
6859
6860 let variable = &value[2..value.len() - 1];
6861 is_valid_env_variable(variable).then_some(variable)
6862}
6863
6864fn is_valid_env_variable(value: &str) -> bool {
6865 let mut chars = value.chars();
6866 let Some(first) = chars.next() else {
6867 return false;
6868 };
6869
6870 if !(first == '_' || first.is_ascii_uppercase()) {
6871 return false;
6872 }
6873
6874 chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
6875}
6876
6877#[cfg(test)]
6878mod tests {
6879 use super::*;
6880 use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
6881
6882 fn manifest_with_trace(block: &str) -> String {
6883 format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
6884 }
6885
6886 fn lifecycle(task_acceptance: TaskAcceptance, queue_depth: usize) -> LifecycleConfig {
6887 LifecycleConfig {
6888 task_acceptance,
6889 queue_depth,
6890 ..Default::default()
6891 }
6892 }
6893
6894 #[test]
6895 fn tasks_held_at_once_under_queue_is_one_running_plus_queue_depth_waiting() {
6896 assert_eq!(lifecycle(TaskAcceptance::Queue, 1).tasks_held_at_once(), 2);
6897 assert_eq!(lifecycle(TaskAcceptance::Queue, 9).tasks_held_at_once(), 10);
6898 }
6899
6900 #[test]
6901 fn queue_has_room_is_false_only_for_a_queue_at_depth_zero() {
6902 assert!(!lifecycle(TaskAcceptance::Queue, 0).queue_has_room());
6903 assert!(lifecycle(TaskAcceptance::Queue, 1).queue_has_room());
6904 assert!(lifecycle(TaskAcceptance::Single, 0).queue_has_room());
6905 assert!(lifecycle(TaskAcceptance::None, 0).queue_has_room());
6906 assert!(lifecycle(TaskAcceptance::Single, 3).queue_has_room());
6907 }
6908
6909 fn parse_lifecycle(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
6910 RuntimeManifest::from_yaml_str(&format!(
6911 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n{block}"
6912 ))
6913 }
6914
6915 #[test]
6916 fn queue_depth_zero_under_queue_is_refused_at_load() {
6917 let error = parse_lifecycle(" task_acceptance: queue\n queue_depth: 0\n").unwrap_err();
6918 assert!(
6919 matches!(error, RuntimeManifestError::QueueWithoutRoom),
6920 "{error:?}"
6921 );
6922 assert_eq!(
6923 error.to_string(),
6924 "murmur.yaml: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves \
6925 task_acceptance: queue no room for a waiting task"
6926 );
6927 }
6928
6929 #[test]
6931 fn queue_depth_zero_outside_queue_still_parses() {
6932 for block in [
6933 " task_acceptance: single\n queue_depth: 0\n",
6934 " task_acceptance: none\n queue_depth: 0\n",
6935 " queue_depth: 0\n",
6936 ] {
6937 let lifecycle = parse_lifecycle(block)
6938 .unwrap_or_else(|error| panic!("{block:?}: {error}"))
6939 .effective_lifecycle();
6940 assert_eq!(lifecycle.queue_depth, 0, "{block:?}");
6941 }
6942 let queue = parse_lifecycle(" task_acceptance: queue\n queue_depth: 1\n")
6943 .unwrap()
6944 .effective_lifecycle();
6945 assert_eq!(queue.task_acceptance, TaskAcceptance::Queue);
6946 assert_eq!(queue.queue_depth, 1);
6947 }
6948
6949 #[test]
6950 fn tasks_held_at_once_under_single_is_one_whatever_the_depth() {
6951 assert_eq!(lifecycle(TaskAcceptance::Single, 1).tasks_held_at_once(), 1);
6952 assert_eq!(lifecycle(TaskAcceptance::Single, 5).tasks_held_at_once(), 1);
6953 }
6954
6955 #[test]
6956 fn tasks_held_at_once_under_none_is_zero_whatever_the_depth() {
6957 assert_eq!(lifecycle(TaskAcceptance::None, 1).tasks_held_at_once(), 0);
6958 assert_eq!(lifecycle(TaskAcceptance::None, 5).tasks_held_at_once(), 0);
6959 }
6960
6961 #[test]
6964 fn tasks_held_at_once_of_the_default_lifecycle_and_default_depth() {
6965 assert_eq!(LifecycleConfig::default().tasks_held_at_once(), 1);
6966 let queue = RuntimeManifest::from_yaml_str(
6967 "name: cap\nversion: 0.0.1\nlifecycle:\n task_acceptance: queue\n",
6968 )
6969 .unwrap()
6970 .effective_lifecycle();
6971 assert_eq!(queue.queue_depth, 1);
6972 assert_eq!(queue.tasks_held_at_once(), 2);
6973 }
6974
6975 #[test]
6978 fn absent_trace_block_stays_absent_and_defaults_to_meta() {
6979 let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
6980 assert_eq!(manifest.trace, None);
6981 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
6982 }
6983
6984 #[test]
6985 fn each_capture_mode_parses_from_the_trace_block() {
6986 for (yaml, expected) in [
6987 ("none", TraceCapture::None),
6988 ("meta", TraceCapture::Meta),
6989 ("content", TraceCapture::Content),
6990 ] {
6991 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
6992 " capture: {yaml}\n"
6993 )))
6994 .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
6995 assert_eq!(
6996 manifest.trace,
6997 Some(TraceConfig {
6998 capture: expected,
6999 retain: None
7000 })
7001 );
7002 }
7003 }
7004
7005 #[test]
7011 fn no_retain_block_anywhere_parses_as_no_policy() {
7012 let manifest = RuntimeManifest::from_yaml_str(
7013 "name: cap\nversion: 0.0.1\ntrace:\n capture: content\ncontext:\n record: on\n",
7014 )
7015 .unwrap();
7016 assert_eq!(manifest.trace.unwrap().retain, None);
7017 assert_eq!(manifest.context.unwrap().retain, None);
7018 }
7019
7020 #[test]
7021 fn trace_retain_parses_both_keys_and_the_day_suffix() {
7022 let manifest = RuntimeManifest::from_yaml_str(
7023 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 50\n max_age: 14d\n",
7024 )
7025 .unwrap();
7026 assert_eq!(
7027 manifest.trace.unwrap().retain,
7028 Some(TraceRetainConfig {
7029 max_sessions: Some(50),
7030 max_age_secs: Some(14 * 86_400),
7031 })
7032 );
7033 }
7034
7035 fn context_images_of(block: &str) -> Result<ContextImagesConfig, RuntimeManifestError> {
7036 RuntimeManifest::from_yaml_str(&format!(
7037 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n{block}"
7038 ))
7039 .map(|manifest| manifest.context.unwrap().images)
7040 }
7041
7042 #[test]
7043 fn context_images_defaults_apply_without_the_block_and_to_each_absent_key() {
7044 let defaults = ContextImagesConfig {
7045 keep: 5,
7046 max_bytes: 5 * 1024 * 1024,
7047 max_per_turn: 5,
7048 };
7049 assert_eq!(ContextImagesConfig::default(), defaults);
7050 assert_eq!(context_images_of("").unwrap(), defaults);
7051 assert_eq!(
7052 context_images_of(" images:\n keep: 2\n").unwrap(),
7053 ContextImagesConfig {
7054 keep: 2,
7055 ..defaults
7056 }
7057 );
7058 }
7059
7060 #[test]
7061 fn context_images_parses_every_key() {
7062 assert_eq!(
7063 context_images_of(" images:\n keep: 1\n max_bytes: 1Ki\n max_per_turn: 9\n")
7064 .unwrap(),
7065 ContextImagesConfig {
7066 keep: 1,
7067 max_bytes: 1024,
7068 max_per_turn: 9,
7069 }
7070 );
7071 assert_eq!(
7072 context_images_of(" images:\n max_bytes: 4096\n")
7073 .unwrap()
7074 .max_bytes,
7075 4096
7076 );
7077 }
7078
7079 #[test]
7080 fn context_images_refuses_zero_and_a_decimal_byte_suffix() {
7081 for (block, field) in [
7082 (" images:\n keep: 0\n", "context.images.keep"),
7083 (
7084 " images:\n max_per_turn: 0\n",
7085 "context.images.max_per_turn",
7086 ),
7087 (" images:\n max_bytes: 0\n", "context.images.max_bytes"),
7088 (
7089 " images:\n max_bytes: 10MB\n",
7090 "context.images.max_bytes",
7091 ),
7092 (" images:\n keep: many\n", "context.images.keep"),
7093 ] {
7094 match context_images_of(block) {
7095 Err(RuntimeManifestError::InvalidInferenceConfig { field: got, .. }) => {
7096 assert_eq!(got, field, "{block}")
7097 }
7098 other => panic!("{block}: expected a refusal naming {field}, got {other:?}"),
7099 }
7100 }
7101 let error = context_images_of(" images:\n keep: 0\n").unwrap_err();
7102 assert!(
7103 error.to_string().contains("got '0'"),
7104 "the value is named as written: {error}"
7105 );
7106 }
7107
7108 #[test]
7109 fn context_images_unknown_key_is_suggested() {
7110 assert_eq!(
7111 unknown_keys_of("name: cap\nversion: 0.1.0\ncontext:\n images:\n kep: 3\n"),
7112 vec![(
7113 "kep".to_string(),
7114 "context.images".to_string(),
7115 Some("keep".to_string())
7116 )]
7117 );
7118 }
7119
7120 #[test]
7121 fn context_retain_parses_both_keys_and_either_alone() {
7122 let both = RuntimeManifest::from_yaml_str(
7123 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n max_messages: 2000\n max_age: 90d\n",
7124 )
7125 .unwrap();
7126 assert_eq!(
7127 both.context.unwrap().retain,
7128 Some(ContextRetainConfig {
7129 max_messages: Some(2000),
7130 max_age_secs: Some(90 * 86_400),
7131 })
7132 );
7133
7134 let count_only = RuntimeManifest::from_yaml_str(
7135 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 10\n",
7136 )
7137 .unwrap();
7138 assert_eq!(
7139 count_only.context.unwrap().retain,
7140 Some(ContextRetainConfig {
7141 max_messages: Some(10),
7142 max_age_secs: None,
7143 })
7144 );
7145 }
7146
7147 #[test]
7150 fn an_empty_retain_block_is_refused_naming_the_block() {
7151 for (yaml, expect_trace) in [
7152 (
7153 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain: {}\n",
7154 true,
7155 ),
7156 (
7157 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n",
7158 true,
7159 ),
7160 (
7161 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain: {}\n",
7162 false,
7163 ),
7164 (
7165 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n",
7166 false,
7167 ),
7168 ] {
7169 let err = RuntimeManifest::from_yaml_str(yaml)
7170 .expect_err("an empty retain block must be refused");
7171 let (field, message) = match (&err, expect_trace) {
7172 (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
7173 (field, message)
7174 }
7175 (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
7176 (field, message)
7177 }
7178 _ => panic!("wrong variant for {yaml:?}: {err:?}"),
7179 };
7180 assert_eq!(
7181 field,
7182 if expect_trace {
7183 "trace.retain"
7184 } else {
7185 "context.retain"
7186 }
7187 );
7188 assert!(message.contains("at least one key"), "{message}");
7189 }
7190 }
7191
7192 #[test]
7195 fn a_zero_retain_limit_is_refused_naming_the_key() {
7196 let trace = RuntimeManifest::from_yaml_str(
7197 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 0\n",
7198 )
7199 .expect_err("max_sessions: 0 must be refused");
7200 assert!(
7201 matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
7202 if field == "trace.retain.max_sessions"),
7203 "{trace:?}"
7204 );
7205
7206 let context = RuntimeManifest::from_yaml_str(
7207 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 0\n",
7208 )
7209 .expect_err("max_messages: 0 must be refused");
7210 assert!(
7211 matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
7212 if field == "context.retain.max_messages"),
7213 "{context:?}"
7214 );
7215
7216 let age = RuntimeManifest::from_yaml_str(
7217 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_age: 0\n",
7218 )
7219 .expect_err("max_age: 0 must be refused");
7220 assert!(
7221 matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
7222 if field == "trace.retain.max_age"),
7223 "{age:?}"
7224 );
7225 }
7226
7227 #[test]
7228 fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
7229 let err = RuntimeManifest::from_yaml_str(
7230 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_traces: 3\n",
7231 )
7232 .expect_err("an unknown key must be refused");
7233 match err {
7234 RuntimeManifestError::InvalidTraceConfig { field, message } => {
7235 assert_eq!(field, "trace.retain");
7236 assert!(message.contains("max_traces"), "{message}");
7237 assert!(message.contains("max_sessions"), "{message}");
7238 }
7239 other => panic!("expected InvalidTraceConfig, got {other:?}"),
7240 }
7241 }
7242
7243 #[test]
7244 fn an_unparseable_retain_duration_reports_the_accepted_form() {
7245 let err = RuntimeManifest::from_yaml_str(
7246 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_age: 5 weeks\n",
7247 )
7248 .expect_err("'5 weeks' must be refused");
7249 match err {
7250 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
7251 assert_eq!(field, "context.retain.max_age");
7252 assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
7253 }
7254 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
7255 }
7256 }
7257
7258 #[test]
7259 fn unparseable_capture_value_names_the_field_and_accepted_values() {
7260 let err = RuntimeManifest::from_yaml_str(&manifest_with_trace(" capture: verbose\n"))
7261 .expect_err("'verbose' is not a capture mode");
7262 let rendered = err.to_string();
7263 assert!(rendered.contains("trace.capture"), "{rendered}");
7264 for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
7265 assert!(rendered.contains(accepted), "{rendered}");
7266 }
7267 }
7268
7269 #[test]
7270 fn resources_block_is_optional_and_absent_stays_absent() {
7271 let manifest = RuntimeManifest::from_yaml_str(
7272 r#"
7273name: cap
7274version: 0.0.1
7275capabilities:
7276 shell:
7277 allow: [bash]
7278"#,
7279 )
7280 .unwrap();
7281
7282 let caps = manifest.capabilities.expect("shell block must parse");
7286 assert_eq!(caps.resources, None);
7287 }
7288
7289 #[test]
7290 fn resources_fields_parse_and_each_stays_independently_optional() {
7291 let manifest = RuntimeManifest::from_yaml_str(
7292 r#"
7293name: cap
7294version: 0.0.1
7295capabilities:
7296 resources:
7297 max_processes: 32
7298 max_open_files: 16
7299 max_file_size_bytes: 1048576
7300 cpu_seconds: 30
7301 memory_bytes: 268435456
7302 cgroup_memory_bytes: 536870912
7303 cgroup_pids_max: 64
7304 cgroup_cpu_percent: 50
7305 cgroup_io_bytes_per_sec: 1048576
7306 workdir_max_bytes: 2097152
7307"#,
7308 )
7309 .unwrap();
7310
7311 let resources = manifest
7312 .capabilities
7313 .and_then(|caps| caps.resources)
7314 .expect("resources block must parse");
7315 assert_eq!(resources.max_processes, Some(32));
7316 assert_eq!(resources.max_open_files, Some(16));
7317 assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
7318 assert_eq!(resources.cpu_seconds, Some(30));
7319 assert_eq!(resources.memory_bytes, Some(268_435_456));
7320 assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
7321 assert_eq!(resources.cgroup_pids_max, Some(64));
7322 assert_eq!(resources.cgroup_cpu_percent, Some(50));
7323 assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
7324 assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
7325 }
7326
7327 #[test]
7328 fn state_block_is_optional_and_absent_stays_absent() {
7329 let manifest = RuntimeManifest::from_yaml_str(
7330 r#"
7331name: cap
7332version: 0.0.1
7333artifacts:
7334 - name: local/notes-tool
7335 version: 1.0.0
7336 runtime: tool
7337 capabilities:
7338 filesystem:
7339 scope: cache
7340"#,
7341 )
7342 .unwrap();
7343
7344 let caps = manifest.artifacts[0]
7346 .capabilities
7347 .as_ref()
7348 .expect("filesystem block must parse");
7349 assert_eq!(caps.state, None);
7350 }
7351
7352 #[test]
7356 fn state_block_declared_empty_grants_the_capsule_named_store() {
7357 let manifest = RuntimeManifest::from_yaml_str(
7358 r#"
7359name: cap
7360version: 0.0.1
7361artifacts:
7362 - name: local/notes-tool
7363 version: 1.0.0
7364 runtime: tool
7365 capabilities:
7366 state: {}
7367"#,
7368 )
7369 .unwrap();
7370
7371 let state = manifest.artifacts[0]
7372 .capabilities
7373 .as_ref()
7374 .and_then(|caps| caps.state.as_ref())
7375 .expect("state block must parse");
7376 assert_eq!(state.store, None);
7377 }
7378
7379 #[test]
7380 fn state_store_name_parses_and_stays_independently_optional() {
7381 let manifest = RuntimeManifest::from_yaml_str(
7382 r#"
7383name: cap
7384version: 0.0.1
7385artifacts:
7386 - name: local/notes-tool
7387 version: 1.0.0
7388 runtime: tool
7389 capabilities:
7390 state:
7391 store: " shey "
7392 network:
7393 allow: [https://api.example.com]
7394"#,
7395 )
7396 .unwrap();
7397
7398 let caps = manifest.artifacts[0]
7399 .capabilities
7400 .as_ref()
7401 .expect("capabilities must parse");
7402 assert_eq!(
7405 caps.state.as_ref().and_then(|state| state.store.as_deref()),
7406 Some("shey")
7407 );
7408 assert_eq!(
7410 caps.network
7411 .as_ref()
7412 .map(|network| network.allow.as_slice()),
7413 Some(["https://api.example.com".to_string()].as_slice())
7414 );
7415 assert_eq!(caps.filesystem, None);
7416 }
7417
7418 #[test]
7422 fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
7423 let manifest = RuntimeManifest::from_yaml_str(
7424 r#"
7425name: cap
7426version: 0.0.1
7427artifacts:
7428 - name: local/notes-tool
7429 version: 1.0.0
7430 runtime: tool
7431 capabilities:
7432 state:
7433 store: ""
7434"#,
7435 )
7436 .unwrap();
7437
7438 assert_eq!(
7439 manifest.artifacts[0]
7440 .capabilities
7441 .as_ref()
7442 .and_then(|caps| caps.state.as_ref())
7443 .and_then(|state| state.store.as_deref()),
7444 Some("")
7445 );
7446 }
7447
7448 #[test]
7449 fn resources_partial_block_leaves_undeclared_fields_none() {
7450 let manifest = RuntimeManifest::from_yaml_str(
7451 r#"
7452name: cap
7453version: 0.0.1
7454capabilities:
7455 resources:
7456 max_open_files: 16
7457"#,
7458 )
7459 .unwrap();
7460
7461 let resources = manifest
7462 .capabilities
7463 .and_then(|caps| caps.resources)
7464 .expect("resources block must parse");
7465 assert_eq!(resources.max_open_files, Some(16));
7466 assert_eq!(resources.max_processes, None);
7467 assert_eq!(resources.workdir_max_bytes, None);
7468 }
7469
7470 #[test]
7474 fn resources_zero_is_rejected_on_every_field() {
7475 for field in [
7476 "max_processes",
7477 "max_open_files",
7478 "max_file_size_bytes",
7479 "cpu_seconds",
7480 "memory_bytes",
7481 "cgroup_memory_bytes",
7482 "cgroup_pids_max",
7483 "cgroup_cpu_percent",
7484 "cgroup_io_bytes_per_sec",
7485 "workdir_max_bytes",
7486 ] {
7487 let yaml =
7488 format!("name: cap\nversion: 0.0.1\ncapabilities:\n resources:\n {field}: 0\n");
7489 let error =
7490 RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
7491
7492 match error {
7493 RuntimeManifestError::InvalidCapabilities {
7494 field: reported,
7495 message,
7496 } => {
7497 assert_eq!(reported, format!("capabilities.resources.{field}"));
7498 assert_eq!(message, "must be greater than zero");
7499 }
7500 other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
7501 }
7502 }
7503 }
7504
7505 #[test]
7506 fn artifact_runtime_defaults_to_tool() {
7507 let manifest = RuntimeManifest::from_yaml_str(
7508 r#"
7509name: cap
7510version: 0.0.1
7511artifacts:
7512 - name: local/echo
7513 version: 1.2.3
7514"#,
7515 )
7516 .unwrap();
7517
7518 assert_eq!(manifest.artifacts.len(), 1);
7519 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7520 }
7521
7522 #[test]
7523 fn artifact_runtime_tool_explicit() {
7524 let manifest = RuntimeManifest::from_yaml_str(
7525 r#"
7526name: cap
7527version: 0.0.1
7528artifacts:
7529 - name: local/echo
7530 version: 1.2.3
7531 runtime: tool
7532"#,
7533 )
7534 .unwrap();
7535
7536 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7537 }
7538
7539 #[test]
7540 fn artifact_runtime_wasm_is_error() {
7541 let err = RuntimeManifest::from_yaml_str(
7542 r#"
7543name: cap
7544version: 0.0.1
7545artifacts:
7546 - name: local/echo
7547 version: 1.2.3
7548 runtime: wasm
7549"#,
7550 )
7551 .unwrap_err();
7552
7553 let msg = err.to_string();
7554 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7555 assert!(msg.contains("wasm"), "error was: {msg}");
7556 }
7557
7558 #[test]
7559 fn artifact_runtime_native_is_error() {
7560 let err = RuntimeManifest::from_yaml_str(
7561 r#"
7562name: cap
7563version: 0.0.1
7564artifacts:
7565 - name: local/echo
7566 version: 1.2.3
7567 runtime: native
7568"#,
7569 )
7570 .unwrap_err();
7571
7572 let msg = err.to_string();
7573 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7574 assert!(msg.contains("native"), "error was: {msg}");
7575 }
7576
7577 #[test]
7578 fn invalid_artifact_reports_index() {
7579 let err = RuntimeManifest::from_yaml_str(
7580 r#"
7581name: cap
7582version: 0.0.1
7583artifacts:
7584 - name: ""
7585 version: 1.2.3
7586"#,
7587 )
7588 .unwrap_err();
7589
7590 let msg = err.to_string();
7591 assert!(msg.contains("index 0"));
7592 assert!(msg.contains("missing required field 'name'"));
7593 }
7594
7595 #[test]
7596 fn capabilities_parse_when_all_fields_present() {
7597 let manifest = RuntimeManifest::from_yaml_str(
7598 r#"
7599name: cap
7600version: 0.0.1
7601artifacts:
7602 - name: local/echo
7603 version: 1.2.3
7604capabilities:
7605 network:
7606 allow:
7607 - https://api.anthropic.com
7608 - http://127.0.0.1:8080
7609 filesystem:
7610 scope: ./sandbox
7611"#,
7612 )
7613 .unwrap();
7614
7615 let capabilities = manifest.capabilities.expect("capabilities should exist");
7616 assert_eq!(
7617 capabilities.network.unwrap().allow,
7618 vec![
7619 "https://api.anthropic.com".to_string(),
7620 "http://127.0.0.1:8080".to_string()
7621 ]
7622 );
7623 assert_eq!(
7624 capabilities.filesystem.unwrap().scope,
7625 Some("./sandbox".to_string())
7626 );
7627 }
7628
7629 #[test]
7630 fn env_capabilities_parse_allowlist() {
7631 let manifest = RuntimeManifest::from_yaml_str(
7632 r#"
7633name: cap
7634version: 0.0.1
7635artifacts:
7636 - name: local/echo
7637 version: 1.2.3
7638capabilities:
7639 env:
7640 allow:
7641 - MY_APP_REGION
7642 - GITHUB_TOKEN
7643"#,
7644 )
7645 .unwrap();
7646
7647 let capabilities = manifest.capabilities.expect("capabilities should exist");
7648 assert_eq!(
7649 capabilities.env.unwrap().allow,
7650 vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
7651 );
7652 }
7653
7654 #[test]
7655 fn env_capabilities_absent_when_block_omitted() {
7656 let manifest = RuntimeManifest::from_yaml_str(
7657 r#"
7658name: cap
7659version: 0.0.1
7660artifacts:
7661 - name: local/echo
7662 version: 1.2.3
7663capabilities:
7664 network:
7665 allow:
7666 - https://api.anthropic.com
7667"#,
7668 )
7669 .unwrap();
7670
7671 assert!(manifest.capabilities.unwrap().env.is_none());
7672 }
7673
7674 #[test]
7677 fn env_capabilities_allow_empty_list_is_accepted() {
7678 let manifest = RuntimeManifest::from_yaml_str(
7679 r#"
7680name: cap
7681version: 0.0.1
7682artifacts:
7683 - name: local/echo
7684 version: 1.2.3
7685capabilities:
7686 env: {}
7687"#,
7688 )
7689 .unwrap();
7690
7691 assert_eq!(
7692 manifest.capabilities.unwrap().env,
7693 Some(EnvCapabilities { allow: vec![] })
7694 );
7695 }
7696
7697 #[test]
7698 fn missing_capabilities_block_is_backward_compatible() {
7699 let manifest = RuntimeManifest::from_yaml_str(
7700 r#"
7701name: cap
7702version: 0.0.1
7703artifacts:
7704 - name: local/echo
7705 version: 1.2.3
7706"#,
7707 )
7708 .unwrap();
7709
7710 assert!(manifest.capabilities.is_none());
7711 }
7712
7713 #[test]
7714 fn network_only_capabilities_parse() {
7715 let manifest = RuntimeManifest::from_yaml_str(
7716 r#"
7717name: cap
7718version: 0.0.1
7719artifacts:
7720 - name: local/echo
7721 version: 1.2.3
7722capabilities:
7723 network:
7724 allow:
7725 - https://allowed.example.com
7726"#,
7727 )
7728 .unwrap();
7729
7730 let capabilities = manifest.capabilities.unwrap();
7731 assert_eq!(
7732 capabilities.network.unwrap().allow,
7733 vec!["https://allowed.example.com".to_string()]
7734 );
7735 assert!(capabilities.filesystem.is_none());
7736 }
7737
7738 #[test]
7739 fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
7740 let manifest = RuntimeManifest::from_yaml_str(
7741 r#"
7742name: cap
7743version: 0.0.1
7744artifacts:
7745 - name: local/echo
7746 version: 1.2.3
7747capabilities:
7748 network: {}
7749"#,
7750 )
7751 .unwrap();
7752
7753 let capabilities = manifest.capabilities.unwrap();
7754 assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
7755 }
7756
7757 #[test]
7760 fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
7761 let manifest = RuntimeManifest::from_yaml_str(
7762 r#"
7763name: cap
7764version: 0.0.1
7765artifacts:
7766 - name: local/echo
7767 version: 1.2.3
7768capabilities:
7769 network:
7770 allow:
7771 - https://api.anthropic.com
7772"#,
7773 )
7774 .unwrap();
7775
7776 let network = manifest.capabilities.unwrap().network.unwrap();
7777 assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
7778 assert!(!network.unix_sockets);
7779 }
7780
7781 #[test]
7782 fn network_unix_sockets_parses_explicit_false() {
7783 let manifest = RuntimeManifest::from_yaml_str(
7784 r#"
7785name: cap
7786version: 0.0.1
7787artifacts:
7788 - name: local/echo
7789 version: 1.2.3
7790capabilities:
7791 network:
7792 unix_sockets: false
7793"#,
7794 )
7795 .unwrap();
7796
7797 assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
7798 }
7799
7800 #[test]
7801 fn network_unix_sockets_parses_explicit_true() {
7802 let manifest = RuntimeManifest::from_yaml_str(
7803 r#"
7804name: cap
7805version: 0.0.1
7806artifacts:
7807 - name: local/echo
7808 version: 1.2.3
7809capabilities:
7810 network:
7811 unix_sockets: true
7812"#,
7813 )
7814 .unwrap();
7815
7816 let network = manifest.capabilities.unwrap().network.unwrap();
7817 assert!(network.unix_sockets);
7818 assert_eq!(network.allow, Vec::<String>::new());
7820 }
7821
7822 #[test]
7826 fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
7827 let manifest = RuntimeManifest::from_yaml_str(
7828 r#"
7829name: cap
7830version: 0.0.1
7831capabilities:
7832 filesystem:
7833 read_only:
7834 - tests
7835 - " bench/fixtures "
7836 - ""
7837 - " "
7838"#,
7839 )
7840 .unwrap();
7841
7842 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7843 assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
7844 }
7845
7846 #[test]
7848 fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
7849 let manifest = RuntimeManifest::from_yaml_str(
7850 r#"
7851name: cap
7852version: 0.0.1
7853capabilities:
7854 filesystem:
7855 scope: workdir
7856"#,
7857 )
7858 .unwrap();
7859
7860 assert!(manifest
7861 .capabilities
7862 .unwrap()
7863 .filesystem
7864 .unwrap()
7865 .read_only
7866 .is_empty());
7867 }
7868
7869 #[test]
7872 fn filesystem_read_only_does_not_judge_path_shape() {
7873 let manifest = RuntimeManifest::from_yaml_str(
7874 r#"
7875name: cap
7876version: 0.0.1
7877capabilities:
7878 filesystem:
7879 read_only:
7880 - /etc
7881 - ../outside
7882"#,
7883 )
7884 .expect("shape is judged by the runtime, not the parser");
7885
7886 assert_eq!(
7887 manifest.capabilities.unwrap().filesystem.unwrap().read_only,
7888 vec!["/etc", "../outside"]
7889 );
7890 }
7891
7892 #[test]
7896 fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
7897 let manifest = RuntimeManifest::from_yaml_str(
7898 r#"
7899name: cap
7900version: 0.0.1
7901artifacts:
7902 - name: local/echo
7903 version: 1.2.3
7904capabilities:
7905 filesystem:
7906 scope: workdir
7907"#,
7908 )
7909 .unwrap();
7910
7911 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7912 assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
7913 assert!(!filesystem.workdir_exec);
7914 }
7915
7916 #[test]
7921 fn filesystem_block_stays_absent_when_undeclared() {
7922 let manifest = RuntimeManifest::from_yaml_str(
7923 r#"
7924name: cap
7925version: 0.0.1
7926artifacts:
7927 - name: local/echo
7928 version: 1.2.3
7929capabilities:
7930 network:
7931 allow:
7932 - https://api.anthropic.com
7933"#,
7934 )
7935 .unwrap();
7936
7937 assert!(manifest.capabilities.unwrap().filesystem.is_none());
7938 }
7939
7940 #[test]
7941 fn filesystem_workdir_exec_parses_explicit_false() {
7942 let manifest = RuntimeManifest::from_yaml_str(
7943 r#"
7944name: cap
7945version: 0.0.1
7946artifacts:
7947 - name: local/echo
7948 version: 1.2.3
7949capabilities:
7950 filesystem:
7951 workdir_exec: false
7952"#,
7953 )
7954 .unwrap();
7955
7956 assert!(
7957 !manifest
7958 .capabilities
7959 .unwrap()
7960 .filesystem
7961 .unwrap()
7962 .workdir_exec
7963 );
7964 }
7965
7966 #[test]
7967 fn filesystem_workdir_exec_parses_explicit_true() {
7968 let manifest = RuntimeManifest::from_yaml_str(
7969 r#"
7970name: cap
7971version: 0.0.1
7972artifacts:
7973 - name: local/echo
7974 version: 1.2.3
7975capabilities:
7976 filesystem:
7977 workdir_exec: true
7978"#,
7979 )
7980 .unwrap();
7981
7982 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7983 assert!(filesystem.workdir_exec);
7984 assert_eq!(filesystem.scope, None);
7987 }
7988
7989 #[test]
7990 fn network_internal_port_parses() {
7991 let manifest = RuntimeManifest::from_yaml_str(
7992 r#"
7993name: worker
7994version: 0.1.0
7995artifacts: []
7996
7997network:
7998 internal_port: 58172
7999"#,
8000 )
8001 .unwrap();
8002
8003 assert_eq!(
8004 manifest.network.unwrap().internal_port,
8005 Some(58172),
8006 "network.internal_port should parse to Some(58172)"
8007 );
8008 }
8009
8010 #[test]
8011 fn network_internal_port_absent_when_omitted() {
8012 let manifest = RuntimeManifest::from_yaml_str(
8013 r#"
8014name: worker
8015version: 0.1.0
8016artifacts: []
8017"#,
8018 )
8019 .unwrap();
8020
8021 assert!(manifest.network.is_none());
8022 }
8023
8024 #[test]
8025 fn shell_capabilities_parse_with_allow_and_env_fields() {
8026 let manifest = RuntimeManifest::from_yaml_str(
8027 r#"
8028name: cap
8029version: 0.0.1
8030artifacts: []
8031capabilities:
8032 shell:
8033 allow:
8034 - bash
8035 - git
8036 strip_env:
8037 - "*_TOKEN"
8038 - SECRET_KEY
8039 baseline_env:
8040 - SSH_AUTH_SOCK
8041"#,
8042 )
8043 .unwrap();
8044
8045 let shell = manifest
8046 .capabilities
8047 .unwrap()
8048 .shell
8049 .expect("shell capabilities should be present");
8050 assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
8051 assert_eq!(
8052 shell.strip_env,
8053 Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
8054 );
8055 assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
8056 }
8057
8058 #[test]
8059 fn shell_capabilities_require_non_empty_allowlist() {
8060 let err = RuntimeManifest::from_yaml_str(
8061 r#"
8062name: cap
8063version: 0.0.1
8064artifacts: []
8065capabilities:
8066 shell:
8067 allow: []
8068"#,
8069 )
8070 .unwrap_err();
8071
8072 let msg = err.to_string();
8073 assert!(msg.contains("capabilities.shell.allow"));
8074 assert!(msg.contains("at least one"));
8075 }
8076
8077 fn shell_allow_error(entry: &str) -> String {
8078 let yaml = format!(
8079 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n shell:\n allow: [bash, {}]\n",
8080 serde_json::to_string(entry).unwrap()
8081 );
8082 RuntimeManifest::from_yaml_str(&yaml)
8083 .expect_err(&format!("{entry:?} must be refused"))
8084 .to_string()
8085 }
8086
8087 #[test]
8088 fn shell_allow_refuses_an_entry_that_is_not_a_bare_name() {
8089 for (entry, reason) in [
8090 ("/tmp/uv-tool/bin/uv", "is a path"),
8091 ("bin/uv", "is a path"),
8092 ("..", "names a directory"),
8093 (".", "names a directory"),
8094 ("", "is empty"),
8095 ("a b", "contains whitespace"),
8096 ("uv\t", "contains whitespace"),
8097 ("uv\0", "contains a NUL byte"),
8098 ] {
8099 let message = shell_allow_error(entry);
8100 assert!(
8101 message.contains("'capabilities.shell.allow[1]'"),
8102 "{entry:?}: {message}"
8103 );
8104 assert!(
8105 message.contains(&format!(
8106 "'{}' {reason}; an entry is a bare binary name",
8107 entry.escape_debug()
8108 )),
8109 "{entry:?}: {message}"
8110 );
8111 }
8112 }
8113
8114 #[test]
8115 fn shell_allow_accepts_bare_names() {
8116 let manifest = RuntimeManifest::from_yaml_str(
8117 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n shell:\n allow: [bash, python3.12, git-lfs, \"c++\"]\n",
8118 )
8119 .unwrap();
8120 assert_eq!(
8121 manifest.capabilities.unwrap().shell.unwrap().allow,
8122 ["bash", "python3.12", "git-lfs", "c++"]
8123 );
8124 }
8125
8126 #[test]
8127 fn interpreter_runtime_parses_accepted_shape() {
8128 let manifest = RuntimeManifest::from_yaml_str(
8129 r#"
8130name: cap
8131version: 0.0.1
8132artifacts: []
8133capabilities:
8134 shell:
8135 allow:
8136 - python3
8137 interpreter_runtime:
8138 - binary: python3
8139 dirs:
8140 - path: /usr/lib/python3.11
8141 list_dir: true
8142 - path: /usr/lib/python3.11/lib-dynload
8143 list_dir: false
8144"#,
8145 )
8146 .unwrap();
8147
8148 let shell = manifest.capabilities.unwrap().shell.unwrap();
8149 assert_eq!(
8150 shell.interpreter_runtime,
8151 vec![InterpreterRuntimeGrant {
8152 binary: "python3".to_string(),
8153 dirs: vec![
8154 InterpreterRuntimeDir {
8155 path: "/usr/lib/python3.11".to_string(),
8156 list_dir: true,
8157 },
8158 InterpreterRuntimeDir {
8159 path: "/usr/lib/python3.11/lib-dynload".to_string(),
8160 list_dir: false,
8161 },
8162 ],
8163 }]
8164 );
8165 }
8166
8167 #[test]
8168 fn interpreter_runtime_defaults_to_empty_when_absent() {
8169 let manifest = RuntimeManifest::from_yaml_str(
8170 r#"
8171name: cap
8172version: 0.0.1
8173artifacts: []
8174capabilities:
8175 shell:
8176 allow:
8177 - python3
8178"#,
8179 )
8180 .unwrap();
8181
8182 let shell = manifest.capabilities.unwrap().shell.unwrap();
8183 assert!(shell.interpreter_runtime.is_empty());
8184 }
8185
8186 fn interpreter_runtime_reject(grant_yaml: &str) -> String {
8189 let yaml = format!(
8190 r#"
8191name: cap
8192version: 0.0.1
8193artifacts: []
8194capabilities:
8195 shell:
8196 allow:
8197 - python3
8198 interpreter_runtime:
8199{grant_yaml}
8200"#,
8201 );
8202 RuntimeManifest::from_yaml_str(&yaml)
8203 .expect_err("grant should be rejected")
8204 .to_string()
8205 }
8206
8207 #[test]
8208 fn interpreter_runtime_rejects_binary_not_in_allow() {
8209 let msg = interpreter_runtime_reject(
8210 " - binary: ruby\n dirs:\n - path: /usr/lib/ruby\n list_dir: true\n",
8211 );
8212 assert!(
8213 msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
8214 "{msg}"
8215 );
8216 assert!(msg.contains("ruby"), "{msg}");
8217 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
8218 }
8219
8220 #[test]
8221 fn interpreter_runtime_rejects_relative_path() {
8222 let msg = interpreter_runtime_reject(
8223 " - binary: python3\n dirs:\n - path: usr/lib/python3.11\n list_dir: true\n",
8224 );
8225 assert!(
8226 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
8227 "{msg}"
8228 );
8229 assert!(msg.contains("absolute"), "{msg}");
8230 }
8231
8232 fn manifest_with_plan(block: &str) -> String {
8236 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n{block}")
8237 }
8238
8239 #[test]
8240 fn plan_submit_true_grants_the_capsule() {
8241 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n"))
8242 .expect("a plan block parses");
8243 assert_eq!(
8244 manifest.capabilities.unwrap().plan,
8245 Some(PlanCapabilities { submit: true })
8246 );
8247 }
8248
8249 #[test]
8252 fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
8253 let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: false\n"))
8254 .expect("an explicit denial parses");
8255 assert_eq!(
8256 explicit.capabilities.unwrap().plan,
8257 Some(PlanCapabilities { submit: false })
8258 );
8259
8260 let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
8261 .expect("no capabilities block parses");
8262 assert!(absent.capabilities.is_none());
8263 }
8264
8265 #[test]
8268 fn plan_without_submit_is_rejected() {
8269 let err = RuntimeManifest::from_yaml_str(&manifest_with_plan(" {}\n"))
8270 .expect_err("an empty plan block is refused");
8271 let msg = err.to_string();
8272 assert!(msg.contains("capabilities.plan.submit"), "{msg}");
8273 assert!(msg.contains("never"), "{msg}");
8274 }
8275
8276 #[test]
8278 fn an_unknown_plan_key_is_reported_by_path() {
8279 let manifest =
8280 RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n depth: 3\n"))
8281 .expect("an unknown key is a warning, not a parse error");
8282 assert!(
8283 manifest
8284 .unknown_keys
8285 .iter()
8286 .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
8287 "{:?}",
8288 manifest.unknown_keys
8289 );
8290 }
8291
8292 fn manifest_with_install(block: &str) -> String {
8296 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n install:\n{block}")
8297 }
8298
8299 #[test]
8300 fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
8301 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8302 " skill: [ana/code-review, \"ana/style-*\"]\n tool: [\"local/b-*\", local/a, \"murmur/*\", \"*\"]\n",
8303 ))
8304 .expect("an install block parses");
8305 assert_eq!(
8306 manifest.capabilities.unwrap().install,
8307 Some(InstallCapabilities {
8308 skill: vec!["ana/code-review".into(), "ana/style-*".into()],
8309 tool: vec![
8310 "local/b-*".into(),
8311 "local/a".into(),
8312 "murmur/*".into(),
8313 "*".into()
8314 ],
8315 from: InstallFrom::Stores,
8316 })
8317 );
8318 }
8319
8320 #[test]
8323 fn capabilities_install_accepts_either_list_alone() {
8324 let skill_only =
8325 RuntimeManifest::from_yaml_str(&manifest_with_install(" skill: [local/a]\n"))
8326 .expect("skill alone parses");
8327 assert_eq!(
8328 skill_only.capabilities.unwrap().install,
8329 Some(InstallCapabilities {
8330 skill: vec!["local/a".into()],
8331 tool: vec![],
8332 from: InstallFrom::Stores,
8333 })
8334 );
8335 let tool_only =
8336 RuntimeManifest::from_yaml_str(&manifest_with_install(" tool: [\"local/b-*\"]\n"))
8337 .expect("tool alone parses");
8338 assert_eq!(
8339 tool_only.capabilities.unwrap().install,
8340 Some(InstallCapabilities {
8341 skill: vec![],
8342 tool: vec!["local/b-*".into()],
8343 from: InstallFrom::Stores,
8344 })
8345 );
8346 }
8347
8348 #[test]
8349 fn capabilities_install_absent_is_none() {
8350 let manifest = RuntimeManifest::from_yaml_str(
8351 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n submit: true\n",
8352 )
8353 .expect("a capabilities block without install parses");
8354 assert_eq!(manifest.capabilities.unwrap().install, None);
8355 }
8356
8357 fn install_from(list: &str) -> Result<InstallFrom, RuntimeManifestError> {
8358 RuntimeManifest::from_yaml_str(&manifest_with_install(&format!(
8359 " tool: [\"murmur/tool-*\"]\n from: {list}\n"
8360 )))
8361 .map(|manifest| manifest.capabilities.unwrap().install.unwrap().from)
8362 }
8363
8364 #[test]
8365 fn install_from_absent_is_stores() {
8366 let manifest =
8367 RuntimeManifest::from_yaml_str(&manifest_with_install(" tool: [local/a]\n"))
8368 .unwrap();
8369 assert_eq!(
8370 manifest.capabilities.unwrap().install.unwrap().from,
8371 InstallFrom::Stores
8372 );
8373 }
8374
8375 #[test]
8376 fn install_from_accepts_stores_alone_and_with_sources_in_either_order() {
8377 assert_eq!(install_from("[stores]").unwrap(), InstallFrom::Stores);
8378 assert_eq!(
8379 install_from("[stores, sources]").unwrap(),
8380 InstallFrom::StoresAndSources
8381 );
8382 assert_eq!(
8383 install_from("[sources, stores]").unwrap(),
8384 InstallFrom::StoresAndSources
8385 );
8386 assert_eq!(InstallFrom::StoresAndSources.as_list(), "stores, sources");
8387 assert!(InstallFrom::StoresAndSources.sources());
8388 assert!(!InstallFrom::Stores.sources());
8389 }
8390
8391 #[test]
8392 fn install_from_refuses_empty_sourceless_duplicate_and_unknown_lists() {
8393 for (list, why) in [
8394 ("[]", "is empty"),
8395 ("[sources]", "does not name stores"),
8396 ("[stores, stores]", "names \"stores\" twice"),
8397 ("[github]", "names \"github\", which is not a place"),
8398 ] {
8399 match install_from(list) {
8400 Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8401 assert_eq!(field, "capabilities.install.from", "{list}");
8402 assert!(message.contains(why), "{list}: {message}");
8403 assert!(
8404 message.contains("expected [stores] or [stores, sources]"),
8405 "{list}: {message}"
8406 );
8407 }
8408 other => panic!("{list}: expected InvalidCapabilities, got {other:?}"),
8409 }
8410 }
8411 }
8412
8413 #[test]
8414 fn install_from_is_no_unknown_key() {
8415 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8416 " tool: [local/a]\n from: [stores]\n",
8417 ))
8418 .unwrap();
8419 assert!(
8420 manifest
8421 .unknown_keys
8422 .iter()
8423 .all(|key| key.block_path != "capabilities.install"),
8424 "{:?}",
8425 manifest.unknown_keys
8426 );
8427 }
8428
8429 #[test]
8430 fn install_from_capabilities_install_is_refused_under_transport_process() {
8431 let yaml = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n install:\n \
8432 tool: [\"murmur/tool-*\"]\ninference:\n transport: process\n \
8433 command: claude\n model: claude-haiku-4-5-20251001\n driver:\n \
8434 artifact: murmur/driver-claude-code\n";
8435 match RuntimeManifest::from_yaml_str(yaml) {
8436 Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8437 assert_eq!(field, "capabilities.install");
8438 assert_eq!(
8439 message,
8440 "is not valid with transport: process; the harness owns its tool list, so \
8441 a pulled artifact could never reach it"
8442 );
8443 }
8444 other => panic!("expected InvalidCapabilities, got {other:?}"),
8445 }
8446 }
8447
8448 #[test]
8451 fn capabilities_install_that_names_nothing_is_refused() {
8452 for block in [
8453 " {}\n",
8454 " skill: []\n",
8455 " skill: []\n tool: []\n",
8456 " driver: [x]\n",
8457 ] {
8458 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
8459 .expect_err("an install block naming nothing is refused");
8460 match &err {
8461 RuntimeManifestError::InvalidCapabilities { field, message } => {
8462 assert_eq!(field, "capabilities.install", "{block:?}");
8463 assert!(message.contains("at least one"), "{message}");
8464 assert!(message.contains("omit the block"), "{message}");
8465 }
8466 other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
8467 }
8468 }
8469 }
8470
8471 #[test]
8472 fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
8473 for entry in [
8474 "a*b", "*x", "**", "Foo", "-a", "a/b/c", " ", "*/x", "a/*x", "a/x*y", "A/x",
8475 ] {
8476 for key in ["skill", "tool"] {
8477 let block = format!(" {key}: [{entry:?}]\n");
8478 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8479 .expect_err("a bad entry is refused");
8480 match &err {
8481 RuntimeManifestError::InvalidArtifactName {
8482 field,
8483 message,
8484 hint,
8485 } => {
8486 assert_eq!(field, &format!("capabilities.install.{key}[0]"));
8487 assert!(message.contains(&format!("{entry:?}")), "{message}");
8488 assert!(
8489 message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
8490 "{message}"
8491 );
8492 assert_eq!(hint, &None, "{entry:?}");
8493 }
8494 other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8495 }
8496 }
8497 }
8498 }
8499
8500 #[test]
8502 fn capabilities_install_refuses_an_unscoped_entry_with_the_scoped_hint() {
8503 for (entry, scoped) in [
8504 ("tool-*", "local/tool-*"),
8505 ("mur*", "local/mur*"),
8506 ("tool-x", "local/tool-x"),
8507 ("murmur-tool-editor", "murmur/tool-editor"),
8508 ] {
8509 let block = format!(" tool: [\"local/ok\", {entry:?}]\n");
8510 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8511 .expect_err("an unscoped entry is refused");
8512 match &err {
8513 RuntimeManifestError::InvalidArtifactName {
8514 field,
8515 message,
8516 hint,
8517 } => {
8518 assert_eq!(field, "capabilities.install.tool[1]");
8519 assert_eq!(
8520 message,
8521 &format!(
8522 "entry {entry:?} is invalid ('{entry}' names no scope); expected {}",
8523 crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
8524 )
8525 );
8526 let hint = hint.as_deref().expect("an unscoped entry carries a hint");
8527 assert!(hint.contains(scoped), "{entry:?}: {hint}");
8528 }
8529 other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8530 }
8531 assert!(
8532 err.to_string().contains("'capabilities.install.tool[1]'"),
8533 "{err}"
8534 );
8535 }
8536 }
8537
8538 #[test]
8540 fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
8541 for runtime in ["tool", "hook", "driver", "skill"] {
8542 let yaml = format!(
8543 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/a\n version: 0.1.0\n \
8544 runtime: {runtime}\n capabilities:\n install:\n skill: [x]\n"
8545 );
8546 let err = RuntimeManifest::from_yaml_str(&yaml)
8547 .expect_err("a per-artifact install block is refused");
8548 match &err {
8549 RuntimeManifestError::InvalidArtifact { index, message } => {
8550 assert_eq!(*index, 0);
8551 assert!(message.contains("capabilities.install"), "{message}");
8552 assert!(message.contains("capsule-wide"), "{message}");
8553 }
8554 other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
8555 }
8556 }
8557 }
8558
8559 #[test]
8561 fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
8562 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8563 " skill: [local/a]\n driver: [x]\n",
8564 ))
8565 .expect("an unknown key beside a real entry is a warning, not a parse error");
8566 assert!(
8567 manifest
8568 .unknown_keys
8569 .iter()
8570 .any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
8571 "{:?}",
8572 manifest.unknown_keys
8573 );
8574 assert_eq!(
8575 manifest.capabilities.unwrap().install,
8576 Some(InstallCapabilities {
8577 skill: vec!["local/a".into()],
8578 tool: vec![],
8579 from: InstallFrom::Stores,
8580 })
8581 );
8582 }
8583
8584 fn manifest_with_task_io(runtime: &str, block: &str) -> String {
8588 format!(
8589 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/gate\n version: 1.2.3\n \
8590 runtime: {runtime}\n capabilities:\n task_io:\n{block}"
8591 )
8592 }
8593
8594 #[test]
8595 fn task_io_read_true_grants_the_hook() {
8596 let manifest =
8597 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: true\n"))
8598 .expect("task_io on a hook entry parses");
8599 assert_eq!(
8600 manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
8601 Some(TaskIoCapabilities { read: true })
8602 );
8603 }
8604
8605 #[test]
8608 fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
8609 let explicit =
8610 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: false\n"))
8611 .expect("an explicit denial parses");
8612 assert_eq!(
8613 explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
8614 Some(TaskIoCapabilities { read: false })
8615 );
8616
8617 let absent = RuntimeManifest::from_yaml_str(
8618 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/gate\n version: 1.2.3\n \
8619 runtime: hook\n",
8620 )
8621 .expect("no capabilities block parses");
8622 assert!(absent.artifacts[0].capabilities.is_none());
8623 }
8624
8625 #[test]
8628 fn task_io_without_read_is_rejected() {
8629 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " {}\n"))
8630 .unwrap_err();
8631 let msg = err.to_string();
8632 assert!(
8633 msg.contains("capabilities.task_io.read"),
8634 "error was: {msg}"
8635 );
8636 assert!(msg.contains("explicitly"), "error was: {msg}");
8637 }
8638
8639 #[test]
8642 fn task_io_outside_a_hook_entry_is_rejected() {
8643 for runtime in ["tool", "driver", "skill"] {
8644 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
8645 runtime,
8646 " read: true\n",
8647 ))
8648 .unwrap_err();
8649 let msg = err.to_string();
8650 assert!(
8651 msg.contains("capabilities") && msg.contains("runtime: hook"),
8652 "{runtime} entry: error was: {msg}"
8653 );
8654 }
8655 }
8656
8657 fn manifest_with_filesystem(runtime: &str, block: &str) -> String {
8661 format!(
8662 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/thing\n version: 0.1.0\n \
8663 runtime: {runtime}\n capabilities:\n filesystem:\n{block}"
8664 )
8665 }
8666
8667 #[test]
8669 fn filesystem_records_on_a_hook_entry_parses() {
8670 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8671 "hook",
8672 " records: true\n",
8673 ))
8674 .expect("records on a hook entry parses");
8675 let filesystem = manifest.artifacts[0]
8676 .capabilities
8677 .as_ref()
8678 .unwrap()
8679 .filesystem
8680 .as_ref()
8681 .unwrap();
8682 assert!(filesystem.records);
8683 assert_eq!(filesystem.scope, None);
8684
8685 let scoped =
8686 RuntimeManifest::from_yaml_str(&manifest_with_filesystem("hook", " scope: .\n"))
8687 .unwrap();
8688 let filesystem = scoped.artifacts[0]
8689 .capabilities
8690 .as_ref()
8691 .unwrap()
8692 .filesystem
8693 .as_ref()
8694 .unwrap();
8695 assert!(!filesystem.records);
8696 assert!(scoped.unknown_keys.is_empty(), "{:?}", scoped.unknown_keys);
8697 }
8698
8699 #[test]
8702 fn filesystem_records_is_refused_beside_a_scope_off_a_hook_and_capsule_wide() {
8703 let beside = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8704 "hook",
8705 " records: true\n scope: .\n",
8706 ))
8707 .unwrap_err()
8708 .to_string();
8709 assert!(
8710 beside.contains("capabilities.filesystem.records"),
8711 "{beside}"
8712 );
8713 assert!(beside.contains("capabilities.filesystem.scope"), "{beside}");
8714 for runtime in ["tool", "driver"] {
8715 let off_hook = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8716 runtime,
8717 " records: true\n",
8718 ))
8719 .unwrap_err()
8720 .to_string();
8721 assert!(
8722 off_hook.contains("capabilities.filesystem.records"),
8723 "{off_hook}"
8724 );
8725 assert!(off_hook.contains("runtime: hook"), "{off_hook}");
8726 }
8727 let capsule_wide = RuntimeManifest::from_yaml_str(
8728 "name: cap\nversion: 0.0.1\ncapabilities:\n filesystem:\n records: true\n",
8729 )
8730 .unwrap_err()
8731 .to_string();
8732 assert!(
8733 capsule_wide.contains("capabilities.filesystem.records"),
8734 "{capsule_wide}"
8735 );
8736 }
8737
8738 #[test]
8741 fn task_io_in_the_capsule_wide_block_is_rejected() {
8742 let err = RuntimeManifest::from_yaml_str(
8743 "name: cap\nversion: 0.0.1\ncapabilities:\n task_io:\n read: true\n",
8744 )
8745 .unwrap_err();
8746 let msg = err.to_string();
8747 assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
8748 assert!(msg.contains("runtime: hook"), "error was: {msg}");
8749 }
8750
8751 fn manifest_with_conversation(runtime: &str, block: &str) -> String {
8755 format!(
8756 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/memory\n version: 1.2.3\n \
8757 runtime: {runtime}\n capabilities:\n conversation:\n{block}"
8758 )
8759 }
8760
8761 #[test]
8762 fn conversation_read_lowers_onto_the_hook_entry() {
8763 for (declared, expected) in [("true", true), ("false", false)] {
8764 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8765 "hook",
8766 &format!(" read: {declared}\n"),
8767 ))
8768 .expect("conversation on a hook entry parses");
8769 assert_eq!(
8770 manifest.artifacts[0]
8771 .capabilities
8772 .as_ref()
8773 .unwrap()
8774 .conversation,
8775 Some(ConversationCapabilities { read: expected })
8776 );
8777 }
8778 }
8779
8780 #[test]
8783 fn conversation_without_read_is_rejected() {
8784 let err =
8785 RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", " {}\n"))
8786 .unwrap_err();
8787 let msg = err.to_string();
8788 assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
8789 assert!(msg.contains("explicitly"), "was: {msg}");
8790 }
8791
8792 #[test]
8795 fn conversation_outside_a_hook_entry_is_rejected() {
8796 for runtime in ["tool", "driver", "skill"] {
8797 let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8798 runtime,
8799 " read: true\n",
8800 ))
8801 .unwrap_err();
8802 let msg = err.to_string();
8803 assert!(
8804 msg.contains("capabilities") && msg.contains("runtime: hook"),
8805 "{runtime} entry: error was: {msg}"
8806 );
8807 }
8808 }
8809
8810 #[test]
8813 fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
8814 let manifest = RuntimeManifest::from_yaml_str(
8815 "name: cap\nversion: 0.0.1\ncapabilities:\n conversation:\n read: true\n",
8816 )
8817 .expect("a capsule-wide block is accepted");
8818 assert_eq!(
8819 manifest.capabilities.unwrap().conversation,
8820 Some(ConversationCapabilities { read: true })
8821 );
8822 }
8823
8824 #[test]
8829 fn a_context_block_records_by_default() {
8830 let context = context_of("context:\n max_tokens: 1000\n").unwrap();
8831 assert!(context.record);
8832 assert_eq!(context.record_store, None);
8833 }
8834
8835 #[test]
8836 fn record_off_and_on_are_the_two_accepted_values() {
8837 assert!(!context_of("context:\n record: off\n").unwrap().record);
8838 assert!(context_of("context:\n record: on\n").unwrap().record);
8839
8840 let err = RuntimeManifest::from_yaml_str(
8841 "name: cap\nversion: 0.0.1\ncontext:\n record: sometimes\n",
8842 )
8843 .unwrap_err();
8844 let msg = err.to_string();
8845 assert!(msg.contains("context.record"), "was: {msg}");
8846 assert!(msg.contains("sometimes"), "was: {msg}");
8847 }
8848
8849 #[test]
8852 fn record_store_is_kept_verbatim_and_trimmed() {
8853 assert_eq!(
8854 context_of("context:\n record_store: ' shey '\n")
8855 .unwrap()
8856 .record_store,
8857 Some("shey".to_string())
8858 );
8859 assert_eq!(
8860 context_of("context:\n record: off\n record_store: shey\n")
8861 .unwrap()
8862 .record_store,
8863 Some("shey".to_string()),
8864 "a store declared beside record: off is kept and simply never used"
8865 );
8866 }
8867
8868 #[test]
8869 fn interpreter_runtime_rejects_missing_list_dir() {
8870 let msg = interpreter_runtime_reject(
8871 " - binary: python3\n dirs:\n - path: /usr/lib/python3.11\n",
8872 );
8873 assert!(
8874 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
8875 "{msg}"
8876 );
8877 assert!(msg.contains("explicitly"), "{msg}");
8878 }
8879
8880 #[test]
8881 fn interpreter_runtime_rejects_empty_dirs() {
8882 let msg = interpreter_runtime_reject(" - binary: python3\n dirs: []\n");
8883 assert!(
8884 msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
8885 "{msg}"
8886 );
8887 assert!(msg.contains("at least one"), "{msg}");
8888 }
8889
8890 #[test]
8891 fn staged_runtime_parses_accepted_shape() {
8892 let manifest = RuntimeManifest::from_yaml_str(
8893 r#"
8894name: cap
8895version: 0.0.1
8896artifacts: []
8897capabilities:
8898 containment: sealed
8899 shell:
8900 allow:
8901 - python3
8902 - bash
8903 staged_runtime:
8904 - binary: python3
8905 source_path: /opt/testbed/conda/envs/django__django
8906 pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
8907"#,
8908 )
8909 .unwrap();
8910
8911 let shell = manifest.capabilities.unwrap().shell.unwrap();
8912 assert_eq!(
8913 shell.staged_runtime,
8914 vec![StagedRuntimeGrant {
8915 binary: "python3".to_string(),
8916 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
8917 pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
8918 }]
8919 );
8920 assert!(shell.interpreter_runtime.is_empty());
8923 }
8924
8925 #[test]
8926 fn staged_runtime_defaults_to_empty_when_absent() {
8927 let manifest = RuntimeManifest::from_yaml_str(
8928 r#"
8929name: cap
8930version: 0.0.1
8931artifacts: []
8932capabilities:
8933 shell:
8934 allow:
8935 - python3
8936"#,
8937 )
8938 .unwrap();
8939
8940 let shell = manifest.capabilities.unwrap().shell.unwrap();
8941 assert!(shell.staged_runtime.is_empty());
8942 }
8943
8944 fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
8948 let yaml = format!(
8949 r#"
8950name: cap
8951version: 0.0.1
8952artifacts: []
8953capabilities:
8954 shell:
8955 allow:
8956 - python3
8957{extra} staged_runtime:
8958{grant_yaml}
8959"#,
8960 );
8961 RuntimeManifest::from_yaml_str(&yaml)
8962 .expect_err("grant should be rejected")
8963 .to_string()
8964 }
8965
8966 #[test]
8967 fn staged_runtime_rejects_binary_not_in_allow() {
8968 let msg = staged_runtime_reject(
8969 " - binary: ruby\n source_path: /opt/ruby\n pin: ruby-3.2.2\n",
8970 "",
8971 );
8972 assert!(
8973 msg.contains("capabilities.shell.staged_runtime[0].binary"),
8974 "{msg}"
8975 );
8976 assert!(msg.contains("ruby"), "{msg}");
8977 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
8978 }
8979
8980 #[test]
8981 fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
8982 let msg = staged_runtime_reject(
8983 " - binary: python3\n source_path: /opt/py\n pin: cpython-3.9.19\n",
8984 " interpreter_runtime:\n - binary: python3\n dirs:\n - path: /usr/lib/python3.9\n list_dir: true\n",
8985 );
8986 assert!(
8987 msg.contains("capabilities.shell.staged_runtime[0].binary"),
8988 "{msg}"
8989 );
8990 assert!(msg.contains("python3"), "{msg}");
8991 assert!(msg.contains("interpreter_runtime"), "{msg}");
8992 assert!(msg.contains("mutually exclusive"), "{msg}");
8993 }
8994
8995 #[test]
8996 fn staged_runtime_rejects_relative_source_path() {
8997 let msg = staged_runtime_reject(
8998 " - binary: python3\n source_path: opt/testbed/conda\n pin: cpython-3.9.19\n",
8999 "",
9000 );
9001 assert!(
9002 msg.contains("capabilities.shell.staged_runtime[0].source_path"),
9003 "{msg}"
9004 );
9005 assert!(msg.contains("absolute"), "{msg}");
9006 }
9007
9008 #[test]
9009 fn staged_runtime_rejects_missing_or_empty_pin() {
9010 for grant in [
9013 " - binary: python3\n source_path: /opt/testbed/conda\n",
9014 " - binary: python3\n source_path: /opt/testbed/conda\n pin: \" \"\n",
9015 ] {
9016 let msg = staged_runtime_reject(grant, "");
9017 assert!(
9018 msg.contains("capabilities.shell.staged_runtime[0].pin"),
9019 "{msg}"
9020 );
9021 assert!(msg.contains("never inferred"), "{msg}");
9022 }
9023 }
9024
9025 #[test]
9026 fn parses_inference_block() {
9027 let manifest = RuntimeManifest::from_yaml_str(
9028 r#"
9029name: cap
9030version: 0.0.1
9031artifacts:
9032 - name: murmur/driver-anthropic
9033 version: 0.1.0
9034 runtime: driver
9035 gateway:
9036 endpoint: http://127.0.0.1:8080
9037 api_key: literal-key
9038inference:
9039 transport: http
9040 model: test-model
9041 driver:
9042 artifact: murmur/driver-anthropic
9043 config:
9044 temperature: 0.2
9045 headers:
9046 x-test: true
9047"#,
9048 )
9049 .unwrap();
9050
9051 assert_eq!(
9052 manifest.artifacts[0].gateway,
9053 Some(ArtifactGateway {
9054 endpoint: "http://127.0.0.1:8080".to_string(),
9055 api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
9056 keyless: false,
9057 })
9058 );
9059 let inference = manifest.inference.expect("inference should exist");
9060 assert_eq!(inference.transport, "http");
9061 assert_eq!(inference.model, "test-model");
9062 let driver = inference.driver.as_ref().expect("driver should be present");
9063 assert_eq!(driver.artifact, "murmur/driver-anthropic");
9064 assert_eq!(
9065 driver.config,
9066 Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
9067 );
9068 assert!(inference.system_prompt.is_none());
9069 assert!(inference.system_prompt_file.is_none());
9070 }
9071
9072 fn tool_gateway_manifest(block: &str) -> String {
9075 format!(
9076 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/web-search\n version: 0.1.0\n runtime: tool\n gateway:{block}\n"
9077 )
9078 }
9079
9080 fn gateway_endpoint_error(endpoint: &str) -> String {
9081 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
9082 "\n endpoint: \"{endpoint}\""
9083 )))
9084 .unwrap_err()
9085 .to_string()
9086 }
9087
9088 #[test]
9089 fn gateway_endpoint_https_is_accepted() {
9090 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9091 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
9092 ))
9093 .unwrap();
9094 assert_eq!(
9095 manifest.artifacts[0].gateway,
9096 Some(ArtifactGateway {
9097 endpoint: "https://api.tavily.com".to_string(),
9098 api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
9099 keyless: false,
9100 })
9101 );
9102 }
9103
9104 #[test]
9105 fn gateway_endpoint_http_localhost_is_accepted() {
9106 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9107 "\n endpoint: http://localhost:11434\n keyless: true",
9108 ))
9109 .unwrap();
9110 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
9111 assert_eq!(gateway.endpoint, "http://localhost:11434");
9112 assert_eq!(gateway.api_key, None);
9113 assert!(gateway.keyless);
9114 }
9115
9116 #[test]
9117 fn gateway_endpoint_http_loopback_ip_is_accepted() {
9118 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9119 "\n endpoint: http://127.0.0.1:8080\n api_key: ${LOCAL_KEY}",
9120 ))
9121 .unwrap();
9122 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
9123 assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
9124 assert_eq!(
9125 gateway.api_key,
9126 Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
9127 );
9128 }
9129
9130 #[test]
9131 fn gateway_endpoint_without_scheme_is_refused() {
9132 let msg = gateway_endpoint_error("api.tavily.com");
9133 assert!(msg.contains("gateway.endpoint"), "{msg}");
9134 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9135 assert!(msg.contains("does not parse as a URL"), "{msg}");
9136 }
9137
9138 #[test]
9139 fn gateway_endpoint_ftp_scheme_is_refused() {
9140 let msg = gateway_endpoint_error("ftp://example.com");
9141 assert!(msg.contains("gateway.endpoint"), "{msg}");
9142 assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
9143 }
9144
9145 #[test]
9146 fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
9147 let msg = gateway_endpoint_error("http://api.attacker.example.com");
9148 assert!(msg.contains("gateway.endpoint"), "{msg}");
9149 assert!(msg.contains("api.attacker.example.com"), "{msg}");
9150 assert!(msg.contains("non-loopback"), "{msg}");
9151 }
9152
9153 #[test]
9154 fn gateway_endpoint_with_a_query_is_refused() {
9155 let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
9156 assert!(msg.contains("gateway.endpoint"), "{msg}");
9157 assert!(msg.contains("carries a query"), "{msg}");
9158 }
9159
9160 #[test]
9161 fn gateway_endpoint_with_a_fragment_is_refused() {
9162 let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
9163 assert!(msg.contains("gateway.endpoint"), "{msg}");
9164 assert!(msg.contains("carries a fragment"), "{msg}");
9165 }
9166
9167 #[test]
9168 fn gateway_endpoint_with_userinfo_is_refused() {
9169 let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
9170 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9171 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
9172 assert!(msg.contains("host 'api.example.com'"), "{msg}");
9173 assert!(msg.contains("gateway.api_key"), "{msg}");
9174 assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
9175 assert!(
9176 !msg.contains("sk-in-url"),
9177 "a password in the URL is never echoed: {msg}"
9178 );
9179 }
9180
9181 #[test]
9182 fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
9183 let msg = gateway_endpoint_error("https://user:sk-in-url@");
9184 assert!(msg.contains("does not parse as a URL"), "{msg}");
9185 assert!(
9186 !msg.contains("sk-in-url"),
9187 "a password in the URL is never echoed: {msg}"
9188 );
9189 }
9190
9191 #[test]
9192 fn gateway_endpoint_with_a_masquerading_host_is_refused() {
9193 let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
9194 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
9195 assert!(
9196 msg.contains("addresses host '127.0.0.1'"),
9197 "the message names the host the URL really addresses: {msg}"
9198 );
9199 }
9200
9201 #[test]
9202 fn gateway_endpoint_with_interpolation_is_refused() {
9203 let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
9204 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9205 assert!(msg.contains("contains '${'"), "{msg}");
9206 assert!(msg.contains("not interpolated"), "{msg}");
9207 }
9208
9209 #[test]
9210 fn gateway_endpoint_http_unspecified_address_is_refused() {
9211 let msg = gateway_endpoint_error("http://0.0.0.0:8080");
9212 assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
9213 }
9214
9215 #[test]
9216 fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
9217 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9218 "\n endpoint: http://[::1]:8080\n api_key: ${LOCAL_KEY}",
9219 ))
9220 .unwrap();
9221 assert_eq!(
9222 manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
9223 "http://[::1]:8080"
9224 );
9225 }
9226
9227 fn gateway_credential_error(block: &str) -> RuntimeManifestError {
9229 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
9230 }
9231
9232 fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
9233 match &error {
9234 RuntimeManifestError::GatewayWithoutCredential {
9235 index,
9236 name,
9237 endpoint: refused,
9238 } => {
9239 assert_eq!(*index, 0);
9240 assert_eq!(name, "local/web-search");
9241 assert_eq!(refused, endpoint);
9242 }
9243 other => panic!("expected GatewayWithoutCredential, got {other:?}"),
9244 }
9245 let msg = error.to_string();
9246 assert!(
9247 msg.contains(&format!(
9248 "artifact 'local/web-search' declares gateway.endpoint '{endpoint}' but binds no \
9249 credential"
9250 )),
9251 "{msg}"
9252 );
9253 }
9254
9255 #[test]
9256 fn gateway_credential_absent_is_refused() {
9257 assert_without_credential(
9258 gateway_credential_error("\n endpoint: https://api.tavily.com"),
9259 "https://api.tavily.com",
9260 );
9261 }
9262
9263 #[test]
9264 fn gateway_credential_blank_is_refused() {
9265 for key in ["\"\"", "\" \"", "''"] {
9266 assert_without_credential(
9267 gateway_credential_error(&format!(
9268 "\n endpoint: https://api.tavily.com\n api_key: {key}"
9269 )),
9270 "https://api.tavily.com",
9271 );
9272 }
9273 }
9274
9275 #[test]
9276 fn gateway_credential_null_is_refused() {
9277 for key in ["~", "null", ""] {
9278 assert_without_credential(
9279 gateway_credential_error(&format!(
9280 "\n endpoint: https://api.tavily.com\n api_key: {key}"
9281 )),
9282 "https://api.tavily.com",
9283 );
9284 }
9285 }
9286
9287 #[test]
9288 fn gateway_credential_keyless_false_is_refused() {
9289 assert_without_credential(
9290 gateway_credential_error(
9291 "\n endpoint: https://api.tavily.com\n keyless: false",
9292 ),
9293 "https://api.tavily.com",
9294 );
9295 }
9296
9297 #[test]
9298 fn gateway_credential_keyless_is_accepted() {
9299 for (parse, mode) in [
9300 (
9301 RuntimeManifest::from_yaml_str as fn(&str) -> _,
9302 "with secrets",
9303 ),
9304 (
9305 RuntimeManifest::from_yaml_str_without_secrets,
9306 "without secrets",
9307 ),
9308 ] {
9309 let manifest = parse(&tool_gateway_manifest(
9310 "\n endpoint: https://api.example.com\n keyless: true",
9311 ))
9312 .unwrap();
9313 assert_eq!(
9314 manifest.artifacts[0].gateway,
9315 Some(ArtifactGateway {
9316 endpoint: "https://api.example.com".to_string(),
9317 api_key: None,
9318 keyless: true,
9319 }),
9320 "{mode}"
9321 );
9322 }
9323 }
9324
9325 #[test]
9326 fn gateway_credential_keyless_not_a_boolean_is_refused() {
9327 for value in ["\"true\"", "yes please", "1"] {
9328 let error = gateway_credential_error(&format!(
9329 "\n endpoint: https://api.tavily.com\n keyless: {value}"
9330 ));
9331 assert!(
9332 matches!(
9333 error,
9334 RuntimeManifestError::InvalidArtifact { index: 0, .. }
9335 ),
9336 "{value}: {error:?}"
9337 );
9338 assert!(
9339 error
9340 .to_string()
9341 .contains("gateway.keyless with a value that is not a boolean"),
9342 "{error}"
9343 );
9344 }
9345 }
9346
9347 #[test]
9348 fn gateway_credential_and_keyless_together_are_refused() {
9349 for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
9350 let error = gateway_credential_error(&format!(
9351 "\n endpoint: https://api.tavily.com\n api_key: {key}\n keyless: true"
9352 ));
9353 assert!(
9354 matches!(
9355 error,
9356 RuntimeManifestError::InvalidArtifact { index: 0, .. }
9357 ),
9358 "{error:?}"
9359 );
9360 assert!(
9361 error.to_string().contains(
9362 "artifact 'local/web-search' declares both gateway.api_key and gateway.keyless: \
9363 true; a gateway either presents a key or is keyless"
9364 ),
9365 "{error}"
9366 );
9367 }
9368 }
9369
9370 #[test]
9371 fn gateway_credential_absent_is_refused_without_secrets() {
9372 for block in [
9373 "\n endpoint: https://api.tavily.com",
9374 "\n endpoint: https://api.tavily.com\n api_key: \"\"",
9375 "\n endpoint: https://api.tavily.com\n keyless: false",
9376 ] {
9377 assert_without_credential(
9378 RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
9379 .unwrap_err(),
9380 "https://api.tavily.com",
9381 );
9382 }
9383 let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
9384 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
9385 ))
9386 .unwrap();
9387 let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
9388 assert_eq!(gateway.api_key, None, "the key reference is left unread");
9389 assert!(!gateway.keyless, "a keyed gateway is never keyless");
9390 }
9391
9392 #[test]
9395 fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
9396 for endpoint in [
9397 "http://127.0.0.1:11434",
9398 "http://localhost:11434",
9399 "http://[::1]:11434",
9400 "https://10.0.0.5",
9401 "https://192.168.1.10:8443",
9402 ] {
9403 assert_without_credential(
9404 gateway_credential_error(&format!("\n endpoint: {endpoint}")),
9405 endpoint,
9406 );
9407 }
9408 }
9409
9410 #[test]
9411 fn gateway_endpoint_missing_from_the_block_is_refused() {
9412 for block in [
9413 "\n api_key: ${TAVILY_API_KEY}",
9414 "",
9415 " {}",
9416 "\n endpoint: \" \"",
9417 ] {
9418 let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
9419 .unwrap_err()
9420 .to_string();
9421 assert!(
9422 msg.contains("without 'gateway.endpoint'"),
9423 "{block:?}: {msg}"
9424 );
9425 assert!(
9426 msg.contains("artifact 'local/web-search'"),
9427 "{block:?}: {msg}"
9428 );
9429 }
9430 }
9431
9432 #[test]
9433 fn gateway_block_unknown_key_is_reported() {
9434 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9435 "\n endpoint: https://api.tavily.com\n keyless: true\n api_kee: x",
9436 ))
9437 .unwrap();
9438 let unknown = manifest.unknown_keys;
9439 assert_eq!(unknown.len(), 1, "{unknown:?}");
9440 assert_eq!(unknown[0].key, "api_kee");
9441 assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
9442 }
9443
9444 #[test]
9445 fn gateway_on_skill_entry_is_refused() {
9446 let msg = RuntimeManifest::from_yaml_str(
9447 "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",
9448 )
9449 .unwrap_err()
9450 .to_string();
9451 assert!(
9452 msg.contains("artifact 'local/notes' declares 'gateway:'"),
9453 "{msg}"
9454 );
9455 assert!(msg.contains("runtime: skill"), "{msg}");
9456 }
9457
9458 #[test]
9459 fn gateway_on_hook_entry_is_accepted() {
9460 let manifest = RuntimeManifest::from_yaml_str(
9461 "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",
9462 )
9463 .unwrap();
9464 assert!(manifest.artifacts[0].gateway.is_some());
9465 }
9466
9467 fn http_driver_manifest(artifacts: &str) -> String {
9470 format!(
9471 "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur/driver-anthropic\n"
9472 )
9473 }
9474
9475 #[test]
9476 fn http_driver_entry_without_gateway_endpoint_is_refused() {
9477 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9478 "\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver",
9479 ))
9480 .unwrap_err()
9481 .to_string();
9482 assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9483 assert!(msg.contains("gateway.endpoint"), "{msg}");
9484 }
9485
9486 #[test]
9487 fn http_driver_absent_from_artifacts_is_refused() {
9488 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
9489 .unwrap_err()
9490 .to_string();
9491 assert!(msg.contains("inference.driver.artifact"), "{msg}");
9492 assert!(msg.contains("not declared in artifacts:"), "{msg}");
9493 }
9494
9495 #[test]
9496 fn gateway_on_unconfigured_driver_entry_is_refused() {
9497 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9498 "\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",
9499 ))
9500 .unwrap_err()
9501 .to_string();
9502 assert!(msg.contains("'murmur/driver-openai'"), "{msg}");
9503 assert!(
9504 msg.contains("accepted only on the entry inference.driver.artifact names"),
9505 "{msg}"
9506 );
9507
9508 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";
9509 let msg = RuntimeManifest::from_yaml_str(no_inference)
9510 .unwrap_err()
9511 .to_string();
9512 assert!(msg.contains("not the configured inference driver"), "{msg}");
9513 }
9514
9515 #[test]
9516 fn gateway_on_driver_entry_under_process_transport_is_refused() {
9517 let msg = RuntimeManifest::from_yaml_str(
9518 "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",
9519 )
9520 .unwrap_err()
9521 .to_string();
9522 assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9523 assert!(msg.contains("transport: process"), "{msg}");
9524 }
9525
9526 #[test]
9527 fn inference_api_key_is_refused_naming_gateway_api_key() {
9528 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";
9529 for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
9530 let manifest = http_driver_manifest(entry)
9531 .replace(" model:", &format!(" api_key: {value}\n model:"));
9532 let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
9533 let msg = err.to_string();
9534 assert!(msg.contains("'inference.api_key'"), "{msg}");
9535 assert!(msg.contains("was removed"), "{msg}");
9536 assert!(
9537 msg.contains(
9538 "set gateway.api_key on the artifacts: entry 'murmur/driver-anthropic'"
9539 ),
9540 "{msg}"
9541 );
9542 assert!(!msg.contains("sk-literal"), "{msg}");
9543 }
9544
9545 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n api_key: ${KEY}\n";
9546 let msg = RuntimeManifest::from_yaml_str(process)
9547 .unwrap_err()
9548 .to_string();
9549 assert!(msg.contains("'inference.api_key'"), "{msg}");
9550 assert!(
9551 msg.contains("on the artifacts: entry inference.driver.artifact names"),
9552 "{msg}"
9553 );
9554 assert!(
9555 RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
9556 "the refusal does not depend on resolving secrets"
9557 );
9558 }
9559
9560 #[test]
9561 fn inference_endpoint_is_refused_naming_gateway_endpoint() {
9562 let manifest = http_driver_manifest(
9563 "\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",
9564 )
9565 .replace(" model:", " endpoint: https://api.anthropic.com\n model:");
9566 let msg = RuntimeManifest::from_yaml_str(&manifest)
9567 .unwrap_err()
9568 .to_string();
9569 assert!(msg.contains("'inference.endpoint'"), "{msg}");
9570 assert!(
9571 msg.contains("set gateway.endpoint on the artifacts: entry 'murmur/driver-anthropic'"),
9572 "{msg}"
9573 );
9574
9575 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n endpoint: http://localhost:8080\n";
9576 let msg = RuntimeManifest::from_yaml_str(process)
9577 .unwrap_err()
9578 .to_string();
9579 assert!(msg.contains("'inference.endpoint'"), "{msg}");
9580 assert!(msg.contains("was removed"), "{msg}");
9581 }
9582
9583 #[test]
9584 fn without_secrets_leaves_gateway_api_key_unread() {
9585 let manifest = tool_gateway_manifest(
9586 "\n endpoint: https://api.tavily.com\n api_key: tvly-literal",
9587 );
9588 let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
9589 assert_eq!(
9590 resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
9591 Some(ApiKeyReference::Literal("tvly-literal".to_string()))
9592 );
9593 let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
9594 let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
9595 assert_eq!(gateway.endpoint, "https://api.tavily.com");
9596 assert_eq!(gateway.api_key, None);
9597 }
9598
9599 #[test]
9600 fn referenced_env_variables_reads_gateway_api_key() {
9601 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";
9602 let referenced = referenced_env_variables(manifest);
9603 assert_eq!(
9604 referenced,
9605 vec![
9606 ReferencedEnvVariable {
9607 field: "artifacts.murmur/driver-anthropic.gateway.api_key".to_string(),
9608 variable: "ANTHROPIC_API_KEY".to_string(),
9609 },
9610 ReferencedEnvVariable {
9611 field: "artifacts.local/web-search.gateway.api_key".to_string(),
9612 variable: "TAVILY_API_KEY".to_string(),
9613 },
9614 ]
9615 );
9616 assert!(referenced
9617 .iter()
9618 .all(ReferencedEnvVariable::is_gateway_credential));
9619 }
9620
9621 #[test]
9622 fn parses_inline_system_prompt() {
9623 let manifest = RuntimeManifest::from_yaml_str(
9624 r#"
9625name: cap
9626version: 0.0.1
9627artifacts:
9628 - name: murmur/driver-anthropic
9629 version: 0.1.0
9630 runtime: driver
9631 gateway:
9632 endpoint: http://127.0.0.1:8080
9633 keyless: true
9634inference:
9635 model: test-model
9636 system_prompt: "Always begin with CONFIRMED:"
9637 driver:
9638 artifact: murmur/driver-anthropic
9639"#,
9640 )
9641 .unwrap();
9642
9643 let inference = manifest.inference.unwrap();
9644 assert_eq!(
9645 inference.system_prompt,
9646 Some("Always begin with CONFIRMED:".to_string())
9647 );
9648 assert!(inference.system_prompt_file.is_none());
9649 }
9650
9651 #[test]
9652 fn parses_compaction_system_prompt() {
9653 let manifest = RuntimeManifest::from_yaml_str(
9654 r#"
9655name: cap
9656version: 0.0.1
9657artifacts:
9658 - name: murmur/driver-anthropic
9659 version: 0.1.0
9660 runtime: driver
9661 gateway:
9662 endpoint: http://127.0.0.1:8080
9663 keyless: true
9664inference:
9665 model: test-model
9666 compaction:
9667 model: compaction-model
9668 system_prompt: "task = X, currently editing Y, already tried Z."
9669 driver:
9670 artifact: murmur/driver-anthropic
9671"#,
9672 )
9673 .unwrap();
9674
9675 let compaction = manifest.inference.unwrap().compaction.unwrap();
9676 assert_eq!(
9677 compaction.system_prompt,
9678 Some("task = X, currently editing Y, already tried Z.".to_string())
9679 );
9680 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9681 }
9682
9683 #[test]
9686 fn compaction_system_prompt_absent_stays_none() {
9687 let manifest = RuntimeManifest::from_yaml_str(
9688 r#"
9689name: cap
9690version: 0.0.1
9691artifacts:
9692 - name: murmur/driver-anthropic
9693 version: 0.1.0
9694 runtime: driver
9695 gateway:
9696 endpoint: http://127.0.0.1:8080
9697 keyless: true
9698inference:
9699 model: test-model
9700 compaction:
9701 model: compaction-model
9702 driver:
9703 artifact: murmur/driver-anthropic
9704"#,
9705 )
9706 .unwrap();
9707
9708 let compaction = manifest.inference.unwrap().compaction.unwrap();
9709 assert!(compaction.system_prompt.is_none());
9710 assert!(compaction.system_prompt_file.is_none());
9711 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9712 }
9713
9714 #[test]
9718 fn parses_compaction_system_prompt_file() {
9719 let manifest = RuntimeManifest::from_yaml_str(
9720 r#"
9721name: cap
9722version: 0.0.1
9723artifacts:
9724 - name: murmur/driver-anthropic
9725 version: 0.1.0
9726 runtime: driver
9727 gateway:
9728 endpoint: http://127.0.0.1:8080
9729 keyless: true
9730inference:
9731 model: test-model
9732 compaction:
9733 model: compaction-model
9734 system_prompt_file: "compaction-instructions.md"
9735 driver:
9736 artifact: murmur/driver-anthropic
9737"#,
9738 )
9739 .unwrap();
9740
9741 let compaction = manifest.inference.unwrap().compaction.unwrap();
9742 assert_eq!(
9743 compaction.system_prompt_file,
9744 Some("compaction-instructions.md".to_string())
9745 );
9746 assert!(compaction.system_prompt.is_none());
9747 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9748 }
9749
9750 #[test]
9753 fn compaction_system_prompt_file_independent_of_model() {
9754 let manifest = RuntimeManifest::from_yaml_str(
9755 r#"
9756name: cap
9757version: 0.0.1
9758artifacts:
9759 - name: murmur/driver-anthropic
9760 version: 0.1.0
9761 runtime: driver
9762 gateway:
9763 endpoint: http://127.0.0.1:8080
9764 keyless: true
9765inference:
9766 model: test-model
9767 compaction:
9768 threshold: 0.5
9769 system_prompt_file: "compaction-instructions.md"
9770 driver:
9771 artifact: murmur/driver-anthropic
9772"#,
9773 )
9774 .unwrap();
9775
9776 let compaction = manifest.inference.unwrap().compaction.unwrap();
9777 assert_eq!(
9778 compaction.system_prompt_file,
9779 Some("compaction-instructions.md".to_string())
9780 );
9781 assert!(compaction.model.is_none());
9782 assert_eq!(compaction.threshold, Some(0.5));
9783 }
9784
9785 #[test]
9789 fn parses_compaction_dump_summaries() {
9790 let parse = |line: &str| {
9791 RuntimeManifest::from_yaml_str(&format!(
9792 r#"
9793name: cap
9794version: 0.0.1
9795artifacts:
9796 - name: murmur/driver-anthropic
9797 version: 0.1.0
9798 runtime: driver
9799 gateway:
9800 endpoint: http://127.0.0.1:8080
9801 keyless: true
9802inference:
9803 model: test-model
9804 compaction:
9805 model: compaction-model
9806{line} driver:
9807 artifact: murmur/driver-anthropic
9808"#
9809 ))
9810 .unwrap()
9811 .inference
9812 .unwrap()
9813 .compaction
9814 .unwrap()
9815 };
9816
9817 assert_eq!(
9818 parse(" dump_summaries: true\n").dump_summaries,
9819 Some(true)
9820 );
9821 assert_eq!(
9822 parse(" dump_summaries: false\n").dump_summaries,
9823 Some(false)
9824 );
9825 assert_eq!(parse("").dump_summaries, None);
9826 }
9827
9828 #[test]
9832 fn compaction_dump_summaries_does_not_interact_with_other_fields() {
9833 let compaction = RuntimeManifest::from_yaml_str(
9834 r#"
9835name: cap
9836version: 0.0.1
9837artifacts:
9838 - name: murmur/driver-anthropic
9839 version: 0.1.0
9840 runtime: driver
9841 gateway:
9842 endpoint: http://127.0.0.1:8080
9843 keyless: true
9844inference:
9845 model: test-model
9846 compaction:
9847 threshold: 0.5
9848 model: compaction-model
9849 system_prompt_file: "compaction-instructions.md"
9850 dump_summaries: true
9851 driver:
9852 artifact: murmur/driver-anthropic
9853"#,
9854 )
9855 .unwrap()
9856 .inference
9857 .unwrap()
9858 .compaction
9859 .unwrap();
9860
9861 assert_eq!(compaction.dump_summaries, Some(true));
9862 assert_eq!(compaction.threshold, Some(0.5));
9863 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9864 assert_eq!(
9865 compaction.system_prompt_file,
9866 Some("compaction-instructions.md".to_string())
9867 );
9868 assert!(compaction.system_prompt.is_none());
9869
9870 let err = RuntimeManifest::from_yaml_str(
9872 r#"
9873name: cap
9874version: 0.0.1
9875artifacts:
9876 - name: murmur/driver-anthropic
9877 version: 0.1.0
9878 runtime: driver
9879 gateway:
9880 endpoint: http://127.0.0.1:8080
9881 keyless: true
9882inference:
9883 model: test-model
9884 compaction:
9885 dump_summaries: true
9886 system_prompt: "inline"
9887 system_prompt_file: "compaction-instructions.md"
9888 driver:
9889 artifact: murmur/driver-anthropic
9890"#,
9891 )
9892 .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
9893 assert!(matches!(
9894 err,
9895 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9896 if field == "inference.compaction.system_prompt"
9897 ));
9898
9899 let err = RuntimeManifest::from_yaml_str(
9901 r#"
9902name: cap
9903version: 0.0.1
9904artifacts:
9905 - name: murmur/driver-anthropic
9906 version: 0.1.0
9907 runtime: driver
9908 gateway:
9909 endpoint: http://127.0.0.1:8080
9910 keyless: true
9911inference:
9912 model: test-model
9913 compaction:
9914 dump_summaries: true
9915 threshold: 1.5
9916 driver:
9917 artifact: murmur/driver-anthropic
9918"#,
9919 )
9920 .expect_err("dump_summaries must not suppress the threshold range check");
9921 assert!(matches!(
9922 err,
9923 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9924 if field == "inference.compaction.threshold"
9925 ));
9926 }
9927
9928 fn compaction_call_manifest(lines: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
9929 RuntimeManifest::from_yaml_str(&format!(
9930 r#"
9931name: cap
9932version: 0.0.1
9933artifacts:
9934 - name: murmur/driver-anthropic
9935 version: 0.1.0
9936 runtime: driver
9937 gateway:
9938 endpoint: http://127.0.0.1:8080
9939 keyless: true
9940inference:
9941 model: test-model
9942 driver:
9943 artifact: murmur/driver-anthropic
9944 config:
9945 reasoning_effort: max
9946 compaction:
9947{lines}"#
9948 ))
9949 }
9950
9951 #[test]
9952 fn parses_compaction_max_tokens_and_driver_config() {
9953 let manifest = compaction_call_manifest(
9954 " max_tokens: 8000\n driver_config:\n reasoning_effort: low\n extra: [1, 2]\n",
9955 )
9956 .unwrap();
9957 assert!(
9958 manifest.unknown_keys.is_empty(),
9959 "neither key is unknown: {:?}",
9960 manifest.unknown_keys
9961 );
9962 let compaction = manifest.inference.unwrap().compaction.unwrap();
9963 assert_eq!(compaction.max_tokens, Some(8000));
9964 assert_eq!(
9965 compaction.driver_config.as_deref(),
9966 Some(r#"{"extra":[1,2],"reasoning_effort":"low"}"#)
9967 );
9968
9969 let compaction = compaction_call_manifest(" threshold: 0.5\n")
9970 .unwrap()
9971 .inference
9972 .unwrap()
9973 .compaction
9974 .unwrap();
9975 assert_eq!(compaction.max_tokens, None);
9976 assert_eq!(compaction.driver_config, None);
9977
9978 let compaction = compaction_call_manifest(" driver_config: {}\n")
9980 .unwrap()
9981 .inference
9982 .unwrap()
9983 .compaction
9984 .unwrap();
9985 assert_eq!(compaction.driver_config.as_deref(), Some("{}"));
9986 }
9987
9988 #[test]
9989 fn compaction_max_tokens_zero_is_refused() {
9990 match compaction_call_manifest(" max_tokens: 0\n").unwrap_err() {
9991 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9992 assert_eq!(field, "inference.compaction.max_tokens");
9993 assert_eq!(message, "must be greater than 0");
9994 }
9995 other => panic!("unexpected error: {other:?}"),
9996 }
9997 }
9998
9999 #[test]
10000 fn compaction_driver_config_must_be_a_mapping() {
10001 for value in ["low", "[reasoning_effort]", "3"] {
10002 match compaction_call_manifest(&format!(" driver_config: {value}\n")).unwrap_err() {
10003 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10004 assert_eq!(field, "inference.compaction.driver_config");
10005 assert_eq!(message, "expected mapping/object");
10006 }
10007 other => panic!("unexpected error for {value}: {other:?}"),
10008 }
10009 }
10010 }
10011
10012 #[test]
10015 fn process_transport_rejects_compaction_call_keys() {
10016 for (key, value) in [
10017 ("max_tokens", "8000"),
10018 ("driver_config", "{reasoning_effort: low}"),
10019 ] {
10020 let err = RuntimeManifest::from_yaml_str(&format!(
10021 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-claude-code\n \
10022 version: 0.1.0\n runtime: driver\ninference:\n transport: process\n \
10023 model: claude-haiku-4-5-20251001\n driver:\n artifact: murmur/driver-claude-code\n \
10024 compaction:\n {key}: {value}\n"
10025 ))
10026 .unwrap_err();
10027 match err {
10028 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10029 assert_eq!(field, format!("inference.compaction.{key}"));
10030 assert_eq!(message, "is not valid with transport: process");
10031 }
10032 other => panic!("unexpected error for {key}: {other:?}"),
10033 }
10034 }
10035 }
10036
10037 #[test]
10041 fn compaction_prompt_sources_are_mutually_exclusive() {
10042 let err = RuntimeManifest::from_yaml_str(
10043 r#"
10044name: cap
10045version: 0.0.1
10046artifacts:
10047 - name: murmur/driver-anthropic
10048 version: 0.1.0
10049 runtime: driver
10050 gateway:
10051 endpoint: http://127.0.0.1:8080
10052 keyless: true
10053inference:
10054 model: test-model
10055 compaction:
10056 system_prompt: "inline"
10057 system_prompt_file: "compaction-instructions.md"
10058 driver:
10059 artifact: murmur/driver-anthropic
10060"#,
10061 )
10062 .expect_err("both compaction prompt sources set must fail");
10063
10064 match err {
10065 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10066 assert_eq!(field, "inference.compaction.system_prompt");
10067 assert_eq!(
10068 message,
10069 "at most one of inference.compaction.system_prompt, \
10070 inference.compaction.system_prompt_file may be set"
10071 );
10072 }
10073 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
10074 }
10075 }
10076
10077 #[test]
10081 fn top_level_and_compaction_prompt_sources_do_not_collide() {
10082 let manifest = RuntimeManifest::from_yaml_str(
10083 r#"
10084name: cap
10085version: 0.0.1
10086artifacts:
10087 - name: murmur/driver-anthropic
10088 version: 0.1.0
10089 runtime: driver
10090 gateway:
10091 endpoint: http://127.0.0.1:8080
10092 keyless: true
10093inference:
10094 model: test-model
10095 system_prompt: "be helpful"
10096 compaction:
10097 system_prompt_file: "compaction-instructions.md"
10098 driver:
10099 artifact: murmur/driver-anthropic
10100"#,
10101 )
10102 .unwrap();
10103
10104 let inference = manifest.inference.unwrap();
10105 assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
10106 assert_eq!(
10107 inference.compaction.unwrap().system_prompt_file,
10108 Some("compaction-instructions.md".to_string())
10109 );
10110 }
10111
10112 #[test]
10113 fn parses_system_prompt_file() {
10114 let manifest = RuntimeManifest::from_yaml_str(
10115 r#"
10116name: cap
10117version: 0.0.1
10118artifacts:
10119 - name: murmur/driver-anthropic
10120 version: 0.1.0
10121 runtime: driver
10122 gateway:
10123 endpoint: http://127.0.0.1:8080
10124 keyless: true
10125inference:
10126 model: test-model
10127 system_prompt_file: "conventions.md"
10128 driver:
10129 artifact: murmur/driver-anthropic
10130"#,
10131 )
10132 .unwrap();
10133
10134 let inference = manifest.inference.unwrap();
10135 assert_eq!(
10136 inference.system_prompt_file,
10137 Some("conventions.md".to_string())
10138 );
10139 assert!(inference.system_prompt.is_none());
10140 }
10141
10142 #[test]
10143 fn rejects_both_inline_and_file_system_prompt() {
10144 let err = RuntimeManifest::from_yaml_str(
10145 r#"
10146name: cap
10147version: 0.0.1
10148artifacts:
10149 - name: murmur/driver-anthropic
10150 version: 0.1.0
10151 runtime: driver
10152 gateway:
10153 endpoint: http://127.0.0.1:8080
10154 keyless: true
10155inference:
10156 model: test-model
10157 system_prompt: "inline"
10158 system_prompt_file: "conventions.md"
10159 driver:
10160 artifact: murmur/driver-anthropic
10161"#,
10162 )
10163 .unwrap_err();
10164
10165 let msg = err.to_string();
10166 assert!(msg.contains("inference.system_prompt"));
10167 assert!(msg.contains("system_prompt_file"));
10168 }
10169
10170 fn instruction_files_manifest(transport: &str, entries: &str) -> String {
10171 let (artifact, inference) = if transport == "process" {
10172 (
10173 " - name: murmur/driver-claude-code\n version: 0.1.0\n runtime: driver\n",
10174 " transport: process\n driver:\n artifact: murmur/driver-claude-code\n",
10175 )
10176 } else {
10177 (
10178 " - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n \
10179 gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\n",
10180 " model: test-model\n driver:\n artifact: murmur/driver-anthropic\n",
10181 )
10182 };
10183 format!(
10184 "name: cap\nversion: 0.0.1\nartifacts:\n{artifact}inference:\n{inference} \
10185 instruction_files: {entries}\n"
10186 )
10187 }
10188
10189 #[test]
10190 fn instruction_files_absent_is_none() {
10191 let manifest = RuntimeManifest::from_yaml_str(
10192 r#"
10193name: cap
10194version: 0.0.1
10195artifacts:
10196 - name: murmur/driver-anthropic
10197 version: 0.1.0
10198 runtime: driver
10199 gateway:
10200 endpoint: http://127.0.0.1:8080
10201 keyless: true
10202inference:
10203 model: test-model
10204 driver:
10205 artifact: murmur/driver-anthropic
10206"#,
10207 )
10208 .unwrap();
10209 assert_eq!(manifest.inference.unwrap().instruction_files, None);
10210 }
10211
10212 #[test]
10215 fn instruction_files_are_kept_verbatim_in_order_on_both_transports() {
10216 for transport in ["http", "process"] {
10217 let manifest = RuntimeManifest::from_yaml_str(&instruction_files_manifest(
10218 transport,
10219 "[CLAUDE.md, ./AGENTS.md, docs/agents/RULES.md]",
10220 ))
10221 .unwrap();
10222 assert!(
10223 manifest.unknown_keys.is_empty(),
10224 "{transport}: {:?}",
10225 manifest.unknown_keys
10226 );
10227 assert_eq!(
10228 manifest.inference.unwrap().instruction_files,
10229 Some(vec![
10230 "CLAUDE.md".to_string(),
10231 "./AGENTS.md".to_string(),
10232 "docs/agents/RULES.md".to_string(),
10233 ]),
10234 "{transport}"
10235 );
10236 }
10237 }
10238
10239 #[test]
10240 fn instruction_files_empty_list_is_some_empty() {
10241 let manifest =
10242 RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", "[]")).unwrap();
10243 assert_eq!(manifest.inference.unwrap().instruction_files, Some(vec![]));
10244 }
10245
10246 #[test]
10249 fn instruction_files_refuse_entries_that_leave_the_work_folder() {
10250 for (entries, index, entry, problem) in [
10251 ("[/etc/passwd]", 0, "/etc/passwd", "is absolute"),
10252 (
10253 "[AGENTS.md, ../AGENTS.md]",
10254 1,
10255 "../AGENTS.md",
10256 "'..' component",
10257 ),
10258 ("[a/../../b]", 0, "a/../../b", "'..' component"),
10259 ("[\"\"]", 0, "", "is empty"),
10260 ("[AGENTS.md, \" \"]", 1, " ", "is empty"),
10261 ] {
10262 let err = RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", entries))
10263 .unwrap_err();
10264 let RuntimeManifestError::InvalidInferenceConfig { field, message } = &err else {
10265 panic!("{entries}: expected InvalidInferenceConfig, got {err:?}");
10266 };
10267 assert_eq!(field, &format!("inference.instruction_files[{index}]"));
10268 assert!(message.contains(&format!("'{entry}'")), "{message}");
10269 assert!(message.contains(problem), "{message}");
10270 assert!(
10271 message.contains("must be a relative path inside the work folder"),
10272 "{message}"
10273 );
10274 }
10275 }
10276
10277 #[test]
10280 fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
10281 let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
10282 let yaml = format!(
10283 "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"
10284 );
10285
10286 assert_eq!(
10287 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
10288 .gateway
10289 .clone()
10290 .unwrap()
10291 .api_key,
10292 Some(ApiKeyReference::Environment(key.to_string()))
10293 );
10294 assert_eq!(
10295 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
10296 .unwrap()
10297 .artifacts[0]
10298 .gateway
10299 .clone()
10300 .unwrap()
10301 .api_key,
10302 None
10303 );
10304 }
10305
10306 #[test]
10307 fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
10308 let literal = ["sk-", "ant-", "debug"].concat();
10309 let debug = format!(
10310 "{:?} {:?}",
10311 ApiKeyReference::Environment("SOME_KEY".to_string()),
10312 ApiKeyReference::Literal(literal.clone())
10313 );
10314 assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
10315 assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
10316 assert!(!debug.contains(&literal), "{debug}");
10317 }
10318
10319 #[test]
10320 fn parse_without_secrets_drops_a_literal_api_key() {
10321 let literal = ["sk-", "ant-", "literal"].concat();
10324 let yaml = format!(
10325 "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"
10326 );
10327
10328 assert_eq!(
10329 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
10330 .gateway
10331 .clone()
10332 .unwrap()
10333 .api_key,
10334 Some(ApiKeyReference::Literal(literal))
10335 );
10336 assert_eq!(
10337 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
10338 .unwrap()
10339 .artifacts[0]
10340 .gateway
10341 .clone()
10342 .unwrap()
10343 .api_key,
10344 None
10345 );
10346 }
10347
10348 #[test]
10351 fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
10352 let process_with_key = format!(
10353 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n model: test-model\n api_key: {}\n",
10354 ["sk-", "ant-", "secret"].concat()
10355 );
10356 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";
10357
10358 for yaml in [process_with_key.as_str(), http_without_model] {
10359 let resolving = RuntimeManifest::from_yaml_str(yaml)
10360 .unwrap_err()
10361 .to_string();
10362 let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
10363 .unwrap_err()
10364 .to_string();
10365 assert_eq!(resolving, without_secrets);
10366 }
10367 }
10368
10369 #[test]
10373 fn referenced_env_variables_reports_a_reference_and_nothing_else() {
10374 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";
10375 let referenced = referenced_env_variables(referencing);
10376 assert_eq!(referenced.len(), 1);
10377 assert_eq!(
10378 referenced[0].field,
10379 "artifacts.murmur/driver-anthropic.gateway.api_key"
10380 );
10381 assert!(referenced[0].is_gateway_credential());
10382 assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
10383
10384 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";
10385 assert!(referenced_env_variables(literal).is_empty());
10386
10387 let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
10388 assert!(referenced_env_variables(no_inference).is_empty());
10389
10390 let unparseable = "name: broken\nversion: [\n";
10391 assert!(referenced_env_variables(unparseable).is_empty());
10392 }
10393
10394 #[test]
10395 fn inference_requires_driver_artifact() {
10396 let err = RuntimeManifest::from_yaml_str(
10397 r#"
10398name: cap
10399version: 0.0.1
10400artifacts: []
10401inference:
10402 transport: http
10403 endpoint: http://127.0.0.1:8080
10404 model: test-model
10405"#,
10406 )
10407 .unwrap_err();
10408
10409 assert!(err.to_string().contains("inference.driver"));
10410 }
10411
10412 #[test]
10413 fn test_runtime_driver_deserializes() {
10414 let manifest = RuntimeManifest::from_yaml_str(
10415 r#"
10416name: cap
10417version: 0.0.1
10418artifacts:
10419 - name: local/my-provider
10420 version: 1.0.0
10421 runtime: driver
10422"#,
10423 )
10424 .unwrap();
10425
10426 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
10427 }
10428
10429 #[test]
10430 fn test_runtime_unknown_is_error() {
10431 let err = RuntimeManifest::from_yaml_str(
10432 r#"
10433name: cap
10434version: 0.0.1
10435artifacts:
10436 - name: local/foo
10437 version: 1.0.0
10438 runtime: unknown_type
10439"#,
10440 )
10441 .unwrap_err();
10442
10443 let msg = err.to_string();
10444 assert!(msg.contains("runtime"), "error should mention 'runtime'");
10445 assert!(
10446 msg.contains("unknown_type"),
10447 "error should identify the bad value"
10448 );
10449 }
10450
10451 #[test]
10452 fn test_runtime_all_variants_roundtrip() {
10453 for variant in [
10454 ArtifactRuntime::Tool,
10455 ArtifactRuntime::Driver,
10456 ArtifactRuntime::Hook,
10457 ArtifactRuntime::Skill,
10458 ] {
10459 let json = serde_json::to_string(&variant).unwrap();
10460 let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
10461 assert_eq!(variant, back);
10462 }
10463 }
10464
10465 #[test]
10466 fn skill_source_without_version_defaults_to_local() {
10467 let manifest = RuntimeManifest::from_yaml_str(
10468 r#"
10469name: cap
10470version: 0.0.1
10471artifacts:
10472 - name: my-skill
10473 source: ./skills/my-skill/skill.md
10474 runtime: skill
10475"#,
10476 )
10477 .unwrap();
10478
10479 assert_eq!(manifest.artifacts.len(), 1);
10480 let artifact = &manifest.artifacts[0];
10481 assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
10482 assert_eq!(
10483 artifact.source.as_deref(),
10484 Some("./skills/my-skill/skill.md")
10485 );
10486 assert_eq!(artifact.version, "local");
10487 }
10488
10489 #[test]
10490 fn skill_source_directory_path_is_accepted() {
10491 let manifest = RuntimeManifest::from_yaml_str(
10492 r#"
10493name: cap
10494version: 0.0.1
10495artifacts:
10496 - name: my-skill
10497 source: ./skills/my-skill/
10498 runtime: skill
10499"#,
10500 )
10501 .unwrap();
10502
10503 assert_eq!(
10504 manifest.artifacts[0].source.as_deref(),
10505 Some("./skills/my-skill/")
10506 );
10507 assert_eq!(manifest.artifacts[0].version, "local");
10508 }
10509
10510 #[test]
10511 fn skill_source_with_explicit_version_substitutes_local() {
10512 let manifest = RuntimeManifest::from_yaml_str(
10514 r#"
10515name: cap
10516version: 0.0.1
10517artifacts:
10518 - name: my-skill
10519 source: ./skills/my-skill/skill.md
10520 version: 9.9.9
10521 runtime: skill
10522"#,
10523 )
10524 .unwrap();
10525
10526 assert_eq!(manifest.artifacts[0].version, "local");
10527 }
10528
10529 #[test]
10530 fn local_source_true_allows_source_on_non_skill_runtime() {
10531 for runtime in ["tool", "driver", "hook"] {
10532 let yaml = format!(
10533 r#"
10534name: cap
10535version: 0.0.1
10536artifacts:
10537 - name: my-thing
10538 source: ./local/path
10539 local_source: true
10540 runtime: {runtime}
10541"#
10542 );
10543 let manifest = RuntimeManifest::from_yaml_str(&yaml)
10544 .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
10545 let artifact = &manifest.artifacts[0];
10546 assert!(artifact.local_source, "runtime {runtime}");
10547 assert_eq!(artifact.source.as_deref(), Some("./local/path"));
10548 assert_eq!(artifact.version, "local", "runtime {runtime}");
10549 }
10550 }
10551
10552 #[test]
10553 fn local_source_false_on_skill_with_source_is_error() {
10554 let err = RuntimeManifest::from_yaml_str(
10557 r#"
10558name: cap
10559version: 0.0.1
10560artifacts:
10561 - name: my-skill
10562 source: ./skills/my-skill
10563 local_source: false
10564 runtime: skill
10565"#,
10566 )
10567 .unwrap_err();
10568 let msg = err.to_string();
10569 assert!(msg.contains("'local_source: true'"), "error was: {msg}");
10570 assert!(msg.contains("my-skill"), "error was: {msg}");
10571 }
10572
10573 #[test]
10574 fn local_source_defaults_from_role_when_absent() {
10575 let manifest = RuntimeManifest::from_yaml_str(
10576 r#"
10577name: cap
10578version: 0.0.1
10579artifacts:
10580 - name: my-skill
10581 source: ./skills/my-skill
10582 runtime: skill
10583 - name: local/my-tool
10584 version: 1.0.0
10585 runtime: tool
10586"#,
10587 )
10588 .unwrap();
10589 assert!(manifest.artifacts[0].local_source, "skill defaults to true");
10590 assert!(
10591 !manifest.artifacts[1].local_source,
10592 "tool defaults to false"
10593 );
10594 }
10595
10596 #[test]
10597 fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
10598 let manifest = RuntimeManifest::from_yaml_str(
10599 r#"
10600name: cap
10601version: 0.0.1
10602artifacts:
10603 - name: local/my-tool
10604 version: 1.0.0
10605 runtime: tool
10606 prompt_payload: true
10607 - name: murmur/driver-anthropic
10608 version: 0.1.0
10609 runtime: driver
10610 gateway:
10611 endpoint: http://127.0.0.1:8080
10612 keyless: true
10613inference:
10614 model: test-model
10615 system_prompt_artifact: local/my-tool
10616 driver:
10617 artifact: murmur/driver-anthropic
10618"#,
10619 )
10620 .unwrap();
10621 assert!(manifest.artifacts[0].prompt_payload);
10622 }
10623
10624 #[test]
10625 fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
10626 let err = RuntimeManifest::from_yaml_str(
10627 r#"
10628name: cap
10629version: 0.0.1
10630artifacts:
10631 - name: local/my-skill
10632 version: 1.0.0
10633 runtime: skill
10634 prompt_payload: false
10635 - name: murmur/driver-anthropic
10636 version: 0.1.0
10637 runtime: driver
10638 gateway:
10639 endpoint: http://127.0.0.1:8080
10640 keyless: true
10641inference:
10642 model: test-model
10643 system_prompt_artifact: local/my-skill
10644 driver:
10645 artifact: murmur/driver-anthropic
10646"#,
10647 )
10648 .unwrap_err();
10649 let msg = err.to_string();
10650 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
10651 }
10652
10653 #[test]
10654 fn prompt_payload_defaults_from_role_when_absent() {
10655 let manifest = RuntimeManifest::from_yaml_str(
10656 r#"
10657name: cap
10658version: 0.0.1
10659artifacts:
10660 - name: local/my-skill
10661 version: 1.0.0
10662 runtime: skill
10663 - name: local/my-tool
10664 version: 1.0.0
10665 runtime: tool
10666"#,
10667 )
10668 .unwrap();
10669 assert!(
10670 manifest.artifacts[0].prompt_payload,
10671 "skill defaults to true"
10672 );
10673 assert!(
10674 !manifest.artifacts[1].prompt_payload,
10675 "tool defaults to false"
10676 );
10677 }
10678
10679 #[test]
10680 fn source_on_non_skill_runtime_is_error() {
10681 for runtime in ["tool", "driver", "hook"] {
10682 let yaml = format!(
10683 r#"
10684name: cap
10685version: 0.0.1
10686artifacts:
10687 - name: my-thing
10688 source: ./local/path
10689 runtime: {runtime}
10690"#
10691 );
10692 let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
10693 let msg = err.to_string();
10694 assert!(
10695 msg.contains("'local_source: true'"),
10696 "runtime {runtime}: error was: {msg}"
10697 );
10698 assert!(
10699 msg.contains("my-thing"),
10700 "runtime {runtime}: error was: {msg}"
10701 );
10702 }
10703 }
10704
10705 #[test]
10706 fn registry_skill_still_requires_version() {
10707 let err = RuntimeManifest::from_yaml_str(
10708 r#"
10709name: cap
10710version: 0.0.1
10711artifacts:
10712 - name: local/my-skill
10713 runtime: skill
10714"#,
10715 )
10716 .unwrap_err();
10717
10718 let msg = err.to_string();
10719 assert!(
10720 msg.contains("missing required field 'version'"),
10721 "error was: {msg}"
10722 );
10723 }
10724
10725 #[test]
10726 fn rejects_unknown_inference_transport() {
10727 let err = RuntimeManifest::from_yaml_str(
10728 r#"
10729name: cap
10730version: 0.0.1
10731artifacts: []
10732inference:
10733 transport: grpc
10734 model: test-model
10735 driver:
10736 artifact: murmur/driver-anthropic
10737"#,
10738 )
10739 .unwrap_err();
10740
10741 let msg = err.to_string();
10742 assert!(msg.contains("inference.transport"), "error was: {msg}");
10743 assert!(msg.contains("unknown value"), "error was: {msg}");
10744 assert!(msg.contains("grpc"), "error was: {msg}");
10745 }
10746
10747 #[test]
10750 fn process_transport_with_command_and_no_driver_is_refused() {
10751 let err = RuntimeManifest::from_yaml_str(
10752 r#"
10753name: cap
10754version: 0.0.1
10755artifacts: []
10756inference:
10757 transport: process
10758 command: some-cli
10759 model: some-model
10760 max_turns: 20
10761"#,
10762 )
10763 .unwrap_err();
10764
10765 let msg = err.to_string();
10766 assert!(
10767 msg.contains("inference.driver.artifact"),
10768 "error was: {msg}"
10769 );
10770 assert!(msg.contains("inference.command"), "error was: {msg}");
10771 assert!(
10772 msg.contains("murmur/driver-claude-code"),
10773 "error was: {msg}"
10774 );
10775 }
10776
10777 #[test]
10778 fn process_transport_with_neither_driver_nor_command_is_refused() {
10779 let err = RuntimeManifest::from_yaml_str(
10780 r#"
10781name: cap
10782version: 0.0.1
10783artifacts: []
10784inference:
10785 transport: process
10786 model: some-model
10787"#,
10788 )
10789 .unwrap_err();
10790
10791 let msg = err.to_string();
10792 assert!(
10793 msg.contains("inference.driver.artifact"),
10794 "error was: {msg}"
10795 );
10796 assert!(
10797 msg.contains("murmur/driver-claude-code"),
10798 "error was: {msg}"
10799 );
10800 }
10801
10802 #[test]
10803 fn process_transport_allows_absent_model() {
10804 let manifest =
10807 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
10808 .expect("process transport should parse without a model");
10809 let inference = manifest.inference.expect("inference present");
10810 assert_eq!(inference.transport, "process");
10811 assert!(inference.command.is_none());
10812 assert_eq!(
10813 inference.model, "",
10814 "absent model resolves to empty (harness default)"
10815 );
10816 }
10817
10818 fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
10819 format!(
10820 "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n transport: process\n \
10821 driver:\n artifact: local/fixture-process-driver\n{inference_extra}"
10822 )
10823 }
10824
10825 const PROCESS_DRIVER_ENTRY: &str =
10826 " - name: local/fixture-process-driver\n version: 0.1.0\n runtime: driver";
10827
10828 #[test]
10829 fn process_transport_accepts_driver() {
10830 let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10831 PROCESS_DRIVER_ENTRY,
10832 " config:\n profile: work\n command: /usr/local/bin/fixture-cli\n",
10833 ))
10834 .unwrap();
10835
10836 let inference = manifest.inference.expect("inference should exist");
10837 assert_eq!(inference.transport, "process");
10838 let driver = inference.driver.expect("driver should be parsed");
10839 assert_eq!(driver.artifact, "local/fixture-process-driver");
10840 assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
10841 assert_eq!(
10842 inference.command.as_deref(),
10843 Some("/usr/local/bin/fixture-cli")
10844 );
10845 }
10846
10847 #[test]
10848 fn process_transport_accepts_driver_without_command() {
10849 let manifest =
10850 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
10851 .unwrap();
10852
10853 let inference = manifest.inference.expect("inference should exist");
10854 assert_eq!(
10855 inference.driver.map(|d| d.artifact).as_deref(),
10856 Some("local/fixture-process-driver")
10857 );
10858 assert!(inference.command.is_none());
10859 assert_eq!(inference.model, "");
10860 }
10861
10862 #[test]
10863 fn process_driver_entry_must_be_declared() {
10864 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10865 " - name: local/other\n version: 0.1.0\n runtime: tool",
10866 "",
10867 ))
10868 .unwrap_err()
10869 .to_string();
10870 assert!(msg.contains("inference.driver.artifact"), "{msg}");
10871 assert!(
10872 msg.contains("artifact 'local/fixture-process-driver' is not declared in artifacts:"),
10873 "{msg}"
10874 );
10875 }
10876
10877 #[test]
10878 fn process_driver_entry_must_be_runtime_driver() {
10879 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10880 " - name: local/fixture-process-driver\n version: 0.1.0\n runtime: tool",
10881 "",
10882 ))
10883 .unwrap_err()
10884 .to_string();
10885 assert!(
10886 msg.contains("must be declared with runtime: driver"),
10887 "{msg}"
10888 );
10889 }
10890
10891 #[test]
10892 fn process_driver_entry_refuses_capabilities() {
10893 let entry =
10894 format!("{PROCESS_DRIVER_ENTRY}\n capabilities:\n env:\n allow: [HOME]");
10895 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
10896 .unwrap_err()
10897 .to_string();
10898 assert!(msg.contains("granted nothing"), "{msg}");
10899 assert!(msg.contains("inference.driver.config"), "{msg}");
10900 }
10901
10902 #[test]
10903 fn process_driver_entry_refuses_config() {
10904 let entry = format!("{PROCESS_DRIVER_ENTRY}\n config:\n profile: work");
10905 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
10906 .unwrap_err()
10907 .to_string();
10908 assert!(msg.contains("granted nothing"), "{msg}");
10909 assert!(msg.contains("remove capabilities:/config:"), "{msg}");
10910 }
10911
10912 #[test]
10913 fn process_transport_rejects_endpoint() {
10914 let err = RuntimeManifest::from_yaml_str(
10915 r#"
10916name: cap
10917version: 0.0.1
10918artifacts: []
10919inference:
10920 transport: process
10921 command: claude
10922 model: claude-haiku-4-5-20251001
10923 endpoint: http://localhost:8080
10924"#,
10925 )
10926 .unwrap_err();
10927
10928 let msg = err.to_string();
10929 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
10930 assert!(msg.contains("was removed"), "error was: {msg}");
10931 }
10932
10933 #[test]
10934 fn process_transport_rejects_api_key() {
10935 let key = ["sk-", "ant-", "secret"].concat();
10938 let err = RuntimeManifest::from_yaml_str(&format!(
10939 r#"
10940name: cap
10941version: 0.0.1
10942artifacts: []
10943inference:
10944 transport: process
10945 command: claude
10946 model: claude-haiku-4-5-20251001
10947 api_key: {key}
10948"#
10949 ))
10950 .unwrap_err();
10951
10952 let msg = err.to_string();
10953 assert!(msg.contains("inference.api_key"), "error was: {msg}");
10954 assert!(msg.contains("was removed"), "error was: {msg}");
10955 assert!(!msg.contains(&key), "error was: {msg}");
10956 }
10957
10958 #[test]
10959 fn process_transport_rejects_max_tokens() {
10960 let err = RuntimeManifest::from_yaml_str(
10963 r#"
10964name: cap
10965version: 0.0.1
10966artifacts: []
10967inference:
10968 transport: process
10969 command: claude
10970 model: claude-haiku-4-5-20251001
10971 max_tokens: 4096
10972"#,
10973 )
10974 .unwrap_err();
10975
10976 let msg = err.to_string();
10977 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
10978 assert!(
10979 msg.contains("not valid with transport: process"),
10980 "error was: {msg}"
10981 );
10982 }
10983
10984 #[test]
10988 fn process_transport_carries_max_session_tokens() {
10989 let manifest = RuntimeManifest::from_yaml_str(
10990 r#"
10991name: cap
10992version: 0.0.1
10993artifacts:
10994 - name: murmur/driver-claude-code
10995 version: 0.1.0
10996 runtime: driver
10997inference:
10998 transport: process
10999 driver:
11000 artifact: murmur/driver-claude-code
11001 model: claude-haiku-4-5-20251001
11002 max_session_tokens: 200000
11003"#,
11004 )
11005 .unwrap();
11006
11007 assert_eq!(
11008 manifest.inference.unwrap().max_session_tokens,
11009 Some(200_000)
11010 );
11011 }
11012
11013 #[test]
11014 fn process_transport_rejects_a_zero_max_session_tokens() {
11015 let err = RuntimeManifest::from_yaml_str(
11016 r#"
11017name: cap
11018version: 0.0.1
11019artifacts:
11020 - name: murmur/driver-claude-code
11021 version: 0.1.0
11022 runtime: driver
11023inference:
11024 transport: process
11025 driver:
11026 artifact: murmur/driver-claude-code
11027 model: claude-haiku-4-5-20251001
11028 max_session_tokens: 0
11029"#,
11030 )
11031 .unwrap_err();
11032
11033 let msg = err.to_string();
11034 assert!(
11035 msg.contains("inference.max_session_tokens"),
11036 "error was: {msg}"
11037 );
11038 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
11039 }
11040
11041 #[test]
11042 fn inference_max_session_tokens_round_trips() {
11043 let manifest = RuntimeManifest::from_yaml_str(
11044 r#"
11045name: cap
11046version: 0.0.1
11047artifacts:
11048 - name: murmur/driver-anthropic
11049 version: 0.1.0
11050 runtime: driver
11051 gateway:
11052 endpoint: https://api.anthropic.com
11053 api_key: test-key
11054inference:
11055 transport: http
11056 model: claude-opus-4-5
11057 max_session_tokens: 5000000000
11058 driver:
11059 artifact: murmur/driver-anthropic
11060"#,
11061 )
11062 .unwrap();
11063
11064 assert_eq!(
11065 manifest.inference.unwrap().max_session_tokens,
11066 Some(5_000_000_000)
11067 );
11068 }
11069
11070 #[test]
11071 fn inference_max_session_tokens_absent_is_none() {
11072 let manifest = RuntimeManifest::from_yaml_str(
11073 r#"
11074name: cap
11075version: 0.0.1
11076artifacts:
11077 - name: murmur/driver-anthropic
11078 version: 0.1.0
11079 runtime: driver
11080 gateway:
11081 endpoint: https://api.anthropic.com
11082 api_key: test-key
11083inference:
11084 transport: http
11085 model: claude-opus-4-5
11086 driver:
11087 artifact: murmur/driver-anthropic
11088"#,
11089 )
11090 .unwrap();
11091
11092 assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
11093 }
11094
11095 #[test]
11096 fn inference_max_session_tokens_zero_is_rejected() {
11097 let err = RuntimeManifest::from_yaml_str(
11098 r#"
11099name: cap
11100version: 0.0.1
11101artifacts:
11102 - name: murmur/driver-anthropic
11103 version: 0.1.0
11104 runtime: driver
11105 gateway:
11106 endpoint: https://api.anthropic.com
11107 api_key: test-key
11108inference:
11109 transport: http
11110 model: claude-opus-4-5
11111 max_session_tokens: 0
11112 driver:
11113 artifact: murmur/driver-anthropic
11114"#,
11115 )
11116 .unwrap_err();
11117
11118 let msg = err.to_string();
11119 assert!(
11120 msg.contains("inference.max_session_tokens"),
11121 "error was: {msg}"
11122 );
11123 assert!(msg.contains("greater than 0"), "error was: {msg}");
11124 }
11125
11126 fn http_manifest_with_inference(extra: &str) -> String {
11128 format!(
11129 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n \
11130 version: 0.1.0\n runtime: driver\n gateway:\n \
11131 endpoint: https://api.anthropic.com\n api_key: test-key\n\
11132 inference:\n transport: http\n model: claude-opus-4-5\n driver:\n \
11133 artifact: murmur/driver-anthropic\n{extra}"
11134 )
11135 }
11136
11137 #[test]
11138 fn inference_tool_refresh_absent_is_compaction() {
11139 let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
11140 assert_eq!(
11141 manifest.inference.unwrap().tool_refresh,
11142 ToolRefresh::Compaction
11143 );
11144 assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
11145 }
11146
11147 #[test]
11148 fn inference_tool_refresh_parses_both_values() {
11149 for (value, expected) in [
11150 ("compaction", ToolRefresh::Compaction),
11151 ("immediate", ToolRefresh::Immediate),
11152 ] {
11153 let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
11154 " tool_refresh: {value}\n"
11155 )))
11156 .unwrap();
11157 assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
11158 assert_eq!(expected.wire_name(), value);
11159 assert!(
11160 manifest.unknown_keys.is_empty(),
11161 "tool_refresh is a known key: {:?}",
11162 manifest.unknown_keys
11163 );
11164 }
11165 }
11166
11167 #[test]
11168 fn inference_tool_refresh_rejects_an_unknown_value() {
11169 let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
11170 " tool_refresh: sometimes\n",
11171 ))
11172 .unwrap_err();
11173 match err {
11174 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
11175 assert_eq!(field, "inference.tool_refresh");
11176 assert_eq!(
11177 message,
11178 "must be one of: compaction, immediate; got 'sometimes'"
11179 );
11180 }
11181 other => panic!("unexpected error: {other:?}"),
11182 }
11183 }
11184
11185 #[test]
11188 fn process_transport_rejects_tool_refresh() {
11189 for value in ["compaction", "immediate"] {
11190 let err = RuntimeManifest::from_yaml_str(&format!(
11191 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n \
11192 command: claude\n model: claude-haiku-4-5-20251001\n tool_refresh: {value}\n"
11193 ))
11194 .unwrap_err();
11195 match err {
11196 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
11197 assert_eq!(field, "inference.tool_refresh");
11198 assert_eq!(message, "is not valid with transport: process");
11199 }
11200 other => panic!("unexpected error for {value}: {other:?}"),
11201 }
11202 }
11203 }
11204
11205 #[test]
11206 fn inference_max_tokens_round_trips() {
11207 let manifest = RuntimeManifest::from_yaml_str(
11208 r#"
11209name: cap
11210version: 0.0.1
11211artifacts:
11212 - name: murmur/driver-anthropic
11213 version: 0.1.0
11214 runtime: driver
11215 gateway:
11216 endpoint: https://api.anthropic.com
11217 api_key: test-key
11218inference:
11219 transport: http
11220 model: claude-opus-4-5
11221 max_tokens: 4096
11222 driver:
11223 artifact: murmur/driver-anthropic
11224"#,
11225 )
11226 .unwrap();
11227
11228 let inference = manifest.inference.unwrap();
11229 assert_eq!(inference.max_tokens, Some(4096));
11230 }
11231
11232 #[test]
11233 fn inference_max_tokens_absent_is_none() {
11234 let manifest = RuntimeManifest::from_yaml_str(
11237 r#"
11238name: cap
11239version: 0.0.1
11240artifacts:
11241 - name: murmur/driver-anthropic
11242 version: 0.1.0
11243 runtime: driver
11244 gateway:
11245 endpoint: https://api.anthropic.com
11246 api_key: test-key
11247inference:
11248 transport: http
11249 model: claude-opus-4-5
11250 driver:
11251 artifact: murmur/driver-anthropic
11252"#,
11253 )
11254 .unwrap();
11255
11256 assert_eq!(manifest.inference.unwrap().max_tokens, None);
11257 }
11258
11259 #[test]
11260 fn inference_max_tokens_zero_is_rejected() {
11261 let err = RuntimeManifest::from_yaml_str(
11262 r#"
11263name: cap
11264version: 0.0.1
11265artifacts:
11266 - name: murmur/driver-anthropic
11267 version: 0.1.0
11268 runtime: driver
11269 gateway:
11270 endpoint: https://api.anthropic.com
11271 api_key: test-key
11272inference:
11273 transport: http
11274 model: claude-opus-4-5
11275 max_tokens: 0
11276 driver:
11277 artifact: murmur/driver-anthropic
11278"#,
11279 )
11280 .unwrap_err();
11281
11282 let msg = err.to_string();
11283 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
11284 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
11285 }
11286
11287 #[test]
11288 fn inference_max_tokens_large_value_is_accepted() {
11289 let manifest = RuntimeManifest::from_yaml_str(
11292 r#"
11293name: cap
11294version: 0.0.1
11295artifacts:
11296 - name: murmur/driver-anthropic
11297 version: 0.1.0
11298 runtime: driver
11299 gateway:
11300 endpoint: https://api.anthropic.com
11301 api_key: test-key
11302inference:
11303 transport: http
11304 model: claude-opus-4-5
11305 max_tokens: 999999
11306 driver:
11307 artifact: murmur/driver-anthropic
11308"#,
11309 )
11310 .unwrap();
11311
11312 assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
11313 }
11314
11315 #[test]
11316 fn inference_and_context_max_tokens_are_independent() {
11317 let manifest = RuntimeManifest::from_yaml_str(
11320 r#"
11321name: cap
11322version: 0.0.1
11323artifacts:
11324 - name: murmur/driver-anthropic
11325 version: 0.1.0
11326 runtime: driver
11327 gateway:
11328 endpoint: https://api.anthropic.com
11329 api_key: test-key
11330inference:
11331 transport: http
11332 model: claude-opus-4-5
11333 max_tokens: 4096
11334 driver:
11335 artifact: murmur/driver-anthropic
11336context:
11337 max_tokens: 200000
11338"#,
11339 )
11340 .unwrap();
11341
11342 assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
11343 assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
11344 }
11345
11346 #[test]
11347 fn hook_config_defaults_when_fields_absent() {
11348 let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
11349 assert_eq!(config.binding, HookBinding::All);
11350 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
11351 assert_eq!(config.commit_policy, HookCommitPolicy::None);
11352 }
11353
11354 #[test]
11355 fn hook_config_parses_all_fields() {
11356 let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
11357 let config = parse_hook_config_from_yaml(yaml).unwrap();
11358 assert_eq!(config.binding, HookBinding::OnCompaction);
11359 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
11360 assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
11361 }
11362
11363 #[test]
11364 fn hook_config_parses_on_task_start() {
11365 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
11366 let config = parse_hook_config_from_yaml(yaml).unwrap();
11367 assert_eq!(config.binding, HookBinding::OnTaskStart);
11368 }
11369
11370 #[test]
11371 fn hook_config_parses_on_task_end() {
11372 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
11373 let config = parse_hook_config_from_yaml(yaml).unwrap();
11374 assert_eq!(config.binding, HookBinding::OnTaskEnd);
11375 }
11376
11377 #[test]
11378 fn hook_config_unknown_binding_lists_task_events() {
11379 let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
11380 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11381 assert!(err.contains("on-task-start"), "error was: {err}");
11382 assert!(err.contains("on-task-end"), "error was: {err}");
11383 }
11384
11385 #[test]
11386 fn hook_config_async_with_commit_is_rejected() {
11387 let yaml =
11388 "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
11389 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11390 assert!(err.contains("async-with-commit"), "error was: {err}");
11391 }
11392
11393 #[test]
11394 fn hook_config_on_stage_async_is_rejected() {
11395 let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
11396 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11397 assert!(err.contains("on-stage"), "error was: {err}");
11398 assert!(err.contains("blocking"), "error was: {err}");
11399 }
11400
11401 #[test]
11402 fn hook_config_async_none_is_valid() {
11403 let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
11404 let config = parse_hook_config_from_yaml(yaml).unwrap();
11405 assert_eq!(config.execution_mode, HookExecutionMode::Async);
11406 assert_eq!(config.commit_policy, HookCommitPolicy::None);
11407 }
11408
11409 #[test]
11410 fn hook_config_parses_reopen_task_commit_policy() {
11411 let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
11412 let config = parse_hook_config_from_yaml(yaml).unwrap();
11413 assert_eq!(config.binding, HookBinding::OnTaskEnd);
11414 assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
11415 }
11416
11417 #[test]
11418 fn hook_config_unknown_commit_policy_lists_reopen_task() {
11419 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11420 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11421 assert!(err.contains("reopen-task"), "error was: {err}");
11422 }
11423
11424 #[test]
11425 fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
11426 for binding in ["on-shell", "on-tool-call"] {
11427 let yaml =
11428 format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
11429 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11430 assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
11431 }
11432 assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
11433 }
11434
11435 #[test]
11438 fn hook_config_deny_without_a_binding_is_rejected() {
11439 let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
11440 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11441 assert!(
11442 err.contains("requires an explicit binding"),
11443 "error was: {err}"
11444 );
11445 assert!(err.contains("on-shell"), "error was: {err}");
11446 assert!(err.contains("on-tool-call"), "error was: {err}");
11447 }
11448
11449 #[test]
11451 fn hook_config_deny_on_an_ungated_binding_is_rejected() {
11452 let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
11453 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11454 assert!(err.contains("is not valid for binding"), "error was: {err}");
11455 assert!(err.contains("replace-context"), "error was: {err}");
11456 }
11457
11458 #[test]
11460 fn hook_config_async_deny_is_rejected() {
11461 let yaml =
11462 "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
11463 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11464 assert!(err.contains("async-with-commit"), "error was: {err}");
11465 assert!(err.contains("deny"), "error was: {err}");
11466 }
11467
11468 #[test]
11469 fn hook_config_unknown_commit_policy_lists_deny() {
11470 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11471 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11472 assert!(err.contains("deny"), "error was: {err}");
11473 }
11474
11475 #[test]
11478 fn hook_config_async_reopen_task_is_rejected() {
11479 let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
11480 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11481 assert!(err.contains("async-with-commit"), "error was: {err}");
11482 assert!(err.contains("reopen-task"), "error was: {err}");
11483 }
11484
11485 #[test]
11491 fn hook_config_non_committing_binding_rejects_any_commit_policy() {
11492 for binding in ["on-session-start", "on-session-end"] {
11493 let yaml = format!(
11494 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
11495 );
11496 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11497 assert!(err.contains(binding), "error was: {err}");
11498 assert!(err.contains("replace-context"), "error was: {err}");
11499 assert!(
11500 err.contains("honors commit_policy 'none'"),
11501 "error was: {err}"
11502 );
11503 }
11504 }
11505
11506 #[test]
11509 fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
11510 for (binding, declared, honored) in [
11511 ("on-stage", "reopen-task", "write-manifests"),
11512 ("on-compaction", "write-manifests", "replace-context"),
11513 ("on-task-end", "replace-context", "reopen-task"),
11514 ("on-task-start", "replace-context", "seed-context"),
11515 ("on-shell", "replace-context", "deny"),
11516 ("on-tool-call", "replace-context", "deny"),
11517 ] {
11518 let yaml = format!(
11519 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
11520 );
11521 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11522 assert!(err.contains(binding), "error was: {err}");
11523 assert!(
11524 err.contains(&format!("commit_policy '{declared}' is not valid")),
11525 "error was: {err}"
11526 );
11527 assert!(
11528 err.contains(&format!("honors commit_policy '{honored}'")),
11529 "error was: {err}"
11530 );
11531 }
11532 }
11533
11534 #[test]
11537 fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
11538 for declared in ["replace-context", "write-manifests", "reopen-task"] {
11539 let yaml = format!(
11540 "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
11541 );
11542 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11543 assert!(err.contains("on-inference"), "error was: {err}");
11544 assert!(err.contains(declared), "error was: {err}");
11545 assert!(
11546 err.contains("honors commit_policy 'none'"),
11547 "error was: {err}"
11548 );
11549 assert!(err.contains("artifact"), "error was: {err}");
11550 }
11551 }
11552
11553 #[test]
11556 fn hook_config_matching_binding_and_commit_policy_is_valid() {
11557 for (binding, policy, expected) in [
11558 (
11559 "on-stage",
11560 "write-manifests",
11561 HookCommitPolicy::WriteManifests,
11562 ),
11563 (
11564 "on-compaction",
11565 "replace-context",
11566 HookCommitPolicy::ReplaceContext,
11567 ),
11568 ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
11569 ("on-shell", "deny", HookCommitPolicy::Deny),
11570 ("on-tool-call", "deny", HookCommitPolicy::Deny),
11571 ] {
11572 let yaml =
11573 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
11574 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11575 assert_eq!(config.commit_policy, expected);
11576 }
11577 }
11578
11579 #[test]
11582 fn hook_config_commit_policy_none_is_valid_for_every_binding() {
11583 for binding in [
11584 "on-stage",
11585 "on-session-start",
11586 "on-task-start",
11587 "on-inference",
11588 "on-tool-call",
11589 "on-shell",
11590 "on-compaction",
11591 "on-task-end",
11592 "on-session-end",
11593 ] {
11594 let yaml =
11595 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
11596 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11597 assert_eq!(config.commit_policy, HookCommitPolicy::None);
11598 }
11599 }
11600
11601 #[test]
11606 fn hook_config_omitted_binding_accepts_every_commit_policy() {
11607 for (policy, expected) in [
11608 ("replace-context", HookCommitPolicy::ReplaceContext),
11609 ("write-manifests", HookCommitPolicy::WriteManifests),
11610 ("reopen-task", HookCommitPolicy::ReopenTask),
11611 ("none", HookCommitPolicy::None),
11612 ] {
11613 let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
11614 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11615 assert_eq!(config.binding, HookBinding::All);
11616 assert_eq!(config.commit_policy, expected);
11617 }
11618 }
11619
11620 #[test]
11625 fn hook_config_shipped_default_artifact_manifests_still_parse() {
11626 let shipped: [(&str, &str, HookConfig); 8] = [
11627 (
11628 "murmur-hook-compact",
11629 "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
11630 HookConfig {
11631 binding: HookBinding::OnCompaction,
11632 execution_mode: HookExecutionMode::Blocking,
11633 commit_policy: HookCommitPolicy::ReplaceContext,
11634 },
11635 ),
11636 (
11637 "murmur-hook-shell-desc",
11638 "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
11639 HookConfig {
11640 binding: HookBinding::OnStage,
11641 execution_mode: HookExecutionMode::Blocking,
11642 commit_policy: HookCommitPolicy::WriteManifests,
11643 },
11644 ),
11645 (
11646 "murmur-hook-regression-verifier",
11648 "execution_mode: blocking\ncommit_policy: reopen-task\n",
11649 HookConfig {
11650 binding: HookBinding::All,
11651 execution_mode: HookExecutionMode::Blocking,
11652 commit_policy: HookCommitPolicy::ReopenTask,
11653 },
11654 ),
11655 (
11656 "murmur-hook-memory",
11657 "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
11658 HookConfig {
11659 binding: HookBinding::OnTaskStart,
11660 execution_mode: HookExecutionMode::Blocking,
11661 commit_policy: HookCommitPolicy::SeedContext,
11662 },
11663 ),
11664 (
11665 "murmur-hook-debug",
11666 "execution_mode: async\ncommit_policy: none\n",
11667 HookConfig {
11668 binding: HookBinding::All,
11669 execution_mode: HookExecutionMode::Async,
11670 commit_policy: HookCommitPolicy::None,
11671 },
11672 ),
11673 (
11674 "murmur-hook-diff-summary",
11675 "execution_mode: blocking\ncommit_policy: none\n",
11676 HookConfig {
11677 binding: HookBinding::All,
11678 execution_mode: HookExecutionMode::Blocking,
11679 commit_policy: HookCommitPolicy::None,
11680 },
11681 ),
11682 ("murmur-hook-eval", "", HookConfig::default()),
11683 ("murmur-hook-grafana", "", HookConfig::default()),
11684 ];
11685
11686 for (name, fields, expected) in shipped {
11687 let yaml = format!("name: {name}\nruntime: hook\n{fields}");
11688 let config = parse_hook_config_from_yaml(&yaml)
11689 .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
11690 assert_eq!(config, expected, "shipped hook {name}");
11691 }
11692 }
11693
11694 #[test]
11698 fn commit_policy_for_binding_matches_the_declarable_pairs() {
11699 assert_eq!(
11700 commit_policy_for_binding(&HookBinding::OnStage),
11701 Some(HookCommitPolicy::WriteManifests)
11702 );
11703 assert_eq!(
11704 commit_policy_for_binding(&HookBinding::OnCompaction),
11705 Some(HookCommitPolicy::ReplaceContext)
11706 );
11707 assert_eq!(
11708 commit_policy_for_binding(&HookBinding::OnTaskEnd),
11709 Some(HookCommitPolicy::ReopenTask)
11710 );
11711 assert_eq!(
11712 commit_policy_for_binding(&HookBinding::OnTaskStart),
11713 Some(HookCommitPolicy::SeedContext)
11714 );
11715 assert_eq!(
11716 commit_policy_for_binding(&HookBinding::OnToolCall),
11717 Some(HookCommitPolicy::Deny)
11718 );
11719 assert_eq!(
11720 commit_policy_for_binding(&HookBinding::OnShell),
11721 Some(HookCommitPolicy::Deny)
11722 );
11723 for binding in [
11727 HookBinding::OnSessionStart,
11728 HookBinding::OnInference,
11729 HookBinding::OnSessionEnd,
11730 HookBinding::All,
11731 ] {
11732 assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
11733 }
11734 }
11735
11736 #[test]
11739 fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
11740 let config = parse_hook_config_from_yaml(
11741 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
11742 commit_policy: seed-context\n",
11743 )
11744 .expect("on-task-start + seed-context is the declarable pairing");
11745 assert_eq!(config.binding, HookBinding::OnTaskStart);
11746 assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
11747
11748 let error = parse_hook_config_from_yaml(
11749 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
11750 commit_policy: seed-context\n",
11751 )
11752 .expect_err("on-compaction cannot commit a seed");
11753 assert!(error.contains("on-compaction"), "{error}");
11754 assert!(error.contains("seed-context"), "{error}");
11755 }
11756
11757 #[test]
11760 fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
11761 let error = parse_hook_config_from_yaml(
11762 "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
11763 )
11764 .expect_err("a misspelled policy is rejected");
11765 assert!(error.contains("seed-context"), "{error}");
11766 }
11767
11768 fn context_of(yaml_block: &str) -> Option<ContextConfig> {
11771 RuntimeManifest::from_yaml_str(&format!(
11772 "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
11773 ))
11774 .expect("the manifest under test parses")
11775 .context
11776 }
11777
11778 #[test]
11779 fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
11780 let context = context_of(
11781 "context:\n max_tokens: 200000\n seed_budget: 0.10\n seed_overflow_margin: 0.10\n",
11782 )
11783 .expect("a declared context block parses to Some");
11784 assert_eq!(context.max_tokens, Some(200_000));
11785 assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
11786 assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
11787 }
11788
11789 #[test]
11790 fn seed_budget_and_overflow_margin_default_when_omitted() {
11791 let context = context_of("context:\n max_tokens: 200000\n")
11792 .expect("a declared context block parses to Some");
11793 assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
11794 assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
11795 }
11796
11797 #[test]
11800 fn seed_budget_defaults_do_not_create_a_context_block() {
11801 assert_eq!(context_of(""), None);
11802 }
11803
11804 #[test]
11805 fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
11806 for (block, field) in [
11807 ("context:\n seed_budget: 1.5\n", "context.seed_budget"),
11808 ("context:\n seed_budget: -0.1\n", "context.seed_budget"),
11809 (
11810 "context:\n seed_overflow_margin: 2.0\n",
11811 "context.seed_overflow_margin",
11812 ),
11813 ] {
11814 let error = RuntimeManifest::from_yaml_str(&format!(
11815 "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
11816 ))
11817 .expect_err("an out-of-range fraction is rejected");
11818 match error {
11819 RuntimeManifestError::InvalidInferenceConfig {
11820 field: got,
11821 ref message,
11822 } => {
11823 assert_eq!(got, field);
11824 assert!(message.contains("0.0 and 1.0"), "{message}");
11825 }
11826 other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
11827 }
11828 }
11829 }
11830
11831 #[test]
11832 fn deploy_block_mur_version_parses() {
11833 let manifest = RuntimeManifest::from_yaml_str(
11834 "name: cap\nversion: 0.1.0\nartifacts: []\ndeploy:\n mur_version: \"0.4.5\"\n",
11835 )
11836 .unwrap();
11837 assert_eq!(manifest.deploy_mur_version(), Some("0.4.5"));
11838 assert_eq!(
11839 manifest.deploy,
11840 Some(DeployConfig {
11841 mur_version: Some("0.4.5".to_string())
11842 })
11843 );
11844 }
11845
11846 #[test]
11847 fn deploy_block_absent_is_none() {
11848 let manifest =
11849 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
11850 assert_eq!(manifest.deploy, None);
11851 assert_eq!(manifest.deploy_mur_version(), None);
11852 }
11853
11854 #[test]
11856 fn deploy_block_blank_mur_version_is_none() {
11857 for deploy in [
11858 "deploy:\n mur_version: \"\"\n",
11859 "deploy:\n mur_version: \" \"\n",
11860 ] {
11861 let manifest = RuntimeManifest::from_yaml_str(&format!(
11862 "name: cap\nversion: 0.1.0\nartifacts: []\n{deploy}"
11863 ))
11864 .unwrap();
11865 assert_eq!(manifest.deploy_mur_version(), None, "{deploy:?}");
11866 }
11867 let manifest =
11868 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\ndeploy: {}\n").unwrap();
11869 assert_eq!(manifest.deploy, Some(DeployConfig::default()));
11870 assert_eq!(manifest.deploy_mur_version(), None);
11871 }
11872
11873 #[test]
11874 fn deploy_block_misspelled_key_is_reported_under_deploy() {
11875 assert_eq!(
11876 unknown_keys_of("name: cap\nversion: 0.1.0\ndeploy:\n mur_versoin: \"0.4.5\"\n"),
11877 vec![(
11878 "mur_versoin".to_string(),
11879 "deploy".to_string(),
11880 Some("mur_version".to_string())
11881 )]
11882 );
11883 }
11884
11885 #[test]
11886 fn a_top_level_mur_version_is_refused_naming_deploy_mur_version() {
11887 for pin in [
11888 "mur_version: \"0.4.5\"\n",
11889 "mur_version: 0.4.0\n",
11890 "mur_version: \"\"\n",
11891 "mur_version:\n",
11892 ] {
11893 let error = RuntimeManifest::from_yaml_str(&format!(
11894 "name: cap\nversion: 0.1.0\nartifacts: []\n{pin}"
11895 ))
11896 .expect_err(pin);
11897 assert!(
11898 matches!(error, RuntimeManifestError::TopLevelMurVersion),
11899 "{pin:?}: {error:?}"
11900 );
11901 assert!(error.to_string().contains("deploy.mur_version"), "{error}");
11902 }
11903 assert_eq!(
11904 RuntimeManifestError::TopLevelMurVersion.to_string(),
11905 "murmur.yaml: 'mur_version' is not a top-level key — the mur release a deploy \
11906 installs is pinned as 'deploy.mur_version'"
11907 );
11908 }
11909
11910 #[test]
11911 fn a_top_level_mur_version_is_refused_beside_a_deploy_block() {
11912 let yaml = "name: cap\nversion: 0.1.0\nmur_version: \"0.4.5\"\ndeploy:\n mur_version: \"0.4.5\"\n";
11913 assert!(matches!(
11914 RuntimeManifest::from_yaml_str(yaml),
11915 Err(RuntimeManifestError::TopLevelMurVersion)
11916 ));
11917 assert!(matches!(
11918 RuntimeManifest::from_yaml_str_without_secrets(yaml),
11919 Err(RuntimeManifestError::TopLevelMurVersion)
11920 ));
11921 }
11922
11923 #[test]
11924 fn inference_max_turns_absent_sets_no_cap() {
11925 let manifest = RuntimeManifest::from_yaml_str(
11926 "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",
11927 ).unwrap();
11928 assert_eq!(manifest.inference.unwrap().max_turns, None);
11929 }
11930
11931 #[test]
11932 fn inference_max_turns_explicit_value() {
11933 let manifest = RuntimeManifest::from_yaml_str(
11934 "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",
11935 ).unwrap();
11936 assert_eq!(manifest.inference.unwrap().max_turns, Some(20));
11937 }
11938
11939 #[test]
11940 fn inference_max_turns_zero_is_rejected() {
11941 let err = RuntimeManifest::from_yaml_str(
11942 "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",
11943 ).unwrap_err();
11944 let msg = err.to_string();
11945 assert!(msg.contains("inference.max_turns"), "error was: {msg}");
11946 assert!(msg.contains("greater than 0"), "error was: {msg}");
11947 }
11948
11949 #[test]
11951 fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
11952 let manifest = RuntimeManifest::from_yaml_str(
11953 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 45\n",
11954 )
11955 .unwrap();
11956 let lifecycle = manifest.lifecycle.unwrap();
11957 assert_eq!(lifecycle.shell_grace_secs, Some(45));
11958 assert_eq!(
11960 lifecycle.task_acceptance,
11961 LifecycleConfig::default().task_acceptance
11962 );
11963 assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
11964 assert_eq!(
11965 lifecycle.queue_depth,
11966 LifecycleConfig::default().queue_depth
11967 );
11968 assert_eq!(lifecycle.input_timeout_secs, None);
11969 assert_eq!(
11970 lifecycle.conversation_mode,
11971 LifecycleConfig::default().conversation_mode
11972 );
11973 assert_eq!(
11974 lifecycle.max_task_reopens,
11975 LifecycleConfig::default().max_task_reopens
11976 );
11977 }
11978
11979 #[test]
11981 fn lifecycle_shell_grace_secs_is_unset_when_not_declared() {
11982 let manifest = RuntimeManifest::from_yaml_str(
11983 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
11984 )
11985 .unwrap();
11986 let lifecycle = manifest.lifecycle.unwrap();
11987 assert_eq!(lifecycle.shell_grace_secs, None);
11988 assert_eq!(lifecycle.queue_depth, 3);
11989
11990 let without_block =
11991 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11992 assert_eq!(without_block.effective_lifecycle().shell_grace_secs, None);
11993 assert_eq!(LifecycleConfig::default().shell_grace_secs, None);
11994 }
11995
11996 #[test]
11999 fn lifecycle_delegation_deadline_secs_defaults_to_600() {
12000 let without_block =
12001 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12002 assert_eq!(
12003 without_block.effective_lifecycle().delegation_deadline_secs,
12004 600
12005 );
12006
12007 let without_key = RuntimeManifest::from_yaml_str(
12008 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12009 )
12010 .unwrap();
12011 let lifecycle = without_key.lifecycle.unwrap();
12012 assert_eq!(lifecycle.delegation_deadline_secs, 600);
12013 assert_eq!(lifecycle.queue_depth, 3);
12014 }
12015
12016 #[test]
12017 fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
12018 let manifest = RuntimeManifest::from_yaml_str(
12019 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
12020 delegation_deadline_secs: 30\n",
12021 )
12022 .unwrap();
12023 let lifecycle = manifest.lifecycle.unwrap();
12024 assert_eq!(lifecycle.delegation_deadline_secs, 30);
12025 assert_eq!(
12027 lifecycle.shell_grace_secs,
12028 LifecycleConfig::default().shell_grace_secs
12029 );
12030 assert_eq!(
12031 lifecycle.max_task_reopens,
12032 LifecycleConfig::default().max_task_reopens
12033 );
12034 }
12035
12036 #[test]
12038 fn lifecycle_delegation_deadline_secs_is_a_known_key() {
12039 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
12040 let manifest = RuntimeManifest::from_yaml_str(
12041 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
12042 delegation_deadline_secs: 30\n delegation_deadline: 30\n",
12043 )
12044 .unwrap();
12045 let reported: Vec<(String, String)> = manifest
12046 .unknown_keys
12047 .iter()
12048 .map(|key| (key.key.clone(), key.block_path.clone()))
12049 .collect();
12050 assert_eq!(
12051 reported,
12052 vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
12053 "the declared key is known and only the misspelt neighbour is reported"
12054 );
12055 assert_eq!(
12056 manifest.lifecycle.unwrap().delegation_deadline_secs,
12057 30,
12058 "the misspelt neighbour does not disturb the declared value"
12059 );
12060 }
12061
12062 #[test]
12064 fn lifecycle_deadline_notice_secs_defaults_to_none() {
12065 let without_block =
12066 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12067 assert_eq!(
12068 without_block.effective_lifecycle().deadline_notice_secs,
12069 None
12070 );
12071 let without_key = RuntimeManifest::from_yaml_str(
12072 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12073 )
12074 .unwrap();
12075 assert_eq!(without_key.lifecycle.unwrap().deadline_notice_secs, None);
12076 }
12077
12078 #[test]
12080 fn lifecycle_deadline_notice_secs_takes_zero_and_positive_values() {
12081 for (declared, expected) in [("0", 0), ("90", 90)] {
12082 let manifest = RuntimeManifest::from_yaml_str(&format!(
12083 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
12084 deadline_notice_secs: {declared}\n"
12085 ))
12086 .unwrap();
12087 assert!(manifest.unknown_keys.is_empty());
12088 assert_eq!(
12089 manifest.lifecycle.unwrap().deadline_notice_secs,
12090 Some(expected)
12091 );
12092 }
12093 }
12094
12095 #[test]
12096 fn lifecycle_deadline_notice_secs_refuses_a_negative_or_non_integer_value() {
12097 for declared in ["-5", "1.5", "soon"] {
12098 let refused = RuntimeManifest::from_yaml_str(&format!(
12099 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
12100 deadline_notice_secs: {declared}\n"
12101 ));
12102 assert!(refused.is_err(), "{declared} is refused: {refused:?}");
12103 }
12104 }
12105
12106 #[test]
12107 fn lifecycle_deadline_notice_secs_is_a_known_key() {
12108 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"deadline_notice_secs"));
12109 }
12110
12111 fn lifecycle_with_keep_working(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
12112 RuntimeManifest::from_yaml_str(&format!(
12113 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n keep_working:\n{block}"
12114 ))
12115 }
12116
12117 fn refused_keep_working_field(block: &str) -> (String, String) {
12118 match lifecycle_with_keep_working(block) {
12119 Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
12120 (field, message)
12121 }
12122 other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
12123 }
12124 }
12125
12126 #[test]
12127 fn lifecycle_keep_working_is_off_when_absent() {
12128 let without_block =
12129 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12130 assert_eq!(without_block.effective_lifecycle().keep_working, None);
12131 let without_key = RuntimeManifest::from_yaml_str(
12132 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12133 )
12134 .unwrap();
12135 assert_eq!(without_key.lifecycle.unwrap().keep_working, None);
12136 }
12137
12138 #[test]
12139 fn lifecycle_keep_working_takes_the_declared_message_and_limit() {
12140 let manifest =
12141 lifecycle_with_keep_working(" message: \"go on\"\n limit: 3\n").unwrap();
12142 assert!(
12143 manifest.unknown_keys.is_empty(),
12144 "{:?}",
12145 manifest.unknown_keys
12146 );
12147 assert_eq!(
12148 manifest.lifecycle.unwrap().keep_working,
12149 Some(KeepWorking {
12150 message: "go on".into(),
12151 limit: 3
12152 })
12153 );
12154 }
12155
12156 #[test]
12158 fn lifecycle_keep_working_keeps_the_message_as_written() {
12159 let manifest =
12160 lifecycle_with_keep_working(" message: \" go on \\n\"\n limit: 1\n").unwrap();
12161 assert_eq!(
12162 manifest.lifecycle.unwrap().keep_working.unwrap().message,
12163 " go on \n"
12164 );
12165 }
12166
12167 #[test]
12168 fn lifecycle_keep_working_refuses_a_missing_or_blank_message() {
12169 for block in [
12170 " limit: 3\n",
12171 " message: \"\"\n limit: 3\n",
12172 " message: \" \\n\\t \"\n limit: 3\n",
12173 ] {
12174 let (field, _) = refused_keep_working_field(block);
12175 assert_eq!(field, "lifecycle.keep_working.message", "{block:?}");
12176 }
12177 }
12178
12179 #[test]
12180 fn lifecycle_keep_working_refuses_a_missing_limit() {
12181 let (field, message) = refused_keep_working_field(" message: go on\n");
12182 assert_eq!(field, "lifecycle.keep_working.limit");
12183 assert_eq!(message, "is required");
12184 }
12185
12186 #[test]
12187 fn lifecycle_keep_working_refuses_a_zero_limit_and_says_how_to_turn_it_off() {
12188 let (field, message) = refused_keep_working_field(" message: go on\n limit: 0\n");
12189 assert_eq!(field, "lifecycle.keep_working.limit");
12190 assert!(
12191 message.contains("omit lifecycle.keep_working"),
12192 "the refusal says how to turn the option off: {message}"
12193 );
12194 }
12195
12196 #[test]
12197 fn lifecycle_keep_working_reports_an_unknown_key_inside_the_block() {
12198 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"keep_working"));
12199 let manifest =
12200 lifecycle_with_keep_working(" message: go on\n limit: 2\n lmit: 3\n").unwrap();
12201 let reported: Vec<(String, String)> = manifest
12202 .unknown_keys
12203 .iter()
12204 .map(|key| (key.key.clone(), key.block_path.clone()))
12205 .collect();
12206 assert_eq!(
12207 reported,
12208 vec![("lmit".to_string(), "lifecycle.keep_working".to_string())]
12209 );
12210 }
12211
12212 fn lifecycle_with_done_check(
12213 allow: &str,
12214 block: &str,
12215 ) -> Result<RuntimeManifest, RuntimeManifestError> {
12216 RuntimeManifest::from_yaml_str(&format!(
12217 "name: cap\nversion: 0.0.1\nartifacts: []\n{allow}lifecycle:\n done_check:\n{block}"
12218 ))
12219 }
12220
12221 const ALLOW_BASH: &str = "capabilities:\n shell:\n allow: [bash]\n";
12222
12223 fn refused_done_check_field(allow: &str, block: &str) -> (String, String) {
12224 match lifecycle_with_done_check(allow, block) {
12225 Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
12226 (field, message)
12227 }
12228 other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
12229 }
12230 }
12231
12232 #[test]
12233 fn lifecycle_done_check_is_off_when_absent() {
12234 let without_block =
12235 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12236 assert_eq!(without_block.effective_lifecycle().done_check, None);
12237 let without_key = RuntimeManifest::from_yaml_str(
12238 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12239 )
12240 .unwrap();
12241 assert_eq!(without_key.lifecycle.unwrap().done_check, None);
12242 }
12243
12244 #[test]
12245 fn lifecycle_done_check_takes_the_command_and_limit_with_the_default_timeout() {
12246 let manifest = lifecycle_with_done_check(
12247 ALLOW_BASH,
12248 " command: \"bash -c 'true'\"\n limit: 2\n",
12249 )
12250 .unwrap();
12251 assert!(
12252 manifest.unknown_keys.is_empty(),
12253 "{:?}",
12254 manifest.unknown_keys
12255 );
12256 let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
12257 assert_eq!(
12258 done_check,
12259 DoneCheck {
12260 command: "bash -c 'true'".into(),
12261 limit: 2,
12262 timeout_secs: 600,
12263 }
12264 );
12265 assert_eq!(done_check.argv(), ["bash", "-c", "true"]);
12266 }
12267
12268 #[test]
12270 fn lifecycle_done_check_accepts_a_zero_limit_and_an_explicit_timeout() {
12271 let manifest = lifecycle_with_done_check(
12272 ALLOW_BASH,
12273 " command: bash -c 'exit 1'\n limit: 0\n timeout_secs: 5\n",
12274 )
12275 .unwrap();
12276 let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
12277 assert_eq!((done_check.limit, done_check.timeout_secs), (0, 5));
12278 }
12279
12280 #[test]
12281 fn lifecycle_done_check_refuses_each_invalid_field() {
12282 for (allow, block, field, message) in [
12283 (ALLOW_BASH, " limit: 1\n", "command", "is required"),
12284 (
12285 ALLOW_BASH,
12286 " command: \" \"\n limit: 1\n",
12287 "command",
12288 "must not be blank",
12289 ),
12290 (
12291 ALLOW_BASH,
12292 " command: \"''\"\n limit: 1\n",
12293 "command",
12294 "must name a program",
12295 ),
12296 (
12297 ALLOW_BASH,
12298 " command: cargo test\n limit: 1\n",
12299 "command",
12300 "runs 'cargo', which is not in capabilities.shell.allow",
12301 ),
12302 (
12303 "",
12304 " command: bash -c true\n limit: 1\n",
12305 "command",
12306 "runs 'bash', which is not in capabilities.shell.allow",
12307 ),
12308 (
12309 ALLOW_BASH,
12310 " command: bash -c true\n",
12311 "limit",
12312 "is required",
12313 ),
12314 (
12315 ALLOW_BASH,
12316 " command: bash -c true\n limit: 1\n timeout_secs: 0\n",
12317 "timeout_secs",
12318 "must be at least 1",
12319 ),
12320 ] {
12321 assert_eq!(
12322 refused_done_check_field(allow, block),
12323 (format!("lifecycle.done_check.{field}"), message.to_string()),
12324 "{block:?}"
12325 );
12326 }
12327 }
12328
12329 #[test]
12330 fn lifecycle_done_check_reports_an_unknown_key_inside_the_block() {
12331 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"done_check"));
12332 let manifest = lifecycle_with_done_check(
12333 ALLOW_BASH,
12334 " command: bash -c true\n limit: 2\n limt: 3\n",
12335 )
12336 .unwrap();
12337 let reported: Vec<(String, String)> = manifest
12338 .unknown_keys
12339 .iter()
12340 .map(|key| (key.key.clone(), key.block_path.clone()))
12341 .collect();
12342 assert_eq!(
12343 reported,
12344 vec![("limt".to_string(), "lifecycle.done_check".to_string())]
12345 );
12346 }
12347
12348 #[test]
12351 fn lifecycle_shell_grace_secs_zero_is_accepted() {
12352 let manifest = RuntimeManifest::from_yaml_str(
12353 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 0\n",
12354 )
12355 .unwrap();
12356 let lifecycle = manifest.lifecycle.unwrap();
12357 assert_eq!(lifecycle.shell_grace_secs, Some(0));
12358 assert_eq!(
12359 lifecycle.max_task_reopens,
12360 LifecycleConfig::default().max_task_reopens
12361 );
12362 }
12363
12364 #[test]
12365 fn lifecycle_max_task_reopens_defaults_to_1() {
12366 let manifest = RuntimeManifest::from_yaml_str(
12367 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n after_task: sleep\n",
12368 )
12369 .unwrap();
12370 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
12371 }
12372
12373 #[test]
12375 fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
12376 let manifest =
12377 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12378 assert!(manifest.lifecycle.is_none());
12379 assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
12380 }
12381
12382 #[test]
12383 fn lifecycle_max_task_reopens_explicit_value() {
12384 let manifest = RuntimeManifest::from_yaml_str(
12385 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 3\n",
12386 )
12387 .unwrap();
12388 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
12389 }
12390
12391 #[test]
12393 fn lifecycle_max_task_reopens_zero_is_accepted() {
12394 let manifest = RuntimeManifest::from_yaml_str(
12395 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 0\n",
12396 )
12397 .unwrap();
12398 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
12399 }
12400
12401 #[test]
12403 fn lifecycle_max_task_reopens_with_process_transport() {
12404 let manifest = RuntimeManifest::from_yaml_str(
12405 "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",
12406 ).unwrap();
12407 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
12408 }
12409
12410 #[test]
12411 fn inference_max_task_reopens_is_rejected_under_http_transport() {
12412 let err = RuntimeManifest::from_yaml_str(
12413 "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",
12414 ).unwrap_err();
12415 assert!(
12416 matches!(
12417 &err,
12418 RuntimeManifestError::InvalidInferenceConfig { field, .. }
12419 if field == "inference.max_task_reopens"
12420 ),
12421 "error was: {err:?}"
12422 );
12423 let msg = err.to_string();
12424 assert!(
12425 msg.contains("lifecycle.max_task_reopens"),
12426 "error was: {msg}"
12427 );
12428 }
12429
12430 #[test]
12431 fn inference_max_task_reopens_is_rejected_under_process_transport() {
12432 let err = RuntimeManifest::from_yaml_str(
12433 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n max_task_reopens: 2\n",
12434 ).unwrap_err();
12435 assert!(
12436 matches!(
12437 &err,
12438 RuntimeManifestError::InvalidInferenceConfig { field, .. }
12439 if field == "inference.max_task_reopens"
12440 ),
12441 "error was: {err:?}"
12442 );
12443 let msg = err.to_string();
12444 assert!(
12445 msg.contains("lifecycle.max_task_reopens"),
12446 "error was: {msg}"
12447 );
12448 }
12449
12450 #[test]
12452 fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
12453 let err = RuntimeManifest::from_yaml_str(
12454 "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",
12455 ).unwrap_err();
12456 assert!(
12457 matches!(
12458 &err,
12459 RuntimeManifestError::InvalidInferenceConfig { field, .. }
12460 if field == "inference.max_task_reopens"
12461 ),
12462 "error was: {err:?}"
12463 );
12464 }
12465
12466 #[test]
12467 fn skill_is_llm_visible() {
12468 assert!(ArtifactRuntime::Skill.is_llm_visible());
12469 }
12470
12471 #[test]
12472 fn driver_and_hook_are_not_llm_visible() {
12473 assert!(!ArtifactRuntime::Driver.is_llm_visible());
12474 assert!(!ArtifactRuntime::Hook.is_llm_visible());
12475 }
12476
12477 #[test]
12478 fn parses_system_prompt_artifact() {
12479 let manifest = RuntimeManifest::from_yaml_str(
12480 r#"
12481name: cap
12482version: 0.0.1
12483artifacts:
12484 - name: local/my-skill
12485 version: 1.0.0
12486 runtime: skill
12487 - name: murmur/driver-anthropic
12488 version: 0.1.0
12489 runtime: driver
12490 gateway:
12491 endpoint: http://127.0.0.1:8080
12492 keyless: true
12493inference:
12494 model: test-model
12495 system_prompt_artifact: local/my-skill
12496 driver:
12497 artifact: murmur/driver-anthropic
12498"#,
12499 )
12500 .unwrap();
12501
12502 let inference = manifest.inference.unwrap();
12503 assert_eq!(
12504 inference.system_prompt_artifact,
12505 Some("local/my-skill".to_string())
12506 );
12507 assert!(inference.system_prompt.is_none());
12508 assert!(inference.system_prompt_file.is_none());
12509 }
12510
12511 #[test]
12512 fn rejects_system_prompt_artifact_with_inline_prompt() {
12513 let err = RuntimeManifest::from_yaml_str(
12514 r#"
12515name: cap
12516version: 0.0.1
12517artifacts:
12518 - name: local/my-skill
12519 version: 1.0.0
12520 runtime: skill
12521 - name: murmur/driver-anthropic
12522 version: 0.1.0
12523 runtime: driver
12524 gateway:
12525 endpoint: http://127.0.0.1:8080
12526 keyless: true
12527inference:
12528 model: test-model
12529 system_prompt: "inline"
12530 system_prompt_artifact: local/my-skill
12531 driver:
12532 artifact: murmur/driver-anthropic
12533"#,
12534 )
12535 .unwrap_err();
12536
12537 let msg = err.to_string();
12538 assert!(msg.contains("at most one"), "error was: {msg}");
12539 }
12540
12541 #[test]
12542 fn rejects_system_prompt_artifact_naming_non_skill() {
12543 let err = RuntimeManifest::from_yaml_str(
12544 r#"
12545name: cap
12546version: 0.0.1
12547artifacts:
12548 - name: local/my-tool
12549 version: 1.0.0
12550 runtime: tool
12551 - name: murmur/driver-anthropic
12552 version: 0.1.0
12553 runtime: driver
12554 gateway:
12555 endpoint: http://127.0.0.1:8080
12556 keyless: true
12557inference:
12558 model: test-model
12559 system_prompt_artifact: local/my-tool
12560 driver:
12561 artifact: murmur/driver-anthropic
12562"#,
12563 )
12564 .unwrap_err();
12565
12566 let msg = err.to_string();
12567 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12568 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
12569 }
12570
12571 #[test]
12572 fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
12573 let err = RuntimeManifest::from_yaml_str(
12574 r#"
12575name: cap
12576version: 0.0.1
12577artifacts:
12578 - name: murmur/driver-anthropic
12579 version: 0.1.0
12580 runtime: driver
12581 gateway:
12582 endpoint: http://127.0.0.1:8080
12583 keyless: true
12584inference:
12585 model: test-model
12586 system_prompt_artifact: missing-skill
12587 driver:
12588 artifact: murmur/driver-anthropic
12589"#,
12590 )
12591 .unwrap_err();
12592
12593 let msg = err.to_string();
12594 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12595 assert!(msg.contains("not declared"), "error was: {msg}");
12596 }
12597
12598 #[test]
12599 fn rejects_all_three_system_prompt_sources() {
12600 let err = RuntimeManifest::from_yaml_str(
12601 r#"
12602name: cap
12603version: 0.0.1
12604artifacts:
12605 - name: local/my-skill
12606 version: 1.0.0
12607 runtime: skill
12608 - name: murmur/driver-anthropic
12609 version: 0.1.0
12610 runtime: driver
12611 gateway:
12612 endpoint: http://127.0.0.1:8080
12613 keyless: true
12614inference:
12615 model: test-model
12616 system_prompt: "inline"
12617 system_prompt_file: "conventions.md"
12618 system_prompt_artifact: local/my-skill
12619 driver:
12620 artifact: murmur/driver-anthropic
12621"#,
12622 )
12623 .unwrap_err();
12624
12625 let msg = err.to_string();
12626 assert!(msg.contains("at most one"), "error was: {msg}");
12627 }
12628
12629 #[test]
12632 fn hook_artifact_capabilities_parse_network_and_filesystem() {
12633 let manifest = RuntimeManifest::from_yaml_str(
12634 r#"
12635name: cap
12636version: 0.0.1
12637artifacts:
12638 - name: local/telemetry-hook
12639 version: 1.0.0
12640 runtime: hook
12641 capabilities:
12642 network:
12643 allow:
12644 - https://telemetry.example.com
12645 filesystem:
12646 scope: hook-state
12647"#,
12648 )
12649 .unwrap();
12650
12651 let caps = manifest.artifacts[0]
12652 .capabilities
12653 .as_ref()
12654 .expect("hook entry carries a capability grant");
12655 assert_eq!(
12656 caps.network.as_ref().unwrap().allow,
12657 vec!["https://telemetry.example.com".to_string()]
12658 );
12659 assert_eq!(
12660 caps.filesystem.as_ref().unwrap().scope,
12661 Some("hook-state".to_string())
12662 );
12663 }
12664
12665 #[test]
12668 fn hook_artifact_without_capabilities_is_none() {
12669 let manifest = RuntimeManifest::from_yaml_str(
12670 r#"
12671name: cap
12672version: 0.0.1
12673artifacts:
12674 - name: local/quiet-hook
12675 version: 1.0.0
12676 runtime: hook
12677"#,
12678 )
12679 .unwrap();
12680
12681 assert!(manifest.artifacts[0].capabilities.is_none());
12682 }
12683
12684 #[test]
12687 fn tool_and_driver_artifact_capabilities_parse() {
12688 for role in ["tool", "driver"] {
12689 let yaml = format!(
12690 r#"
12691name: cap
12692version: 0.0.1
12693artifacts:
12694 - name: local/scoped
12695 version: 1.0.0
12696 runtime: {role}
12697 capabilities:
12698 network:
12699 allow:
12700 - https://api.example.com
12701 filesystem:
12702 scope: cache
12703"#
12704 );
12705 let manifest = RuntimeManifest::from_yaml_str(&yaml)
12706 .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
12707
12708 let caps = manifest.artifacts[0]
12709 .capabilities
12710 .as_ref()
12711 .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
12712 assert_eq!(
12713 caps.network.as_ref().unwrap().allow,
12714 vec!["https://api.example.com".to_string()]
12715 );
12716 assert_eq!(
12717 caps.filesystem.as_ref().unwrap().scope,
12718 Some("cache".to_string())
12719 );
12720 }
12721 }
12722
12723 #[test]
12726 fn tool_artifact_without_capabilities_is_none() {
12727 let manifest = RuntimeManifest::from_yaml_str(
12728 r#"
12729name: cap
12730version: 0.0.1
12731artifacts:
12732 - name: local/plain-tool
12733 version: 1.0.0
12734 runtime: tool
12735"#,
12736 )
12737 .unwrap();
12738
12739 assert!(manifest.artifacts[0].capabilities.is_none());
12740 }
12741
12742 #[test]
12745 fn skill_artifact_capabilities_are_rejected() {
12746 let yaml = r#"
12747name: cap
12748version: 0.0.1
12749artifacts:
12750 - name: local/sneaky
12751 version: 1.0.0
12752 runtime: skill
12753 capabilities:
12754 network:
12755 allow:
12756 - https://evil.example.com
12757"#;
12758 let err = match RuntimeManifest::from_yaml_str(yaml) {
12759 Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
12760 Err(err) => err,
12761 };
12762 let msg = err.to_string();
12763 assert!(msg.contains("sneaky"), "error was: {msg}");
12764 assert!(
12765 msg.contains(
12766 "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
12767 entries"
12768 ),
12769 "error was: {msg}"
12770 );
12771 assert!(msg.contains("runtime: skill"), "error was: {msg}");
12772 }
12773
12774 #[test]
12779 fn artifact_config_is_carried_verbatim() {
12780 let manifest = RuntimeManifest::from_yaml_str(
12781 r#"
12782name: cap
12783version: 0.0.1
12784artifacts:
12785 - name: murmur/tool-corpus
12786 version: 0.1.0
12787 runtime: tool
12788 config:
12789 read_recent: { default: 20, max: 100 }
12790"#,
12791 )
12792 .unwrap();
12793
12794 let config = manifest.artifacts[0]
12795 .config
12796 .as_ref()
12797 .expect("a declared block parses");
12798 assert_eq!(
12799 config["read_recent"]["max"],
12800 serde_norway::Value::Number(100.into())
12801 );
12802 }
12803
12804 #[test]
12806 fn an_undeclared_config_is_none() {
12807 let manifest = RuntimeManifest::from_yaml_str(
12808 r#"
12809name: cap
12810version: 0.0.1
12811artifacts:
12812 - name: local/git
12813 version: 1.0.0
12814 runtime: tool
12815"#,
12816 )
12817 .unwrap();
12818
12819 assert!(manifest.artifacts[0].config.is_none());
12820 }
12821
12822 #[test]
12826 fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
12827 let manifest = RuntimeManifest::from_yaml_str(
12828 r#"
12829name: cap
12830version: 0.0.1
12831artifacts:
12832 - name: local/git
12833 version: 1.0.0
12834 runtime: tool
12835 config:
12836"#,
12837 )
12838 .unwrap();
12839
12840 assert_eq!(
12841 manifest.artifacts[0].config,
12842 Some(serde_norway::Value::Null),
12843 "an empty block must survive parsing as a declaration, not vanish into None"
12844 );
12845 }
12846
12847 #[test]
12850 fn skill_artifact_config_is_rejected() {
12851 let yaml = r#"
12852name: cap
12853version: 0.0.1
12854artifacts:
12855 - name: local/sneaky
12856 version: 1.0.0
12857 runtime: skill
12858 config:
12859 who: me
12860"#;
12861 let err = match RuntimeManifest::from_yaml_str(yaml) {
12862 Ok(_) => panic!("config: must be rejected for runtime: skill"),
12863 Err(err) => err,
12864 };
12865 assert!(matches!(
12866 err,
12867 RuntimeManifestError::InvalidArtifact { index: 0, .. }
12868 ));
12869 let msg = err.to_string();
12870 assert!(msg.contains("sneaky"), "error was: {msg}");
12871 assert!(msg.contains("runtime: skill"), "error was: {msg}");
12872 assert!(
12873 msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
12874 "error was: {msg}"
12875 );
12876 }
12877
12878 #[test]
12881 fn a_capsule_wide_config_block_is_rejected() {
12882 let yaml = r#"
12883name: cap
12884version: 0.0.1
12885config:
12886 who: capsule
12887artifacts:
12888 - name: local/git
12889 version: 1.0.0
12890 runtime: tool
12891"#;
12892 let err = match RuntimeManifest::from_yaml_str(yaml) {
12893 Ok(_) => panic!("a capsule-wide config block must be rejected"),
12894 Err(err) => err,
12895 };
12896 let msg = err.to_string();
12897 assert!(msg.contains("config"), "error was: {msg}");
12898 assert!(msg.contains("declared per artifact"), "error was: {msg}");
12899 }
12900
12901 #[test]
12905 fn hook_on_overflow_block_parses() {
12906 let manifest = RuntimeManifest::from_yaml_str(
12907 r#"
12908name: cap
12909version: 0.0.1
12910artifacts:
12911 - name: murmur/hook-grafana
12912 version: 1.0.0
12913 runtime: hook
12914 on_overflow: block
12915"#,
12916 )
12917 .unwrap();
12918
12919 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
12920 }
12921
12922 #[test]
12925 fn hook_on_overflow_defaults_to_drop() {
12926 let manifest = RuntimeManifest::from_yaml_str(
12927 r#"
12928name: cap
12929version: 0.0.1
12930artifacts:
12931 - name: murmur/hook-grafana
12932 version: 1.0.0
12933 runtime: hook
12934"#,
12935 )
12936 .unwrap();
12937
12938 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
12939 assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
12940 }
12941
12942 #[test]
12945 fn on_overflow_on_a_non_hook_artifact_is_rejected() {
12946 let yaml = r#"
12947name: cap
12948version: 0.0.1
12949artifacts:
12950 - name: local/git
12951 version: 1.0.0
12952 runtime: tool
12953 on_overflow: block
12954"#;
12955 let err = match RuntimeManifest::from_yaml_str(yaml) {
12956 Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
12957 Err(err) => err,
12958 };
12959 assert!(matches!(
12960 err,
12961 RuntimeManifestError::InvalidArtifact { index: 0, .. }
12962 ));
12963 let msg = err.to_string();
12964 assert!(msg.contains("git"), "error was: {msg}");
12965 assert!(
12966 msg.contains("only recognized on 'runtime: hook' entries"),
12967 "error was: {msg}"
12968 );
12969 }
12970
12971 #[test]
12973 fn unknown_on_overflow_value_is_rejected() {
12974 let yaml = r#"
12975name: cap
12976version: 0.0.1
12977artifacts:
12978 - name: murmur/hook-grafana
12979 version: 1.0.0
12980 runtime: hook
12981 on_overflow: spill
12982"#;
12983 let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
12984 let msg = err.to_string();
12985 assert!(msg.contains("spill"), "error was: {msg}");
12986 assert!(msg.contains("expected: drop, block"), "error was: {msg}");
12987 }
12988
12989 #[test]
12992 fn on_overflow_is_accepted_on_any_hook_entry() {
12993 let manifest = RuntimeManifest::from_yaml_str(
12994 r#"
12995name: cap
12996version: 0.0.1
12997artifacts:
12998 - name: murmur/hook-eval
12999 version: 1.0.0
13000 runtime: hook
13001 on_overflow: drop
13002 capabilities:
13003 network:
13004 allow:
13005 - https://telemetry.example.com
13006"#,
13007 )
13008 .unwrap();
13009
13010 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
13011 assert!(manifest.artifacts[0].capabilities.is_some());
13012 }
13013
13014 #[test]
13017 fn capsule_wide_and_hook_capabilities_coexist() {
13018 let manifest = RuntimeManifest::from_yaml_str(
13019 r#"
13020name: cap
13021version: 0.0.1
13022artifacts:
13023 - name: local/telemetry-hook
13024 version: 1.0.0
13025 runtime: hook
13026 capabilities:
13027 network:
13028 allow:
13029 - https://telemetry.example.com
13030capabilities:
13031 network:
13032 allow:
13033 - https://api.anthropic.com
13034"#,
13035 )
13036 .unwrap();
13037
13038 assert_eq!(
13039 manifest.capabilities.unwrap().network.unwrap().allow,
13040 vec!["https://api.anthropic.com".to_string()]
13041 );
13042 assert_eq!(
13043 manifest.artifacts[0]
13044 .capabilities
13045 .as_ref()
13046 .unwrap()
13047 .network
13048 .as_ref()
13049 .unwrap()
13050 .allow,
13051 vec!["https://telemetry.example.com".to_string()]
13052 );
13053 }
13054
13055 #[test]
13059 fn hook_own_manifest_capabilities_are_not_read_into_config() {
13060 let config = parse_hook_config_from_yaml(
13061 r#"
13062name: telemetry-hook
13063version: 1.0.0
13064binding: on-inference
13065execution_mode: blocking
13066capabilities:
13067 network:
13068 allow:
13069 - https://evil.example.com
13070 filesystem:
13071 scope: /
13072"#,
13073 )
13074 .expect("a self-declared capabilities block is ignored, not an error");
13075
13076 assert_eq!(
13077 config,
13078 HookConfig {
13079 binding: HookBinding::OnInference,
13080 execution_mode: HookExecutionMode::Blocking,
13081 commit_policy: HookCommitPolicy::None,
13082 }
13083 );
13084 }
13085
13086 #[test]
13089 fn containment_classes_order_weakest_to_strongest() {
13090 assert!(ContainmentClass::Unconfined < ContainmentClass::Advisory);
13091 assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
13092 assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
13093 assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
13094 assert_eq!(
13095 ContainmentClass::ALL.iter().copied().max(),
13096 Some(ContainmentClass::Sealed)
13097 );
13098 }
13099
13100 #[test]
13101 fn containment_class_defaults_to_advisory() {
13102 assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
13103 }
13104
13105 #[test]
13106 fn containment_class_round_trips_through_string() {
13107 for class in ContainmentClass::ALL {
13108 assert_eq!(
13109 class.to_string().parse::<ContainmentClass>(),
13110 Ok(class),
13111 "{class} must survive Display -> FromStr"
13112 );
13113 }
13114 assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
13115 assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
13116 assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
13117 assert_eq!(ContainmentClass::Unconfined.as_str(), "unconfined");
13118 assert_eq!(ContainmentClass::Unconfined.to_string(), "unconfined");
13119 assert_eq!(
13120 serde_json::to_string(&ContainmentClass::Unconfined).unwrap(),
13121 "\"unconfined\""
13122 );
13123 assert_eq!(
13124 serde_norway::from_str::<ContainmentClass>("unconfined").unwrap(),
13125 ContainmentClass::Unconfined
13126 );
13127 }
13128
13129 #[test]
13130 fn containment_declarable_classes_exclude_unconfined() {
13131 use ContainmentClass::{Advisory, Scoped, Sealed, Unconfined};
13132 assert_eq!(ContainmentClass::DECLARABLE, [Advisory, Scoped, Sealed]);
13133 assert_eq!(
13134 ContainmentClass::ALL,
13135 [Unconfined, Advisory, Scoped, Sealed]
13136 );
13137 let inputs: Vec<Option<ContainmentClass>> = std::iter::once(None)
13138 .chain(ContainmentClass::DECLARABLE.map(Some))
13139 .collect();
13140 for &workspace in &inputs {
13141 for &manifest in &inputs {
13142 for &cli in &inputs {
13143 assert_ne!(
13144 effective_containment_floor(workspace, manifest, cli),
13145 Unconfined
13146 );
13147 }
13148 }
13149 }
13150 }
13151
13152 #[test]
13153 fn containment_class_rejects_unknown_names() {
13154 let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
13155 assert_eq!(err.value, "paranoid");
13156 assert_eq!(
13157 err.to_string(),
13158 "must be one of: advisory, scoped, sealed; got 'paranoid'"
13159 );
13160 assert!("Sealed".parse::<ContainmentClass>().is_err());
13162 }
13163
13164 #[test]
13165 fn containment_class_serializes_as_its_wire_name() {
13166 assert_eq!(
13167 serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
13168 "\"scoped\""
13169 );
13170 assert_eq!(
13171 serde_norway::from_str::<ContainmentClass>("sealed").unwrap(),
13172 ContainmentClass::Sealed
13173 );
13174 }
13175
13176 #[test]
13177 fn effective_floor_is_advisory_when_nothing_is_declared() {
13178 assert_eq!(
13179 effective_containment_floor(None, None, None),
13180 ContainmentClass::Advisory
13181 );
13182 }
13183
13184 #[test]
13185 fn effective_floor_takes_the_strongest_across_every_presence_combination() {
13186 use ContainmentClass::{Advisory, Scoped, Sealed};
13187
13188 assert_eq!(
13190 effective_containment_floor(Some(Scoped), None, None),
13191 Scoped
13192 );
13193 assert_eq!(
13194 effective_containment_floor(None, Some(Scoped), None),
13195 Scoped
13196 );
13197 assert_eq!(
13198 effective_containment_floor(None, None, Some(Scoped)),
13199 Scoped
13200 );
13201 assert_eq!(
13202 effective_containment_floor(Some(Sealed), None, None),
13203 Sealed
13204 );
13205 assert_eq!(
13206 effective_containment_floor(None, Some(Sealed), None),
13207 Sealed
13208 );
13209 assert_eq!(
13210 effective_containment_floor(None, None, Some(Sealed)),
13211 Sealed
13212 );
13213
13214 assert_eq!(
13217 effective_containment_floor(Some(Advisory), Some(Sealed), None),
13218 Sealed
13219 );
13220 assert_eq!(
13221 effective_containment_floor(Some(Sealed), Some(Advisory), None),
13222 Sealed
13223 );
13224 assert_eq!(
13225 effective_containment_floor(Some(Advisory), None, Some(Scoped)),
13226 Scoped
13227 );
13228 assert_eq!(
13229 effective_containment_floor(Some(Scoped), None, Some(Advisory)),
13230 Scoped
13231 );
13232 assert_eq!(
13233 effective_containment_floor(None, Some(Advisory), Some(Scoped)),
13234 Scoped
13235 );
13236 assert_eq!(
13237 effective_containment_floor(None, Some(Scoped), Some(Advisory)),
13238 Scoped
13239 );
13240
13241 assert_eq!(
13243 effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
13244 Sealed
13245 );
13246 assert_eq!(
13247 effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
13248 Sealed
13249 );
13250 assert_eq!(
13251 effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
13252 Sealed
13253 );
13254 assert_eq!(
13255 effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
13256 Scoped
13257 );
13258 assert_eq!(
13259 effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
13260 Advisory
13261 );
13262 }
13263
13264 #[test]
13265 fn manifest_parses_each_declared_containment_class() {
13266 for class in ContainmentClass::DECLARABLE {
13267 let manifest = RuntimeManifest::from_yaml_str(&format!(
13268 r#"
13269name: cap
13270version: 0.0.1
13271capabilities:
13272 containment: {class}
13273"#
13274 ))
13275 .expect("a valid containment class parses");
13276
13277 assert_eq!(
13278 manifest.capabilities.unwrap().containment,
13279 Some(class),
13280 "capabilities.containment: {class}"
13281 );
13282 }
13283 }
13284
13285 #[test]
13286 fn manifest_without_containment_key_declares_nothing() {
13287 let manifest = RuntimeManifest::from_yaml_str(
13289 r#"
13290name: cap
13291version: 0.0.1
13292"#,
13293 )
13294 .unwrap();
13295 assert!(manifest.capabilities.is_none());
13296
13297 let manifest = RuntimeManifest::from_yaml_str(
13300 r#"
13301name: cap
13302version: 0.0.1
13303capabilities:
13304 network:
13305 allow:
13306 - https://api.example.com
13307"#,
13308 )
13309 .unwrap();
13310 assert_eq!(manifest.capabilities.unwrap().containment, None);
13311 }
13312
13313 #[test]
13314 fn manifest_rejects_an_unknown_containment_class() {
13315 let err = RuntimeManifest::from_yaml_str(
13316 r#"
13317name: cap
13318version: 0.0.1
13319capabilities:
13320 containment: paranoid
13321"#,
13322 )
13323 .expect_err("an unknown containment class is rejected at parse time");
13324
13325 match err {
13326 RuntimeManifestError::InvalidCapabilities { field, message } => {
13327 assert_eq!(field, "capabilities.containment");
13328 assert_eq!(
13329 message,
13330 "must be one of: advisory, scoped, sealed; got 'paranoid'"
13331 );
13332 }
13333 other => panic!("expected InvalidCapabilities, got {other:?}"),
13334 }
13335 }
13336
13337 #[test]
13338 fn manifest_refuses_an_unconfined_containment_class() {
13339 let err = RuntimeManifest::from_yaml_str(
13340 r#"
13341name: cap
13342version: 0.0.1
13343capabilities:
13344 containment: unconfined
13345"#,
13346 )
13347 .expect_err("a manifest cannot choose the unconfined stance");
13348
13349 match err {
13350 RuntimeManifestError::InvalidCapabilities { field, message } => {
13351 assert_eq!(field, "capabilities.containment");
13352 assert!(message.contains("mur run --unconfined"), "{message}");
13353 assert!(message.contains("~/.murmur/config.yaml"), "{message}");
13354 }
13355 other => panic!("expected InvalidCapabilities, got {other:?}"),
13356 }
13357 }
13358
13359 fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
13362 RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
13363 }
13364
13365 fn export_error(block: &str) -> (String, String) {
13366 match exports_manifest(block).expect_err("this exports block must not parse") {
13367 RuntimeManifestError::InvalidExports { field, message } => (field, message),
13368 other => panic!("expected InvalidExports, got {other:?}"),
13369 }
13370 }
13371
13372 fn peer_fetch_error(block: &str) -> (String, String) {
13373 match exports_manifest(block).expect_err("this capabilities block must not parse") {
13374 RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
13375 other => panic!("expected InvalidCapabilities, got {other:?}"),
13376 }
13377 }
13378
13379 #[test]
13382 fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
13383 assert_eq!(parse_duration_secs("90").unwrap(), 90);
13384 assert_eq!(parse_duration_secs("30s").unwrap(), 30);
13385 assert_eq!(parse_duration_secs("15m").unwrap(), 900);
13386 assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
13387 assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
13388 assert_eq!(parse_duration_secs(" 2h ").unwrap(), 7200);
13389 assert_eq!(parse_duration_secs("0").unwrap(), 0);
13390 }
13391
13392 #[test]
13395 fn parse_duration_secs_rejects_every_other_spelling() {
13396 for input in [
13397 "",
13398 " ",
13399 "5 minutes",
13400 "30S",
13401 "1H",
13402 "5min",
13403 "1.5h",
13404 "-1",
13405 "m",
13406 "h",
13407 "1h30m",
13408 "0x10",
13409 "30 s",
13410 ] {
13411 let error = parse_duration_secs(input)
13412 .expect_err(&format!("'{input}' must not parse as a duration"));
13413 assert!(
13414 error.contains(DURATION_ACCEPTED_FORM),
13415 "'{input}' must be refused with the accepted form; got: {error}"
13416 );
13417 }
13418 }
13419
13420 #[test]
13421 fn parse_duration_secs_refuses_to_overflow() {
13422 assert!(parse_duration_secs("18446744073709551615h")
13423 .unwrap_err()
13424 .contains("overflows"));
13425 }
13426
13427 #[test]
13428 fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
13429 let manifest = exports_manifest("exports:\n peer_files:\n root: out/\n")
13430 .expect("a minimal peer_files block parses");
13431 let peer = manifest.exports.unwrap().peer_files.unwrap();
13432 assert_eq!(peer.root, "out/");
13433 assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
13434 assert_eq!(peer.max_bytes, 10_485_760);
13435 assert_eq!(peer.max_ttl_secs, None);
13438 assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
13439 assert_eq!(peer.effective_max_ttl_secs(), 3600);
13440 }
13441
13442 #[test]
13443 fn peer_files_accepts_every_duration_spelling() {
13444 for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
13445 let manifest = exports_manifest(&format!(
13446 "exports:\n peer_files:\n root: out/\n max_ttl: {declared}\n"
13447 ))
13448 .expect("a declared max_ttl parses");
13449 let peer = manifest.exports.unwrap().peer_files.unwrap();
13450 assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
13451 assert_eq!(peer.effective_max_ttl_secs(), expected);
13452 }
13453 }
13454
13455 #[test]
13458 fn the_two_export_blocks_are_independent() {
13459 let both = exports_manifest(
13460 "exports:\n files:\n root: out/\n mode: read-only\n \
13461 peer_files:\n root: out/handoff/\n",
13462 )
13463 .expect("both blocks parse together")
13464 .exports
13465 .unwrap();
13466 assert_eq!(both.files.unwrap().root, "out/");
13467 assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
13468
13469 let only_files =
13470 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
13471 .unwrap()
13472 .exports
13473 .unwrap();
13474 assert!(only_files.peer_files.is_none());
13475
13476 let only_peer = exports_manifest("exports:\n peer_files:\n root: out/\n")
13477 .unwrap()
13478 .exports
13479 .unwrap();
13480 assert!(only_peer.files.is_none());
13481
13482 let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
13483 assert!(neither.files.is_none() && neither.peer_files.is_none());
13484 }
13485
13486 #[test]
13487 fn peer_files_root_must_be_relative_and_inside_the_workdir() {
13488 for root in ["../out", "/etc", "out/../../etc"] {
13489 let (field, message) =
13490 export_error(&format!("exports:\n peer_files:\n root: {root}\n"));
13491 assert_eq!(field, "exports.peer_files.root", "root: {root}");
13492 assert!(
13493 message.contains(EXPORT_ROOT_ACCEPTED_FORM),
13494 "root: {root}; got: {message}"
13495 );
13496 }
13497 }
13498
13499 #[test]
13500 fn peer_files_root_is_required() {
13501 let (field, message) = export_error("exports:\n peer_files: {}\n");
13502 assert_eq!(field, "exports.peer_files.root");
13503 assert!(message.contains("is required"));
13504 assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
13505 }
13506
13507 #[test]
13508 fn peer_files_max_ttl_states_its_accepted_form() {
13509 for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
13510 let (field, message) = export_error(&format!(
13511 "exports:\n peer_files:\n root: out/\n max_ttl: \"{declared}\"\n"
13512 ));
13513 assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
13514 assert!(
13515 message.contains(DURATION_ACCEPTED_FORM),
13516 "max_ttl: {declared}; got: {message}"
13517 );
13518 }
13519 }
13520
13521 #[test]
13524 fn peer_files_max_ttl_rejects_zero() {
13525 let (field, message) =
13526 export_error("exports:\n peer_files:\n root: out/\n max_ttl: 0\n");
13527 assert_eq!(field, "exports.peer_files.max_ttl");
13528 assert!(message.contains("greater than zero"));
13529 }
13530
13531 #[test]
13532 fn peer_files_max_bytes_states_its_accepted_form() {
13533 let (field, message) =
13534 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 10MB\n");
13535 assert_eq!(field, "exports.peer_files.max_bytes");
13536 assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
13537 }
13538
13539 #[test]
13540 fn peer_files_max_bytes_rejects_zero() {
13541 let (field, message) =
13542 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 0\n");
13543 assert_eq!(field, "exports.peer_files.max_bytes");
13544 assert!(message.contains("greater than zero"));
13545 }
13546
13547 #[test]
13551 fn peer_tasks_consents_only_on_an_explicit_true() {
13552 let absent = exports_manifest("artifacts: []\n").expect("no exports block parses");
13553 assert!(absent.exports.is_none());
13554 assert!(!absent.accepts_peer_tasks());
13555
13556 let refused = exports_manifest("exports:\n peer_tasks:\n accept: false\n")
13557 .expect("an explicit refusal parses");
13558 assert_eq!(
13559 refused.exports.as_ref().unwrap().peer_tasks,
13560 Some(PeerTasksExport { accept: false })
13561 );
13562 assert!(!refused.accepts_peer_tasks());
13563 assert!(!refused.exports.as_ref().unwrap().accepts_peer_tasks());
13564
13565 let accepted = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
13566 .expect("consent parses");
13567 assert_eq!(
13568 accepted.exports.as_ref().unwrap().peer_tasks,
13569 Some(PeerTasksExport { accept: true })
13570 );
13571 assert!(accepted.accepts_peer_tasks());
13572 assert!(accepted.exports.as_ref().unwrap().accepts_peer_tasks());
13573 }
13574
13575 #[test]
13577 fn peer_tasks_alone_declares_no_file_plane() {
13578 let manifest = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
13579 .expect("a peer_tasks-only exports block parses");
13580 let exports = manifest.exports.unwrap();
13581 assert_eq!(exports.files, None);
13582 assert_eq!(exports.peer_files, None);
13583 }
13584
13585 #[test]
13587 fn a_file_export_does_not_consent_to_peer_tasks() {
13588 let manifest =
13589 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
13590 .expect("a files export parses");
13591 assert_eq!(manifest.exports.as_ref().unwrap().peer_tasks, None);
13592 assert!(!manifest.accepts_peer_tasks());
13593 }
13594
13595 #[test]
13598 fn peer_tasks_without_accept_is_refused() {
13599 let (field, message) = export_error("exports:\n peer_tasks: {}\n");
13600 assert_eq!(field, "exports.peer_tasks.accept");
13601 assert_eq!(
13602 message,
13603 "must be set explicitly to true or false — consent to peer tasks is never inferred"
13604 );
13605 }
13606
13607 #[test]
13609 fn peer_tasks_of_the_wrong_type_is_refused_naming_the_field() {
13610 for (block, field) in [
13611 (
13612 "exports:\n peer_tasks:\n accept: \"yes\"\n",
13613 "exports.peer_tasks.accept",
13614 ),
13615 (
13616 "exports:\n peer_tasks:\n accept: 1\n",
13617 "exports.peer_tasks.accept",
13618 ),
13619 ("exports:\n peer_tasks: true\n", "exports.peer_tasks"),
13620 ("exports:\n peer_tasks: [peer-a]\n", "exports.peer_tasks"),
13621 ] {
13622 let message = exports_manifest(block)
13623 .expect_err(&format!("{block:?} must not parse"))
13624 .to_string();
13625 assert!(message.contains(field), "{block:?}: {message}");
13626 }
13627 }
13628
13629 #[test]
13632 fn an_unknown_peer_tasks_key_is_reported_by_path() {
13633 let manifest =
13634 exports_manifest("exports:\n peer_tasks:\n accept: false\n allow: [peer-a]\n")
13635 .expect("an unknown key is a warning, not a parse error");
13636 assert!(
13637 manifest
13638 .unknown_keys
13639 .iter()
13640 .any(|key| key.block_path == "exports.peer_tasks" && key.key == "allow"),
13641 "{:?}",
13642 manifest.unknown_keys
13643 );
13644 assert!(!manifest.accepts_peer_tasks());
13645 }
13646
13647 #[test]
13650 fn peer_fetch_allow_carries_its_declared_destinations() {
13651 let manifest = exports_manifest(
13652 "capabilities:\n peer_fetch:\n allow:\n - localhost:41234\n \
13653 - reporting.internal\n - https://gateway.example.com\n",
13654 )
13655 .expect("a peer_fetch block parses");
13656 assert_eq!(
13657 manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
13658 vec![
13659 "localhost:41234".to_string(),
13660 "reporting.internal".to_string(),
13661 "https://gateway.example.com".to_string(),
13662 ]
13663 );
13664 }
13665
13666 #[test]
13669 fn peer_fetch_allow_must_not_be_empty() {
13670 let (field, message) = peer_fetch_error("capabilities:\n peer_fetch:\n allow: []\n");
13671 assert_eq!(field, "capabilities.peer_fetch.allow");
13672 assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
13673 }
13674
13675 #[test]
13676 fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
13677 for entry in [
13678 "http://[not a host",
13679 "\"\"",
13680 "localhost:notaport",
13681 "http://example.com/path",
13682 "ftp://example.com",
13683 ] {
13684 let (field, message) = peer_fetch_error(&format!(
13685 "capabilities:\n peer_fetch:\n allow:\n - {entry}\n"
13686 ));
13687 assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
13688 assert!(
13689 message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
13690 "entry: {entry}; got: {message}"
13691 );
13692 }
13693 }
13694
13695 #[test]
13698 fn peer_fetch_does_not_widen_network_allow() {
13699 let capabilities = exports_manifest(
13700 "capabilities:\n network:\n allow:\n - api.example.com\n \
13701 peer_fetch:\n allow:\n - localhost:41234\n",
13702 )
13703 .unwrap()
13704 .capabilities
13705 .unwrap();
13706 assert_eq!(
13707 capabilities.network.unwrap().allow,
13708 vec!["api.example.com".to_string()]
13709 );
13710 assert_eq!(
13711 capabilities.peer_fetch.unwrap().allow,
13712 vec!["localhost:41234".to_string()]
13713 );
13714 }
13715
13716 #[test]
13717 fn an_absent_peer_fetch_block_is_absent() {
13718 assert!(exports_manifest("capabilities: {}\n")
13719 .unwrap()
13720 .capabilities
13721 .unwrap()
13722 .peer_fetch
13723 .is_none());
13724 }
13725
13726 #[test]
13727 fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
13728 assert_eq!(parse_byte_size("0").unwrap(), 0);
13729 assert_eq!(parse_byte_size("4096").unwrap(), 4096);
13730 assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
13731 assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
13732 assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
13733 assert_eq!(parse_byte_size(" 10Mi ").unwrap(), 10 * 1024 * 1024);
13734 }
13735
13736 #[test]
13739 fn parse_byte_size_rejects_every_other_spelling() {
13740 for input in [
13741 "", " ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
13742 "1Ki1",
13743 ] {
13744 let error = parse_byte_size(input)
13745 .expect_err(&format!("'{input}' must not parse as a byte size"));
13746 assert!(
13747 error.contains(BYTE_SIZE_ACCEPTED_FORM),
13748 "'{input}' must be refused with the accepted form; got: {error}"
13749 );
13750 }
13751 }
13752
13753 #[test]
13754 fn parse_byte_size_refuses_to_overflow() {
13755 assert!(parse_byte_size("18446744073709551615Gi")
13756 .unwrap_err()
13757 .contains("overflows"));
13758 }
13759
13760 #[test]
13761 fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
13762 let manifest =
13763 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
13764 .expect("a minimal exports block parses");
13765 let files = manifest.exports.unwrap().files.unwrap();
13766 assert_eq!(files.root, "out/");
13767 assert_eq!(files.mode, ExportMode::ReadOnly);
13768 assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
13769 assert_eq!(files.max_bytes, 10_485_760);
13770 }
13771
13772 #[test]
13773 fn exports_files_accepts_both_byte_spellings() {
13774 for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
13775 let manifest = exports_manifest(&format!(
13776 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: {declared}\n"
13777 ))
13778 .expect("a declared max_bytes parses");
13779 assert_eq!(
13780 manifest.exports.unwrap().files.unwrap().max_bytes,
13781 expected,
13782 "max_bytes: {declared}"
13783 );
13784 }
13785 }
13786
13787 #[test]
13790 fn an_exports_block_without_files_declares_no_plane() {
13791 let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
13792 assert!(manifest.exports.unwrap().files.is_none());
13793 }
13794
13795 #[test]
13796 fn an_absent_exports_block_is_absent() {
13797 let manifest = exports_manifest("").expect("a manifest without exports parses");
13798 assert!(manifest.exports.is_none());
13799 }
13800
13801 #[test]
13802 fn exports_files_root_must_be_relative_and_inside_the_workdir() {
13803 for root in ["../out", "/etc", "out/../../etc", "''"] {
13804 let (field, message) = export_error(&format!(
13805 "exports:\n files:\n root: {root}\n mode: read-only\n"
13806 ));
13807 assert_eq!(field, "exports.files.root", "root: {root}");
13808 assert!(
13809 message.contains("must be a relative path inside the workdir"),
13810 "root: {root}; got: {message}"
13811 );
13812 }
13813 }
13814
13815 #[test]
13816 fn exports_files_root_is_required() {
13817 let (field, message) = export_error("exports:\n files:\n mode: read-only\n");
13818 assert_eq!(field, "exports.files.root");
13819 assert_eq!(
13820 message,
13821 "is required and must be a relative path inside the workdir"
13822 );
13823 }
13824
13825 #[test]
13826 fn exports_files_mode_is_required_and_read_only() {
13827 let (field, message) = export_error("exports:\n files:\n root: out/\n");
13828 assert_eq!(field, "exports.files.mode");
13829 assert_eq!(message, "is required and must be 'read-only'");
13830
13831 let (field, message) =
13832 export_error("exports:\n files:\n root: out/\n mode: read-write\n");
13833 assert_eq!(field, "exports.files.mode");
13834 assert_eq!(message, "'read-write' must be 'read-only'");
13835 }
13836
13837 #[test]
13838 fn exports_files_max_bytes_states_its_accepted_form() {
13839 let (field, message) = export_error(
13840 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 10MB\n",
13841 );
13842 assert_eq!(field, "exports.files.max_bytes");
13843 assert_eq!(
13844 message,
13845 "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
13846 );
13847 }
13848
13849 #[test]
13852 fn exports_files_max_bytes_rejects_zero() {
13853 let (field, message) = export_error(
13854 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 0\n",
13855 );
13856 assert_eq!(field, "exports.files.max_bytes");
13857 assert!(message.contains("must be greater than zero"), "{message}");
13858 }
13859
13860 #[test]
13862 fn exports_files_root_accepts_a_current_dir_prefix() {
13863 let manifest =
13864 exports_manifest("exports:\n files:\n root: ./out\n mode: read-only\n")
13865 .expect("./out is a legal root");
13866 assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
13867 }
13868
13869 fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
13872 RuntimeManifest::from_yaml_str(yaml)
13873 .expect("fixture must parse")
13874 .unknown_keys
13875 .into_iter()
13876 .map(|key| (key.key, key.block_path, key.nearest_known))
13877 .collect()
13878 }
13879
13880 #[test]
13883 fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
13884 assert_eq!(
13885 unknown_keys_of(
13886 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n read-only:\n - tests\n"
13887 ),
13888 vec![(
13889 "read-only".to_string(),
13890 "capabilities.filesystem".to_string(),
13891 Some("read_only".to_string())
13892 )]
13893 );
13894 }
13895
13896 #[test]
13900 fn a_manifest_of_recognized_keys_reports_nothing() {
13901 assert!(unknown_keys_of(
13902 r#"
13903name: cap
13904version: 0.1.0
13905deploy:
13906 mur_version: "0.2.0"
13907artifacts:
13908 - name: local/notes-tool
13909 version: 0.1.0
13910 runtime: tool
13911 capabilities:
13912 filesystem:
13913 scope: notes
13914 network:
13915 allow:
13916 - example.com:443
13917capabilities:
13918 filesystem:
13919 read_only:
13920 - tests
13921 - bench/fixtures
13922 workdir_exec: false
13923 shell:
13924 allow:
13925 - git
13926 - python3
13927 network:
13928 allow:
13929 - example.com:443
13930 unix_sockets: false
13931"#
13932 )
13933 .is_empty());
13934 }
13935
13936 #[test]
13939 fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
13940 assert_eq!(
13941 unknown_keys_of(
13942 "name: cap\nversion: 0.1.0\nartifacts:\n - name: local/t\n version: 0.1.0\n \
13943 runtime: tool\n capabilities:\n shell:\n allow:\n - git\n \
13944 allwo:\n - git\n"
13945 ),
13946 vec![(
13947 "allwo".to_string(),
13948 "artifacts[0].capabilities.shell".to_string(),
13949 Some("allow".to_string())
13950 )]
13951 );
13952 }
13953
13954 #[test]
13957 fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
13958 assert_eq!(
13959 unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
13960 vec![("quantum_teleport".to_string(), String::new(), None)]
13961 );
13962 }
13963
13964 #[test]
13969 fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
13970 assert!(unknown_keys_of(
13971 "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
13972 )
13973 .is_empty());
13974 }
13975
13976 #[test]
13978 fn every_build_side_key_is_recognized() {
13979 assert!(unknown_keys_of(
13980 "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
13981 wasm\nrequires_files:\n - tool.wasm\n - assets/logo.png\n"
13982 )
13983 .is_empty());
13984 }
13985
13986 #[test]
13989 fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
13990 assert_eq!(
13991 unknown_keys_of(
13992 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
13993 static\nrequires_files:\n - capsule.wasm\nquantum_teleport: true\n"
13994 ),
13995 vec![("quantum_teleport".to_string(), String::new(), None)]
13996 );
13997 }
13998
13999 #[test]
14003 fn the_build_side_keys_change_nothing_the_run_path_reads() {
14004 let body = "artifacts:\n - name: local/notes-tool\n version: 0.1.0\n runtime: tool\n\
14005 \x20 - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: \
14006 driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\n\
14007 capabilities:\n filesystem:\n read_only:\n - tests\n\
14008 inference:\n transport: http\n model: test-model\n driver:\n \
14009 artifact: murmur/driver-anthropic\n";
14010 let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
14011 .expect("fixture must parse");
14012 let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
14013 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n \
14014 any: shape\nrequires_files: not-a-list\n{body}"
14015 ))
14016 .expect("a build-side key of any shape never refuses a manifest");
14017
14018 assert!(with_build_keys.unknown_keys.is_empty());
14019 assert!(
14020 !plain.artifacts.is_empty()
14021 && plain.capabilities.is_some()
14022 && plain.inference.is_some(),
14023 "the fixture must populate the blocks the comparison below rests on, or it \
14024 compares absence against absence"
14025 );
14026 assert_eq!(with_build_keys.name, plain.name);
14027 assert_eq!(with_build_keys.version, plain.version);
14028 assert_eq!(with_build_keys.artifacts, plain.artifacts);
14029 assert_eq!(with_build_keys.capabilities, plain.capabilities);
14030 assert_eq!(with_build_keys.inference, plain.inference);
14031 }
14032
14033 #[test]
14036 fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
14037 let manifest = RuntimeManifest::from_yaml_str(
14038 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n scope: notes\n \
14039 read-only:\n - tests\n",
14040 )
14041 .expect("an unrecognized key never refuses a manifest");
14042 assert_eq!(
14043 manifest
14044 .capabilities
14045 .as_ref()
14046 .and_then(|caps| caps.filesystem.as_ref())
14047 .and_then(|fs| fs.scope.as_deref()),
14048 Some("notes")
14049 );
14050 assert_eq!(manifest.unknown_keys.len(), 1);
14051 }
14052
14053 #[test]
14055 fn nested_blocks_outside_capabilities_are_walked_too() {
14056 let keys = unknown_keys_of(
14057 "name: cap\nversion: 0.1.0\ncontext:\n max_tokns: 100\nexports:\n files:\n \
14058 root: out\n mode: read-only\n max_byts: 10\n",
14059 );
14060 assert_eq!(
14061 keys,
14062 vec![
14063 (
14064 "max_tokns".to_string(),
14065 "context".to_string(),
14066 Some("max_tokens".to_string())
14067 ),
14068 (
14069 "max_byts".to_string(),
14070 "exports.files".to_string(),
14071 Some("max_bytes".to_string())
14072 ),
14073 ]
14074 );
14075 }
14076
14077 #[test]
14084 fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
14085 let source = include_str!("runtime_manifest.rs");
14086 let lines: Vec<&str> = source.lines().collect();
14087
14088 let mut checked = 0usize;
14089 for (start, line) in lines.iter().enumerate() {
14090 let Some(name) = line
14091 .strip_prefix("struct Raw")
14092 .and_then(|rest| rest.strip_suffix(" {"))
14093 else {
14094 continue;
14095 };
14096 let name = format!("Raw{name}");
14097 let end = (start + 1..lines.len())
14098 .find(|index| lines[*index] == "}")
14099 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
14100 let body = &lines[start + 1..end];
14101
14102 assert!(
14103 body.contains(&" #[serde(flatten)]")
14104 && body.contains(&" unknown: UnknownKeys,"),
14105 "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
14106 so a key it does not recognize would be dropped instead of reported"
14107 );
14108
14109 let declared = serde_field_names(body);
14110 let known = known_keys_of(source, &name);
14111 assert_eq!(
14112 declared, known,
14113 "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
14114 suggester would propose the wrong set for that block"
14115 );
14116 checked += 1;
14117 }
14118 assert!(
14119 checked >= 30,
14120 "expected every Raw* struct to be scanned, found only {checked}"
14121 );
14122 }
14123
14124 #[test]
14131 fn every_raw_struct_descends_into_the_blocks_it_owns() {
14132 let source = include_str!("runtime_manifest.rs");
14133 let lines: Vec<&str> = source.lines().collect();
14134
14135 let mut structs = 0usize;
14136 let mut children = 0usize;
14137 for (start, line) in lines.iter().enumerate() {
14138 let Some(name) = line
14139 .strip_prefix("struct Raw")
14140 .and_then(|rest| rest.strip_suffix(" {"))
14141 else {
14142 continue;
14143 };
14144 let name = format!("Raw{name}");
14145 let end = (start + 1..lines.len())
14146 .find(|index| lines[*index] == "}")
14147 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
14148 let owned = block_typed_fields(&lines[start + 1..end]);
14149 structs += 1;
14150
14151 if owned.is_empty() {
14152 continue;
14153 }
14154 let block = raw_block_impl_of(source, &name);
14155 assert!(
14156 block.contains("fn walk_children"),
14157 "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
14158 `walk_children`, so every key written inside {} would go unreported",
14159 if owned.len() == 1 { "" } else { "s" },
14160 owned.join(", "),
14161 if owned.len() == 1 { "it" } else { "them" }
14162 );
14163 for field in &owned {
14164 assert!(
14165 mentions_field(&block, field),
14166 "{name}'s `walk_children` never touches `self.{field}`, so every key written \
14167 inside that block would go unreported — descend into it with \
14168 `collect_block(.., &child_path(path, \"{field}\"), out)`"
14169 );
14170 children += 1;
14171 }
14172 }
14173 assert!(
14174 structs >= 30,
14175 "expected every Raw* struct to be scanned, found only {structs}"
14176 );
14177 assert!(
14178 children >= 31,
14179 "expected every nested block field to be scanned, found only {children}"
14180 );
14181 }
14182
14183 fn mentions_field(impl_body: &str, field: &str) -> bool {
14187 let needle = format!("self.{field}");
14188 impl_body.match_indices(&needle).any(|(at, _)| {
14189 impl_body[at + needle.len()..]
14190 .chars()
14191 .next()
14192 .is_none_or(|next| !next.is_alphanumeric() && next != '_')
14193 })
14194 }
14195
14196 fn block_typed_fields(body: &[&str]) -> Vec<String> {
14200 let mut fields = Vec::new();
14201 for line in body {
14202 let trimmed = line.trim();
14203 if trimmed.starts_with("//") || trimmed.starts_with("#[") {
14204 continue;
14205 }
14206 let Some((field, declared)) = line
14207 .strip_prefix(" ")
14208 .and_then(|rest| rest.split_once(':'))
14209 else {
14210 continue;
14211 };
14212 if field.is_empty()
14213 || !field
14214 .chars()
14215 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
14216 {
14217 continue;
14218 }
14219 if declared.contains("Raw") {
14220 fields.push(field.to_string());
14221 }
14222 }
14223 fields
14224 }
14225
14226 fn raw_block_impl_of(source: &str, name: &str) -> String {
14229 let header = format!("impl RawBlock for {name} {{");
14230 let body = source
14231 .split_once(&header)
14232 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
14233 .1;
14234 body.split_once("\n}\n")
14235 .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
14236 .0
14237 .to_string()
14238 }
14239
14240 fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
14247 let raw: RawRuntimeManifest = serde_norway::from_str(yaml).expect("fixture must parse");
14248 collect_unknown_keys(&raw)
14249 .into_iter()
14250 .map(|key| (key.key, key.block_path))
14251 .collect()
14252 }
14253
14254 #[test]
14255 fn spawn_workdir_snapshot_is_a_known_key_beside_a_sibling_probe() {
14256 let keys = raw_unknown_keys(
14257 "name: cap\nversion: 0.1.0\ncapabilities:\n spawn:\n allow: [local/r]\n \
14258 workdir_snapshot: [local/r]\n probe_spawn: 1\n",
14259 );
14260 assert_eq!(
14261 keys,
14262 vec![("probe_spawn".to_string(), "capabilities.spawn".to_string())]
14263 );
14264 }
14265
14266 #[test]
14270 fn every_block_reports_its_own_unknown_key_at_its_own_path() {
14271 let keys = raw_unknown_keys(
14272 r#"
14273name: every-block
14274version: 0.1.0
14275probe_root: 1
14276artifacts:
14277 - name: local/t
14278 version: 0.1.0
14279 runtime: tool
14280 probe_artifact: 1
14281 capabilities:
14282 probe_artifact_capabilities: 1
14283 shell:
14284 probe_artifact_shell: 1
14285capabilities:
14286 probe_capabilities: 1
14287 network:
14288 probe_network: 1
14289 peer_fetch:
14290 probe_peer_fetch: 1
14291 filesystem:
14292 probe_filesystem: 1
14293 shell:
14294 probe_shell: 1
14295 interpreter_runtime:
14296 - binary: python3
14297 probe_interpreter_runtime: 1
14298 dirs:
14299 - path: /usr/lib/python3
14300 probe_interpreter_runtime_dir: 1
14301 staged_runtime:
14302 - binary: rg
14303 probe_staged_runtime: 1
14304 spawn:
14305 probe_spawn: 1
14306 env:
14307 probe_env: 1
14308 limits:
14309 probe_limits: 1
14310 resources:
14311 probe_resources: 1
14312 state:
14313 probe_state: 1
14314 task_io:
14315 probe_task_io: 1
14316 conversation:
14317 probe_conversation: 1
14318 install:
14319 probe_install: 1
14320inference:
14321 probe_inference: 1
14322 driver:
14323 probe_driver: 1
14324 compaction:
14325 probe_compaction: 1
14326context:
14327 probe_context: 1
14328observability:
14329 probe_observability: 1
14330 eval:
14331 probe_eval: 1
14332 scorers:
14333 - type: exact_match
14334 probe_scorer: 1
14335trace:
14336 probe_trace: 1
14337network:
14338 probe_network_config: 1
14339lifecycle:
14340 probe_lifecycle: 1
14341exports:
14342 probe_exports: 1
14343 files:
14344 probe_files: 1
14345 peer_files:
14346 probe_peer_files: 1
14347control:
14348 probe_control: 1
14349"#,
14350 );
14351
14352 let expected: Vec<(String, String)> = [
14353 ("probe_root", ""),
14354 ("probe_artifact", "artifacts[0]"),
14355 ("probe_artifact_capabilities", "artifacts[0].capabilities"),
14356 ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
14357 ("probe_capabilities", "capabilities"),
14358 ("probe_network", "capabilities.network"),
14359 ("probe_peer_fetch", "capabilities.peer_fetch"),
14360 ("probe_filesystem", "capabilities.filesystem"),
14361 ("probe_shell", "capabilities.shell"),
14362 (
14363 "probe_interpreter_runtime",
14364 "capabilities.shell.interpreter_runtime[0]",
14365 ),
14366 (
14367 "probe_interpreter_runtime_dir",
14368 "capabilities.shell.interpreter_runtime[0].dirs[0]",
14369 ),
14370 (
14371 "probe_staged_runtime",
14372 "capabilities.shell.staged_runtime[0]",
14373 ),
14374 ("probe_spawn", "capabilities.spawn"),
14375 ("probe_env", "capabilities.env"),
14376 ("probe_limits", "capabilities.limits"),
14377 ("probe_resources", "capabilities.resources"),
14378 ("probe_state", "capabilities.state"),
14379 ("probe_task_io", "capabilities.task_io"),
14380 ("probe_conversation", "capabilities.conversation"),
14381 ("probe_install", "capabilities.install"),
14382 ("probe_inference", "inference"),
14383 ("probe_driver", "inference.driver"),
14384 ("probe_compaction", "inference.compaction"),
14385 ("probe_context", "context"),
14386 ("probe_observability", "observability"),
14387 ("probe_eval", "observability.eval"),
14388 ("probe_scorer", "observability.eval.scorers[0]"),
14389 ("probe_trace", "trace"),
14390 ("probe_network_config", "network"),
14391 ("probe_lifecycle", "lifecycle"),
14392 ("probe_exports", "exports"),
14393 ("probe_files", "exports.files"),
14394 ("probe_peer_files", "exports.peer_files"),
14395 ("probe_control", "control"),
14396 ]
14397 .into_iter()
14398 .map(|(key, path)| (key.to_string(), path.to_string()))
14399 .collect();
14400
14401 assert_eq!(keys, expected);
14402 }
14403
14404 fn serde_field_names(body: &[&str]) -> Vec<String> {
14408 let mut names = Vec::new();
14409 let mut rename: Option<String> = None;
14410 for line in body {
14411 let trimmed = line.trim();
14412 if trimmed.starts_with("#[") {
14413 if let Some(rest) = trimmed.split("rename = \"").nth(1) {
14414 if !trimmed.contains("rename_all") {
14415 rename = rest.split('"').next().map(str::to_string);
14416 }
14417 }
14418 continue;
14419 }
14420 if trimmed.starts_with("//") {
14421 continue;
14422 }
14423 let Some(field) = line
14424 .strip_prefix(" ")
14425 .and_then(|rest| rest.split(':').next())
14426 else {
14427 continue;
14428 };
14429 if field.is_empty()
14430 || !field
14431 .chars()
14432 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
14433 {
14434 continue;
14435 }
14436 if field == "unknown" {
14437 rename = None;
14438 continue;
14439 }
14440 names.push(rename.take().unwrap_or_else(|| field.to_string()));
14441 }
14442 names
14443 }
14444
14445 fn known_keys_of(source: &str, name: &str) -> Vec<String> {
14449 let header = format!("impl RawBlock for {name} {{");
14450 let body = source
14451 .split_once(&header)
14452 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
14453 .1;
14454 let array = body
14456 .split_once("KNOWN_KEYS: &'static [&'static str] =")
14457 .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
14458 .1
14459 .trim_start()
14460 .strip_prefix("&[")
14461 .unwrap_or_else(|| panic!("{name}'s KNOWN_KEYS is not an array literal"))
14462 .split_once("];")
14463 .expect("KNOWN_KEYS array is unterminated")
14464 .0;
14465 array
14466 .split('"')
14467 .skip(1)
14468 .step_by(2)
14469 .map(str::to_string)
14470 .collect()
14471 }
14472}
14473
14474#[cfg(test)]
14475mod control_tests {
14476 use super::*;
14477
14478 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";
14479
14480 fn agent(transport: &str, control: &str) -> String {
14483 let driver = if transport == "http" {
14484 " - 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"
14485 } else {
14486 " - name: local/drv\n version: 0.1.0\n runtime: driver\n"
14487 };
14488 format!(
14489 "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}"
14490 )
14491 }
14492
14493 fn refusal(yaml: &str) -> (String, String) {
14494 match RuntimeManifest::from_yaml_str(yaml) {
14495 Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14496 other => panic!("expected InvalidControl, got {other:?}"),
14497 }
14498 }
14499
14500 #[test]
14501 fn control_absent_empty_and_empty_lists_load_as_none() {
14502 for control in [
14503 "",
14504 "control: {}\n",
14505 "control:\n",
14506 "control:\n settings: []\n secrets: []\n",
14507 ] {
14508 let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
14509 .unwrap_or_else(|err| panic!("{control:?}: {err}"));
14510 assert_eq!(manifest.control, None, "{control:?}");
14511 }
14512 }
14513
14514 #[test]
14515 fn control_parses_settings_and_secrets_in_manifest_order() {
14516 let yaml = agent(
14517 "http",
14518 "control:\n settings: [inference.max_tokens]\n secrets: [CARD_TOKEN]\n",
14519 );
14520 for manifest in [
14521 RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14522 RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14523 ] {
14524 let control = manifest.control.unwrap();
14525 assert_eq!(
14526 control.settings,
14527 vec![ControllableSetting::InferenceMaxTokens]
14528 );
14529 assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
14530 assert!(control.declares_secret("CARD_TOKEN"));
14531 assert!(!control.declares_secret("DRIVER_KEY"));
14532 }
14533 }
14534
14535 #[test]
14536 fn control_setting_wire_names_round_trip() {
14537 for setting in ControllableSetting::ALL {
14538 assert_eq!(
14539 ControllableSetting::from_wire_name(setting.wire_name()),
14540 Some(*setting)
14541 );
14542 }
14543 assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
14544 }
14545
14546 #[test]
14549 fn control_manifest_refusals_name_the_field() {
14550 let cases: &[(&str, &str, &str, &str)] = &[
14551 (
14552 "http",
14553 "control:\n settings: [inference.model]\n",
14554 "control.settings",
14555 "'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
14556 ),
14557 (
14558 "process",
14559 "control:\n settings: [inference.max_tokens]\n",
14560 "control.settings",
14561 "'inference.max_tokens' is controllable only under inference.transport: http",
14562 ),
14563 (
14564 "http",
14565 "control:\n settings: [inference.max_tokens, inference.max_tokens]\n",
14566 "control.settings",
14567 "'inference.max_tokens' is listed more than once",
14568 ),
14569 (
14570 "http",
14571 "control:\n secrets: [CARD_TOKEN, CARD_TOKEN]\n",
14572 "control.secrets",
14573 "'CARD_TOKEN' is listed more than once",
14574 ),
14575 (
14576 "http",
14577 "control:\n secrets: [card-token]\n",
14578 "control.secrets",
14579 "'card-token' is not a credential name",
14580 ),
14581 (
14582 "http",
14583 "control:\n secrets: [OTHER_TOKEN]\n",
14584 "control.secrets",
14585 "'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
14586 ),
14587 (
14588 "http",
14589 "control:\n secrets: [DRIVER_KEY]\n",
14590 "control.secrets",
14591 "'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.local/drv.gateway.api_key)",
14592 ),
14593 (
14594 "http",
14595 "control:\n settings: inference.max_tokens\n",
14596 "control.settings",
14597 "must be a list of names",
14598 ),
14599 (
14600 "http",
14601 "control:\n secrets: [1]\n",
14602 "control.secrets",
14603 "must be a list of names",
14604 ),
14605 ];
14606 for (transport, control, field, message) in cases {
14607 let (got_field, got_message) = refusal(&agent(transport, control));
14608 assert_eq!(&got_field, field, "{control:?}");
14609 assert!(
14610 got_message.starts_with(message),
14611 "{control:?}: {got_message}"
14612 );
14613 let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
14614 .unwrap_err()
14615 .to_string();
14616 assert!(
14617 rendered.contains(&format!("invalid control config for '{field}'")),
14618 "{rendered}"
14619 );
14620 }
14621 }
14622
14623 #[test]
14624 fn control_on_a_capsule_with_no_inference_is_refused() {
14625 let yaml = format!(
14626 "name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n secrets: [CARD_TOKEN]\n"
14627 );
14628 let (field, message) = refusal(&yaml);
14629 assert_eq!(field, "control");
14630 assert!(message.contains("no inference: block"), "{message}");
14631 }
14632
14633 #[test]
14634 fn control_unknown_key_is_reported_once_at_its_block() {
14635 let manifest = RuntimeManifest::from_yaml_str(&agent(
14636 "http",
14637 "control:\n settings: [inference.max_tokens]\n agent_may_call: true\n",
14638 ))
14639 .unwrap();
14640 let keys: Vec<(&str, &str)> = manifest
14641 .unknown_keys
14642 .iter()
14643 .map(|key| (key.key.as_str(), key.block_path.as_str()))
14644 .collect();
14645 assert_eq!(keys, vec![("agent_may_call", "control")]);
14646 assert_eq!(
14647 manifest.control.unwrap().settings,
14648 vec![ControllableSetting::InferenceMaxTokens]
14649 );
14650 }
14651}
14652
14653#[cfg(test)]
14654mod driver_choice_tests {
14655 use super::*;
14656
14657 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";
14658 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";
14659 const ALT_NO_GATEWAY: &str = " - name: local/alt\n version: 0.1.0\n runtime: driver\n";
14660 const TOOL: &str = " - name: local/card-api\n version: 0.1.0\n runtime: tool\n";
14661 const GPT: &str =
14662 " - name: gpt\n model: gpt-5\n driver:\n artifact: local/alt\n";
14663 const HAIKU: &str =
14664 " - name: haiku\n model: haiku\n driver:\n artifact: local/drv\n";
14665 const SWITCHABLE: &str = "control:\n settings: [inference.driver]\n";
14666
14667 fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
14670 let alternates = if alternates.is_empty() {
14671 String::new()
14672 } else {
14673 format!(" alternates:\n{alternates}")
14674 };
14675 format!(
14676 "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}"
14677 )
14678 }
14679
14680 fn refusal(yaml: &str) -> (String, String) {
14681 match RuntimeManifest::from_yaml_str(yaml) {
14682 Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
14683 | Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14684 other => panic!("expected an inference or control refusal, got {other:?}"),
14685 }
14686 }
14687
14688 #[test]
14689 fn driver_choice_alternates_parse_in_manifest_order() {
14690 let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
14691 for manifest in [
14692 RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14693 RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14694 ] {
14695 let inference = manifest.inference.unwrap();
14696 assert_eq!(
14697 inference.alternates,
14698 vec![
14699 InferenceAlternate {
14700 name: "gpt".to_string(),
14701 model: "gpt-5".to_string(),
14702 driver: "local/alt".to_string(),
14703 },
14704 InferenceAlternate {
14705 name: "haiku".to_string(),
14706 model: "haiku".to_string(),
14707 driver: "local/drv".to_string(),
14708 },
14709 ]
14710 );
14711 let control = manifest.control.unwrap();
14712 assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
14713 assert!(control.agent_settings.is_empty());
14714 }
14715 }
14716
14717 #[test]
14718 fn driver_choice_absent_alternates_are_empty() {
14719 let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
14720 assert!(manifest.inference.unwrap().alternates.is_empty());
14721 }
14722
14723 #[test]
14724 fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
14725 let manifest = RuntimeManifest::from_yaml_str(&capsule(
14726 ALT,
14727 GPT,
14728 "control:\n agent_settings: [inference.driver]\n",
14729 ))
14730 .unwrap();
14731 let control = manifest.control.unwrap();
14732 assert!(control.settings.is_empty());
14733 assert_eq!(
14734 control.agent_settings,
14735 vec![ControllableSetting::InferenceDriver]
14736 );
14737 assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
14738 assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
14739 assert!(control.secrets.is_empty());
14740 }
14741
14742 #[test]
14743 fn driver_choice_wire_name_round_trips() {
14744 assert_eq!(
14745 ControllableSetting::from_wire_name("inference.driver"),
14746 Some(ControllableSetting::InferenceDriver)
14747 );
14748 assert_eq!(
14749 ControllableSetting::InferenceDriver.wire_name(),
14750 "inference.driver"
14751 );
14752 assert_eq!(
14753 ControllableSetting::AGENT_SETTABLE,
14754 &[ControllableSetting::InferenceDriver]
14755 );
14756 }
14757
14758 #[test]
14760 fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
14761 RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
14762 .expect("an alternate's driver carries its own gateway");
14763 match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
14764 Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
14765 assert!(message.contains("inference.alternates"), "{message}")
14766 }
14767 other => panic!("expected InvalidArtifact, got {other:?}"),
14768 }
14769 }
14770
14771 #[test]
14773 fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
14774 let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
14775 assert_eq!(
14776 manifest.inference.unwrap().alternates[0].driver,
14777 "local/drv"
14778 );
14779 }
14780
14781 #[test]
14782 fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
14783 let manifest = RuntimeManifest::from_yaml_str(&capsule(
14784 ALT,
14785 GPT,
14786 "control:\n settings: [inference.driver]\n secrets: [ALT_KEY]\n",
14787 ))
14788 .unwrap();
14789 assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
14790 let (field, message) = refusal(&capsule(
14791 ALT,
14792 GPT,
14793 "control:\n settings: [inference.driver]\n secrets: [DRIVER_KEY]\n",
14794 ));
14795 assert_eq!(field, "control.secrets");
14796 assert!(
14797 message.contains("keys the configured inference driver's gateway"),
14798 "{message}"
14799 );
14800 }
14801
14802 #[test]
14803 fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
14804 let alternate = " - name: gpt\n model: gpt-5\n temperature: 1\n driver:\n artifact: local/alt\n";
14805 let manifest =
14806 RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
14807 let keys: Vec<(&str, &str)> = manifest
14808 .unknown_keys
14809 .iter()
14810 .map(|key| (key.key.as_str(), key.block_path.as_str()))
14811 .collect();
14812 assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
14813 }
14814
14815 #[test]
14818 fn driver_choice_manifest_refusals_name_the_field() {
14819 let alternate = |name: &str, model: &str, driver: &str| {
14820 format!(" - name: {name}\n model: {model}\n driver:\n artifact: {driver}\n")
14821 };
14822 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";
14823 let cases: Vec<(String, &str, &str)> = vec![
14824 (
14825 format!("{process} alternates:\n{GPT}"),
14826 "inference.alternates",
14827 "is not valid with transport: process",
14828 ),
14829 (
14830 format!("{process}control:\n settings: [inference.driver]\n"),
14831 "control.settings",
14832 "'inference.driver' is controllable only under inference.transport: http",
14833 ),
14834 (
14835 capsule(ALT, &alternate("primary", "gpt-5", "local/alt"), SWITCHABLE),
14836 "inference.alternates[0].name",
14837 "'primary' is reserved",
14838 ),
14839 (
14840 capsule(
14841 ALT,
14842 &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt", "gpt-4", "local/alt")),
14843 SWITCHABLE,
14844 ),
14845 "inference.alternates[1].name",
14846 "'gpt' names more than one alternate",
14847 ),
14848 (
14849 capsule(ALT, &alternate("GPT", "gpt-5", "local/alt"), SWITCHABLE),
14850 "inference.alternates[0].name",
14851 "'GPT' is not a driver choice name",
14852 ),
14853 (
14854 capsule(ALT, &alternate("5gpt", "gpt-5", "local/alt"), SWITCHABLE),
14855 "inference.alternates[0].name",
14856 "'5gpt' is not a driver choice name",
14857 ),
14858 (
14859 capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "local/alt"), SWITCHABLE),
14860 "inference.alternates[0].name",
14861 "is not a driver choice name",
14862 ),
14863 (
14864 capsule(ALT, " - name: gpt\n model: \"\"\n driver:\n artifact: local/alt\n", SWITCHABLE),
14865 "inference.alternates[0].model",
14866 "missing required field",
14867 ),
14868 (
14869 capsule(ALT, " - name: gpt\n model: gpt-5\n", SWITCHABLE),
14870 "inference.alternates[0].driver.artifact",
14871 "missing required field",
14872 ),
14873 (
14874 capsule("", &alternate("gpt", "gpt-5", "local/nowhere"), SWITCHABLE),
14875 "inference.alternates[0].driver.artifact",
14876 "artifact 'local/nowhere' is not declared in artifacts:",
14877 ),
14878 (
14879 capsule(TOOL, &alternate("gpt", "gpt-5", "local/card-api"), SWITCHABLE),
14880 "inference.alternates[0].driver.artifact",
14881 "artifact 'local/card-api' is declared with runtime: tool",
14882 ),
14883 (
14884 capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "local/alt"), SWITCHABLE),
14885 "inference.alternates[0].driver.artifact",
14886 "artifact 'local/alt' declares no gateway.endpoint",
14887 ),
14888 (
14889 capsule(
14890 ALT,
14891 " - name: gpt\n model: gpt-5\n driver:\n artifact: local/alt\n config:\n thinking: enabled\n",
14892 SWITCHABLE,
14893 ),
14894 "inference.alternates[0].driver.config",
14895 "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",
14896 ),
14897 (
14898 capsule("", &alternate("same", "m", "local/drv"), SWITCHABLE),
14899 "inference.alternates[0]",
14900 "'same' is driver 'local/drv' with model 'm', which is the primary choice",
14901 ),
14902 (
14903 capsule(
14904 ALT,
14905 &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt-too", "gpt-5", "local/alt")),
14906 SWITCHABLE,
14907 ),
14908 "inference.alternates[1]",
14909 "'gpt-too' is driver 'local/alt' with model 'gpt-5', which alternate 'gpt' already is",
14910 ),
14911 (
14912 capsule(ALT, GPT, ""),
14913 "inference.alternates",
14914 "declares alternates that nothing could ever select",
14915 ),
14916 (
14917 capsule(ALT, GPT, "control:\n settings: [inference.max_tokens]\n"),
14918 "inference.alternates",
14919 "declares alternates that nothing could ever select",
14920 ),
14921 (
14922 capsule("", "", SWITCHABLE),
14923 "control.settings",
14924 "'inference.driver' selects among inference.alternates, and this capsule declares none",
14925 ),
14926 (
14927 capsule("", "", "control:\n agent_settings: [inference.driver]\n"),
14928 "control.agent_settings",
14929 "'inference.driver' selects among inference.alternates, and this capsule declares none",
14930 ),
14931 (
14932 capsule(ALT, GPT, "control:\n agent_settings: [inference.max_tokens]\n"),
14933 "control.agent_settings",
14934 "'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
14935 ),
14936 (
14937 capsule(
14938 ALT,
14939 GPT,
14940 "control:\n agent_settings: [inference.driver, inference.driver]\n",
14941 ),
14942 "control.agent_settings",
14943 "'inference.driver' is listed more than once",
14944 ),
14945 (
14946 capsule(ALT, GPT, "control:\n agent_settings: inference.driver\n"),
14947 "control.agent_settings",
14948 "must be a list of names",
14949 ),
14950 ];
14951 for (yaml, field, message) in &cases {
14952 let (got_field, got_message) = refusal(yaml);
14953 assert_eq!(&got_field, field, "{yaml}");
14954 assert!(got_message.contains(message), "{yaml}\n{got_message}");
14955 let rendered = RuntimeManifest::from_yaml_str(yaml)
14956 .unwrap_err()
14957 .to_string();
14958 assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
14959 }
14960 }
14961}
14962
14963#[cfg(test)]
14964mod network_authentication_tests {
14965 use super::*;
14966
14967 fn network_authentication_of(
14968 block: &str,
14969 ) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
14970 RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
14971 .map(|manifest| manifest.network.and_then(|network| network.authentication))
14972 }
14973
14974 fn network_authentication_refusal(block: &str) -> (String, String) {
14975 match network_authentication_of(block) {
14976 Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
14977 other => panic!("expected InvalidNetworkConfig, got {other:?}"),
14978 }
14979 }
14980
14981 #[test]
14982 fn network_authentication_with_credentials_parses_sorted_by_name() {
14983 let auth = network_authentication_of(
14984 "network:\n authentication:\n scheme: bearer\n credentials:\n \
14985 watcher:\n scopes: [GetTask, murmur.WatchSession]\n \
14986 reader:\n scopes: [resources/files]\n",
14987 )
14988 .unwrap()
14989 .expect("declared");
14990 assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
14991 assert_eq!(
14992 auth.credentials,
14993 vec![
14994 DoorCredential {
14995 name: "reader".to_string(),
14996 scopes: vec!["resources/files".to_string()],
14997 },
14998 DoorCredential {
14999 name: "watcher".to_string(),
15000 scopes: vec!["GetTask".to_string(), "murmur.WatchSession".to_string()],
15002 },
15003 ]
15004 );
15005 }
15006
15007 #[test]
15008 fn network_authentication_scheme_alone_parses() {
15009 let auth = network_authentication_of("network:\n authentication:\n scheme: bearer\n")
15010 .unwrap()
15011 .expect("declared");
15012 assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
15013 assert!(auth.credentials.is_empty());
15014 }
15015
15016 #[test]
15017 fn network_authentication_absent_is_a_public_door() {
15018 assert_eq!(network_authentication_of("").unwrap(), None);
15019 assert_eq!(
15020 network_authentication_of("network:\n internal_port: 8080\n").unwrap(),
15021 None
15022 );
15023 let manifest = RuntimeManifest::from_yaml_str(
15024 "name: cap\nversion: 0.1.0\nnetwork:\n internal_port: 8080\n",
15025 )
15026 .unwrap();
15027 assert_eq!(
15028 manifest.network,
15029 Some(NetworkConfig {
15030 internal_port: Some(8080),
15031 authentication: None,
15032 })
15033 );
15034 }
15035
15036 #[test]
15037 fn network_authentication_without_a_scheme_is_refused() {
15038 let (field, message) = network_authentication_refusal(
15039 "network:\n authentication:\n credentials:\n w:\n scopes: [GetTask]\n",
15040 );
15041 assert_eq!(field, "network.authentication.scheme");
15042 assert!(message.contains("required"), "{message}");
15043 }
15044
15045 #[test]
15046 fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
15047 for scheme in ["mtls", "Bearer", "basic"] {
15048 let (field, message) = network_authentication_refusal(&format!(
15049 "network:\n authentication:\n scheme: {scheme}\n"
15050 ));
15051 assert_eq!(field, "network.authentication.scheme");
15052 assert!(message.contains("bearer"), "{message}");
15053 assert!(message.contains(scheme), "{message}");
15054 }
15055 }
15056
15057 #[test]
15058 fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
15059 let long = format!("a{}", "b".repeat(32));
15060 for name in [
15061 "operator",
15062 "Watcher",
15063 "1st",
15064 "_x",
15065 "-x",
15066 "wat.cher",
15067 long.as_str(),
15068 ] {
15069 let (field, message) = network_authentication_refusal(&format!(
15070 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15071 '{name}':\n scopes: [GetTask]\n"
15072 ));
15073 assert_eq!(field, "network.authentication.credentials", "{name}");
15074 assert!(message.contains(name), "{message}");
15075 }
15076 let longest = format!("a{}", "b".repeat(31));
15077 for name in ["w", "a-b_c9", longest.as_str()] {
15078 network_authentication_of(&format!(
15079 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15080 {name}:\n scopes: [GetTask]\n"
15081 ))
15082 .unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
15083 }
15084 }
15085
15086 #[test]
15087 fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
15088 let credential = |body: &str| {
15089 format!(
15090 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15091 watcher:\n{body}"
15092 )
15093 };
15094 let field = "network.authentication.credentials.watcher.scopes";
15095
15096 let (got, message) = network_authentication_refusal(&credential(" {}\n"));
15097 assert_eq!(got, field);
15098 assert!(message.contains("required"), "{message}");
15099
15100 let (got, message) = network_authentication_refusal(&credential(" scopes: []\n"));
15101 assert_eq!(got, field);
15102 assert!(message.contains("no scope"), "{message}");
15103
15104 let (got, message) =
15105 network_authentication_refusal(&credential(" scopes: [tasks/list]\n"));
15106 assert_eq!(got, field);
15107 assert!(message.contains("tasks/list"), "{message}");
15108 assert!(message.contains("resources/files"), "{message}");
15109
15110 let (got, message) =
15111 network_authentication_refusal(&credential(" scopes: [GetExtendedAgentCard]\n"));
15112 assert_eq!(got, field);
15113 assert!(message.contains("GetExtendedAgentCard"), "{message}");
15114
15115 let (got, message) = network_authentication_refusal(&credential(
15116 " scopes: [GetTask, murmur.WatchSession, GetTask]\n",
15117 ));
15118 assert_eq!(got, field);
15119 assert!(message.contains("more than once"), "{message}");
15120 }
15121
15122 #[test]
15123 fn network_authentication_refuses_a_retired_scope_naming_its_replacement() {
15124 let field = "network.authentication.credentials.watcher.scopes";
15125 for (retired, replacement) in [
15126 ("message/send", "SendMessage"),
15127 ("message/stream", "SendStreamingMessage"),
15128 ("tasks/get", "GetTask"),
15129 ("tasks/cancel", "CancelTask"),
15130 ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
15131 ("stream/watch", "murmur.WatchSession"),
15132 ("session/stop", "murmur.StopSession"),
15133 ] {
15134 let error = network_authentication_of(&format!(
15135 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15136 watcher:\n scopes: [murmur.WatchSession, {retired}]\n"
15137 ))
15138 .unwrap_err();
15139 let rendered = error.to_string();
15140 match error {
15141 RuntimeManifestError::RetiredDoorScope {
15142 field: got_field,
15143 scope,
15144 replacement: got_replacement,
15145 } => {
15146 assert_eq!(got_field, field);
15147 assert_eq!(scope, retired);
15148 assert_eq!(got_replacement, replacement);
15149 }
15150 other => panic!("expected RetiredDoorScope for {retired}, got {other:?}"),
15151 }
15152 assert!(
15153 rendered.contains(&format!(
15154 "'{retired}' is a retired name of a door method, and the door answers only \
15155 the names its card lists"
15156 )),
15157 "{rendered}"
15158 );
15159 assert!(rendered.contains(field), "{rendered}");
15160 }
15161 }
15162
15163 #[test]
15164 fn network_authentication_accepts_the_v1_scopes() {
15165 let auth = network_authentication_of(
15166 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15167 watcher:\n scopes: [GetTask, murmur.WatchSession, murmur.StopSession]\n",
15168 )
15169 .unwrap()
15170 .unwrap();
15171 assert_eq!(
15172 auth.credentials[0].scopes,
15173 ["GetTask", "murmur.WatchSession", "murmur.StopSession"]
15174 );
15175 }
15176
15177 #[test]
15180 fn door_scope_list_tasks_and_subscribe_to_task_load_on_a_credential() {
15181 let auth = network_authentication_of(
15182 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15183 alice:\n scopes: [SendMessage, ListTasks, SubscribeToTask]\n",
15184 )
15185 .unwrap()
15186 .unwrap();
15187 assert_eq!(
15188 auth.credentials[0].scopes,
15189 ["SendMessage", "ListTasks", "SubscribeToTask"]
15190 );
15191 assert_eq!(
15192 DOOR_SCOPES,
15193 [
15194 "SendMessage",
15195 "SendStreamingMessage",
15196 "murmur.WatchSession",
15197 "GetTask",
15198 "ListTasks",
15199 "CancelTask",
15200 "SubscribeToTask",
15201 "murmur.StopSession",
15202 "resources/files",
15203 ]
15204 );
15205 }
15206
15207 #[test]
15208 fn network_authentication_accepts_every_door_scope() {
15209 let auth = network_authentication_of(&format!(
15210 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15211 everything:\n scopes: [{}]\n",
15212 DOOR_SCOPES.join(", ")
15213 ))
15214 .unwrap()
15215 .unwrap();
15216 assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
15217 assert_eq!(OPERATOR_CREDENTIAL, "operator");
15218 }
15219
15220 #[test]
15221 fn network_authentication_is_refused_beside_spawn_allow() {
15222 let (field, message) = network_authentication_refusal(
15223 "network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
15224 allow: [local/worker]\n",
15225 );
15226 assert_eq!(field, "network.authentication");
15227 assert!(message.contains("capabilities.spawn.allow"), "{message}");
15228 assert!(message.contains("token"), "{message}");
15229 let error = RuntimeManifest::from_yaml_str(
15230 "name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n\
15231 capabilities:\n spawn:\n allow: [local/worker]\n",
15232 )
15233 .unwrap_err()
15234 .to_string();
15235 assert!(error.contains("network.authentication"), "{error}");
15236 assert!(error.contains("capabilities.spawn.allow"), "{error}");
15237
15238 network_authentication_of(
15240 "network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
15241 allow: []\n",
15242 )
15243 .unwrap();
15244 }
15245
15246 #[test]
15247 fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
15248 let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
15249 "name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n \
15250 schemes: x\n credentials:\n watcher:\n scopes: [GetTask]\n \
15251 scope: [GetTask]\n",
15252 )
15253 .unwrap()
15254 .unknown_keys
15255 .into_iter()
15256 .map(|key| (key.key, key.block_path, key.nearest_known))
15257 .collect();
15258 assert_eq!(
15259 keys,
15260 vec![
15261 (
15262 "schemes".to_string(),
15263 "network.authentication".to_string(),
15264 Some("scheme".to_string())
15265 ),
15266 (
15267 "scope".to_string(),
15268 "network.authentication.credentials.watcher".to_string(),
15269 Some("scopes".to_string())
15270 ),
15271 ]
15272 );
15273 }
15274}
15275
15276#[cfg(test)]
15277mod registry_block_tests {
15278 use super::*;
15279
15280 const REGISTRY_BLOCK: &str = "registry:\n default: local\n";
15281
15282 #[test]
15283 fn registry_block_is_refused_by_both_entry_points() {
15284 let input = format!("name: cap\nversion: 0.0.1\nartifacts: []\n{REGISTRY_BLOCK}");
15285 for result in [
15286 RuntimeManifest::from_yaml_str(&input),
15287 RuntimeManifest::from_yaml_str_without_secrets(&input),
15288 ] {
15289 let err = result.unwrap_err();
15290 assert!(
15291 matches!(err, RuntimeManifestError::RegistryBlock),
15292 "{err:?}"
15293 );
15294 assert_eq!(err.to_string(), REGISTRY_BLOCK_MESSAGE);
15295 }
15296 }
15297
15298 #[test]
15299 fn registry_block_is_refused_before_any_other_field() {
15300 let input = format!(
15303 "version: 0.0.1\nlifecycle:\n task_acceptance: queue\n queue_depth: 0\n\
15304 {REGISTRY_BLOCK}"
15305 );
15306 let err = RuntimeManifest::from_yaml_str(&input).unwrap_err();
15307 assert!(
15308 matches!(err, RuntimeManifestError::RegistryBlock),
15309 "{err:?}"
15310 );
15311 }
15312
15313 #[test]
15314 fn registry_block_refusal_applies_whatever_the_block_holds() {
15315 for block in ["registry: local\n", "registry: {}\n", "registry: []\n"] {
15316 let input = format!("name: cap\nversion: 0.0.1\n{block}");
15317 assert!(
15318 matches!(
15319 RuntimeManifest::from_yaml_str(&input),
15320 Err(RuntimeManifestError::RegistryBlock)
15321 ),
15322 "{block}"
15323 );
15324 }
15325 }
15326
15327 #[test]
15328 fn a_manifest_without_a_registry_block_parses_unchanged() {
15329 let manifest =
15330 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
15331 assert_eq!(manifest.name, "cap");
15332 assert_eq!(manifest.version, "0.0.1");
15333 }
15334}
15335
15336#[cfg(test)]
15337mod scoped_name_tests {
15338 use super::*;
15339
15340 fn name_refusal(yaml: &str) -> (String, String, Option<String>) {
15341 match RuntimeManifest::from_yaml_str(yaml) {
15342 Err(RuntimeManifestError::InvalidArtifactName {
15343 field,
15344 message,
15345 hint,
15346 }) => (field, message, hint),
15347 other => panic!("expected InvalidArtifactName, got {other:?}"),
15348 }
15349 }
15350
15351 #[test]
15352 fn scoped_name_a_bare_artifact_entry_is_refused_with_the_scoped_forms() {
15353 let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n - name: tool-editor\n \
15354 version: 0.1.0\n runtime: tool\n";
15355 let (field, message, hint) = name_refusal(yaml);
15356 assert_eq!(field, "artifacts[0].name");
15357 assert_eq!(
15358 message,
15359 "'tool-editor' is not a full artifact name: an artifact is named <scope>/<name>"
15360 );
15361 assert_eq!(
15362 hint.as_deref(),
15363 Some(
15364 "name it with its scope: 'murmur/tool-editor' for a Murmur default artifact, \
15365 '<owner>/tool-editor' for one published from github.com/<owner>, or \
15366 'local/tool-editor' for one installed from a .mur.zip file"
15367 )
15368 );
15369 let rendered = RuntimeManifest::from_yaml_str(yaml)
15370 .unwrap_err()
15371 .to_string();
15372 assert!(
15373 rendered.starts_with("murmur.yaml: invalid artifact name in 'artifacts[0].name': "),
15374 "{rendered}"
15375 );
15376 }
15377
15378 #[test]
15379 fn scoped_name_a_source_entry_keeps_its_bare_name() {
15380 let bare = "name: cap\nversion: 0.0.1\nartifacts:\n - name: style\n \
15381 source: ./skills/style\n runtime: skill\n";
15382 RuntimeManifest::from_yaml_str(bare).expect("a source: entry is named by its bare name");
15383
15384 let (field, message, hint) = name_refusal(&bare.replace("name: style", "name: ana/style"));
15385 assert_eq!(field, "artifacts[0].name");
15386 assert!(
15387 message
15388 .starts_with("'ana/style' declares 'source:', so it is named by its bare name; "),
15389 "{message}"
15390 );
15391 assert_eq!(hint.as_deref(), Some("drop the scope: name it 'style'"));
15392 }
15393
15394 #[test]
15395 fn scoped_name_spawn_allow_refuses_a_bare_entry_and_a_shared_bare_part() {
15396 let with_allow = |allow: &str| {
15397 format!("name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n allow: [{allow}]\n")
15398 };
15399 let manifest = RuntimeManifest::from_yaml_str(&with_allow("local/worker, ana/reviewer"))
15400 .expect("full names in two scopes with distinct bare parts parse");
15401 assert_eq!(
15402 manifest.capabilities.unwrap().spawn.unwrap().allow,
15403 vec!["local/worker".to_string(), "ana/reviewer".to_string()]
15404 );
15405
15406 let (field, message, hint) = name_refusal(&with_allow("worker"));
15407 assert_eq!(field, "capabilities.spawn.allow[0]");
15408 assert!(
15409 message.contains("'worker' is not a full artifact name"),
15410 "{message}"
15411 );
15412 assert!(hint.unwrap().contains("local/worker"));
15413
15414 let (field, message, hint) = name_refusal(&with_allow("local/worker, ana/worker"));
15415 assert_eq!(field, "capabilities.spawn.allow[1]");
15416 assert_eq!(
15417 message,
15418 "'local/worker' and 'ana/worker' share the name 'worker', which is how delegate-task \
15419 offers a capsule to the model"
15420 );
15421 assert_eq!(
15422 hint.as_deref(),
15423 Some("keep one of 'local/worker' and 'ana/worker' in capabilities.spawn.allow")
15424 );
15425 }
15426
15427 #[test]
15428 fn spawn_workdir_snapshot_lowers_beside_allow_and_is_empty_when_absent() {
15429 let yaml = "name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n \
15430 allow: [local/reviewer, local/worker]\n \
15431 workdir_snapshot: [local/reviewer]\n";
15432 let spawn = RuntimeManifest::from_yaml_str(yaml)
15433 .expect("a snapshot entry that is also in allow parses")
15434 .capabilities
15435 .unwrap()
15436 .spawn
15437 .unwrap();
15438 assert_eq!(
15439 spawn,
15440 SpawnCapabilities {
15441 allow: vec!["local/reviewer".to_string(), "local/worker".to_string()],
15442 workdir_snapshot: vec!["local/reviewer".to_string()],
15443 }
15444 );
15445
15446 let absent = "name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n \
15447 allow: [local/reviewer]\n";
15448 let spawn = RuntimeManifest::from_yaml_str(absent)
15449 .unwrap()
15450 .capabilities
15451 .unwrap()
15452 .spawn
15453 .unwrap();
15454 assert!(spawn.workdir_snapshot.is_empty());
15455 }
15456
15457 #[test]
15458 fn spawn_workdir_snapshot_refuses_an_entry_outside_allow_a_bare_name_and_a_duplicate() {
15459 let with = |snapshot: &str| {
15460 format!(
15461 "name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n \
15462 allow: [local/reviewer, local/worker]\n workdir_snapshot: [{snapshot}]\n"
15463 )
15464 };
15465
15466 let (field, message, hint) = name_refusal(&with("local/reviewer, local/other"));
15467 assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
15468 assert_eq!(
15469 message,
15470 "'local/other' is not in capabilities.spawn.allow, so this capsule cannot delegate \
15471 to it"
15472 );
15473 assert!(
15474 hint.as_deref()
15475 .is_some_and(|hint| hint.contains("capabilities.spawn.allow")),
15476 "{hint:?}"
15477 );
15478
15479 let (field, message, _) = name_refusal(&with("reviewer"));
15480 assert_eq!(field, "capabilities.spawn.workdir_snapshot[0]");
15481 assert!(
15482 message.contains("'reviewer' is not a full artifact name"),
15483 "{message}"
15484 );
15485
15486 let (field, message, hint) = name_refusal(&with("local/reviewer, local/reviewer"));
15487 assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
15488 assert_eq!(message, "'local/reviewer' is listed more than once");
15489 assert_eq!(
15490 hint.as_deref(),
15491 Some("keep one 'local/reviewer' in capabilities.spawn.workdir_snapshot")
15492 );
15493 }
15494
15495 #[test]
15496 fn scoped_name_a_reference_by_bare_part_names_the_declared_entry() {
15497 let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n \
15498 version: 0.1.0\n runtime: driver\n gateway:\n \
15499 endpoint: https://api.anthropic.com\n api_key: k\ninference:\n \
15500 transport: http\n model: m\n driver:\n artifact: driver-anthropic\n";
15501 let message = RuntimeManifest::from_yaml_str(yaml)
15502 .unwrap_err()
15503 .to_string();
15504 assert!(
15505 message.contains(
15506 "artifact 'driver-anthropic' is not declared in artifacts: (the declared \
15507 'murmur/driver-anthropic' has that name; refer to it as \
15508 'murmur/driver-anthropic')"
15509 ),
15510 "{message}"
15511 );
15512 }
15513}
15514
15515#[cfg(test)]
15516mod inference_retry_tests {
15517 use super::*;
15518
15519 const HTTP: &str = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/drv\n \
15520 version: 0.1.0\n runtime: driver\n gateway:\n endpoint: \
15521 http://127.0.0.1:8080\n api_key: sk-test\ninference:\n transport: \
15522 http\n model: m\n driver:\n artifact: local/drv\n";
15523 const PROCESS: &str = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/drv\n \
15524 version: 0.1.0\n runtime: driver\ninference:\n transport: \
15525 process\n driver:\n artifact: local/drv\n";
15526
15527 fn retry(yaml: &str) -> InferenceRetry {
15528 RuntimeManifest::from_yaml_str(yaml)
15529 .unwrap()
15530 .inference
15531 .unwrap()
15532 .retry
15533 }
15534
15535 fn refusal(yaml: &str) -> (String, String) {
15536 match RuntimeManifest::from_yaml_str(yaml) {
15537 Err(RuntimeManifestError::InvalidInferenceConfig { field, message }) => {
15538 (field, message)
15539 }
15540 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
15541 }
15542 }
15543
15544 #[test]
15545 fn inference_retry_defaults_when_absent_and_per_key_when_partial() {
15546 assert_eq!(retry(HTTP), InferenceRetry::default());
15547 assert_eq!(
15548 InferenceRetry::default(),
15549 InferenceRetry {
15550 max_retries: 6,
15551 initial_delay_ms: 1_000,
15552 max_delay_ms: 60_000,
15553 }
15554 );
15555 assert_eq!(
15556 retry(&format!("{HTTP} retry:\n max_retries: 2\n")),
15557 InferenceRetry {
15558 max_retries: 2,
15559 ..InferenceRetry::default()
15560 }
15561 );
15562 assert_eq!(
15563 retry(&format!(
15564 "{HTTP} retry:\n initial_delay_ms: 10\n max_delay_ms: 2000\n"
15565 )),
15566 InferenceRetry {
15567 max_retries: 6,
15568 initial_delay_ms: 10,
15569 max_delay_ms: 2000,
15570 }
15571 );
15572 assert_eq!(
15573 retry(&format!(
15574 "{HTTP} retry:\n max_retries: 0\n initial_delay_ms: 60000\n \
15575 max_delay_ms: 3600000\n"
15576 )),
15577 InferenceRetry {
15578 max_retries: 0,
15579 initial_delay_ms: 60_000,
15580 max_delay_ms: 3_600_000,
15581 }
15582 );
15583 assert_eq!(retry(PROCESS), InferenceRetry::default());
15584 }
15585
15586 #[test]
15587 fn inference_retry_bounds_are_refused_naming_the_field() {
15588 for (block, field) in [
15589 ("max_retries: 21", "inference.retry.max_retries"),
15590 ("initial_delay_ms: 0", "inference.retry.initial_delay_ms"),
15591 (
15592 "initial_delay_ms: 60001",
15593 "inference.retry.initial_delay_ms",
15594 ),
15595 ("max_delay_ms: 0", "inference.retry.max_delay_ms"),
15596 ("max_delay_ms: 3600001", "inference.retry.max_delay_ms"),
15597 (
15598 "initial_delay_ms: 5000\n max_delay_ms: 1000",
15599 "inference.retry.max_delay_ms",
15600 ),
15601 ] {
15602 let (got, message) = refusal(&format!("{HTTP} retry:\n {block}\n"));
15603 assert_eq!(got, field, "{block}: {message}");
15604 }
15605 let (_, message) = refusal(&format!(
15606 "{HTTP} retry:\n initial_delay_ms: 5000\n max_delay_ms: 1000\n"
15607 ));
15608 assert!(message.contains("initial_delay_ms"), "{message}");
15609 }
15610
15611 #[test]
15612 fn inference_retry_is_refused_under_transport_process() {
15613 let (field, message) = refusal(&format!("{PROCESS} retry:\n max_retries: 2\n"));
15614 assert_eq!(field, "inference.retry");
15615 assert_eq!(
15616 message,
15617 "is not valid with transport: process; the harness retries its own provider calls"
15618 );
15619 }
15620
15621 #[test]
15622 fn inference_retry_unknown_key_is_reported_at_its_block() {
15623 let manifest = RuntimeManifest::from_yaml_str(&format!(
15624 "{HTTP} retry:\n max_retries: 2\n jitter: full\n"
15625 ))
15626 .unwrap();
15627 let keys: Vec<(&str, &str)> = manifest
15628 .unknown_keys
15629 .iter()
15630 .map(|key| (key.key.as_str(), key.block_path.as_str()))
15631 .collect();
15632 assert_eq!(keys, vec![("jitter", "inference.retry")]);
15633 }
15634}
15635
15636#[cfg(test)]
15637mod host_paths_tests {
15638 use super::*;
15639
15640 const ENTRY: &str = " - path: ~/.cargo\n access: read-write\n";
15641
15642 fn refused_field(input: &str) -> String {
15644 let fields: Vec<String> = [
15645 RuntimeManifest::from_yaml_str(input),
15646 RuntimeManifest::from_yaml_str_without_secrets(input),
15647 ]
15648 .into_iter()
15649 .map(|result| match result {
15650 Err(RuntimeManifestError::HostPathsDeclared { field }) => field,
15651 other => panic!("expected HostPathsDeclared, got {other:?}"),
15652 })
15653 .collect();
15654 assert_eq!(
15655 fields[0], fields[1],
15656 "both entry points refuse the same field"
15657 );
15658 fields[0].clone()
15659 }
15660
15661 #[test]
15662 fn host_paths_is_refused_wherever_a_manifest_writes_it() {
15663 let top = format!("name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths:\n{ENTRY}");
15664 assert_eq!(refused_field(&top), "host_paths");
15665
15666 let capabilities = format!(
15667 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n host_paths:\n {}",
15668 ENTRY.replace('\n', "\n ").trim_end()
15669 );
15670 assert_eq!(refused_field(&capabilities), "capabilities.host_paths");
15671
15672 let filesystem = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n \
15673 filesystem:\n host_paths: []\n";
15674 assert_eq!(
15675 refused_field(filesystem),
15676 "capabilities.filesystem.host_paths"
15677 );
15678
15679 let artifact = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/tool-a\n \
15680 version: 0.1.0\n runtime: tool\n - name: murmur/tool-b\n \
15681 version: 0.1.0\n runtime: tool\n capabilities:\n \
15682 host_paths: []\n";
15683 assert_eq!(
15684 refused_field(artifact),
15685 "artifacts[1].capabilities.host_paths"
15686 );
15687 }
15688
15689 #[test]
15690 fn the_refusal_names_the_field_and_is_never_an_unknown_key_warning() {
15691 let input = "name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths: []\n";
15692 let error = RuntimeManifest::from_yaml_str(input).unwrap_err();
15693 assert!(error.to_string().contains("'host_paths'"), "{error}");
15694 assert!(error.to_string().contains("murmur.yaml"), "{error}");
15695 }
15696
15697 #[test]
15698 fn a_manifest_without_host_paths_parses_unchanged() {
15699 let input = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n filesystem:\n \
15700 workdir_exec: false\n";
15701 assert!(RuntimeManifest::from_yaml_str(input).is_ok());
15702 }
15703}