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