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 pub max_value_bytes: Option<u64>,
1182}
1183
1184impl Store {
1185 pub fn prefix(&self) -> &str {
1186 self.prefix.as_deref().unwrap_or("agentd")
1187 }
1188}
1189
1190#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1191#[serde(rename_all = "lowercase")]
1192pub enum StoreKind {
1193 Mcp,
1194 Http,
1195 File,
1198 Memory,
1199 #[default]
1200 None,
1201}
1202
1203#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1207#[serde(deny_unknown_fields)]
1208pub struct StoreFile {
1209 #[serde(default)]
1210 pub path: Option<String>,
1211 #[serde(default)]
1218 pub min_free: Option<String>,
1219}
1220
1221#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1223#[serde(deny_unknown_fields, default)]
1224pub struct StreamCfg {
1225 pub retention: StreamRetention,
1226}
1227
1228#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1232#[serde(deny_unknown_fields, default)]
1233pub struct StreamRetention {
1234 pub max_events: Option<u64>,
1235 pub max_age: Option<Dur>,
1236}
1237
1238impl StreamCfg {
1239 pub fn max_events(&self) -> u64 {
1240 self.retention.max_events.unwrap_or(10_000)
1241 }
1242 pub fn max_age_ms(&self) -> Option<u64> {
1243 self.retention.max_age.map(|d| d.0.as_millis() as u64)
1244 }
1245}
1246
1247pub fn file_store_root(store: &Store) -> std::path::PathBuf {
1264 file_store_root_in(store, &|k| std::env::var_os(k))
1265}
1266
1267fn file_store_root_in(
1271 store: &Store,
1272 env: &dyn Fn(&str) -> Option<std::ffi::OsString>,
1273) -> std::path::PathBuf {
1274 use std::path::PathBuf;
1275 if let Some(p) = store.file.as_ref().and_then(|f| f.path.as_deref()) {
1276 return PathBuf::from(p);
1277 }
1278 if let Some(d) = env("AGENTD_STATE_DIR") {
1279 return PathBuf::from(d);
1280 }
1281 if let Some(d) = env("XDG_STATE_HOME") {
1282 return PathBuf::from(d).join("agentd").join("state");
1283 }
1284 if let Some(h) = env("HOME") {
1285 return PathBuf::from(h)
1286 .join(".local")
1287 .join("state")
1288 .join("agentd")
1289 .join("state");
1290 }
1291 std::env::temp_dir().join("agentd").join("state")
1292}
1293
1294#[derive(Debug, Clone, Deserialize, PartialEq)]
1295#[serde(deny_unknown_fields)]
1296pub struct StoreMcp {
1297 pub server: String,
1298 #[serde(default)]
1299 pub put: Option<StoreOp>,
1300 #[serde(default)]
1301 pub get: Option<StoreOp>,
1302 #[serde(default)]
1303 pub list: Option<StoreOp>,
1304 #[serde(default)]
1305 pub delete: Option<StoreOp>,
1306}
1307
1308#[derive(Debug, Clone, Deserialize, PartialEq)]
1309#[serde(deny_unknown_fields)]
1310pub struct StoreOp {
1311 pub tool: String,
1312 #[serde(default)]
1313 pub args: Option<String>,
1314 #[serde(default)]
1315 pub ok: Option<String>,
1316 #[serde(default)]
1317 pub conflict: Option<String>,
1318 #[serde(default)]
1319 pub value: Option<String>,
1320 #[serde(default)]
1321 pub keys: Option<String>,
1322}
1323
1324#[derive(Debug, Clone, Deserialize, PartialEq)]
1325#[serde(deny_unknown_fields)]
1326pub struct StoreHttp {
1327 pub base_url: String,
1328 #[serde(default)]
1329 pub headers: BTreeMap<String, String>,
1330 #[serde(default)]
1331 pub get: Option<HttpOp>,
1332 #[serde(default)]
1333 pub put: Option<HttpOp>,
1334 #[serde(default)]
1335 pub list: Option<HttpOp>,
1336 #[serde(default)]
1337 pub delete: Option<HttpOp>,
1338}
1339
1340#[derive(Debug, Clone, Deserialize, PartialEq)]
1341#[serde(deny_unknown_fields)]
1342pub struct HttpOp {
1343 #[serde(default)]
1344 pub method: Option<String>,
1345 pub url: String,
1346 #[serde(default)]
1347 pub body: Option<String>,
1348 #[serde(default)]
1349 pub value: Option<String>,
1350 #[serde(default)]
1351 pub keys: Option<String>,
1352 #[serde(default)]
1353 pub conflict_status: Option<u16>,
1354}
1355
1356#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1357#[serde(deny_unknown_fields, default)]
1358pub struct Checkpoint {
1359 pub debounce_ms: Option<u64>,
1360}
1361
1362#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1369#[serde(deny_unknown_fields, default)]
1370pub struct Retention {
1371 pub runs: RunRetention,
1372}
1373
1374#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1375#[serde(deny_unknown_fields, default)]
1376pub struct RunRetention {
1377 pub keep_last: Option<u32>,
1379 pub ttl: Option<Dur>,
1381}
1382
1383#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1384#[serde(deny_unknown_fields, default)]
1385pub struct Durability {
1386 pub a2a: Option<DurabilityLevel>,
1387 pub steps: Option<DurabilityLevel>,
1388 pub work: Option<WorkDurability>,
1395}
1396
1397#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1398#[serde(rename_all = "lowercase")]
1399pub enum WorkDurability {
1400 Durable,
1401 Ephemeral,
1402}
1403
1404#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1405#[serde(rename_all = "lowercase")]
1406pub enum DurabilityLevel {
1407 Strict,
1408 Eventual,
1409}
1410
1411#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1412#[serde(rename_all = "lowercase")]
1413pub enum StoreOnError {
1414 #[default]
1415 Halt,
1416 Degrade,
1417}
1418
1419#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1420#[serde(deny_unknown_fields, default)]
1421pub struct Memory {
1422 pub max_value_bytes: Option<u64>,
1423 pub list_default_limit: Option<u64>,
1424}
1425
1426#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1427#[serde(deny_unknown_fields, default)]
1428pub struct Context {
1429 pub compact_at: Option<f64>,
1430 pub keep_last: Option<u32>,
1431 pub model_window: Option<u64>,
1434 pub plan: Plan,
1435 pub template: Option<String>,
1440 pub templates: BTreeMap<String, String>,
1444 pub summarize: Summarize,
1447}
1448
1449#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1450#[serde(deny_unknown_fields, default)]
1451pub struct Summarize {
1452 pub prompt: Option<String>,
1457 pub model: Option<String>,
1460}
1461
1462#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1463#[serde(deny_unknown_fields, default)]
1464pub struct Plan {
1465 pub max_items: Option<u32>,
1466}
1467
1468#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1469#[serde(deny_unknown_fields, default)]
1470pub struct Knowledge {
1471 pub server: Option<String>,
1472 pub auto_context: AutoContext,
1473}
1474
1475#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1476#[serde(deny_unknown_fields, default)]
1477pub struct AutoContext {
1478 pub on: AutoContextOn,
1479 pub top_k: Option<u32>,
1480 pub max_bytes: Option<u64>,
1481}
1482
1483#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1484#[serde(rename_all = "lowercase")]
1485pub enum AutoContextOn {
1486 Turn,
1487 #[default]
1488 Never,
1489}
1490
1491#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1492#[serde(deny_unknown_fields, default)]
1493pub struct Search {
1494 pub server: Option<String>,
1495}
1496
1497#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1498#[serde(deny_unknown_fields, default)]
1499pub struct Skills {
1500 pub sources: Vec<SkillSource>,
1501 pub dir: Option<String>,
1508 pub reference_prefix: Option<String>,
1509 pub max_loaded: Option<u32>,
1510 pub max_bytes: Option<u64>,
1511}
1512
1513#[derive(Debug, Clone, Deserialize, PartialEq)]
1514#[serde(deny_unknown_fields)]
1515pub struct SkillSource {
1516 pub server: String,
1517 #[serde(default)]
1518 pub discover: Discover,
1519 #[serde(default)]
1520 pub filter: Option<String>,
1521}
1522
1523#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1524#[serde(rename_all = "lowercase")]
1525pub enum Discover {
1526 Prompts,
1527 Resources,
1528 #[default]
1529 Auto,
1530}
1531
1532#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1533#[serde(deny_unknown_fields, default)]
1534pub struct Limits {
1535 pub max_runs: Option<u32>,
1536 pub run: RunLimits,
1537 pub subagents: SubagentLimits,
1538 pub inline_max_bytes: Option<u64>,
1539 pub step_timeout: Option<Dur>,
1540 pub workflow: WorkflowLimits,
1541 pub max_message_depth: Option<u32>,
1547}
1548
1549pub const DEFAULT_MESSAGE_DEPTH: u32 = 8;
1553
1554impl Limits {
1555 pub fn message_depth(&self) -> u32 {
1556 self.max_message_depth.unwrap_or(DEFAULT_MESSAGE_DEPTH)
1557 }
1558}
1559
1560#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1562#[serde(deny_unknown_fields, default)]
1563pub struct WorkflowLimits {
1564 pub fan_out: Option<u32>,
1570}
1571
1572#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1573#[serde(deny_unknown_fields, default)]
1574pub struct RunLimits {
1575 pub steps: Option<u32>,
1576 pub tokens: Option<u64>,
1577 pub deadline: Option<Dur>,
1578}
1579
1580impl RunLimits {
1581 pub fn steps(&self) -> u32 {
1582 self.steps.unwrap_or(500)
1583 }
1584 pub fn tokens(&self) -> u64 {
1585 self.tokens.unwrap_or(2_000_000)
1586 }
1587 pub fn deadline(&self) -> Duration {
1588 self.deadline
1589 .map(|d| d.0)
1590 .unwrap_or(Duration::from_secs(3600))
1591 }
1592}
1593
1594#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1595#[serde(deny_unknown_fields, default)]
1596pub struct SubagentLimits {
1597 pub depth: Option<u32>,
1598 pub breadth: Option<u32>,
1599 pub total: Option<u32>,
1600 pub rate: Option<String>,
1601 pub instances: InstanceLimits,
1605}
1606
1607#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1608#[serde(deny_unknown_fields, default)]
1609pub struct InstanceLimits {
1610 pub breadth: Option<u32>,
1611 pub total: Option<u32>,
1612 pub rate: Option<String>,
1613}
1614
1615#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1617#[serde(deny_unknown_fields, default)]
1618pub struct Subagents {
1619 pub allow_freeform: Option<bool>,
1624 pub defaults: SubagentDefaults,
1627 pub templates: BTreeMap<String, SubagentTemplate>,
1633}
1634
1635#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1636#[serde(deny_unknown_fields, default)]
1637pub struct SubagentDefaults {
1638 pub model: Option<String>,
1639 pub priority: Option<String>,
1640 pub mode: Option<String>,
1641 pub durable: Option<bool>,
1643 pub limits: Option<Value>,
1647}
1648
1649#[derive(Debug, Clone, Deserialize, PartialEq)]
1650#[serde(deny_unknown_fields)]
1651pub struct SubagentTemplate {
1652 pub instruction: String,
1657 #[serde(default)]
1659 pub params: BTreeMap<String, ParamSpec>,
1660 #[serde(default)]
1662 pub servers: Option<Vec<String>>,
1663 #[serde(default)]
1664 pub tools: Option<Vec<String>>,
1665 #[serde(default)]
1668 pub limits: Option<Value>,
1669 #[serde(default)]
1670 pub mode: Option<String>,
1671 #[serde(default)]
1672 pub model: Option<String>,
1673 #[serde(default)]
1674 pub priority: Option<String>,
1675 #[serde(default)]
1676 pub skills: Option<Value>,
1677 #[serde(default)]
1678 pub context: Option<Value>,
1679 #[serde(default)]
1680 pub output_contract: Option<String>,
1681 #[serde(default)]
1682 pub output_schema: Option<Value>,
1683 #[serde(default)]
1686 pub budget: Option<Value>,
1687 #[serde(default)]
1690 pub ttl: Option<Dur>,
1691 #[serde(default)]
1694 pub until: Option<String>,
1695 #[serde(default)]
1697 pub singleton: bool,
1698 #[serde(default)]
1702 pub durable: Option<bool>,
1703 #[serde(default)]
1708 pub result: Option<Value>,
1709 #[serde(default)]
1714 pub mirror_streams: Option<Vec<String>>,
1715}
1716
1717#[derive(Debug, Clone, Deserialize, PartialEq)]
1719#[serde(deny_unknown_fields)]
1720pub struct ParamSpec {
1721 #[serde(rename = "type", default)]
1723 pub kind: Option<String>,
1724 #[serde(default)]
1725 pub required: bool,
1726 #[serde(default)]
1727 pub default: Option<Value>,
1728 #[serde(rename = "enum", default)]
1729 pub one_of: Option<Vec<Value>>,
1730 #[serde(default)]
1731 pub description: Option<String>,
1732}
1733
1734#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1735#[serde(deny_unknown_fields, default)]
1736pub struct Lifecycle {
1737 pub run_until: RunUntil,
1738 pub idle_grace: Option<Dur>,
1739 pub drain_timeout: Option<Dur>,
1740 pub run_id: Option<String>,
1741 pub exit_code_map: BTreeMap<String, i32>,
1742 pub watch_config: bool,
1743 pub until_signal: Option<String>,
1748}
1749
1750impl Lifecycle {
1751 pub fn drain_timeout(&self) -> Duration {
1752 self.drain_timeout
1753 .map(|d| d.0)
1754 .unwrap_or(Duration::from_secs(25))
1755 }
1756 pub fn idle_grace(&self) -> Duration {
1757 self.idle_grace
1758 .map(|d| d.0)
1759 .unwrap_or(Duration::from_secs(5))
1760 }
1761}
1762
1763#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1764#[serde(rename_all = "lowercase")]
1765pub enum RunUntil {
1766 #[default]
1767 Auto,
1768 Idle,
1769 Drained,
1770}
1771
1772#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1773#[serde(deny_unknown_fields, default)]
1774pub struct Identity {
1775 pub autonomous_as: Option<String>,
1780 #[serde(default)]
1782 pub labels: BTreeMap<String, String>,
1783}
1784
1785impl Identity {
1786 pub fn autonomous_id(&self) -> &str {
1787 self.autonomous_as.as_deref().unwrap_or("system")
1788 }
1789}
1790
1791#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1792#[serde(deny_unknown_fields, default)]
1793pub struct A2a {
1794 pub listen: Option<String>,
1795 pub tls: A2aTls,
1796 pub bearer: Option<Secret>,
1797 pub principals: Vec<Principal>,
1798 pub peers: Vec<A2aPeer>,
1799 pub conversation_ttl: Option<Dur>,
1800 pub push: A2aPush,
1801}
1802
1803#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1813#[serde(deny_unknown_fields, default)]
1814pub struct A2aPush {
1815 pub enabled: bool,
1817 pub allow_private: bool,
1819}
1820
1821#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1822#[serde(deny_unknown_fields, default)]
1823pub struct A2aTls {
1824 pub cert: Option<String>,
1825 pub key: Option<String>,
1826 pub client_ca: Option<String>,
1827}
1828
1829#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1840#[serde(deny_unknown_fields, default)]
1841pub struct Interface {
1842 pub enabled: bool,
1844 pub debug: bool,
1848 pub origins: Vec<String>,
1851 pub display: Display,
1854 pub pairing: Pairing,
1858}
1859
1860#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1866#[serde(deny_unknown_fields, default)]
1867pub struct Display {
1868 pub top: Option<Vec<String>>,
1869 pub bottom: Option<Vec<String>>,
1870}
1871
1872pub const DISPLAY_ITEMS: &[&str] = &[
1874 "name", "version", "instance", "model", "endpoint", "conn", "debug", "draining", "active", "turns", "tokens", "tool_calls",
1886 "runs", "subagents", "conversations", "screen", "keys", "clock", ];
1893
1894#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1900#[serde(deny_unknown_fields, default)]
1901pub struct Pairing {
1902 pub enabled: bool,
1903 pub role: Option<Role>,
1906 pub ttl: Option<Dur>,
1908}
1909
1910#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1916#[serde(deny_unknown_fields, default)]
1917pub struct Webhooks {
1918 pub listen: Option<String>,
1921 pub tls: A2aTls,
1922 pub default_auth: Option<WebhookAuth>,
1924}
1925
1926#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1930#[serde(deny_unknown_fields, default)]
1931pub struct WebhookAuth {
1932 pub hmac: Option<Hmac>,
1934 pub bearer: Option<Secret>,
1936 pub header: Option<HeaderMatch>,
1938 pub none: bool,
1940}
1941
1942#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1943#[serde(deny_unknown_fields, default)]
1944pub struct Hmac {
1945 pub secret: Option<Secret>,
1946 pub header: Option<String>,
1948 pub algo: Option<String>,
1950 pub prefix: Option<String>,
1952}
1953
1954#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1955#[serde(deny_unknown_fields, default)]
1956pub struct HeaderMatch {
1957 pub name: Option<String>,
1958 pub equals: Option<Secret>,
1959}
1960
1961#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1966#[serde(deny_unknown_fields, default)]
1967pub struct Goal {
1968 pub statement: Option<String>,
1970 pub check: GoalCheck,
1971 pub stuck_after: Option<u32>,
1973 pub on_achieved: Option<GoalAction>,
1975 pub on_stuck: Option<GoalAction>,
1977}
1978
1979#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1980#[serde(deny_unknown_fields, default)]
1981pub struct GoalCheck {
1982 pub every: Option<Dur>,
1984 pub condition: Option<String>,
1986 pub via: Option<String>,
1988}
1989
1990#[derive(Debug, Clone, PartialEq)]
1993pub enum GoalAction {
1994 Finish,
1995 Idle,
1996 Replan,
1997 Escalate,
1998 Workflow(String),
1999}
2000
2001impl<'de> Deserialize<'de> for GoalAction {
2002 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
2003 use serde::de::Error;
2004 match Value::deserialize(d)? {
2005 Value::String(s) => match s.as_str() {
2006 "finish" => Ok(GoalAction::Finish),
2007 "idle" => Ok(GoalAction::Idle),
2008 "replan" => Ok(GoalAction::Replan),
2009 "escalate" => Ok(GoalAction::Escalate),
2010 other => Err(D::Error::custom(format!(
2011 "unknown goal action '{other}' (want finish|idle|replan|escalate|{{workflow: <name>}})"
2012 ))),
2013 },
2014 Value::Object(m) => match m.get("workflow").and_then(Value::as_str) {
2015 Some(w) => Ok(GoalAction::Workflow(w.to_string())),
2016 None => Err(D::Error::custom(
2017 "a goal action object must be { workflow: <name> }",
2018 )),
2019 },
2020 _ => Err(D::Error::custom(
2021 "a goal action must be a string or { workflow: <name> }",
2022 )),
2023 }
2024 }
2025}
2026
2027#[derive(Debug, Clone, Deserialize, PartialEq)]
2028#[serde(deny_unknown_fields)]
2029pub struct Principal {
2030 #[serde(rename = "match")]
2031 pub matcher: PrincipalMatch,
2032 pub role: Role,
2033 #[serde(default)]
2034 pub grants: Vec<String>,
2035 #[serde(default)]
2036 pub quotas: Option<Quotas>,
2037 #[serde(default)]
2045 pub labels: BTreeMap<String, String>,
2046}
2047
2048#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2049#[serde(deny_unknown_fields, default)]
2050pub struct PrincipalMatch {
2051 pub san: Option<String>,
2052 pub sub: Option<String>,
2053 pub bearer_ref: Option<String>,
2054 pub aauth_agent: Option<String>,
2055 pub any: bool,
2056}
2057
2058#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2059#[serde(rename_all = "lowercase")]
2060pub enum Role {
2061 Operator,
2062 User,
2063 Agent,
2064 Anonymous,
2065}
2066
2067#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2068#[serde(deny_unknown_fields, default)]
2069pub struct Quotas {
2070 pub rate: Option<String>,
2071 pub budget: Option<Budget>,
2072}
2073
2074#[derive(Debug, Clone, Deserialize, PartialEq)]
2075#[serde(deny_unknown_fields)]
2076pub struct A2aPeer {
2077 pub name: String,
2078 #[serde(default)]
2081 pub endpoint: String,
2082 #[serde(default)]
2085 pub service: Option<String>,
2086 #[serde(default)]
2087 pub headers: BTreeMap<String, String>,
2088 #[serde(default)]
2089 pub client_cert: Option<String>,
2090 #[serde(default)]
2091 pub client_key: Option<String>,
2092 #[serde(default)]
2096 pub auth: Option<Auth>,
2097}
2098
2099#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2100#[serde(deny_unknown_fields, default)]
2101pub struct Observability {
2102 pub log_level: Option<String>,
2103 pub log_content: bool,
2104 pub otel: Otel,
2105 pub metrics_addr: Option<String>,
2106 pub health_file: Option<String>,
2107 pub report_file: Option<String>,
2108 pub events_ring: Option<u32>,
2109 pub audit: Audit,
2110 pub traceparent: Option<String>,
2111 pub runtime_events: Option<RuntimeEvents>,
2115}
2116
2117#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2119#[serde(deny_unknown_fields, default)]
2120pub struct RuntimeEvents {
2121 pub stream: Option<String>,
2123 pub include: Vec<String>,
2126 pub sampled: Vec<String>,
2130 pub queue: Option<u32>,
2133}
2134
2135pub const DEFAULT_TAP_QUEUE: u32 = 512;
2137
2138impl RuntimeEvents {
2139 pub fn queue_cap(&self) -> usize {
2140 self.queue.unwrap_or(DEFAULT_TAP_QUEUE) as usize
2141 }
2142}
2143
2144#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2145#[serde(deny_unknown_fields, default)]
2146pub struct Otel {
2147 pub endpoint: Option<String>,
2148 pub traces: Option<bool>,
2149 pub metrics: Option<bool>,
2150 pub logs: Option<bool>,
2151}
2152
2153#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2154#[serde(deny_unknown_fields, default)]
2155pub struct Audit {
2156 pub sink: Option<Vec<AuditSink>>,
2157 pub stream: Option<String>,
2161}
2162
2163#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
2164#[serde(rename_all = "lowercase")]
2165pub enum AuditSink {
2166 Log,
2167 Store,
2168 Stream,
2173}
2174
2175#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2176#[serde(deny_unknown_fields, default)]
2177pub struct Security {
2178 pub allow_trifecta: bool,
2179 pub tls_ca: Option<String>,
2180 pub aauth: Option<AAuth>,
2181 pub cgroup: Cgroup,
2182 pub exec: Exec,
2183 pub workflows: WorkflowSecurity,
2184 pub egress: Egress,
2189 pub policies: Vec<Policy>,
2198}
2199
2200#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2202#[serde(deny_unknown_fields, default)]
2203pub struct Policy {
2204 #[serde(rename = "match")]
2205 pub matcher: PolicyMatch,
2206 pub action: PolicyAction,
2207 pub question: Option<String>,
2210 pub on_timeout: Option<PolicyAction>,
2213 pub timeout: Option<Dur>,
2214}
2215
2216#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2217#[serde(deny_unknown_fields, default)]
2218pub struct PolicyMatch {
2219 pub tool: Option<String>,
2221 pub tags: Vec<String>,
2223 pub caller: Vec<PolicyCaller>,
2225 pub principal: Option<String>,
2227 pub args: Option<String>,
2231}
2232
2233#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
2234#[serde(rename_all = "lowercase")]
2235pub enum PolicyCaller {
2236 Root,
2237 Workflow,
2238 Subagent,
2239}
2240
2241#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
2242#[serde(rename_all = "lowercase")]
2243pub enum PolicyAction {
2244 #[default]
2245 Allow,
2246 Deny,
2247 Ask,
2252 Shadow,
2257}
2258
2259#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
2261#[serde(rename_all = "lowercase")]
2262pub enum Egress {
2263 #[default]
2264 Open,
2265 Closed,
2266}
2267
2268#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2270#[serde(deny_unknown_fields, default)]
2271pub struct WorkflowSecurity {
2272 pub immutable: bool,
2287}
2288
2289#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2299#[serde(deny_unknown_fields, default)]
2300pub struct Exec {
2301 pub enabled: bool,
2303 pub allow: Vec<String>,
2306 pub workdir: Option<String>,
2308 pub timeout: Option<Dur>,
2310 pub max_output: Option<u64>,
2312 pub env: Vec<String>,
2315}
2316
2317#[derive(Debug, Clone, Deserialize, PartialEq)]
2318#[serde(deny_unknown_fields)]
2319pub struct AAuth {
2320 pub provider: String,
2321 #[serde(default)]
2322 pub key_file: Option<String>,
2323 #[serde(default)]
2324 pub enroll_token: Option<Secret>,
2325 #[serde(default)]
2326 pub enroll_assertion_file: Option<String>,
2327 #[serde(default)]
2328 pub person_server: Option<String>,
2329}
2330
2331#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2332#[serde(deny_unknown_fields, default)]
2333pub struct Cgroup {
2334 pub spec: Option<String>,
2335 pub memory_max: Option<String>,
2336 pub pids_max: Option<String>,
2337}
2338
2339#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2341pub struct FoundRef {
2342 pub kind: &'static str,
2344 pub name: String,
2345 pub at: String,
2347}
2348
2349pub fn scan_references(value: &Value, at: &str, out: &mut Vec<FoundRef>) {
2357 match value {
2358 Value::String(s) => {
2359 let mut rest = s.as_str();
2360 while let Some(open) = rest.find("{{") {
2361 let after = &rest[open + 2..];
2362 let Some(close) = after.find("}}") else { break };
2363 let token = after[..close].trim();
2364 if let Some(n) = token.strip_prefix("secret:") {
2365 out.push(FoundRef {
2366 kind: "secret",
2367 name: n.trim().into(),
2368 at: at.into(),
2369 });
2370 } else if let Some(p) = token.strip_prefix("secret-file:") {
2371 out.push(FoundRef {
2372 kind: "secret-file",
2373 name: p.trim().into(),
2374 at: at.into(),
2375 });
2376 } else if let Some(c) = token.strip_prefix("config.") {
2377 out.push(FoundRef {
2378 kind: "config",
2379 name: c.trim().into(),
2380 at: at.into(),
2381 });
2382 }
2383 rest = &after[close + 2..];
2384 }
2385 }
2386 Value::Array(a) => {
2387 for (i, v) in a.iter().enumerate() {
2388 scan_references(v, &format!("{at}[{i}]"), out);
2389 }
2390 }
2391 Value::Object(o) => {
2392 for (k, v) in o {
2393 scan_references(v, &format!("{at}.{k}"), out);
2394 }
2395 }
2396 _ => {}
2397 }
2398}
2399
2400pub fn hmac_algos(value: &Value, at: &str) -> Vec<(String, String)> {
2408 let mut out = Vec::new();
2409 fn walk(v: &Value, at: &str, out: &mut Vec<(String, String)>) {
2410 match v {
2411 Value::Object(o) => {
2412 for (k, child) in o {
2413 if k == "hmac"
2414 && let Some(a) = child.get("algo").and_then(Value::as_str)
2415 {
2416 out.push((format!("{at}.hmac.algo"), a.to_string()));
2417 }
2418 walk(child, &format!("{at}.{k}"), out);
2419 }
2420 }
2421 Value::Array(a) => {
2422 for (i, child) in a.iter().enumerate() {
2423 walk(child, &format!("{at}[{i}]"), out);
2424 }
2425 }
2426 _ => {}
2427 }
2428 }
2429 walk(value, at, &mut out);
2430 out
2431}
2432
2433pub fn missing_references(value: &Value, at: &str, vars: &BTreeMap<String, Value>) -> Vec<String> {
2438 let mut found = Vec::new();
2439 scan_references(value, at, &mut found);
2440 let mut by_ref: BTreeMap<(&'static str, String), Vec<String>> = BTreeMap::new();
2441 for r in found {
2442 let missing = match r.kind {
2443 "secret" => !crate::sec::secret::secret_available(&r.name),
2444 "secret-file" => std::fs::metadata(&r.name).is_err(),
2445 "config" => {
2446 let mut parts = r.name.split('.');
2447 let mut cur = parts.next().and_then(|p| vars.get(p));
2448 for p in parts {
2449 cur = cur.and_then(|v| v.get(p));
2450 }
2451 cur.is_none()
2452 }
2453 _ => false,
2454 };
2455 if missing {
2456 by_ref.entry((r.kind, r.name)).or_default().push(r.at);
2457 }
2458 }
2459 by_ref
2460 .into_iter()
2461 .map(|((kind, name), ats)| {
2462 let what = match kind {
2463 "secret" => format!("{{{{secret:{name}}}}} is not set in the environment"),
2464 "secret-file" => format!("{{{{secret-file:{name}}}}} is not readable"),
2465 _ => format!("config.{name} is not defined in vars"),
2466 };
2467 format!("{what} (referenced at {})", ats.join(", "))
2468 })
2469 .collect()
2470}
2471
2472pub fn substitute_config_vars(
2481 value: &mut Value,
2482 vars: &BTreeMap<String, Value>,
2483 at: &str,
2484 errs: &mut Vec<String>,
2485) {
2486 fn lookup<'a>(vars: &'a BTreeMap<String, Value>, path: &str) -> Option<&'a Value> {
2487 let mut parts = path.split('.');
2488 let mut cur = vars.get(parts.next()?)?;
2489 for p in parts {
2490 cur = cur.get(p)?;
2491 }
2492 Some(cur)
2493 }
2494 fn token_at(s: &str, from: usize) -> Option<(usize, usize, String)> {
2495 let start = s[from..].find("{{config.")? + from;
2496 let end = s[start..].find("}}")? + start + 2;
2497 let name = s[start + 9..end - 2].trim().to_string();
2498 Some((start, end, name))
2499 }
2500 match value {
2501 Value::String(s) => {
2502 if let Some((0, end, name)) = token_at(s, 0)
2504 && end == s.len()
2505 {
2506 match lookup(vars, &name) {
2507 Some(v) => *value = v.clone(),
2508 None => errs.push(format!("{at}: config.{name} is not defined in vars")),
2509 }
2510 return;
2511 }
2512 let mut out = String::new();
2513 let mut pos = 0;
2514 while let Some((start, end, name)) = token_at(s, pos) {
2515 out.push_str(&s[pos..start]);
2516 match lookup(vars, &name) {
2517 Some(Value::String(v)) => out.push_str(v),
2518 Some(v) => out.push_str(&v.to_string()),
2519 None => {
2520 errs.push(format!("{at}: config.{name} is not defined in vars"));
2521 out.push_str(&s[start..end]);
2522 }
2523 }
2524 pos = end;
2525 }
2526 if pos > 0 {
2527 out.push_str(&s[pos..]);
2528 *s = out;
2529 }
2530 }
2531 Value::Array(a) => {
2532 for (i, v) in a.iter_mut().enumerate() {
2533 substitute_config_vars(v, vars, &format!("{at}[{i}]"), errs);
2534 }
2535 }
2536 Value::Object(o) => {
2537 for (k, v) in o.iter_mut() {
2538 substitute_config_vars(v, vars, &format!("{at}.{k}"), errs);
2539 }
2540 }
2541 _ => {}
2542 }
2543}
2544
2545impl Settings {
2546 pub fn from_document(mut doc: Value, source: &str) -> Result<Settings, String> {
2555 let vars: BTreeMap<String, Value> = doc
2556 .get("vars")
2557 .and_then(Value::as_object)
2558 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
2559 .unwrap_or_default();
2560 let workflows = doc.as_object_mut().and_then(|o| o.remove("workflows"));
2561 let mut errs = Vec::new();
2562 substitute_config_vars(&mut doc, &vars, source, &mut errs);
2563 if let (Some(o), Some(w)) = (doc.as_object_mut(), workflows) {
2564 o.insert("workflows".into(), w);
2565 }
2566 if !errs.is_empty() {
2567 return Err(format!(
2568 "{} unresolved config var reference(s):\n {}",
2569 errs.len(),
2570 errs.join("\n ")
2571 ));
2572 }
2573 let mut extraction = None;
2584 if let Some(instr) = doc
2585 .get("agent")
2586 .and_then(|a| a.get("instruction"))
2587 .and_then(Value::as_str)
2588 .map(str::to_string)
2589 && !looks_like_resource_uri(&instr)
2590 && instr.lines().any(|l| l.starts_with(":::"))
2591 {
2592 match crate::config::directives::extract(&instr) {
2593 Ok(ex) => {
2594 if let Some(a) = doc.get_mut("agent").and_then(Value::as_object_mut) {
2599 a.insert("instruction".into(), Value::String(ex.cleaned.clone()));
2600 }
2601 if let (Some(o), Value::Object(fragment)) =
2602 (doc.as_object_mut(), ex.config.clone())
2603 {
2604 crate::config::directives::merge_missing(o, fragment, false);
2605 }
2606 extraction = Some(ex);
2607 }
2608 Err(errs) => {
2609 return Err(format!(
2610 "{source}: agent.instruction directives:
2611 {}",
2612 errs.join(
2613 "
2614 "
2615 )
2616 ));
2617 }
2618 }
2619 }
2620 let mut settings: Settings =
2621 serde_json::from_value(doc).map_err(|e| format!("{source} parse error: {e}"))?;
2622 if let Some(ex) = extraction {
2623 settings.agent.inline_skills = ex.skills;
2624 settings.workflows.extend(ex.workflows);
2625 }
2626 Ok(settings)
2627 }
2628
2629 pub fn instance_name(&self) -> String {
2632 if let Some(n) = &self.agent.name {
2633 return n.clone();
2634 }
2635 let id =
2636 crate::identity::Identity::from_env(self.lifecycle.run_id.as_deref().unwrap_or(""));
2637 if let Some(inst) = id.instance.filter(|i| !i.trim().is_empty()) {
2638 return inst;
2639 }
2640 std::env::var("HOSTNAME")
2641 .ok()
2642 .filter(|h| !h.trim().is_empty())
2643 .unwrap_or_else(|| "agentd".to_string())
2644 }
2645
2646 pub fn is_long_lived(&self) -> bool {
2657 self.a2a.listen.is_some()
2658 || self.webhooks.listen.is_some()
2659 || self.goal.is_some()
2660 || self.workflows.iter().any(workflow_is_long_lived)
2661 }
2662}
2663
2664pub const V2_KEYS: &[&str] = &[
2673 "agent",
2674 "store",
2675 "workflows",
2676 "tools",
2677 "a2a",
2678 "lifecycle",
2679 "observability",
2680 "security",
2681 "knowledge",
2682 "search",
2683 "skills",
2684 "memory",
2685 "context",
2686 "vars",
2687 "streams",
2688];
2689
2690pub const V1_KEYS: &[&str] = &[
2692 "intelligence_headers",
2693 "model_swap",
2694 "model",
2695 "max_tokens",
2696 "mcp_servers",
2697 "subscribe",
2698 "a2a_peers",
2699 "log_level",
2700];
2701
2702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2703pub enum Detected {
2704 Empty,
2706 V1,
2708 V2,
2710 Mixed,
2712}
2713
2714pub fn detect(doc: &Value) -> Detected {
2716 let Some(obj) = doc.as_object() else {
2717 return Detected::Empty;
2718 };
2719 if obj.is_empty() {
2720 return Detected::Empty;
2721 }
2722 let version = obj.get("config_version").and_then(Value::as_str);
2723 let intel_is_object = obj.get("intelligence").is_some_and(Value::is_object);
2724 let intel_is_string = obj.get("intelligence").is_some_and(Value::is_string);
2725 let has_v2 = version == Some(schema::CONFIG_VERSION)
2726 || intel_is_object
2727 || obj.keys().any(|k| V2_KEYS.contains(&k.as_str()));
2728 let has_v1 = intel_is_string
2729 || obj.keys().any(|k| V1_KEYS.contains(&k.as_str()))
2730 || matches!(version, Some(v) if v != schema::CONFIG_VERSION);
2731 match (has_v1, has_v2) {
2732 (true, true) => Detected::Mixed,
2733 (false, true) => Detected::V2,
2734 (true, false) => Detected::V1,
2735 (false, false) => Detected::V1,
2739 }
2740}
2741
2742#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2748pub enum AliasKind {
2749 Set,
2751 SetTrue,
2753 Append,
2755 SetFromFile,
2757 Special,
2759}
2760
2761#[derive(Debug, Clone, Copy)]
2763pub struct Alias {
2764 pub flag: &'static str,
2765 pub path: &'static str,
2766 pub kind: AliasKind,
2767}
2768
2769pub const ALIASES: &[Alias] = &[
2773 Alias {
2774 flag: "--instruction",
2775 path: "agent.instruction",
2776 kind: AliasKind::Set,
2777 },
2778 Alias {
2779 flag: "--instruction-file",
2780 path: "agent.instruction",
2781 kind: AliasKind::SetFromFile,
2782 },
2783 Alias {
2784 flag: "--prompt",
2785 path: "agent.prompt",
2786 kind: AliasKind::Set,
2787 },
2788 Alias {
2789 flag: "--prompt-file",
2790 path: "agent.prompt",
2791 kind: AliasKind::SetFromFile,
2792 },
2793 Alias {
2794 flag: "--intelligence",
2795 path: "intelligence.endpoints",
2796 kind: AliasKind::Set,
2797 },
2798 Alias {
2799 flag: "--intelligence-token",
2800 path: "intelligence.token",
2801 kind: AliasKind::Set,
2802 },
2803 Alias {
2804 flag: "--intelligence-token-file",
2805 path: "intelligence.token_file",
2806 kind: AliasKind::Set,
2807 },
2808 Alias {
2809 flag: "--model",
2810 path: "intelligence.model",
2811 kind: AliasKind::Set,
2812 },
2813 Alias {
2814 flag: "--model-swap",
2815 path: "intelligence.swap_policy",
2816 kind: AliasKind::Set,
2817 },
2818 Alias {
2819 flag: "--budget-tokens-lifetime",
2820 path: "intelligence.budget.lifetime_tokens",
2821 kind: AliasKind::Set,
2822 },
2823 Alias {
2824 flag: "--mcp",
2825 path: "mcp.servers",
2826 kind: AliasKind::Append,
2827 },
2828 Alias {
2829 flag: "--mcp-tags",
2830 path: "mcp.servers",
2831 kind: AliasKind::Special,
2832 },
2833 Alias {
2834 flag: "--a2a-peer",
2835 path: "a2a.peers",
2836 kind: AliasKind::Append,
2837 },
2838 Alias {
2839 flag: "--workflow",
2840 path: "workflows",
2841 kind: AliasKind::Append,
2842 },
2843 Alias {
2844 flag: "--max-steps",
2845 path: "limits.run.steps",
2846 kind: AliasKind::Set,
2847 },
2848 Alias {
2849 flag: "--max-tokens",
2850 path: "limits.run.tokens",
2851 kind: AliasKind::Set,
2852 },
2853 Alias {
2854 flag: "--deadline",
2855 path: "limits.run.deadline",
2856 kind: AliasKind::Set,
2857 },
2858 Alias {
2859 flag: "--max-depth",
2860 path: "limits.subagents.depth",
2861 kind: AliasKind::Set,
2862 },
2863 Alias {
2864 flag: "--run-id",
2865 path: "lifecycle.run_id",
2866 kind: AliasKind::Set,
2867 },
2868 Alias {
2869 flag: "--drain-timeout",
2870 path: "lifecycle.drain_timeout",
2871 kind: AliasKind::Set,
2872 },
2873 Alias {
2874 flag: "--watch-config",
2875 path: "lifecycle.watch_config",
2876 kind: AliasKind::SetTrue,
2877 },
2878 Alias {
2879 flag: "--budget-exit-code",
2880 path: "lifecycle.exit_code_map",
2881 kind: AliasKind::Special,
2882 },
2883 Alias {
2884 flag: "--listen",
2885 path: "a2a.listen",
2886 kind: AliasKind::Set,
2887 },
2888 Alias {
2889 flag: "--serve-mcp",
2890 path: "a2a.listen",
2891 kind: AliasKind::Set,
2892 },
2893 Alias {
2894 flag: "--serve-cert",
2895 path: "a2a.tls.cert",
2896 kind: AliasKind::Set,
2897 },
2898 Alias {
2899 flag: "--serve-key",
2900 path: "a2a.tls.key",
2901 kind: AliasKind::Set,
2902 },
2903 Alias {
2904 flag: "--serve-client-ca",
2905 path: "a2a.tls.client_ca",
2906 kind: AliasKind::Set,
2907 },
2908 Alias {
2909 flag: "--serve-bearer",
2910 path: "a2a.bearer",
2911 kind: AliasKind::Set,
2912 },
2913 Alias {
2914 flag: "--log-level",
2915 path: "observability.log_level",
2916 kind: AliasKind::Set,
2917 },
2918 Alias {
2919 flag: "--log-content",
2920 path: "observability.log_content",
2921 kind: AliasKind::SetTrue,
2922 },
2923 Alias {
2924 flag: "--metrics-addr",
2925 path: "observability.metrics_addr",
2926 kind: AliasKind::Set,
2927 },
2928 Alias {
2929 flag: "--health-file",
2930 path: "observability.health_file",
2931 kind: AliasKind::Set,
2932 },
2933 Alias {
2934 flag: "--report-file",
2935 path: "observability.report_file",
2936 kind: AliasKind::Set,
2937 },
2938 Alias {
2939 flag: "--events-ring",
2940 path: "observability.events_ring",
2941 kind: AliasKind::Set,
2942 },
2943 Alias {
2944 flag: "--traceparent",
2945 path: "observability.traceparent",
2946 kind: AliasKind::Set,
2947 },
2948 Alias {
2949 flag: "--allow-trifecta",
2950 path: "security.allow_trifecta",
2951 kind: AliasKind::SetTrue,
2952 },
2953 Alias {
2954 flag: "--tls-ca",
2955 path: "security.tls_ca",
2956 kind: AliasKind::Set,
2957 },
2958 Alias {
2959 flag: "--aauth-provider",
2960 path: "security.aauth.provider",
2961 kind: AliasKind::Set,
2962 },
2963 Alias {
2964 flag: "--aauth-key-file",
2965 path: "security.aauth.key_file",
2966 kind: AliasKind::Set,
2967 },
2968 Alias {
2969 flag: "--aauth-enroll-token",
2970 path: "security.aauth.enroll_token",
2971 kind: AliasKind::Set,
2972 },
2973 Alias {
2974 flag: "--aauth-enroll-assertion-file",
2975 path: "security.aauth.enroll_assertion_file",
2976 kind: AliasKind::Set,
2977 },
2978 Alias {
2979 flag: "--aauth-person-server",
2980 path: "security.aauth.person_server",
2981 kind: AliasKind::Set,
2982 },
2983 Alias {
2984 flag: "--cgroup",
2985 path: "security.cgroup.spec",
2986 kind: AliasKind::Set,
2987 },
2988 Alias {
2989 flag: "--cgroup-memory-max",
2990 path: "security.cgroup.memory_max",
2991 kind: AliasKind::Set,
2992 },
2993 Alias {
2994 flag: "--cgroup-pids-max",
2995 path: "security.cgroup.pids_max",
2996 kind: AliasKind::Set,
2997 },
2998];
2999
3000pub const ENV_ALIASES: &[(&str, &str)] = &[
3005 ("INSTRUCTION", "agent.instruction"),
3006 ("PROMPT", "agent.prompt"),
3007 ("INTELLIGENCE", "intelligence.endpoints"),
3008 ("INTELLIGENCE_TOKEN", "intelligence.token"),
3009 ("INTELLIGENCE_TOKEN_FILE", "intelligence.token_file"),
3010 ("MODEL", "intelligence.model"),
3011 ("MODEL_SWAP", "intelligence.swap_policy"),
3012 ("BUDGET_TOKENS", "intelligence.budget.lifetime_tokens"),
3013 ("MAX_STEPS", "limits.run.steps"),
3014 ("MAX_TOKENS", "limits.run.tokens"),
3015 ("DEADLINE", "limits.run.deadline"),
3016 ("RUN_ID", "lifecycle.run_id"),
3017 ("DRAIN_TIMEOUT", "lifecycle.drain_timeout"),
3018 ("LOG_LEVEL", "observability.log_level"),
3019 ("LOG_CONTENT", "observability.log_content"),
3020 ("METRICS_ADDR", "observability.metrics_addr"),
3021 ("TRACEPARENT", "observability.traceparent"),
3022 ("SERVE_MCP", "a2a.listen"),
3023 ("SERVE_BEARER", "a2a.bearer"),
3024 ("TLS_CA", "security.tls_ca"),
3025 ("ALLOW_TRIFECTA", "security.allow_trifecta"),
3026 ("WATCH_CONFIG", "lifecycle.watch_config"),
3027];
3028
3029pub const REMOVED_FLAGS: &[(&str, &str)] = &[
3033 (
3034 "--mode",
3035 "modes are gone: give the workflow a start node (`once` | `loop` | `schedule` | `subscribe` | `signal` | `event` | `a2a` | `manual`) and set `lifecycle.run_until` if needed",
3036 ),
3037 (
3038 "--subscribe",
3039 "use a `subscribe` start node: `{kind: subscribe, server: <name>, uri: <uri>}`",
3040 ),
3041 (
3042 "--continue",
3043 "use a `subscribe` start node with `deliver: wait` (or a warm subagent)",
3044 ),
3045 (
3046 "--interval",
3047 "use a `loop` start node with `interval`, or a `schedule` start node with `every`",
3048 ),
3049 ("--cron", "use a `schedule` start node with `cron`"),
3050 (
3055 "--shard",
3056 "agentd does not partition work; give each replica its own subscription (docs/scaling.md)",
3057 ),
3058 (
3059 "--claim",
3060 "call the queue's own claim/lease tools from a workflow step (docs/scaling.md)",
3061 ),
3062 ("--claim-ttl", "it went with --claim"),
3063 ("--claim-renew-fraction", "it went with --claim"),
3064 (
3065 "--standby",
3066 "there is no standby pool; a worker replica is an ordinary instance with its own subscription",
3067 ),
3068 ("--assign-from", "it went with --standby"),
3069 (
3070 "--workflow-resume",
3071 "automatic: runs resume from the store on restart (`resume_policy` per workflow)",
3072 ),
3073 (
3074 "--workflow-resume-force",
3075 "set `resume_policy: force` on the workflow",
3076 ),
3077];
3078
3079#[derive(Debug, Clone)]
3087pub struct Loaded {
3088 pub settings: Settings,
3089 pub doc: Value,
3091 pub file_doc: Value,
3093 pub files: Vec<(String, Format)>,
3094 pub warnings: Vec<String>,
3097}
3098
3099#[derive(Debug, Clone, PartialEq, Eq)]
3102pub enum Ask {
3103 Run,
3104 Help,
3105 Version,
3106 Schema,
3107 WorkflowSchema,
3108 ContextTemplate,
3111 Validate,
3112 Capabilities,
3113 Login(String),
3116 Logout(String),
3118}
3119
3120pub fn probe(args: &[String], env: &[(String, String)]) -> Result<Detected, ConfigError> {
3123 let env = super::debrand_env(env);
3124 let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
3125 let flag_v2 = args
3128 .windows(2)
3129 .any(|w| matches!(w[0].as_str(), "--config-version" | "--config_version") && w[1] == "1")
3130 || args
3131 .iter()
3132 .any(|a| a == "--config-version=1" || a == "--config_version=1")
3133 || envmap
3134 .get("AGENTD_CONFIG_VERSION")
3135 .or_else(|| envmap.get("CONFIG_VERSION"))
3136 .is_some_and(|v| *v == "1");
3137 let paths = super::config_paths_from_map(args, &envmap).paths;
3138 if paths.is_empty() {
3139 return Ok(if flag_v2 {
3140 Detected::V2
3141 } else {
3142 Detected::Empty
3143 });
3144 }
3145 let (doc, _) = file::read_documents_checked(&paths, &|_, _| Ok(())).map_err(usage)?;
3146 let d = detect(&doc);
3147 Ok(match (d, flag_v2) {
3148 (Detected::Empty, true) => Detected::V2,
3149 (Detected::V1, true) => Detected::Mixed,
3150 (d, _) => d,
3151 })
3152}
3153
3154pub fn load(args: &[String], env: &[(String, String)]) -> Result<(Loaded, Ask), ConfigError> {
3159 let env = super::debrand_env(env);
3160 let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
3161 let schema = schema::schema();
3162 let bindings = paths::bindings_of(&schema);
3163 let mut warnings = Vec::new();
3164
3165 let super::ConfigPaths {
3167 paths: config_paths,
3168 discovered,
3169 ambiguous,
3170 } = super::config_paths_from_map(args, &envmap);
3171 if let Some(e) = ambiguous {
3179 return Err(usage(e));
3180 }
3181 let (file_doc, files) = if config_paths.is_empty() {
3182 (Value::Object(Map::new()), Vec::new())
3183 } else {
3184 file::read_documents_checked(&config_paths, &|doc, source| {
3185 match detect(doc) {
3188 Detected::V2 | Detected::Empty => {
3189 Settings::from_document(doc.clone(), source).map(|_| ())
3190 }
3191 _ => Ok(()),
3192 }
3193 })
3194 .map_err(usage)?
3195 };
3196 match detect(&file_doc) {
3197 Detected::Mixed => {
3198 return Err(usage(
3199 "config file mixes legacy flat keys (model/subscribe/mcp_servers/…) with settings sections (agent/intelligence/…); \
3200 migrate the legacy keys (docs/configuration.md §migration)"
3201 .into(),
3202 ));
3203 }
3204 Detected::V1 => {
3205 return Err(usage(
3206 "config file speaks the retired flat schema; the loader needs `config_version: \"1\"` or settings sections (agent/intelligence/…)".into(),
3207 ));
3208 }
3209 _ => {}
3210 }
3211 if discovered {
3224 let file = config_paths.join(", ");
3225 let file = file.as_str();
3226 if let Some((_, label)) = DISCOVERY_FORBIDDEN_RELAXATIONS
3227 .iter()
3228 .find(|(ptr, _)| file_doc.pointer(ptr).and_then(Value::as_bool) == Some(true))
3229 {
3230 return Err(usage(format!(
3231 "{file} was discovered, not named, and it sets {label}: a config found in the \
3232 working directory may not relax a security control. Pass `--config {file}` if \
3233 you meant to run under that file's grant."
3234 )));
3235 }
3236 let touched = discovered_security_settings(&file_doc);
3240 if !touched.is_empty() {
3241 warnings.push(format!(
3242 "adopted the discovered config {file} (no --config given); it sets {}",
3243 touched.join(", ")
3244 ));
3245 }
3246 }
3247 let mut doc = file_doc.clone();
3248
3249 let mut env_doc = Value::Object(Map::new());
3253 for (name, path) in ENV_ALIASES {
3254 let candidates = [
3255 format!("AGENTD_{name}"),
3256 format!("AGENT_{name}"),
3257 (*name).to_string(),
3258 ];
3259 if let Some(raw) = candidates.iter().find_map(|k| envmap.get(k.as_str())) {
3260 let binding = binding_for(&bindings, path)
3261 .ok_or_else(|| usage(format!("internal: alias path {path} not in schema")))?;
3262 let v = binding
3263 .coerce(raw)
3264 .map_err(|e| usage(format!("invalid {}: {e}", candidates[0])))?;
3265 paths::set_path(&mut env_doc, path, v);
3266 }
3267 }
3268 let (derived, _applied) = paths::env_document_in(&bindings, &envmap).map_err(usage)?;
3269 file::merge_into(&mut env_doc, derived);
3270 file::merge_into(&mut doc, env_doc);
3271
3272 let mut ask = Ask::Run;
3274 let mut mcp_tags: Vec<(String, Vec<String>)> = Vec::new();
3275 let mut it = args.iter().peekable();
3276 while let Some(arg) = it.next() {
3277 let a = arg.as_str();
3278 match a {
3279 "-h" | "--help" => ask = Ask::Help,
3280 "-V" | "--version" => ask = Ask::Version,
3281 "--config-schema" | "--config-schema=1" => ask = Ask::Schema,
3282 "--workflow-schema" => ask = Ask::WorkflowSchema,
3283 "--context-template" => ask = Ask::ContextTemplate,
3284 "--validate-config" => ask = Ask::Validate,
3285 "--capabilities" => ask = Ask::Capabilities,
3286 "--login" => {
3287 let t = it
3288 .next()
3289 .cloned()
3290 .ok_or_else(|| usage("--login requires a target (e.g. mcp:<name>)".into()))?;
3291 ask = Ask::Login(t);
3292 }
3293 "--logout" => {
3294 let t = it
3295 .next()
3296 .cloned()
3297 .ok_or_else(|| usage("--logout requires a target (e.g. mcp:<name>)".into()))?;
3298 ask = Ask::Logout(t);
3299 }
3300 "--config" | "-c" => {
3301 it.next(); }
3303 _ if matches!(
3305 crate::config::config_flag(a),
3306 crate::config::ConfigFlag::Inline(_)
3307 ) => {}
3308 _ => {
3309 if let Some((flag, hint)) = REMOVED_FLAGS.iter().find(|(f, _)| *f == a) {
3310 return Err(usage(format!("{flag} was removed in agentd: {hint}")));
3311 }
3312 if let Some(alias) = ALIASES.iter().find(|al| al.flag == a) {
3313 apply_alias(&mut doc, &bindings, alias, &mut it, &mut mcp_tags)?;
3314 continue;
3315 }
3316 match paths::resolve_flag_in(&bindings, a).map_err(usage)? {
3317 Some(target) => {
3318 let raw = if matches!(target.value_kind(), paths::Kind::Boolean)
3319 && !it.peek().is_some_and(|n| !n.starts_with("--"))
3320 {
3321 "true".to_string()
3322 } else {
3323 it.next()
3324 .cloned()
3325 .ok_or_else(|| usage(format!("{a} requires a value")))?
3326 };
3327 let value = paths::coerce(target.value_kind(), &raw)
3328 .map_err(|e| usage(format!("invalid {a}: {e}")))?;
3329 file::merge_into(&mut doc, target.document(value));
3330 }
3331 None => return Err(usage(format!("unknown argument: {a}"))),
3332 }
3333 }
3334 }
3335 }
3336 for (name, tags) in mcp_tags {
3338 let Some(servers) = doc
3339 .pointer_mut("/mcp/servers")
3340 .and_then(Value::as_array_mut)
3341 else {
3342 return Err(usage(format!(
3343 "--mcp-tags references unknown server '{name}'"
3344 )));
3345 };
3346 match servers
3347 .iter_mut()
3348 .find(|s| s.get("name").and_then(Value::as_str) == Some(name.as_str()))
3349 {
3350 Some(s) => {
3351 s["tags"] = json!({ "*": tags });
3352 }
3353 None => {
3354 return Err(usage(format!(
3355 "--mcp-tags references unknown server '{name}'"
3356 )));
3357 }
3358 }
3359 }
3360
3361 apply_default_folders(&mut doc, &config_dirs(&config_paths), &mut warnings);
3367
3368 if ask == Ask::Run || ask == Ask::Validate {
3370 apply_instruction_sugar(&mut doc);
3371 }
3372
3373 if let Err(e) = substitute_env(&mut doc, &envmap) {
3377 return Err(usage(e));
3378 }
3379
3380 let mut settings = Settings::from_document(doc.clone(), "config").map_err(usage)?;
3382 let store_stated =
3413 doc.pointer("/store/kind").is_some() || settings.store.kind != StoreKind::default();
3414 if !store_stated && settings.is_long_lived() {
3415 settings.store.kind = StoreKind::File;
3416 }
3417 let service_errors = resolve_services(&mut settings);
3421 let mut loaded = Loaded {
3422 settings,
3423 doc,
3424 file_doc,
3425 files,
3426 warnings: Vec::new(),
3427 };
3428 if ask == Ask::Run && crate::config::prompt::prompt_missing_requested() {
3434 let mut found = Vec::new();
3435 scan_references(&loaded.doc, "config", &mut found);
3436 let mut names: Vec<String> = found
3437 .into_iter()
3438 .filter(|r| r.kind == "secret" && !crate::sec::secret::secret_available(&r.name))
3439 .map(|r| r.name)
3440 .collect();
3441 names.sort();
3442 names.dedup();
3443 for name in names {
3444 match crate::config::prompt::read_secret_from_tty(&format!("{name} (secret)")) {
3445 Ok(v) => crate::sec::secret::set_prompted(&name, v),
3446 Err(_) => break,
3449 }
3450 }
3451 }
3452 let mut diags = validate(&loaded);
3453 diags.errors.splice(0..0, service_errors);
3454 warnings.extend(diags.warnings);
3455 loaded.warnings = warnings;
3456 if ask != Ask::Validate
3457 && ask != Ask::Help
3458 && ask != Ask::Version
3459 && ask != Ask::Schema
3460 && ask != Ask::WorkflowSchema
3461 && ask != Ask::ContextTemplate
3462 && !matches!(ask, Ask::Login(_) | Ask::Logout(_))
3463 && let Some(first) = diags.errors.first()
3464 {
3465 let msg = if diags.errors.len() == 1 {
3470 first.clone()
3471 } else {
3472 format!(
3473 "{} configuration errors:\n - {}",
3474 diags.errors.len(),
3475 diags.errors.join("\n - ")
3476 )
3477 };
3478 return Err(usage(msg));
3479 }
3480 if ask == Ask::Validate && !diags.errors.is_empty() {
3481 return Err(ConfigError::Validate(Err(diags
3482 .errors
3483 .iter()
3484 .map(|d| super::config_invalid_line(d))
3485 .collect::<Vec<_>>()
3486 .join("\n"))));
3487 }
3488 Ok((loaded, ask))
3489}
3490
3491const DISCOVERY_FORBIDDEN_RELAXATIONS: [(&str, &str); 2] = [
3499 ("/security/allow_trifecta", "security.allow_trifecta"),
3500 ("/security/exec/enabled", "security.exec.enabled"),
3501];
3502
3503const DISCOVERY_SECURITY_SETTINGS: [(&str, &str); 12] = [
3510 ("/intelligence/endpoints", "intelligence.endpoints"),
3511 ("/intelligence/token", "intelligence.token"),
3512 ("/intelligence/token_file", "intelligence.token_file"),
3513 ("/intelligence/headers", "intelligence.headers"),
3514 ("/intelligence/auth", "intelligence.auth"),
3515 ("/mcp/servers", "mcp.servers"),
3516 ("/tools/overrides", "tools.overrides"),
3517 ("/store", "store"),
3518 ("/a2a/listen", "a2a.listen"),
3519 ("/a2a/peers", "a2a.peers"),
3520 ("/webhooks/listen", "webhooks.listen"),
3521 ("/security", "security"),
3522];
3523
3524fn discovered_security_settings(file_doc: &Value) -> Vec<&'static str> {
3528 DISCOVERY_SECURITY_SETTINGS
3529 .iter()
3530 .filter(|(ptr, _)| file_doc.pointer(ptr).is_some_and(|v| !v.is_null()))
3531 .map(|(_, label)| *label)
3532 .collect()
3533}
3534
3535fn binding_for<'a>(bindings: &'a [Binding], path: &str) -> Option<&'a Binding> {
3536 bindings.iter().find(|b| b.path == path)
3537}
3538
3539fn apply_alias(
3540 doc: &mut Value,
3541 bindings: &[Binding],
3542 alias: &Alias,
3543 it: &mut std::iter::Peekable<std::slice::Iter<'_, String>>,
3544 mcp_tags: &mut Vec<(String, Vec<String>)>,
3545) -> Result<(), ConfigError> {
3546 let mut take = || -> Result<String, ConfigError> {
3547 it.next()
3548 .cloned()
3549 .ok_or_else(|| usage(format!("{} requires a value", alias.flag)))
3550 };
3551 match alias.kind {
3552 AliasKind::Set => {
3553 let raw = take()?;
3554 let b = binding_for(bindings, alias.path).ok_or_else(|| {
3555 usage(format!("internal: alias path {} not in schema", alias.path))
3556 })?;
3557 let v = b
3558 .coerce(&raw)
3559 .map_err(|e| usage(format!("invalid {}: {e}", alias.flag)))?;
3560 let mut patch = Value::Object(Map::new());
3561 paths::set_path(&mut patch, alias.path, v);
3562 file::merge_into(doc, patch);
3563 }
3564 AliasKind::SetTrue => {
3565 let mut patch = Value::Object(Map::new());
3566 paths::set_path(&mut patch, alias.path, Value::Bool(true));
3567 file::merge_into(doc, patch);
3568 }
3569 AliasKind::SetFromFile => {
3570 let path = take()?;
3571 let text = super::read_file(&path)?;
3572 let mut patch = Value::Object(Map::new());
3573 paths::set_path(&mut patch, alias.path, Value::String(text));
3574 file::merge_into(doc, patch);
3575 }
3576 AliasKind::Append => {
3577 let raw = take()?;
3578 let element = match alias.flag {
3579 "--mcp" => {
3580 let (name, endpoint) = raw
3581 .split_once('=')
3582 .ok_or_else(|| usage(format!("--mcp: want name=endpoint (got: {raw})")))?;
3583 json!({ "name": name.trim(), "endpoint": endpoint.trim() })
3584 }
3585 "--a2a-peer" => {
3586 let (name, endpoint) = raw.split_once('=').ok_or_else(|| {
3587 usage(format!("--a2a-peer: want name=endpoint (got: {raw})"))
3588 })?;
3589 json!({ "name": name.trim(), "endpoint": endpoint.trim() })
3590 }
3591 "--workflow" => {
3592 let name = std::path::Path::new(&raw)
3593 .file_stem()
3594 .and_then(|s| s.to_str())
3595 .unwrap_or("workflow")
3596 .to_string();
3597 json!({ "name": name, "file": raw })
3598 }
3599 other => return Err(usage(format!("internal: no append rule for {other}"))),
3600 };
3601 append_at(doc, alias.path, element);
3602 }
3603 AliasKind::Special => match alias.flag {
3604 "--mcp-tags" => {
3605 let raw = take()?;
3606 let (name, tags) = raw
3607 .split_once('=')
3608 .ok_or_else(|| usage(format!("--mcp-tags: want name=tag,tag (got: {raw})")))?;
3609 mcp_tags.push((
3610 name.trim().to_string(),
3611 tags.split(',')
3612 .map(str::trim)
3613 .filter(|t| !t.is_empty())
3614 .map(str::to_string)
3615 .collect(),
3616 ));
3617 }
3618 "--budget-exit-code" => {
3619 let raw = take()?;
3620 let n: i64 = raw
3621 .trim()
3622 .parse()
3623 .ok()
3624 .filter(|n| (0..=255).contains(n))
3625 .ok_or_else(|| {
3626 usage(format!("invalid --budget-exit-code: {raw} (want 0..=255)"))
3627 })?;
3628 let mut patch = Value::Object(Map::new());
3629 paths::set_path(
3630 &mut patch,
3631 "lifecycle.exit_code_map",
3632 json!({ "3": n, "7": n }),
3633 );
3634 file::merge_into(doc, patch);
3635 }
3636 other => return Err(usage(format!("internal: no special rule for {other}"))),
3637 },
3638 }
3639 Ok(())
3640}
3641
3642fn append_at(doc: &mut Value, path: &str, element: Value) {
3644 let pointer = format!("/{}", path.replace('.', "/"));
3645 if doc.pointer(&pointer).is_none() {
3646 let mut patch = Value::Object(Map::new());
3647 paths::set_path(&mut patch, path, Value::Array(Vec::new()));
3648 file::merge_into(doc, patch);
3649 }
3650 if let Some(arr) = doc.pointer_mut(&pointer) {
3651 if !arr.is_array() {
3652 *arr = Value::Array(Vec::new());
3653 }
3654 arr.as_array_mut().expect("array").push(element);
3655 }
3656}
3657
3658fn config_dirs(paths: &[String]) -> Vec<PathBuf> {
3677 if paths.is_empty() {
3678 return vec![PathBuf::from(".")];
3679 }
3680 let mut out: Vec<PathBuf> = Vec::new();
3681 for p in paths.iter().rev() {
3682 let d = match Path::new(p).parent() {
3688 Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
3689 _ => PathBuf::from("."),
3690 };
3691 if !out.contains(&d) {
3692 out.push(d);
3693 }
3694 }
3695 out
3696}
3697
3698fn folder_files(dir: &Path, exts: &[&str]) -> Vec<PathBuf> {
3702 let Ok(rd) = std::fs::read_dir(dir) else {
3703 return Vec::new();
3704 };
3705 let mut out: Vec<PathBuf> = rd
3706 .flatten()
3707 .map(|e| e.path())
3708 .filter(|p| p.is_file())
3709 .filter(|p| {
3710 p.extension()
3711 .and_then(|e| e.to_str())
3712 .is_some_and(|e| exts.contains(&e))
3713 })
3714 .collect();
3715 out.sort();
3716 out
3717}
3718
3719fn apply_default_folders(doc: &mut Value, dirs: &[PathBuf], warnings: &mut Vec<String>) {
3736 let Some(obj) = doc.as_object_mut() else {
3737 return;
3738 };
3739
3740 fn find(dirs: &[PathBuf], name: &str, has: impl Fn(&Path) -> bool) -> Option<PathBuf> {
3742 dirs.iter().map(|d| d.join(name)).find(|p| has(p))
3743 }
3744
3745 if !obj.contains_key("workflows")
3748 && let Some(d) = find(dirs, "workflows", |p| {
3749 !folder_files(p, &["yaml", "yml", "json"]).is_empty()
3750 })
3751 {
3752 obj.insert(
3753 "workflows".into(),
3754 json!([{"dir": d.to_string_lossy(), "glob": "*.yaml,*.yml,*.json"}]),
3755 );
3756 }
3757
3758 if obj.get("skills").and_then(|s| s.get("dir")).is_none()
3761 && let Some(d) = find(dirs, "skills", |p| {
3762 !folder_files(p, &["md"]).is_empty()
3763 || std::fs::read_dir(p)
3764 .is_ok_and(|rd| rd.flatten().any(|e| e.path().join("SKILL.md").is_file()))
3765 })
3766 && let Some(sk) = obj
3767 .entry("skills")
3768 .or_insert_with(|| json!({}))
3769 .as_object_mut()
3770 {
3771 sk.insert("dir".into(), json!(d.to_string_lossy()));
3772 }
3773
3774 if obj
3776 .get("subagents")
3777 .and_then(|s| s.get("templates"))
3778 .is_none()
3779 && let Some(d) = find(dirs, "subagents", |p| {
3780 !folder_files(p, &["yaml", "yml", "json"]).is_empty()
3781 })
3782 {
3783 let mut templates = Map::new();
3784 for path in folder_files(&d, &["yaml", "yml", "json"]) {
3785 let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
3786 continue;
3787 };
3788 match file::read_document(&path.to_string_lossy()) {
3789 Ok((v, _)) => {
3790 templates.insert(name.to_string(), v);
3791 }
3792 Err(e) => warnings.push(format!("subagents template {}: {e}", path.display())),
3793 }
3794 }
3795 if !templates.is_empty()
3796 && let Some(sub) = obj
3797 .entry("subagents")
3798 .or_insert_with(|| json!({}))
3799 .as_object_mut()
3800 {
3801 sub.insert("templates".into(), Value::Object(templates));
3802 }
3803 }
3804
3805 if obj
3809 .get("context")
3810 .and_then(|c| c.get("templates"))
3811 .is_none()
3812 && let Some(d) = find(dirs, "context", |p| {
3813 !folder_files(p, &["md", "txt", "hbs"]).is_empty()
3814 })
3815 {
3816 let mut templates = Map::new();
3817 for path in folder_files(&d, &["md", "txt", "hbs"]) {
3818 let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
3819 continue;
3820 };
3821 match std::fs::read_to_string(&path) {
3822 Ok(text) => {
3823 templates.insert(name.to_string(), Value::String(text));
3824 }
3825 Err(e) => warnings.push(format!("context template {}: {e}", path.display())),
3826 }
3827 }
3828 if !templates.is_empty()
3829 && let Some(ctx) = obj
3830 .entry("context")
3831 .or_insert_with(|| json!({}))
3832 .as_object_mut()
3833 {
3834 ctx.insert("templates".into(), Value::Object(templates));
3835 }
3836 }
3837}
3838
3839fn apply_instruction_sugar(doc: &mut Value) {
3849 let has_workflows = doc
3850 .pointer("/workflows")
3851 .and_then(Value::as_array)
3852 .is_some_and(|w| !w.is_empty());
3853 let nonblank = |p: &str| {
3854 doc.pointer(p)
3855 .and_then(Value::as_str)
3856 .is_some_and(|s| !s.trim().is_empty())
3857 };
3858 let has_instruction = nonblank("/agent/instruction");
3859 let carries_workflow = doc
3864 .pointer("/agent/instruction")
3865 .and_then(Value::as_str)
3866 .is_some_and(|t| t.lines().any(|l| l.starts_with(":::workflow")));
3867 if has_workflows || carries_workflow || !has_instruction || nonblank("/agent/prompt") {
3870 return;
3871 }
3872 let work = json!({
3873 "kind": "agent",
3874 "depends_on": ["start"],
3875 "instruction": "{{env.instruction}}",
3876 });
3877 let mut patch = Value::Object(Map::new());
3878 paths::set_path(
3879 &mut patch,
3880 "workflows",
3881 json!([{
3882 "name": "main",
3883 "version": 3,
3884 "steps": {
3885 "start": { "kind": "once" },
3886 "work": work,
3887 "done": { "kind": "finish", "depends_on": ["work"], "status": "completed", "output": "{{steps.work.output}}" }
3888 }
3889 }]),
3890 );
3891 file::merge_into(doc, patch);
3892}
3893
3894fn substitute_env(v: &mut Value, env: &HashMap<&str, &str>) -> Result<(), String> {
3904 match v {
3905 Value::String(s) => {
3906 if s.as_bytes().contains(&b'$') {
3907 *s = expand_env_str(s, env)?;
3908 }
3909 Ok(())
3910 }
3911 Value::Array(a) => a.iter_mut().try_for_each(|item| substitute_env(item, env)),
3912 Value::Object(m) => m.values_mut().try_for_each(|val| substitute_env(val, env)),
3913 _ => Ok(()),
3914 }
3915}
3916
3917fn expand_env_str(s: &str, env: &HashMap<&str, &str>) -> Result<String, String> {
3919 let mut out = String::with_capacity(s.len());
3920 let b = s.as_bytes();
3921 let mut i = 0;
3922 while i < b.len() {
3923 if b[i] == b'$' {
3927 if b.get(i + 1) == Some(&b'$') {
3928 out.push('$'); i += 2;
3930 continue;
3931 }
3932 if b.get(i + 1) == Some(&b'{') {
3933 let start = i + 2;
3934 let Some(rel) = s[start..].find('}') else {
3935 return Err(format!("unterminated `${{` in config value {s:?}"));
3936 };
3937 let end = start + rel;
3938 let expr = &s[start..end];
3939 let (name, default) = match expr.split_once(":-") {
3940 Some((n, d)) => (n.trim(), Some(d)),
3941 None => (expr.trim(), None),
3942 };
3943 if name.is_empty() {
3944 return Err(format!("empty `${{}}` reference in config value {s:?}"));
3945 }
3946 if !name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') {
3947 return Err(format!(
3948 "invalid environment variable name {name:?} in `${{{expr}}}`"
3949 ));
3950 }
3951 match env.get(name) {
3952 Some(val) => out.push_str(val),
3953 None => match default {
3954 Some(d) => out.push_str(d),
3955 None => {
3956 return Err(format!(
3957 "environment variable ${{{name}}} is not set (referenced in config); \
3958 set it or write ${{{name}:-default}}"
3959 ));
3960 }
3961 },
3962 }
3963 i = end + 1;
3964 continue;
3965 }
3966 }
3967 let ch = s[i..].chars().next().unwrap();
3968 out.push(ch);
3969 i += ch.len_utf8();
3970 }
3971 Ok(out)
3972}
3973
3974#[derive(Debug, Default, Clone)]
3981pub struct Diagnostics {
3982 pub errors: Vec<String>,
3983 pub warnings: Vec<String>,
3984}
3985
3986fn validate_auth_block(auth: &Auth, ctx: &str) -> Vec<String> {
3992 let mut out = Vec::new();
3993 for (field, s) in [
3995 ("client_secret", &auth.client_secret),
3996 ("token", &auth.token),
3997 ("value", &auth.value),
3998 ] {
3999 if let Some(sec) = s
4000 && !sec.0.trim().is_empty()
4001 && !crate::sec::secret::has_secret_ref(&sec.0)
4002 {
4003 out.push(format!(
4004 "{ctx}: auth.{field} carries an inline credential; use a {{{{secret:…}}}} reference"
4005 ));
4006 }
4007 }
4008 match auth.kind {
4009 AuthKind::Static => {
4010 let has_bearer = auth.token.is_some();
4011 let has_header = auth.header.is_some() && auth.value.is_some();
4012 if !has_bearer && !has_header {
4013 out.push(format!(
4014 "{ctx}: auth.kind static needs `token` (a bearer) or `header` + `value`"
4015 ));
4016 }
4017 }
4018 AuthKind::Aws => {
4019 if auth.region.is_none() {
4020 out.push(format!("{ctx}: auth.kind aws needs `region`"));
4021 }
4022 if auth.service.is_none() {
4023 out.push(format!(
4024 "{ctx}: auth.kind aws needs `service` (e.g. bedrock, execute-api)"
4025 ));
4026 }
4027 match auth.source.as_deref() {
4028 Some("sso") => {
4029 if auth.sso_start_url.is_none()
4030 || auth.account_id.is_none()
4031 || auth.role_name.is_none()
4032 {
4033 out.push(format!(
4034 "{ctx}: aws source sso needs `sso_start_url` + `account_id` + `role_name`"
4035 ));
4036 }
4037 }
4038 Some(src) if !matches!(src, "env" | "static" | "imds" | "irsa") => {
4039 out.push(format!(
4040 "{ctx}: auth.source '{src}' is not a known AWS source (env|static|imds|irsa|sso)"
4041 ));
4042 }
4043 _ => {}
4044 }
4045 }
4046 AuthKind::Spiffe => match auth.svid.as_deref().unwrap_or("jwt") {
4047 "jwt" => {
4048 if auth.jwt_svid_file.is_none() {
4049 out.push(format!(
4050 "{ctx}: auth.kind spiffe (svid jwt) needs `jwt_svid_file`"
4051 ));
4052 }
4053 }
4054 "x509" => {
4055 if auth.svid_file.is_none() || auth.key_file.is_none() {
4056 out.push(format!(
4057 "{ctx}: auth.kind spiffe (svid x509) needs `svid_file` + `key_file`"
4058 ));
4059 }
4060 }
4061 other => out.push(format!("{ctx}: auth.svid '{other}' (want jwt|x509)")),
4062 },
4063 AuthKind::Oauth2 => {
4064 if auth.client_id.is_none() {
4065 out.push(format!("{ctx}: auth.kind oauth2 needs `client_id`"));
4066 }
4067 if auth.token_url.is_none() && auth.issuer.is_none() {
4068 out.push(format!(
4069 "{ctx}: auth oauth2 needs `token_url` or `issuer` (for discovery)"
4070 ));
4071 }
4072 match auth.grant.unwrap_or(OAuthGrant::Device) {
4073 OAuthGrant::Device => {
4074 if auth.device_authorization_url.is_none() && auth.issuer.is_none() {
4075 out.push(format!(
4076 "{ctx}: the device grant needs `device_authorization_url` or `issuer`"
4077 ));
4078 }
4079 }
4080 OAuthGrant::ClientCredentials => {
4081 if auth.client_secret.is_none() {
4082 out.push(format!(
4083 "{ctx}: the client_credentials grant needs `client_secret`"
4084 ));
4085 }
4086 }
4087 OAuthGrant::AuthorizationCode => {
4088 if auth.authorization_url.is_none() && auth.issuer.is_none() {
4089 out.push(format!(
4090 "{ctx}: the authorization_code grant needs `authorization_url` or `issuer`"
4091 ));
4092 }
4093 }
4094 }
4095 }
4096 }
4097 out
4098}
4099
4100fn unresolved_secret_ref(value: &str) -> Option<String> {
4111 if !crate::sec::secret::has_secret_ref(value) {
4112 return None;
4113 }
4114 crate::sec::secret::refs_resolvable(value, &|k| {
4117 crate::sec::secret::prompted_of(k).or_else(|| std::env::var(k).ok())
4118 })
4119 .err()
4120}
4121
4122pub fn validate(loaded: &Loaded) -> Diagnostics {
4123 let s = &loaded.settings;
4124 let mut d = Diagnostics::default();
4125 let err = |d: &mut Diagnostics, m: String| d.errors.push(m);
4126
4127 for m in missing_references(&loaded.doc, "config", &s.vars) {
4141 err(&mut d, m);
4142 }
4143
4144 for (at, algo) in hmac_algos(&loaded.doc, "config") {
4149 if !algo.eq_ignore_ascii_case("sha256") {
4150 err(
4151 &mut d,
4152 format!(
4153 "{at} {algo:?} is not implemented — agentd computes HMAC-SHA256 only; use `algo: sha256` (or omit it) and have senders sign SHA-256"
4154 ),
4155 );
4156 }
4157 }
4158
4159 if let Some(v) = &s.config_version
4161 && v != schema::CONFIG_VERSION
4162 {
4163 err(
4164 &mut d,
4165 format!(
4166 "config_version must be \"{}\" (got {v:?})",
4167 schema::CONFIG_VERSION
4168 ),
4169 );
4170 }
4171
4172 if let Some(re) = &s.observability.runtime_events {
4177 match re.stream.as_deref() {
4178 None => err(
4179 &mut d,
4180 "observability.runtime_events: `stream` is required".into(),
4181 ),
4182 Some(name) if !s.streams.contains_key(name) => err(
4183 &mut d,
4184 format!(
4185 "observability.runtime_events.stream: {name:?} is not declared (add it under `streams:`)"
4186 ),
4187 ),
4188 Some(_) => {}
4189 }
4190 if re.include.is_empty() && re.sampled.is_empty() {
4191 err(
4192 &mut d,
4193 "observability.runtime_events: name at least one family in `include` or `sampled`"
4194 .into(),
4195 );
4196 }
4197 for f in re.include.iter().chain(re.sampled.iter()) {
4198 if !crate::obs::log::EVENT_FAMILIES.contains(&f.as_str()) {
4199 err(
4200 &mut d,
4201 format!(
4202 "observability.runtime_events: unknown event family {f:?} (known: {})",
4203 crate::obs::log::EVENT_FAMILIES.join(", ")
4204 ),
4205 );
4206 }
4207 }
4208 for f in &re.sampled {
4209 if re.include.contains(f) {
4210 err(
4211 &mut d,
4212 format!(
4213 "observability.runtime_events: family {f:?} is in both `include` and `sampled` — pick one"
4214 ),
4215 );
4216 }
4217 }
4218 }
4219 if let Some(sinks) = &s.observability.audit.sink
4220 && sinks.iter().any(|x| matches!(x, AuditSink::Stream))
4221 {
4222 match s.observability.audit.stream.as_deref() {
4223 None => err(
4224 &mut d,
4225 "observability.audit: `sink: [stream]` needs `stream: <name>`".into(),
4226 ),
4227 Some(name) if !s.streams.contains_key(name) => err(
4228 &mut d,
4229 format!(
4230 "observability.audit.stream: {name:?} is not declared (add it under `streams:`)"
4231 ),
4232 ),
4233 Some(_) => {}
4234 }
4235 }
4236
4237 for (name, t) in &s.intelligence.models {
4241 let at = format!("intelligence.models.{name}");
4242 if t.model.as_deref().unwrap_or("").trim().is_empty() {
4243 err(&mut d, format!("{at}: `model` is required"));
4244 }
4245 if let Some(svc) = &t.service {
4246 match s.services.get(svc) {
4247 None => err(
4248 &mut d,
4249 format!("{at}.service: {svc:?} is not declared (add it under `services:`)"),
4250 ),
4251 Some(entry) if entry.kind != ServiceKind::Intelligence => err(
4252 &mut d,
4253 format!(
4254 "{at}.service: {svc:?} is `kind: {}` — a model tier needs `kind: intelligence`",
4255 entry.kind.as_str()
4256 ),
4257 ),
4258 Some(_) => {}
4259 }
4260 }
4261 if let Some(f) = &t.fallback {
4262 if !s.intelligence.models.contains_key(f) {
4263 err(&mut d, format!("{at}.fallback: no model tier named {f:?}"));
4264 } else if f == name {
4265 err(
4266 &mut d,
4267 format!("{at}.fallback: a tier cannot fall back to itself"),
4268 );
4269 }
4270 }
4271 }
4272 for name in s.intelligence.models.keys() {
4275 let mut seen = vec![name.clone()];
4276 let mut cur = name.clone();
4277 while let Some(next) = s
4278 .intelligence
4279 .models
4280 .get(&cur)
4281 .and_then(|t| t.fallback.clone())
4282 {
4283 if seen.contains(&next) {
4284 err(
4285 &mut d,
4286 format!(
4287 "intelligence.models: fallback cycle {} -> {next}",
4288 seen.join(" -> ")
4289 ),
4290 );
4291 break;
4292 }
4293 seen.push(next.clone());
4294 cur = next;
4295 }
4296 }
4297 for (field, reference) in [
4298 ("intelligence.default", s.intelligence.default.as_ref()),
4299 (
4300 "intelligence.preflight_model",
4301 s.intelligence.preflight_model.as_ref(),
4302 ),
4303 (
4304 "context.summarize.model",
4305 s.context.summarize.model.as_ref(),
4306 ),
4307 ] {
4308 if let Some(r) = reference
4312 && !s.intelligence.models.is_empty()
4313 && !s.intelligence.models.contains_key(r)
4314 {
4315 err(
4316 &mut d,
4317 format!(
4318 "{field}: {r:?} is not a declared model tier (known: {})",
4319 s.intelligence
4320 .models
4321 .keys()
4322 .cloned()
4323 .collect::<Vec<_>>()
4324 .join(", ")
4325 ),
4326 );
4327 }
4328 }
4329
4330 for (i, p) in s.a2a.principals.iter().enumerate() {
4334 let Some(q) = &p.quotas else { continue };
4335 if let Some(r) = &q.rate
4336 && let Err(e) = crate::supervisor::tree::parse_rate(r)
4337 {
4338 err(&mut d, format!("a2a.principals[{i}].quotas.rate: {e}"));
4339 }
4340 if let Some(b) = &q.budget {
4341 validate_budget(b, &format!("a2a.principals[{i}].quotas.budget"), &mut d);
4342 }
4343 }
4344
4345 for (i, p) in s.security.policies.iter().enumerate() {
4348 let at = format!("security.policies[{i}]");
4349 if let Some(expr) = &p.matcher.args {
4350 if !cfg!(feature = "cel") {
4351 err(
4352 &mut d,
4353 format!(
4354 "{at}: `match.args` needs the `cel` feature; this build cannot evaluate an \
4355 argument guard, and silently treating it as no-match would turn a deny \
4356 into an allow"
4357 ),
4358 );
4359 } else if let Err(e) =
4360 crate::cel::compile_check(expr.trim().trim_start_matches("CEL:").trim())
4361 {
4362 err(&mut d, format!("{at}: match.args: {e}"));
4363 }
4364 }
4365 for t in &p.matcher.tags {
4366 if !["untrusted_input", "sensitive", "egress"].contains(&t.as_str()) {
4367 err(
4368 &mut d,
4369 format!("{at}: unknown tag {t:?} (want untrusted_input|sensitive|egress)"),
4370 );
4371 }
4372 }
4373 if p.action != PolicyAction::Ask && (p.question.is_some() || p.on_timeout.is_some()) {
4374 err(
4375 &mut d,
4376 format!("{at}: `question`/`on_timeout` apply to `action: ask`"),
4377 );
4378 }
4379 if p.on_timeout == Some(PolicyAction::Ask) {
4380 err(
4381 &mut d,
4382 format!("{at}: `on_timeout: ask` would ask again forever"),
4383 );
4384 }
4385 }
4386
4387 for e in &s.intelligence.endpoints {
4389 if let Err(e) = super::validate_intelligence_uri(e) {
4390 err(&mut d, e.to_string());
4391 }
4392 }
4393 if let Some(p) = &s.intelligence.swap_policy
4394 && super::SwapPolicy::parse(p).is_none()
4395 {
4396 err(
4397 &mut d,
4398 format!("intelligence.swap_policy: {p:?} (want finish-on-old|restart-turn)"),
4399 );
4400 }
4401 if s.intelligence.token.is_some() && s.intelligence.token_file.is_some() {
4402 d.warnings.push(
4403 "intelligence.token and intelligence.token_file are both set; the inline token wins"
4404 .into(),
4405 );
4406 }
4407 if let Some(auth) = &s.intelligence.auth {
4408 for e in validate_auth_block(auth, "intelligence") {
4409 err(&mut d, e);
4410 }
4411 }
4412 if let Some(dialect) = &s.intelligence.dialect {
4413 if crate::intel::client::Provider::from_dialect(Some(dialect)).is_none() {
4414 err(
4415 &mut d,
4416 format!("intelligence.dialect: {dialect:?} (want openai|anthropic|bedrock)"),
4417 );
4418 }
4419 if dialect == "bedrock"
4422 && !matches!(
4423 s.intelligence.auth.as_ref().map(|a| a.kind),
4424 Some(AuthKind::Aws)
4425 )
4426 {
4427 err(
4428 &mut d,
4429 "intelligence.dialect: bedrock requires intelligence.auth.kind = aws (SigV4)"
4430 .into(),
4431 );
4432 }
4433 }
4434 validate_budget(&s.intelligence.budget, "intelligence.budget", &mut d);
4435 if let Some(b) = &s.agent.conversation_budget {
4436 validate_budget(b, "agent.conversation_budget", &mut d);
4437 }
4438 for (name, value) in &s.intelligence.headers {
4439 if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
4440 err(
4441 &mut d,
4442 format!(
4443 "intelligence.headers['{name}'] looks like a credential but has an inline value; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}}"
4444 ),
4445 );
4446 } else if let Some(e) = unresolved_secret_ref(value) {
4447 err(&mut d, format!("intelligence.headers['{name}']: {e}"));
4448 }
4449 }
4450
4451 let mut names = std::collections::HashSet::new();
4453 for srv in &s.mcp.servers {
4454 if srv.name.trim().is_empty() {
4455 err(&mut d, "mcp.servers[]: a server has an empty name".into());
4456 }
4457 if !names.insert(srv.name.as_str()) {
4458 err(
4459 &mut d,
4460 format!("mcp.servers[]: duplicate server name '{}'", srv.name),
4461 );
4462 }
4463 if srv.name == "code" {
4464 err(
4465 &mut d,
4466 "mcp.servers[]: the server name 'code' is reserved for code-registered tools"
4467 .into(),
4468 );
4469 }
4470 if srv.endpoint.is_empty() {
4471 if srv.service.is_none() {
4474 err(
4475 &mut d,
4476 format!(
4477 "mcp server '{}' needs an `endpoint` or a `service:` catalog reference",
4478 srv.name
4479 ),
4480 );
4481 }
4482 } else if let Err(e) = super::mcp_endpoint_scheme_ok(&srv.endpoint) {
4483 err(&mut d, format!("mcp server '{}': {e}", srv.name));
4484 }
4485 if let Err(e) = srv.tag_set() {
4486 err(&mut d, e);
4487 }
4488 for (h, v) in &srv.headers {
4489 if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
4490 err(
4491 &mut d,
4492 format!(
4493 "mcp server '{}' header '{h}' looks like a credential but has an inline value; use a {{{{secret:…}}}} reference",
4494 srv.name
4495 ),
4496 );
4497 } else if let Some(e) = unresolved_secret_ref(v) {
4498 err(
4499 &mut d,
4500 format!("mcp server '{}' header '{h}': {e}", srv.name),
4501 );
4502 }
4503 }
4504 if let Some(auth) = &srv.auth {
4505 for e in validate_auth_block(auth, &format!("mcp server '{}'", srv.name)) {
4506 err(&mut d, e);
4507 }
4508 }
4509 }
4510 for (name, svc) in &s.services {
4512 if name.is_empty()
4513 || !name
4514 .chars()
4515 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
4516 {
4517 err(
4518 &mut d,
4519 format!("services: entry name '{name}' must be [a-zA-Z0-9_-]+"),
4520 );
4521 }
4522 match svc.kind {
4525 ServiceKind::Peer => {
4526 if let Err(e) = crate::config::A2aEndpoint::parse(&svc.endpoint) {
4527 err(&mut d, format!("services.{name}: {e}"));
4528 }
4529 }
4530 _ => {
4531 if let Err(e) = super::mcp_endpoint_scheme_ok(&svc.endpoint) {
4532 err(&mut d, format!("services.{name}: {e}"));
4533 }
4534 }
4535 }
4536 if svc.kind != ServiceKind::Mcp {
4539 for (set, what) in [
4540 (svc.allow.is_some(), "allow"),
4541 (!svc.exclude.is_empty(), "exclude"),
4542 (!svc.tags.is_empty(), "tags"),
4543 (svc.breaker.is_some(), "breaker"),
4544 ] {
4545 if set {
4546 err(
4547 &mut d,
4548 format!(
4549 "services.{name}: `{what}` applies to `kind: mcp` entries only (this entry is `kind: {}`)",
4550 svc.kind.as_str()
4551 ),
4552 );
4553 }
4554 }
4555 }
4556 if svc.methods.is_some() && svc.kind != ServiceKind::Http {
4557 err(
4558 &mut d,
4559 format!(
4560 "services.{name}: `methods` applies to `kind: http` entries only (this entry is `kind: {}`)",
4561 svc.kind.as_str()
4562 ),
4563 );
4564 }
4565 if let Some(ms) = &svc.methods {
4566 for m in ms {
4567 if !matches!(
4568 m.as_str(),
4569 "GET" | "PUT" | "POST" | "DELETE" | "PATCH" | "HEAD"
4570 ) {
4571 err(
4572 &mut d,
4573 format!(
4574 "services.{name}.methods: unknown method '{m}' (want GET|PUT|POST|DELETE|PATCH|HEAD, uppercase)"
4575 ),
4576 );
4577 }
4578 }
4579 }
4580 if let Some(b) = &svc.breaker
4581 && crate::runtime::breaker::Config::of(Some(b)).is_none()
4582 {
4583 err(
4584 &mut d,
4585 format!(
4586 "services.{name}.breaker: want {{failures: N>=1, cooldown: \"60s\"}} — both fields required"
4587 ),
4588 );
4589 }
4590 for list in svc.tags.values() {
4591 for t in list {
4592 if crate::sec::scope::TrifectaTag::parse(t).is_none() {
4593 err(
4594 &mut d,
4595 format!("services.{name} has unknown trifecta tag '{t}'"),
4596 );
4597 }
4598 }
4599 }
4600 for (h, v) in &svc.headers {
4601 if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
4602 err(
4603 &mut d,
4604 format!(
4605 "services.{name} header '{h}' looks like a credential but has an inline value; use a {{{{secret:…}}}} reference"
4606 ),
4607 );
4608 } else if let Some(e) = unresolved_secret_ref(v) {
4609 err(&mut d, format!("services.{name} header '{h}': {e}"));
4610 }
4611 }
4612 if let Some(auth) = &svc.auth {
4613 for e in validate_auth_block(auth, &format!("services.{name}")) {
4614 err(&mut d, e);
4615 }
4616 }
4617 if let Some(r) = &svc.rate
4618 && let Err(e) = crate::supervisor::tree::parse_rate(r)
4619 {
4620 err(&mut d, format!("services.{name}.rate: {e}"));
4621 }
4622 }
4623 {
4627 let entries: Vec<(&String, &Service)> = s.services.iter().collect();
4628 for i in 0..entries.len() {
4629 for j in (i + 1)..entries.len() {
4630 if entries[i].1.kind != entries[j].1.kind {
4631 continue;
4632 }
4633 let kind = entries[i].1.kind;
4634 let one = BTreeMap::from([(entries[i].0.clone(), entries[i].1.clone())]);
4635 let other = BTreeMap::from([(entries[j].0.clone(), entries[j].1.clone())]);
4636 if service_match(&one, kind, &entries[j].1.endpoint).is_some()
4637 || service_match(&other, kind, &entries[i].1.endpoint).is_some()
4638 {
4639 err(
4640 &mut d,
4641 format!(
4642 "services.{} and services.{} have prefix-comparable endpoints of the same kind — URL matching must be unambiguous",
4643 entries[i].0, entries[j].0
4644 ),
4645 );
4646 }
4647 }
4648 }
4649 }
4650 if s.security.egress == Egress::Closed {
4653 let closed = |d: &mut Diagnostics, kind: ServiceKind, what: &str, url: &str| {
4654 if service_match(&s.services, kind, url).is_none() {
4655 d.errors.push(format!(
4656 "security.egress is closed and {what} ({url}) matches no `kind: {}` services: catalog entry — catalog the endpoint to allow it",
4657 kind.as_str()
4658 ));
4659 }
4660 };
4661 for srv in &s.mcp.servers {
4662 if !srv.endpoint.is_empty() {
4663 closed(
4664 &mut d,
4665 ServiceKind::Mcp,
4666 &format!("mcp server '{}'", srv.name),
4667 &srv.endpoint,
4668 );
4669 }
4670 }
4671 for e in &s.intelligence.endpoints {
4672 if !e.starts_with("mock:") {
4674 closed(
4675 &mut d,
4676 ServiceKind::Intelligence,
4677 "intelligence endpoint",
4678 e,
4679 );
4680 }
4681 }
4682 for p in &s.a2a.peers {
4683 if !p.endpoint.is_empty() {
4684 closed(
4685 &mut d,
4686 ServiceKind::Peer,
4687 &format!("a2a peer '{}'", p.name),
4688 &p.endpoint,
4689 );
4690 }
4691 }
4692 if s.store.kind == StoreKind::Http
4693 && let Some(h) = &s.store.http
4694 {
4695 closed(
4696 &mut d,
4697 ServiceKind::Http,
4698 "store.http.base_url",
4699 &h.base_url,
4700 );
4701 for (opname, op) in [
4703 ("get", &h.get),
4704 ("put", &h.put),
4705 ("list", &h.list),
4706 ("delete", &h.delete),
4707 ] {
4708 if let Some(op) = op
4709 && !op.url.starts_with("{base_url}")
4710 && !op.url.contains("{{")
4711 {
4712 closed(
4713 &mut d,
4714 ServiceKind::Http,
4715 &format!("store.http.{opname}.url"),
4716 &op.url,
4717 );
4718 }
4719 }
4720 }
4721 for w in &s.workflows {
4722 if let Some(u) = w.get("url").and_then(Value::as_str) {
4723 closed(&mut d, ServiceKind::Http, "workflow reference url", u);
4724 }
4725 if let Some(steps) = w.get("steps").and_then(Value::as_object) {
4728 for (sid, st) in steps {
4729 if st.get("kind").and_then(Value::as_str) == Some("http")
4730 && let Some(u) = st.get("url").and_then(Value::as_str)
4731 && !u.contains("{{")
4732 {
4733 closed(&mut d, ServiceKind::Http, &format!("http step '{sid}'"), u);
4734 }
4735 }
4736 }
4737 }
4738 if s.observability.otel.endpoint.is_some() {
4740 d.warnings.push(
4741 "security.egress: closed does not cover observability.otel.endpoint (telemetry export is operator plumbing, not agent egress)".into(),
4742 );
4743 }
4744 }
4745 {
4749 let known: &[&str] = &[
4750 "instance",
4751 "instruction",
4752 "extra",
4753 "tools",
4754 "workflows",
4755 "services",
4756 "egress_closed",
4757 "streams",
4758 "templates",
4759 "skills",
4760 "peers",
4761 "signals",
4762 "memory",
4763 ];
4764 let mut check = |what: String, src: &str, is_default_slot: bool| {
4765 match crate::context::prompt::Template::parse(src) {
4766 Err(e) => err(&mut d, format!("{what}: {e}")),
4767 Ok(t) => {
4768 for r in &t.roots {
4769 if !known.contains(&r.as_str()) {
4770 err(
4771 &mut d,
4772 format!(
4773 "{what}: unknown reference {{{{{r}}}}} (available: {})",
4774 known.join(", ")
4775 ),
4776 );
4777 }
4778 }
4779 if t.needs_cel && !cfg!(feature = "cel") {
4780 err(
4781 &mut d,
4782 format!(
4783 "{what}: uses an expression, which needs the 'cel' build feature (bare paths work without it)"
4784 ),
4785 );
4786 }
4787 if is_default_slot && !t.reads("instruction") {
4790 d.warnings.push(format!(
4791 "{what} never references {{{{instruction}}}} — this agent's standing policy will not reach the model"
4792 ));
4793 }
4794 }
4795 }
4796 };
4797 if let Some(src) = &s.context.template {
4798 check("context.template".into(), src, true);
4799 }
4800 for (name, src) in &s.context.templates {
4801 check(format!("context.templates.{name}"), src, false);
4802 }
4803 }
4804 if let Err(errs) = crate::config::templates::compile_templates(s) {
4809 for e in errs {
4810 err(&mut d, e);
4811 }
4812 }
4813 if !s.subagents.templates.is_empty() && s.a2a.listen.is_none() {
4814 d.warnings.push(
4815 "subagents.templates are declared but a2a.listen is unset — instance-tier children get no `parent` peer (they cannot call home)".into(),
4816 );
4817 }
4818 let server_known = |n: &str| s.mcp.servers.iter().any(|x| x.name == n);
4819
4820 for (name, ov) in &s.tools.overrides {
4822 if !server_known(&ov.server) {
4823 err(
4824 &mut d,
4825 format!(
4826 "tools.overrides['{name}'] references undeclared MCP server '{}'",
4827 ov.server
4828 ),
4829 );
4830 }
4831 if s.tools.disabled.iter().any(|x| x == name) {
4832 err(
4833 &mut d,
4834 format!("tool '{name}' is both disabled and overridden"),
4835 );
4836 }
4837 for (label, tpl) in [("args", &ov.args), ("result", &ov.result)] {
4838 if let Some(t) = tpl
4839 && let Some(expr) = t.strip_prefix("CEL:")
4840 && let Err(e) = crate::cel::compile_check(expr.trim())
4841 {
4842 err(&mut d, format!("tools.overrides['{name}'].{label}: {e}"));
4843 }
4844 }
4845 }
4846
4847 match s.store.kind {
4849 StoreKind::Mcp => match &s.store.mcp {
4850 None => err(&mut d, "store.kind is mcp but store.mcp is not set".into()),
4851 Some(m) => {
4852 if !server_known(&m.server) {
4853 err(
4854 &mut d,
4855 format!(
4856 "store.mcp.server '{}' is not a declared MCP server",
4857 m.server
4858 ),
4859 );
4860 }
4861 for (label, op) in [
4862 ("put", &m.put),
4863 ("get", &m.get),
4864 ("list", &m.list),
4865 ("delete", &m.delete),
4866 ] {
4867 if let Some(op) = op {
4868 for (f, t) in [
4869 ("args", &op.args),
4870 ("ok", &op.ok),
4871 ("conflict", &op.conflict),
4872 ("value", &op.value),
4873 ("keys", &op.keys),
4874 ] {
4875 if let Some(t) = t
4876 && let Some(expr) = t.strip_prefix("CEL:")
4877 && let Err(e) = crate::cel::compile_check(expr.trim())
4878 {
4879 err(&mut d, format!("store.mcp.{label}.{f}: {e}"));
4880 }
4881 }
4882 }
4883 }
4884 }
4885 },
4886 StoreKind::Http => match &s.store.http {
4887 None => err(
4888 &mut d,
4889 "store.kind is http but store.http is not set".into(),
4890 ),
4891 Some(h) => {
4892 if !(h.base_url.starts_with("https://") || h.base_url.starts_with("http://")) {
4893 err(
4894 &mut d,
4895 format!(
4896 "store.http.base_url must be an http(s) URL (got {})",
4897 h.base_url
4898 ),
4899 );
4900 }
4901 if h.get.is_none() || h.put.is_none() {
4902 err(
4903 &mut d,
4904 "store.http needs at least `get` and `put` operations".into(),
4905 );
4906 }
4907 for (name, v) in &h.headers {
4908 if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(v) {
4909 err(
4910 &mut d,
4911 format!(
4912 "store.http.headers['{name}'] looks like a credential but has an inline value"
4913 ),
4914 );
4915 } else if let Some(e) = unresolved_secret_ref(v) {
4916 err(&mut d, format!("store.http.headers['{name}']: {e}"));
4917 }
4918 }
4919 }
4920 },
4921 StoreKind::File => {
4922 if let Some(f) = &s.store.file
4928 && f.path.as_deref().is_some_and(|p| p.trim().is_empty())
4929 {
4930 err(
4931 &mut d,
4932 "store.file.path is empty — set a directory, or omit the field to use $AGENTD_STATE_DIR / $XDG_STATE_HOME/agentd/state".into(),
4933 );
4934 }
4935 }
4936 StoreKind::Memory => {
4937 d.warnings.push(
4938 "store.kind is memory: state does not survive the process (dev/test only)".into(),
4939 );
4940 }
4941 StoreKind::None => {
4942 if s.is_long_lived() {
4951 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());
4952 } else if !s.workflows.is_empty() {
4953 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());
4954 }
4955 }
4956 }
4957 if s.store.file.is_some() && s.store.kind != StoreKind::File {
4963 d.warnings.push(format!(
4964 "store.file is set but store.kind is {} — the file adapter is not in use and the block is ignored",
4965 format!("{:?}", s.store.kind).to_lowercase()
4969 ));
4970 }
4971 if let Some(ms) = s.store.checkpoint.debounce_ms
4972 && ms > 60_000
4973 {
4974 d.warnings.push(format!(
4975 "store.checkpoint.debounce_ms is {ms} (> 60s): progress may lag far behind reality"
4976 ));
4977 }
4978
4979 if let Some(k) = &s.knowledge.server
4981 && !server_known(k)
4982 {
4983 err(
4984 &mut d,
4985 format!("knowledge.server '{k}' is not a declared MCP server"),
4986 );
4987 }
4988 if let Some(k) = &s.search.server
4989 && !server_known(k)
4990 {
4991 err(
4992 &mut d,
4993 format!("search.server '{k}' is not a declared MCP server"),
4994 );
4995 }
4996 for src in &s.skills.sources {
4997 if !server_known(&src.server) {
4998 err(
4999 &mut d,
5000 format!(
5001 "skills.sources[] references undeclared MCP server '{}'",
5002 src.server
5003 ),
5004 );
5005 }
5006 }
5007 if let Some(c) = s.context.compact_at
5008 && !(c > 0.0 && c <= 1.0)
5009 {
5010 err(
5011 &mut d,
5012 format!("context.compact_at must be in (0, 1] (got {c})"),
5013 );
5014 }
5015
5016 let mut wf_names = std::collections::HashSet::new();
5018 for (i, w) in s.workflows.iter().enumerate() {
5019 let Some(obj) = w.as_object() else {
5020 err(&mut d, format!("workflows[{i}] must be an object"));
5021 continue;
5022 };
5023 if obj.contains_key("dir") {
5027 continue;
5028 }
5029 let name = obj.get("name").and_then(Value::as_str).unwrap_or("");
5030 if name.trim().is_empty() {
5031 err(&mut d, format!("workflows[{i}] has no name"));
5032 } else if !wf_names.insert(name.to_string()) {
5033 err(
5034 &mut d,
5035 format!("workflows[]: duplicate workflow name '{name}'"),
5036 );
5037 }
5038 if !s.intelligence.models.is_empty()
5042 && let Some(steps) = obj.get("steps").and_then(Value::as_object)
5043 {
5044 for (sid, st) in steps {
5045 let Some(m) = st.get("model").and_then(Value::as_str) else {
5046 continue;
5047 };
5048 if !s.intelligence.models.contains_key(m) {
5049 err(
5050 &mut d,
5051 format!(
5052 "workflow '{name}' step '{sid}': model {m:?} is not a declared tier (known: {})",
5053 s.intelligence
5054 .models
5055 .keys()
5056 .cloned()
5057 .collect::<Vec<_>>()
5058 .join(", ")
5059 ),
5060 );
5061 }
5062 }
5063 }
5064 let sources = ["file", "uri", "url", "steps"]
5068 .iter()
5069 .filter(|k| obj.contains_key(**k))
5070 .count();
5071 if sources != 1 {
5072 err(
5073 &mut d,
5074 format!(
5075 "workflows['{name}'] must have exactly one of file | uri | url | steps (dir is a separate entry shape)"
5076 ),
5077 );
5078 }
5079 if let Some(f) = obj.get("file").and_then(Value::as_str) {
5088 let mut folded = Value::String(f.to_string());
5089 let mut ignored = Vec::new();
5090 substitute_config_vars(&mut folded, &s.vars, "workflow entry", &mut ignored);
5091 if ignored.is_empty()
5092 && let Some(path) = folded.as_str()
5093 && !std::path::Path::new(path).exists()
5094 {
5095 err(
5096 &mut d,
5097 format!("workflows['{name}'].file {path:?} does not exist"),
5098 );
5099 }
5100 }
5101 }
5102
5103 for (k, v) in &s.lifecycle.exit_code_map {
5105 if k != "3" && k != "7" {
5106 err(
5107 &mut d,
5108 format!(
5109 "lifecycle.exit_code_map: only the policy codes 3 and 7 are remappable (got key {k:?})"
5110 ),
5111 );
5112 }
5113 if !(0..=255).contains(v) {
5114 err(
5115 &mut d,
5116 format!("lifecycle.exit_code_map[{k}] must be 0..=255 (got {v})"),
5117 );
5118 }
5119 }
5120 if s.lifecycle.watch_config && loaded.files.is_empty() {
5121 err(
5122 &mut d,
5123 "lifecycle.watch_config requires a config file (--config / AGENTD_CONFIG)".into(),
5124 );
5125 }
5126
5127 if let Some(l) = &s.a2a.listen {
5129 match super::ServeTarget::parse(l) {
5130 Ok(super::ServeTarget::Http { bind, tls }) => {
5131 let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
5132 if tls && (s.a2a.tls.cert.is_none() || s.a2a.tls.key.is_none()) {
5133 err(
5134 &mut d,
5135 "a2a.listen is https:// but a2a.tls.cert / a2a.tls.key are not set".into(),
5136 );
5137 }
5138 if !loopback
5139 && s.a2a.tls.client_ca.is_none()
5140 && s.a2a.bearer.is_none()
5141 && !s.interface.pairing.enabled
5142 {
5143 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());
5144 }
5145 if !tls && !loopback {
5146 err(
5147 &mut d,
5148 "a2a.listen plaintext http:// is allowed for loopback only; use https://"
5149 .into(),
5150 );
5151 }
5152 }
5153 Ok(super::ServeTarget::Unix { .. }) => {
5154 if s.a2a.tls.cert.is_some()
5158 || s.a2a.tls.key.is_some()
5159 || s.a2a.tls.client_ca.is_some()
5160 {
5161 err(
5162 &mut d,
5163 "a2a.listen is unix:// — the kernel authenticates peers (same-uid); a2a.tls does not apply and must be unset".into(),
5164 );
5165 }
5166 }
5167 Err(e) => err(&mut d, format!("a2a.listen: {e}")),
5168 }
5169 }
5170
5171 if s.interface.enabled && s.a2a.listen.is_none() {
5173 err(
5174 &mut d,
5175 "interface.enabled requires a2a.listen (the interface is served on the A2A listener)"
5176 .into(),
5177 );
5178 }
5179 if s.interface.debug && !s.interface.enabled {
5180 d.warnings
5181 .push("interface.debug has no effect while interface.enabled is false".into());
5182 }
5183 for o in &s.interface.origins {
5184 let ok = o
5186 .split_once("://")
5187 .map(|(scheme, rest)| {
5188 matches!(scheme, "http" | "https") && !rest.is_empty() && !rest.contains('/')
5189 })
5190 .unwrap_or(false);
5191 if !ok {
5192 err(
5193 &mut d,
5194 format!(
5195 "interface.origins: {o:?} is not an origin (want scheme://host[:port], no path)"
5196 ),
5197 );
5198 }
5199 }
5200 for (edge, items) in [
5203 ("top", &s.interface.display.top),
5204 ("bottom", &s.interface.display.bottom),
5205 ] {
5206 for item in items.iter().flatten() {
5207 if let Some(key) = item.strip_prefix("memory:") {
5213 if key.is_empty() {
5214 d.errors.push(format!(
5215 "interface.display.{edge}: {item:?} names no memory key"
5216 ));
5217 } else if let Err(e) = crate::context::memory::Memory::check_key(key) {
5218 d.errors
5219 .push(format!("interface.display.{edge}: {item:?}: {e}"));
5220 }
5221 continue;
5222 }
5223 if !DISPLAY_ITEMS.contains(&item.as_str()) {
5224 d.warnings.push(format!(
5225 "interface.display.{edge}: unknown item {item:?} (clients skip it); known: {}, \
5226 or memory:<key> for a value a workflow maintains",
5227 DISPLAY_ITEMS.join(", ")
5228 ));
5229 }
5230 }
5231 }
5232 if s.interface.pairing.enabled {
5234 if !s.interface.enabled {
5235 err(
5236 &mut d,
5237 "interface.pairing.enabled requires interface.enabled (pairing rides the interface surface)".into(),
5238 );
5239 }
5240 if let Some(role) = s.interface.pairing.role
5241 && !matches!(role, Role::Operator | Role::User)
5242 {
5243 err(
5244 &mut d,
5245 "interface.pairing.role must be operator or user".into(),
5246 );
5247 }
5248 }
5249
5250 let uses_webhook = s.workflows.iter().any(workflow_uses_webhook);
5252 if uses_webhook && s.webhooks.listen.is_none() {
5253 err(&mut d, "a `webhook` node (start or wait) is used but webhooks.listen is not set — configure webhooks.listen (https://host:port)".into());
5254 }
5255 if let Some(l) = &s.webhooks.listen {
5256 match super::ServeTarget::parse(l) {
5257 Ok(super::ServeTarget::Unix { .. }) => {
5258 err(
5259 &mut d,
5260 "webhooks.listen does not support unix:// (webhooks are an external surface); use https://".into(),
5261 );
5262 }
5263 Ok(super::ServeTarget::Http { bind, tls }) => {
5264 let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
5265 if tls && (s.webhooks.tls.cert.is_none() || s.webhooks.tls.key.is_none()) {
5266 err(
5267 &mut d,
5268 "webhooks.listen is https:// but webhooks.tls.cert / webhooks.tls.key are not set"
5269 .into(),
5270 );
5271 }
5272 if !tls && !loopback {
5273 err(
5274 &mut d,
5275 "webhooks.listen plaintext http:// is allowed for loopback only; use https://"
5276 .into(),
5277 );
5278 }
5279 if !loopback && !webhook_default_verifies(s.webhooks.default_auth.as_ref()) {
5291 let mut open: Vec<String> = Vec::new();
5292 let mut nodes = 0usize;
5293 for w in &s.workflows {
5294 let wf = w.get("name").and_then(Value::as_str).unwrap_or("?");
5295 for (node, auth) in webhook_nodes(w) {
5296 nodes += 1;
5297 if !webhook_auth_verifies(auth) {
5298 open.push(format!("{wf}/{node}"));
5299 }
5300 }
5301 }
5302 if !open.is_empty() {
5303 err(
5304 &mut d,
5305 format!(
5306 "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: {}",
5307 open.join(", ")
5308 ),
5309 );
5310 } else if nodes == 0 {
5311 d.warnings.push("webhooks.listen is non-loopback with no webhooks.default_auth — every webhook node must declare its own `auth` (HMAC recommended)".into());
5314 }
5315 }
5316 }
5317 Err(e) => err(&mut d, format!("webhooks.listen: {e}")),
5318 }
5319 }
5320
5321 if let Some(g) = &s.goal {
5323 let via = g.check.via.as_deref().unwrap_or("both");
5324 if via == "condition" && g.check.condition.is_none() {
5325 err(
5326 &mut d,
5327 "goal.check.via is 'condition' but goal.check.condition is not set".into(),
5328 );
5329 }
5330 for (label, act) in [("on_achieved", &g.on_achieved), ("on_stuck", &g.on_stuck)] {
5331 if let Some(GoalAction::Workflow(name)) = act
5332 && !s
5333 .workflows
5334 .iter()
5335 .any(|w| w.get("name").and_then(Value::as_str) == Some(name.as_str()))
5336 {
5337 err(
5338 &mut d,
5339 format!(
5340 "goal.{label} references workflow '{name}', which is not defined in workflows"
5341 ),
5342 );
5343 }
5344 }
5345 }
5346
5347 let mut peer_names = std::collections::HashSet::new();
5348 for p in &s.a2a.peers {
5349 if !peer_names.insert(p.name.as_str()) {
5350 err(
5351 &mut d,
5352 format!("a2a.peers[]: duplicate peer name '{}'", p.name),
5353 );
5354 }
5355 let unix_peer = p.endpoint.starts_with("unix://") || p.endpoint.starts_with("unix:");
5356 if unix_peer && !cfg!(unix) {
5357 err(
5358 &mut d,
5359 format!("a2a peer '{}': unix:// endpoints are unix-only", p.name),
5360 );
5361 }
5362 if !unix_peer && !p.endpoint.starts_with("https://") && !p.endpoint.starts_with("http://") {
5363 err(
5364 &mut d,
5365 format!(
5366 "a2a peer '{}': endpoint must be http(s):// (or unix:///path for a co-located peer)",
5367 p.name
5368 ),
5369 );
5370 }
5371 if p.client_cert.is_some() != p.client_key.is_some() {
5372 err(
5373 &mut d,
5374 format!(
5375 "a2a peer '{}': client_cert and client_key must be set together",
5376 p.name
5377 ),
5378 );
5379 }
5380 if let Some(auth) = &p.auth {
5381 for e in validate_auth_block(auth, &format!("a2a peer '{}'", p.name)) {
5382 err(&mut d, e);
5383 }
5384 if auth.kind == AuthKind::Aws {
5385 err(
5386 &mut d,
5387 format!(
5388 "a2a peer '{}': auth kind `aws` is not accepted for peers — use static, oauth2 or spiffe",
5389 p.name
5390 ),
5391 );
5392 }
5393 }
5394 for (h, v) in &p.headers {
5395 if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
5396 err(
5397 &mut d,
5398 format!(
5399 "a2a peer '{}' header '{h}' looks like a credential but has an inline value",
5400 p.name
5401 ),
5402 );
5403 } else if let Some(e) = unresolved_secret_ref(v) {
5404 err(&mut d, format!("a2a peer '{}' header '{h}': {e}", p.name));
5405 }
5406 }
5407 }
5408 for (i, pr) in s.a2a.principals.iter().enumerate() {
5409 let m = &pr.matcher;
5410 if m.san.is_none()
5411 && m.sub.is_none()
5412 && m.bearer_ref.is_none()
5413 && m.aauth_agent.is_none()
5414 && !m.any
5415 {
5416 err(
5417 &mut d,
5418 format!(
5419 "a2a.principals[{i}]: match needs one of san | sub | bearer_ref | aauth_agent | any"
5420 ),
5421 );
5422 }
5423 if m.any && pr.role == Role::Operator {
5424 err(
5425 &mut d,
5426 format!("a2a.principals[{i}]: `any` cannot grant the operator role"),
5427 );
5428 }
5429 }
5430
5431 if let Some(l) = &s.observability.log_level
5433 && crate::obs::log::Level::parse(l).is_none()
5434 {
5435 err(
5436 &mut d,
5437 format!("observability.log_level: {l:?} (want trace|debug|info|warn|error)"),
5438 );
5439 }
5440
5441 for m in secret_violations(&loaded.file_doc) {
5443 err(&mut d, m);
5444 }
5445 for f in &s.observability.audit.sink.clone().unwrap_or_default() {
5446 if *f == AuditSink::Store && s.store.kind == StoreKind::None {
5447 err(
5448 &mut d,
5449 "observability.audit.sink includes `store` but store.kind is none".into(),
5450 );
5451 }
5452 }
5453
5454 let mut tags = Vec::new();
5456 for srv in &s.mcp.servers {
5457 match srv.tag_set() {
5458 Ok(t) if t.is_empty() => tags.push(crate::sec::scope::TrifectaTag::UntrustedInput),
5459 Ok(t) => tags.extend(t),
5460 Err(_) => {}
5461 }
5462 }
5463 #[cfg(feature = "exec")]
5472 if s.security.exec.enabled {
5473 tags.push(crate::sec::scope::TrifectaTag::Sensitive);
5474 tags.push(crate::sec::scope::TrifectaTag::Egress);
5475 }
5476 use crate::sec::scope::{TrifectaVerdict, check_trifecta};
5477 if check_trifecta(tags, s.security.allow_trifecta) == TrifectaVerdict::RefusedTrifecta {
5478 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());
5479 }
5480
5481 for (path, level) in [
5497 ("store.durability.a2a", s.store.durability.a2a),
5498 ("store.durability.steps", s.store.durability.steps),
5499 ] {
5500 if level == Some(DurabilityLevel::Eventual) {
5501 d.errors.push(format!(
5502 "{path}: `eventual` is not implemented — every durable write is strict \
5503 (checkpoint-before-effect). Remove the key; `strict` is the default and \
5504 the only behaviour."
5505 ));
5506 }
5507 }
5508 for (i, w) in s.workflows.iter().enumerate() {
5515 if w.get("steps").is_some() {
5516 for msg in missing_references(w, &format!("workflows[{i}]"), &s.vars) {
5517 err(&mut d, msg);
5518 }
5519 }
5520 }
5521 for w in &s.workflows {
5522 if w.get("steps").is_none() {
5525 if let Some(h) = w.get("headers").and_then(Value::as_object) {
5528 for (k, v) in h {
5529 if let Some(val) = v.as_str()
5530 && super::is_secret_shaped_key(k)
5531 && !crate::sec::secret::has_secret_ref(val)
5532 {
5533 d.errors.push(format!(
5534 "workflows: headers[{k:?}] looks like a credential — use {{{{secret:NAME}}}} rather than a literal"
5535 ));
5536 }
5537 }
5538 }
5539 continue;
5540 }
5541 if let Err(errs) = crate::engine::model::parse_workflow(w) {
5542 d.errors.extend(errs);
5544 }
5545 let cap = s
5548 .limits
5549 .workflow
5550 .fan_out
5551 .unwrap_or(crate::engine::model::MAX_BATCH_PARALLEL as u32);
5552 let wname = w.get("name").and_then(Value::as_str).unwrap_or("?");
5553 if let Some(steps) = w.get("steps").and_then(Value::as_object) {
5554 for (sid, step) in steps {
5555 let want = step.get("parallel").and_then(Value::as_u64).or_else(|| {
5556 step.get("batch")
5557 .and_then(|b| b.get("parallel"))
5558 .and_then(Value::as_u64)
5559 });
5560 if let Some(want) = want
5561 && want > cap as u64
5562 {
5563 d.errors.push(format!(
5564 "workflow {wname:?} step {sid:?}: parallel {want} exceeds \
5565 limits.workflow.fan_out ({cap}) — raise the limit or lower the step"
5566 ));
5567 }
5568 }
5569 }
5570 }
5571 d
5572}
5573
5574pub use crate::engine::model::is_long_lived_start;
5580
5581pub fn workflow_is_long_lived(w: &Value) -> bool {
5583 w.get("steps")
5584 .and_then(Value::as_object)
5585 .is_some_and(|steps| {
5586 steps.values().any(|st| {
5587 st.get("kind")
5588 .and_then(Value::as_str)
5589 .is_some_and(is_long_lived_start)
5590 })
5591 })
5592}
5593
5594pub fn workflow_uses_webhook(w: &Value) -> bool {
5598 w.get("steps")
5599 .and_then(Value::as_object)
5600 .is_some_and(|steps| {
5601 steps.values().any(|st| {
5602 let kind = st.get("kind").and_then(Value::as_str);
5603 kind == Some("webhook")
5604 || (matches!(kind, Some("wait") | Some("await"))
5605 && st.get("on").and_then(Value::as_str) == Some("webhook"))
5606 })
5607 })
5608}
5609
5610fn webhook_nodes(w: &Value) -> Vec<(&str, Option<&Value>)> {
5616 let Some(steps) = w.get("steps").and_then(Value::as_object) else {
5617 return Vec::new();
5618 };
5619 steps
5620 .iter()
5621 .filter_map(|(id, st)| {
5622 let kind = st.get("kind").and_then(Value::as_str);
5623 if kind == Some("webhook") {
5624 Some((id.as_str(), st.get("auth")))
5625 } else if matches!(kind, Some("wait") | Some("await"))
5626 && st.get("on").and_then(Value::as_str) == Some("webhook")
5627 {
5628 Some((id.as_str(), st.get("webhook").and_then(|c| c.get("auth"))))
5629 } else {
5630 None
5631 }
5632 })
5633 .collect()
5634}
5635
5636fn webhook_auth_verifies(auth: Option<&Value>) -> bool {
5643 let Some(a) = auth else { return false };
5644 if a.get("none").and_then(Value::as_bool) == Some(true) {
5645 return false;
5646 }
5647 a.get("hmac").and_then(Value::as_object).is_some()
5648 || a.get("header").and_then(Value::as_object).is_some()
5649 || a.get("bearer").and_then(Value::as_str).is_some()
5650}
5651
5652fn webhook_default_verifies(d: Option<&WebhookAuth>) -> bool {
5657 d.is_some_and(|d| !d.none && (d.hmac.is_some() || d.bearer.is_some() || d.header.is_some()))
5658}
5659
5660fn validate_budget(b: &Budget, at: &str, d: &mut Diagnostics) {
5661 for (i, w) in b.windows.iter().enumerate() {
5662 if w.tokens.is_none() && w.requests.is_none() {
5663 d.errors
5664 .push(format!("{at}.windows[{i}]: set tokens and/or requests"));
5665 }
5666 if let Some(r) = &w.reset {
5667 let ok = r.len() == 6
5672 && r.is_ascii()
5673 && r.ends_with('Z')
5674 && r[..2].parse::<u32>().is_ok_and(|h| h < 24)
5675 && &r[2..3] == ":"
5676 && r[3..5].parse::<u32>().is_ok_and(|m| m < 60);
5677 if !ok {
5678 d.errors.push(format!(
5679 "{at}.windows[{i}].reset must be HH:MMZ (got {r:?})"
5680 ));
5681 }
5682 if !w.per.is_calendar() {
5683 d.warnings.push(format!(
5684 "{at}.windows[{i}].reset is only meaningful for day/week windows"
5685 ));
5686 }
5687 }
5688 }
5689 if let Some(f) = b.slow.factor
5690 && !(f > 0.0 && f <= 1.0)
5691 {
5692 d.errors
5693 .push(format!("{at}.slow.factor must be in (0, 1] (got {f})"));
5694 }
5695 if b.on_exhausted == BudgetTactic::Degrade && b.degrade.model.is_none() {
5696 d.errors.push(format!(
5697 "{at}.on_exhausted is degrade but {at}.degrade.model is not set"
5698 ));
5699 }
5700 if b.reserve.estimate == ReserveEstimate::Fixed && b.reserve.fixed.is_none() {
5701 d.errors.push(format!(
5702 "{at}.reserve.estimate is fixed but {at}.reserve.fixed is not set"
5703 ));
5704 }
5705}
5706
5707const FILE_SECRET_PATHS: &[&str] = &[
5709 "/intelligence/token",
5710 "/a2a/bearer",
5711 "/security/aauth/enroll_token",
5712];
5713
5714fn secret_violations(file_doc: &Value) -> Vec<String> {
5716 let mut out = Vec::new();
5717 for p in FILE_SECRET_PATHS {
5718 if let Some(Value::String(v)) = file_doc.pointer(p)
5719 && !crate::sec::secret::has_secret_ref(v)
5720 {
5721 out.push(format!(
5722 "config file: {} carries an inline credential; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}} (or set it from env/flag)",
5723 p.trim_start_matches('/').replace('/', ".")
5724 ));
5725 }
5726 }
5727 if let Some(servers) = file_doc.pointer("/mcp/servers").and_then(Value::as_array) {
5728 for s in servers {
5729 if let Some(Value::String(v)) = s.pointer("/oauth/client_secret")
5730 && !crate::sec::secret::has_secret_ref(v)
5731 {
5732 out.push(format!(
5733 "config file: mcp server '{}' oauth.client_secret carries an inline credential; use a {{{{secret:…}}}} reference",
5734 s.get("name").and_then(Value::as_str).unwrap_or("?")
5735 ));
5736 }
5737 }
5738 }
5739 out
5740}
5741
5742pub const RESTART_ONLY_PATHS: &[&str] = &[
5749 "config_version",
5750 "agent.name",
5751 "store.kind",
5752 "store.prefix",
5753 "store.mcp",
5754 "store.http",
5755 "store.file",
5758 "lifecycle.run_until",
5759 "lifecycle.drain_timeout",
5760 "lifecycle.run_id",
5761 "lifecycle.exit_code_map",
5762 "lifecycle.watch_config",
5763 "a2a.listen",
5764 "a2a.tls",
5765 "a2a.bearer",
5766 "interface.enabled",
5774 "interface.pairing",
5775 "store.max_value_bytes",
5782 "webhooks.listen",
5787 "webhooks.tls",
5788 "observability.otel",
5789 "observability.metrics_addr",
5790 "observability.health_file",
5791 "observability.events_ring",
5792 "observability.traceparent",
5793 "security",
5794];
5795
5796pub const RELOADABLE_PATHS: &[&str] = &[
5816 "a2a.conversation_ttl",
5817 "a2a.peers",
5818 "a2a.principals",
5819 "a2a.push",
5820 "agent.approval",
5821 "agent.ask_human_fallback",
5822 "agent.conversation_budget",
5823 "agent.instruction",
5824 "agent.max_parallel_turns",
5825 "agent.on_workflow_finished",
5826 "agent.preflight",
5827 "agent.prompt",
5828 "agent.tools",
5829 "agent.wake_on",
5830 "context.compact_at",
5831 "context.keep_last",
5832 "context.model_window",
5833 "context.plan",
5834 "context.summarize",
5835 "context.template",
5836 "context.templates",
5837 "goal.check",
5838 "goal.on_achieved",
5839 "goal.on_stuck",
5840 "goal.statement",
5841 "goal.stuck_after",
5842 "identity.autonomous_as",
5843 "identity.labels",
5844 "intelligence.auth",
5845 "intelligence.budget",
5846 "intelligence.default",
5847 "intelligence.dialect",
5848 "intelligence.endpoints",
5849 "intelligence.headers",
5850 "intelligence.model",
5851 "intelligence.models",
5852 "intelligence.preflight_model",
5853 "intelligence.pricing",
5854 "intelligence.structured_output",
5855 "intelligence.swap_policy",
5856 "intelligence.timeout",
5857 "intelligence.token",
5858 "intelligence.token_file",
5859 "interface.debug",
5860 "interface.display",
5861 "interface.origins",
5862 "knowledge.auto_context",
5863 "knowledge.server",
5864 "lifecycle.idle_grace",
5865 "lifecycle.until_signal",
5866 "limits.inline_max_bytes",
5867 "limits.max_message_depth",
5868 "limits.max_runs",
5869 "limits.run",
5870 "limits.step_timeout",
5871 "limits.subagents",
5872 "limits.workflow",
5873 "mcp.default_timeout",
5874 "mcp.servers",
5875 "memory.list_default_limit",
5876 "memory.max_value_bytes",
5877 "observability.audit",
5878 "observability.log_content",
5879 "observability.log_level",
5880 "observability.report_file",
5881 "observability.runtime_events",
5882 "search.server",
5883 "services",
5884 "skills.dir",
5885 "skills.max_bytes",
5886 "skills.max_loaded",
5887 "skills.reference_prefix",
5888 "skills.sources",
5889 "store.audit",
5890 "store.checkpoint",
5891 "store.durability",
5892 "store.on_error",
5893 "store.retention",
5894 "store.timeout",
5895 "streams",
5896 "subagents.allow_freeform",
5897 "subagents.defaults",
5898 "subagents.templates",
5899 "tools.disabled",
5900 "tools.overrides",
5901 "vars",
5902 "webhooks.default_auth",
5903 "workflows.allow_private",
5904 "workflows.armed",
5905 "workflows.concurrency",
5906 "workflows.description",
5907 "workflows.dir",
5908 "workflows.durable",
5909 "workflows.file",
5910 "workflows.glob",
5911 "workflows.headers",
5912 "workflows.inputs",
5913 "workflows.key",
5914 "workflows.limits",
5915 "workflows.name",
5916 "workflows.outputs",
5917 "workflows.priority",
5918 "workflows.state",
5919 "workflows.steps",
5920 "workflows.timeout",
5921 "workflows.tool",
5922 "workflows.unload",
5923 "workflows.uri",
5924 "workflows.url",
5925 "workflows.version",
5926];
5927
5928pub fn restart_only_diff(running: &Value, candidate: &Value) -> Vec<String> {
5930 RESTART_ONLY_PATHS
5931 .iter()
5932 .filter(|p| {
5933 let ptr = format!("/{}", p.replace('.', "/"));
5934 running.pointer(&ptr) != candidate.pointer(&ptr)
5935 })
5936 .map(|p| (*p).to_string())
5937 .collect()
5938}
5939
5940pub fn help_section() -> String {
5942 paths::help_section_in(&paths::bindings_of(&schema::schema()))
5943}
5944
5945pub fn help_text() -> String {
5948 let mut out = format!(
5949 "agentd {ver} — a durable, workflow-driven agent (config schema v2)\n\
5950 \n\
5951 USAGE:\n\
5952 \x20 agentd --config <settings.yaml> [--config <overlay.yaml> …] [--<path> <value> …]\n\
5953 \x20 agentd --prompt <TEXT> --intelligence <URL> # one-shot: ask, answer, exit\n\
5954 \x20 agentd --instruction <TEXT> --intelligence <URL> [--mcp name=endpoint …] # one-shot sugar\n\
5955 \x20 agentd tui|ui --config <settings.yaml> [--<path> <value> …] # + a display client\n\
5956 \n\
5957 Every setting is a document path (YAML/JSON file, AGENTD_<PATH> env, --<path> flag);\n\
5958 several files merge in order (later wins). Precedence: built-in < files < env < flags.\n\
5959 \n\
5960 ALIASES (short spellings of paths):\n",
5961 ver = crate::VERSION
5962 );
5963 for a in ALIASES {
5964 let shape = match a.kind {
5965 AliasKind::Set | AliasKind::SetFromFile => "<value>",
5966 AliasKind::SetTrue => "",
5967 AliasKind::Append => "<value> (adds one)",
5968 AliasKind::Special => "<value>",
5969 };
5970 out.push_str(&format!(" {:<32} {} → {}\n", a.flag, shape, a.path));
5971 }
5972 out.push_str(
5973 "\nSUBCOMMANDS (run the daemon with a display client attached):\n\
5974 \x20 tui + the terminal UI (fullscreen; --inline for in-place)\n\
5975 \x20 ui + the web UI, opened in a browser\n\
5976 \x20 both need `interface.enabled: true`, which the\n\
5977 \x20 subcommand sets for you; the client exits with the daemon.\n\
5978 \x20 Detached instead: run `agentd -c …`, then `agentd-tui\n\
5979 \x20 --endpoint <url>` (npm i -g @agentd-dev/cli).\n\
5980 \nCONTROL:\n\
5981 \x20 -c, --config <PATH> a settings file (repeatable; `=` form too; or AGENT_CONFIG=a.yaml:b.yaml)\n\
5982 \x20 --validate-config load+validate everything, print the verdict, exit 0/2\n\
5983 \x20 --config-schema print the settings JSON Schema and exit\n\
5984 \x20 --context-template print the built-in system-prompt template and exit\n\
5985 \x20 --workflow-schema print the workflow JSON Schema + node registry and exit\n\
5986 \x20 --capabilities print the capabilities manifest and exit\n\
5987 \x20 --login <target> complete an OAuth device-login for an endpoint (e.g. mcp:<name>) and cache the token\n\
5988 \x20 --logout <target> evict a cached credential\n\
5989 \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\
5990 \x20 --env <FILE> load a dotenv file into this process's environment (repeatable; real env wins, later files win)\n\
5991 \x20 -h, --help / -V, --version\n\
5992 \nREMOVED FLAGS:\n",
5993 );
5994 for (flag, hint) in REMOVED_FLAGS {
5995 out.push_str(&format!(" {flag:<32} {hint}\n"));
5996 }
5997 out.push('\n');
5998 out.push_str(&help_section());
5999 out
6000}
6001
6002#[cfg(test)]
6003mod tests {
6004 use super::*;
6005 use std::io::Write;
6006
6007 fn args(v: &[&str]) -> Vec<String> {
6008 v.iter().map(|s| s.to_string()).collect()
6009 }
6010
6011 fn write_tmp(contents: &str, ext: &str) -> tempfile::NamedTempFile {
6012 let mut f = tempfile::Builder::new()
6013 .suffix(&format!(".{ext}"))
6014 .tempfile()
6015 .unwrap();
6016 f.write_all(contents.as_bytes()).unwrap();
6017 f.flush().unwrap();
6018 f
6019 }
6020
6021 fn base_env() -> Vec<(String, String)> {
6031 for name in ["BILLING", "PEER"] {
6035 crate::sec::secret::set_prompted(name, "test-value".into());
6036 }
6037 vec![(
6038 "AGENTD_INTELLIGENCE_ENDPOINTS".into(),
6039 "https://intel.example/v1".into(),
6040 )]
6041 }
6042
6043 fn struct_fields_at(doc_path: &str) -> Vec<String> {
6049 let mut probe = Value::Object(Map::new());
6051 let path = if doc_path.is_empty() {
6052 "__probe__".to_string()
6053 } else {
6054 format!("{doc_path}.__probe__")
6055 };
6056 paths::set_path(&mut probe, &path, json!(1));
6057 let err = Settings::from_document(probe, "t").expect_err("probe must be rejected");
6058 let after = err.split("expected").nth(1).unwrap_or("");
6061 let mut out: Vec<String> = after
6062 .split('`')
6063 .skip(1)
6064 .step_by(2)
6065 .map(str::to_string)
6066 .collect();
6067 out.sort();
6068 out
6069 }
6070
6071 fn schema_props_at(schema: &Value, doc_path: &str) -> Vec<String> {
6072 let mut node = schema.clone();
6073 let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
6074 for seg in doc_path.split('.').filter(|s| !s.is_empty()) {
6075 let props = node.get("properties").cloned().unwrap_or(Value::Null);
6076 node = props.get(seg).cloned().unwrap_or(Value::Null);
6077 if let Some(r) = node.get("$ref").and_then(Value::as_str)
6078 && let Some(name) = r.strip_prefix("#/$defs/")
6079 {
6080 node = defs.get(name).cloned().unwrap_or(Value::Null);
6081 }
6082 }
6083 let mut out: Vec<String> = node
6084 .get("properties")
6085 .and_then(Value::as_object)
6086 .map(|m| m.keys().cloned().collect())
6087 .unwrap_or_default();
6088 out.sort();
6089 out
6090 }
6091
6092 #[test]
6108 fn schema_matches_struct_for_collection_item_types() {
6109 fn fields_of(probe: Value) -> Vec<String> {
6112 let err = Settings::from_document(probe, "t").expect_err("probe must be rejected");
6113 let after = err.split("expected").nth(1).unwrap_or("");
6114 let mut out: Vec<String> = after
6115 .split('`')
6116 .skip(1)
6117 .step_by(2)
6118 .map(str::to_string)
6119 .collect();
6120 out.sort();
6121 out.dedup();
6122 out
6123 }
6124 fn def_props(schema: &Value, name: &str) -> Vec<String> {
6125 let mut out: Vec<String> = schema["$defs"][name]["properties"]
6126 .as_object()
6127 .map(|m| m.keys().cloned().collect())
6128 .unwrap_or_default();
6129 out.sort();
6130 out
6131 }
6132
6133 let schema = schema::schema();
6134 for (def, probe) in [
6135 (
6136 "Service",
6137 json!({"services": {"p": {"endpoint": "https://x", "__probe__": 1}}}),
6138 ),
6139 (
6140 "A2aPeer",
6141 json!({"a2a": {"peers": [{"name": "p", "endpoint": "https://x", "__probe__": 1}]}}),
6142 ),
6143 ] {
6144 assert_eq!(
6145 def_props(&schema, def),
6146 fields_of(probe),
6147 "schema/struct drift in $defs/{def}"
6148 );
6149 }
6150
6151 assert_eq!(
6155 schema["$defs"]["Service"]["properties"]["kind"]["enum"],
6156 json!(["mcp", "intelligence", "peer", "http"]),
6157 );
6158 }
6159
6160 #[test]
6161 fn schema_matches_struct_at_every_object() {
6162 let schema = schema::schema();
6163 for path in [
6164 "",
6165 "agent",
6166 "agent.tools",
6167 "intelligence",
6168 "intelligence.auth",
6169 "intelligence.budget",
6170 "intelligence.budget.slow",
6171 "intelligence.budget.degrade",
6172 "intelligence.budget.reserve",
6173 "mcp",
6174 "tools",
6175 "store",
6176 "store.checkpoint",
6177 "store.durability",
6178 "memory",
6179 "context",
6180 "context.plan",
6181 "context.summarize",
6182 "knowledge",
6183 "knowledge.auto_context",
6184 "search",
6185 "skills",
6186 "limits",
6187 "limits.run",
6188 "limits.subagents",
6189 "limits.subagents.instances",
6190 "subagents",
6191 "subagents.defaults",
6192 "lifecycle",
6193 "a2a",
6194 "a2a.tls",
6195 "observability",
6196 "observability.otel",
6197 "observability.audit",
6198 "security",
6199 "security.cgroup",
6200 "security.exec",
6201 ] {
6202 let s = schema_props_at(&schema, path);
6203 let f = struct_fields_at(path);
6204 assert_eq!(s, f, "schema/struct drift at `{path}`");
6205 }
6206 }
6207
6208 #[test]
6209 fn every_schema_path_deserializes_a_sample() {
6210 for b in paths::bindings_of(&schema::schema()) {
6214 let sample = match &b.kind {
6215 paths::Kind::String => match b.path.as_str() {
6216 "config_version" => json!("2"),
6217 _ => json!("x"),
6218 },
6219 paths::Kind::Integer => json!(1),
6220 paths::Kind::Number => json!(0.5),
6221 paths::Kind::Boolean => json!(true),
6222 paths::Kind::Enum(vs) => json!(vs[0]),
6223 paths::Kind::Array(item) => match (**item).clone() {
6224 paths::Kind::Object => match b.path.as_str() {
6225 "mcp.servers" => {
6226 json!([{"name": "a", "endpoint": "https://a.example/mcp"}])
6227 }
6228 "workflows" => json!([{"name": "w", "steps": {}}]),
6229 "a2a.principals" => json!([{"match": {"any": true}, "role": "user"}]),
6230 "a2a.peers" => json!([{"name": "p", "endpoint": "https://p.example"}]),
6231 "skills.sources" => json!([{"server": "s"}]),
6232 "security.policies" => {
6233 json!([{"match": {"tool": "fs.*"}, "action": "deny"}])
6234 }
6235 "intelligence.budget.windows" | "agent.conversation_budget.windows" => {
6236 json!([{"per": "hour", "tokens": 1}])
6237 }
6238 other => panic!("no sample for object list {other}"),
6239 },
6240 paths::Kind::Enum(vs) => json!([vs[0]]),
6241 _ => json!(["s"]),
6242 },
6243 paths::Kind::Object => match b.path.as_str() {
6244 "intelligence.pricing" => json!({"m": {"input_per_1k": 1.0}}),
6245 "intelligence.models" => json!({"small": {"model": "m-1"}}),
6246 "tools.overrides" => json!({"memory.get": {"server": "s", "tool": "t"}}),
6247 "store.mcp" => json!({"server": "s"}),
6248 "streams" => json!({"orders": {"retention": {"max_events": 1}}}),
6249 "services" => json!({"billing": {"endpoint": "https://b.example/mcp"}}),
6250 "subagents.templates" => json!({"t": {"instruction": "do the thing"}}),
6251 "subagents.defaults.limits" => json!({"max_tokens": 1000}),
6252 "store.http" => json!({"base_url": "https://s"}),
6253 "security.aauth" => json!({"provider": "https://apd"}),
6254 "lifecycle.exit_code_map" => json!({"3": 0}),
6255 _ => json!({"k": "v"}),
6256 },
6257 paths::Kind::Any => match b.path.as_str() {
6258 "intelligence.endpoints" => json!("https://a,https://b"),
6259 "goal.on_achieved" | "goal.on_stuck" => json!("finish"),
6260 p if p.ends_with("timeout")
6261 || p.ends_with("deadline")
6262 || p.ends_with("_grace")
6263 || p.ends_with("ttl")
6264 || p.ends_with("every") =>
6265 {
6266 json!("10s")
6267 }
6268 p if p.starts_with("agent.tools.") => json!("all"),
6269 _ => json!("x"),
6270 },
6271 };
6272 let mut doc = Value::Object(Map::new());
6273 paths::set_path(&mut doc, &b.path, sample);
6274 fill_required(&mut doc, &schema::schema(), &b.path);
6275 Settings::from_document(doc, "t")
6276 .unwrap_or_else(|e| panic!("path {} does not deserialize: {e}", b.path));
6277 }
6278 }
6279
6280 fn fill_required(doc: &mut Value, schema: &Value, path: &str) {
6284 let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
6285 let resolve = |v: &Value| -> Value {
6286 match v
6287 .get("$ref")
6288 .and_then(Value::as_str)
6289 .and_then(|r| r.strip_prefix("#/$defs/"))
6290 {
6291 Some(name) => defs.get(name).cloned().unwrap_or(Value::Null),
6292 None => v.clone(),
6293 }
6294 };
6295 let mut node = schema.clone();
6296 let mut prefix = String::new();
6297 let segs: Vec<&str> = path.split('.').collect();
6298 for (i, seg) in segs.iter().enumerate() {
6299 let props = node.get("properties").cloned().unwrap_or(Value::Null);
6300 node = resolve(&props.get(*seg).cloned().unwrap_or(Value::Null));
6301 prefix = if prefix.is_empty() {
6302 (*seg).to_string()
6303 } else {
6304 format!("{prefix}.{seg}")
6305 };
6306 if i + 1 == segs.len() {
6307 break;
6308 }
6309 if let Some(req) = node.get("required").and_then(Value::as_array) {
6310 let props = node.get("properties").cloned().unwrap_or(Value::Null);
6311 for r in req.iter().filter_map(Value::as_str) {
6312 let p = format!("{prefix}.{r}");
6313 if doc.pointer(&format!("/{}", p.replace('.', "/"))).is_none() {
6314 let sample = match props
6317 .get(r)
6318 .and_then(|f| f.get("enum"))
6319 .and_then(Value::as_array)
6320 .filter(|a| !a.is_empty())
6321 {
6322 Some(vs) => vs[0].clone(),
6323 None => match r {
6324 "provider" | "base_url" | "url" => json!("https://x.example"),
6325 _ => json!("x"),
6326 },
6327 };
6328 paths::set_path(doc, &p, sample);
6329 }
6330 }
6331 }
6332 }
6333 }
6334
6335 #[test]
6336 fn env_and_flag_names_derive_from_the_v2_paths() {
6337 let bs = paths::bindings_of(&schema::schema());
6338 let model = bs.iter().find(|b| b.path == "intelligence.model").unwrap();
6339 assert_eq!(model.env_names()[0], "AGENTD_INTELLIGENCE_MODEL");
6340 assert_eq!(model.env_names()[2], "INTELLIGENCE_MODEL");
6341 assert_eq!(model.flag(), "--intelligence-model");
6342 let steps = bs.iter().find(|b| b.path == "limits.run.steps").unwrap();
6343 assert_eq!(steps.env_names()[0], "AGENTD_LIMITS_RUN_STEPS");
6344 let mut seen = std::collections::HashSet::new();
6346 for b in &bs {
6347 assert!(seen.insert(b.flag()), "duplicate flag {}", b.flag());
6348 }
6349 }
6350
6351 #[test]
6354 fn detects_v1_v2_mixed_and_empty() {
6355 assert_eq!(detect(&json!({})), Detected::Empty);
6356 assert_eq!(detect(&json!({"model": "m"})), Detected::V1);
6357 assert_eq!(detect(&json!({"config_version": "1"})), Detected::V2);
6358 assert_eq!(
6359 detect(&json!({"agent": {"instruction": "x"}})),
6360 Detected::V2
6361 );
6362 assert_eq!(detect(&json!({"agent": {}, "model": "m"})), Detected::Mixed);
6363 assert_eq!(
6364 detect(&json!({"config_version": "1.0", "model": "m"})),
6365 Detected::V1
6366 );
6367 assert_eq!(
6369 detect(&json!({"model": "m", "limits": {"max_steps": 1}})),
6370 Detected::V1
6371 );
6372 assert_eq!(
6373 detect(&json!({"intelligence": "https://x", "limits": {}})),
6374 Detected::V1
6375 );
6376 assert_eq!(
6377 detect(&json!({"intelligence": {"model": "m"}, "limits": {}})),
6378 Detected::V2
6379 );
6380 assert_eq!(detect(&json!({"limits": {"max_steps": 1}})), Detected::V1);
6381 }
6382
6383 #[cfg(feature = "exec")]
6386 #[test]
6387 fn enabling_exec_next_to_untrusted_input_assembles_the_trifecta() {
6388 let cfg = "config_version: \"1\"\nstore: {kind: memory}\n\
6394 mcp:\n servers:\n - name: web\n endpoint: https://mcp-web.internal/mcp\n tags: {\"*\": [untrusted_input]}\n\
6395 security:\n exec: {enabled: true, workdir: /tmp, allow: [git]}\n";
6396 let f = write_tmp(cfg, "yaml");
6397 let e = load(
6398 &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
6399 &base_env(),
6400 )
6401 .unwrap_err();
6402 assert!(format!("{e}").contains("lethal-trifecta refused"), "{e}");
6403
6404 load(
6406 &args(&[
6407 "--config",
6408 f.path().to_str().unwrap(),
6409 "--validate-config",
6410 "--allow-trifecta",
6411 ]),
6412 &base_env(),
6413 )
6414 .expect("--allow-trifecta is the escape hatch");
6415
6416 let alone = write_tmp(
6418 "config_version: \"1\"\nstore: {kind: memory}\n\
6419 security:\n exec: {enabled: true, workdir: /tmp, allow: [git]}\n",
6420 "yaml",
6421 );
6422 load(
6423 &args(&[
6424 "--config",
6425 alone.path().to_str().unwrap(),
6426 "--validate-config",
6427 ]),
6428 &base_env(),
6429 )
6430 .expect("two legs are not the trifecta");
6431 }
6432
6433 #[test]
6434 fn validate_config_catches_workflow_body_errors_the_runtime_would_refuse() {
6435 let f = write_tmp(
6439 "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",
6440 "yaml",
6441 );
6442 let e = load(
6443 &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
6444 &base_env(),
6445 )
6446 .unwrap_err();
6447 let msg = format!("{e}");
6448 assert!(msg.contains("unknown field"), "{msg}");
6449 assert!(msg.contains("prompt"), "{msg}");
6450 assert!(
6451 msg.contains("instruction"),
6452 "names the allowed fields: {msg}"
6453 );
6454
6455 let ok = write_tmp(
6457 "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",
6458 "yaml",
6459 );
6460 load(
6461 &args(&["--config", ok.path().to_str().unwrap(), "--validate-config"]),
6462 &base_env(),
6463 )
6464 .expect("a correct workflow validates");
6465 }
6466
6467 #[test]
6468 fn a_prompt_is_a_message_not_a_sugar_workflow() {
6469 let (l, ask) = load(&args(&["--prompt", "do the thing"]), &base_env()).unwrap();
6474 assert_eq!(ask, Ask::Run);
6475 assert_eq!(l.settings.agent.prompt.as_deref(), Some("do the thing"));
6476 assert!(
6477 l.settings.workflows.is_empty(),
6478 "a prompt needs no workflow: {:?}",
6479 l.settings.workflows
6480 );
6481
6482 let (only_instr, _) = load(&args(&["--instruction", "be terse"]), &base_env()).unwrap();
6484 assert_eq!(only_instr.settings.workflows.len(), 1);
6485
6486 let (both, _) = load(
6489 &args(&["--prompt", "do the thing", "--instruction", "be terse"]),
6490 &base_env(),
6491 )
6492 .unwrap();
6493 assert!(both.settings.workflows.is_empty());
6494 assert_eq!(both.settings.agent.instruction.as_deref(), Some("be terse"));
6495
6496 let mut env = base_env();
6498 env.push(("AGENTD_AGENT_PROMPT".into(), "from env".into()));
6499 let (from_env, _) = load(&args(&[]), &env).unwrap();
6500 assert_eq!(from_env.settings.agent.prompt.as_deref(), Some("from env"));
6501 }
6502
6503 #[test]
6504 fn minimal_instruction_run_gets_the_sugar_workflow() {
6505 let (l, ask) = load(&args(&["--instruction", "do it"]), &base_env()).unwrap();
6506 assert_eq!(ask, Ask::Run);
6507 assert_eq!(l.settings.agent.instruction.as_deref(), Some("do it"));
6508 assert_eq!(
6509 l.settings.intelligence.endpoints,
6510 vec!["https://intel.example/v1"]
6511 );
6512 assert_eq!(l.settings.workflows.len(), 1, "sugar workflow synthesized");
6513 assert_eq!(l.settings.workflows[0]["name"], json!("main"));
6514 assert_eq!(
6515 l.settings.workflows[0]["steps"]["start"]["kind"],
6516 json!("once")
6517 );
6518 assert!(
6520 l.warnings.iter().any(|w| w.contains("not durable")),
6521 "{:?}",
6522 l.warnings
6523 );
6524 }
6525
6526 #[test]
6527 fn a_long_lived_instance_defaults_to_the_file_store_but_an_explicit_none_is_refused() {
6528 let (l, _) = load(
6530 &args(&[
6531 "--instruction",
6532 "x",
6533 "--a2a.listen",
6534 "http://127.0.0.1:8443",
6535 ]),
6536 &base_env(),
6537 )
6538 .unwrap();
6539 assert_eq!(l.settings.store.kind, StoreKind::File);
6540 let f = write_tmp(
6542 "config_version: \"1\"\nworkflows:\n - name: w\n steps:\n s: {kind: schedule, cron: \"* * * * *\"}\n f: {kind: finish, depends_on: [s], status: completed}\n",
6543 "yaml",
6544 );
6545 let (l, _) = load(
6546 &args(&["--config", f.path().to_str().unwrap()]),
6547 &base_env(),
6548 )
6549 .unwrap();
6550 assert_eq!(l.settings.store.kind, StoreKind::File);
6551 let e = load(
6554 &args(&[
6555 "--config",
6556 f.path().to_str().unwrap(),
6557 "--store.kind",
6558 "none",
6559 ]),
6560 &base_env(),
6561 )
6562 .unwrap_err();
6563 assert!(format!("{e}").contains("long-lived"), "{e}");
6564 let (l, _) = load(&args(&["--instruction", "x"]), &base_env()).unwrap();
6567 assert_eq!(l.settings.store.kind, StoreKind::None);
6568 let (l, _) = load(
6570 &args(&["--instruction", "x", "--store.kind", "memory"]),
6571 &base_env(),
6572 )
6573 .unwrap();
6574 assert!(
6575 l.warnings.iter().any(|w| w.contains("memory")),
6576 "{:?}",
6577 l.warnings
6578 );
6579 }
6580
6581 #[test]
6584 fn expand_env_str_covers_the_forms() {
6585 let env: HashMap<&str, &str> = [("HOST", "db.internal"), ("PORT", "5432")]
6586 .into_iter()
6587 .collect();
6588 assert_eq!(
6590 expand_env_str("${HOST}:${PORT}", &env).unwrap(),
6591 "db.internal:5432"
6592 );
6593 assert_eq!(
6595 expand_env_str("${MISSING:-fallback}", &env).unwrap(),
6596 "fallback"
6597 );
6598 assert_eq!(
6599 expand_env_str("${HOST:-fallback}", &env).unwrap(),
6600 "db.internal"
6601 );
6602 assert_eq!(
6604 expand_env_str("$HOST costs $5", &env).unwrap(),
6605 "$HOST costs $5"
6606 );
6607 assert_eq!(expand_env_str("$${HOST}", &env).unwrap(), "${HOST}");
6609 assert!(
6611 expand_env_str("${NOPE}", &env)
6612 .unwrap_err()
6613 .contains("NOPE")
6614 );
6615 assert!(expand_env_str("${HOST", &env).is_err());
6617 assert!(expand_env_str("${bad-name}", &env).is_err());
6618 }
6619
6620 #[test]
6642 fn every_config_path_is_classified() {
6643 let schema: Value = schema::schema();
6644 let defs = &schema["$defs"];
6645
6646 let mut paths: Vec<String> = Vec::new();
6649 for (section, node) in schema["properties"]
6650 .as_object()
6651 .expect("the schema has properties")
6652 {
6653 let target = node
6654 .get("$ref")
6655 .or_else(|| node.get("items").and_then(|i| i.get("$ref")))
6656 .and_then(Value::as_str)
6657 .and_then(|r| r.rsplit('/').next())
6658 .and_then(|name| defs.get(name))
6659 .unwrap_or(node);
6660 match target.get("properties").and_then(Value::as_object) {
6661 Some(fields) if !fields.is_empty() => {
6662 paths.extend(fields.keys().map(|f| format!("{section}.{f}")));
6663 }
6664 _ => paths.push(section.clone()),
6665 }
6666 }
6667 assert!(
6668 paths.len() > 100,
6669 "the schema walk found only {} paths — it stopped resolving $refs",
6670 paths.len()
6671 );
6672
6673 let covered = |p: &str, list: &[&str]| {
6674 list.iter()
6675 .any(|e| p == *e || p.starts_with(&format!("{e}.")))
6676 };
6677 let unclassified: Vec<&String> = paths
6678 .iter()
6679 .filter(|p| !covered(p, RESTART_ONLY_PATHS) && !covered(p, RELOADABLE_PATHS))
6680 .collect();
6681 assert!(
6682 unclassified.is_empty(),
6683 "these config paths are in neither RESTART_ONLY_PATHS nor \
6684 RELOADABLE_PATHS, so nobody has said whether a reload applies them \
6685 — add each to one list (prefer restart-only when unsure): {unclassified:#?}"
6686 );
6687
6688 let both: Vec<&&str> = RELOADABLE_PATHS
6692 .iter()
6693 .filter(|p| covered(p, RESTART_ONLY_PATHS))
6694 .collect();
6695 assert!(both.is_empty(), "classified as both: {both:?}");
6696 let stale: Vec<&&str> = RELOADABLE_PATHS
6697 .iter()
6698 .filter(|e| {
6699 !paths
6700 .iter()
6701 .any(|p| p == *e || p.starts_with(&format!("{e}.")))
6702 })
6703 .collect();
6704 assert!(
6705 stale.is_empty(),
6706 "RELOADABLE_PATHS names paths the schema does not have — renamed or \
6707 removed fields leave the coverage check weaker than it looks: {stale:?}"
6708 );
6709 }
6710
6711 #[test]
6721 fn principal_and_webhook_rules_reload_while_their_sockets_stay_restart_only() {
6722 let base = json!({
6723 "a2a": {"listen": "http://127.0.0.1:1", "principals": [
6724 {"match": {"any": true}, "role": "user", "labels": {"team": "alpha"}}]},
6725 "webhooks": {"listen": "http://127.0.0.1:2",
6726 "default_auth": {"hmac": {"secret": "{{secret:S}}"}}},
6727 "agent": {"instruction": "before"},
6728 });
6729
6730 let mut changed = base.clone();
6732 changed["a2a"]["principals"][0]["labels"]["team"] = json!("bravo");
6733 assert!(
6734 restart_only_diff(&base, &changed).is_empty(),
6735 "principal rules are rebuilt into the live resolver"
6736 );
6737
6738 let mut rotated = base.clone();
6741 rotated["webhooks"]["default_auth"]["hmac"]["secret"] = json!("{{secret:S2}}");
6742 assert!(
6743 restart_only_diff(&base, &rotated).is_empty(),
6744 "webhook auth is rebuilt into the live handler"
6745 );
6746
6747 let mut moved = base.clone();
6750 moved["webhooks"]["listen"] = json!("http://127.0.0.1:3");
6751 assert_eq!(restart_only_diff(&base, &moved), ["webhooks.listen"]);
6752
6753 let mut retls = base.clone();
6754 retls["webhooks"]["tls"] = json!({"cert": "/c.pem", "key": "/k.pem"});
6755 assert_eq!(restart_only_diff(&base, &retls), ["webhooks.tls"]);
6756
6757 let mut instr = base.clone();
6759 instr["agent"]["instruction"] = json!("after");
6760 assert!(restart_only_diff(&base, &instr).is_empty());
6761 }
6762
6763 #[test]
6764 fn config_vars_fold_typed_values_and_collect_every_miss() {
6765 let file = write_tmp(
6766 "config_version: \"1\"\n\
6767 vars:\n region: eu-1\n port: 8443\n team:\n name: platform\n\
6768 agent:\n name: \"svc-{{config.region}}\"\n instruction: serve\n preflight: never\n\
6769 intelligence:\n endpoints: [https://x/v1]\n model: m\n\
6770 store:\n kind: memory\n\
6771 limits:\n step_timeout: \"{{config.port}}s\"\n\
6772 workflows:\n - name: w\n steps:\n\
6773 \x20 s: {kind: once}\n\
6774 \x20 c: {kind: http, depends_on: [s], url: \"https://api.{{config.region}}.example\", headers: {x-team: \"{{config.team.name}}\"}}\n\
6775 \x20 f: {kind: finish, depends_on: [c]}\n",
6776 "yaml",
6777 );
6778 let (l, _) = load(
6779 &args(&["--config", file.path().to_str().unwrap()]),
6780 &base_env(),
6781 )
6782 .unwrap();
6783 assert_eq!(l.settings.agent.name.as_deref(), Some("svc-eu-1"));
6785 assert_eq!(
6787 l.settings
6788 .limits
6789 .step_timeout
6790 .as_ref()
6791 .map(|d| d.0.as_secs()),
6792 Some(8443)
6793 );
6794 let wf = &l.settings.workflows[0];
6798 assert_eq!(
6799 wf.pointer("/steps/c/url").and_then(Value::as_str),
6800 Some("https://api.{{config.region}}.example")
6801 );
6802
6803 let bad = write_tmp(
6805 "config_version: \"1\"\n\
6806 vars:\n set: yes\n\
6807 agent:\n name: \"{{config.gone}}\"\n instruction: serve\n preflight: never\n\
6808 intelligence:\n endpoints: [\"https://{{config.also_gone}}/v1\"]\n model: m\n\
6809 store:\n kind: memory\n",
6810 "yaml",
6811 );
6812 let err = load(
6813 &args(&["--config", bad.path().to_str().unwrap()]),
6814 &base_env(),
6815 )
6816 .err()
6817 .map(|e| e.to_string())
6818 .unwrap_or_default();
6819 assert!(err.contains("config.gone"), "{err}");
6820 assert!(err.contains("config.also_gone"), "{err}");
6821 assert!(
6822 err.contains("2 unresolved config var reference"),
6823 "all misses in one report: {err}"
6824 );
6825 }
6826
6827 #[test]
6828 fn missing_references_name_every_gap_with_its_locations() {
6829 let doc = serde_json::json!({
6830 "a": "{{secret:SUB_WINDOW_TEST_UNSET}}",
6831 "b": {"c": ["{{secret-file:/definitely/not/here}}", "{{config.gone}}"]},
6832 "d": "{{secret:SUB_WINDOW_TEST_UNSET}} again",
6833 });
6834 let vars: std::collections::BTreeMap<String, Value> =
6835 [("present".to_string(), serde_json::json!(1))].into();
6836 let missing = missing_references(&doc, "cfg", &vars);
6837 assert_eq!(missing.len(), 3, "{missing:?}");
6838 let all = missing.join("\n");
6839 assert!(
6840 all.contains("{{secret:SUB_WINDOW_TEST_UNSET}} is not set"),
6841 "{all}"
6842 );
6843 assert!(
6844 all.contains("cfg.a") && all.contains("cfg.d"),
6845 "both locations: {all}"
6846 );
6847 assert!(
6848 all.contains("{{secret-file:/definitely/not/here}} is not readable"),
6849 "{all}"
6850 );
6851 assert!(all.contains("config.gone is not defined in vars"), "{all}");
6852 let ok = serde_json::json!({"x": "{{config.present}}"});
6854 assert!(missing_references(&ok, "cfg", &vars).is_empty());
6855 }
6856
6857 #[test]
6858 fn env_substitution_reaches_config_values_and_workflows() {
6859 let file = write_tmp(
6860 "config_version: \"1\"\n\
6861 agent:\n name: ${SVC_NAME}\n instruction: serve\n preflight: never\n\
6862 intelligence:\n endpoints: [https://x/v1]\n model: m\n\
6863 store:\n kind: memory\n\
6864 workflows:\n - name: w\n steps:\n\
6865 \x20 s: {kind: once}\n\
6866 \x20 c: {kind: http, depends_on: [s], url: \"https://api.${REGION:-us}.example/${SVC_NAME}\"}\n\
6867 \x20 f: {kind: finish, depends_on: [c]}\n",
6868 "yaml",
6869 );
6870 let mut env = base_env();
6871 env.push(("SVC_NAME".into(), "billing".into()));
6872 let (l, _) = load(&args(&["--config", file.path().to_str().unwrap()]), &env).unwrap();
6874 assert_eq!(
6876 l.settings.agent.name.as_deref(),
6877 Some("billing"),
6878 "the `${{SVC_NAME}}` in a config value was substituted"
6879 );
6880 let url = l.settings.workflows[0]
6883 .pointer("/steps/c/url")
6884 .and_then(Value::as_str)
6885 .unwrap_or_default();
6886 assert_eq!(
6887 url, "https://api.us.example/billing",
6888 "the workflow value was substituted (default + set var)"
6889 );
6890 }
6891
6892 #[test]
6893 fn mcp_server_oauth_is_carried_to_the_runtime_spec() {
6894 let s = McpServer {
6898 name: "gh".into(),
6899 endpoint: "https://mcp.example".into(),
6900 service: None,
6901 service_rate: None,
6902 ns: None,
6903 headers: BTreeMap::new(),
6904 tags: BTreeMap::new(),
6905 allow: None,
6906 exclude: Vec::new(),
6907 aauth: None,
6908 oauth: Some(McpOauth {
6909 token_url: "https://auth.example/token".into(),
6910 client_id: "cid".into(),
6911 client_secret: Secret("{{secret:CS}}".into()),
6912 scope: Some("mcp:read".into()),
6913 }),
6914 auth: None,
6915 timeout: None,
6916 };
6917 let spec = s.to_spec().unwrap();
6918 let o = spec.oauth.expect("oauth reaches the runtime spec");
6919 assert_eq!(o.token_url, "https://auth.example/token");
6920 assert_eq!(o.client_id, "cid");
6921 assert_eq!(o.client_secret, "{{secret:CS}}");
6923 assert_eq!(o.scope.as_deref(), Some("mcp:read"));
6924 }
6925
6926 #[test]
6927 fn files_env_flags_layer_in_order_with_aliases() {
6928 let base = write_tmp(
6929 "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",
6930 "yaml",
6931 );
6932 let over = write_tmp("intelligence:\n model: over-model\n", "yml");
6933 let mut env = base_env();
6934 env.clear();
6935 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(
6939 &args(&[
6940 "--config",
6941 base.path().to_str().unwrap(),
6942 "--config",
6943 over.path().to_str().unwrap(),
6944 "--max-steps",
6945 "30",
6946 "--mcp",
6947 "fs=https://fs.example/mcp",
6948 "--mcp-tags",
6949 "fs=sensitive",
6950 "--intelligence.headers.x-team",
6951 "ops",
6952 ]),
6953 &env,
6954 )
6955 .unwrap();
6956 let s = &l.settings;
6957 assert_eq!(
6958 s.agent.instruction.as_deref(),
6959 Some("env-instruction"),
6960 "env > file"
6961 );
6962 assert_eq!(
6963 s.intelligence.model.as_deref(),
6964 Some("env-model"),
6965 "env alias > later file"
6966 );
6967 assert_eq!(s.limits.run.steps(), 30, "flag alias > env");
6968 assert_eq!(s.mcp.servers.len(), 1);
6969 assert_eq!(s.mcp.servers[0].name, "fs");
6970 assert_eq!(s.mcp.servers[0].tags["*"], vec!["sensitive"]);
6971 assert_eq!(
6972 s.intelligence.headers.get("x-team").map(String::as_str),
6973 Some("ops")
6974 );
6975 assert_eq!(l.files.len(), 2);
6976 let env2: Vec<(String, String)> = vec![
6978 ("AGENT_MODEL".into(), "legacy".into()),
6979 ("AGENTD_INTELLIGENCE_MODEL".into(), "path".into()),
6980 ("AGENTD_INTELLIGENCE_ENDPOINTS".into(), "https://i".into()),
6981 ];
6982 let (l2, _) = load(
6983 &args(&["--instruction", "x", "--store.kind", "memory"]),
6984 &env2,
6985 )
6986 .unwrap();
6987 assert_eq!(l2.settings.intelligence.model.as_deref(), Some("path"));
6988 }
6989
6990 #[test]
6991 fn removed_flags_name_their_replacement() {
6992 for (flag, _) in REMOVED_FLAGS {
6993 let e = load(&args(&[flag, "x"]), &base_env()).unwrap_err();
6994 assert!(format!("{e}").contains("removed in agentd"), "{flag}: {e}");
6995 }
6996 let e = load(&args(&["--mode", "reactive"]), &base_env()).unwrap_err();
6997 assert!(format!("{e}").contains("start node"), "{e}");
6998 }
6999
7000 #[test]
7001 fn mixed_and_v1_files_are_refused_by_the_v2_loader() {
7002 let mixed = write_tmp("agent: {instruction: x}\nmodel: m\n", "yaml");
7003 let e = load(
7004 &args(&["--config", mixed.path().to_str().unwrap()]),
7005 &base_env(),
7006 )
7007 .unwrap_err();
7008 assert!(format!("{e}").contains("mixes legacy flat keys"), "{e}");
7009 let v1 = write_tmp("model: m\n", "yaml");
7010 let e = load(
7011 &args(&["--config", v1.path().to_str().unwrap()]),
7012 &base_env(),
7013 )
7014 .unwrap_err();
7015 assert!(format!("{e}").contains("retired flat schema"), "{e}");
7016 }
7017
7018 #[test]
7019 fn budget_exit_code_and_instruction_file_aliases() {
7020 let f = write_tmp("read me from a file", "txt");
7021 let (l, _) = load(
7022 &args(&[
7023 "--instruction-file",
7024 f.path().to_str().unwrap(),
7025 "--budget-exit-code",
7026 "9",
7027 "--store.kind",
7028 "memory",
7029 ]),
7030 &base_env(),
7031 )
7032 .unwrap();
7033 assert_eq!(
7034 l.settings.agent.instruction.as_deref(),
7035 Some("read me from a file")
7036 );
7037 assert_eq!(l.settings.lifecycle.exit_code_map.get("3"), Some(&9));
7038 assert_eq!(l.settings.lifecycle.exit_code_map.get("7"), Some(&9));
7039 }
7040
7041 fn load_doc(yaml: &str) -> Result<Loaded, ConfigError> {
7044 let f = write_tmp(yaml, "yaml");
7045 load(&args(&["--config", f.path().to_str().unwrap()]), &[]).map(|(l, _)| l)
7046 }
7047
7048 #[test]
7049 fn validation_collects_the_document_rules() {
7050 let e = load_doc(
7052 "config_version: \"1\"\nintelligence:\n endpoints: [https://i]\n token: sk-inline\n",
7053 )
7054 .unwrap_err();
7055 assert!(format!("{e}").contains("inline credential"), "{e}");
7056 let (l, _) = load(
7057 &args(&[
7058 "--intelligence",
7059 "https://i",
7060 "--intelligence-token",
7061 "sk-inline",
7062 ]),
7063 &[],
7064 )
7065 .unwrap();
7066 assert_eq!(
7067 l.settings.intelligence.token.as_ref().map(|s| s.0.as_str()),
7068 Some("sk-inline")
7069 );
7070 assert!(
7071 !format!("{:?}", l.settings).contains("sk-inline"),
7072 "Debug redacts"
7073 );
7074
7075 let e = load_doc(
7078 "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",
7079 )
7080 .unwrap_err();
7081 assert!(matches!(e, ConfigError::Usage(_)), "{e}");
7082
7083 let f = write_tmp(
7085 "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",
7086 "yaml",
7087 );
7088 let e = load(
7089 &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
7090 &[],
7091 )
7092 .unwrap_err();
7093 let ConfigError::Validate(Err(lines)) = e else {
7094 panic!("expected a validate verdict, got {e:?}")
7095 };
7096 for needle in [
7097 "store.mcp.server 'nope'",
7098 "knowledge.server 'kb'",
7099 "skills.sources[]",
7100 "tools.overrides['memory.get']",
7101 "both disabled and overridden",
7102 "only the policy codes 3 and 7",
7103 "0..=255",
7104 ] {
7105 assert!(lines.contains(needle), "missing {needle} in:\n{lines}");
7106 }
7107
7108 let e = load_doc("config_version: \"1\"\nstore: {kind: memory}\na2a: {listen: \"https://0.0.0.0:8443\"}\n").unwrap_err();
7110 assert!(format!("{e}").contains("a2a.tls.cert"), "{e}");
7111 let e = load_doc("config_version: \"1\"\nstore: {kind: memory}\na2a: {listen: \"http://0.0.0.0:8080\"}\n").unwrap_err();
7112 assert!(format!("{e}").contains("loopback"), "{e}");
7113 let e = load_doc(
7115 "config_version: \"1\"\na2a: {principals: [{match: {any: true}, role: operator}]}\n",
7116 )
7117 .unwrap_err();
7118 assert!(format!("{e}").contains("operator role"), "{e}");
7119 let e = load_doc("config_version: \"1\"\nintelligence: {budget: {windows: [{per: hour}], on_exhausted: degrade}}\n").unwrap_err();
7121 assert!(format!("{e}").contains("tokens and/or requests"), "{e}");
7122 let e = load_doc(
7124 "config_version: \"1\"\nmcp:\n servers:\n - {name: fs, endpoint: https://fs/mcp, tags: {\"*\": [untrusted_input, sensitive, egress]}}\n",
7125 )
7126 .unwrap_err();
7127 assert!(format!("{e}").contains("lethal-trifecta"), "{e}");
7128 }
7129
7130 #[test]
7131 fn restart_only_diff_names_changed_paths() {
7132 let a = json!({"agent": {"name": "x", "instruction": "i"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
7133 let b = json!({"agent": {"name": "y", "instruction": "j"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
7134 assert_eq!(restart_only_diff(&a, &b), vec!["agent.name".to_string()]);
7135 let c = json!({"agent": {"name": "x", "instruction": "changed"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
7136 assert!(
7137 restart_only_diff(&a, &c).is_empty(),
7138 "instruction is reloadable"
7139 );
7140 }
7141
7142 #[test]
7143 fn duration_and_tool_select_scalars() {
7144 let s = Settings::from_document(
7145 json!({"limits": {"run": {"deadline": "90s"}, "step_timeout": 5}, "agent": {"tools": {"mcp": "none", "internal": ["memory.get"]}}}),
7146 "t",
7147 )
7148 .unwrap();
7149 assert_eq!(s.limits.run.deadline(), Duration::from_secs(90));
7150 assert_eq!(s.limits.step_timeout, Some(Dur(Duration::from_secs(5))));
7151 assert!(!s.agent.tools.mcp.allows("fs.read"));
7152 assert!(s.agent.tools.internal.allows("memory.get"));
7153 assert!(!s.agent.tools.internal.allows("finish"));
7154 assert!(s.agent.tools.code.allows("anything"));
7155 assert!(
7156 Settings::from_document(json!({"limits": {"run": {"deadline": "soon"}}}), "t").is_err()
7157 );
7158 }
7159
7160 #[test]
7161 fn instruction_config_directives_define_the_agent_and_explicit_keys_win() {
7162 let instr = ":::config\nlimits: {max_runs: 9}\n:::\n\
7163 :::stream{name=orders}\nretention: {max_events: 50}\n:::\n\
7164 :::mcp{name=fs}\nendpoint: \"https://fs.internal/mcp\"\nexclude: [\"delete_*\"]\n:::\n\
7165 Do the work.";
7166 let s = Settings::from_document(
7167 json!({"agent": {"instruction": instr}, "limits": {"max_runs": 3}}),
7168 "t",
7169 )
7170 .unwrap();
7171 assert_eq!(
7172 s.limits.max_runs,
7173 Some(3),
7174 "an explicit key beats the fragment"
7175 );
7176 assert_eq!(
7177 s.streams.get("orders").map(|c| c.max_events()),
7178 Some(50),
7179 "the fragment fills what the config left unsaid"
7180 );
7181 let srv = s
7182 .mcp
7183 .servers
7184 .iter()
7185 .find(|m| m.name == "fs")
7186 .expect("declared");
7187 assert_eq!(srv.endpoint, "https://fs.internal/mcp");
7188 assert_eq!(srv.exclude, vec!["delete_*"]);
7189 let cleaned = s.agent.instruction.as_deref().unwrap();
7190 assert!(cleaned.contains("Do the work."));
7191 assert!(
7192 !cleaned.contains("endpoint"),
7193 "machinery never reaches the model"
7194 );
7195 assert!(
7198 Settings::from_document(
7199 json!({"agent": {"instruction": ":::config\nnot_a_section: 1\n:::\nx"}}),
7200 "t"
7201 )
7202 .is_err()
7203 );
7204 }
7205
7206 #[test]
7209 fn file_store_root_walks_the_chain_in_order() {
7210 use std::ffi::OsString;
7211 use std::path::PathBuf;
7212 let env = |pairs: Vec<(&'static str, &'static str)>| {
7213 move |k: &str| -> Option<OsString> {
7214 pairs
7215 .iter()
7216 .find(|(n, _)| *n == k)
7217 .map(|(_, v)| OsString::from(*v))
7218 }
7219 };
7220 let all = vec![
7221 ("AGENTD_STATE_DIR", "/state-dir"),
7222 ("XDG_STATE_HOME", "/xdg"),
7223 ("HOME", "/home/a"),
7224 ];
7225 let with_file = |path: Option<&str>| Store {
7226 file: Some(StoreFile {
7227 path: path.map(str::to_string),
7228 min_free: None,
7229 }),
7230 ..Store::default()
7231 };
7232
7233 assert_eq!(
7235 file_store_root_in(&with_file(Some("/var/lib/agentd")), &env(all.clone())),
7236 PathBuf::from("/var/lib/agentd")
7237 );
7238 assert_eq!(
7241 file_store_root_in(&with_file(None), &env(all.clone())),
7242 PathBuf::from("/state-dir")
7243 );
7244 assert_eq!(
7246 file_store_root_in(&Store::default(), &env(all[1..].to_vec())),
7247 PathBuf::from("/xdg/agentd/state")
7248 );
7249 assert_eq!(
7251 file_store_root_in(&Store::default(), &env(all[2..].to_vec())),
7252 PathBuf::from("/home/a/.local/state/agentd/state")
7253 );
7254 assert_eq!(
7256 file_store_root_in(&Store::default(), &env(vec![])),
7257 std::env::temp_dir().join("agentd").join("state")
7258 );
7259 assert!(
7262 file_store_root_in(&Store::default(), &env(all[1..].to_vec()))
7263 .ends_with("agentd/state")
7264 );
7265 }
7266
7267 #[test]
7268 fn file_store_validation_diagnostics() {
7269 let l = load_doc("config_version: \"1\"\nstore: {kind: file}\n").unwrap();
7271 assert_eq!(l.settings.store.kind, StoreKind::File);
7272 assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
7273 let l = load_doc(
7275 "config_version: \"1\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\na2a: {listen: \"http://127.0.0.1:8080\"}\n",
7276 )
7277 .unwrap();
7278 assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
7279 assert_eq!(
7280 file_store_root(&l.settings.store),
7281 std::path::PathBuf::from("/var/lib/agentd")
7282 );
7283
7284 let e = load_doc("config_version: \"1\"\nstore: {kind: file, file: {path: \"\"}}\n")
7286 .unwrap_err();
7287 assert!(format!("{e}").contains("store.file.path is empty"), "{e}");
7288
7289 let l = load_doc(
7292 "config_version: \"1\"\nstore: {kind: memory, file: {path: /var/lib/agentd}}\n",
7293 )
7294 .unwrap();
7295 let d = validate(&l);
7296 assert!(d.errors.is_empty(), "{:?}", d.errors);
7297 assert!(
7298 d.warnings
7299 .iter()
7300 .any(|w| w.contains("store.file is set but store.kind is memory")),
7301 "{:?}",
7302 d.warnings
7303 );
7304 let l =
7306 load_doc("config_version: \"1\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\n")
7307 .unwrap();
7308 assert!(
7309 !validate(&l)
7310 .warnings
7311 .iter()
7312 .any(|w| w.contains("store.file")),
7313 "{:?}",
7314 validate(&l).warnings
7315 );
7316 assert_eq!(
7318 restart_only_diff(
7319 &json!({"store": {"kind": "file", "file": {"path": "/a"}}}),
7320 &json!({"store": {"kind": "file", "file": {"path": "/b"}}})
7321 ),
7322 vec!["store.file".to_string()]
7323 );
7324 }
7325
7326 #[test]
7327 fn instruction_uri_detection() {
7328 assert!(looks_like_resource_uri("mcp://docs/agent-instruction"));
7329 assert!(looks_like_resource_uri("docs://agent"));
7330 assert!(!looks_like_resource_uri("You are a helpful agent."));
7331 assert!(!looks_like_resource_uri(
7332 "see https://x.example for details"
7333 ));
7334 assert!(!looks_like_resource_uri("://nope"));
7335 }
7336
7337 #[test]
7338 fn help_and_schema_asks_short_circuit_validation() {
7339 let (_, ask) = load(&args(&["--help"]), &[]).unwrap();
7340 assert_eq!(ask, Ask::Help);
7341 let (_, ask) = load(&args(&["--config-schema=1"]), &[]).unwrap();
7342 assert_eq!(ask, Ask::Schema);
7343 let (_, ask) = load(&args(&["--workflow-schema"]), &[]).unwrap();
7346 assert_eq!(ask, Ask::WorkflowSchema);
7347 assert!(help_section().contains("intelligence.model"));
7348 }
7349
7350 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";
7353
7354 #[test]
7355 fn service_reference_inherits_and_narrows() {
7356 let f = write_tmp(
7357 &format!(
7358 "{CATALOG}mcp:\n servers:\n - {{name: money, service: billing, allow: [charge_lookup], ns: fin}}\n"
7359 ),
7360 "yaml",
7361 );
7362 let (loaded, _) = load(
7363 &args(&["--config", f.path().to_str().unwrap()]),
7364 &base_env(),
7365 )
7366 .unwrap();
7367 let s = &loaded.settings.mcp.servers[0];
7368 assert_eq!(s.endpoint, "https://billing.example/mcp", "inherited");
7369 assert!(s.auth.is_some(), "inherited auth");
7370 assert_eq!(s.headers["X-Env"], "prod", "inherited headers");
7371 assert_eq!(s.allow.as_deref(), Some(&["charge_lookup".to_string()][..]));
7372 assert_eq!(
7373 s.exclude,
7374 vec!["invoice_purge".to_string()],
7375 "exclude unions"
7376 );
7377 assert_eq!(s.tags["*"], vec!["sensitive"], "tag floor applied");
7378 assert_eq!(s.ns.as_deref(), Some("fin"), "consumer-local ns kept");
7379 }
7380
7381 #[test]
7382 fn service_reference_without_allow_inherits_the_ceiling() {
7383 let f = write_tmp(
7384 &format!("{CATALOG}mcp:\n servers:\n - {{name: money, service: billing}}\n"),
7385 "yaml",
7386 );
7387 let (loaded, _) = load(
7388 &args(&["--config", f.path().to_str().unwrap()]),
7389 &base_env(),
7390 )
7391 .unwrap();
7392 let s = &loaded.settings.mcp.servers[0];
7393 assert_eq!(
7394 s.allow.as_deref(),
7395 Some(&["charge_lookup".to_string(), "invoice_*".to_string()][..]),
7396 "absent consumer allow inherits the catalog ceiling"
7397 );
7398 }
7399
7400 #[test]
7401 fn service_reference_refuses_restated_connection_settings() {
7402 let f = write_tmp(
7403 &format!(
7404 "{CATALOG}mcp:\n servers:\n - {{name: money, service: billing, endpoint: \"https://other.example\"}}\n"
7405 ),
7406 "yaml",
7407 );
7408 let e = load(
7409 &args(&["--config", f.path().to_str().unwrap()]),
7410 &base_env(),
7411 )
7412 .unwrap_err();
7413 let msg = format!("{e}");
7414 assert!(msg.contains("restates `endpoint`"), "{msg}");
7415 }
7416
7417 #[test]
7418 fn service_allow_widening_is_refused() {
7419 let f = write_tmp(
7420 &format!(
7421 "{CATALOG}mcp:\n servers:\n - {{name: money, service: billing, allow: [refund_all]}}\n"
7422 ),
7423 "yaml",
7424 );
7425 let e = load(
7426 &args(&["--config", f.path().to_str().unwrap()]),
7427 &base_env(),
7428 )
7429 .unwrap_err();
7430 let msg = format!("{e}");
7431 assert!(msg.contains("widens the ceiling"), "{msg}");
7432 }
7433
7434 #[test]
7435 fn unknown_service_reference_is_refused() {
7436 let f = write_tmp(
7437 "config_version: \"1\"\nstore: {kind: memory}\nmcp:\n servers:\n - {name: x, service: nope}\n",
7438 "yaml",
7439 );
7440 let e = load(
7441 &args(&["--config", f.path().to_str().unwrap()]),
7442 &base_env(),
7443 )
7444 .unwrap_err();
7445 assert!(format!("{e}").contains("unknown service 'nope'"), "{e}");
7446 }
7447
7448 #[test]
7449 fn tag_floor_applies_to_inline_matching_servers() {
7450 let f = write_tmp(
7454 &format!(
7455 "{CATALOG}mcp:\n servers:\n - {{name: sneaky, endpoint: \"https://billing.example/mcp/sub\"}}\n"
7456 ),
7457 "yaml",
7458 );
7459 let (loaded, _) = load(
7460 &args(&["--config", f.path().to_str().unwrap()]),
7461 &base_env(),
7462 )
7463 .unwrap();
7464 assert_eq!(
7465 loaded.settings.mcp.servers[0].tags["*"],
7466 vec!["sensitive"],
7467 "the catalog's tags are a floor for any matching endpoint"
7468 );
7469 }
7470
7471 #[test]
7472 fn egress_closed_refuses_uncatalogued_and_admits_catalogued() {
7473 let f = write_tmp(
7474 &format!(
7475 "{CATALOG}security: {{egress: closed}}\nmcp:\n servers:\n - {{name: rogue, endpoint: \"https://rogue.example/mcp\"}}\n"
7476 ),
7477 "yaml",
7478 );
7479 let e = load(
7480 &args(&["--config", f.path().to_str().unwrap()]),
7481 &base_env(),
7482 )
7483 .unwrap_err();
7484 let msg = format!("{e}");
7485 assert!(
7486 msg.contains("matches no `kind: mcp` services: catalog entry"),
7487 "{msg}"
7488 );
7489
7490 let ok = write_tmp(
7491 &format!(
7492 "{CATALOG}security: {{egress: closed}}\nmcp:\n servers:\n - {{name: money, service: billing}}\n"
7493 ),
7494 "yaml",
7495 );
7496 load(
7497 &args(&["--config", ok.path().to_str().unwrap()]),
7498 &base_env(),
7499 )
7500 .expect("a catalogued reference passes closed egress");
7501 }
7502
7503 #[test]
7504 fn ambiguous_catalog_endpoints_are_refused() {
7505 let f = write_tmp(
7506 "config_version: \"1\"\nstore: {kind: memory}\nservices:\n a: {endpoint: \"https://s.example/mcp\"}\n b: {endpoint: \"https://s.example/mcp/deeper\"}\n",
7507 "yaml",
7508 );
7509 let e = load(
7510 &args(&["--config", f.path().to_str().unwrap()]),
7511 &base_env(),
7512 )
7513 .unwrap_err();
7514 assert!(format!("{e}").contains("prefix-comparable"), "{e}");
7515 }
7516
7517 #[test]
7518 fn service_match_respects_segment_boundaries() {
7519 let mut services = BTreeMap::new();
7520 services.insert(
7521 "a".to_string(),
7522 Service {
7523 kind: ServiceKind::Mcp,
7524 endpoint: "https://s.example/api".into(),
7525 headers: BTreeMap::new(),
7526 tags: BTreeMap::new(),
7527 allow: None,
7528 exclude: Vec::new(),
7529 auth: None,
7530 rate: None,
7531 timeout: None,
7532 methods: None,
7533 breaker: None,
7534 },
7535 );
7536 let m = ServiceKind::Mcp;
7537 assert!(service_match(&services, m, "https://s.example/api").is_some());
7538 assert!(service_match(&services, m, "https://s.example/api/v2").is_some());
7539 assert!(
7540 service_match(&services, m, "https://s.example/apiary").is_none(),
7541 "prefix match is on segment boundaries, not string prefixes"
7542 );
7543 assert!(service_match(&services, m, "https://other.example/api").is_none());
7544 assert!(
7545 service_match(&services, m, "http://s.example/api").is_none(),
7546 "scheme must match"
7547 );
7548 assert!(
7549 service_match(&services, ServiceKind::Http, "https://s.example/api").is_none(),
7550 "matching is kind-filtered"
7551 );
7552 }
7553
7554 #[test]
7555 fn peer_references_resolve_and_all_four_kinds_gate_closed_egress() {
7556 let f = write_tmp(
7559 "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",
7560 "yaml",
7561 );
7562 let (loaded, _) = load(
7563 &args(&["--config", f.path().to_str().unwrap()]),
7564 &base_env(),
7565 )
7566 .expect("all surfaces catalogued ⇒ closed mode admits the config");
7567 let p = &loaded.settings.a2a.peers[0];
7568 assert_eq!(p.endpoint, "https://peer.example", "peer inherited");
7569 assert!(p.auth.is_some(), "peer inherited auth");
7570
7571 let bad = write_tmp(
7572 "config_version: \"1\"\nstore: {kind: memory}\nsecurity: {egress: closed}\na2a:\n peers:\n - {name: rogue, endpoint: \"https://rogue.example\"}\n",
7573 "yaml",
7574 );
7575 let e = load(
7576 &args(&["--config", bad.path().to_str().unwrap()]),
7577 &base_env(),
7578 )
7579 .unwrap_err();
7580 assert!(
7581 format!("{e}").contains("kind: peer"),
7582 "an uncatalogued peer is refused naming the kind: {e}"
7583 );
7584
7585 let badi = write_tmp(
7586 "config_version: \"1\"\nstore: {kind: memory}\nsecurity: {egress: closed}\nintelligence: {endpoints: \"https://rogue-intel.example/v1\"}\n",
7587 "yaml",
7588 );
7589 let e = load(&args(&["--config", badi.path().to_str().unwrap()]), &[]).unwrap_err();
7590 assert!(
7591 format!("{e}").contains("kind: intelligence"),
7592 "an uncatalogued intelligence endpoint is refused: {e}"
7593 );
7594 }
7595
7596 #[test]
7597 fn kind_specific_entry_fields_are_validated() {
7598 let f = write_tmp(
7599 "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",
7600 "yaml",
7601 );
7602 let e = load(
7603 &args(&["--config", f.path().to_str().unwrap()]),
7604 &base_env(),
7605 )
7606 .unwrap_err();
7607 let msg = format!("{e}");
7608 assert!(msg.contains("`tags` applies to `kind: mcp`"), "{msg}");
7609 assert!(msg.contains("`methods` applies to `kind: http`"), "{msg}");
7610 }
7611
7612 #[test]
7616 fn the_message_hop_cap_binds_without_being_configured() {
7617 let l = Limits::default();
7618 assert_eq!(l.max_message_depth, None, "unset by default");
7619 assert_eq!(l.message_depth(), DEFAULT_MESSAGE_DEPTH);
7620 assert!(
7621 l.message_depth() > 0,
7622 "a cap of 0 would refuse every message"
7623 );
7624 let tuned = Limits {
7626 max_message_depth: Some(2),
7627 ..Default::default()
7628 };
7629 assert_eq!(tuned.message_depth(), 2);
7630 }
7631
7632 #[test]
7633 fn pattern_subsumption_covers_the_glob_grammar() {
7634 assert!(pattern_subsumes("charge_lookup", "charge_lookup"));
7635 assert!(pattern_subsumes("charge_lookup", "charge_*"));
7636 assert!(pattern_subsumes("charge_*", "charge_*"));
7637 assert!(pattern_subsumes("charge_x_*", "charge_*"));
7638 assert!(!pattern_subsumes("charge_*", "charge_lookup"));
7639 assert!(!pattern_subsumes("refund_all", "charge_*"));
7640 assert!(pattern_subsumes("anything", "*"));
7641 }
7642}