1pub mod schema;
17
18use super::file::{self, Format};
19use super::paths::{self, Binding};
20use super::{ConfigError, usage};
21use serde::{Deserialize, Serialize};
22use serde_json::{Map, Value, json};
23use std::collections::{BTreeMap, HashMap};
24use std::fmt;
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
35pub struct Dur(pub Duration);
36
37impl fmt::Debug for Dur {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 write!(f, "{:?}", self.0)
40 }
41}
42
43impl<'de> Deserialize<'de> for Dur {
44 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
45 #[derive(Deserialize)]
46 #[serde(untagged)]
47 enum Raw {
48 Secs(u64),
49 Text(String),
50 }
51 match Raw::deserialize(d)? {
52 Raw::Secs(s) => Ok(Dur(Duration::from_secs(s))),
53 Raw::Text(t) => super::parse_duration(&t)
54 .map(Dur)
55 .map_err(serde::de::Error::custom),
56 }
57 }
58}
59
60#[derive(Clone, PartialEq, Eq, Deserialize, Default)]
65pub struct Secret(pub String);
66
67impl fmt::Debug for Secret {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 f.write_str("***")
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
75#[serde(untagged)]
76pub enum ToolSelect {
77 Keyword(SelectKeyword),
78 List(Vec<String>),
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum SelectKeyword {
84 All,
85 None,
86}
87
88impl Default for ToolSelect {
89 fn default() -> Self {
90 ToolSelect::Keyword(SelectKeyword::All)
91 }
92}
93
94impl ToolSelect {
95 pub fn allows(&self, name: &str) -> bool {
96 match self {
97 ToolSelect::Keyword(SelectKeyword::All) => true,
98 ToolSelect::Keyword(SelectKeyword::None) => false,
99 ToolSelect::List(l) => l.iter().any(|n| n == name),
100 }
101 }
102}
103
104fn string_or_list<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
105 #[derive(Deserialize)]
106 #[serde(untagged)]
107 enum Raw {
108 List(Vec<String>),
109 One(String),
110 }
111 Ok(match Raw::deserialize(d)? {
112 Raw::List(l) => l,
113 Raw::One(s) => s
114 .split(',')
115 .map(str::trim)
116 .filter(|s| !s.is_empty())
117 .map(str::to_string)
118 .collect(),
119 })
120}
121
122#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
130#[serde(deny_unknown_fields, default)]
131pub struct Settings {
132 pub config_version: Option<String>,
133 #[serde(default)]
138 pub streams: BTreeMap<String, StreamCfg>,
139 #[serde(default)]
145 pub services: BTreeMap<String, Service>,
146 pub vars: BTreeMap<String, Value>,
158 pub agent: Agent,
159 pub intelligence: Intelligence,
160 pub mcp: Mcp,
161 pub tools: Tools,
162 pub store: Store,
163 pub memory: Memory,
164 pub context: Context,
165 pub knowledge: Knowledge,
166 pub search: Search,
167 pub skills: Skills,
168 pub workflows: Vec<Value>,
172 pub limits: Limits,
173 pub lifecycle: Lifecycle,
174 pub subagents: Subagents,
178 pub a2a: A2a,
179 pub interface: Interface,
182 pub webhooks: Webhooks,
185 pub goal: Option<Goal>,
188 pub observability: Observability,
189 pub security: Security,
190 pub identity: Identity,
192}
193
194#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
195#[serde(deny_unknown_fields, default)]
196pub struct Agent {
197 pub name: Option<String>,
198 pub instruction: Option<String>,
201 pub prompt: Option<String>,
207 #[serde(skip)]
212 pub inline_skills: Vec<crate::config::directives::InlineSkill>,
213 pub preflight: Preflight,
214 pub wake_on: Option<Vec<WakeEvent>>,
215 pub on_workflow_finished: OnWorkflowFinished,
216 pub tools: AgentTools,
217 pub max_parallel_turns: Option<u32>,
218 pub conversation_budget: Option<Budget>,
219 pub ask_human_fallback: AskHumanFallback,
225 pub approval: Approval,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
241#[serde(rename_all = "lowercase")]
242pub enum Approval {
243 #[default]
246 #[serde(alias = "await", alias = "human")]
247 Ask,
248 Auto,
251 #[serde(alias = "accept_all", alias = "yes")]
258 Accept,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
263#[serde(rename_all = "lowercase")]
264pub enum AskHumanFallback {
265 #[serde(alias = "pause", alias = "idle")]
267 Wait,
268 #[default]
270 #[serde(alias = "finish", alias = "stop")]
271 Fail,
272 Auto,
275}
276
277impl Agent {
278 pub fn wake_on(&self) -> Vec<WakeEvent> {
282 self.wake_on.clone().unwrap_or_else(|| {
283 vec![
284 WakeEvent::A2aMessage,
285 WakeEvent::HumanReply,
286 WakeEvent::SubagentResult,
287 WakeEvent::WorkflowFailed,
288 ]
289 })
290 }
291 pub fn max_parallel_turns(&self) -> u32 {
292 self.max_parallel_turns.unwrap_or(4)
293 }
294 pub fn instruction_is_uri(&self) -> bool {
296 self.instruction
297 .as_deref()
298 .is_some_and(looks_like_resource_uri)
299 }
300}
301
302pub fn looks_like_resource_uri(s: &str) -> bool {
306 let t = s.trim();
307 if t.contains(char::is_whitespace) {
308 return false;
309 }
310 let Some((scheme, rest)) = t.split_once("://") else {
311 return false;
312 };
313 !scheme.is_empty()
314 && scheme
315 .chars()
316 .next()
317 .is_some_and(|c| c.is_ascii_alphabetic())
318 && scheme
319 .chars()
320 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
321 && !rest.is_empty()
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
325#[serde(rename_all = "lowercase")]
326pub enum Preflight {
327 Never,
328 #[default]
329 Auto,
330 Always,
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
334#[serde(rename_all = "snake_case")]
335pub enum WakeEvent {
336 A2aMessage,
337 HumanReply,
338 SubagentResult,
339 WorkflowFinished,
340 WorkflowFailed,
341 InstructionUpdated,
342 BudgetResumed,
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
346#[serde(rename_all = "lowercase")]
347pub enum OnWorkflowFinished {
348 Ignore,
349 #[default]
350 Note,
351 Think,
352}
353
354#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
355#[serde(deny_unknown_fields, default)]
356pub struct AgentTools {
357 pub internal: ToolSelect,
358 pub mcp: ToolSelect,
359 pub code: ToolSelect,
360}
361
362#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
363#[serde(deny_unknown_fields, default)]
364pub struct Intelligence {
365 #[serde(deserialize_with = "string_or_list")]
366 pub endpoints: Vec<String>,
367 pub model: Option<String>,
368 pub dialect: Option<String>,
372 pub token: Option<Secret>,
373 pub token_file: Option<String>,
374 pub headers: BTreeMap<String, String>,
375 pub auth: Option<Auth>,
379 pub swap_policy: Option<String>,
380 pub structured_output: StructuredOutput,
381 pub budget: Budget,
382 pub pricing: BTreeMap<String, Pricing>,
383 pub timeout: Option<Dur>,
384 pub models: BTreeMap<String, ModelTier>,
397 pub default: Option<String>,
399 pub preflight_model: Option<String>,
403}
404
405#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
407#[serde(deny_unknown_fields, default)]
408pub struct ModelTier {
409 pub model: Option<String>,
411 pub service: Option<String>,
414 pub window: Option<u64>,
418 pub fallback: Option<String>,
421 pub pricing: Option<Pricing>,
422}
423
424impl Intelligence {
425 pub fn wire_model(&self, reference: &str) -> String {
433 match self.models.get(reference).and_then(|t| t.model.clone()) {
434 Some(m) => m,
435 None => reference.to_string(),
436 }
437 }
438
439 pub fn tier(&self, reference: &str) -> Option<&ModelTier> {
441 self.models.get(reference)
442 }
443
444 pub fn default_reference(&self) -> Option<String> {
447 self.default.clone().or_else(|| self.model.clone())
448 }
449
450 pub fn fallback_chain(&self, reference: &str) -> Vec<String> {
455 let mut out = Vec::new();
456 let mut cur = reference.to_string();
457 for _ in 0..8 {
458 let Some(next) = self.models.get(&cur).and_then(|t| t.fallback.clone()) else {
459 break;
460 };
461 if out.contains(&next) || next == reference {
462 break;
463 }
464 out.push(next.clone());
465 cur = next;
466 }
467 out
468 }
469
470 pub fn timeout(&self) -> Duration {
471 self.timeout.map(|d| d.0).unwrap_or(Duration::from_secs(60))
472 }
473 pub fn endpoint_list(&self) -> Option<String> {
475 if self.endpoints.is_empty() {
476 None
477 } else {
478 Some(self.endpoints.join(","))
479 }
480 }
481}
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
484#[serde(rename_all = "snake_case")]
485pub enum StructuredOutput {
486 #[default]
487 Auto,
488 JsonSchema,
489 Tool,
490 Prompt,
491}
492
493#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
494#[serde(deny_unknown_fields, default)]
495pub struct Budget {
496 pub windows: Vec<BudgetWindow>,
497 pub lifetime_tokens: Option<u64>,
498 pub scope: Option<Vec<BudgetScope>>,
499 pub on_exhausted: BudgetTactic,
500 pub slow: Slow,
501 pub degrade: Degrade,
502 pub reserve: Reserve,
503}
504
505#[derive(Debug, Clone, Deserialize, PartialEq)]
506#[serde(deny_unknown_fields)]
507pub struct BudgetWindow {
508 pub per: WindowUnit,
509 #[serde(default)]
510 pub tokens: Option<u64>,
511 #[serde(default)]
512 pub requests: Option<u64>,
513 #[serde(default)]
514 pub reset: Option<String>,
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
518#[serde(rename_all = "lowercase")]
519pub enum WindowUnit {
520 Second,
521 Minute,
522 Hour,
523 Day,
524 Week,
525}
526
527impl WindowUnit {
528 pub fn duration(self) -> Duration {
529 match self {
530 WindowUnit::Second => Duration::from_secs(1),
531 WindowUnit::Minute => Duration::from_secs(60),
532 WindowUnit::Hour => Duration::from_secs(3600),
533 WindowUnit::Day => Duration::from_secs(86_400),
534 WindowUnit::Week => Duration::from_secs(7 * 86_400),
535 }
536 }
537 pub fn is_calendar(self) -> bool {
539 matches!(self, WindowUnit::Day | WindowUnit::Week)
540 }
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
544#[serde(rename_all = "lowercase")]
545pub enum BudgetScope {
546 Instance,
547 Run,
548 Conversation,
549 Principal,
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
553#[serde(rename_all = "lowercase")]
554pub enum BudgetTactic {
555 #[default]
556 Wait,
557 Slow,
558 Degrade,
559 Refuse,
560 Fail,
561}
562
563#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
564#[serde(deny_unknown_fields, default)]
565pub struct Slow {
566 pub factor: Option<f64>,
567}
568
569#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
570#[serde(deny_unknown_fields, default)]
571pub struct Degrade {
572 pub model: Option<String>,
573}
574
575#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
576#[serde(deny_unknown_fields, default)]
577pub struct Reserve {
578 pub estimate: ReserveEstimate,
579 pub fixed: Option<u64>,
580}
581
582#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
583#[serde(rename_all = "lowercase")]
584pub enum ReserveEstimate {
585 #[default]
586 Context,
587 Fixed,
588 None,
589}
590
591#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
592#[serde(deny_unknown_fields, default)]
593pub struct Pricing {
594 pub input_per_1k: Option<f64>,
595 pub output_per_1k: Option<f64>,
596 pub currency: Option<String>,
597}
598
599#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
600#[serde(deny_unknown_fields, default)]
601pub struct Mcp {
602 pub servers: Vec<McpServer>,
603 pub default_timeout: Option<Dur>,
604}
605
606#[derive(Debug, Clone, Deserialize, PartialEq)]
612#[serde(deny_unknown_fields)]
613pub struct Service {
614 #[serde(default)]
615 pub kind: ServiceKind,
616 pub endpoint: String,
617 #[serde(default)]
618 pub headers: BTreeMap<String, String>,
619 #[serde(default)]
622 pub tags: BTreeMap<String, Vec<String>>,
623 #[serde(default)]
626 pub allow: Option<Vec<String>>,
627 #[serde(default)]
628 pub exclude: Vec<String>,
629 #[serde(default)]
630 pub auth: Option<Auth>,
631 #[serde(default)]
634 pub rate: Option<String>,
635 #[serde(default)]
636 pub timeout: Option<Dur>,
637 #[serde(default)]
640 pub methods: Option<Vec<String>>,
641 #[serde(default)]
645 pub breaker: Option<Value>,
646}
647
648#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
653#[serde(rename_all = "lowercase")]
654pub enum ServiceKind {
655 #[default]
656 Mcp,
657 Intelligence,
658 Peer,
659 Http,
660}
661
662impl ServiceKind {
663 pub fn as_str(self) -> &'static str {
664 match self {
665 ServiceKind::Mcp => "mcp",
666 ServiceKind::Intelligence => "intelligence",
667 ServiceKind::Peer => "peer",
668 ServiceKind::Http => "http",
669 }
670 }
671}
672
673pub fn resolve_services(s: &mut Settings) -> Vec<String> {
681 let services = s.services.clone();
682 let mut errs = Vec::new();
683 for srv in &mut s.mcp.servers {
684 let Some(name) = srv.service.clone() else {
685 continue;
686 };
687 let Some(entry) = services.get(&name) else {
688 errs.push(format!(
689 "mcp server '{}' references unknown service '{name}' (services.{name} is not declared)",
690 srv.name
691 ));
692 continue;
693 };
694 for (restated, what) in [
698 (!srv.endpoint.is_empty(), "endpoint"),
699 (srv.auth.is_some(), "auth"),
700 (srv.oauth.is_some(), "oauth"),
701 (!srv.headers.is_empty(), "headers"),
702 ] {
703 if restated {
704 errs.push(format!(
705 "mcp server '{}' references service '{name}' and restates `{what}` — a referencing consumer inherits connection settings from the catalog",
706 srv.name
707 ));
708 }
709 }
710 srv.endpoint = entry.endpoint.clone();
711 srv.auth = entry.auth.clone();
712 srv.headers = entry.headers.clone();
713 if srv.timeout.is_none() {
714 srv.timeout = entry.timeout;
715 }
716 match (&entry.allow, &mut srv.allow) {
720 (Some(ceil), Some(mine)) => {
721 for p in mine.iter() {
722 if !ceil.iter().any(|c| pattern_subsumes(p, c)) {
723 errs.push(format!(
724 "mcp server '{}': allow pattern '{p}' widens the ceiling of service '{name}' (catalog allow: {ceil:?})",
725 srv.name
726 ));
727 }
728 }
729 }
730 (Some(ceil), mine @ None) => *mine = Some(ceil.clone()),
731 _ => {}
732 }
733 for e in &entry.exclude {
734 if !srv.exclude.contains(e) {
735 srv.exclude.push(e.clone());
736 }
737 }
738 union_tags(&mut srv.tags, &entry.tags);
739 srv.service_rate = entry.rate.clone();
740 }
741 for srv in &mut s.mcp.servers {
748 if srv.endpoint.is_empty() {
749 continue;
750 }
751 if let Some((name, entry)) = service_match(&services, ServiceKind::Mcp, &srv.endpoint) {
752 union_tags(&mut srv.tags, &entry.tags);
753 if srv.service.is_none() {
754 srv.service = Some(name.clone());
755 srv.service_rate = entry.rate.clone();
756 }
757 }
758 }
759 for peer in &mut s.a2a.peers {
762 let Some(name) = peer.service.clone() else {
763 continue;
764 };
765 let entry = match services.get(&name) {
766 Some(e) if e.kind == ServiceKind::Peer => e,
767 Some(e) => {
768 errs.push(format!(
769 "a2a peer '{}' references service '{name}', which is `kind: {}` (a peer reference needs `kind: peer`)",
770 peer.name,
771 e.kind.as_str()
772 ));
773 continue;
774 }
775 None => {
776 errs.push(format!(
777 "a2a peer '{}' references unknown service '{name}' (services.{name} is not declared)",
778 peer.name
779 ));
780 continue;
781 }
782 };
783 for (restated, what) in [
784 (!peer.endpoint.is_empty(), "endpoint"),
785 (peer.auth.is_some(), "auth"),
786 (!peer.headers.is_empty(), "headers"),
787 ] {
788 if restated {
789 errs.push(format!(
790 "a2a peer '{}' references service '{name}' and restates `{what}` — a referencing consumer inherits connection settings from the catalog",
791 peer.name
792 ));
793 }
794 }
795 peer.endpoint = entry.endpoint.clone();
796 peer.auth = entry.auth.clone();
797 peer.headers = entry.headers.clone();
798 }
799 errs
800}
801
802fn pattern_subsumes(p: &str, ceiling: &str) -> bool {
806 match ceiling.strip_suffix('*') {
807 Some(prefix) => p.strip_suffix('*').unwrap_or(p).starts_with(prefix),
808 None => p == ceiling,
809 }
810}
811
812fn union_tags(into: &mut BTreeMap<String, Vec<String>>, from: &BTreeMap<String, Vec<String>>) {
814 for (k, list) in from {
815 let slot = into.entry(k.clone()).or_default();
816 for t in list {
817 if !slot.contains(t) {
818 slot.push(t.clone());
819 }
820 }
821 }
822}
823
824pub fn service_match<'a>(
830 services: &'a BTreeMap<String, Service>,
831 kind: ServiceKind,
832 url: &str,
833) -> Option<(&'a String, &'a Service)> {
834 let (scheme, authority, path) = split_url(url)?;
835 services.iter().find(|(_, e)| {
836 if e.kind != kind {
837 return false;
838 }
839 let Some((es, ea, ep)) = split_url(&e.endpoint) else {
840 return false;
841 };
842 scheme == es
843 && authority.eq_ignore_ascii_case(&ea)
844 && (ep.is_empty()
845 || ep == "/"
846 || path == ep
847 || (path.starts_with(&ep)
848 && (ep.ends_with('/') || path.as_bytes().get(ep.len()) == Some(&b'/'))))
849 })
850}
851
852fn split_url(url: &str) -> Option<(String, String, String)> {
855 if let Some(rest) = url
856 .strip_prefix("unix://")
857 .or_else(|| url.strip_prefix("unix:"))
858 {
859 return Some(("unix".into(), rest.to_string(), String::new()));
860 }
861 let (scheme, rest) = url.split_once("://")?;
862 let (authority, path) = match rest.split_once('/') {
863 Some((a, p)) => (a, format!("/{p}")),
864 None => (rest, String::new()),
865 };
866 Some((scheme.to_string(), authority.to_string(), path))
867}
868
869pub fn egress_allows(
873 services: &BTreeMap<String, Service>,
874 egress: Egress,
875 kind: ServiceKind,
876 url: &str,
877) -> Result<(), String> {
878 if egress == Egress::Open || service_match(services, kind, url).is_some() {
879 return Ok(());
880 }
881 Err(format!(
882 "security.egress is `closed` and {url} matches no `kind: {}` services: catalog entry — catalog the endpoint to allow it",
883 kind.as_str()
884 ))
885}
886
887#[derive(Debug, Clone, Deserialize, PartialEq)]
888#[serde(deny_unknown_fields)]
889pub struct McpServer {
890 pub name: String,
891 #[serde(default)]
894 pub endpoint: String,
895 #[serde(default)]
899 pub service: Option<String>,
900 #[serde(skip)]
903 pub service_rate: Option<String>,
904 #[serde(default)]
905 pub ns: Option<String>,
906 #[serde(default)]
907 pub headers: BTreeMap<String, String>,
908 #[serde(default)]
909 pub tags: BTreeMap<String, Vec<String>>,
910 #[serde(default)]
915 pub allow: Option<Vec<String>>,
916 #[serde(default)]
917 pub exclude: Vec<String>,
918 #[serde(default)]
919 pub aauth: Option<bool>,
920 #[serde(default)]
921 pub oauth: Option<McpOauth>,
922 #[serde(default)]
928 pub auth: Option<Auth>,
929 #[serde(default)]
930 pub timeout: Option<Dur>,
931}
932
933impl McpServer {
934 pub fn tag_set(&self) -> Result<Vec<crate::sec::scope::TrifectaTag>, String> {
938 let mut out = Vec::new();
939 for list in self.tags.values() {
940 for t in list {
941 let tag = crate::sec::scope::TrifectaTag::parse(t).ok_or_else(|| {
942 format!("mcp server '{}' has unknown trifecta tag '{t}'", self.name)
943 })?;
944 if !out.contains(&tag) {
945 out.push(tag);
946 }
947 }
948 }
949 Ok(out)
950 }
951
952 pub fn to_spec(&self) -> Result<super::McpServerSpec, String> {
954 Ok(super::McpServerSpec {
955 name: self.name.clone(),
956 endpoint: self.endpoint.clone(),
957 headers: self
958 .headers
959 .iter()
960 .map(|(k, v)| (k.clone(), v.clone()))
961 .collect(),
962 tags: self.tag_set()?,
963 aauth: self.aauth,
964 oauth: self.oauth.as_ref().map(|o| super::McpOauthSpec {
968 token_url: o.token_url.clone(),
969 client_id: o.client_id.clone(),
970 client_secret: o.client_secret.0.clone(),
971 scope: o.scope.clone(),
972 }),
973 auth: self.auth.as_ref().map(|a| a.to_spec()),
974 service: self.service.clone(),
975 rate: self.service_rate.clone(),
976 })
977 }
978}
979
980#[derive(Debug, Clone, Deserialize, PartialEq)]
981#[serde(deny_unknown_fields)]
982pub struct McpOauth {
983 pub token_url: String,
984 pub client_id: String,
985 pub client_secret: Secret,
986 #[serde(default)]
987 pub scope: Option<String>,
988}
989
990#[derive(Debug, Clone, Deserialize, PartialEq)]
995#[serde(deny_unknown_fields)]
996pub struct Auth {
997 pub kind: AuthKind,
998 #[serde(default)]
1003 pub issuer: Option<String>,
1004 #[serde(default)]
1005 pub token_url: Option<String>,
1006 #[serde(default)]
1007 pub device_authorization_url: Option<String>,
1008 #[serde(default)]
1009 pub authorization_url: Option<String>,
1010 #[serde(default)]
1011 pub client_id: Option<String>,
1012 #[serde(default)]
1015 pub client_secret: Option<Secret>,
1016 #[serde(default)]
1019 pub grant: Option<OAuthGrant>,
1020 #[serde(default)]
1021 pub scopes: Vec<String>,
1022 #[serde(default)]
1023 pub audience: Option<String>,
1024 #[serde(default)]
1027 pub token: Option<Secret>,
1028 #[serde(default)]
1030 pub header: Option<String>,
1031 #[serde(default)]
1032 pub value: Option<Secret>,
1033 #[serde(default)]
1035 pub region: Option<String>,
1036 #[serde(default)]
1038 pub service: Option<String>,
1039 #[serde(default)]
1044 pub source: Option<String>,
1045 #[serde(default)]
1048 pub sso_start_url: Option<String>,
1049 #[serde(default)]
1050 pub account_id: Option<String>,
1051 #[serde(default)]
1052 pub role_name: Option<String>,
1053 #[serde(default)]
1057 pub svid: Option<String>,
1058 #[serde(default)]
1061 pub jwt_svid_file: Option<String>,
1062 #[serde(default)]
1064 pub svid_file: Option<String>,
1065 #[serde(default)]
1066 pub key_file: Option<String>,
1067}
1068
1069impl Auth {
1070 pub fn to_spec(&self) -> super::AuthSpec {
1073 super::AuthSpec {
1074 kind: match self.kind {
1075 AuthKind::Static => "static",
1076 AuthKind::Oauth2 => "oauth2",
1077 AuthKind::Aws => "aws",
1078 AuthKind::Spiffe => "spiffe",
1079 }
1080 .to_string(),
1081 grant: self.grant.map(|g| {
1082 match g {
1083 OAuthGrant::Device => "device",
1084 OAuthGrant::AuthorizationCode => "authorization_code",
1085 OAuthGrant::ClientCredentials => "client_credentials",
1086 }
1087 .to_string()
1088 }),
1089 issuer: self.issuer.clone(),
1090 token_url: self.token_url.clone(),
1091 device_authorization_url: self.device_authorization_url.clone(),
1092 authorization_url: self.authorization_url.clone(),
1093 client_id: self.client_id.clone(),
1094 client_secret: self.client_secret.as_ref().map(|s| s.0.clone()),
1095 scopes: self.scopes.clone(),
1096 audience: self.audience.clone(),
1097 token: self.token.as_ref().map(|s| s.0.clone()),
1098 header: self.header.clone(),
1099 value: self.value.as_ref().map(|s| s.0.clone()),
1100 region: self.region.clone(),
1101 service: self.service.clone(),
1102 source: self.source.clone(),
1103 sso_start_url: self.sso_start_url.clone(),
1104 account_id: self.account_id.clone(),
1105 role_name: self.role_name.clone(),
1106 svid: self.svid.clone(),
1107 jwt_svid_file: self.jwt_svid_file.clone(),
1108 svid_file: self.svid_file.clone(),
1109 key_file: self.key_file.clone(),
1110 }
1111 }
1112}
1113
1114#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1116#[serde(rename_all = "snake_case")]
1117pub enum AuthKind {
1118 Static,
1120 Oauth2,
1122 Aws,
1124 Spiffe,
1126}
1127
1128#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1130#[serde(rename_all = "snake_case")]
1131pub enum OAuthGrant {
1132 Device,
1134 AuthorizationCode,
1136 ClientCredentials,
1138}
1139
1140#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1141#[serde(deny_unknown_fields, default)]
1142pub struct Tools {
1143 pub disabled: Vec<String>,
1144 pub overrides: BTreeMap<String, ToolOverride>,
1145}
1146
1147#[derive(Debug, Clone, Deserialize, PartialEq)]
1148#[serde(deny_unknown_fields)]
1149pub struct ToolOverride {
1150 pub server: String,
1151 pub tool: String,
1152 #[serde(default)]
1153 pub args: Option<String>,
1154 #[serde(default)]
1155 pub result: Option<String>,
1156}
1157
1158#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1159#[serde(deny_unknown_fields, default)]
1160pub struct Store {
1161 pub kind: StoreKind,
1162 pub prefix: Option<String>,
1163 pub mcp: Option<StoreMcp>,
1164 pub http: Option<StoreHttp>,
1165 pub file: Option<StoreFile>,
1166 pub checkpoint: Checkpoint,
1167 pub durability: Durability,
1168 pub retention: Retention,
1169 pub on_error: StoreOnError,
1170 pub audit: bool,
1171 pub timeout: Option<Dur>,
1172}
1173
1174impl Store {
1175 pub fn prefix(&self) -> &str {
1176 self.prefix.as_deref().unwrap_or("agentd")
1177 }
1178}
1179
1180#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1181#[serde(rename_all = "lowercase")]
1182pub enum StoreKind {
1183 Mcp,
1184 Http,
1185 File,
1188 Memory,
1189 #[default]
1190 None,
1191}
1192
1193#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1197#[serde(deny_unknown_fields)]
1198pub struct StoreFile {
1199 #[serde(default)]
1200 pub path: Option<String>,
1201 #[serde(default)]
1208 pub min_free: Option<String>,
1209}
1210
1211#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1213#[serde(deny_unknown_fields, default)]
1214pub struct StreamCfg {
1215 pub retention: StreamRetention,
1216}
1217
1218#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1222#[serde(deny_unknown_fields, default)]
1223pub struct StreamRetention {
1224 pub max_events: Option<u64>,
1225 pub max_age: Option<Dur>,
1226}
1227
1228impl StreamCfg {
1229 pub fn max_events(&self) -> u64 {
1230 self.retention.max_events.unwrap_or(10_000)
1231 }
1232 pub fn max_age_ms(&self) -> Option<u64> {
1233 self.retention.max_age.map(|d| d.0.as_millis() as u64)
1234 }
1235}
1236
1237pub fn file_store_root(store: &Store) -> std::path::PathBuf {
1254 file_store_root_in(store, &|k| std::env::var_os(k))
1255}
1256
1257fn file_store_root_in(
1261 store: &Store,
1262 env: &dyn Fn(&str) -> Option<std::ffi::OsString>,
1263) -> std::path::PathBuf {
1264 use std::path::PathBuf;
1265 if let Some(p) = store.file.as_ref().and_then(|f| f.path.as_deref()) {
1266 return PathBuf::from(p);
1267 }
1268 if let Some(d) = env("AGENTD_STATE_DIR") {
1269 return PathBuf::from(d);
1270 }
1271 if let Some(d) = env("XDG_STATE_HOME") {
1272 return PathBuf::from(d).join("agentd").join("state");
1273 }
1274 if let Some(h) = env("HOME") {
1275 return PathBuf::from(h)
1276 .join(".local")
1277 .join("state")
1278 .join("agentd")
1279 .join("state");
1280 }
1281 std::env::temp_dir().join("agentd").join("state")
1282}
1283
1284#[derive(Debug, Clone, Deserialize, PartialEq)]
1285#[serde(deny_unknown_fields)]
1286pub struct StoreMcp {
1287 pub server: String,
1288 #[serde(default)]
1289 pub put: Option<StoreOp>,
1290 #[serde(default)]
1291 pub get: Option<StoreOp>,
1292 #[serde(default)]
1293 pub list: Option<StoreOp>,
1294 #[serde(default)]
1295 pub delete: Option<StoreOp>,
1296}
1297
1298#[derive(Debug, Clone, Deserialize, PartialEq)]
1299#[serde(deny_unknown_fields)]
1300pub struct StoreOp {
1301 pub tool: String,
1302 #[serde(default)]
1303 pub args: Option<String>,
1304 #[serde(default)]
1305 pub ok: Option<String>,
1306 #[serde(default)]
1307 pub conflict: Option<String>,
1308 #[serde(default)]
1309 pub value: Option<String>,
1310 #[serde(default)]
1311 pub keys: Option<String>,
1312}
1313
1314#[derive(Debug, Clone, Deserialize, PartialEq)]
1315#[serde(deny_unknown_fields)]
1316pub struct StoreHttp {
1317 pub base_url: String,
1318 #[serde(default)]
1319 pub headers: BTreeMap<String, String>,
1320 #[serde(default)]
1321 pub get: Option<HttpOp>,
1322 #[serde(default)]
1323 pub put: Option<HttpOp>,
1324 #[serde(default)]
1325 pub list: Option<HttpOp>,
1326 #[serde(default)]
1327 pub delete: Option<HttpOp>,
1328}
1329
1330#[derive(Debug, Clone, Deserialize, PartialEq)]
1331#[serde(deny_unknown_fields)]
1332pub struct HttpOp {
1333 #[serde(default)]
1334 pub method: Option<String>,
1335 pub url: String,
1336 #[serde(default)]
1337 pub body: Option<String>,
1338 #[serde(default)]
1339 pub value: Option<String>,
1340 #[serde(default)]
1341 pub keys: Option<String>,
1342 #[serde(default)]
1343 pub conflict_status: Option<u16>,
1344}
1345
1346#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1347#[serde(deny_unknown_fields, default)]
1348pub struct Checkpoint {
1349 pub debounce_ms: Option<u64>,
1350}
1351
1352#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1359#[serde(deny_unknown_fields, default)]
1360pub struct Retention {
1361 pub runs: RunRetention,
1362}
1363
1364#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1365#[serde(deny_unknown_fields, default)]
1366pub struct RunRetention {
1367 pub keep_last: Option<u32>,
1369 pub ttl: Option<Dur>,
1371}
1372
1373#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1374#[serde(deny_unknown_fields, default)]
1375pub struct Durability {
1376 pub a2a: Option<DurabilityLevel>,
1377 pub steps: Option<DurabilityLevel>,
1378 pub work: Option<WorkDurability>,
1385}
1386
1387#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1388#[serde(rename_all = "lowercase")]
1389pub enum WorkDurability {
1390 Durable,
1391 Ephemeral,
1392}
1393
1394#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1395#[serde(rename_all = "lowercase")]
1396pub enum DurabilityLevel {
1397 Strict,
1398 Eventual,
1399}
1400
1401#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1402#[serde(rename_all = "lowercase")]
1403pub enum StoreOnError {
1404 #[default]
1405 Halt,
1406 Degrade,
1407}
1408
1409#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1410#[serde(deny_unknown_fields, default)]
1411pub struct Memory {
1412 pub max_value_bytes: Option<u64>,
1413 pub list_default_limit: Option<u64>,
1414}
1415
1416#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1417#[serde(deny_unknown_fields, default)]
1418pub struct Context {
1419 pub compact_at: Option<f64>,
1420 pub keep_last: Option<u32>,
1421 pub model_window: Option<u64>,
1424 pub plan: Plan,
1425 pub template: Option<String>,
1430 pub templates: BTreeMap<String, String>,
1434 pub summarize: Summarize,
1437}
1438
1439#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1440#[serde(deny_unknown_fields, default)]
1441pub struct Summarize {
1442 pub prompt: Option<String>,
1447 pub model: Option<String>,
1450}
1451
1452#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1453#[serde(deny_unknown_fields, default)]
1454pub struct Plan {
1455 pub max_items: Option<u32>,
1456}
1457
1458#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1459#[serde(deny_unknown_fields, default)]
1460pub struct Knowledge {
1461 pub server: Option<String>,
1462 pub auto_context: AutoContext,
1463}
1464
1465#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1466#[serde(deny_unknown_fields, default)]
1467pub struct AutoContext {
1468 pub on: AutoContextOn,
1469 pub top_k: Option<u32>,
1470 pub max_bytes: Option<u64>,
1471}
1472
1473#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1474#[serde(rename_all = "lowercase")]
1475pub enum AutoContextOn {
1476 Turn,
1477 #[default]
1478 Never,
1479}
1480
1481#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1482#[serde(deny_unknown_fields, default)]
1483pub struct Search {
1484 pub server: Option<String>,
1485}
1486
1487#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1488#[serde(deny_unknown_fields, default)]
1489pub struct Skills {
1490 pub sources: Vec<SkillSource>,
1491 pub dir: Option<String>,
1498 pub reference_prefix: Option<String>,
1499 pub max_loaded: Option<u32>,
1500 pub max_bytes: Option<u64>,
1501}
1502
1503#[derive(Debug, Clone, Deserialize, PartialEq)]
1504#[serde(deny_unknown_fields)]
1505pub struct SkillSource {
1506 pub server: String,
1507 #[serde(default)]
1508 pub discover: Discover,
1509 #[serde(default)]
1510 pub filter: Option<String>,
1511}
1512
1513#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1514#[serde(rename_all = "lowercase")]
1515pub enum Discover {
1516 Prompts,
1517 Resources,
1518 #[default]
1519 Auto,
1520}
1521
1522#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1523#[serde(deny_unknown_fields, default)]
1524pub struct Limits {
1525 pub max_runs: Option<u32>,
1526 pub run: RunLimits,
1527 pub subagents: SubagentLimits,
1528 pub inline_max_bytes: Option<u64>,
1529 pub step_timeout: Option<Dur>,
1530 pub workflow: WorkflowLimits,
1531 pub max_message_depth: Option<u32>,
1537}
1538
1539pub const DEFAULT_MESSAGE_DEPTH: u32 = 8;
1543
1544impl Limits {
1545 pub fn message_depth(&self) -> u32 {
1546 self.max_message_depth.unwrap_or(DEFAULT_MESSAGE_DEPTH)
1547 }
1548}
1549
1550#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1552#[serde(deny_unknown_fields, default)]
1553pub struct WorkflowLimits {
1554 pub fan_out: Option<u32>,
1560}
1561
1562#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1563#[serde(deny_unknown_fields, default)]
1564pub struct RunLimits {
1565 pub steps: Option<u32>,
1566 pub tokens: Option<u64>,
1567 pub deadline: Option<Dur>,
1568}
1569
1570impl RunLimits {
1571 pub fn steps(&self) -> u32 {
1572 self.steps.unwrap_or(500)
1573 }
1574 pub fn tokens(&self) -> u64 {
1575 self.tokens.unwrap_or(2_000_000)
1576 }
1577 pub fn deadline(&self) -> Duration {
1578 self.deadline
1579 .map(|d| d.0)
1580 .unwrap_or(Duration::from_secs(3600))
1581 }
1582}
1583
1584#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1585#[serde(deny_unknown_fields, default)]
1586pub struct SubagentLimits {
1587 pub depth: Option<u32>,
1588 pub breadth: Option<u32>,
1589 pub total: Option<u32>,
1590 pub rate: Option<String>,
1591 pub instances: InstanceLimits,
1595}
1596
1597#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1598#[serde(deny_unknown_fields, default)]
1599pub struct InstanceLimits {
1600 pub breadth: Option<u32>,
1601 pub total: Option<u32>,
1602 pub rate: Option<String>,
1603}
1604
1605#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1607#[serde(deny_unknown_fields, default)]
1608pub struct Subagents {
1609 pub allow_freeform: Option<bool>,
1614 pub defaults: SubagentDefaults,
1617 pub templates: BTreeMap<String, SubagentTemplate>,
1623}
1624
1625#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1626#[serde(deny_unknown_fields, default)]
1627pub struct SubagentDefaults {
1628 pub model: Option<String>,
1629 pub priority: Option<String>,
1630 pub mode: Option<String>,
1631 pub durable: Option<bool>,
1633 pub limits: Option<Value>,
1637}
1638
1639#[derive(Debug, Clone, Deserialize, PartialEq)]
1640#[serde(deny_unknown_fields)]
1641pub struct SubagentTemplate {
1642 pub instruction: String,
1647 #[serde(default)]
1649 pub params: BTreeMap<String, ParamSpec>,
1650 #[serde(default)]
1652 pub servers: Option<Vec<String>>,
1653 #[serde(default)]
1654 pub tools: Option<Vec<String>>,
1655 #[serde(default)]
1658 pub limits: Option<Value>,
1659 #[serde(default)]
1660 pub mode: Option<String>,
1661 #[serde(default)]
1662 pub model: Option<String>,
1663 #[serde(default)]
1664 pub priority: Option<String>,
1665 #[serde(default)]
1666 pub skills: Option<Value>,
1667 #[serde(default)]
1668 pub context: Option<Value>,
1669 #[serde(default)]
1670 pub output_contract: Option<String>,
1671 #[serde(default)]
1672 pub output_schema: Option<Value>,
1673 #[serde(default)]
1676 pub budget: Option<Value>,
1677 #[serde(default)]
1680 pub ttl: Option<Dur>,
1681 #[serde(default)]
1684 pub until: Option<String>,
1685 #[serde(default)]
1687 pub singleton: bool,
1688 #[serde(default)]
1692 pub durable: Option<bool>,
1693 #[serde(default)]
1698 pub result: Option<Value>,
1699 #[serde(default)]
1704 pub mirror_streams: Option<Vec<String>>,
1705}
1706
1707#[derive(Debug, Clone, Deserialize, PartialEq)]
1709#[serde(deny_unknown_fields)]
1710pub struct ParamSpec {
1711 #[serde(rename = "type", default)]
1713 pub kind: Option<String>,
1714 #[serde(default)]
1715 pub required: bool,
1716 #[serde(default)]
1717 pub default: Option<Value>,
1718 #[serde(rename = "enum", default)]
1719 pub one_of: Option<Vec<Value>>,
1720 #[serde(default)]
1721 pub description: Option<String>,
1722}
1723
1724#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1725#[serde(deny_unknown_fields, default)]
1726pub struct Lifecycle {
1727 pub run_until: RunUntil,
1728 pub idle_grace: Option<Dur>,
1729 pub drain_timeout: Option<Dur>,
1730 pub run_id: Option<String>,
1731 pub exit_code_map: BTreeMap<String, i32>,
1732 pub watch_config: bool,
1733 pub until_signal: Option<String>,
1738}
1739
1740impl Lifecycle {
1741 pub fn drain_timeout(&self) -> Duration {
1742 self.drain_timeout
1743 .map(|d| d.0)
1744 .unwrap_or(Duration::from_secs(25))
1745 }
1746 pub fn idle_grace(&self) -> Duration {
1747 self.idle_grace
1748 .map(|d| d.0)
1749 .unwrap_or(Duration::from_secs(5))
1750 }
1751}
1752
1753#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1754#[serde(rename_all = "lowercase")]
1755pub enum RunUntil {
1756 #[default]
1757 Auto,
1758 Idle,
1759 Drained,
1760}
1761
1762#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1763#[serde(deny_unknown_fields, default)]
1764pub struct Identity {
1765 pub autonomous_as: Option<String>,
1770 #[serde(default)]
1772 pub labels: BTreeMap<String, String>,
1773}
1774
1775impl Identity {
1776 pub fn autonomous_id(&self) -> &str {
1777 self.autonomous_as.as_deref().unwrap_or("system")
1778 }
1779}
1780
1781#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1782#[serde(deny_unknown_fields, default)]
1783pub struct A2a {
1784 pub listen: Option<String>,
1785 pub tls: A2aTls,
1786 pub bearer: Option<Secret>,
1787 pub principals: Vec<Principal>,
1788 pub peers: Vec<A2aPeer>,
1789 pub conversation_ttl: Option<Dur>,
1790 pub push: A2aPush,
1791}
1792
1793#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1803#[serde(deny_unknown_fields, default)]
1804pub struct A2aPush {
1805 pub enabled: bool,
1807 pub allow_private: bool,
1809}
1810
1811#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1812#[serde(deny_unknown_fields, default)]
1813pub struct A2aTls {
1814 pub cert: Option<String>,
1815 pub key: Option<String>,
1816 pub client_ca: Option<String>,
1817}
1818
1819#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1830#[serde(deny_unknown_fields, default)]
1831pub struct Interface {
1832 pub enabled: bool,
1834 pub debug: bool,
1838 pub origins: Vec<String>,
1841 pub display: Display,
1844 pub pairing: Pairing,
1848}
1849
1850#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1856#[serde(deny_unknown_fields, default)]
1857pub struct Display {
1858 pub top: Option<Vec<String>>,
1859 pub bottom: Option<Vec<String>>,
1860}
1861
1862pub const DISPLAY_ITEMS: &[&str] = &[
1864 "name", "version", "instance", "model", "endpoint", "conn", "debug", "draining", "active", "turns", "tokens", "tool_calls",
1876 "runs", "subagents", "conversations", "screen", "keys", "clock", ];
1883
1884#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1890#[serde(deny_unknown_fields, default)]
1891pub struct Pairing {
1892 pub enabled: bool,
1893 pub role: Option<Role>,
1896 pub ttl: Option<Dur>,
1898}
1899
1900#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1906#[serde(deny_unknown_fields, default)]
1907pub struct Webhooks {
1908 pub listen: Option<String>,
1911 pub tls: A2aTls,
1912 pub default_auth: Option<WebhookAuth>,
1914}
1915
1916#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1920#[serde(deny_unknown_fields, default)]
1921pub struct WebhookAuth {
1922 pub hmac: Option<Hmac>,
1924 pub bearer: Option<Secret>,
1926 pub header: Option<HeaderMatch>,
1928 pub none: bool,
1930}
1931
1932#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1933#[serde(deny_unknown_fields, default)]
1934pub struct Hmac {
1935 pub secret: Option<Secret>,
1936 pub header: Option<String>,
1938 pub algo: Option<String>,
1940 pub prefix: Option<String>,
1942}
1943
1944#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1945#[serde(deny_unknown_fields, default)]
1946pub struct HeaderMatch {
1947 pub name: Option<String>,
1948 pub equals: Option<Secret>,
1949}
1950
1951#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1956#[serde(deny_unknown_fields, default)]
1957pub struct Goal {
1958 pub statement: Option<String>,
1960 pub check: GoalCheck,
1961 pub stuck_after: Option<u32>,
1963 pub on_achieved: Option<GoalAction>,
1965 pub on_stuck: Option<GoalAction>,
1967}
1968
1969#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1970#[serde(deny_unknown_fields, default)]
1971pub struct GoalCheck {
1972 pub every: Option<Dur>,
1974 pub condition: Option<String>,
1976 pub via: Option<String>,
1978}
1979
1980#[derive(Debug, Clone, PartialEq)]
1983pub enum GoalAction {
1984 Finish,
1985 Idle,
1986 Replan,
1987 Escalate,
1988 Workflow(String),
1989}
1990
1991impl<'de> Deserialize<'de> for GoalAction {
1992 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1993 use serde::de::Error;
1994 match Value::deserialize(d)? {
1995 Value::String(s) => match s.as_str() {
1996 "finish" => Ok(GoalAction::Finish),
1997 "idle" => Ok(GoalAction::Idle),
1998 "replan" => Ok(GoalAction::Replan),
1999 "escalate" => Ok(GoalAction::Escalate),
2000 other => Err(D::Error::custom(format!(
2001 "unknown goal action '{other}' (want finish|idle|replan|escalate|{{workflow: <name>}})"
2002 ))),
2003 },
2004 Value::Object(m) => match m.get("workflow").and_then(Value::as_str) {
2005 Some(w) => Ok(GoalAction::Workflow(w.to_string())),
2006 None => Err(D::Error::custom(
2007 "a goal action object must be { workflow: <name> }",
2008 )),
2009 },
2010 _ => Err(D::Error::custom(
2011 "a goal action must be a string or { workflow: <name> }",
2012 )),
2013 }
2014 }
2015}
2016
2017#[derive(Debug, Clone, Deserialize, PartialEq)]
2018#[serde(deny_unknown_fields)]
2019pub struct Principal {
2020 #[serde(rename = "match")]
2021 pub matcher: PrincipalMatch,
2022 pub role: Role,
2023 #[serde(default)]
2024 pub grants: Vec<String>,
2025 #[serde(default)]
2026 pub quotas: Option<Quotas>,
2027 #[serde(default)]
2035 pub labels: BTreeMap<String, String>,
2036}
2037
2038#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2039#[serde(deny_unknown_fields, default)]
2040pub struct PrincipalMatch {
2041 pub san: Option<String>,
2042 pub sub: Option<String>,
2043 pub bearer_ref: Option<String>,
2044 pub aauth_agent: Option<String>,
2045 pub any: bool,
2046}
2047
2048#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2049#[serde(rename_all = "lowercase")]
2050pub enum Role {
2051 Operator,
2052 User,
2053 Agent,
2054 Anonymous,
2055}
2056
2057#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2058#[serde(deny_unknown_fields, default)]
2059pub struct Quotas {
2060 pub rate: Option<String>,
2061 pub budget: Option<Budget>,
2062}
2063
2064#[derive(Debug, Clone, Deserialize, PartialEq)]
2065#[serde(deny_unknown_fields)]
2066pub struct A2aPeer {
2067 pub name: String,
2068 #[serde(default)]
2071 pub endpoint: String,
2072 #[serde(default)]
2075 pub service: Option<String>,
2076 #[serde(default)]
2077 pub headers: BTreeMap<String, String>,
2078 #[serde(default)]
2079 pub client_cert: Option<String>,
2080 #[serde(default)]
2081 pub client_key: Option<String>,
2082 #[serde(default)]
2086 pub auth: Option<Auth>,
2087}
2088
2089#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2090#[serde(deny_unknown_fields, default)]
2091pub struct Observability {
2092 pub log_level: Option<String>,
2093 pub log_content: bool,
2094 pub otel: Otel,
2095 pub metrics_addr: Option<String>,
2096 pub health_file: Option<String>,
2097 pub report_file: Option<String>,
2098 pub events_ring: Option<u32>,
2099 pub audit: Audit,
2100 pub traceparent: Option<String>,
2101 pub runtime_events: Option<RuntimeEvents>,
2105}
2106
2107#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2109#[serde(deny_unknown_fields, default)]
2110pub struct RuntimeEvents {
2111 pub stream: Option<String>,
2113 pub include: Vec<String>,
2116 pub sampled: Vec<String>,
2120 pub queue: Option<u32>,
2123}
2124
2125pub const DEFAULT_TAP_QUEUE: u32 = 512;
2127
2128impl RuntimeEvents {
2129 pub fn queue_cap(&self) -> usize {
2130 self.queue.unwrap_or(DEFAULT_TAP_QUEUE) as usize
2131 }
2132}
2133
2134#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2135#[serde(deny_unknown_fields, default)]
2136pub struct Otel {
2137 pub endpoint: Option<String>,
2138 pub traces: Option<bool>,
2139 pub metrics: Option<bool>,
2140 pub logs: Option<bool>,
2141}
2142
2143#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2144#[serde(deny_unknown_fields, default)]
2145pub struct Audit {
2146 pub sink: Option<Vec<AuditSink>>,
2147 pub stream: Option<String>,
2151}
2152
2153#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
2154#[serde(rename_all = "lowercase")]
2155pub enum AuditSink {
2156 Log,
2157 Store,
2158 Stream,
2163}
2164
2165#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2166#[serde(deny_unknown_fields, default)]
2167pub struct Security {
2168 pub allow_trifecta: bool,
2169 pub tls_ca: Option<String>,
2170 pub aauth: Option<AAuth>,
2171 pub cgroup: Cgroup,
2172 pub exec: Exec,
2173 pub workflows: WorkflowSecurity,
2174 pub egress: Egress,
2179 pub policies: Vec<Policy>,
2188}
2189
2190#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2192#[serde(deny_unknown_fields, default)]
2193pub struct Policy {
2194 #[serde(rename = "match")]
2195 pub matcher: PolicyMatch,
2196 pub action: PolicyAction,
2197 pub question: Option<String>,
2200 pub on_timeout: Option<PolicyAction>,
2203 pub timeout: Option<Dur>,
2204}
2205
2206#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2207#[serde(deny_unknown_fields, default)]
2208pub struct PolicyMatch {
2209 pub tool: Option<String>,
2211 pub tags: Vec<String>,
2213 pub caller: Vec<PolicyCaller>,
2215 pub principal: Option<String>,
2217 pub args: Option<String>,
2221}
2222
2223#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
2224#[serde(rename_all = "lowercase")]
2225pub enum PolicyCaller {
2226 Root,
2227 Workflow,
2228 Subagent,
2229}
2230
2231#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
2232#[serde(rename_all = "lowercase")]
2233pub enum PolicyAction {
2234 #[default]
2235 Allow,
2236 Deny,
2237 Ask,
2242 Shadow,
2247}
2248
2249#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
2251#[serde(rename_all = "lowercase")]
2252pub enum Egress {
2253 #[default]
2254 Open,
2255 Closed,
2256}
2257
2258#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2260#[serde(deny_unknown_fields, default)]
2261pub struct WorkflowSecurity {
2262 pub immutable: bool,
2277}
2278
2279#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2289#[serde(deny_unknown_fields, default)]
2290pub struct Exec {
2291 pub enabled: bool,
2293 pub allow: Vec<String>,
2296 pub workdir: Option<String>,
2298 pub timeout: Option<Dur>,
2300 pub max_output: Option<u64>,
2302 pub env: Vec<String>,
2305}
2306
2307#[derive(Debug, Clone, Deserialize, PartialEq)]
2308#[serde(deny_unknown_fields)]
2309pub struct AAuth {
2310 pub provider: String,
2311 #[serde(default)]
2312 pub key_file: Option<String>,
2313 #[serde(default)]
2314 pub enroll_token: Option<Secret>,
2315 #[serde(default)]
2316 pub enroll_assertion_file: Option<String>,
2317 #[serde(default)]
2318 pub person_server: Option<String>,
2319}
2320
2321#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2322#[serde(deny_unknown_fields, default)]
2323pub struct Cgroup {
2324 pub spec: Option<String>,
2325 pub memory_max: Option<String>,
2326 pub pids_max: Option<String>,
2327}
2328
2329#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2331pub struct FoundRef {
2332 pub kind: &'static str,
2334 pub name: String,
2335 pub at: String,
2337}
2338
2339pub fn scan_references(value: &Value, at: &str, out: &mut Vec<FoundRef>) {
2347 match value {
2348 Value::String(s) => {
2349 let mut rest = s.as_str();
2350 while let Some(open) = rest.find("{{") {
2351 let after = &rest[open + 2..];
2352 let Some(close) = after.find("}}") else { break };
2353 let token = after[..close].trim();
2354 if let Some(n) = token.strip_prefix("secret:") {
2355 out.push(FoundRef {
2356 kind: "secret",
2357 name: n.trim().into(),
2358 at: at.into(),
2359 });
2360 } else if let Some(p) = token.strip_prefix("secret-file:") {
2361 out.push(FoundRef {
2362 kind: "secret-file",
2363 name: p.trim().into(),
2364 at: at.into(),
2365 });
2366 } else if let Some(c) = token.strip_prefix("config.") {
2367 out.push(FoundRef {
2368 kind: "config",
2369 name: c.trim().into(),
2370 at: at.into(),
2371 });
2372 }
2373 rest = &after[close + 2..];
2374 }
2375 }
2376 Value::Array(a) => {
2377 for (i, v) in a.iter().enumerate() {
2378 scan_references(v, &format!("{at}[{i}]"), out);
2379 }
2380 }
2381 Value::Object(o) => {
2382 for (k, v) in o {
2383 scan_references(v, &format!("{at}.{k}"), out);
2384 }
2385 }
2386 _ => {}
2387 }
2388}
2389
2390pub fn hmac_algos(value: &Value, at: &str) -> Vec<(String, String)> {
2398 let mut out = Vec::new();
2399 fn walk(v: &Value, at: &str, out: &mut Vec<(String, String)>) {
2400 match v {
2401 Value::Object(o) => {
2402 for (k, child) in o {
2403 if k == "hmac"
2404 && let Some(a) = child.get("algo").and_then(Value::as_str)
2405 {
2406 out.push((format!("{at}.hmac.algo"), a.to_string()));
2407 }
2408 walk(child, &format!("{at}.{k}"), out);
2409 }
2410 }
2411 Value::Array(a) => {
2412 for (i, child) in a.iter().enumerate() {
2413 walk(child, &format!("{at}[{i}]"), out);
2414 }
2415 }
2416 _ => {}
2417 }
2418 }
2419 walk(value, at, &mut out);
2420 out
2421}
2422
2423pub fn missing_references(value: &Value, at: &str, vars: &BTreeMap<String, Value>) -> Vec<String> {
2428 let mut found = Vec::new();
2429 scan_references(value, at, &mut found);
2430 let mut by_ref: BTreeMap<(&'static str, String), Vec<String>> = BTreeMap::new();
2431 for r in found {
2432 let missing = match r.kind {
2433 "secret" => !crate::sec::secret::secret_available(&r.name),
2434 "secret-file" => std::fs::metadata(&r.name).is_err(),
2435 "config" => {
2436 let mut parts = r.name.split('.');
2437 let mut cur = parts.next().and_then(|p| vars.get(p));
2438 for p in parts {
2439 cur = cur.and_then(|v| v.get(p));
2440 }
2441 cur.is_none()
2442 }
2443 _ => false,
2444 };
2445 if missing {
2446 by_ref.entry((r.kind, r.name)).or_default().push(r.at);
2447 }
2448 }
2449 by_ref
2450 .into_iter()
2451 .map(|((kind, name), ats)| {
2452 let what = match kind {
2453 "secret" => format!("{{{{secret:{name}}}}} is not set in the environment"),
2454 "secret-file" => format!("{{{{secret-file:{name}}}}} is not readable"),
2455 _ => format!("config.{name} is not defined in vars"),
2456 };
2457 format!("{what} (referenced at {})", ats.join(", "))
2458 })
2459 .collect()
2460}
2461
2462pub fn substitute_config_vars(
2471 value: &mut Value,
2472 vars: &BTreeMap<String, Value>,
2473 at: &str,
2474 errs: &mut Vec<String>,
2475) {
2476 fn lookup<'a>(vars: &'a BTreeMap<String, Value>, path: &str) -> Option<&'a Value> {
2477 let mut parts = path.split('.');
2478 let mut cur = vars.get(parts.next()?)?;
2479 for p in parts {
2480 cur = cur.get(p)?;
2481 }
2482 Some(cur)
2483 }
2484 fn token_at(s: &str, from: usize) -> Option<(usize, usize, String)> {
2485 let start = s[from..].find("{{config.")? + from;
2486 let end = s[start..].find("}}")? + start + 2;
2487 let name = s[start + 9..end - 2].trim().to_string();
2488 Some((start, end, name))
2489 }
2490 match value {
2491 Value::String(s) => {
2492 if let Some((0, end, name)) = token_at(s, 0)
2494 && end == s.len()
2495 {
2496 match lookup(vars, &name) {
2497 Some(v) => *value = v.clone(),
2498 None => errs.push(format!("{at}: config.{name} is not defined in vars")),
2499 }
2500 return;
2501 }
2502 let mut out = String::new();
2503 let mut pos = 0;
2504 while let Some((start, end, name)) = token_at(s, pos) {
2505 out.push_str(&s[pos..start]);
2506 match lookup(vars, &name) {
2507 Some(Value::String(v)) => out.push_str(v),
2508 Some(v) => out.push_str(&v.to_string()),
2509 None => {
2510 errs.push(format!("{at}: config.{name} is not defined in vars"));
2511 out.push_str(&s[start..end]);
2512 }
2513 }
2514 pos = end;
2515 }
2516 if pos > 0 {
2517 out.push_str(&s[pos..]);
2518 *s = out;
2519 }
2520 }
2521 Value::Array(a) => {
2522 for (i, v) in a.iter_mut().enumerate() {
2523 substitute_config_vars(v, vars, &format!("{at}[{i}]"), errs);
2524 }
2525 }
2526 Value::Object(o) => {
2527 for (k, v) in o.iter_mut() {
2528 substitute_config_vars(v, vars, &format!("{at}.{k}"), errs);
2529 }
2530 }
2531 _ => {}
2532 }
2533}
2534
2535impl Settings {
2536 pub fn from_document(mut doc: Value, source: &str) -> Result<Settings, String> {
2545 let vars: BTreeMap<String, Value> = doc
2546 .get("vars")
2547 .and_then(Value::as_object)
2548 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
2549 .unwrap_or_default();
2550 let workflows = doc.as_object_mut().and_then(|o| o.remove("workflows"));
2551 let mut errs = Vec::new();
2552 substitute_config_vars(&mut doc, &vars, source, &mut errs);
2553 if let (Some(o), Some(w)) = (doc.as_object_mut(), workflows) {
2554 o.insert("workflows".into(), w);
2555 }
2556 if !errs.is_empty() {
2557 return Err(format!(
2558 "{} unresolved config var reference(s):\n {}",
2559 errs.len(),
2560 errs.join("\n ")
2561 ));
2562 }
2563 let mut extraction = None;
2574 if let Some(instr) = doc
2575 .get("agent")
2576 .and_then(|a| a.get("instruction"))
2577 .and_then(Value::as_str)
2578 .map(str::to_string)
2579 && !looks_like_resource_uri(&instr)
2580 && instr.lines().any(|l| l.starts_with(":::"))
2581 {
2582 match crate::config::directives::extract(&instr) {
2583 Ok(ex) => {
2584 if let Some(a) = doc.get_mut("agent").and_then(Value::as_object_mut) {
2589 a.insert("instruction".into(), Value::String(ex.cleaned.clone()));
2590 }
2591 if let (Some(o), Value::Object(fragment)) =
2592 (doc.as_object_mut(), ex.config.clone())
2593 {
2594 crate::config::directives::merge_missing(o, fragment, false);
2595 }
2596 extraction = Some(ex);
2597 }
2598 Err(errs) => {
2599 return Err(format!(
2600 "{source}: agent.instruction directives:
2601 {}",
2602 errs.join(
2603 "
2604 "
2605 )
2606 ));
2607 }
2608 }
2609 }
2610 let mut settings: Settings =
2611 serde_json::from_value(doc).map_err(|e| format!("{source} parse error: {e}"))?;
2612 if let Some(ex) = extraction {
2613 settings.agent.inline_skills = ex.skills;
2614 settings.workflows.extend(ex.workflows);
2615 }
2616 Ok(settings)
2617 }
2618
2619 pub fn instance_name(&self) -> String {
2622 if let Some(n) = &self.agent.name {
2623 return n.clone();
2624 }
2625 let id =
2626 crate::identity::Identity::from_env(self.lifecycle.run_id.as_deref().unwrap_or(""));
2627 if let Some(inst) = id.instance.filter(|i| !i.trim().is_empty()) {
2628 return inst;
2629 }
2630 std::env::var("HOSTNAME")
2631 .ok()
2632 .filter(|h| !h.trim().is_empty())
2633 .unwrap_or_else(|| "agentd".to_string())
2634 }
2635
2636 pub fn is_long_lived(&self) -> bool {
2647 self.a2a.listen.is_some()
2648 || self.webhooks.listen.is_some()
2649 || self.goal.is_some()
2650 || self.workflows.iter().any(workflow_is_long_lived)
2651 }
2652}
2653
2654pub const V2_KEYS: &[&str] = &[
2663 "agent",
2664 "store",
2665 "workflows",
2666 "tools",
2667 "a2a",
2668 "lifecycle",
2669 "observability",
2670 "security",
2671 "knowledge",
2672 "search",
2673 "skills",
2674 "memory",
2675 "context",
2676 "vars",
2677 "streams",
2678];
2679
2680pub const V1_KEYS: &[&str] = &[
2682 "intelligence_headers",
2683 "model_swap",
2684 "model",
2685 "max_tokens",
2686 "mcp_servers",
2687 "subscribe",
2688 "a2a_peers",
2689 "log_level",
2690];
2691
2692#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2693pub enum Detected {
2694 Empty,
2696 V1,
2698 V2,
2700 Mixed,
2702}
2703
2704pub fn detect(doc: &Value) -> Detected {
2706 let Some(obj) = doc.as_object() else {
2707 return Detected::Empty;
2708 };
2709 if obj.is_empty() {
2710 return Detected::Empty;
2711 }
2712 let version = obj.get("config_version").and_then(Value::as_str);
2713 let intel_is_object = obj.get("intelligence").is_some_and(Value::is_object);
2714 let intel_is_string = obj.get("intelligence").is_some_and(Value::is_string);
2715 let has_v2 = version == Some(schema::CONFIG_VERSION)
2716 || intel_is_object
2717 || obj.keys().any(|k| V2_KEYS.contains(&k.as_str()));
2718 let has_v1 = intel_is_string
2719 || obj.keys().any(|k| V1_KEYS.contains(&k.as_str()))
2720 || matches!(version, Some(v) if v != schema::CONFIG_VERSION);
2721 match (has_v1, has_v2) {
2722 (true, true) => Detected::Mixed,
2723 (false, true) => Detected::V2,
2724 (true, false) => Detected::V1,
2725 (false, false) => Detected::V1,
2729 }
2730}
2731
2732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2738pub enum AliasKind {
2739 Set,
2741 SetTrue,
2743 Append,
2745 SetFromFile,
2747 Special,
2749}
2750
2751#[derive(Debug, Clone, Copy)]
2753pub struct Alias {
2754 pub flag: &'static str,
2755 pub path: &'static str,
2756 pub kind: AliasKind,
2757}
2758
2759pub const ALIASES: &[Alias] = &[
2763 Alias {
2764 flag: "--instruction",
2765 path: "agent.instruction",
2766 kind: AliasKind::Set,
2767 },
2768 Alias {
2769 flag: "--instruction-file",
2770 path: "agent.instruction",
2771 kind: AliasKind::SetFromFile,
2772 },
2773 Alias {
2774 flag: "--prompt",
2775 path: "agent.prompt",
2776 kind: AliasKind::Set,
2777 },
2778 Alias {
2779 flag: "--prompt-file",
2780 path: "agent.prompt",
2781 kind: AliasKind::SetFromFile,
2782 },
2783 Alias {
2784 flag: "--intelligence",
2785 path: "intelligence.endpoints",
2786 kind: AliasKind::Set,
2787 },
2788 Alias {
2789 flag: "--intelligence-token",
2790 path: "intelligence.token",
2791 kind: AliasKind::Set,
2792 },
2793 Alias {
2794 flag: "--intelligence-token-file",
2795 path: "intelligence.token_file",
2796 kind: AliasKind::Set,
2797 },
2798 Alias {
2799 flag: "--model",
2800 path: "intelligence.model",
2801 kind: AliasKind::Set,
2802 },
2803 Alias {
2804 flag: "--model-swap",
2805 path: "intelligence.swap_policy",
2806 kind: AliasKind::Set,
2807 },
2808 Alias {
2809 flag: "--budget-tokens-lifetime",
2810 path: "intelligence.budget.lifetime_tokens",
2811 kind: AliasKind::Set,
2812 },
2813 Alias {
2814 flag: "--mcp",
2815 path: "mcp.servers",
2816 kind: AliasKind::Append,
2817 },
2818 Alias {
2819 flag: "--mcp-tags",
2820 path: "mcp.servers",
2821 kind: AliasKind::Special,
2822 },
2823 Alias {
2824 flag: "--a2a-peer",
2825 path: "a2a.peers",
2826 kind: AliasKind::Append,
2827 },
2828 Alias {
2829 flag: "--workflow",
2830 path: "workflows",
2831 kind: AliasKind::Append,
2832 },
2833 Alias {
2834 flag: "--max-steps",
2835 path: "limits.run.steps",
2836 kind: AliasKind::Set,
2837 },
2838 Alias {
2839 flag: "--max-tokens",
2840 path: "limits.run.tokens",
2841 kind: AliasKind::Set,
2842 },
2843 Alias {
2844 flag: "--deadline",
2845 path: "limits.run.deadline",
2846 kind: AliasKind::Set,
2847 },
2848 Alias {
2849 flag: "--max-depth",
2850 path: "limits.subagents.depth",
2851 kind: AliasKind::Set,
2852 },
2853 Alias {
2854 flag: "--run-id",
2855 path: "lifecycle.run_id",
2856 kind: AliasKind::Set,
2857 },
2858 Alias {
2859 flag: "--drain-timeout",
2860 path: "lifecycle.drain_timeout",
2861 kind: AliasKind::Set,
2862 },
2863 Alias {
2864 flag: "--watch-config",
2865 path: "lifecycle.watch_config",
2866 kind: AliasKind::SetTrue,
2867 },
2868 Alias {
2869 flag: "--budget-exit-code",
2870 path: "lifecycle.exit_code_map",
2871 kind: AliasKind::Special,
2872 },
2873 Alias {
2874 flag: "--listen",
2875 path: "a2a.listen",
2876 kind: AliasKind::Set,
2877 },
2878 Alias {
2879 flag: "--serve-mcp",
2880 path: "a2a.listen",
2881 kind: AliasKind::Set,
2882 },
2883 Alias {
2884 flag: "--serve-cert",
2885 path: "a2a.tls.cert",
2886 kind: AliasKind::Set,
2887 },
2888 Alias {
2889 flag: "--serve-key",
2890 path: "a2a.tls.key",
2891 kind: AliasKind::Set,
2892 },
2893 Alias {
2894 flag: "--serve-client-ca",
2895 path: "a2a.tls.client_ca",
2896 kind: AliasKind::Set,
2897 },
2898 Alias {
2899 flag: "--serve-bearer",
2900 path: "a2a.bearer",
2901 kind: AliasKind::Set,
2902 },
2903 Alias {
2904 flag: "--log-level",
2905 path: "observability.log_level",
2906 kind: AliasKind::Set,
2907 },
2908 Alias {
2909 flag: "--log-content",
2910 path: "observability.log_content",
2911 kind: AliasKind::SetTrue,
2912 },
2913 Alias {
2914 flag: "--metrics-addr",
2915 path: "observability.metrics_addr",
2916 kind: AliasKind::Set,
2917 },
2918 Alias {
2919 flag: "--health-file",
2920 path: "observability.health_file",
2921 kind: AliasKind::Set,
2922 },
2923 Alias {
2924 flag: "--report-file",
2925 path: "observability.report_file",
2926 kind: AliasKind::Set,
2927 },
2928 Alias {
2929 flag: "--events-ring",
2930 path: "observability.events_ring",
2931 kind: AliasKind::Set,
2932 },
2933 Alias {
2934 flag: "--traceparent",
2935 path: "observability.traceparent",
2936 kind: AliasKind::Set,
2937 },
2938 Alias {
2939 flag: "--allow-trifecta",
2940 path: "security.allow_trifecta",
2941 kind: AliasKind::SetTrue,
2942 },
2943 Alias {
2944 flag: "--tls-ca",
2945 path: "security.tls_ca",
2946 kind: AliasKind::Set,
2947 },
2948 Alias {
2949 flag: "--aauth-provider",
2950 path: "security.aauth.provider",
2951 kind: AliasKind::Set,
2952 },
2953 Alias {
2954 flag: "--aauth-key-file",
2955 path: "security.aauth.key_file",
2956 kind: AliasKind::Set,
2957 },
2958 Alias {
2959 flag: "--aauth-enroll-token",
2960 path: "security.aauth.enroll_token",
2961 kind: AliasKind::Set,
2962 },
2963 Alias {
2964 flag: "--aauth-enroll-assertion-file",
2965 path: "security.aauth.enroll_assertion_file",
2966 kind: AliasKind::Set,
2967 },
2968 Alias {
2969 flag: "--aauth-person-server",
2970 path: "security.aauth.person_server",
2971 kind: AliasKind::Set,
2972 },
2973 Alias {
2974 flag: "--cgroup",
2975 path: "security.cgroup.spec",
2976 kind: AliasKind::Set,
2977 },
2978 Alias {
2979 flag: "--cgroup-memory-max",
2980 path: "security.cgroup.memory_max",
2981 kind: AliasKind::Set,
2982 },
2983 Alias {
2984 flag: "--cgroup-pids-max",
2985 path: "security.cgroup.pids_max",
2986 kind: AliasKind::Set,
2987 },
2988];
2989
2990pub const ENV_ALIASES: &[(&str, &str)] = &[
2995 ("INSTRUCTION", "agent.instruction"),
2996 ("PROMPT", "agent.prompt"),
2997 ("INTELLIGENCE", "intelligence.endpoints"),
2998 ("INTELLIGENCE_TOKEN", "intelligence.token"),
2999 ("INTELLIGENCE_TOKEN_FILE", "intelligence.token_file"),
3000 ("MODEL", "intelligence.model"),
3001 ("MODEL_SWAP", "intelligence.swap_policy"),
3002 ("BUDGET_TOKENS", "intelligence.budget.lifetime_tokens"),
3003 ("MAX_STEPS", "limits.run.steps"),
3004 ("MAX_TOKENS", "limits.run.tokens"),
3005 ("DEADLINE", "limits.run.deadline"),
3006 ("RUN_ID", "lifecycle.run_id"),
3007 ("DRAIN_TIMEOUT", "lifecycle.drain_timeout"),
3008 ("LOG_LEVEL", "observability.log_level"),
3009 ("LOG_CONTENT", "observability.log_content"),
3010 ("METRICS_ADDR", "observability.metrics_addr"),
3011 ("TRACEPARENT", "observability.traceparent"),
3012 ("SERVE_MCP", "a2a.listen"),
3013 ("SERVE_BEARER", "a2a.bearer"),
3014 ("TLS_CA", "security.tls_ca"),
3015 ("ALLOW_TRIFECTA", "security.allow_trifecta"),
3016 ("WATCH_CONFIG", "lifecycle.watch_config"),
3017];
3018
3019pub const REMOVED_FLAGS: &[(&str, &str)] = &[
3023 (
3024 "--mode",
3025 "modes are gone: give the workflow a start node (`once` | `loop` | `schedule` | `subscribe` | `signal` | `event` | `a2a` | `manual`) and set `lifecycle.run_until` if needed",
3026 ),
3027 (
3028 "--subscribe",
3029 "use a `subscribe` start node: `{kind: subscribe, server: <name>, uri: <uri>}`",
3030 ),
3031 (
3032 "--continue",
3033 "use a `subscribe` start node with `deliver: wait` (or a warm subagent)",
3034 ),
3035 (
3036 "--interval",
3037 "use a `loop` start node with `interval`, or a `schedule` start node with `every`",
3038 ),
3039 ("--cron", "use a `schedule` start node with `cron`"),
3040 (
3045 "--shard",
3046 "agentd does not partition work; give each replica its own subscription (docs/scaling.md)",
3047 ),
3048 (
3049 "--claim",
3050 "call the queue's own claim/lease tools from a workflow step (docs/scaling.md)",
3051 ),
3052 ("--claim-ttl", "it went with --claim"),
3053 ("--claim-renew-fraction", "it went with --claim"),
3054 (
3055 "--standby",
3056 "there is no standby pool; a worker replica is an ordinary instance with its own subscription",
3057 ),
3058 ("--assign-from", "it went with --standby"),
3059 (
3060 "--workflow-resume",
3061 "automatic: runs resume from the store on restart (`resume_policy` per workflow)",
3062 ),
3063 (
3064 "--workflow-resume-force",
3065 "set `resume_policy: force` on the workflow",
3066 ),
3067];
3068
3069#[derive(Debug, Clone)]
3077pub struct Loaded {
3078 pub settings: Settings,
3079 pub doc: Value,
3081 pub file_doc: Value,
3083 pub files: Vec<(String, Format)>,
3084 pub warnings: Vec<String>,
3087}
3088
3089#[derive(Debug, Clone, PartialEq, Eq)]
3092pub enum Ask {
3093 Run,
3094 Help,
3095 Version,
3096 Schema,
3097 WorkflowSchema,
3098 ContextTemplate,
3101 Validate,
3102 Capabilities,
3103 Login(String),
3106 Logout(String),
3108}
3109
3110pub fn probe(args: &[String], env: &[(String, String)]) -> Result<Detected, ConfigError> {
3113 let env = super::debrand_env(env);
3114 let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
3115 let flag_v2 = args
3118 .windows(2)
3119 .any(|w| matches!(w[0].as_str(), "--config-version" | "--config_version") && w[1] == "1")
3120 || args
3121 .iter()
3122 .any(|a| a == "--config-version=1" || a == "--config_version=1")
3123 || envmap
3124 .get("AGENTD_CONFIG_VERSION")
3125 .or_else(|| envmap.get("CONFIG_VERSION"))
3126 .is_some_and(|v| *v == "1");
3127 let paths = super::config_paths_from_map(args, &envmap).paths;
3128 if paths.is_empty() {
3129 return Ok(if flag_v2 {
3130 Detected::V2
3131 } else {
3132 Detected::Empty
3133 });
3134 }
3135 let (doc, _) = file::read_documents_checked(&paths, &|_, _| Ok(())).map_err(usage)?;
3136 let d = detect(&doc);
3137 Ok(match (d, flag_v2) {
3138 (Detected::Empty, true) => Detected::V2,
3139 (Detected::V1, true) => Detected::Mixed,
3140 (d, _) => d,
3141 })
3142}
3143
3144pub fn load(args: &[String], env: &[(String, String)]) -> Result<(Loaded, Ask), ConfigError> {
3149 let env = super::debrand_env(env);
3150 let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
3151 let schema = schema::schema();
3152 let bindings = paths::bindings_of(&schema);
3153 let mut warnings = Vec::new();
3154
3155 let super::ConfigPaths {
3157 paths: config_paths,
3158 discovered,
3159 ambiguous,
3160 } = super::config_paths_from_map(args, &envmap);
3161 if let Some(e) = ambiguous {
3169 return Err(usage(e));
3170 }
3171 let (file_doc, files) = if config_paths.is_empty() {
3172 (Value::Object(Map::new()), Vec::new())
3173 } else {
3174 file::read_documents_checked(&config_paths, &|doc, source| {
3175 match detect(doc) {
3178 Detected::V2 | Detected::Empty => {
3179 Settings::from_document(doc.clone(), source).map(|_| ())
3180 }
3181 _ => Ok(()),
3182 }
3183 })
3184 .map_err(usage)?
3185 };
3186 match detect(&file_doc) {
3187 Detected::Mixed => {
3188 return Err(usage(
3189 "config file mixes legacy flat keys (model/subscribe/mcp_servers/…) with settings sections (agent/intelligence/…); \
3190 migrate the legacy keys (docs/configuration.md §migration)"
3191 .into(),
3192 ));
3193 }
3194 Detected::V1 => {
3195 return Err(usage(
3196 "config file speaks the retired flat schema; the loader needs `config_version: \"1\"` or settings sections (agent/intelligence/…)".into(),
3197 ));
3198 }
3199 _ => {}
3200 }
3201 if discovered {
3214 let file = config_paths.join(", ");
3215 let file = file.as_str();
3216 if let Some((_, label)) = DISCOVERY_FORBIDDEN_RELAXATIONS
3217 .iter()
3218 .find(|(ptr, _)| file_doc.pointer(ptr).and_then(Value::as_bool) == Some(true))
3219 {
3220 return Err(usage(format!(
3221 "{file} was discovered, not named, and it sets {label}: a config found in the \
3222 working directory may not relax a security control. Pass `--config {file}` if \
3223 you meant to run under that file's grant."
3224 )));
3225 }
3226 let touched = discovered_security_settings(&file_doc);
3230 if !touched.is_empty() {
3231 warnings.push(format!(
3232 "adopted the discovered config {file} (no --config given); it sets {}",
3233 touched.join(", ")
3234 ));
3235 }
3236 }
3237 let mut doc = file_doc.clone();
3238
3239 let mut env_doc = Value::Object(Map::new());
3243 for (name, path) in ENV_ALIASES {
3244 let candidates = [
3245 format!("AGENTD_{name}"),
3246 format!("AGENT_{name}"),
3247 (*name).to_string(),
3248 ];
3249 if let Some(raw) = candidates.iter().find_map(|k| envmap.get(k.as_str())) {
3250 let binding = binding_for(&bindings, path)
3251 .ok_or_else(|| usage(format!("internal: alias path {path} not in schema")))?;
3252 let v = binding
3253 .coerce(raw)
3254 .map_err(|e| usage(format!("invalid {}: {e}", candidates[0])))?;
3255 paths::set_path(&mut env_doc, path, v);
3256 }
3257 }
3258 let (derived, _applied) = paths::env_document_in(&bindings, &envmap).map_err(usage)?;
3259 file::merge_into(&mut env_doc, derived);
3260 file::merge_into(&mut doc, env_doc);
3261
3262 let mut ask = Ask::Run;
3264 let mut mcp_tags: Vec<(String, Vec<String>)> = Vec::new();
3265 let mut it = args.iter().peekable();
3266 while let Some(arg) = it.next() {
3267 let a = arg.as_str();
3268 match a {
3269 "-h" | "--help" => ask = Ask::Help,
3270 "-V" | "--version" => ask = Ask::Version,
3271 "--config-schema" | "--config-schema=1" => ask = Ask::Schema,
3272 "--workflow-schema" => ask = Ask::WorkflowSchema,
3273 "--context-template" => ask = Ask::ContextTemplate,
3274 "--validate-config" => ask = Ask::Validate,
3275 "--capabilities" => ask = Ask::Capabilities,
3276 "--login" => {
3277 let t = it
3278 .next()
3279 .cloned()
3280 .ok_or_else(|| usage("--login requires a target (e.g. mcp:<name>)".into()))?;
3281 ask = Ask::Login(t);
3282 }
3283 "--logout" => {
3284 let t = it
3285 .next()
3286 .cloned()
3287 .ok_or_else(|| usage("--logout requires a target (e.g. mcp:<name>)".into()))?;
3288 ask = Ask::Logout(t);
3289 }
3290 "--config" | "-c" => {
3291 it.next(); }
3293 _ if matches!(
3295 crate::config::config_flag(a),
3296 crate::config::ConfigFlag::Inline(_)
3297 ) => {}
3298 _ => {
3299 if let Some((flag, hint)) = REMOVED_FLAGS.iter().find(|(f, _)| *f == a) {
3300 return Err(usage(format!("{flag} was removed in agentd: {hint}")));
3301 }
3302 if let Some(alias) = ALIASES.iter().find(|al| al.flag == a) {
3303 apply_alias(&mut doc, &bindings, alias, &mut it, &mut mcp_tags)?;
3304 continue;
3305 }
3306 match paths::resolve_flag_in(&bindings, a).map_err(usage)? {
3307 Some(target) => {
3308 let raw = if matches!(target.value_kind(), paths::Kind::Boolean)
3309 && !it.peek().is_some_and(|n| !n.starts_with("--"))
3310 {
3311 "true".to_string()
3312 } else {
3313 it.next()
3314 .cloned()
3315 .ok_or_else(|| usage(format!("{a} requires a value")))?
3316 };
3317 let value = paths::coerce(target.value_kind(), &raw)
3318 .map_err(|e| usage(format!("invalid {a}: {e}")))?;
3319 file::merge_into(&mut doc, target.document(value));
3320 }
3321 None => return Err(usage(format!("unknown argument: {a}"))),
3322 }
3323 }
3324 }
3325 }
3326 for (name, tags) in mcp_tags {
3328 let Some(servers) = doc
3329 .pointer_mut("/mcp/servers")
3330 .and_then(Value::as_array_mut)
3331 else {
3332 return Err(usage(format!(
3333 "--mcp-tags references unknown server '{name}'"
3334 )));
3335 };
3336 match servers
3337 .iter_mut()
3338 .find(|s| s.get("name").and_then(Value::as_str) == Some(name.as_str()))
3339 {
3340 Some(s) => {
3341 s["tags"] = json!({ "*": tags });
3342 }
3343 None => {
3344 return Err(usage(format!(
3345 "--mcp-tags references unknown server '{name}'"
3346 )));
3347 }
3348 }
3349 }
3350
3351 apply_default_folders(&mut doc, &config_dirs(&config_paths), &mut warnings);
3357
3358 if ask == Ask::Run || ask == Ask::Validate {
3360 apply_instruction_sugar(&mut doc);
3361 }
3362
3363 if let Err(e) = substitute_env(&mut doc, &envmap) {
3367 return Err(usage(e));
3368 }
3369
3370 let mut settings = Settings::from_document(doc.clone(), "config").map_err(usage)?;
3372 let store_stated =
3403 doc.pointer("/store/kind").is_some() || settings.store.kind != StoreKind::default();
3404 if !store_stated && settings.is_long_lived() {
3405 settings.store.kind = StoreKind::File;
3406 }
3407 let service_errors = resolve_services(&mut settings);
3411 let mut loaded = Loaded {
3412 settings,
3413 doc,
3414 file_doc,
3415 files,
3416 warnings: Vec::new(),
3417 };
3418 if ask == Ask::Run && crate::config::prompt::prompt_missing_requested() {
3424 let mut found = Vec::new();
3425 scan_references(&loaded.doc, "config", &mut found);
3426 let mut names: Vec<String> = found
3427 .into_iter()
3428 .filter(|r| r.kind == "secret" && !crate::sec::secret::secret_available(&r.name))
3429 .map(|r| r.name)
3430 .collect();
3431 names.sort();
3432 names.dedup();
3433 for name in names {
3434 match crate::config::prompt::read_secret_from_tty(&format!("{name} (secret)")) {
3435 Ok(v) => crate::sec::secret::set_prompted(&name, v),
3436 Err(_) => break,
3439 }
3440 }
3441 }
3442 let mut diags = validate(&loaded);
3443 diags.errors.splice(0..0, service_errors);
3444 warnings.extend(diags.warnings);
3445 loaded.warnings = warnings;
3446 if ask != Ask::Validate
3447 && ask != Ask::Help
3448 && ask != Ask::Version
3449 && ask != Ask::Schema
3450 && ask != Ask::WorkflowSchema
3451 && ask != Ask::ContextTemplate
3452 && !matches!(ask, Ask::Login(_) | Ask::Logout(_))
3453 && let Some(first) = diags.errors.first()
3454 {
3455 let msg = if diags.errors.len() == 1 {
3460 first.clone()
3461 } else {
3462 format!(
3463 "{} configuration errors:\n - {}",
3464 diags.errors.len(),
3465 diags.errors.join("\n - ")
3466 )
3467 };
3468 return Err(usage(msg));
3469 }
3470 if ask == Ask::Validate && !diags.errors.is_empty() {
3471 return Err(ConfigError::Validate(Err(diags
3472 .errors
3473 .iter()
3474 .map(|d| super::config_invalid_line(d))
3475 .collect::<Vec<_>>()
3476 .join("\n"))));
3477 }
3478 Ok((loaded, ask))
3479}
3480
3481const DISCOVERY_FORBIDDEN_RELAXATIONS: [(&str, &str); 2] = [
3489 ("/security/allow_trifecta", "security.allow_trifecta"),
3490 ("/security/exec/enabled", "security.exec.enabled"),
3491];
3492
3493const DISCOVERY_SECURITY_SETTINGS: [(&str, &str); 12] = [
3500 ("/intelligence/endpoints", "intelligence.endpoints"),
3501 ("/intelligence/token", "intelligence.token"),
3502 ("/intelligence/token_file", "intelligence.token_file"),
3503 ("/intelligence/headers", "intelligence.headers"),
3504 ("/intelligence/auth", "intelligence.auth"),
3505 ("/mcp/servers", "mcp.servers"),
3506 ("/tools/overrides", "tools.overrides"),
3507 ("/store", "store"),
3508 ("/a2a/listen", "a2a.listen"),
3509 ("/a2a/peers", "a2a.peers"),
3510 ("/webhooks/listen", "webhooks.listen"),
3511 ("/security", "security"),
3512];
3513
3514fn discovered_security_settings(file_doc: &Value) -> Vec<&'static str> {
3518 DISCOVERY_SECURITY_SETTINGS
3519 .iter()
3520 .filter(|(ptr, _)| file_doc.pointer(ptr).is_some_and(|v| !v.is_null()))
3521 .map(|(_, label)| *label)
3522 .collect()
3523}
3524
3525fn binding_for<'a>(bindings: &'a [Binding], path: &str) -> Option<&'a Binding> {
3526 bindings.iter().find(|b| b.path == path)
3527}
3528
3529fn apply_alias(
3530 doc: &mut Value,
3531 bindings: &[Binding],
3532 alias: &Alias,
3533 it: &mut std::iter::Peekable<std::slice::Iter<'_, String>>,
3534 mcp_tags: &mut Vec<(String, Vec<String>)>,
3535) -> Result<(), ConfigError> {
3536 let mut take = || -> Result<String, ConfigError> {
3537 it.next()
3538 .cloned()
3539 .ok_or_else(|| usage(format!("{} requires a value", alias.flag)))
3540 };
3541 match alias.kind {
3542 AliasKind::Set => {
3543 let raw = take()?;
3544 let b = binding_for(bindings, alias.path).ok_or_else(|| {
3545 usage(format!("internal: alias path {} not in schema", alias.path))
3546 })?;
3547 let v = b
3548 .coerce(&raw)
3549 .map_err(|e| usage(format!("invalid {}: {e}", alias.flag)))?;
3550 let mut patch = Value::Object(Map::new());
3551 paths::set_path(&mut patch, alias.path, v);
3552 file::merge_into(doc, patch);
3553 }
3554 AliasKind::SetTrue => {
3555 let mut patch = Value::Object(Map::new());
3556 paths::set_path(&mut patch, alias.path, Value::Bool(true));
3557 file::merge_into(doc, patch);
3558 }
3559 AliasKind::SetFromFile => {
3560 let path = take()?;
3561 let text = super::read_file(&path)?;
3562 let mut patch = Value::Object(Map::new());
3563 paths::set_path(&mut patch, alias.path, Value::String(text));
3564 file::merge_into(doc, patch);
3565 }
3566 AliasKind::Append => {
3567 let raw = take()?;
3568 let element = match alias.flag {
3569 "--mcp" => {
3570 let (name, endpoint) = raw
3571 .split_once('=')
3572 .ok_or_else(|| usage(format!("--mcp: want name=endpoint (got: {raw})")))?;
3573 json!({ "name": name.trim(), "endpoint": endpoint.trim() })
3574 }
3575 "--a2a-peer" => {
3576 let (name, endpoint) = raw.split_once('=').ok_or_else(|| {
3577 usage(format!("--a2a-peer: want name=endpoint (got: {raw})"))
3578 })?;
3579 json!({ "name": name.trim(), "endpoint": endpoint.trim() })
3580 }
3581 "--workflow" => {
3582 let name = std::path::Path::new(&raw)
3583 .file_stem()
3584 .and_then(|s| s.to_str())
3585 .unwrap_or("workflow")
3586 .to_string();
3587 json!({ "name": name, "file": raw })
3588 }
3589 other => return Err(usage(format!("internal: no append rule for {other}"))),
3590 };
3591 append_at(doc, alias.path, element);
3592 }
3593 AliasKind::Special => match alias.flag {
3594 "--mcp-tags" => {
3595 let raw = take()?;
3596 let (name, tags) = raw
3597 .split_once('=')
3598 .ok_or_else(|| usage(format!("--mcp-tags: want name=tag,tag (got: {raw})")))?;
3599 mcp_tags.push((
3600 name.trim().to_string(),
3601 tags.split(',')
3602 .map(str::trim)
3603 .filter(|t| !t.is_empty())
3604 .map(str::to_string)
3605 .collect(),
3606 ));
3607 }
3608 "--budget-exit-code" => {
3609 let raw = take()?;
3610 let n: i64 = raw
3611 .trim()
3612 .parse()
3613 .ok()
3614 .filter(|n| (0..=255).contains(n))
3615 .ok_or_else(|| {
3616 usage(format!("invalid --budget-exit-code: {raw} (want 0..=255)"))
3617 })?;
3618 let mut patch = Value::Object(Map::new());
3619 paths::set_path(
3620 &mut patch,
3621 "lifecycle.exit_code_map",
3622 json!({ "3": n, "7": n }),
3623 );
3624 file::merge_into(doc, patch);
3625 }
3626 other => return Err(usage(format!("internal: no special rule for {other}"))),
3627 },
3628 }
3629 Ok(())
3630}
3631
3632fn append_at(doc: &mut Value, path: &str, element: Value) {
3634 let pointer = format!("/{}", path.replace('.', "/"));
3635 if doc.pointer(&pointer).is_none() {
3636 let mut patch = Value::Object(Map::new());
3637 paths::set_path(&mut patch, path, Value::Array(Vec::new()));
3638 file::merge_into(doc, patch);
3639 }
3640 if let Some(arr) = doc.pointer_mut(&pointer) {
3641 if !arr.is_array() {
3642 *arr = Value::Array(Vec::new());
3643 }
3644 arr.as_array_mut().expect("array").push(element);
3645 }
3646}
3647
3648fn config_dirs(paths: &[String]) -> Vec<PathBuf> {
3667 if paths.is_empty() {
3668 return vec![PathBuf::from(".")];
3669 }
3670 let mut out: Vec<PathBuf> = Vec::new();
3671 for p in paths.iter().rev() {
3672 let d = match Path::new(p).parent() {
3678 Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
3679 _ => PathBuf::from("."),
3680 };
3681 if !out.contains(&d) {
3682 out.push(d);
3683 }
3684 }
3685 out
3686}
3687
3688fn folder_files(dir: &Path, exts: &[&str]) -> Vec<PathBuf> {
3692 let Ok(rd) = std::fs::read_dir(dir) else {
3693 return Vec::new();
3694 };
3695 let mut out: Vec<PathBuf> = rd
3696 .flatten()
3697 .map(|e| e.path())
3698 .filter(|p| p.is_file())
3699 .filter(|p| {
3700 p.extension()
3701 .and_then(|e| e.to_str())
3702 .is_some_and(|e| exts.contains(&e))
3703 })
3704 .collect();
3705 out.sort();
3706 out
3707}
3708
3709fn apply_default_folders(doc: &mut Value, dirs: &[PathBuf], warnings: &mut Vec<String>) {
3726 let Some(obj) = doc.as_object_mut() else {
3727 return;
3728 };
3729
3730 fn find(dirs: &[PathBuf], name: &str, has: impl Fn(&Path) -> bool) -> Option<PathBuf> {
3732 dirs.iter().map(|d| d.join(name)).find(|p| has(p))
3733 }
3734
3735 if !obj.contains_key("workflows")
3738 && let Some(d) = find(dirs, "workflows", |p| {
3739 !folder_files(p, &["yaml", "yml", "json"]).is_empty()
3740 })
3741 {
3742 obj.insert(
3743 "workflows".into(),
3744 json!([{"dir": d.to_string_lossy(), "glob": "*.yaml,*.yml,*.json"}]),
3745 );
3746 }
3747
3748 if obj.get("skills").and_then(|s| s.get("dir")).is_none()
3751 && let Some(d) = find(dirs, "skills", |p| {
3752 !folder_files(p, &["md"]).is_empty()
3753 || std::fs::read_dir(p)
3754 .is_ok_and(|rd| rd.flatten().any(|e| e.path().join("SKILL.md").is_file()))
3755 })
3756 && let Some(sk) = obj
3757 .entry("skills")
3758 .or_insert_with(|| json!({}))
3759 .as_object_mut()
3760 {
3761 sk.insert("dir".into(), json!(d.to_string_lossy()));
3762 }
3763
3764 if obj
3766 .get("subagents")
3767 .and_then(|s| s.get("templates"))
3768 .is_none()
3769 && let Some(d) = find(dirs, "subagents", |p| {
3770 !folder_files(p, &["yaml", "yml", "json"]).is_empty()
3771 })
3772 {
3773 let mut templates = Map::new();
3774 for path in folder_files(&d, &["yaml", "yml", "json"]) {
3775 let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
3776 continue;
3777 };
3778 match file::read_document(&path.to_string_lossy()) {
3779 Ok((v, _)) => {
3780 templates.insert(name.to_string(), v);
3781 }
3782 Err(e) => warnings.push(format!("subagents template {}: {e}", path.display())),
3783 }
3784 }
3785 if !templates.is_empty()
3786 && let Some(sub) = obj
3787 .entry("subagents")
3788 .or_insert_with(|| json!({}))
3789 .as_object_mut()
3790 {
3791 sub.insert("templates".into(), Value::Object(templates));
3792 }
3793 }
3794
3795 if obj
3799 .get("context")
3800 .and_then(|c| c.get("templates"))
3801 .is_none()
3802 && let Some(d) = find(dirs, "context", |p| {
3803 !folder_files(p, &["md", "txt", "hbs"]).is_empty()
3804 })
3805 {
3806 let mut templates = Map::new();
3807 for path in folder_files(&d, &["md", "txt", "hbs"]) {
3808 let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
3809 continue;
3810 };
3811 match std::fs::read_to_string(&path) {
3812 Ok(text) => {
3813 templates.insert(name.to_string(), Value::String(text));
3814 }
3815 Err(e) => warnings.push(format!("context template {}: {e}", path.display())),
3816 }
3817 }
3818 if !templates.is_empty()
3819 && let Some(ctx) = obj
3820 .entry("context")
3821 .or_insert_with(|| json!({}))
3822 .as_object_mut()
3823 {
3824 ctx.insert("templates".into(), Value::Object(templates));
3825 }
3826 }
3827}
3828
3829fn apply_instruction_sugar(doc: &mut Value) {
3839 let has_workflows = doc
3840 .pointer("/workflows")
3841 .and_then(Value::as_array)
3842 .is_some_and(|w| !w.is_empty());
3843 let nonblank = |p: &str| {
3844 doc.pointer(p)
3845 .and_then(Value::as_str)
3846 .is_some_and(|s| !s.trim().is_empty())
3847 };
3848 let has_instruction = nonblank("/agent/instruction");
3849 let carries_workflow = doc
3854 .pointer("/agent/instruction")
3855 .and_then(Value::as_str)
3856 .is_some_and(|t| t.lines().any(|l| l.starts_with(":::workflow")));
3857 if has_workflows || carries_workflow || !has_instruction || nonblank("/agent/prompt") {
3860 return;
3861 }
3862 let work = json!({
3863 "kind": "agent",
3864 "depends_on": ["start"],
3865 "instruction": "{{env.instruction}}",
3866 });
3867 let mut patch = Value::Object(Map::new());
3868 paths::set_path(
3869 &mut patch,
3870 "workflows",
3871 json!([{
3872 "name": "main",
3873 "version": 3,
3874 "steps": {
3875 "start": { "kind": "once" },
3876 "work": work,
3877 "done": { "kind": "finish", "depends_on": ["work"], "status": "completed", "output": "{{steps.work.output}}" }
3878 }
3879 }]),
3880 );
3881 file::merge_into(doc, patch);
3882}
3883
3884fn substitute_env(v: &mut Value, env: &HashMap<&str, &str>) -> Result<(), String> {
3894 match v {
3895 Value::String(s) => {
3896 if s.as_bytes().contains(&b'$') {
3897 *s = expand_env_str(s, env)?;
3898 }
3899 Ok(())
3900 }
3901 Value::Array(a) => a.iter_mut().try_for_each(|item| substitute_env(item, env)),
3902 Value::Object(m) => m.values_mut().try_for_each(|val| substitute_env(val, env)),
3903 _ => Ok(()),
3904 }
3905}
3906
3907fn expand_env_str(s: &str, env: &HashMap<&str, &str>) -> Result<String, String> {
3909 let mut out = String::with_capacity(s.len());
3910 let b = s.as_bytes();
3911 let mut i = 0;
3912 while i < b.len() {
3913 if b[i] == b'$' {
3917 if b.get(i + 1) == Some(&b'$') {
3918 out.push('$'); i += 2;
3920 continue;
3921 }
3922 if b.get(i + 1) == Some(&b'{') {
3923 let start = i + 2;
3924 let Some(rel) = s[start..].find('}') else {
3925 return Err(format!("unterminated `${{` in config value {s:?}"));
3926 };
3927 let end = start + rel;
3928 let expr = &s[start..end];
3929 let (name, default) = match expr.split_once(":-") {
3930 Some((n, d)) => (n.trim(), Some(d)),
3931 None => (expr.trim(), None),
3932 };
3933 if name.is_empty() {
3934 return Err(format!("empty `${{}}` reference in config value {s:?}"));
3935 }
3936 if !name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') {
3937 return Err(format!(
3938 "invalid environment variable name {name:?} in `${{{expr}}}`"
3939 ));
3940 }
3941 match env.get(name) {
3942 Some(val) => out.push_str(val),
3943 None => match default {
3944 Some(d) => out.push_str(d),
3945 None => {
3946 return Err(format!(
3947 "environment variable ${{{name}}} is not set (referenced in config); \
3948 set it or write ${{{name}:-default}}"
3949 ));
3950 }
3951 },
3952 }
3953 i = end + 1;
3954 continue;
3955 }
3956 }
3957 let ch = s[i..].chars().next().unwrap();
3958 out.push(ch);
3959 i += ch.len_utf8();
3960 }
3961 Ok(out)
3962}
3963
3964#[derive(Debug, Default, Clone)]
3971pub struct Diagnostics {
3972 pub errors: Vec<String>,
3973 pub warnings: Vec<String>,
3974}
3975
3976fn validate_auth_block(auth: &Auth, ctx: &str) -> Vec<String> {
3982 let mut out = Vec::new();
3983 for (field, s) in [
3985 ("client_secret", &auth.client_secret),
3986 ("token", &auth.token),
3987 ("value", &auth.value),
3988 ] {
3989 if let Some(sec) = s
3990 && !sec.0.trim().is_empty()
3991 && !crate::sec::secret::has_secret_ref(&sec.0)
3992 {
3993 out.push(format!(
3994 "{ctx}: auth.{field} carries an inline credential; use a {{{{secret:…}}}} reference"
3995 ));
3996 }
3997 }
3998 match auth.kind {
3999 AuthKind::Static => {
4000 let has_bearer = auth.token.is_some();
4001 let has_header = auth.header.is_some() && auth.value.is_some();
4002 if !has_bearer && !has_header {
4003 out.push(format!(
4004 "{ctx}: auth.kind static needs `token` (a bearer) or `header` + `value`"
4005 ));
4006 }
4007 }
4008 AuthKind::Aws => {
4009 if auth.region.is_none() {
4010 out.push(format!("{ctx}: auth.kind aws needs `region`"));
4011 }
4012 if auth.service.is_none() {
4013 out.push(format!(
4014 "{ctx}: auth.kind aws needs `service` (e.g. bedrock, execute-api)"
4015 ));
4016 }
4017 match auth.source.as_deref() {
4018 Some("sso") => {
4019 if auth.sso_start_url.is_none()
4020 || auth.account_id.is_none()
4021 || auth.role_name.is_none()
4022 {
4023 out.push(format!(
4024 "{ctx}: aws source sso needs `sso_start_url` + `account_id` + `role_name`"
4025 ));
4026 }
4027 }
4028 Some(src) if !matches!(src, "env" | "static" | "imds" | "irsa") => {
4029 out.push(format!(
4030 "{ctx}: auth.source '{src}' is not a known AWS source (env|static|imds|irsa|sso)"
4031 ));
4032 }
4033 _ => {}
4034 }
4035 }
4036 AuthKind::Spiffe => match auth.svid.as_deref().unwrap_or("jwt") {
4037 "jwt" => {
4038 if auth.jwt_svid_file.is_none() {
4039 out.push(format!(
4040 "{ctx}: auth.kind spiffe (svid jwt) needs `jwt_svid_file`"
4041 ));
4042 }
4043 }
4044 "x509" => {
4045 if auth.svid_file.is_none() || auth.key_file.is_none() {
4046 out.push(format!(
4047 "{ctx}: auth.kind spiffe (svid x509) needs `svid_file` + `key_file`"
4048 ));
4049 }
4050 }
4051 other => out.push(format!("{ctx}: auth.svid '{other}' (want jwt|x509)")),
4052 },
4053 AuthKind::Oauth2 => {
4054 if auth.client_id.is_none() {
4055 out.push(format!("{ctx}: auth.kind oauth2 needs `client_id`"));
4056 }
4057 if auth.token_url.is_none() && auth.issuer.is_none() {
4058 out.push(format!(
4059 "{ctx}: auth oauth2 needs `token_url` or `issuer` (for discovery)"
4060 ));
4061 }
4062 match auth.grant.unwrap_or(OAuthGrant::Device) {
4063 OAuthGrant::Device => {
4064 if auth.device_authorization_url.is_none() && auth.issuer.is_none() {
4065 out.push(format!(
4066 "{ctx}: the device grant needs `device_authorization_url` or `issuer`"
4067 ));
4068 }
4069 }
4070 OAuthGrant::ClientCredentials => {
4071 if auth.client_secret.is_none() {
4072 out.push(format!(
4073 "{ctx}: the client_credentials grant needs `client_secret`"
4074 ));
4075 }
4076 }
4077 OAuthGrant::AuthorizationCode => {
4078 if auth.authorization_url.is_none() && auth.issuer.is_none() {
4079 out.push(format!(
4080 "{ctx}: the authorization_code grant needs `authorization_url` or `issuer`"
4081 ));
4082 }
4083 }
4084 }
4085 }
4086 }
4087 out
4088}
4089
4090fn unresolved_secret_ref(value: &str) -> Option<String> {
4101 if !crate::sec::secret::has_secret_ref(value) {
4102 return None;
4103 }
4104 crate::sec::secret::refs_resolvable(value, &|k| {
4107 crate::sec::secret::prompted_of(k).or_else(|| std::env::var(k).ok())
4108 })
4109 .err()
4110}
4111
4112pub fn validate(loaded: &Loaded) -> Diagnostics {
4113 let s = &loaded.settings;
4114 let mut d = Diagnostics::default();
4115 let err = |d: &mut Diagnostics, m: String| d.errors.push(m);
4116
4117 for m in missing_references(&loaded.doc, "config", &s.vars) {
4131 err(&mut d, m);
4132 }
4133
4134 for (at, algo) in hmac_algos(&loaded.doc, "config") {
4139 if !algo.eq_ignore_ascii_case("sha256") {
4140 err(
4141 &mut d,
4142 format!(
4143 "{at} {algo:?} is not implemented — agentd computes HMAC-SHA256 only; use `algo: sha256` (or omit it) and have senders sign SHA-256"
4144 ),
4145 );
4146 }
4147 }
4148
4149 if let Some(v) = &s.config_version
4151 && v != schema::CONFIG_VERSION
4152 {
4153 err(
4154 &mut d,
4155 format!(
4156 "config_version must be \"{}\" (got {v:?})",
4157 schema::CONFIG_VERSION
4158 ),
4159 );
4160 }
4161
4162 if let Some(re) = &s.observability.runtime_events {
4167 match re.stream.as_deref() {
4168 None => err(
4169 &mut d,
4170 "observability.runtime_events: `stream` is required".into(),
4171 ),
4172 Some(name) if !s.streams.contains_key(name) => err(
4173 &mut d,
4174 format!(
4175 "observability.runtime_events.stream: {name:?} is not declared (add it under `streams:`)"
4176 ),
4177 ),
4178 Some(_) => {}
4179 }
4180 if re.include.is_empty() && re.sampled.is_empty() {
4181 err(
4182 &mut d,
4183 "observability.runtime_events: name at least one family in `include` or `sampled`"
4184 .into(),
4185 );
4186 }
4187 for f in re.include.iter().chain(re.sampled.iter()) {
4188 if !crate::obs::log::EVENT_FAMILIES.contains(&f.as_str()) {
4189 err(
4190 &mut d,
4191 format!(
4192 "observability.runtime_events: unknown event family {f:?} (known: {})",
4193 crate::obs::log::EVENT_FAMILIES.join(", ")
4194 ),
4195 );
4196 }
4197 }
4198 for f in &re.sampled {
4199 if re.include.contains(f) {
4200 err(
4201 &mut d,
4202 format!(
4203 "observability.runtime_events: family {f:?} is in both `include` and `sampled` — pick one"
4204 ),
4205 );
4206 }
4207 }
4208 }
4209 if let Some(sinks) = &s.observability.audit.sink
4210 && sinks.iter().any(|x| matches!(x, AuditSink::Stream))
4211 {
4212 match s.observability.audit.stream.as_deref() {
4213 None => err(
4214 &mut d,
4215 "observability.audit: `sink: [stream]` needs `stream: <name>`".into(),
4216 ),
4217 Some(name) if !s.streams.contains_key(name) => err(
4218 &mut d,
4219 format!(
4220 "observability.audit.stream: {name:?} is not declared (add it under `streams:`)"
4221 ),
4222 ),
4223 Some(_) => {}
4224 }
4225 }
4226
4227 for (name, t) in &s.intelligence.models {
4231 let at = format!("intelligence.models.{name}");
4232 if t.model.as_deref().unwrap_or("").trim().is_empty() {
4233 err(&mut d, format!("{at}: `model` is required"));
4234 }
4235 if let Some(svc) = &t.service {
4236 match s.services.get(svc) {
4237 None => err(
4238 &mut d,
4239 format!("{at}.service: {svc:?} is not declared (add it under `services:`)"),
4240 ),
4241 Some(entry) if entry.kind != ServiceKind::Intelligence => err(
4242 &mut d,
4243 format!(
4244 "{at}.service: {svc:?} is `kind: {}` — a model tier needs `kind: intelligence`",
4245 entry.kind.as_str()
4246 ),
4247 ),
4248 Some(_) => {}
4249 }
4250 }
4251 if let Some(f) = &t.fallback {
4252 if !s.intelligence.models.contains_key(f) {
4253 err(&mut d, format!("{at}.fallback: no model tier named {f:?}"));
4254 } else if f == name {
4255 err(
4256 &mut d,
4257 format!("{at}.fallback: a tier cannot fall back to itself"),
4258 );
4259 }
4260 }
4261 }
4262 for name in s.intelligence.models.keys() {
4265 let mut seen = vec![name.clone()];
4266 let mut cur = name.clone();
4267 while let Some(next) = s
4268 .intelligence
4269 .models
4270 .get(&cur)
4271 .and_then(|t| t.fallback.clone())
4272 {
4273 if seen.contains(&next) {
4274 err(
4275 &mut d,
4276 format!(
4277 "intelligence.models: fallback cycle {} -> {next}",
4278 seen.join(" -> ")
4279 ),
4280 );
4281 break;
4282 }
4283 seen.push(next.clone());
4284 cur = next;
4285 }
4286 }
4287 for (field, reference) in [
4288 ("intelligence.default", s.intelligence.default.as_ref()),
4289 (
4290 "intelligence.preflight_model",
4291 s.intelligence.preflight_model.as_ref(),
4292 ),
4293 (
4294 "context.summarize.model",
4295 s.context.summarize.model.as_ref(),
4296 ),
4297 ] {
4298 if let Some(r) = reference
4302 && !s.intelligence.models.is_empty()
4303 && !s.intelligence.models.contains_key(r)
4304 {
4305 err(
4306 &mut d,
4307 format!(
4308 "{field}: {r:?} is not a declared model tier (known: {})",
4309 s.intelligence
4310 .models
4311 .keys()
4312 .cloned()
4313 .collect::<Vec<_>>()
4314 .join(", ")
4315 ),
4316 );
4317 }
4318 }
4319
4320 for (i, p) in s.a2a.principals.iter().enumerate() {
4324 let Some(q) = &p.quotas else { continue };
4325 if let Some(r) = &q.rate
4326 && let Err(e) = crate::supervisor::tree::parse_rate(r)
4327 {
4328 err(&mut d, format!("a2a.principals[{i}].quotas.rate: {e}"));
4329 }
4330 if let Some(b) = &q.budget {
4331 validate_budget(b, &format!("a2a.principals[{i}].quotas.budget"), &mut d);
4332 }
4333 }
4334
4335 for (i, p) in s.security.policies.iter().enumerate() {
4338 let at = format!("security.policies[{i}]");
4339 if let Some(expr) = &p.matcher.args {
4340 if !cfg!(feature = "cel") {
4341 err(
4342 &mut d,
4343 format!(
4344 "{at}: `match.args` needs the `cel` feature; this build cannot evaluate an \
4345 argument guard, and silently treating it as no-match would turn a deny \
4346 into an allow"
4347 ),
4348 );
4349 } else if let Err(e) =
4350 crate::cel::compile_check(expr.trim().trim_start_matches("CEL:").trim())
4351 {
4352 err(&mut d, format!("{at}: match.args: {e}"));
4353 }
4354 }
4355 for t in &p.matcher.tags {
4356 if !["untrusted_input", "sensitive", "egress"].contains(&t.as_str()) {
4357 err(
4358 &mut d,
4359 format!("{at}: unknown tag {t:?} (want untrusted_input|sensitive|egress)"),
4360 );
4361 }
4362 }
4363 if p.action != PolicyAction::Ask && (p.question.is_some() || p.on_timeout.is_some()) {
4364 err(
4365 &mut d,
4366 format!("{at}: `question`/`on_timeout` apply to `action: ask`"),
4367 );
4368 }
4369 if p.on_timeout == Some(PolicyAction::Ask) {
4370 err(
4371 &mut d,
4372 format!("{at}: `on_timeout: ask` would ask again forever"),
4373 );
4374 }
4375 }
4376
4377 for e in &s.intelligence.endpoints {
4379 if let Err(e) = super::validate_intelligence_uri(e) {
4380 err(&mut d, e.to_string());
4381 }
4382 }
4383 if let Some(p) = &s.intelligence.swap_policy
4384 && super::SwapPolicy::parse(p).is_none()
4385 {
4386 err(
4387 &mut d,
4388 format!("intelligence.swap_policy: {p:?} (want finish-on-old|restart-turn)"),
4389 );
4390 }
4391 if s.intelligence.token.is_some() && s.intelligence.token_file.is_some() {
4392 d.warnings.push(
4393 "intelligence.token and intelligence.token_file are both set; the inline token wins"
4394 .into(),
4395 );
4396 }
4397 if let Some(auth) = &s.intelligence.auth {
4398 for e in validate_auth_block(auth, "intelligence") {
4399 err(&mut d, e);
4400 }
4401 }
4402 if let Some(dialect) = &s.intelligence.dialect {
4403 if crate::intel::client::Provider::from_dialect(Some(dialect)).is_none() {
4404 err(
4405 &mut d,
4406 format!("intelligence.dialect: {dialect:?} (want openai|anthropic|bedrock)"),
4407 );
4408 }
4409 if dialect == "bedrock"
4412 && !matches!(
4413 s.intelligence.auth.as_ref().map(|a| a.kind),
4414 Some(AuthKind::Aws)
4415 )
4416 {
4417 err(
4418 &mut d,
4419 "intelligence.dialect: bedrock requires intelligence.auth.kind = aws (SigV4)"
4420 .into(),
4421 );
4422 }
4423 }
4424 validate_budget(&s.intelligence.budget, "intelligence.budget", &mut d);
4425 if let Some(b) = &s.agent.conversation_budget {
4426 validate_budget(b, "agent.conversation_budget", &mut d);
4427 }
4428 for (name, value) in &s.intelligence.headers {
4429 if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
4430 err(
4431 &mut d,
4432 format!(
4433 "intelligence.headers['{name}'] looks like a credential but has an inline value; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}}"
4434 ),
4435 );
4436 } else if let Some(e) = unresolved_secret_ref(value) {
4437 err(&mut d, format!("intelligence.headers['{name}']: {e}"));
4438 }
4439 }
4440
4441 let mut names = std::collections::HashSet::new();
4443 for srv in &s.mcp.servers {
4444 if srv.name.trim().is_empty() {
4445 err(&mut d, "mcp.servers[]: a server has an empty name".into());
4446 }
4447 if !names.insert(srv.name.as_str()) {
4448 err(
4449 &mut d,
4450 format!("mcp.servers[]: duplicate server name '{}'", srv.name),
4451 );
4452 }
4453 if srv.name == "code" {
4454 err(
4455 &mut d,
4456 "mcp.servers[]: the server name 'code' is reserved for code-registered tools"
4457 .into(),
4458 );
4459 }
4460 if srv.endpoint.is_empty() {
4461 if srv.service.is_none() {
4464 err(
4465 &mut d,
4466 format!(
4467 "mcp server '{}' needs an `endpoint` or a `service:` catalog reference",
4468 srv.name
4469 ),
4470 );
4471 }
4472 } else if let Err(e) = super::mcp_endpoint_scheme_ok(&srv.endpoint) {
4473 err(&mut d, format!("mcp server '{}': {e}", srv.name));
4474 }
4475 if let Err(e) = srv.tag_set() {
4476 err(&mut d, e);
4477 }
4478 for (h, v) in &srv.headers {
4479 if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
4480 err(
4481 &mut d,
4482 format!(
4483 "mcp server '{}' header '{h}' looks like a credential but has an inline value; use a {{{{secret:…}}}} reference",
4484 srv.name
4485 ),
4486 );
4487 } else if let Some(e) = unresolved_secret_ref(v) {
4488 err(
4489 &mut d,
4490 format!("mcp server '{}' header '{h}': {e}", srv.name),
4491 );
4492 }
4493 }
4494 if let Some(auth) = &srv.auth {
4495 for e in validate_auth_block(auth, &format!("mcp server '{}'", srv.name)) {
4496 err(&mut d, e);
4497 }
4498 }
4499 }
4500 for (name, svc) in &s.services {
4502 if name.is_empty()
4503 || !name
4504 .chars()
4505 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
4506 {
4507 err(
4508 &mut d,
4509 format!("services: entry name '{name}' must be [a-zA-Z0-9_-]+"),
4510 );
4511 }
4512 match svc.kind {
4515 ServiceKind::Peer => {
4516 if let Err(e) = crate::config::A2aEndpoint::parse(&svc.endpoint) {
4517 err(&mut d, format!("services.{name}: {e}"));
4518 }
4519 }
4520 _ => {
4521 if let Err(e) = super::mcp_endpoint_scheme_ok(&svc.endpoint) {
4522 err(&mut d, format!("services.{name}: {e}"));
4523 }
4524 }
4525 }
4526 if svc.kind != ServiceKind::Mcp {
4529 for (set, what) in [
4530 (svc.allow.is_some(), "allow"),
4531 (!svc.exclude.is_empty(), "exclude"),
4532 (!svc.tags.is_empty(), "tags"),
4533 (svc.breaker.is_some(), "breaker"),
4534 ] {
4535 if set {
4536 err(
4537 &mut d,
4538 format!(
4539 "services.{name}: `{what}` applies to `kind: mcp` entries only (this entry is `kind: {}`)",
4540 svc.kind.as_str()
4541 ),
4542 );
4543 }
4544 }
4545 }
4546 if svc.methods.is_some() && svc.kind != ServiceKind::Http {
4547 err(
4548 &mut d,
4549 format!(
4550 "services.{name}: `methods` applies to `kind: http` entries only (this entry is `kind: {}`)",
4551 svc.kind.as_str()
4552 ),
4553 );
4554 }
4555 if let Some(ms) = &svc.methods {
4556 for m in ms {
4557 if !matches!(
4558 m.as_str(),
4559 "GET" | "PUT" | "POST" | "DELETE" | "PATCH" | "HEAD"
4560 ) {
4561 err(
4562 &mut d,
4563 format!(
4564 "services.{name}.methods: unknown method '{m}' (want GET|PUT|POST|DELETE|PATCH|HEAD, uppercase)"
4565 ),
4566 );
4567 }
4568 }
4569 }
4570 if let Some(b) = &svc.breaker
4571 && crate::runtime::breaker::Config::of(Some(b)).is_none()
4572 {
4573 err(
4574 &mut d,
4575 format!(
4576 "services.{name}.breaker: want {{failures: N>=1, cooldown: \"60s\"}} — both fields required"
4577 ),
4578 );
4579 }
4580 for list in svc.tags.values() {
4581 for t in list {
4582 if crate::sec::scope::TrifectaTag::parse(t).is_none() {
4583 err(
4584 &mut d,
4585 format!("services.{name} has unknown trifecta tag '{t}'"),
4586 );
4587 }
4588 }
4589 }
4590 for (h, v) in &svc.headers {
4591 if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
4592 err(
4593 &mut d,
4594 format!(
4595 "services.{name} header '{h}' looks like a credential but has an inline value; use a {{{{secret:…}}}} reference"
4596 ),
4597 );
4598 } else if let Some(e) = unresolved_secret_ref(v) {
4599 err(&mut d, format!("services.{name} header '{h}': {e}"));
4600 }
4601 }
4602 if let Some(auth) = &svc.auth {
4603 for e in validate_auth_block(auth, &format!("services.{name}")) {
4604 err(&mut d, e);
4605 }
4606 }
4607 if let Some(r) = &svc.rate
4608 && let Err(e) = crate::supervisor::tree::parse_rate(r)
4609 {
4610 err(&mut d, format!("services.{name}.rate: {e}"));
4611 }
4612 }
4613 {
4617 let entries: Vec<(&String, &Service)> = s.services.iter().collect();
4618 for i in 0..entries.len() {
4619 for j in (i + 1)..entries.len() {
4620 if entries[i].1.kind != entries[j].1.kind {
4621 continue;
4622 }
4623 let kind = entries[i].1.kind;
4624 let one = BTreeMap::from([(entries[i].0.clone(), entries[i].1.clone())]);
4625 let other = BTreeMap::from([(entries[j].0.clone(), entries[j].1.clone())]);
4626 if service_match(&one, kind, &entries[j].1.endpoint).is_some()
4627 || service_match(&other, kind, &entries[i].1.endpoint).is_some()
4628 {
4629 err(
4630 &mut d,
4631 format!(
4632 "services.{} and services.{} have prefix-comparable endpoints of the same kind — URL matching must be unambiguous",
4633 entries[i].0, entries[j].0
4634 ),
4635 );
4636 }
4637 }
4638 }
4639 }
4640 if s.security.egress == Egress::Closed {
4643 let closed = |d: &mut Diagnostics, kind: ServiceKind, what: &str, url: &str| {
4644 if service_match(&s.services, kind, url).is_none() {
4645 d.errors.push(format!(
4646 "security.egress is closed and {what} ({url}) matches no `kind: {}` services: catalog entry — catalog the endpoint to allow it",
4647 kind.as_str()
4648 ));
4649 }
4650 };
4651 for srv in &s.mcp.servers {
4652 if !srv.endpoint.is_empty() {
4653 closed(
4654 &mut d,
4655 ServiceKind::Mcp,
4656 &format!("mcp server '{}'", srv.name),
4657 &srv.endpoint,
4658 );
4659 }
4660 }
4661 for e in &s.intelligence.endpoints {
4662 if !e.starts_with("mock:") {
4664 closed(
4665 &mut d,
4666 ServiceKind::Intelligence,
4667 "intelligence endpoint",
4668 e,
4669 );
4670 }
4671 }
4672 for p in &s.a2a.peers {
4673 if !p.endpoint.is_empty() {
4674 closed(
4675 &mut d,
4676 ServiceKind::Peer,
4677 &format!("a2a peer '{}'", p.name),
4678 &p.endpoint,
4679 );
4680 }
4681 }
4682 if s.store.kind == StoreKind::Http
4683 && let Some(h) = &s.store.http
4684 {
4685 closed(
4686 &mut d,
4687 ServiceKind::Http,
4688 "store.http.base_url",
4689 &h.base_url,
4690 );
4691 for (opname, op) in [
4693 ("get", &h.get),
4694 ("put", &h.put),
4695 ("list", &h.list),
4696 ("delete", &h.delete),
4697 ] {
4698 if let Some(op) = op
4699 && !op.url.starts_with("{base_url}")
4700 && !op.url.contains("{{")
4701 {
4702 closed(
4703 &mut d,
4704 ServiceKind::Http,
4705 &format!("store.http.{opname}.url"),
4706 &op.url,
4707 );
4708 }
4709 }
4710 }
4711 for w in &s.workflows {
4712 if let Some(u) = w.get("url").and_then(Value::as_str) {
4713 closed(&mut d, ServiceKind::Http, "workflow reference url", u);
4714 }
4715 if let Some(steps) = w.get("steps").and_then(Value::as_object) {
4718 for (sid, st) in steps {
4719 if st.get("kind").and_then(Value::as_str) == Some("http")
4720 && let Some(u) = st.get("url").and_then(Value::as_str)
4721 && !u.contains("{{")
4722 {
4723 closed(&mut d, ServiceKind::Http, &format!("http step '{sid}'"), u);
4724 }
4725 }
4726 }
4727 }
4728 if s.observability.otel.endpoint.is_some() {
4730 d.warnings.push(
4731 "security.egress: closed does not cover observability.otel.endpoint (telemetry export is operator plumbing, not agent egress)".into(),
4732 );
4733 }
4734 }
4735 {
4739 let known: &[&str] = &[
4740 "instance",
4741 "instruction",
4742 "extra",
4743 "tools",
4744 "workflows",
4745 "services",
4746 "egress_closed",
4747 "streams",
4748 "templates",
4749 "skills",
4750 "peers",
4751 "signals",
4752 "memory",
4753 ];
4754 let mut check = |what: String, src: &str, is_default_slot: bool| {
4755 match crate::context::prompt::Template::parse(src) {
4756 Err(e) => err(&mut d, format!("{what}: {e}")),
4757 Ok(t) => {
4758 for r in &t.roots {
4759 if !known.contains(&r.as_str()) {
4760 err(
4761 &mut d,
4762 format!(
4763 "{what}: unknown reference {{{{{r}}}}} (available: {})",
4764 known.join(", ")
4765 ),
4766 );
4767 }
4768 }
4769 if t.needs_cel && !cfg!(feature = "cel") {
4770 err(
4771 &mut d,
4772 format!(
4773 "{what}: uses an expression, which needs the 'cel' build feature (bare paths work without it)"
4774 ),
4775 );
4776 }
4777 if is_default_slot && !t.reads("instruction") {
4780 d.warnings.push(format!(
4781 "{what} never references {{{{instruction}}}} — this agent's standing policy will not reach the model"
4782 ));
4783 }
4784 }
4785 }
4786 };
4787 if let Some(src) = &s.context.template {
4788 check("context.template".into(), src, true);
4789 }
4790 for (name, src) in &s.context.templates {
4791 check(format!("context.templates.{name}"), src, false);
4792 }
4793 }
4794 if let Err(errs) = crate::config::templates::compile_templates(s) {
4799 for e in errs {
4800 err(&mut d, e);
4801 }
4802 }
4803 if !s.subagents.templates.is_empty() && s.a2a.listen.is_none() {
4804 d.warnings.push(
4805 "subagents.templates are declared but a2a.listen is unset — instance-tier children get no `parent` peer (they cannot call home)".into(),
4806 );
4807 }
4808 let server_known = |n: &str| s.mcp.servers.iter().any(|x| x.name == n);
4809
4810 for (name, ov) in &s.tools.overrides {
4812 if !server_known(&ov.server) {
4813 err(
4814 &mut d,
4815 format!(
4816 "tools.overrides['{name}'] references undeclared MCP server '{}'",
4817 ov.server
4818 ),
4819 );
4820 }
4821 if s.tools.disabled.iter().any(|x| x == name) {
4822 err(
4823 &mut d,
4824 format!("tool '{name}' is both disabled and overridden"),
4825 );
4826 }
4827 for (label, tpl) in [("args", &ov.args), ("result", &ov.result)] {
4828 if let Some(t) = tpl
4829 && let Some(expr) = t.strip_prefix("CEL:")
4830 && let Err(e) = crate::cel::compile_check(expr.trim())
4831 {
4832 err(&mut d, format!("tools.overrides['{name}'].{label}: {e}"));
4833 }
4834 }
4835 }
4836
4837 match s.store.kind {
4839 StoreKind::Mcp => match &s.store.mcp {
4840 None => err(&mut d, "store.kind is mcp but store.mcp is not set".into()),
4841 Some(m) => {
4842 if !server_known(&m.server) {
4843 err(
4844 &mut d,
4845 format!(
4846 "store.mcp.server '{}' is not a declared MCP server",
4847 m.server
4848 ),
4849 );
4850 }
4851 for (label, op) in [
4852 ("put", &m.put),
4853 ("get", &m.get),
4854 ("list", &m.list),
4855 ("delete", &m.delete),
4856 ] {
4857 if let Some(op) = op {
4858 for (f, t) in [
4859 ("args", &op.args),
4860 ("ok", &op.ok),
4861 ("conflict", &op.conflict),
4862 ("value", &op.value),
4863 ("keys", &op.keys),
4864 ] {
4865 if let Some(t) = t
4866 && let Some(expr) = t.strip_prefix("CEL:")
4867 && let Err(e) = crate::cel::compile_check(expr.trim())
4868 {
4869 err(&mut d, format!("store.mcp.{label}.{f}: {e}"));
4870 }
4871 }
4872 }
4873 }
4874 }
4875 },
4876 StoreKind::Http => match &s.store.http {
4877 None => err(
4878 &mut d,
4879 "store.kind is http but store.http is not set".into(),
4880 ),
4881 Some(h) => {
4882 if !(h.base_url.starts_with("https://") || h.base_url.starts_with("http://")) {
4883 err(
4884 &mut d,
4885 format!(
4886 "store.http.base_url must be an http(s) URL (got {})",
4887 h.base_url
4888 ),
4889 );
4890 }
4891 if h.get.is_none() || h.put.is_none() {
4892 err(
4893 &mut d,
4894 "store.http needs at least `get` and `put` operations".into(),
4895 );
4896 }
4897 for (name, v) in &h.headers {
4898 if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(v) {
4899 err(
4900 &mut d,
4901 format!(
4902 "store.http.headers['{name}'] looks like a credential but has an inline value"
4903 ),
4904 );
4905 } else if let Some(e) = unresolved_secret_ref(v) {
4906 err(&mut d, format!("store.http.headers['{name}']: {e}"));
4907 }
4908 }
4909 }
4910 },
4911 StoreKind::File => {
4912 if let Some(f) = &s.store.file
4918 && f.path.as_deref().is_some_and(|p| p.trim().is_empty())
4919 {
4920 err(
4921 &mut d,
4922 "store.file.path is empty — set a directory, or omit the field to use $AGENTD_STATE_DIR / $XDG_STATE_HOME/agentd/state".into(),
4923 );
4924 }
4925 }
4926 StoreKind::Memory => {
4927 d.warnings.push(
4928 "store.kind is memory: state does not survive the process (dev/test only)".into(),
4929 );
4930 }
4931 StoreKind::None => {
4932 if s.is_long_lived() {
4941 err(&mut d, "store.kind is none but the instance is long-lived (serves A2A / webhooks / a goal watchdog / has a loop|schedule|subscribe|signal|event|a2a|webhook start node) — configure a durable store (store.kind: file | mcp | http), or drop store.kind to get the local file store by default".into());
4942 } else if !s.workflows.is_empty() {
4943 d.warnings.push("store.kind is none: this one-shot run is not durable (a crash re-runs it from scratch); set store.kind for durability".into());
4944 }
4945 }
4946 }
4947 if s.store.file.is_some() && s.store.kind != StoreKind::File {
4953 d.warnings.push(format!(
4954 "store.file is set but store.kind is {} — the file adapter is not in use and the block is ignored",
4955 format!("{:?}", s.store.kind).to_lowercase()
4959 ));
4960 }
4961 if let Some(ms) = s.store.checkpoint.debounce_ms
4962 && ms > 60_000
4963 {
4964 d.warnings.push(format!(
4965 "store.checkpoint.debounce_ms is {ms} (> 60s): progress may lag far behind reality"
4966 ));
4967 }
4968
4969 if let Some(k) = &s.knowledge.server
4971 && !server_known(k)
4972 {
4973 err(
4974 &mut d,
4975 format!("knowledge.server '{k}' is not a declared MCP server"),
4976 );
4977 }
4978 if let Some(k) = &s.search.server
4979 && !server_known(k)
4980 {
4981 err(
4982 &mut d,
4983 format!("search.server '{k}' is not a declared MCP server"),
4984 );
4985 }
4986 for src in &s.skills.sources {
4987 if !server_known(&src.server) {
4988 err(
4989 &mut d,
4990 format!(
4991 "skills.sources[] references undeclared MCP server '{}'",
4992 src.server
4993 ),
4994 );
4995 }
4996 }
4997 if let Some(c) = s.context.compact_at
4998 && !(c > 0.0 && c <= 1.0)
4999 {
5000 err(
5001 &mut d,
5002 format!("context.compact_at must be in (0, 1] (got {c})"),
5003 );
5004 }
5005
5006 let mut wf_names = std::collections::HashSet::new();
5008 for (i, w) in s.workflows.iter().enumerate() {
5009 let Some(obj) = w.as_object() else {
5010 err(&mut d, format!("workflows[{i}] must be an object"));
5011 continue;
5012 };
5013 if obj.contains_key("dir") {
5017 continue;
5018 }
5019 let name = obj.get("name").and_then(Value::as_str).unwrap_or("");
5020 if name.trim().is_empty() {
5021 err(&mut d, format!("workflows[{i}] has no name"));
5022 } else if !wf_names.insert(name.to_string()) {
5023 err(
5024 &mut d,
5025 format!("workflows[]: duplicate workflow name '{name}'"),
5026 );
5027 }
5028 if !s.intelligence.models.is_empty()
5032 && let Some(steps) = obj.get("steps").and_then(Value::as_object)
5033 {
5034 for (sid, st) in steps {
5035 let Some(m) = st.get("model").and_then(Value::as_str) else {
5036 continue;
5037 };
5038 if !s.intelligence.models.contains_key(m) {
5039 err(
5040 &mut d,
5041 format!(
5042 "workflow '{name}' step '{sid}': model {m:?} is not a declared tier (known: {})",
5043 s.intelligence
5044 .models
5045 .keys()
5046 .cloned()
5047 .collect::<Vec<_>>()
5048 .join(", ")
5049 ),
5050 );
5051 }
5052 }
5053 }
5054 let sources = ["file", "uri", "url", "steps"]
5058 .iter()
5059 .filter(|k| obj.contains_key(**k))
5060 .count();
5061 if sources != 1 {
5062 err(
5063 &mut d,
5064 format!(
5065 "workflows['{name}'] must have exactly one of file | uri | url | steps (dir is a separate entry shape)"
5066 ),
5067 );
5068 }
5069 if let Some(f) = obj.get("file").and_then(Value::as_str) {
5078 let mut folded = Value::String(f.to_string());
5079 let mut ignored = Vec::new();
5080 substitute_config_vars(&mut folded, &s.vars, "workflow entry", &mut ignored);
5081 if ignored.is_empty()
5082 && let Some(path) = folded.as_str()
5083 && !std::path::Path::new(path).exists()
5084 {
5085 err(
5086 &mut d,
5087 format!("workflows['{name}'].file {path:?} does not exist"),
5088 );
5089 }
5090 }
5091 }
5092
5093 for (k, v) in &s.lifecycle.exit_code_map {
5095 if k != "3" && k != "7" {
5096 err(
5097 &mut d,
5098 format!(
5099 "lifecycle.exit_code_map: only the policy codes 3 and 7 are remappable (got key {k:?})"
5100 ),
5101 );
5102 }
5103 if !(0..=255).contains(v) {
5104 err(
5105 &mut d,
5106 format!("lifecycle.exit_code_map[{k}] must be 0..=255 (got {v})"),
5107 );
5108 }
5109 }
5110 if s.lifecycle.watch_config && loaded.files.is_empty() {
5111 err(
5112 &mut d,
5113 "lifecycle.watch_config requires a config file (--config / AGENTD_CONFIG)".into(),
5114 );
5115 }
5116
5117 if let Some(l) = &s.a2a.listen {
5119 match super::ServeTarget::parse(l) {
5120 Ok(super::ServeTarget::Http { bind, tls }) => {
5121 let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
5122 if tls && (s.a2a.tls.cert.is_none() || s.a2a.tls.key.is_none()) {
5123 err(
5124 &mut d,
5125 "a2a.listen is https:// but a2a.tls.cert / a2a.tls.key are not set".into(),
5126 );
5127 }
5128 if !loopback
5129 && s.a2a.tls.client_ca.is_none()
5130 && s.a2a.bearer.is_none()
5131 && !s.interface.pairing.enabled
5132 {
5133 err(&mut d, "a2a.listen on a non-loopback address needs client auth: a2a.bearer, interface.pairing, or a2a.tls.client_ca (mTLS — then EVERY caller needs a client certificate, bearer-only and paired included)".into());
5134 }
5135 if !tls && !loopback {
5136 err(
5137 &mut d,
5138 "a2a.listen plaintext http:// is allowed for loopback only; use https://"
5139 .into(),
5140 );
5141 }
5142 }
5143 Ok(super::ServeTarget::Unix { .. }) => {
5144 if s.a2a.tls.cert.is_some()
5148 || s.a2a.tls.key.is_some()
5149 || s.a2a.tls.client_ca.is_some()
5150 {
5151 err(
5152 &mut d,
5153 "a2a.listen is unix:// — the kernel authenticates peers (same-uid); a2a.tls does not apply and must be unset".into(),
5154 );
5155 }
5156 }
5157 Err(e) => err(&mut d, format!("a2a.listen: {e}")),
5158 }
5159 }
5160
5161 if s.interface.enabled && s.a2a.listen.is_none() {
5163 err(
5164 &mut d,
5165 "interface.enabled requires a2a.listen (the interface is served on the A2A listener)"
5166 .into(),
5167 );
5168 }
5169 if s.interface.debug && !s.interface.enabled {
5170 d.warnings
5171 .push("interface.debug has no effect while interface.enabled is false".into());
5172 }
5173 for o in &s.interface.origins {
5174 let ok = o
5176 .split_once("://")
5177 .map(|(scheme, rest)| {
5178 matches!(scheme, "http" | "https") && !rest.is_empty() && !rest.contains('/')
5179 })
5180 .unwrap_or(false);
5181 if !ok {
5182 err(
5183 &mut d,
5184 format!(
5185 "interface.origins: {o:?} is not an origin (want scheme://host[:port], no path)"
5186 ),
5187 );
5188 }
5189 }
5190 for (edge, items) in [
5193 ("top", &s.interface.display.top),
5194 ("bottom", &s.interface.display.bottom),
5195 ] {
5196 for item in items.iter().flatten() {
5197 if let Some(key) = item.strip_prefix("memory:") {
5203 if key.is_empty() {
5204 d.errors.push(format!(
5205 "interface.display.{edge}: {item:?} names no memory key"
5206 ));
5207 } else if let Err(e) = crate::context::memory::Memory::check_key(key) {
5208 d.errors
5209 .push(format!("interface.display.{edge}: {item:?}: {e}"));
5210 }
5211 continue;
5212 }
5213 if !DISPLAY_ITEMS.contains(&item.as_str()) {
5214 d.warnings.push(format!(
5215 "interface.display.{edge}: unknown item {item:?} (clients skip it); known: {}, \
5216 or memory:<key> for a value a workflow maintains",
5217 DISPLAY_ITEMS.join(", ")
5218 ));
5219 }
5220 }
5221 }
5222 if s.interface.pairing.enabled {
5224 if !s.interface.enabled {
5225 err(
5226 &mut d,
5227 "interface.pairing.enabled requires interface.enabled (pairing rides the interface surface)".into(),
5228 );
5229 }
5230 if let Some(role) = s.interface.pairing.role
5231 && !matches!(role, Role::Operator | Role::User)
5232 {
5233 err(
5234 &mut d,
5235 "interface.pairing.role must be operator or user".into(),
5236 );
5237 }
5238 }
5239
5240 let uses_webhook = s.workflows.iter().any(workflow_uses_webhook);
5242 if uses_webhook && s.webhooks.listen.is_none() {
5243 err(&mut d, "a `webhook` node (start or wait) is used but webhooks.listen is not set — configure webhooks.listen (https://host:port)".into());
5244 }
5245 if let Some(l) = &s.webhooks.listen {
5246 match super::ServeTarget::parse(l) {
5247 Ok(super::ServeTarget::Unix { .. }) => {
5248 err(
5249 &mut d,
5250 "webhooks.listen does not support unix:// (webhooks are an external surface); use https://".into(),
5251 );
5252 }
5253 Ok(super::ServeTarget::Http { bind, tls }) => {
5254 let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
5255 if tls && (s.webhooks.tls.cert.is_none() || s.webhooks.tls.key.is_none()) {
5256 err(
5257 &mut d,
5258 "webhooks.listen is https:// but webhooks.tls.cert / webhooks.tls.key are not set"
5259 .into(),
5260 );
5261 }
5262 if !tls && !loopback {
5263 err(
5264 &mut d,
5265 "webhooks.listen plaintext http:// is allowed for loopback only; use https://"
5266 .into(),
5267 );
5268 }
5269 if !loopback && !webhook_default_verifies(s.webhooks.default_auth.as_ref()) {
5281 let mut open: Vec<String> = Vec::new();
5282 let mut nodes = 0usize;
5283 for w in &s.workflows {
5284 let wf = w.get("name").and_then(Value::as_str).unwrap_or("?");
5285 for (node, auth) in webhook_nodes(w) {
5286 nodes += 1;
5287 if !webhook_auth_verifies(auth) {
5288 open.push(format!("{wf}/{node}"));
5289 }
5290 }
5291 }
5292 if !open.is_empty() {
5293 err(
5294 &mut d,
5295 format!(
5296 "webhooks.listen on a non-loopback address needs auth: set webhooks.default_auth (hmac, bearer or header), or give every `webhook` node its own `auth` (HMAC recommended) — unauthenticated: {}",
5297 open.join(", ")
5298 ),
5299 );
5300 } else if nodes == 0 {
5301 d.warnings.push("webhooks.listen is non-loopback with no webhooks.default_auth — every webhook node must declare its own `auth` (HMAC recommended)".into());
5304 }
5305 }
5306 }
5307 Err(e) => err(&mut d, format!("webhooks.listen: {e}")),
5308 }
5309 }
5310
5311 if let Some(g) = &s.goal {
5313 let via = g.check.via.as_deref().unwrap_or("both");
5314 if via == "condition" && g.check.condition.is_none() {
5315 err(
5316 &mut d,
5317 "goal.check.via is 'condition' but goal.check.condition is not set".into(),
5318 );
5319 }
5320 for (label, act) in [("on_achieved", &g.on_achieved), ("on_stuck", &g.on_stuck)] {
5321 if let Some(GoalAction::Workflow(name)) = act
5322 && !s
5323 .workflows
5324 .iter()
5325 .any(|w| w.get("name").and_then(Value::as_str) == Some(name.as_str()))
5326 {
5327 err(
5328 &mut d,
5329 format!(
5330 "goal.{label} references workflow '{name}', which is not defined in workflows"
5331 ),
5332 );
5333 }
5334 }
5335 }
5336
5337 let mut peer_names = std::collections::HashSet::new();
5338 for p in &s.a2a.peers {
5339 if !peer_names.insert(p.name.as_str()) {
5340 err(
5341 &mut d,
5342 format!("a2a.peers[]: duplicate peer name '{}'", p.name),
5343 );
5344 }
5345 let unix_peer = p.endpoint.starts_with("unix://") || p.endpoint.starts_with("unix:");
5346 if unix_peer && !cfg!(unix) {
5347 err(
5348 &mut d,
5349 format!("a2a peer '{}': unix:// endpoints are unix-only", p.name),
5350 );
5351 }
5352 if !unix_peer && !p.endpoint.starts_with("https://") && !p.endpoint.starts_with("http://") {
5353 err(
5354 &mut d,
5355 format!(
5356 "a2a peer '{}': endpoint must be http(s):// (or unix:///path for a co-located peer)",
5357 p.name
5358 ),
5359 );
5360 }
5361 if p.client_cert.is_some() != p.client_key.is_some() {
5362 err(
5363 &mut d,
5364 format!(
5365 "a2a peer '{}': client_cert and client_key must be set together",
5366 p.name
5367 ),
5368 );
5369 }
5370 if let Some(auth) = &p.auth {
5371 for e in validate_auth_block(auth, &format!("a2a peer '{}'", p.name)) {
5372 err(&mut d, e);
5373 }
5374 if auth.kind == AuthKind::Aws {
5375 err(
5376 &mut d,
5377 format!(
5378 "a2a peer '{}': auth kind `aws` is not accepted for peers — use static, oauth2 or spiffe",
5379 p.name
5380 ),
5381 );
5382 }
5383 }
5384 for (h, v) in &p.headers {
5385 if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
5386 err(
5387 &mut d,
5388 format!(
5389 "a2a peer '{}' header '{h}' looks like a credential but has an inline value",
5390 p.name
5391 ),
5392 );
5393 } else if let Some(e) = unresolved_secret_ref(v) {
5394 err(&mut d, format!("a2a peer '{}' header '{h}': {e}", p.name));
5395 }
5396 }
5397 }
5398 for (i, pr) in s.a2a.principals.iter().enumerate() {
5399 let m = &pr.matcher;
5400 if m.san.is_none()
5401 && m.sub.is_none()
5402 && m.bearer_ref.is_none()
5403 && m.aauth_agent.is_none()
5404 && !m.any
5405 {
5406 err(
5407 &mut d,
5408 format!(
5409 "a2a.principals[{i}]: match needs one of san | sub | bearer_ref | aauth_agent | any"
5410 ),
5411 );
5412 }
5413 if m.any && pr.role == Role::Operator {
5414 err(
5415 &mut d,
5416 format!("a2a.principals[{i}]: `any` cannot grant the operator role"),
5417 );
5418 }
5419 }
5420
5421 if let Some(l) = &s.observability.log_level
5423 && crate::obs::log::Level::parse(l).is_none()
5424 {
5425 err(
5426 &mut d,
5427 format!("observability.log_level: {l:?} (want trace|debug|info|warn|error)"),
5428 );
5429 }
5430
5431 for m in secret_violations(&loaded.file_doc) {
5433 err(&mut d, m);
5434 }
5435 for f in &s.observability.audit.sink.clone().unwrap_or_default() {
5436 if *f == AuditSink::Store && s.store.kind == StoreKind::None {
5437 err(
5438 &mut d,
5439 "observability.audit.sink includes `store` but store.kind is none".into(),
5440 );
5441 }
5442 }
5443
5444 let mut tags = Vec::new();
5446 for srv in &s.mcp.servers {
5447 match srv.tag_set() {
5448 Ok(t) if t.is_empty() => tags.push(crate::sec::scope::TrifectaTag::UntrustedInput),
5449 Ok(t) => tags.extend(t),
5450 Err(_) => {}
5451 }
5452 }
5453 #[cfg(feature = "exec")]
5462 if s.security.exec.enabled {
5463 tags.push(crate::sec::scope::TrifectaTag::Sensitive);
5464 tags.push(crate::sec::scope::TrifectaTag::Egress);
5465 }
5466 use crate::sec::scope::{TrifectaVerdict, check_trifecta};
5467 if check_trifecta(tags, s.security.allow_trifecta) == TrifectaVerdict::RefusedTrifecta {
5468 err(&mut d, "lethal-trifecta refused: the root grant wires untrusted_input + sensitive + egress into one agent; narrow the tags or set security.allow_trifecta (audited)".into());
5469 }
5470
5471 for (path, level) in [
5487 ("store.durability.a2a", s.store.durability.a2a),
5488 ("store.durability.steps", s.store.durability.steps),
5489 ] {
5490 if level == Some(DurabilityLevel::Eventual) {
5491 d.errors.push(format!(
5492 "{path}: `eventual` is not implemented — every durable write is strict \
5493 (checkpoint-before-effect). Remove the key; `strict` is the default and \
5494 the only behaviour."
5495 ));
5496 }
5497 }
5498 for (i, w) in s.workflows.iter().enumerate() {
5505 if w.get("steps").is_some() {
5506 for msg in missing_references(w, &format!("workflows[{i}]"), &s.vars) {
5507 err(&mut d, msg);
5508 }
5509 }
5510 }
5511 for w in &s.workflows {
5512 if w.get("steps").is_none() {
5515 if let Some(h) = w.get("headers").and_then(Value::as_object) {
5518 for (k, v) in h {
5519 if let Some(val) = v.as_str()
5520 && super::is_secret_shaped_key(k)
5521 && !crate::sec::secret::has_secret_ref(val)
5522 {
5523 d.errors.push(format!(
5524 "workflows: headers[{k:?}] looks like a credential — use {{{{secret:NAME}}}} rather than a literal"
5525 ));
5526 }
5527 }
5528 }
5529 continue;
5530 }
5531 if let Err(errs) = crate::engine::model::parse_workflow(w) {
5532 d.errors.extend(errs);
5534 }
5535 let cap = s
5538 .limits
5539 .workflow
5540 .fan_out
5541 .unwrap_or(crate::engine::model::MAX_BATCH_PARALLEL as u32);
5542 let wname = w.get("name").and_then(Value::as_str).unwrap_or("?");
5543 if let Some(steps) = w.get("steps").and_then(Value::as_object) {
5544 for (sid, step) in steps {
5545 let want = step.get("parallel").and_then(Value::as_u64).or_else(|| {
5546 step.get("batch")
5547 .and_then(|b| b.get("parallel"))
5548 .and_then(Value::as_u64)
5549 });
5550 if let Some(want) = want
5551 && want > cap as u64
5552 {
5553 d.errors.push(format!(
5554 "workflow {wname:?} step {sid:?}: parallel {want} exceeds \
5555 limits.workflow.fan_out ({cap}) — raise the limit or lower the step"
5556 ));
5557 }
5558 }
5559 }
5560 }
5561 d
5562}
5563
5564pub use crate::engine::model::is_long_lived_start;
5570
5571pub fn workflow_is_long_lived(w: &Value) -> bool {
5573 w.get("steps")
5574 .and_then(Value::as_object)
5575 .is_some_and(|steps| {
5576 steps.values().any(|st| {
5577 st.get("kind")
5578 .and_then(Value::as_str)
5579 .is_some_and(is_long_lived_start)
5580 })
5581 })
5582}
5583
5584pub fn workflow_uses_webhook(w: &Value) -> bool {
5588 w.get("steps")
5589 .and_then(Value::as_object)
5590 .is_some_and(|steps| {
5591 steps.values().any(|st| {
5592 let kind = st.get("kind").and_then(Value::as_str);
5593 kind == Some("webhook")
5594 || (matches!(kind, Some("wait") | Some("await"))
5595 && st.get("on").and_then(Value::as_str) == Some("webhook"))
5596 })
5597 })
5598}
5599
5600fn webhook_nodes(w: &Value) -> Vec<(&str, Option<&Value>)> {
5606 let Some(steps) = w.get("steps").and_then(Value::as_object) else {
5607 return Vec::new();
5608 };
5609 steps
5610 .iter()
5611 .filter_map(|(id, st)| {
5612 let kind = st.get("kind").and_then(Value::as_str);
5613 if kind == Some("webhook") {
5614 Some((id.as_str(), st.get("auth")))
5615 } else if matches!(kind, Some("wait") | Some("await"))
5616 && st.get("on").and_then(Value::as_str) == Some("webhook")
5617 {
5618 Some((id.as_str(), st.get("webhook").and_then(|c| c.get("auth"))))
5619 } else {
5620 None
5621 }
5622 })
5623 .collect()
5624}
5625
5626fn webhook_auth_verifies(auth: Option<&Value>) -> bool {
5633 let Some(a) = auth else { return false };
5634 if a.get("none").and_then(Value::as_bool) == Some(true) {
5635 return false;
5636 }
5637 a.get("hmac").and_then(Value::as_object).is_some()
5638 || a.get("header").and_then(Value::as_object).is_some()
5639 || a.get("bearer").and_then(Value::as_str).is_some()
5640}
5641
5642fn webhook_default_verifies(d: Option<&WebhookAuth>) -> bool {
5647 d.is_some_and(|d| !d.none && (d.hmac.is_some() || d.bearer.is_some() || d.header.is_some()))
5648}
5649
5650fn validate_budget(b: &Budget, at: &str, d: &mut Diagnostics) {
5651 for (i, w) in b.windows.iter().enumerate() {
5652 if w.tokens.is_none() && w.requests.is_none() {
5653 d.errors
5654 .push(format!("{at}.windows[{i}]: set tokens and/or requests"));
5655 }
5656 if let Some(r) = &w.reset {
5657 let ok = r.len() == 6
5662 && r.is_ascii()
5663 && r.ends_with('Z')
5664 && r[..2].parse::<u32>().is_ok_and(|h| h < 24)
5665 && &r[2..3] == ":"
5666 && r[3..5].parse::<u32>().is_ok_and(|m| m < 60);
5667 if !ok {
5668 d.errors.push(format!(
5669 "{at}.windows[{i}].reset must be HH:MMZ (got {r:?})"
5670 ));
5671 }
5672 if !w.per.is_calendar() {
5673 d.warnings.push(format!(
5674 "{at}.windows[{i}].reset is only meaningful for day/week windows"
5675 ));
5676 }
5677 }
5678 }
5679 if let Some(f) = b.slow.factor
5680 && !(f > 0.0 && f <= 1.0)
5681 {
5682 d.errors
5683 .push(format!("{at}.slow.factor must be in (0, 1] (got {f})"));
5684 }
5685 if b.on_exhausted == BudgetTactic::Degrade && b.degrade.model.is_none() {
5686 d.errors.push(format!(
5687 "{at}.on_exhausted is degrade but {at}.degrade.model is not set"
5688 ));
5689 }
5690 if b.reserve.estimate == ReserveEstimate::Fixed && b.reserve.fixed.is_none() {
5691 d.errors.push(format!(
5692 "{at}.reserve.estimate is fixed but {at}.reserve.fixed is not set"
5693 ));
5694 }
5695}
5696
5697const FILE_SECRET_PATHS: &[&str] = &[
5699 "/intelligence/token",
5700 "/a2a/bearer",
5701 "/security/aauth/enroll_token",
5702];
5703
5704fn secret_violations(file_doc: &Value) -> Vec<String> {
5706 let mut out = Vec::new();
5707 for p in FILE_SECRET_PATHS {
5708 if let Some(Value::String(v)) = file_doc.pointer(p)
5709 && !crate::sec::secret::has_secret_ref(v)
5710 {
5711 out.push(format!(
5712 "config file: {} carries an inline credential; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}} (or set it from env/flag)",
5713 p.trim_start_matches('/').replace('/', ".")
5714 ));
5715 }
5716 }
5717 if let Some(servers) = file_doc.pointer("/mcp/servers").and_then(Value::as_array) {
5718 for s in servers {
5719 if let Some(Value::String(v)) = s.pointer("/oauth/client_secret")
5720 && !crate::sec::secret::has_secret_ref(v)
5721 {
5722 out.push(format!(
5723 "config file: mcp server '{}' oauth.client_secret carries an inline credential; use a {{{{secret:…}}}} reference",
5724 s.get("name").and_then(Value::as_str).unwrap_or("?")
5725 ));
5726 }
5727 }
5728 }
5729 out
5730}
5731
5732pub const RESTART_ONLY_PATHS: &[&str] = &[
5739 "config_version",
5740 "agent.name",
5741 "store.kind",
5742 "store.prefix",
5743 "store.mcp",
5744 "store.http",
5745 "store.file",
5748 "lifecycle.run_until",
5749 "lifecycle.drain_timeout",
5750 "lifecycle.run_id",
5751 "lifecycle.exit_code_map",
5752 "lifecycle.watch_config",
5753 "a2a.listen",
5754 "a2a.tls",
5755 "a2a.bearer",
5756 "a2a.principals",
5765 "webhooks",
5771 "observability.otel",
5772 "observability.metrics_addr",
5773 "observability.health_file",
5774 "observability.events_ring",
5775 "observability.traceparent",
5776 "security",
5777];
5778
5779pub fn restart_only_diff(running: &Value, candidate: &Value) -> Vec<String> {
5781 RESTART_ONLY_PATHS
5782 .iter()
5783 .filter(|p| {
5784 let ptr = format!("/{}", p.replace('.', "/"));
5785 running.pointer(&ptr) != candidate.pointer(&ptr)
5786 })
5787 .map(|p| (*p).to_string())
5788 .collect()
5789}
5790
5791pub fn help_section() -> String {
5793 paths::help_section_in(&paths::bindings_of(&schema::schema()))
5794}
5795
5796pub fn help_text() -> String {
5799 let mut out = format!(
5800 "agentd {ver} — a durable, workflow-driven agent (config schema v2)\n\
5801 \n\
5802 USAGE:\n\
5803 \x20 agentd --config <settings.yaml> [--config <overlay.yaml> …] [--<path> <value> …]\n\
5804 \x20 agentd --prompt <TEXT> --intelligence <URL> # one-shot: ask, answer, exit\n\
5805 \x20 agentd --instruction <TEXT> --intelligence <URL> [--mcp name=endpoint …] # one-shot sugar\n\
5806 \x20 agentd tui|ui --config <settings.yaml> [--<path> <value> …] # + a display client\n\
5807 \n\
5808 Every setting is a document path (YAML/JSON file, AGENTD_<PATH> env, --<path> flag);\n\
5809 several files merge in order (later wins). Precedence: built-in < files < env < flags.\n\
5810 \n\
5811 ALIASES (short spellings of paths):\n",
5812 ver = crate::VERSION
5813 );
5814 for a in ALIASES {
5815 let shape = match a.kind {
5816 AliasKind::Set | AliasKind::SetFromFile => "<value>",
5817 AliasKind::SetTrue => "",
5818 AliasKind::Append => "<value> (adds one)",
5819 AliasKind::Special => "<value>",
5820 };
5821 out.push_str(&format!(" {:<32} {} → {}\n", a.flag, shape, a.path));
5822 }
5823 out.push_str(
5824 "\nSUBCOMMANDS (run the daemon with a display client attached):\n\
5825 \x20 tui + the terminal UI (fullscreen; --inline for in-place)\n\
5826 \x20 ui + the web UI, opened in a browser\n\
5827 \x20 both need `interface.enabled: true`, which the\n\
5828 \x20 subcommand sets for you; the client exits with the daemon.\n\
5829 \x20 Detached instead: run `agentd -c …`, then `agentd-tui\n\
5830 \x20 --endpoint <url>` (npm i -g @agentd-dev/cli).\n\
5831 \nCONTROL:\n\
5832 \x20 -c, --config <PATH> a settings file (repeatable; `=` form too; or AGENT_CONFIG=a.yaml:b.yaml)\n\
5833 \x20 --validate-config load+validate everything, print the verdict, exit 0/2\n\
5834 \x20 --config-schema print the settings JSON Schema and exit\n\
5835 \x20 --context-template print the built-in system-prompt template and exit\n\
5836 \x20 --workflow-schema print the workflow JSON Schema + node registry and exit\n\
5837 \x20 --capabilities print the capabilities manifest and exit\n\
5838 \x20 --login <target> complete an OAuth device-login for an endpoint (e.g. mcp:<name>) and cache the token\n\
5839 \x20 --logout <target> evict a cached credential\n\
5840 \x20 --prompt-missing ask interactively (echo off, on /dev/tty) for each {{secret:NAME}} the startup preflight finds missing; refused without a controlling terminal\n\
5841 \x20 --env <FILE> load a dotenv file into this process's environment (repeatable; real env wins, later files win)\n\
5842 \x20 -h, --help / -V, --version\n\
5843 \nREMOVED FLAGS:\n",
5844 );
5845 for (flag, hint) in REMOVED_FLAGS {
5846 out.push_str(&format!(" {flag:<32} {hint}\n"));
5847 }
5848 out.push('\n');
5849 out.push_str(&help_section());
5850 out
5851}
5852
5853#[cfg(test)]
5854mod tests {
5855 use super::*;
5856 use std::io::Write;
5857
5858 fn args(v: &[&str]) -> Vec<String> {
5859 v.iter().map(|s| s.to_string()).collect()
5860 }
5861
5862 fn write_tmp(contents: &str, ext: &str) -> tempfile::NamedTempFile {
5863 let mut f = tempfile::Builder::new()
5864 .suffix(&format!(".{ext}"))
5865 .tempfile()
5866 .unwrap();
5867 f.write_all(contents.as_bytes()).unwrap();
5868 f.flush().unwrap();
5869 f
5870 }
5871
5872 fn base_env() -> Vec<(String, String)> {
5882 for name in ["BILLING", "PEER"] {
5886 crate::sec::secret::set_prompted(name, "test-value".into());
5887 }
5888 vec![(
5889 "AGENTD_INTELLIGENCE_ENDPOINTS".into(),
5890 "https://intel.example/v1".into(),
5891 )]
5892 }
5893
5894 fn struct_fields_at(doc_path: &str) -> Vec<String> {
5900 let mut probe = Value::Object(Map::new());
5902 let path = if doc_path.is_empty() {
5903 "__probe__".to_string()
5904 } else {
5905 format!("{doc_path}.__probe__")
5906 };
5907 paths::set_path(&mut probe, &path, json!(1));
5908 let err = Settings::from_document(probe, "t").expect_err("probe must be rejected");
5909 let after = err.split("expected").nth(1).unwrap_or("");
5912 let mut out: Vec<String> = after
5913 .split('`')
5914 .skip(1)
5915 .step_by(2)
5916 .map(str::to_string)
5917 .collect();
5918 out.sort();
5919 out
5920 }
5921
5922 fn schema_props_at(schema: &Value, doc_path: &str) -> Vec<String> {
5923 let mut node = schema.clone();
5924 let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
5925 for seg in doc_path.split('.').filter(|s| !s.is_empty()) {
5926 let props = node.get("properties").cloned().unwrap_or(Value::Null);
5927 node = props.get(seg).cloned().unwrap_or(Value::Null);
5928 if let Some(r) = node.get("$ref").and_then(Value::as_str)
5929 && let Some(name) = r.strip_prefix("#/$defs/")
5930 {
5931 node = defs.get(name).cloned().unwrap_or(Value::Null);
5932 }
5933 }
5934 let mut out: Vec<String> = node
5935 .get("properties")
5936 .and_then(Value::as_object)
5937 .map(|m| m.keys().cloned().collect())
5938 .unwrap_or_default();
5939 out.sort();
5940 out
5941 }
5942
5943 #[test]
5959 fn schema_matches_struct_for_collection_item_types() {
5960 fn fields_of(probe: Value) -> Vec<String> {
5963 let err = Settings::from_document(probe, "t").expect_err("probe must be rejected");
5964 let after = err.split("expected").nth(1).unwrap_or("");
5965 let mut out: Vec<String> = after
5966 .split('`')
5967 .skip(1)
5968 .step_by(2)
5969 .map(str::to_string)
5970 .collect();
5971 out.sort();
5972 out.dedup();
5973 out
5974 }
5975 fn def_props(schema: &Value, name: &str) -> Vec<String> {
5976 let mut out: Vec<String> = schema["$defs"][name]["properties"]
5977 .as_object()
5978 .map(|m| m.keys().cloned().collect())
5979 .unwrap_or_default();
5980 out.sort();
5981 out
5982 }
5983
5984 let schema = schema::schema();
5985 for (def, probe) in [
5986 (
5987 "Service",
5988 json!({"services": {"p": {"endpoint": "https://x", "__probe__": 1}}}),
5989 ),
5990 (
5991 "A2aPeer",
5992 json!({"a2a": {"peers": [{"name": "p", "endpoint": "https://x", "__probe__": 1}]}}),
5993 ),
5994 ] {
5995 assert_eq!(
5996 def_props(&schema, def),
5997 fields_of(probe),
5998 "schema/struct drift in $defs/{def}"
5999 );
6000 }
6001
6002 assert_eq!(
6006 schema["$defs"]["Service"]["properties"]["kind"]["enum"],
6007 json!(["mcp", "intelligence", "peer", "http"]),
6008 );
6009 }
6010
6011 #[test]
6012 fn schema_matches_struct_at_every_object() {
6013 let schema = schema::schema();
6014 for path in [
6015 "",
6016 "agent",
6017 "agent.tools",
6018 "intelligence",
6019 "intelligence.auth",
6020 "intelligence.budget",
6021 "intelligence.budget.slow",
6022 "intelligence.budget.degrade",
6023 "intelligence.budget.reserve",
6024 "mcp",
6025 "tools",
6026 "store",
6027 "store.checkpoint",
6028 "store.durability",
6029 "memory",
6030 "context",
6031 "context.plan",
6032 "context.summarize",
6033 "knowledge",
6034 "knowledge.auto_context",
6035 "search",
6036 "skills",
6037 "limits",
6038 "limits.run",
6039 "limits.subagents",
6040 "limits.subagents.instances",
6041 "subagents",
6042 "subagents.defaults",
6043 "lifecycle",
6044 "a2a",
6045 "a2a.tls",
6046 "observability",
6047 "observability.otel",
6048 "observability.audit",
6049 "security",
6050 "security.cgroup",
6051 "security.exec",
6052 ] {
6053 let s = schema_props_at(&schema, path);
6054 let f = struct_fields_at(path);
6055 assert_eq!(s, f, "schema/struct drift at `{path}`");
6056 }
6057 }
6058
6059 #[test]
6060 fn every_schema_path_deserializes_a_sample() {
6061 for b in paths::bindings_of(&schema::schema()) {
6065 let sample = match &b.kind {
6066 paths::Kind::String => match b.path.as_str() {
6067 "config_version" => json!("2"),
6068 _ => json!("x"),
6069 },
6070 paths::Kind::Integer => json!(1),
6071 paths::Kind::Number => json!(0.5),
6072 paths::Kind::Boolean => json!(true),
6073 paths::Kind::Enum(vs) => json!(vs[0]),
6074 paths::Kind::Array(item) => match (**item).clone() {
6075 paths::Kind::Object => match b.path.as_str() {
6076 "mcp.servers" => {
6077 json!([{"name": "a", "endpoint": "https://a.example/mcp"}])
6078 }
6079 "workflows" => json!([{"name": "w", "steps": {}}]),
6080 "a2a.principals" => json!([{"match": {"any": true}, "role": "user"}]),
6081 "a2a.peers" => json!([{"name": "p", "endpoint": "https://p.example"}]),
6082 "skills.sources" => json!([{"server": "s"}]),
6083 "security.policies" => {
6084 json!([{"match": {"tool": "fs.*"}, "action": "deny"}])
6085 }
6086 "intelligence.budget.windows" | "agent.conversation_budget.windows" => {
6087 json!([{"per": "hour", "tokens": 1}])
6088 }
6089 other => panic!("no sample for object list {other}"),
6090 },
6091 paths::Kind::Enum(vs) => json!([vs[0]]),
6092 _ => json!(["s"]),
6093 },
6094 paths::Kind::Object => match b.path.as_str() {
6095 "intelligence.pricing" => json!({"m": {"input_per_1k": 1.0}}),
6096 "intelligence.models" => json!({"small": {"model": "m-1"}}),
6097 "tools.overrides" => json!({"memory.get": {"server": "s", "tool": "t"}}),
6098 "store.mcp" => json!({"server": "s"}),
6099 "streams" => json!({"orders": {"retention": {"max_events": 1}}}),
6100 "services" => json!({"billing": {"endpoint": "https://b.example/mcp"}}),
6101 "subagents.templates" => json!({"t": {"instruction": "do the thing"}}),
6102 "subagents.defaults.limits" => json!({"max_tokens": 1000}),
6103 "store.http" => json!({"base_url": "https://s"}),
6104 "security.aauth" => json!({"provider": "https://apd"}),
6105 "lifecycle.exit_code_map" => json!({"3": 0}),
6106 _ => json!({"k": "v"}),
6107 },
6108 paths::Kind::Any => match b.path.as_str() {
6109 "intelligence.endpoints" => json!("https://a,https://b"),
6110 "goal.on_achieved" | "goal.on_stuck" => json!("finish"),
6111 p if p.ends_with("timeout")
6112 || p.ends_with("deadline")
6113 || p.ends_with("_grace")
6114 || p.ends_with("ttl")
6115 || p.ends_with("every") =>
6116 {
6117 json!("10s")
6118 }
6119 p if p.starts_with("agent.tools.") => json!("all"),
6120 _ => json!("x"),
6121 },
6122 };
6123 let mut doc = Value::Object(Map::new());
6124 paths::set_path(&mut doc, &b.path, sample);
6125 fill_required(&mut doc, &schema::schema(), &b.path);
6126 Settings::from_document(doc, "t")
6127 .unwrap_or_else(|e| panic!("path {} does not deserialize: {e}", b.path));
6128 }
6129 }
6130
6131 fn fill_required(doc: &mut Value, schema: &Value, path: &str) {
6135 let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
6136 let resolve = |v: &Value| -> Value {
6137 match v
6138 .get("$ref")
6139 .and_then(Value::as_str)
6140 .and_then(|r| r.strip_prefix("#/$defs/"))
6141 {
6142 Some(name) => defs.get(name).cloned().unwrap_or(Value::Null),
6143 None => v.clone(),
6144 }
6145 };
6146 let mut node = schema.clone();
6147 let mut prefix = String::new();
6148 let segs: Vec<&str> = path.split('.').collect();
6149 for (i, seg) in segs.iter().enumerate() {
6150 let props = node.get("properties").cloned().unwrap_or(Value::Null);
6151 node = resolve(&props.get(*seg).cloned().unwrap_or(Value::Null));
6152 prefix = if prefix.is_empty() {
6153 (*seg).to_string()
6154 } else {
6155 format!("{prefix}.{seg}")
6156 };
6157 if i + 1 == segs.len() {
6158 break;
6159 }
6160 if let Some(req) = node.get("required").and_then(Value::as_array) {
6161 let props = node.get("properties").cloned().unwrap_or(Value::Null);
6162 for r in req.iter().filter_map(Value::as_str) {
6163 let p = format!("{prefix}.{r}");
6164 if doc.pointer(&format!("/{}", p.replace('.', "/"))).is_none() {
6165 let sample = match props
6168 .get(r)
6169 .and_then(|f| f.get("enum"))
6170 .and_then(Value::as_array)
6171 .filter(|a| !a.is_empty())
6172 {
6173 Some(vs) => vs[0].clone(),
6174 None => match r {
6175 "provider" | "base_url" | "url" => json!("https://x.example"),
6176 _ => json!("x"),
6177 },
6178 };
6179 paths::set_path(doc, &p, sample);
6180 }
6181 }
6182 }
6183 }
6184 }
6185
6186 #[test]
6187 fn env_and_flag_names_derive_from_the_v2_paths() {
6188 let bs = paths::bindings_of(&schema::schema());
6189 let model = bs.iter().find(|b| b.path == "intelligence.model").unwrap();
6190 assert_eq!(model.env_names()[0], "AGENTD_INTELLIGENCE_MODEL");
6191 assert_eq!(model.env_names()[2], "INTELLIGENCE_MODEL");
6192 assert_eq!(model.flag(), "--intelligence-model");
6193 let steps = bs.iter().find(|b| b.path == "limits.run.steps").unwrap();
6194 assert_eq!(steps.env_names()[0], "AGENTD_LIMITS_RUN_STEPS");
6195 let mut seen = std::collections::HashSet::new();
6197 for b in &bs {
6198 assert!(seen.insert(b.flag()), "duplicate flag {}", b.flag());
6199 }
6200 }
6201
6202 #[test]
6205 fn detects_v1_v2_mixed_and_empty() {
6206 assert_eq!(detect(&json!({})), Detected::Empty);
6207 assert_eq!(detect(&json!({"model": "m"})), Detected::V1);
6208 assert_eq!(detect(&json!({"config_version": "1"})), Detected::V2);
6209 assert_eq!(
6210 detect(&json!({"agent": {"instruction": "x"}})),
6211 Detected::V2
6212 );
6213 assert_eq!(detect(&json!({"agent": {}, "model": "m"})), Detected::Mixed);
6214 assert_eq!(
6215 detect(&json!({"config_version": "1.0", "model": "m"})),
6216 Detected::V1
6217 );
6218 assert_eq!(
6220 detect(&json!({"model": "m", "limits": {"max_steps": 1}})),
6221 Detected::V1
6222 );
6223 assert_eq!(
6224 detect(&json!({"intelligence": "https://x", "limits": {}})),
6225 Detected::V1
6226 );
6227 assert_eq!(
6228 detect(&json!({"intelligence": {"model": "m"}, "limits": {}})),
6229 Detected::V2
6230 );
6231 assert_eq!(detect(&json!({"limits": {"max_steps": 1}})), Detected::V1);
6232 }
6233
6234 #[cfg(feature = "exec")]
6237 #[test]
6238 fn enabling_exec_next_to_untrusted_input_assembles_the_trifecta() {
6239 let cfg = "config_version: \"1\"\nstore: {kind: memory}\n\
6245 mcp:\n servers:\n - name: web\n endpoint: https://mcp-web.internal/mcp\n tags: {\"*\": [untrusted_input]}\n\
6246 security:\n exec: {enabled: true, workdir: /tmp, allow: [git]}\n";
6247 let f = write_tmp(cfg, "yaml");
6248 let e = load(
6249 &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
6250 &base_env(),
6251 )
6252 .unwrap_err();
6253 assert!(format!("{e}").contains("lethal-trifecta refused"), "{e}");
6254
6255 load(
6257 &args(&[
6258 "--config",
6259 f.path().to_str().unwrap(),
6260 "--validate-config",
6261 "--allow-trifecta",
6262 ]),
6263 &base_env(),
6264 )
6265 .expect("--allow-trifecta is the escape hatch");
6266
6267 let alone = write_tmp(
6269 "config_version: \"1\"\nstore: {kind: memory}\n\
6270 security:\n exec: {enabled: true, workdir: /tmp, allow: [git]}\n",
6271 "yaml",
6272 );
6273 load(
6274 &args(&[
6275 "--config",
6276 alone.path().to_str().unwrap(),
6277 "--validate-config",
6278 ]),
6279 &base_env(),
6280 )
6281 .expect("two legs are not the trifecta");
6282 }
6283
6284 #[test]
6285 fn validate_config_catches_workflow_body_errors_the_runtime_would_refuse() {
6286 let f = write_tmp(
6290 "config_version: \"1\"\nstore: {kind: memory}\nworkflows:\n - name: w\n version: 3\n steps:\n s: {kind: once}\n a: {kind: agent, depends_on: [s], prompt: \"typo — agent steps take `instruction`\"}\n f: {kind: finish, depends_on: [a], status: completed}\n",
6291 "yaml",
6292 );
6293 let e = load(
6294 &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
6295 &base_env(),
6296 )
6297 .unwrap_err();
6298 let msg = format!("{e}");
6299 assert!(msg.contains("unknown field"), "{msg}");
6300 assert!(msg.contains("prompt"), "{msg}");
6301 assert!(
6302 msg.contains("instruction"),
6303 "names the allowed fields: {msg}"
6304 );
6305
6306 let ok = write_tmp(
6308 "config_version: \"1\"\nstore: {kind: memory}\nworkflows:\n - name: w\n version: 3\n steps:\n s: {kind: once}\n a: {kind: agent, depends_on: [s], instruction: \"do it\"}\n f: {kind: finish, depends_on: [a], status: completed}\n",
6309 "yaml",
6310 );
6311 load(
6312 &args(&["--config", ok.path().to_str().unwrap(), "--validate-config"]),
6313 &base_env(),
6314 )
6315 .expect("a correct workflow validates");
6316 }
6317
6318 #[test]
6319 fn a_prompt_is_a_message_not_a_sugar_workflow() {
6320 let (l, ask) = load(&args(&["--prompt", "do the thing"]), &base_env()).unwrap();
6325 assert_eq!(ask, Ask::Run);
6326 assert_eq!(l.settings.agent.prompt.as_deref(), Some("do the thing"));
6327 assert!(
6328 l.settings.workflows.is_empty(),
6329 "a prompt needs no workflow: {:?}",
6330 l.settings.workflows
6331 );
6332
6333 let (only_instr, _) = load(&args(&["--instruction", "be terse"]), &base_env()).unwrap();
6335 assert_eq!(only_instr.settings.workflows.len(), 1);
6336
6337 let (both, _) = load(
6340 &args(&["--prompt", "do the thing", "--instruction", "be terse"]),
6341 &base_env(),
6342 )
6343 .unwrap();
6344 assert!(both.settings.workflows.is_empty());
6345 assert_eq!(both.settings.agent.instruction.as_deref(), Some("be terse"));
6346
6347 let mut env = base_env();
6349 env.push(("AGENTD_AGENT_PROMPT".into(), "from env".into()));
6350 let (from_env, _) = load(&args(&[]), &env).unwrap();
6351 assert_eq!(from_env.settings.agent.prompt.as_deref(), Some("from env"));
6352 }
6353
6354 #[test]
6355 fn minimal_instruction_run_gets_the_sugar_workflow() {
6356 let (l, ask) = load(&args(&["--instruction", "do it"]), &base_env()).unwrap();
6357 assert_eq!(ask, Ask::Run);
6358 assert_eq!(l.settings.agent.instruction.as_deref(), Some("do it"));
6359 assert_eq!(
6360 l.settings.intelligence.endpoints,
6361 vec!["https://intel.example/v1"]
6362 );
6363 assert_eq!(l.settings.workflows.len(), 1, "sugar workflow synthesized");
6364 assert_eq!(l.settings.workflows[0]["name"], json!("main"));
6365 assert_eq!(
6366 l.settings.workflows[0]["steps"]["start"]["kind"],
6367 json!("once")
6368 );
6369 assert!(
6371 l.warnings.iter().any(|w| w.contains("not durable")),
6372 "{:?}",
6373 l.warnings
6374 );
6375 }
6376
6377 #[test]
6378 fn a_long_lived_instance_defaults_to_the_file_store_but_an_explicit_none_is_refused() {
6379 let (l, _) = load(
6381 &args(&[
6382 "--instruction",
6383 "x",
6384 "--a2a.listen",
6385 "http://127.0.0.1:8443",
6386 ]),
6387 &base_env(),
6388 )
6389 .unwrap();
6390 assert_eq!(l.settings.store.kind, StoreKind::File);
6391 let f = write_tmp(
6393 "config_version: \"1\"\nworkflows:\n - name: w\n steps:\n s: {kind: schedule, cron: \"* * * * *\"}\n f: {kind: finish, depends_on: [s], status: completed}\n",
6394 "yaml",
6395 );
6396 let (l, _) = load(
6397 &args(&["--config", f.path().to_str().unwrap()]),
6398 &base_env(),
6399 )
6400 .unwrap();
6401 assert_eq!(l.settings.store.kind, StoreKind::File);
6402 let e = load(
6405 &args(&[
6406 "--config",
6407 f.path().to_str().unwrap(),
6408 "--store.kind",
6409 "none",
6410 ]),
6411 &base_env(),
6412 )
6413 .unwrap_err();
6414 assert!(format!("{e}").contains("long-lived"), "{e}");
6415 let (l, _) = load(&args(&["--instruction", "x"]), &base_env()).unwrap();
6418 assert_eq!(l.settings.store.kind, StoreKind::None);
6419 let (l, _) = load(
6421 &args(&["--instruction", "x", "--store.kind", "memory"]),
6422 &base_env(),
6423 )
6424 .unwrap();
6425 assert!(
6426 l.warnings.iter().any(|w| w.contains("memory")),
6427 "{:?}",
6428 l.warnings
6429 );
6430 }
6431
6432 #[test]
6435 fn expand_env_str_covers_the_forms() {
6436 let env: HashMap<&str, &str> = [("HOST", "db.internal"), ("PORT", "5432")]
6437 .into_iter()
6438 .collect();
6439 assert_eq!(
6441 expand_env_str("${HOST}:${PORT}", &env).unwrap(),
6442 "db.internal:5432"
6443 );
6444 assert_eq!(
6446 expand_env_str("${MISSING:-fallback}", &env).unwrap(),
6447 "fallback"
6448 );
6449 assert_eq!(
6450 expand_env_str("${HOST:-fallback}", &env).unwrap(),
6451 "db.internal"
6452 );
6453 assert_eq!(
6455 expand_env_str("$HOST costs $5", &env).unwrap(),
6456 "$HOST costs $5"
6457 );
6458 assert_eq!(expand_env_str("$${HOST}", &env).unwrap(), "${HOST}");
6460 assert!(
6462 expand_env_str("${NOPE}", &env)
6463 .unwrap_err()
6464 .contains("NOPE")
6465 );
6466 assert!(expand_env_str("${HOST", &env).is_err());
6468 assert!(expand_env_str("${bad-name}", &env).is_err());
6469 }
6470
6471 #[test]
6479 fn principals_and_webhooks_refuse_a_reload_rather_than_no_op() {
6480 let base = json!({
6481 "a2a": {"listen": "http://127.0.0.1:1", "principals": [
6482 {"match": {"any": true}, "role": "user", "labels": {"team": "alpha"}}]},
6483 "webhooks": {"listen": "http://127.0.0.1:2",
6484 "default_auth": {"hmac": {"secret": "{{secret:S}}"}}},
6485 "agent": {"instruction": "before"},
6486 });
6487
6488 let mut changed = base.clone();
6490 changed["a2a"]["principals"][0]["labels"]["team"] = json!("bravo");
6491 assert_eq!(restart_only_diff(&base, &changed), ["a2a.principals"]);
6492
6493 let mut rotated = base.clone();
6495 rotated["webhooks"]["default_auth"]["hmac"]["secret"] = json!("{{secret:S2}}");
6496 assert_eq!(restart_only_diff(&base, &rotated), ["webhooks"]);
6497
6498 let mut instr = base.clone();
6501 instr["agent"]["instruction"] = json!("after");
6502 assert!(restart_only_diff(&base, &instr).is_empty());
6503 }
6504
6505 #[test]
6506 fn config_vars_fold_typed_values_and_collect_every_miss() {
6507 let file = write_tmp(
6508 "config_version: \"1\"\n\
6509 vars:\n region: eu-1\n port: 8443\n team:\n name: platform\n\
6510 agent:\n name: \"svc-{{config.region}}\"\n instruction: serve\n preflight: never\n\
6511 intelligence:\n endpoints: [https://x/v1]\n model: m\n\
6512 store:\n kind: memory\n\
6513 limits:\n step_timeout: \"{{config.port}}s\"\n\
6514 workflows:\n - name: w\n steps:\n\
6515 \x20 s: {kind: once}\n\
6516 \x20 c: {kind: http, depends_on: [s], url: \"https://api.{{config.region}}.example\", headers: {x-team: \"{{config.team.name}}\"}}\n\
6517 \x20 f: {kind: finish, depends_on: [c]}\n",
6518 "yaml",
6519 );
6520 let (l, _) = load(
6521 &args(&["--config", file.path().to_str().unwrap()]),
6522 &base_env(),
6523 )
6524 .unwrap();
6525 assert_eq!(l.settings.agent.name.as_deref(), Some("svc-eu-1"));
6527 assert_eq!(
6529 l.settings
6530 .limits
6531 .step_timeout
6532 .as_ref()
6533 .map(|d| d.0.as_secs()),
6534 Some(8443)
6535 );
6536 let wf = &l.settings.workflows[0];
6540 assert_eq!(
6541 wf.pointer("/steps/c/url").and_then(Value::as_str),
6542 Some("https://api.{{config.region}}.example")
6543 );
6544
6545 let bad = write_tmp(
6547 "config_version: \"1\"\n\
6548 vars:\n set: yes\n\
6549 agent:\n name: \"{{config.gone}}\"\n instruction: serve\n preflight: never\n\
6550 intelligence:\n endpoints: [\"https://{{config.also_gone}}/v1\"]\n model: m\n\
6551 store:\n kind: memory\n",
6552 "yaml",
6553 );
6554 let err = load(
6555 &args(&["--config", bad.path().to_str().unwrap()]),
6556 &base_env(),
6557 )
6558 .err()
6559 .map(|e| e.to_string())
6560 .unwrap_or_default();
6561 assert!(err.contains("config.gone"), "{err}");
6562 assert!(err.contains("config.also_gone"), "{err}");
6563 assert!(
6564 err.contains("2 unresolved config var reference"),
6565 "all misses in one report: {err}"
6566 );
6567 }
6568
6569 #[test]
6570 fn missing_references_name_every_gap_with_its_locations() {
6571 let doc = serde_json::json!({
6572 "a": "{{secret:SUB_WINDOW_TEST_UNSET}}",
6573 "b": {"c": ["{{secret-file:/definitely/not/here}}", "{{config.gone}}"]},
6574 "d": "{{secret:SUB_WINDOW_TEST_UNSET}} again",
6575 });
6576 let vars: std::collections::BTreeMap<String, Value> =
6577 [("present".to_string(), serde_json::json!(1))].into();
6578 let missing = missing_references(&doc, "cfg", &vars);
6579 assert_eq!(missing.len(), 3, "{missing:?}");
6580 let all = missing.join("\n");
6581 assert!(
6582 all.contains("{{secret:SUB_WINDOW_TEST_UNSET}} is not set"),
6583 "{all}"
6584 );
6585 assert!(
6586 all.contains("cfg.a") && all.contains("cfg.d"),
6587 "both locations: {all}"
6588 );
6589 assert!(
6590 all.contains("{{secret-file:/definitely/not/here}} is not readable"),
6591 "{all}"
6592 );
6593 assert!(all.contains("config.gone is not defined in vars"), "{all}");
6594 let ok = serde_json::json!({"x": "{{config.present}}"});
6596 assert!(missing_references(&ok, "cfg", &vars).is_empty());
6597 }
6598
6599 #[test]
6600 fn env_substitution_reaches_config_values_and_workflows() {
6601 let file = write_tmp(
6602 "config_version: \"1\"\n\
6603 agent:\n name: ${SVC_NAME}\n instruction: serve\n preflight: never\n\
6604 intelligence:\n endpoints: [https://x/v1]\n model: m\n\
6605 store:\n kind: memory\n\
6606 workflows:\n - name: w\n steps:\n\
6607 \x20 s: {kind: once}\n\
6608 \x20 c: {kind: http, depends_on: [s], url: \"https://api.${REGION:-us}.example/${SVC_NAME}\"}\n\
6609 \x20 f: {kind: finish, depends_on: [c]}\n",
6610 "yaml",
6611 );
6612 let mut env = base_env();
6613 env.push(("SVC_NAME".into(), "billing".into()));
6614 let (l, _) = load(&args(&["--config", file.path().to_str().unwrap()]), &env).unwrap();
6616 assert_eq!(
6618 l.settings.agent.name.as_deref(),
6619 Some("billing"),
6620 "the `${{SVC_NAME}}` in a config value was substituted"
6621 );
6622 let url = l.settings.workflows[0]
6625 .pointer("/steps/c/url")
6626 .and_then(Value::as_str)
6627 .unwrap_or_default();
6628 assert_eq!(
6629 url, "https://api.us.example/billing",
6630 "the workflow value was substituted (default + set var)"
6631 );
6632 }
6633
6634 #[test]
6635 fn mcp_server_oauth_is_carried_to_the_runtime_spec() {
6636 let s = McpServer {
6640 name: "gh".into(),
6641 endpoint: "https://mcp.example".into(),
6642 service: None,
6643 service_rate: None,
6644 ns: None,
6645 headers: BTreeMap::new(),
6646 tags: BTreeMap::new(),
6647 allow: None,
6648 exclude: Vec::new(),
6649 aauth: None,
6650 oauth: Some(McpOauth {
6651 token_url: "https://auth.example/token".into(),
6652 client_id: "cid".into(),
6653 client_secret: Secret("{{secret:CS}}".into()),
6654 scope: Some("mcp:read".into()),
6655 }),
6656 auth: None,
6657 timeout: None,
6658 };
6659 let spec = s.to_spec().unwrap();
6660 let o = spec.oauth.expect("oauth reaches the runtime spec");
6661 assert_eq!(o.token_url, "https://auth.example/token");
6662 assert_eq!(o.client_id, "cid");
6663 assert_eq!(o.client_secret, "{{secret:CS}}");
6665 assert_eq!(o.scope.as_deref(), Some("mcp:read"));
6666 }
6667
6668 #[test]
6669 fn files_env_flags_layer_in_order_with_aliases() {
6670 let base = write_tmp(
6671 "config_version: \"1\"\nagent:\n instruction: from-file\nintelligence:\n endpoints: [https://file.example/v1]\n model: file-model\nlimits:\n run:\n steps: 10\nstore: { kind: memory }\n",
6672 "yaml",
6673 );
6674 let over = write_tmp("intelligence:\n model: over-model\n", "yml");
6675 let mut env = base_env();
6676 env.clear();
6677 env.push(("AGENTD_LIMITS_RUN_STEPS".into(), "20".into())); env.push(("AGENT_MODEL".into(), "env-model".into())); env.push(("INSTRUCTION".into(), "env-instruction".into())); let (l, _) = load(
6681 &args(&[
6682 "--config",
6683 base.path().to_str().unwrap(),
6684 "--config",
6685 over.path().to_str().unwrap(),
6686 "--max-steps",
6687 "30",
6688 "--mcp",
6689 "fs=https://fs.example/mcp",
6690 "--mcp-tags",
6691 "fs=sensitive",
6692 "--intelligence.headers.x-team",
6693 "ops",
6694 ]),
6695 &env,
6696 )
6697 .unwrap();
6698 let s = &l.settings;
6699 assert_eq!(
6700 s.agent.instruction.as_deref(),
6701 Some("env-instruction"),
6702 "env > file"
6703 );
6704 assert_eq!(
6705 s.intelligence.model.as_deref(),
6706 Some("env-model"),
6707 "env alias > later file"
6708 );
6709 assert_eq!(s.limits.run.steps(), 30, "flag alias > env");
6710 assert_eq!(s.mcp.servers.len(), 1);
6711 assert_eq!(s.mcp.servers[0].name, "fs");
6712 assert_eq!(s.mcp.servers[0].tags["*"], vec!["sensitive"]);
6713 assert_eq!(
6714 s.intelligence.headers.get("x-team").map(String::as_str),
6715 Some("ops")
6716 );
6717 assert_eq!(l.files.len(), 2);
6718 let env2: Vec<(String, String)> = vec![
6720 ("AGENT_MODEL".into(), "legacy".into()),
6721 ("AGENTD_INTELLIGENCE_MODEL".into(), "path".into()),
6722 ("AGENTD_INTELLIGENCE_ENDPOINTS".into(), "https://i".into()),
6723 ];
6724 let (l2, _) = load(
6725 &args(&["--instruction", "x", "--store.kind", "memory"]),
6726 &env2,
6727 )
6728 .unwrap();
6729 assert_eq!(l2.settings.intelligence.model.as_deref(), Some("path"));
6730 }
6731
6732 #[test]
6733 fn removed_flags_name_their_replacement() {
6734 for (flag, _) in REMOVED_FLAGS {
6735 let e = load(&args(&[flag, "x"]), &base_env()).unwrap_err();
6736 assert!(format!("{e}").contains("removed in agentd"), "{flag}: {e}");
6737 }
6738 let e = load(&args(&["--mode", "reactive"]), &base_env()).unwrap_err();
6739 assert!(format!("{e}").contains("start node"), "{e}");
6740 }
6741
6742 #[test]
6743 fn mixed_and_v1_files_are_refused_by_the_v2_loader() {
6744 let mixed = write_tmp("agent: {instruction: x}\nmodel: m\n", "yaml");
6745 let e = load(
6746 &args(&["--config", mixed.path().to_str().unwrap()]),
6747 &base_env(),
6748 )
6749 .unwrap_err();
6750 assert!(format!("{e}").contains("mixes legacy flat keys"), "{e}");
6751 let v1 = write_tmp("model: m\n", "yaml");
6752 let e = load(
6753 &args(&["--config", v1.path().to_str().unwrap()]),
6754 &base_env(),
6755 )
6756 .unwrap_err();
6757 assert!(format!("{e}").contains("retired flat schema"), "{e}");
6758 }
6759
6760 #[test]
6761 fn budget_exit_code_and_instruction_file_aliases() {
6762 let f = write_tmp("read me from a file", "txt");
6763 let (l, _) = load(
6764 &args(&[
6765 "--instruction-file",
6766 f.path().to_str().unwrap(),
6767 "--budget-exit-code",
6768 "9",
6769 "--store.kind",
6770 "memory",
6771 ]),
6772 &base_env(),
6773 )
6774 .unwrap();
6775 assert_eq!(
6776 l.settings.agent.instruction.as_deref(),
6777 Some("read me from a file")
6778 );
6779 assert_eq!(l.settings.lifecycle.exit_code_map.get("3"), Some(&9));
6780 assert_eq!(l.settings.lifecycle.exit_code_map.get("7"), Some(&9));
6781 }
6782
6783 fn load_doc(yaml: &str) -> Result<Loaded, ConfigError> {
6786 let f = write_tmp(yaml, "yaml");
6787 load(&args(&["--config", f.path().to_str().unwrap()]), &[]).map(|(l, _)| l)
6788 }
6789
6790 #[test]
6791 fn validation_collects_the_document_rules() {
6792 let e = load_doc(
6794 "config_version: \"1\"\nintelligence:\n endpoints: [https://i]\n token: sk-inline\n",
6795 )
6796 .unwrap_err();
6797 assert!(format!("{e}").contains("inline credential"), "{e}");
6798 let (l, _) = load(
6799 &args(&[
6800 "--intelligence",
6801 "https://i",
6802 "--intelligence-token",
6803 "sk-inline",
6804 ]),
6805 &[],
6806 )
6807 .unwrap();
6808 assert_eq!(
6809 l.settings.intelligence.token.as_ref().map(|s| s.0.as_str()),
6810 Some("sk-inline")
6811 );
6812 assert!(
6813 !format!("{:?}", l.settings).contains("sk-inline"),
6814 "Debug redacts"
6815 );
6816
6817 let e = load_doc(
6820 "config_version: \"1\"\nstore: {kind: mcp, mcp: {server: nope}}\nknowledge: {server: kb}\nskills: {sources: [{server: sk}]}\ntools: {overrides: {memory.get: {server: mem, tool: t}}, disabled: [memory.get]}\n",
6821 )
6822 .unwrap_err();
6823 assert!(matches!(e, ConfigError::Usage(_)), "{e}");
6824
6825 let f = write_tmp(
6827 "config_version: \"1\"\nstore: {kind: mcp, mcp: {server: nope}}\nknowledge: {server: kb}\nskills: {sources: [{server: sk}]}\ntools: {overrides: {memory.get: {server: mem, tool: t}}, disabled: [memory.get]}\nlifecycle: {exit_code_map: {\"4\": 300}}\n",
6828 "yaml",
6829 );
6830 let e = load(
6831 &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
6832 &[],
6833 )
6834 .unwrap_err();
6835 let ConfigError::Validate(Err(lines)) = e else {
6836 panic!("expected a validate verdict, got {e:?}")
6837 };
6838 for needle in [
6839 "store.mcp.server 'nope'",
6840 "knowledge.server 'kb'",
6841 "skills.sources[]",
6842 "tools.overrides['memory.get']",
6843 "both disabled and overridden",
6844 "only the policy codes 3 and 7",
6845 "0..=255",
6846 ] {
6847 assert!(lines.contains(needle), "missing {needle} in:\n{lines}");
6848 }
6849
6850 let e = load_doc("config_version: \"1\"\nstore: {kind: memory}\na2a: {listen: \"https://0.0.0.0:8443\"}\n").unwrap_err();
6852 assert!(format!("{e}").contains("a2a.tls.cert"), "{e}");
6853 let e = load_doc("config_version: \"1\"\nstore: {kind: memory}\na2a: {listen: \"http://0.0.0.0:8080\"}\n").unwrap_err();
6854 assert!(format!("{e}").contains("loopback"), "{e}");
6855 let e = load_doc(
6857 "config_version: \"1\"\na2a: {principals: [{match: {any: true}, role: operator}]}\n",
6858 )
6859 .unwrap_err();
6860 assert!(format!("{e}").contains("operator role"), "{e}");
6861 let e = load_doc("config_version: \"1\"\nintelligence: {budget: {windows: [{per: hour}], on_exhausted: degrade}}\n").unwrap_err();
6863 assert!(format!("{e}").contains("tokens and/or requests"), "{e}");
6864 let e = load_doc(
6866 "config_version: \"1\"\nmcp:\n servers:\n - {name: fs, endpoint: https://fs/mcp, tags: {\"*\": [untrusted_input, sensitive, egress]}}\n",
6867 )
6868 .unwrap_err();
6869 assert!(format!("{e}").contains("lethal-trifecta"), "{e}");
6870 }
6871
6872 #[test]
6873 fn restart_only_diff_names_changed_paths() {
6874 let a = json!({"agent": {"name": "x", "instruction": "i"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
6875 let b = json!({"agent": {"name": "y", "instruction": "j"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
6876 assert_eq!(restart_only_diff(&a, &b), vec!["agent.name".to_string()]);
6877 let c = json!({"agent": {"name": "x", "instruction": "changed"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
6878 assert!(
6879 restart_only_diff(&a, &c).is_empty(),
6880 "instruction is reloadable"
6881 );
6882 }
6883
6884 #[test]
6885 fn duration_and_tool_select_scalars() {
6886 let s = Settings::from_document(
6887 json!({"limits": {"run": {"deadline": "90s"}, "step_timeout": 5}, "agent": {"tools": {"mcp": "none", "internal": ["memory.get"]}}}),
6888 "t",
6889 )
6890 .unwrap();
6891 assert_eq!(s.limits.run.deadline(), Duration::from_secs(90));
6892 assert_eq!(s.limits.step_timeout, Some(Dur(Duration::from_secs(5))));
6893 assert!(!s.agent.tools.mcp.allows("fs.read"));
6894 assert!(s.agent.tools.internal.allows("memory.get"));
6895 assert!(!s.agent.tools.internal.allows("finish"));
6896 assert!(s.agent.tools.code.allows("anything"));
6897 assert!(
6898 Settings::from_document(json!({"limits": {"run": {"deadline": "soon"}}}), "t").is_err()
6899 );
6900 }
6901
6902 #[test]
6903 fn instruction_config_directives_define_the_agent_and_explicit_keys_win() {
6904 let instr = ":::config\nlimits: {max_runs: 9}\n:::\n\
6905 :::stream{name=orders}\nretention: {max_events: 50}\n:::\n\
6906 :::mcp{name=fs}\nendpoint: \"https://fs.internal/mcp\"\nexclude: [\"delete_*\"]\n:::\n\
6907 Do the work.";
6908 let s = Settings::from_document(
6909 json!({"agent": {"instruction": instr}, "limits": {"max_runs": 3}}),
6910 "t",
6911 )
6912 .unwrap();
6913 assert_eq!(
6914 s.limits.max_runs,
6915 Some(3),
6916 "an explicit key beats the fragment"
6917 );
6918 assert_eq!(
6919 s.streams.get("orders").map(|c| c.max_events()),
6920 Some(50),
6921 "the fragment fills what the config left unsaid"
6922 );
6923 let srv = s
6924 .mcp
6925 .servers
6926 .iter()
6927 .find(|m| m.name == "fs")
6928 .expect("declared");
6929 assert_eq!(srv.endpoint, "https://fs.internal/mcp");
6930 assert_eq!(srv.exclude, vec!["delete_*"]);
6931 let cleaned = s.agent.instruction.as_deref().unwrap();
6932 assert!(cleaned.contains("Do the work."));
6933 assert!(
6934 !cleaned.contains("endpoint"),
6935 "machinery never reaches the model"
6936 );
6937 assert!(
6940 Settings::from_document(
6941 json!({"agent": {"instruction": ":::config\nnot_a_section: 1\n:::\nx"}}),
6942 "t"
6943 )
6944 .is_err()
6945 );
6946 }
6947
6948 #[test]
6951 fn file_store_root_walks_the_chain_in_order() {
6952 use std::ffi::OsString;
6953 use std::path::PathBuf;
6954 let env = |pairs: Vec<(&'static str, &'static str)>| {
6955 move |k: &str| -> Option<OsString> {
6956 pairs
6957 .iter()
6958 .find(|(n, _)| *n == k)
6959 .map(|(_, v)| OsString::from(*v))
6960 }
6961 };
6962 let all = vec![
6963 ("AGENTD_STATE_DIR", "/state-dir"),
6964 ("XDG_STATE_HOME", "/xdg"),
6965 ("HOME", "/home/a"),
6966 ];
6967 let with_file = |path: Option<&str>| Store {
6968 file: Some(StoreFile {
6969 path: path.map(str::to_string),
6970 min_free: None,
6971 }),
6972 ..Store::default()
6973 };
6974
6975 assert_eq!(
6977 file_store_root_in(&with_file(Some("/var/lib/agentd")), &env(all.clone())),
6978 PathBuf::from("/var/lib/agentd")
6979 );
6980 assert_eq!(
6983 file_store_root_in(&with_file(None), &env(all.clone())),
6984 PathBuf::from("/state-dir")
6985 );
6986 assert_eq!(
6988 file_store_root_in(&Store::default(), &env(all[1..].to_vec())),
6989 PathBuf::from("/xdg/agentd/state")
6990 );
6991 assert_eq!(
6993 file_store_root_in(&Store::default(), &env(all[2..].to_vec())),
6994 PathBuf::from("/home/a/.local/state/agentd/state")
6995 );
6996 assert_eq!(
6998 file_store_root_in(&Store::default(), &env(vec![])),
6999 std::env::temp_dir().join("agentd").join("state")
7000 );
7001 assert!(
7004 file_store_root_in(&Store::default(), &env(all[1..].to_vec()))
7005 .ends_with("agentd/state")
7006 );
7007 }
7008
7009 #[test]
7010 fn file_store_validation_diagnostics() {
7011 let l = load_doc("config_version: \"1\"\nstore: {kind: file}\n").unwrap();
7013 assert_eq!(l.settings.store.kind, StoreKind::File);
7014 assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
7015 let l = load_doc(
7017 "config_version: \"1\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\na2a: {listen: \"http://127.0.0.1:8080\"}\n",
7018 )
7019 .unwrap();
7020 assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
7021 assert_eq!(
7022 file_store_root(&l.settings.store),
7023 std::path::PathBuf::from("/var/lib/agentd")
7024 );
7025
7026 let e = load_doc("config_version: \"1\"\nstore: {kind: file, file: {path: \"\"}}\n")
7028 .unwrap_err();
7029 assert!(format!("{e}").contains("store.file.path is empty"), "{e}");
7030
7031 let l = load_doc(
7034 "config_version: \"1\"\nstore: {kind: memory, file: {path: /var/lib/agentd}}\n",
7035 )
7036 .unwrap();
7037 let d = validate(&l);
7038 assert!(d.errors.is_empty(), "{:?}", d.errors);
7039 assert!(
7040 d.warnings
7041 .iter()
7042 .any(|w| w.contains("store.file is set but store.kind is memory")),
7043 "{:?}",
7044 d.warnings
7045 );
7046 let l =
7048 load_doc("config_version: \"1\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\n")
7049 .unwrap();
7050 assert!(
7051 !validate(&l)
7052 .warnings
7053 .iter()
7054 .any(|w| w.contains("store.file")),
7055 "{:?}",
7056 validate(&l).warnings
7057 );
7058 assert_eq!(
7060 restart_only_diff(
7061 &json!({"store": {"kind": "file", "file": {"path": "/a"}}}),
7062 &json!({"store": {"kind": "file", "file": {"path": "/b"}}})
7063 ),
7064 vec!["store.file".to_string()]
7065 );
7066 }
7067
7068 #[test]
7069 fn instruction_uri_detection() {
7070 assert!(looks_like_resource_uri("mcp://docs/agent-instruction"));
7071 assert!(looks_like_resource_uri("docs://agent"));
7072 assert!(!looks_like_resource_uri("You are a helpful agent."));
7073 assert!(!looks_like_resource_uri(
7074 "see https://x.example for details"
7075 ));
7076 assert!(!looks_like_resource_uri("://nope"));
7077 }
7078
7079 #[test]
7080 fn help_and_schema_asks_short_circuit_validation() {
7081 let (_, ask) = load(&args(&["--help"]), &[]).unwrap();
7082 assert_eq!(ask, Ask::Help);
7083 let (_, ask) = load(&args(&["--config-schema=1"]), &[]).unwrap();
7084 assert_eq!(ask, Ask::Schema);
7085 let (_, ask) = load(&args(&["--workflow-schema"]), &[]).unwrap();
7088 assert_eq!(ask, Ask::WorkflowSchema);
7089 assert!(help_section().contains("intelligence.model"));
7090 }
7091
7092 const CATALOG: &str = "config_version: \"1\"\nstore: {kind: memory}\nservices:\n billing:\n endpoint: https://billing.example/mcp\n auth: {kind: static, token: \"{{secret:BILLING}}\"}\n headers: {X-Env: prod}\n tags: {\"*\": [sensitive]}\n allow: [charge_lookup, invoice_*]\n exclude: [invoice_purge]\n brain:\n kind: intelligence\n endpoint: https://intel.example/v1\n";
7095
7096 #[test]
7097 fn service_reference_inherits_and_narrows() {
7098 let f = write_tmp(
7099 &format!(
7100 "{CATALOG}mcp:\n servers:\n - {{name: money, service: billing, allow: [charge_lookup], ns: fin}}\n"
7101 ),
7102 "yaml",
7103 );
7104 let (loaded, _) = load(
7105 &args(&["--config", f.path().to_str().unwrap()]),
7106 &base_env(),
7107 )
7108 .unwrap();
7109 let s = &loaded.settings.mcp.servers[0];
7110 assert_eq!(s.endpoint, "https://billing.example/mcp", "inherited");
7111 assert!(s.auth.is_some(), "inherited auth");
7112 assert_eq!(s.headers["X-Env"], "prod", "inherited headers");
7113 assert_eq!(s.allow.as_deref(), Some(&["charge_lookup".to_string()][..]));
7114 assert_eq!(
7115 s.exclude,
7116 vec!["invoice_purge".to_string()],
7117 "exclude unions"
7118 );
7119 assert_eq!(s.tags["*"], vec!["sensitive"], "tag floor applied");
7120 assert_eq!(s.ns.as_deref(), Some("fin"), "consumer-local ns kept");
7121 }
7122
7123 #[test]
7124 fn service_reference_without_allow_inherits_the_ceiling() {
7125 let f = write_tmp(
7126 &format!("{CATALOG}mcp:\n servers:\n - {{name: money, service: billing}}\n"),
7127 "yaml",
7128 );
7129 let (loaded, _) = load(
7130 &args(&["--config", f.path().to_str().unwrap()]),
7131 &base_env(),
7132 )
7133 .unwrap();
7134 let s = &loaded.settings.mcp.servers[0];
7135 assert_eq!(
7136 s.allow.as_deref(),
7137 Some(&["charge_lookup".to_string(), "invoice_*".to_string()][..]),
7138 "absent consumer allow inherits the catalog ceiling"
7139 );
7140 }
7141
7142 #[test]
7143 fn service_reference_refuses_restated_connection_settings() {
7144 let f = write_tmp(
7145 &format!(
7146 "{CATALOG}mcp:\n servers:\n - {{name: money, service: billing, endpoint: \"https://other.example\"}}\n"
7147 ),
7148 "yaml",
7149 );
7150 let e = load(
7151 &args(&["--config", f.path().to_str().unwrap()]),
7152 &base_env(),
7153 )
7154 .unwrap_err();
7155 let msg = format!("{e}");
7156 assert!(msg.contains("restates `endpoint`"), "{msg}");
7157 }
7158
7159 #[test]
7160 fn service_allow_widening_is_refused() {
7161 let f = write_tmp(
7162 &format!(
7163 "{CATALOG}mcp:\n servers:\n - {{name: money, service: billing, allow: [refund_all]}}\n"
7164 ),
7165 "yaml",
7166 );
7167 let e = load(
7168 &args(&["--config", f.path().to_str().unwrap()]),
7169 &base_env(),
7170 )
7171 .unwrap_err();
7172 let msg = format!("{e}");
7173 assert!(msg.contains("widens the ceiling"), "{msg}");
7174 }
7175
7176 #[test]
7177 fn unknown_service_reference_is_refused() {
7178 let f = write_tmp(
7179 "config_version: \"1\"\nstore: {kind: memory}\nmcp:\n servers:\n - {name: x, service: nope}\n",
7180 "yaml",
7181 );
7182 let e = load(
7183 &args(&["--config", f.path().to_str().unwrap()]),
7184 &base_env(),
7185 )
7186 .unwrap_err();
7187 assert!(format!("{e}").contains("unknown service 'nope'"), "{e}");
7188 }
7189
7190 #[test]
7191 fn tag_floor_applies_to_inline_matching_servers() {
7192 let f = write_tmp(
7196 &format!(
7197 "{CATALOG}mcp:\n servers:\n - {{name: sneaky, endpoint: \"https://billing.example/mcp/sub\"}}\n"
7198 ),
7199 "yaml",
7200 );
7201 let (loaded, _) = load(
7202 &args(&["--config", f.path().to_str().unwrap()]),
7203 &base_env(),
7204 )
7205 .unwrap();
7206 assert_eq!(
7207 loaded.settings.mcp.servers[0].tags["*"],
7208 vec!["sensitive"],
7209 "the catalog's tags are a floor for any matching endpoint"
7210 );
7211 }
7212
7213 #[test]
7214 fn egress_closed_refuses_uncatalogued_and_admits_catalogued() {
7215 let f = write_tmp(
7216 &format!(
7217 "{CATALOG}security: {{egress: closed}}\nmcp:\n servers:\n - {{name: rogue, endpoint: \"https://rogue.example/mcp\"}}\n"
7218 ),
7219 "yaml",
7220 );
7221 let e = load(
7222 &args(&["--config", f.path().to_str().unwrap()]),
7223 &base_env(),
7224 )
7225 .unwrap_err();
7226 let msg = format!("{e}");
7227 assert!(
7228 msg.contains("matches no `kind: mcp` services: catalog entry"),
7229 "{msg}"
7230 );
7231
7232 let ok = write_tmp(
7233 &format!(
7234 "{CATALOG}security: {{egress: closed}}\nmcp:\n servers:\n - {{name: money, service: billing}}\n"
7235 ),
7236 "yaml",
7237 );
7238 load(
7239 &args(&["--config", ok.path().to_str().unwrap()]),
7240 &base_env(),
7241 )
7242 .expect("a catalogued reference passes closed egress");
7243 }
7244
7245 #[test]
7246 fn ambiguous_catalog_endpoints_are_refused() {
7247 let f = write_tmp(
7248 "config_version: \"1\"\nstore: {kind: memory}\nservices:\n a: {endpoint: \"https://s.example/mcp\"}\n b: {endpoint: \"https://s.example/mcp/deeper\"}\n",
7249 "yaml",
7250 );
7251 let e = load(
7252 &args(&["--config", f.path().to_str().unwrap()]),
7253 &base_env(),
7254 )
7255 .unwrap_err();
7256 assert!(format!("{e}").contains("prefix-comparable"), "{e}");
7257 }
7258
7259 #[test]
7260 fn service_match_respects_segment_boundaries() {
7261 let mut services = BTreeMap::new();
7262 services.insert(
7263 "a".to_string(),
7264 Service {
7265 kind: ServiceKind::Mcp,
7266 endpoint: "https://s.example/api".into(),
7267 headers: BTreeMap::new(),
7268 tags: BTreeMap::new(),
7269 allow: None,
7270 exclude: Vec::new(),
7271 auth: None,
7272 rate: None,
7273 timeout: None,
7274 methods: None,
7275 breaker: None,
7276 },
7277 );
7278 let m = ServiceKind::Mcp;
7279 assert!(service_match(&services, m, "https://s.example/api").is_some());
7280 assert!(service_match(&services, m, "https://s.example/api/v2").is_some());
7281 assert!(
7282 service_match(&services, m, "https://s.example/apiary").is_none(),
7283 "prefix match is on segment boundaries, not string prefixes"
7284 );
7285 assert!(service_match(&services, m, "https://other.example/api").is_none());
7286 assert!(
7287 service_match(&services, m, "http://s.example/api").is_none(),
7288 "scheme must match"
7289 );
7290 assert!(
7291 service_match(&services, ServiceKind::Http, "https://s.example/api").is_none(),
7292 "matching is kind-filtered"
7293 );
7294 }
7295
7296 #[test]
7297 fn peer_references_resolve_and_all_four_kinds_gate_closed_egress() {
7298 let f = write_tmp(
7301 "config_version: \"1\"\nstore: {kind: memory}\nsecurity: {egress: closed}\nservices:\n brain: {kind: intelligence, endpoint: \"https://intel.example/v1\"}\n buddy: {kind: peer, endpoint: \"https://peer.example\", auth: {kind: static, token: \"{{secret:PEER}}\"}}\n hooks: {kind: http, endpoint: \"https://hooks.example\", methods: [POST]}\na2a:\n peers:\n - {name: pal, service: buddy}\nworkflows:\n - name: w\n steps:\n s: {kind: once}\n h: {kind: http, depends_on: [s], method: POST, url: \"https://hooks.example/x\"}\n f: {kind: finish, depends_on: [h], status: completed}\n",
7302 "yaml",
7303 );
7304 let (loaded, _) = load(
7305 &args(&["--config", f.path().to_str().unwrap()]),
7306 &base_env(),
7307 )
7308 .expect("all surfaces catalogued ⇒ closed mode admits the config");
7309 let p = &loaded.settings.a2a.peers[0];
7310 assert_eq!(p.endpoint, "https://peer.example", "peer inherited");
7311 assert!(p.auth.is_some(), "peer inherited auth");
7312
7313 let bad = write_tmp(
7314 "config_version: \"1\"\nstore: {kind: memory}\nsecurity: {egress: closed}\na2a:\n peers:\n - {name: rogue, endpoint: \"https://rogue.example\"}\n",
7315 "yaml",
7316 );
7317 let e = load(
7318 &args(&["--config", bad.path().to_str().unwrap()]),
7319 &base_env(),
7320 )
7321 .unwrap_err();
7322 assert!(
7323 format!("{e}").contains("kind: peer"),
7324 "an uncatalogued peer is refused naming the kind: {e}"
7325 );
7326
7327 let badi = write_tmp(
7328 "config_version: \"1\"\nstore: {kind: memory}\nsecurity: {egress: closed}\nintelligence: {endpoints: \"https://rogue-intel.example/v1\"}\n",
7329 "yaml",
7330 );
7331 let e = load(&args(&["--config", badi.path().to_str().unwrap()]), &[]).unwrap_err();
7332 assert!(
7333 format!("{e}").contains("kind: intelligence"),
7334 "an uncatalogued intelligence endpoint is refused: {e}"
7335 );
7336 }
7337
7338 #[test]
7339 fn kind_specific_entry_fields_are_validated() {
7340 let f = write_tmp(
7341 "config_version: \"1\"\nstore: {kind: memory}\nservices:\n x: {kind: http, endpoint: \"https://x.example\", tags: {\"*\": [egress]}}\n y: {kind: mcp, endpoint: \"https://y.example\", methods: [GET]}\n",
7342 "yaml",
7343 );
7344 let e = load(
7345 &args(&["--config", f.path().to_str().unwrap()]),
7346 &base_env(),
7347 )
7348 .unwrap_err();
7349 let msg = format!("{e}");
7350 assert!(msg.contains("`tags` applies to `kind: mcp`"), "{msg}");
7351 assert!(msg.contains("`methods` applies to `kind: http`"), "{msg}");
7352 }
7353
7354 #[test]
7358 fn the_message_hop_cap_binds_without_being_configured() {
7359 let l = Limits::default();
7360 assert_eq!(l.max_message_depth, None, "unset by default");
7361 assert_eq!(l.message_depth(), DEFAULT_MESSAGE_DEPTH);
7362 assert!(
7363 l.message_depth() > 0,
7364 "a cap of 0 would refuse every message"
7365 );
7366 let tuned = Limits {
7368 max_message_depth: Some(2),
7369 ..Default::default()
7370 };
7371 assert_eq!(tuned.message_depth(), 2);
7372 }
7373
7374 #[test]
7375 fn pattern_subsumption_covers_the_glob_grammar() {
7376 assert!(pattern_subsumes("charge_lookup", "charge_lookup"));
7377 assert!(pattern_subsumes("charge_lookup", "charge_*"));
7378 assert!(pattern_subsumes("charge_*", "charge_*"));
7379 assert!(pattern_subsumes("charge_x_*", "charge_*"));
7380 assert!(!pattern_subsumes("charge_*", "charge_lookup"));
7381 assert!(!pattern_subsumes("refund_all", "charge_*"));
7382 assert!(pattern_subsumes("anything", "*"));
7383 }
7384}