1pub mod schema;
21
22use super::file::{self, Format};
23use super::paths::{self, Binding};
24use super::{ConfigError, usage};
25use serde::{Deserialize, Serialize};
26use serde_json::{Map, Value, json};
27use std::collections::{BTreeMap, HashMap};
28use std::fmt;
29use std::time::Duration;
30
31#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
38pub struct Dur(pub Duration);
39
40impl fmt::Debug for Dur {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 write!(f, "{:?}", self.0)
43 }
44}
45
46impl<'de> Deserialize<'de> for Dur {
47 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
48 #[derive(Deserialize)]
49 #[serde(untagged)]
50 enum Raw {
51 Secs(u64),
52 Text(String),
53 }
54 match Raw::deserialize(d)? {
55 Raw::Secs(s) => Ok(Dur(Duration::from_secs(s))),
56 Raw::Text(t) => super::parse_duration(&t)
57 .map(Dur)
58 .map_err(serde::de::Error::custom),
59 }
60 }
61}
62
63#[derive(Clone, PartialEq, Eq, Deserialize, Default)]
67pub struct Secret(pub String);
68
69impl fmt::Debug for Secret {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 f.write_str("***")
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
77#[serde(untagged)]
78pub enum ToolSelect {
79 Keyword(SelectKeyword),
80 List(Vec<String>),
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum SelectKeyword {
86 All,
87 None,
88}
89
90impl Default for ToolSelect {
91 fn default() -> Self {
92 ToolSelect::Keyword(SelectKeyword::All)
93 }
94}
95
96impl ToolSelect {
97 pub fn allows(&self, name: &str) -> bool {
98 match self {
99 ToolSelect::Keyword(SelectKeyword::All) => true,
100 ToolSelect::Keyword(SelectKeyword::None) => false,
101 ToolSelect::List(l) => l.iter().any(|n| n == name),
102 }
103 }
104}
105
106fn string_or_list<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
107 #[derive(Deserialize)]
108 #[serde(untagged)]
109 enum Raw {
110 List(Vec<String>),
111 One(String),
112 }
113 Ok(match Raw::deserialize(d)? {
114 Raw::List(l) => l,
115 Raw::One(s) => s
116 .split(',')
117 .map(str::trim)
118 .filter(|s| !s.is_empty())
119 .map(str::to_string)
120 .collect(),
121 })
122}
123
124#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
132#[serde(deny_unknown_fields, default)]
133pub struct Settings {
134 pub config_version: Option<String>,
135 pub agent: Agent,
136 pub intelligence: Intelligence,
137 pub mcp: Mcp,
138 pub tools: Tools,
139 pub store: Store,
140 pub memory: Memory,
141 pub context: Context,
142 pub knowledge: Knowledge,
143 pub search: Search,
144 pub skills: Skills,
145 pub workflows: Vec<Value>,
148 pub limits: Limits,
149 pub lifecycle: Lifecycle,
150 pub a2a: A2a,
151 pub interface: Interface,
154 pub webhooks: Webhooks,
157 pub goal: Option<Goal>,
160 pub observability: Observability,
161 pub security: Security,
162}
163
164#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
165#[serde(deny_unknown_fields, default)]
166pub struct Agent {
167 pub name: Option<String>,
168 pub instruction: Option<String>,
171 pub prompt: Option<String>,
177 pub preflight: Preflight,
178 pub wake_on: Option<Vec<WakeEvent>>,
179 pub on_workflow_finished: OnWorkflowFinished,
180 pub tools: AgentTools,
181 pub max_parallel_turns: Option<u32>,
182 pub conversation_budget: Option<Budget>,
183 pub ask_human_fallback: AskHumanFallback,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
193#[serde(rename_all = "lowercase")]
194pub enum AskHumanFallback {
195 #[serde(alias = "pause", alias = "idle")]
197 Wait,
198 #[default]
200 #[serde(alias = "finish", alias = "stop")]
201 Fail,
202 Auto,
205}
206
207impl Agent {
208 pub fn wake_on(&self) -> Vec<WakeEvent> {
210 self.wake_on.clone().unwrap_or_else(|| {
211 vec![
212 WakeEvent::A2aMessage,
213 WakeEvent::HumanReply,
214 WakeEvent::SubagentResult,
215 WakeEvent::WorkflowFailed,
216 ]
217 })
218 }
219 pub fn max_parallel_turns(&self) -> u32 {
220 self.max_parallel_turns.unwrap_or(4)
221 }
222 pub fn instruction_is_uri(&self) -> bool {
224 self.instruction
225 .as_deref()
226 .is_some_and(looks_like_resource_uri)
227 }
228}
229
230pub fn looks_like_resource_uri(s: &str) -> bool {
234 let t = s.trim();
235 if t.contains(char::is_whitespace) {
236 return false;
237 }
238 let Some((scheme, rest)) = t.split_once("://") else {
239 return false;
240 };
241 !scheme.is_empty()
242 && scheme
243 .chars()
244 .next()
245 .is_some_and(|c| c.is_ascii_alphabetic())
246 && scheme
247 .chars()
248 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
249 && !rest.is_empty()
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
253#[serde(rename_all = "lowercase")]
254pub enum Preflight {
255 Never,
256 #[default]
257 Auto,
258 Always,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum WakeEvent {
264 A2aMessage,
265 HumanReply,
266 SubagentResult,
267 WorkflowFinished,
268 WorkflowFailed,
269 InstructionUpdated,
270 BudgetResumed,
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
274#[serde(rename_all = "lowercase")]
275pub enum OnWorkflowFinished {
276 Ignore,
277 #[default]
278 Note,
279 Think,
280}
281
282#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
283#[serde(deny_unknown_fields, default)]
284pub struct AgentTools {
285 pub internal: ToolSelect,
286 pub mcp: ToolSelect,
287 pub code: ToolSelect,
288}
289
290#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
291#[serde(deny_unknown_fields, default)]
292pub struct Intelligence {
293 #[serde(deserialize_with = "string_or_list")]
294 pub endpoints: Vec<String>,
295 pub model: Option<String>,
296 pub dialect: Option<String>,
300 pub token: Option<Secret>,
301 pub token_file: Option<String>,
302 pub headers: BTreeMap<String, String>,
303 pub auth: Option<Auth>,
307 pub swap_policy: Option<String>,
308 pub structured_output: StructuredOutput,
309 pub budget: Budget,
310 pub pricing: BTreeMap<String, Pricing>,
311 pub timeout: Option<Dur>,
312}
313
314impl Intelligence {
315 pub fn timeout(&self) -> Duration {
316 self.timeout.map(|d| d.0).unwrap_or(Duration::from_secs(60))
317 }
318 pub fn endpoint_list(&self) -> Option<String> {
320 if self.endpoints.is_empty() {
321 None
322 } else {
323 Some(self.endpoints.join(","))
324 }
325 }
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
329#[serde(rename_all = "snake_case")]
330pub enum StructuredOutput {
331 #[default]
332 Auto,
333 JsonSchema,
334 Tool,
335 Prompt,
336}
337
338#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
339#[serde(deny_unknown_fields, default)]
340pub struct Budget {
341 pub windows: Vec<BudgetWindow>,
342 pub lifetime_tokens: Option<u64>,
343 pub scope: Option<Vec<BudgetScope>>,
344 pub on_exhausted: BudgetTactic,
345 pub slow: Slow,
346 pub degrade: Degrade,
347 pub reserve: Reserve,
348}
349
350#[derive(Debug, Clone, Deserialize, PartialEq)]
351#[serde(deny_unknown_fields)]
352pub struct BudgetWindow {
353 pub per: WindowUnit,
354 #[serde(default)]
355 pub tokens: Option<u64>,
356 #[serde(default)]
357 pub requests: Option<u64>,
358 #[serde(default)]
359 pub reset: Option<String>,
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
363#[serde(rename_all = "lowercase")]
364pub enum WindowUnit {
365 Second,
366 Minute,
367 Hour,
368 Day,
369 Week,
370}
371
372impl WindowUnit {
373 pub fn duration(self) -> Duration {
374 match self {
375 WindowUnit::Second => Duration::from_secs(1),
376 WindowUnit::Minute => Duration::from_secs(60),
377 WindowUnit::Hour => Duration::from_secs(3600),
378 WindowUnit::Day => Duration::from_secs(86_400),
379 WindowUnit::Week => Duration::from_secs(7 * 86_400),
380 }
381 }
382 pub fn is_calendar(self) -> bool {
384 matches!(self, WindowUnit::Day | WindowUnit::Week)
385 }
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
389#[serde(rename_all = "lowercase")]
390pub enum BudgetScope {
391 Instance,
392 Run,
393 Conversation,
394 Principal,
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
398#[serde(rename_all = "lowercase")]
399pub enum BudgetTactic {
400 #[default]
401 Wait,
402 Slow,
403 Degrade,
404 Refuse,
405 Fail,
406}
407
408#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
409#[serde(deny_unknown_fields, default)]
410pub struct Slow {
411 pub factor: Option<f64>,
412}
413
414#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
415#[serde(deny_unknown_fields, default)]
416pub struct Degrade {
417 pub model: Option<String>,
418}
419
420#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
421#[serde(deny_unknown_fields, default)]
422pub struct Reserve {
423 pub estimate: ReserveEstimate,
424 pub fixed: Option<u64>,
425}
426
427#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
428#[serde(rename_all = "lowercase")]
429pub enum ReserveEstimate {
430 #[default]
431 Context,
432 Fixed,
433 None,
434}
435
436#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
437#[serde(deny_unknown_fields, default)]
438pub struct Pricing {
439 pub input_per_1k: Option<f64>,
440 pub output_per_1k: Option<f64>,
441 pub currency: Option<String>,
442}
443
444#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
445#[serde(deny_unknown_fields, default)]
446pub struct Mcp {
447 pub servers: Vec<McpServer>,
448 pub default_timeout: Option<Dur>,
449}
450
451#[derive(Debug, Clone, Deserialize, PartialEq)]
452#[serde(deny_unknown_fields)]
453pub struct McpServer {
454 pub name: String,
455 pub endpoint: String,
456 #[serde(default)]
457 pub ns: Option<String>,
458 #[serde(default)]
459 pub headers: BTreeMap<String, String>,
460 #[serde(default)]
461 pub tags: BTreeMap<String, Vec<String>>,
462 #[serde(default)]
463 pub aauth: Option<bool>,
464 #[serde(default)]
465 pub oauth: Option<McpOauth>,
466 #[serde(default)]
471 pub auth: Option<Auth>,
472 #[serde(default)]
473 pub timeout: Option<Dur>,
474}
475
476impl McpServer {
477 pub fn tag_set(&self) -> Result<Vec<crate::sec::scope::TrifectaTag>, String> {
479 let mut out = Vec::new();
480 for list in self.tags.values() {
481 for t in list {
482 let tag = crate::sec::scope::TrifectaTag::parse(t).ok_or_else(|| {
483 format!("mcp server '{}' has unknown trifecta tag '{t}'", self.name)
484 })?;
485 if !out.contains(&tag) {
486 out.push(tag);
487 }
488 }
489 }
490 Ok(out)
491 }
492
493 pub fn to_spec(&self) -> Result<super::McpServerSpec, String> {
495 Ok(super::McpServerSpec {
496 name: self.name.clone(),
497 endpoint: self.endpoint.clone(),
498 headers: self
499 .headers
500 .iter()
501 .map(|(k, v)| (k.clone(), v.clone()))
502 .collect(),
503 tags: self.tag_set()?,
504 aauth: self.aauth,
505 oauth: self.oauth.as_ref().map(|o| super::McpOauthSpec {
508 token_url: o.token_url.clone(),
509 client_id: o.client_id.clone(),
510 client_secret: o.client_secret.0.clone(),
511 scope: o.scope.clone(),
512 }),
513 auth: self.auth.as_ref().map(|a| a.to_spec()),
514 })
515 }
516}
517
518#[derive(Debug, Clone, Deserialize, PartialEq)]
519#[serde(deny_unknown_fields)]
520pub struct McpOauth {
521 pub token_url: String,
522 pub client_id: String,
523 pub client_secret: Secret,
524 #[serde(default)]
525 pub scope: Option<String>,
526}
527
528#[derive(Debug, Clone, Deserialize, PartialEq)]
533#[serde(deny_unknown_fields)]
534pub struct Auth {
535 pub kind: AuthKind,
536 #[serde(default)]
540 pub issuer: Option<String>,
541 #[serde(default)]
542 pub token_url: Option<String>,
543 #[serde(default)]
544 pub device_authorization_url: Option<String>,
545 #[serde(default)]
546 pub authorization_url: Option<String>,
547 #[serde(default)]
548 pub client_id: Option<String>,
549 #[serde(default)]
552 pub client_secret: Option<Secret>,
553 #[serde(default)]
556 pub grant: Option<OAuthGrant>,
557 #[serde(default)]
558 pub scopes: Vec<String>,
559 #[serde(default)]
560 pub audience: Option<String>,
561 #[serde(default)]
564 pub token: Option<Secret>,
565 #[serde(default)]
567 pub header: Option<String>,
568 #[serde(default)]
569 pub value: Option<Secret>,
570 #[serde(default)]
572 pub region: Option<String>,
573 #[serde(default)]
575 pub service: Option<String>,
576 #[serde(default)]
579 pub source: Option<String>,
580 #[serde(default)]
583 pub sso_start_url: Option<String>,
584 #[serde(default)]
585 pub account_id: Option<String>,
586 #[serde(default)]
587 pub role_name: Option<String>,
588 #[serde(default)]
592 pub svid: Option<String>,
593 #[serde(default)]
596 pub jwt_svid_file: Option<String>,
597 #[serde(default)]
599 pub svid_file: Option<String>,
600 #[serde(default)]
601 pub key_file: Option<String>,
602}
603
604impl Auth {
605 pub fn to_spec(&self) -> super::AuthSpec {
608 super::AuthSpec {
609 kind: match self.kind {
610 AuthKind::Static => "static",
611 AuthKind::Oauth2 => "oauth2",
612 AuthKind::Aws => "aws",
613 AuthKind::Spiffe => "spiffe",
614 }
615 .to_string(),
616 grant: self.grant.map(|g| {
617 match g {
618 OAuthGrant::Device => "device",
619 OAuthGrant::AuthorizationCode => "authorization_code",
620 OAuthGrant::ClientCredentials => "client_credentials",
621 }
622 .to_string()
623 }),
624 issuer: self.issuer.clone(),
625 token_url: self.token_url.clone(),
626 device_authorization_url: self.device_authorization_url.clone(),
627 authorization_url: self.authorization_url.clone(),
628 client_id: self.client_id.clone(),
629 client_secret: self.client_secret.as_ref().map(|s| s.0.clone()),
630 scopes: self.scopes.clone(),
631 audience: self.audience.clone(),
632 token: self.token.as_ref().map(|s| s.0.clone()),
633 header: self.header.clone(),
634 value: self.value.as_ref().map(|s| s.0.clone()),
635 region: self.region.clone(),
636 service: self.service.clone(),
637 source: self.source.clone(),
638 sso_start_url: self.sso_start_url.clone(),
639 account_id: self.account_id.clone(),
640 role_name: self.role_name.clone(),
641 svid: self.svid.clone(),
642 jwt_svid_file: self.jwt_svid_file.clone(),
643 svid_file: self.svid_file.clone(),
644 key_file: self.key_file.clone(),
645 }
646 }
647}
648
649#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
651#[serde(rename_all = "snake_case")]
652pub enum AuthKind {
653 Static,
655 Oauth2,
657 Aws,
659 Spiffe,
662}
663
664#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
666#[serde(rename_all = "snake_case")]
667pub enum OAuthGrant {
668 Device,
670 AuthorizationCode,
672 ClientCredentials,
674}
675
676#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
677#[serde(deny_unknown_fields, default)]
678pub struct Tools {
679 pub disabled: Vec<String>,
680 pub overrides: BTreeMap<String, ToolOverride>,
681}
682
683#[derive(Debug, Clone, Deserialize, PartialEq)]
684#[serde(deny_unknown_fields)]
685pub struct ToolOverride {
686 pub server: String,
687 pub tool: String,
688 #[serde(default)]
689 pub args: Option<String>,
690 #[serde(default)]
691 pub result: Option<String>,
692}
693
694#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
695#[serde(deny_unknown_fields, default)]
696pub struct Store {
697 pub kind: StoreKind,
698 pub prefix: Option<String>,
699 pub mcp: Option<StoreMcp>,
700 pub http: Option<StoreHttp>,
701 pub file: Option<StoreFile>,
702 pub checkpoint: Checkpoint,
703 pub durability: Durability,
704 pub retention: Retention,
705 pub on_error: StoreOnError,
706 pub audit: bool,
707 pub timeout: Option<Dur>,
708}
709
710impl Store {
711 pub fn prefix(&self) -> &str {
712 self.prefix.as_deref().unwrap_or("agentd")
713 }
714}
715
716#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
717#[serde(rename_all = "lowercase")]
718pub enum StoreKind {
719 Mcp,
720 Http,
721 File,
724 Memory,
725 #[default]
726 None,
727}
728
729#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
733#[serde(deny_unknown_fields)]
734pub struct StoreFile {
735 #[serde(default)]
736 pub path: Option<String>,
737}
738
739pub fn file_store_root(store: &Store) -> std::path::PathBuf {
756 file_store_root_in(store, &|k| std::env::var_os(k))
757}
758
759fn file_store_root_in(
763 store: &Store,
764 env: &dyn Fn(&str) -> Option<std::ffi::OsString>,
765) -> std::path::PathBuf {
766 use std::path::PathBuf;
767 if let Some(p) = store.file.as_ref().and_then(|f| f.path.as_deref()) {
768 return PathBuf::from(p);
769 }
770 if let Some(d) = env("AGENTD_STATE_DIR") {
771 return PathBuf::from(d);
772 }
773 if let Some(d) = env("XDG_STATE_HOME") {
774 return PathBuf::from(d).join("agentd").join("state");
775 }
776 if let Some(h) = env("HOME") {
777 return PathBuf::from(h)
778 .join(".local")
779 .join("state")
780 .join("agentd")
781 .join("state");
782 }
783 std::env::temp_dir().join("agentd").join("state")
784}
785
786#[derive(Debug, Clone, Deserialize, PartialEq)]
787#[serde(deny_unknown_fields)]
788pub struct StoreMcp {
789 pub server: String,
790 #[serde(default)]
791 pub put: Option<StoreOp>,
792 #[serde(default)]
793 pub get: Option<StoreOp>,
794 #[serde(default)]
795 pub list: Option<StoreOp>,
796 #[serde(default)]
797 pub delete: Option<StoreOp>,
798}
799
800#[derive(Debug, Clone, Deserialize, PartialEq)]
801#[serde(deny_unknown_fields)]
802pub struct StoreOp {
803 pub tool: String,
804 #[serde(default)]
805 pub args: Option<String>,
806 #[serde(default)]
807 pub ok: Option<String>,
808 #[serde(default)]
809 pub conflict: Option<String>,
810 #[serde(default)]
811 pub value: Option<String>,
812 #[serde(default)]
813 pub keys: Option<String>,
814}
815
816#[derive(Debug, Clone, Deserialize, PartialEq)]
817#[serde(deny_unknown_fields)]
818pub struct StoreHttp {
819 pub base_url: String,
820 #[serde(default)]
821 pub headers: BTreeMap<String, String>,
822 #[serde(default)]
823 pub get: Option<HttpOp>,
824 #[serde(default)]
825 pub put: Option<HttpOp>,
826 #[serde(default)]
827 pub list: Option<HttpOp>,
828 #[serde(default)]
829 pub delete: Option<HttpOp>,
830}
831
832#[derive(Debug, Clone, Deserialize, PartialEq)]
833#[serde(deny_unknown_fields)]
834pub struct HttpOp {
835 #[serde(default)]
836 pub method: Option<String>,
837 pub url: String,
838 #[serde(default)]
839 pub body: Option<String>,
840 #[serde(default)]
841 pub value: Option<String>,
842 #[serde(default)]
843 pub keys: Option<String>,
844 #[serde(default)]
845 pub conflict_status: Option<u16>,
846}
847
848#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
849#[serde(deny_unknown_fields, default)]
850pub struct Checkpoint {
851 pub debounce_ms: Option<u64>,
852}
853
854#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
861#[serde(deny_unknown_fields, default)]
862pub struct Retention {
863 pub runs: RunRetention,
864}
865
866#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
867#[serde(deny_unknown_fields, default)]
868pub struct RunRetention {
869 pub keep_last: Option<u32>,
871 pub ttl: Option<Dur>,
873}
874
875#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
876#[serde(deny_unknown_fields, default)]
877pub struct Durability {
878 pub a2a: Option<DurabilityLevel>,
879 pub steps: Option<DurabilityLevel>,
880}
881
882#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
883#[serde(rename_all = "lowercase")]
884pub enum DurabilityLevel {
885 Strict,
886 Eventual,
887}
888
889#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
890#[serde(rename_all = "lowercase")]
891pub enum StoreOnError {
892 #[default]
893 Halt,
894 Degrade,
895}
896
897#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
898#[serde(deny_unknown_fields, default)]
899pub struct Memory {
900 pub max_value_bytes: Option<u64>,
901 pub list_default_limit: Option<u64>,
902}
903
904#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
905#[serde(deny_unknown_fields, default)]
906pub struct Context {
907 pub compact_at: Option<f64>,
908 pub keep_last: Option<u32>,
909 pub model_window: Option<u64>,
912 pub plan: Plan,
913}
914
915#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
916#[serde(deny_unknown_fields, default)]
917pub struct Plan {
918 pub max_items: Option<u32>,
919}
920
921#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
922#[serde(deny_unknown_fields, default)]
923pub struct Knowledge {
924 pub server: Option<String>,
925 pub auto_context: AutoContext,
926}
927
928#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
929#[serde(deny_unknown_fields, default)]
930pub struct AutoContext {
931 pub on: AutoContextOn,
932 pub top_k: Option<u32>,
933 pub max_bytes: Option<u64>,
934}
935
936#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
937#[serde(rename_all = "lowercase")]
938pub enum AutoContextOn {
939 Turn,
940 #[default]
941 Never,
942}
943
944#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
945#[serde(deny_unknown_fields, default)]
946pub struct Search {
947 pub server: Option<String>,
948}
949
950#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
951#[serde(deny_unknown_fields, default)]
952pub struct Skills {
953 pub sources: Vec<SkillSource>,
954 pub reference_prefix: Option<String>,
955 pub max_loaded: Option<u32>,
956 pub max_bytes: Option<u64>,
957}
958
959#[derive(Debug, Clone, Deserialize, PartialEq)]
960#[serde(deny_unknown_fields)]
961pub struct SkillSource {
962 pub server: String,
963 #[serde(default)]
964 pub discover: Discover,
965 #[serde(default)]
966 pub filter: Option<String>,
967}
968
969#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
970#[serde(rename_all = "lowercase")]
971pub enum Discover {
972 Prompts,
973 Resources,
974 #[default]
975 Auto,
976}
977
978#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
979#[serde(deny_unknown_fields, default)]
980pub struct Limits {
981 pub max_runs: Option<u32>,
982 pub run: RunLimits,
983 pub subagents: SubagentLimits,
984 pub inline_max_bytes: Option<u64>,
985 pub step_timeout: Option<Dur>,
986 pub workflow: WorkflowLimits,
987}
988
989#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
991#[serde(deny_unknown_fields, default)]
992pub struct WorkflowLimits {
993 pub fan_out: Option<u32>,
999}
1000
1001#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1002#[serde(deny_unknown_fields, default)]
1003pub struct RunLimits {
1004 pub steps: Option<u32>,
1005 pub tokens: Option<u64>,
1006 pub deadline: Option<Dur>,
1007}
1008
1009impl RunLimits {
1010 pub fn steps(&self) -> u32 {
1011 self.steps.unwrap_or(500)
1012 }
1013 pub fn tokens(&self) -> u64 {
1014 self.tokens.unwrap_or(2_000_000)
1015 }
1016 pub fn deadline(&self) -> Duration {
1017 self.deadline
1018 .map(|d| d.0)
1019 .unwrap_or(Duration::from_secs(3600))
1020 }
1021}
1022
1023#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1024#[serde(deny_unknown_fields, default)]
1025pub struct SubagentLimits {
1026 pub depth: Option<u32>,
1027 pub breadth: Option<u32>,
1028 pub total: Option<u32>,
1029 pub rate: Option<String>,
1030}
1031
1032#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1033#[serde(deny_unknown_fields, default)]
1034pub struct Lifecycle {
1035 pub run_until: RunUntil,
1036 pub idle_grace: Option<Dur>,
1037 pub drain_timeout: Option<Dur>,
1038 pub run_id: Option<String>,
1039 pub exit_code_map: BTreeMap<String, i32>,
1040 pub watch_config: bool,
1041}
1042
1043impl Lifecycle {
1044 pub fn drain_timeout(&self) -> Duration {
1045 self.drain_timeout
1046 .map(|d| d.0)
1047 .unwrap_or(Duration::from_secs(25))
1048 }
1049 pub fn idle_grace(&self) -> Duration {
1050 self.idle_grace
1051 .map(|d| d.0)
1052 .unwrap_or(Duration::from_secs(5))
1053 }
1054}
1055
1056#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1057#[serde(rename_all = "lowercase")]
1058pub enum RunUntil {
1059 #[default]
1060 Auto,
1061 Idle,
1062 Drained,
1063}
1064
1065#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1066#[serde(deny_unknown_fields, default)]
1067pub struct A2a {
1068 pub listen: Option<String>,
1069 pub tls: A2aTls,
1070 pub bearer: Option<Secret>,
1071 pub principals: Vec<Principal>,
1072 pub peers: Vec<A2aPeer>,
1073 pub conversation_ttl: Option<Dur>,
1074 pub push: A2aPush,
1075}
1076
1077#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1087#[serde(deny_unknown_fields, default)]
1088pub struct A2aPush {
1089 pub enabled: bool,
1091 pub allow_private: bool,
1093}
1094
1095#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1096#[serde(deny_unknown_fields, default)]
1097pub struct A2aTls {
1098 pub cert: Option<String>,
1099 pub key: Option<String>,
1100 pub client_ca: Option<String>,
1101}
1102
1103#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1114#[serde(deny_unknown_fields, default)]
1115pub struct Interface {
1116 pub enabled: bool,
1118 pub debug: bool,
1122 pub origins: Vec<String>,
1125 pub display: Display,
1128 pub pairing: Pairing,
1132}
1133
1134#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1139#[serde(deny_unknown_fields, default)]
1140pub struct Display {
1141 pub top: Option<Vec<String>>,
1142 pub bottom: Option<Vec<String>>,
1143}
1144
1145pub const DISPLAY_ITEMS: &[&str] = &[
1147 "name", "version", "instance", "model", "endpoint", "conn", "debug", "draining", "active", "turns", "tokens", "tool_calls",
1159 "runs", "subagents", "conversations", "screen", "keys", "clock", ];
1166
1167#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1173#[serde(deny_unknown_fields, default)]
1174pub struct Pairing {
1175 pub enabled: bool,
1176 pub role: Option<Role>,
1179 pub ttl: Option<Dur>,
1181}
1182
1183#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1188#[serde(deny_unknown_fields, default)]
1189pub struct Webhooks {
1190 pub listen: Option<String>,
1193 pub tls: A2aTls,
1194 pub default_auth: Option<WebhookAuth>,
1196}
1197
1198#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1202#[serde(deny_unknown_fields, default)]
1203pub struct WebhookAuth {
1204 pub hmac: Option<Hmac>,
1206 pub bearer: Option<Secret>,
1208 pub header: Option<HeaderMatch>,
1210 pub none: bool,
1212}
1213
1214#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1215#[serde(deny_unknown_fields, default)]
1216pub struct Hmac {
1217 pub secret: Option<Secret>,
1218 pub header: Option<String>,
1220 pub algo: Option<String>,
1222 pub prefix: Option<String>,
1224}
1225
1226#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1227#[serde(deny_unknown_fields, default)]
1228pub struct HeaderMatch {
1229 pub name: Option<String>,
1230 pub equals: Option<Secret>,
1231}
1232
1233#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1237#[serde(deny_unknown_fields, default)]
1238pub struct Goal {
1239 pub statement: Option<String>,
1241 pub check: GoalCheck,
1242 pub stuck_after: Option<u32>,
1244 pub on_achieved: Option<GoalAction>,
1246 pub on_stuck: Option<GoalAction>,
1248}
1249
1250#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1251#[serde(deny_unknown_fields, default)]
1252pub struct GoalCheck {
1253 pub every: Option<Dur>,
1255 pub condition: Option<String>,
1257 pub via: Option<String>,
1259}
1260
1261#[derive(Debug, Clone, PartialEq)]
1264pub enum GoalAction {
1265 Finish,
1266 Idle,
1267 Replan,
1268 Escalate,
1269 Workflow(String),
1270}
1271
1272impl<'de> Deserialize<'de> for GoalAction {
1273 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1274 use serde::de::Error;
1275 match Value::deserialize(d)? {
1276 Value::String(s) => match s.as_str() {
1277 "finish" => Ok(GoalAction::Finish),
1278 "idle" => Ok(GoalAction::Idle),
1279 "replan" => Ok(GoalAction::Replan),
1280 "escalate" => Ok(GoalAction::Escalate),
1281 other => Err(D::Error::custom(format!(
1282 "unknown goal action '{other}' (want finish|idle|replan|escalate|{{workflow: <name>}})"
1283 ))),
1284 },
1285 Value::Object(m) => match m.get("workflow").and_then(Value::as_str) {
1286 Some(w) => Ok(GoalAction::Workflow(w.to_string())),
1287 None => Err(D::Error::custom(
1288 "a goal action object must be { workflow: <name> }",
1289 )),
1290 },
1291 _ => Err(D::Error::custom(
1292 "a goal action must be a string or { workflow: <name> }",
1293 )),
1294 }
1295 }
1296}
1297
1298#[derive(Debug, Clone, Deserialize, PartialEq)]
1299#[serde(deny_unknown_fields)]
1300pub struct Principal {
1301 #[serde(rename = "match")]
1302 pub matcher: PrincipalMatch,
1303 pub role: Role,
1304 #[serde(default)]
1305 pub grants: Vec<String>,
1306 #[serde(default)]
1307 pub quotas: Option<Quotas>,
1308}
1309
1310#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1311#[serde(deny_unknown_fields, default)]
1312pub struct PrincipalMatch {
1313 pub san: Option<String>,
1314 pub sub: Option<String>,
1315 pub bearer_ref: Option<String>,
1316 pub aauth_agent: Option<String>,
1317 pub any: bool,
1318}
1319
1320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1321#[serde(rename_all = "lowercase")]
1322pub enum Role {
1323 Operator,
1324 User,
1325 Agent,
1326 Anonymous,
1327}
1328
1329#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1330#[serde(deny_unknown_fields, default)]
1331pub struct Quotas {
1332 pub rate: Option<String>,
1333 pub budget: Option<Budget>,
1334}
1335
1336#[derive(Debug, Clone, Deserialize, PartialEq)]
1337#[serde(deny_unknown_fields)]
1338pub struct A2aPeer {
1339 pub name: String,
1340 pub endpoint: String,
1341 #[serde(default)]
1342 pub headers: BTreeMap<String, String>,
1343 #[serde(default)]
1344 pub client_cert: Option<String>,
1345 #[serde(default)]
1346 pub client_key: Option<String>,
1347 #[serde(default)]
1351 pub auth: Option<Auth>,
1352}
1353
1354#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1355#[serde(deny_unknown_fields, default)]
1356pub struct Observability {
1357 pub log_level: Option<String>,
1358 pub log_content: bool,
1359 pub otel: Otel,
1360 pub metrics_addr: Option<String>,
1361 pub health_file: Option<String>,
1362 pub report_file: Option<String>,
1363 pub events_ring: Option<u32>,
1364 pub audit: Audit,
1365 pub traceparent: Option<String>,
1366}
1367
1368#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1369#[serde(deny_unknown_fields, default)]
1370pub struct Otel {
1371 pub endpoint: Option<String>,
1372 pub traces: Option<bool>,
1373 pub metrics: Option<bool>,
1374 pub logs: Option<bool>,
1375}
1376
1377#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1378#[serde(deny_unknown_fields, default)]
1379pub struct Audit {
1380 pub sink: Option<Vec<AuditSink>>,
1381}
1382
1383#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1384#[serde(rename_all = "lowercase")]
1385pub enum AuditSink {
1386 Log,
1387 Store,
1388}
1389
1390#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1391#[serde(deny_unknown_fields, default)]
1392pub struct Security {
1393 pub allow_trifecta: bool,
1394 pub tls_ca: Option<String>,
1395 pub aauth: Option<AAuth>,
1396 pub cgroup: Cgroup,
1397 pub exec: Exec,
1398}
1399
1400#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1407#[serde(deny_unknown_fields, default)]
1408pub struct Exec {
1409 pub enabled: bool,
1411 pub allow: Vec<String>,
1414 pub workdir: Option<String>,
1416 pub timeout: Option<Dur>,
1418 pub max_output: Option<u64>,
1420 pub env: Vec<String>,
1423}
1424
1425#[derive(Debug, Clone, Deserialize, PartialEq)]
1426#[serde(deny_unknown_fields)]
1427pub struct AAuth {
1428 pub provider: String,
1429 #[serde(default)]
1430 pub key_file: Option<String>,
1431 #[serde(default)]
1432 pub enroll_token: Option<Secret>,
1433 #[serde(default)]
1434 pub enroll_assertion_file: Option<String>,
1435 #[serde(default)]
1436 pub person_server: Option<String>,
1437}
1438
1439#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1440#[serde(deny_unknown_fields, default)]
1441pub struct Cgroup {
1442 pub spec: Option<String>,
1443 pub memory_max: Option<String>,
1444 pub pids_max: Option<String>,
1445}
1446
1447impl Settings {
1448 pub fn from_document(doc: Value, source: &str) -> Result<Settings, String> {
1450 serde_json::from_value(doc).map_err(|e| format!("{source} parse error: {e}"))
1451 }
1452
1453 pub fn instance_name(&self) -> String {
1456 if let Some(n) = &self.agent.name {
1457 return n.clone();
1458 }
1459 let id =
1460 crate::identity::Identity::from_env(self.lifecycle.run_id.as_deref().unwrap_or(""));
1461 if let Some(inst) = id.instance.filter(|i| !i.trim().is_empty()) {
1462 return inst;
1463 }
1464 std::env::var("HOSTNAME")
1465 .ok()
1466 .filter(|h| !h.trim().is_empty())
1467 .unwrap_or_else(|| "agentd".to_string())
1468 }
1469
1470 pub fn is_long_lived(&self) -> bool {
1481 self.a2a.listen.is_some()
1482 || self.webhooks.listen.is_some()
1483 || self.goal.is_some()
1484 || self.workflows.iter().any(workflow_is_long_lived)
1485 }
1486}
1487
1488pub const V2_KEYS: &[&str] = &[
1496 "agent",
1497 "store",
1498 "workflows",
1499 "tools",
1500 "a2a",
1501 "lifecycle",
1502 "observability",
1503 "security",
1504 "knowledge",
1505 "search",
1506 "skills",
1507 "memory",
1508 "context",
1509];
1510
1511pub const V1_KEYS: &[&str] = &[
1513 "intelligence_headers",
1514 "model_swap",
1515 "model",
1516 "max_tokens",
1517 "mcp_servers",
1518 "subscribe",
1519 "a2a_peers",
1520 "log_level",
1521];
1522
1523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1524pub enum Detected {
1525 Empty,
1527 V1,
1529 V2,
1531 Mixed,
1533}
1534
1535pub fn detect(doc: &Value) -> Detected {
1537 let Some(obj) = doc.as_object() else {
1538 return Detected::Empty;
1539 };
1540 if obj.is_empty() {
1541 return Detected::Empty;
1542 }
1543 let version = obj.get("config_version").and_then(Value::as_str);
1544 let intel_is_object = obj.get("intelligence").is_some_and(Value::is_object);
1545 let intel_is_string = obj.get("intelligence").is_some_and(Value::is_string);
1546 let has_v2 = version == Some(schema::CONFIG_VERSION)
1547 || intel_is_object
1548 || obj.keys().any(|k| V2_KEYS.contains(&k.as_str()));
1549 let has_v1 = intel_is_string
1550 || obj.keys().any(|k| V1_KEYS.contains(&k.as_str()))
1551 || matches!(version, Some(v) if v != schema::CONFIG_VERSION);
1552 match (has_v1, has_v2) {
1553 (true, true) => Detected::Mixed,
1554 (false, true) => Detected::V2,
1555 (true, false) => Detected::V1,
1556 (false, false) => Detected::V1,
1560 }
1561}
1562
1563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1569pub enum AliasKind {
1570 Set,
1572 SetTrue,
1574 Append,
1576 SetFromFile,
1578 Special,
1580}
1581
1582#[derive(Debug, Clone, Copy)]
1584pub struct Alias {
1585 pub flag: &'static str,
1586 pub path: &'static str,
1587 pub kind: AliasKind,
1588}
1589
1590pub const ALIASES: &[Alias] = &[
1593 Alias {
1594 flag: "--instruction",
1595 path: "agent.instruction",
1596 kind: AliasKind::Set,
1597 },
1598 Alias {
1599 flag: "--instruction-file",
1600 path: "agent.instruction",
1601 kind: AliasKind::SetFromFile,
1602 },
1603 Alias {
1604 flag: "--prompt",
1605 path: "agent.prompt",
1606 kind: AliasKind::Set,
1607 },
1608 Alias {
1609 flag: "--prompt-file",
1610 path: "agent.prompt",
1611 kind: AliasKind::SetFromFile,
1612 },
1613 Alias {
1614 flag: "--intelligence",
1615 path: "intelligence.endpoints",
1616 kind: AliasKind::Set,
1617 },
1618 Alias {
1619 flag: "--intelligence-token",
1620 path: "intelligence.token",
1621 kind: AliasKind::Set,
1622 },
1623 Alias {
1624 flag: "--intelligence-token-file",
1625 path: "intelligence.token_file",
1626 kind: AliasKind::Set,
1627 },
1628 Alias {
1629 flag: "--model",
1630 path: "intelligence.model",
1631 kind: AliasKind::Set,
1632 },
1633 Alias {
1634 flag: "--model-swap",
1635 path: "intelligence.swap_policy",
1636 kind: AliasKind::Set,
1637 },
1638 Alias {
1639 flag: "--budget-tokens-lifetime",
1640 path: "intelligence.budget.lifetime_tokens",
1641 kind: AliasKind::Set,
1642 },
1643 Alias {
1644 flag: "--mcp",
1645 path: "mcp.servers",
1646 kind: AliasKind::Append,
1647 },
1648 Alias {
1649 flag: "--mcp-tags",
1650 path: "mcp.servers",
1651 kind: AliasKind::Special,
1652 },
1653 Alias {
1654 flag: "--a2a-peer",
1655 path: "a2a.peers",
1656 kind: AliasKind::Append,
1657 },
1658 Alias {
1659 flag: "--workflow",
1660 path: "workflows",
1661 kind: AliasKind::Append,
1662 },
1663 Alias {
1664 flag: "--max-steps",
1665 path: "limits.run.steps",
1666 kind: AliasKind::Set,
1667 },
1668 Alias {
1669 flag: "--max-tokens",
1670 path: "limits.run.tokens",
1671 kind: AliasKind::Set,
1672 },
1673 Alias {
1674 flag: "--deadline",
1675 path: "limits.run.deadline",
1676 kind: AliasKind::Set,
1677 },
1678 Alias {
1679 flag: "--max-depth",
1680 path: "limits.subagents.depth",
1681 kind: AliasKind::Set,
1682 },
1683 Alias {
1684 flag: "--run-id",
1685 path: "lifecycle.run_id",
1686 kind: AliasKind::Set,
1687 },
1688 Alias {
1689 flag: "--drain-timeout",
1690 path: "lifecycle.drain_timeout",
1691 kind: AliasKind::Set,
1692 },
1693 Alias {
1694 flag: "--watch-config",
1695 path: "lifecycle.watch_config",
1696 kind: AliasKind::SetTrue,
1697 },
1698 Alias {
1699 flag: "--budget-exit-code",
1700 path: "lifecycle.exit_code_map",
1701 kind: AliasKind::Special,
1702 },
1703 Alias {
1704 flag: "--listen",
1705 path: "a2a.listen",
1706 kind: AliasKind::Set,
1707 },
1708 Alias {
1709 flag: "--serve-mcp",
1710 path: "a2a.listen",
1711 kind: AliasKind::Set,
1712 },
1713 Alias {
1714 flag: "--serve-cert",
1715 path: "a2a.tls.cert",
1716 kind: AliasKind::Set,
1717 },
1718 Alias {
1719 flag: "--serve-key",
1720 path: "a2a.tls.key",
1721 kind: AliasKind::Set,
1722 },
1723 Alias {
1724 flag: "--serve-client-ca",
1725 path: "a2a.tls.client_ca",
1726 kind: AliasKind::Set,
1727 },
1728 Alias {
1729 flag: "--serve-bearer",
1730 path: "a2a.bearer",
1731 kind: AliasKind::Set,
1732 },
1733 Alias {
1734 flag: "--log-level",
1735 path: "observability.log_level",
1736 kind: AliasKind::Set,
1737 },
1738 Alias {
1739 flag: "--log-content",
1740 path: "observability.log_content",
1741 kind: AliasKind::SetTrue,
1742 },
1743 Alias {
1744 flag: "--metrics-addr",
1745 path: "observability.metrics_addr",
1746 kind: AliasKind::Set,
1747 },
1748 Alias {
1749 flag: "--health-file",
1750 path: "observability.health_file",
1751 kind: AliasKind::Set,
1752 },
1753 Alias {
1754 flag: "--report-file",
1755 path: "observability.report_file",
1756 kind: AliasKind::Set,
1757 },
1758 Alias {
1759 flag: "--events-ring",
1760 path: "observability.events_ring",
1761 kind: AliasKind::Set,
1762 },
1763 Alias {
1764 flag: "--traceparent",
1765 path: "observability.traceparent",
1766 kind: AliasKind::Set,
1767 },
1768 Alias {
1769 flag: "--allow-trifecta",
1770 path: "security.allow_trifecta",
1771 kind: AliasKind::SetTrue,
1772 },
1773 Alias {
1774 flag: "--tls-ca",
1775 path: "security.tls_ca",
1776 kind: AliasKind::Set,
1777 },
1778 Alias {
1779 flag: "--aauth-provider",
1780 path: "security.aauth.provider",
1781 kind: AliasKind::Set,
1782 },
1783 Alias {
1784 flag: "--aauth-key-file",
1785 path: "security.aauth.key_file",
1786 kind: AliasKind::Set,
1787 },
1788 Alias {
1789 flag: "--aauth-enroll-token",
1790 path: "security.aauth.enroll_token",
1791 kind: AliasKind::Set,
1792 },
1793 Alias {
1794 flag: "--aauth-enroll-assertion-file",
1795 path: "security.aauth.enroll_assertion_file",
1796 kind: AliasKind::Set,
1797 },
1798 Alias {
1799 flag: "--aauth-person-server",
1800 path: "security.aauth.person_server",
1801 kind: AliasKind::Set,
1802 },
1803 Alias {
1804 flag: "--cgroup",
1805 path: "security.cgroup.spec",
1806 kind: AliasKind::Set,
1807 },
1808 Alias {
1809 flag: "--cgroup-memory-max",
1810 path: "security.cgroup.memory_max",
1811 kind: AliasKind::Set,
1812 },
1813 Alias {
1814 flag: "--cgroup-pids-max",
1815 path: "security.cgroup.pids_max",
1816 kind: AliasKind::Set,
1817 },
1818];
1819
1820pub const ENV_ALIASES: &[(&str, &str)] = &[
1824 ("INSTRUCTION", "agent.instruction"),
1825 ("PROMPT", "agent.prompt"),
1826 ("INTELLIGENCE", "intelligence.endpoints"),
1827 ("INTELLIGENCE_TOKEN", "intelligence.token"),
1828 ("INTELLIGENCE_TOKEN_FILE", "intelligence.token_file"),
1829 ("MODEL", "intelligence.model"),
1830 ("MODEL_SWAP", "intelligence.swap_policy"),
1831 ("BUDGET_TOKENS", "intelligence.budget.lifetime_tokens"),
1832 ("MAX_STEPS", "limits.run.steps"),
1833 ("MAX_TOKENS", "limits.run.tokens"),
1834 ("DEADLINE", "limits.run.deadline"),
1835 ("RUN_ID", "lifecycle.run_id"),
1836 ("DRAIN_TIMEOUT", "lifecycle.drain_timeout"),
1837 ("LOG_LEVEL", "observability.log_level"),
1838 ("LOG_CONTENT", "observability.log_content"),
1839 ("METRICS_ADDR", "observability.metrics_addr"),
1840 ("TRACEPARENT", "observability.traceparent"),
1841 ("SERVE_MCP", "a2a.listen"),
1842 ("SERVE_BEARER", "a2a.bearer"),
1843 ("TLS_CA", "security.tls_ca"),
1844 ("ALLOW_TRIFECTA", "security.allow_trifecta"),
1845 ("WATCH_CONFIG", "lifecycle.watch_config"),
1846];
1847
1848pub const REMOVED_FLAGS: &[(&str, &str)] = &[
1850 (
1851 "--mode",
1852 "modes are gone: give the workflow a start node (`once` | `loop` | `schedule` | `subscribe` | `signal` | `event` | `a2a` | `manual`) and set `lifecycle.run_until` if needed",
1853 ),
1854 (
1855 "--subscribe",
1856 "use a `subscribe` start node: `{kind: subscribe, server: <name>, uri: <uri>}`",
1857 ),
1858 (
1859 "--continue",
1860 "use a `subscribe` start node with `deliver: wait` (or a warm subagent)",
1861 ),
1862 (
1863 "--interval",
1864 "use a `loop` start node with `interval`, or a `schedule` start node with `every`",
1865 ),
1866 ("--cron", "use a `schedule` start node with `cron`"),
1867 (
1871 "--shard",
1872 "agentd does not partition work; give each replica its own subscription (docs/scaling.md)",
1873 ),
1874 (
1875 "--claim",
1876 "call the queue's own claim/lease tools from a workflow step (docs/scaling.md §2c)",
1877 ),
1878 ("--claim-ttl", "it went with --claim"),
1879 ("--claim-renew-fraction", "it went with --claim"),
1880 (
1881 "--standby",
1882 "there is no standby pool; a worker replica is an ordinary instance with its own subscription",
1883 ),
1884 ("--assign-from", "it went with --standby"),
1885 (
1886 "--workflow-resume",
1887 "automatic: runs resume from the store on restart (`resume_policy` per workflow)",
1888 ),
1889 (
1890 "--workflow-resume-force",
1891 "set `resume_policy: force` on the workflow",
1892 ),
1893];
1894
1895#[derive(Debug, Clone)]
1903pub struct Loaded {
1904 pub settings: Settings,
1905 pub doc: Value,
1907 pub file_doc: Value,
1909 pub files: Vec<(String, Format)>,
1910 pub warnings: Vec<String>,
1913}
1914
1915#[derive(Debug, Clone, PartialEq, Eq)]
1918pub enum Ask {
1919 Run,
1920 Help,
1921 Version,
1922 Schema,
1923 WorkflowSchema,
1924 Validate,
1925 Capabilities,
1926 Login(String),
1929 Logout(String),
1931}
1932
1933pub fn probe(args: &[String], env: &[(String, String)]) -> Result<Detected, ConfigError> {
1936 let env = super::debrand_env(env);
1937 let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
1938 let flag_v2 = args
1941 .windows(2)
1942 .any(|w| matches!(w[0].as_str(), "--config-version" | "--config_version") && w[1] == "2")
1943 || args
1944 .iter()
1945 .any(|a| a == "--config-version=2" || a == "--config_version=2")
1946 || envmap
1947 .get("AGENTD_CONFIG_VERSION")
1948 .or_else(|| envmap.get("CONFIG_VERSION"))
1949 .is_some_and(|v| *v == "2");
1950 let paths = super::config_paths_from_map(args, &envmap).paths;
1951 if paths.is_empty() {
1952 return Ok(if flag_v2 {
1953 Detected::V2
1954 } else {
1955 Detected::Empty
1956 });
1957 }
1958 let (doc, _) = file::read_documents_checked(&paths, &|_, _| Ok(())).map_err(usage)?;
1959 let d = detect(&doc);
1960 Ok(match (d, flag_v2) {
1961 (Detected::Empty, true) => Detected::V2,
1962 (Detected::V1, true) => Detected::Mixed,
1963 (d, _) => d,
1964 })
1965}
1966
1967pub fn load(args: &[String], env: &[(String, String)]) -> Result<(Loaded, Ask), ConfigError> {
1972 let env = super::debrand_env(env);
1973 let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
1974 let schema = schema::schema();
1975 let bindings = paths::bindings_of(&schema);
1976 let mut warnings = Vec::new();
1977
1978 let super::ConfigPaths {
1980 paths: config_paths,
1981 discovered,
1982 } = super::config_paths_from_map(args, &envmap);
1983 if discovered && config_paths.len() > 1 {
1989 return Err(usage(format!(
1990 "both {} and {} are present; keep one (or name the file with --config)",
1991 super::DISCOVERED_CONFIG_NAMES[0],
1992 super::DISCOVERED_CONFIG_NAMES[1]
1993 )));
1994 }
1995 let (file_doc, files) = if config_paths.is_empty() {
1996 (Value::Object(Map::new()), Vec::new())
1997 } else {
1998 file::read_documents_checked(&config_paths, &|doc, source| {
1999 match detect(doc) {
2002 Detected::V2 | Detected::Empty => {
2003 Settings::from_document(doc.clone(), source).map(|_| ())
2004 }
2005 _ => Ok(()),
2006 }
2007 })
2008 .map_err(usage)?
2009 };
2010 match detect(&file_doc) {
2011 Detected::Mixed => {
2012 return Err(usage(
2013 "config file mixes v1 keys (model/subscribe/mcp_servers/…) with v2 sections (agent/intelligence/…); \
2014 migrate the v1 keys (docs/configuration.md §migration)"
2015 .into(),
2016 ));
2017 }
2018 Detected::V1 => {
2019 return Err(usage(
2020 "config file speaks the v1 schema; the 2.0 loader needs `config_version: \"2\"` or v2 sections".into(),
2021 ));
2022 }
2023 _ => {}
2024 }
2025 if discovered {
2033 let file = config_paths.first().map_or("", String::as_str);
2034 if let Some((_, label)) = DISCOVERY_FORBIDDEN_RELAXATIONS
2035 .iter()
2036 .find(|(ptr, _)| file_doc.pointer(ptr).and_then(Value::as_bool) == Some(true))
2037 {
2038 return Err(usage(format!(
2039 "{file} was discovered, not named, and it sets {label}: a config found in the \
2040 working directory may not relax a security control. Pass `--config {file}` if \
2041 you meant to run under that file's grant."
2042 )));
2043 }
2044 let touched = discovered_security_settings(&file_doc);
2048 if !touched.is_empty() {
2049 warnings.push(format!(
2050 "adopted the discovered config {file} (no --config given); it sets {}",
2051 touched.join(", ")
2052 ));
2053 }
2054 }
2055 let mut doc = file_doc.clone();
2056
2057 let mut env_doc = Value::Object(Map::new());
2059 for (name, path) in ENV_ALIASES {
2060 let candidates = [
2061 format!("AGENTD_{name}"),
2062 format!("AGENT_{name}"),
2063 (*name).to_string(),
2064 ];
2065 if let Some(raw) = candidates.iter().find_map(|k| envmap.get(k.as_str())) {
2066 let binding = binding_for(&bindings, path)
2067 .ok_or_else(|| usage(format!("internal: alias path {path} not in schema")))?;
2068 let v = binding
2069 .coerce(raw)
2070 .map_err(|e| usage(format!("invalid {}: {e}", candidates[0])))?;
2071 paths::set_path(&mut env_doc, path, v);
2072 }
2073 }
2074 let (derived, _applied) = paths::env_document_in(&bindings, &envmap).map_err(usage)?;
2075 file::merge_into(&mut env_doc, derived);
2076 file::merge_into(&mut doc, env_doc);
2077
2078 let mut ask = Ask::Run;
2080 let mut mcp_tags: Vec<(String, Vec<String>)> = Vec::new();
2081 let mut it = args.iter().peekable();
2082 while let Some(arg) = it.next() {
2083 let a = arg.as_str();
2084 match a {
2085 "-h" | "--help" => ask = Ask::Help,
2086 "-V" | "--version" => ask = Ask::Version,
2087 "--config-schema" | "--config-schema=2" => ask = Ask::Schema,
2088 "--workflow-schema" => ask = Ask::WorkflowSchema,
2089 "--validate-config" => ask = Ask::Validate,
2090 "--capabilities" => ask = Ask::Capabilities,
2091 "--login" => {
2092 let t = it
2093 .next()
2094 .cloned()
2095 .ok_or_else(|| usage("--login requires a target (e.g. mcp:<name>)".into()))?;
2096 ask = Ask::Login(t);
2097 }
2098 "--logout" => {
2099 let t = it
2100 .next()
2101 .cloned()
2102 .ok_or_else(|| usage("--logout requires a target (e.g. mcp:<name>)".into()))?;
2103 ask = Ask::Logout(t);
2104 }
2105 "--config" | "-c" => {
2106 it.next(); }
2108 _ if matches!(
2110 crate::config::config_flag(a),
2111 crate::config::ConfigFlag::Inline(_)
2112 ) => {}
2113 _ => {
2114 if let Some((flag, hint)) = REMOVED_FLAGS.iter().find(|(f, _)| *f == a) {
2115 return Err(usage(format!("{flag} was removed in agentd 2.0: {hint}")));
2116 }
2117 if let Some(alias) = ALIASES.iter().find(|al| al.flag == a) {
2118 apply_alias(&mut doc, &bindings, alias, &mut it, &mut mcp_tags)?;
2119 continue;
2120 }
2121 match paths::resolve_flag_in(&bindings, a).map_err(usage)? {
2122 Some(target) => {
2123 let raw = if matches!(target.value_kind(), paths::Kind::Boolean)
2124 && !it.peek().is_some_and(|n| !n.starts_with("--"))
2125 {
2126 "true".to_string()
2127 } else {
2128 it.next()
2129 .cloned()
2130 .ok_or_else(|| usage(format!("{a} requires a value")))?
2131 };
2132 let value = paths::coerce(target.value_kind(), &raw)
2133 .map_err(|e| usage(format!("invalid {a}: {e}")))?;
2134 file::merge_into(&mut doc, target.document(value));
2135 }
2136 None => return Err(usage(format!("unknown argument: {a}"))),
2137 }
2138 }
2139 }
2140 }
2141 for (name, tags) in mcp_tags {
2143 let Some(servers) = doc
2144 .pointer_mut("/mcp/servers")
2145 .and_then(Value::as_array_mut)
2146 else {
2147 return Err(usage(format!(
2148 "--mcp-tags references unknown server '{name}'"
2149 )));
2150 };
2151 match servers
2152 .iter_mut()
2153 .find(|s| s.get("name").and_then(Value::as_str) == Some(name.as_str()))
2154 {
2155 Some(s) => {
2156 s["tags"] = json!({ "*": tags });
2157 }
2158 None => {
2159 return Err(usage(format!(
2160 "--mcp-tags references unknown server '{name}'"
2161 )));
2162 }
2163 }
2164 }
2165
2166 if ask == Ask::Run || ask == Ask::Validate {
2168 apply_instruction_sugar(&mut doc);
2169 }
2170
2171 if let Err(e) = substitute_env(&mut doc, &envmap) {
2175 return Err(usage(e));
2176 }
2177
2178 let mut settings = Settings::from_document(doc.clone(), "config").map_err(usage)?;
2180 if doc.pointer("/store/kind").is_none() && settings.is_long_lived() {
2200 settings.store.kind = StoreKind::File;
2201 }
2202 let mut loaded = Loaded {
2203 settings,
2204 doc,
2205 file_doc,
2206 files,
2207 warnings: Vec::new(),
2208 };
2209 let diags = validate(&loaded);
2210 warnings.extend(diags.warnings);
2211 loaded.warnings = warnings;
2212 if ask != Ask::Validate
2213 && ask != Ask::Help
2214 && ask != Ask::Version
2215 && ask != Ask::Schema
2216 && ask != Ask::WorkflowSchema
2217 && !matches!(ask, Ask::Login(_) | Ask::Logout(_))
2218 && let Some(first) = diags.errors.first()
2219 {
2220 return Err(usage(first.clone()));
2221 }
2222 if ask == Ask::Validate && !diags.errors.is_empty() {
2223 return Err(ConfigError::Validate(Err(diags
2224 .errors
2225 .iter()
2226 .map(|d| super::config_invalid_line(d))
2227 .collect::<Vec<_>>()
2228 .join("\n"))));
2229 }
2230 Ok((loaded, ask))
2231}
2232
2233const DISCOVERY_FORBIDDEN_RELAXATIONS: [(&str, &str); 2] = [
2240 ("/security/allow_trifecta", "security.allow_trifecta"),
2241 ("/security/exec/enabled", "security.exec.enabled"),
2242];
2243
2244const DISCOVERY_SECURITY_SETTINGS: [(&str, &str); 12] = [
2251 ("/intelligence/endpoints", "intelligence.endpoints"),
2252 ("/intelligence/token", "intelligence.token"),
2253 ("/intelligence/token_file", "intelligence.token_file"),
2254 ("/intelligence/headers", "intelligence.headers"),
2255 ("/intelligence/auth", "intelligence.auth"),
2256 ("/mcp/servers", "mcp.servers"),
2257 ("/tools/overrides", "tools.overrides"),
2258 ("/store", "store"),
2259 ("/a2a/listen", "a2a.listen"),
2260 ("/a2a/peers", "a2a.peers"),
2261 ("/webhooks/listen", "webhooks.listen"),
2262 ("/security", "security"),
2263];
2264
2265fn discovered_security_settings(file_doc: &Value) -> Vec<&'static str> {
2268 DISCOVERY_SECURITY_SETTINGS
2269 .iter()
2270 .filter(|(ptr, _)| file_doc.pointer(ptr).is_some_and(|v| !v.is_null()))
2271 .map(|(_, label)| *label)
2272 .collect()
2273}
2274
2275fn binding_for<'a>(bindings: &'a [Binding], path: &str) -> Option<&'a Binding> {
2276 bindings.iter().find(|b| b.path == path)
2277}
2278
2279fn apply_alias(
2280 doc: &mut Value,
2281 bindings: &[Binding],
2282 alias: &Alias,
2283 it: &mut std::iter::Peekable<std::slice::Iter<'_, String>>,
2284 mcp_tags: &mut Vec<(String, Vec<String>)>,
2285) -> Result<(), ConfigError> {
2286 let mut take = || -> Result<String, ConfigError> {
2287 it.next()
2288 .cloned()
2289 .ok_or_else(|| usage(format!("{} requires a value", alias.flag)))
2290 };
2291 match alias.kind {
2292 AliasKind::Set => {
2293 let raw = take()?;
2294 let b = binding_for(bindings, alias.path).ok_or_else(|| {
2295 usage(format!("internal: alias path {} not in schema", alias.path))
2296 })?;
2297 let v = b
2298 .coerce(&raw)
2299 .map_err(|e| usage(format!("invalid {}: {e}", alias.flag)))?;
2300 let mut patch = Value::Object(Map::new());
2301 paths::set_path(&mut patch, alias.path, v);
2302 file::merge_into(doc, patch);
2303 }
2304 AliasKind::SetTrue => {
2305 let mut patch = Value::Object(Map::new());
2306 paths::set_path(&mut patch, alias.path, Value::Bool(true));
2307 file::merge_into(doc, patch);
2308 }
2309 AliasKind::SetFromFile => {
2310 let path = take()?;
2311 let text = super::read_file(&path)?;
2312 let mut patch = Value::Object(Map::new());
2313 paths::set_path(&mut patch, alias.path, Value::String(text));
2314 file::merge_into(doc, patch);
2315 }
2316 AliasKind::Append => {
2317 let raw = take()?;
2318 let element = match alias.flag {
2319 "--mcp" => {
2320 let (name, endpoint) = raw
2321 .split_once('=')
2322 .ok_or_else(|| usage(format!("--mcp: want name=endpoint (got: {raw})")))?;
2323 json!({ "name": name.trim(), "endpoint": endpoint.trim() })
2324 }
2325 "--a2a-peer" => {
2326 let (name, endpoint) = raw.split_once('=').ok_or_else(|| {
2327 usage(format!("--a2a-peer: want name=endpoint (got: {raw})"))
2328 })?;
2329 json!({ "name": name.trim(), "endpoint": endpoint.trim() })
2330 }
2331 "--workflow" => {
2332 let name = std::path::Path::new(&raw)
2333 .file_stem()
2334 .and_then(|s| s.to_str())
2335 .unwrap_or("workflow")
2336 .to_string();
2337 json!({ "name": name, "file": raw })
2338 }
2339 other => return Err(usage(format!("internal: no append rule for {other}"))),
2340 };
2341 append_at(doc, alias.path, element);
2342 }
2343 AliasKind::Special => match alias.flag {
2344 "--mcp-tags" => {
2345 let raw = take()?;
2346 let (name, tags) = raw
2347 .split_once('=')
2348 .ok_or_else(|| usage(format!("--mcp-tags: want name=tag,tag (got: {raw})")))?;
2349 mcp_tags.push((
2350 name.trim().to_string(),
2351 tags.split(',')
2352 .map(str::trim)
2353 .filter(|t| !t.is_empty())
2354 .map(str::to_string)
2355 .collect(),
2356 ));
2357 }
2358 "--budget-exit-code" => {
2359 let raw = take()?;
2360 let n: i64 = raw
2361 .trim()
2362 .parse()
2363 .ok()
2364 .filter(|n| (0..=255).contains(n))
2365 .ok_or_else(|| {
2366 usage(format!("invalid --budget-exit-code: {raw} (want 0..=255)"))
2367 })?;
2368 let mut patch = Value::Object(Map::new());
2369 paths::set_path(
2370 &mut patch,
2371 "lifecycle.exit_code_map",
2372 json!({ "3": n, "7": n }),
2373 );
2374 file::merge_into(doc, patch);
2375 }
2376 other => return Err(usage(format!("internal: no special rule for {other}"))),
2377 },
2378 }
2379 Ok(())
2380}
2381
2382fn append_at(doc: &mut Value, path: &str, element: Value) {
2384 let pointer = format!("/{}", path.replace('.', "/"));
2385 if doc.pointer(&pointer).is_none() {
2386 let mut patch = Value::Object(Map::new());
2387 paths::set_path(&mut patch, path, Value::Array(Vec::new()));
2388 file::merge_into(doc, patch);
2389 }
2390 if let Some(arr) = doc.pointer_mut(&pointer) {
2391 if !arr.is_array() {
2392 *arr = Value::Array(Vec::new());
2393 }
2394 arr.as_array_mut().expect("array").push(element);
2395 }
2396}
2397
2398fn apply_instruction_sugar(doc: &mut Value) {
2408 let has_workflows = doc
2409 .pointer("/workflows")
2410 .and_then(Value::as_array)
2411 .is_some_and(|w| !w.is_empty());
2412 let nonblank = |p: &str| {
2413 doc.pointer(p)
2414 .and_then(Value::as_str)
2415 .is_some_and(|s| !s.trim().is_empty())
2416 };
2417 let has_instruction = nonblank("/agent/instruction");
2418 if has_workflows || !has_instruction || nonblank("/agent/prompt") {
2421 return;
2422 }
2423 let work = json!({
2424 "kind": "agent",
2425 "depends_on": ["start"],
2426 "instruction": "{{env.instruction}}",
2427 });
2428 let mut patch = Value::Object(Map::new());
2429 paths::set_path(
2430 &mut patch,
2431 "workflows",
2432 json!([{
2433 "name": "main",
2434 "version": 3,
2435 "steps": {
2436 "start": { "kind": "once" },
2437 "work": work,
2438 "done": { "kind": "finish", "depends_on": ["work"], "status": "completed", "output": "{{steps.work.output}}" }
2439 }
2440 }]),
2441 );
2442 file::merge_into(doc, patch);
2443}
2444
2445fn substitute_env(v: &mut Value, env: &HashMap<&str, &str>) -> Result<(), String> {
2455 match v {
2456 Value::String(s) => {
2457 if s.as_bytes().contains(&b'$') {
2458 *s = expand_env_str(s, env)?;
2459 }
2460 Ok(())
2461 }
2462 Value::Array(a) => a.iter_mut().try_for_each(|item| substitute_env(item, env)),
2463 Value::Object(m) => m.values_mut().try_for_each(|val| substitute_env(val, env)),
2464 _ => Ok(()),
2465 }
2466}
2467
2468fn expand_env_str(s: &str, env: &HashMap<&str, &str>) -> Result<String, String> {
2470 let mut out = String::with_capacity(s.len());
2471 let b = s.as_bytes();
2472 let mut i = 0;
2473 while i < b.len() {
2474 if b[i] == b'$' {
2478 if b.get(i + 1) == Some(&b'$') {
2479 out.push('$'); i += 2;
2481 continue;
2482 }
2483 if b.get(i + 1) == Some(&b'{') {
2484 let start = i + 2;
2485 let Some(rel) = s[start..].find('}') else {
2486 return Err(format!("unterminated `${{` in config value {s:?}"));
2487 };
2488 let end = start + rel;
2489 let expr = &s[start..end];
2490 let (name, default) = match expr.split_once(":-") {
2491 Some((n, d)) => (n.trim(), Some(d)),
2492 None => (expr.trim(), None),
2493 };
2494 if name.is_empty() {
2495 return Err(format!("empty `${{}}` reference in config value {s:?}"));
2496 }
2497 if !name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') {
2498 return Err(format!(
2499 "invalid environment variable name {name:?} in `${{{expr}}}`"
2500 ));
2501 }
2502 match env.get(name) {
2503 Some(val) => out.push_str(val),
2504 None => match default {
2505 Some(d) => out.push_str(d),
2506 None => {
2507 return Err(format!(
2508 "environment variable ${{{name}}} is not set (referenced in config); \
2509 set it or write ${{{name}:-default}}"
2510 ));
2511 }
2512 },
2513 }
2514 i = end + 1;
2515 continue;
2516 }
2517 }
2518 let ch = s[i..].chars().next().unwrap();
2519 out.push(ch);
2520 i += ch.len_utf8();
2521 }
2522 Ok(out)
2523}
2524
2525#[derive(Debug, Default, Clone)]
2532pub struct Diagnostics {
2533 pub errors: Vec<String>,
2534 pub warnings: Vec<String>,
2535}
2536
2537fn validate_auth_block(auth: &Auth, ctx: &str) -> Vec<String> {
2543 let mut out = Vec::new();
2544 for (field, s) in [
2546 ("client_secret", &auth.client_secret),
2547 ("token", &auth.token),
2548 ("value", &auth.value),
2549 ] {
2550 if let Some(sec) = s
2551 && !sec.0.trim().is_empty()
2552 && !crate::sec::secret::has_secret_ref(&sec.0)
2553 {
2554 out.push(format!(
2555 "{ctx}: auth.{field} carries an inline credential; use a {{{{secret:…}}}} reference"
2556 ));
2557 }
2558 }
2559 match auth.kind {
2560 AuthKind::Static => {
2561 let has_bearer = auth.token.is_some();
2562 let has_header = auth.header.is_some() && auth.value.is_some();
2563 if !has_bearer && !has_header {
2564 out.push(format!(
2565 "{ctx}: auth.kind static needs `token` (a bearer) or `header` + `value`"
2566 ));
2567 }
2568 }
2569 AuthKind::Aws => {
2570 if auth.region.is_none() {
2571 out.push(format!("{ctx}: auth.kind aws needs `region`"));
2572 }
2573 if auth.service.is_none() {
2574 out.push(format!(
2575 "{ctx}: auth.kind aws needs `service` (e.g. bedrock, execute-api)"
2576 ));
2577 }
2578 match auth.source.as_deref() {
2579 Some("sso") => {
2580 if auth.sso_start_url.is_none()
2581 || auth.account_id.is_none()
2582 || auth.role_name.is_none()
2583 {
2584 out.push(format!(
2585 "{ctx}: aws source sso needs `sso_start_url` + `account_id` + `role_name`"
2586 ));
2587 }
2588 }
2589 Some(src) if !matches!(src, "env" | "static" | "imds" | "irsa") => {
2590 out.push(format!(
2591 "{ctx}: auth.source '{src}' is not a known AWS source (env|static|imds|irsa|sso)"
2592 ));
2593 }
2594 _ => {}
2595 }
2596 }
2597 AuthKind::Spiffe => match auth.svid.as_deref().unwrap_or("jwt") {
2598 "jwt" => {
2599 if auth.jwt_svid_file.is_none() {
2600 out.push(format!(
2601 "{ctx}: auth.kind spiffe (svid jwt) needs `jwt_svid_file`"
2602 ));
2603 }
2604 }
2605 "x509" => {
2606 if auth.svid_file.is_none() || auth.key_file.is_none() {
2607 out.push(format!(
2608 "{ctx}: auth.kind spiffe (svid x509) needs `svid_file` + `key_file`"
2609 ));
2610 }
2611 }
2612 other => out.push(format!("{ctx}: auth.svid '{other}' (want jwt|x509)")),
2613 },
2614 AuthKind::Oauth2 => {
2615 if auth.client_id.is_none() {
2616 out.push(format!("{ctx}: auth.kind oauth2 needs `client_id`"));
2617 }
2618 if auth.token_url.is_none() && auth.issuer.is_none() {
2619 out.push(format!(
2620 "{ctx}: auth oauth2 needs `token_url` or `issuer` (for discovery)"
2621 ));
2622 }
2623 match auth.grant.unwrap_or(OAuthGrant::Device) {
2624 OAuthGrant::Device => {
2625 if auth.device_authorization_url.is_none() && auth.issuer.is_none() {
2626 out.push(format!(
2627 "{ctx}: the device grant needs `device_authorization_url` or `issuer`"
2628 ));
2629 }
2630 }
2631 OAuthGrant::ClientCredentials => {
2632 if auth.client_secret.is_none() {
2633 out.push(format!(
2634 "{ctx}: the client_credentials grant needs `client_secret`"
2635 ));
2636 }
2637 }
2638 OAuthGrant::AuthorizationCode => {
2639 if auth.authorization_url.is_none() && auth.issuer.is_none() {
2640 out.push(format!(
2641 "{ctx}: the authorization_code grant needs `authorization_url` or `issuer`"
2642 ));
2643 }
2644 }
2645 }
2646 }
2647 }
2648 out
2649}
2650
2651fn unresolved_secret_ref(value: &str) -> Option<String> {
2662 if !crate::sec::secret::has_secret_ref(value) {
2663 return None;
2664 }
2665 crate::sec::secret::refs_resolvable(value, &|k| std::env::var(k).ok()).err()
2666}
2667
2668pub fn validate(loaded: &Loaded) -> Diagnostics {
2669 let s = &loaded.settings;
2670 let mut d = Diagnostics::default();
2671 let err = |d: &mut Diagnostics, m: String| d.errors.push(m);
2672
2673 if let Some(v) = &s.config_version
2675 && v != schema::CONFIG_VERSION
2676 {
2677 err(
2678 &mut d,
2679 format!(
2680 "config_version must be \"{}\" (got {v:?})",
2681 schema::CONFIG_VERSION
2682 ),
2683 );
2684 }
2685
2686 for e in &s.intelligence.endpoints {
2688 if let Err(e) = super::validate_intelligence_uri(e) {
2689 err(&mut d, e.to_string());
2690 }
2691 }
2692 if let Some(p) = &s.intelligence.swap_policy
2693 && super::SwapPolicy::parse(p).is_none()
2694 {
2695 err(
2696 &mut d,
2697 format!("intelligence.swap_policy: {p:?} (want finish-on-old|restart-turn)"),
2698 );
2699 }
2700 if s.intelligence.token.is_some() && s.intelligence.token_file.is_some() {
2701 d.warnings.push(
2702 "intelligence.token and intelligence.token_file are both set; the inline token wins"
2703 .into(),
2704 );
2705 }
2706 if let Some(auth) = &s.intelligence.auth {
2707 for e in validate_auth_block(auth, "intelligence") {
2708 err(&mut d, e);
2709 }
2710 }
2711 if let Some(dialect) = &s.intelligence.dialect {
2712 if crate::intel::client::Provider::from_dialect(Some(dialect)).is_none() {
2713 err(
2714 &mut d,
2715 format!("intelligence.dialect: {dialect:?} (want openai|anthropic|bedrock)"),
2716 );
2717 }
2718 if dialect == "bedrock"
2721 && !matches!(
2722 s.intelligence.auth.as_ref().map(|a| a.kind),
2723 Some(AuthKind::Aws)
2724 )
2725 {
2726 err(
2727 &mut d,
2728 "intelligence.dialect: bedrock requires intelligence.auth.kind = aws (SigV4)"
2729 .into(),
2730 );
2731 }
2732 }
2733 validate_budget(&s.intelligence.budget, "intelligence.budget", &mut d);
2734 if let Some(b) = &s.agent.conversation_budget {
2735 validate_budget(b, "agent.conversation_budget", &mut d);
2736 }
2737 for (name, value) in &s.intelligence.headers {
2738 if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
2739 err(
2740 &mut d,
2741 format!(
2742 "intelligence.headers['{name}'] looks like a credential but has an inline value; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}}"
2743 ),
2744 );
2745 } else if let Some(e) = unresolved_secret_ref(value) {
2746 err(&mut d, format!("intelligence.headers['{name}']: {e}"));
2747 }
2748 }
2749
2750 let mut names = std::collections::HashSet::new();
2752 for srv in &s.mcp.servers {
2753 if srv.name.trim().is_empty() {
2754 err(&mut d, "mcp.servers[]: a server has an empty name".into());
2755 }
2756 if !names.insert(srv.name.as_str()) {
2757 err(
2758 &mut d,
2759 format!("mcp.servers[]: duplicate server name '{}'", srv.name),
2760 );
2761 }
2762 if srv.name == "code" {
2763 err(
2764 &mut d,
2765 "mcp.servers[]: the server name 'code' is reserved for code-registered tools"
2766 .into(),
2767 );
2768 }
2769 if let Err(e) = super::mcp_endpoint_scheme_ok(&srv.endpoint) {
2770 err(&mut d, format!("mcp server '{}': {e}", srv.name));
2771 }
2772 if let Err(e) = srv.tag_set() {
2773 err(&mut d, e);
2774 }
2775 for (h, v) in &srv.headers {
2776 if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
2777 err(
2778 &mut d,
2779 format!(
2780 "mcp server '{}' header '{h}' looks like a credential but has an inline value; use a {{{{secret:…}}}} reference",
2781 srv.name
2782 ),
2783 );
2784 } else if let Some(e) = unresolved_secret_ref(v) {
2785 err(
2786 &mut d,
2787 format!("mcp server '{}' header '{h}': {e}", srv.name),
2788 );
2789 }
2790 }
2791 if let Some(auth) = &srv.auth {
2792 for e in validate_auth_block(auth, &format!("mcp server '{}'", srv.name)) {
2793 err(&mut d, e);
2794 }
2795 }
2796 }
2797 let server_known = |n: &str| s.mcp.servers.iter().any(|x| x.name == n);
2798
2799 for (name, ov) in &s.tools.overrides {
2801 if !server_known(&ov.server) {
2802 err(
2803 &mut d,
2804 format!(
2805 "tools.overrides['{name}'] references undeclared MCP server '{}'",
2806 ov.server
2807 ),
2808 );
2809 }
2810 if s.tools.disabled.iter().any(|x| x == name) {
2811 err(
2812 &mut d,
2813 format!("tool '{name}' is both disabled and overridden"),
2814 );
2815 }
2816 for (label, tpl) in [("args", &ov.args), ("result", &ov.result)] {
2817 if let Some(t) = tpl
2818 && let Some(expr) = t.strip_prefix("CEL:")
2819 && let Err(e) = crate::cel::compile_check(expr.trim())
2820 {
2821 err(&mut d, format!("tools.overrides['{name}'].{label}: {e}"));
2822 }
2823 }
2824 }
2825
2826 match s.store.kind {
2828 StoreKind::Mcp => match &s.store.mcp {
2829 None => err(&mut d, "store.kind is mcp but store.mcp is not set".into()),
2830 Some(m) => {
2831 if !server_known(&m.server) {
2832 err(
2833 &mut d,
2834 format!(
2835 "store.mcp.server '{}' is not a declared MCP server",
2836 m.server
2837 ),
2838 );
2839 }
2840 for (label, op) in [
2841 ("put", &m.put),
2842 ("get", &m.get),
2843 ("list", &m.list),
2844 ("delete", &m.delete),
2845 ] {
2846 if let Some(op) = op {
2847 for (f, t) in [
2848 ("args", &op.args),
2849 ("ok", &op.ok),
2850 ("conflict", &op.conflict),
2851 ("value", &op.value),
2852 ("keys", &op.keys),
2853 ] {
2854 if let Some(t) = t
2855 && let Some(expr) = t.strip_prefix("CEL:")
2856 && let Err(e) = crate::cel::compile_check(expr.trim())
2857 {
2858 err(&mut d, format!("store.mcp.{label}.{f}: {e}"));
2859 }
2860 }
2861 }
2862 }
2863 }
2864 },
2865 StoreKind::Http => match &s.store.http {
2866 None => err(
2867 &mut d,
2868 "store.kind is http but store.http is not set".into(),
2869 ),
2870 Some(h) => {
2871 if !(h.base_url.starts_with("https://") || h.base_url.starts_with("http://")) {
2872 err(
2873 &mut d,
2874 format!(
2875 "store.http.base_url must be an http(s) URL (got {})",
2876 h.base_url
2877 ),
2878 );
2879 }
2880 if h.get.is_none() || h.put.is_none() {
2881 err(
2882 &mut d,
2883 "store.http needs at least `get` and `put` operations".into(),
2884 );
2885 }
2886 for (name, v) in &h.headers {
2887 if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(v) {
2888 err(
2889 &mut d,
2890 format!(
2891 "store.http.headers['{name}'] looks like a credential but has an inline value"
2892 ),
2893 );
2894 } else if let Some(e) = unresolved_secret_ref(v) {
2895 err(&mut d, format!("store.http.headers['{name}']: {e}"));
2896 }
2897 }
2898 }
2899 },
2900 StoreKind::File => {
2901 if let Some(f) = &s.store.file
2907 && f.path.as_deref().is_some_and(|p| p.trim().is_empty())
2908 {
2909 err(
2910 &mut d,
2911 "store.file.path is empty — set a directory, or omit the field to use $AGENTD_STATE_DIR / $XDG_STATE_HOME/agentd/state".into(),
2912 );
2913 }
2914 }
2915 StoreKind::Memory => {
2916 d.warnings.push(
2917 "store.kind is memory: state does not survive the process (dev/test only)".into(),
2918 );
2919 }
2920 StoreKind::None => {
2921 if s.is_long_lived() {
2930 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());
2931 } else if !s.workflows.is_empty() {
2932 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());
2933 }
2934 }
2935 }
2936 if s.store.file.is_some() && s.store.kind != StoreKind::File {
2942 d.warnings.push(format!(
2943 "store.file is set but store.kind is {} — the file adapter is not in use and the block is ignored",
2944 format!("{:?}", s.store.kind).to_lowercase()
2948 ));
2949 }
2950 if let Some(ms) = s.store.checkpoint.debounce_ms
2951 && ms > 60_000
2952 {
2953 d.warnings.push(format!(
2954 "store.checkpoint.debounce_ms is {ms} (> 60s): progress may lag far behind reality"
2955 ));
2956 }
2957
2958 if let Some(k) = &s.knowledge.server
2960 && !server_known(k)
2961 {
2962 err(
2963 &mut d,
2964 format!("knowledge.server '{k}' is not a declared MCP server"),
2965 );
2966 }
2967 if let Some(k) = &s.search.server
2968 && !server_known(k)
2969 {
2970 err(
2971 &mut d,
2972 format!("search.server '{k}' is not a declared MCP server"),
2973 );
2974 }
2975 for src in &s.skills.sources {
2976 if !server_known(&src.server) {
2977 err(
2978 &mut d,
2979 format!(
2980 "skills.sources[] references undeclared MCP server '{}'",
2981 src.server
2982 ),
2983 );
2984 }
2985 }
2986 if let Some(c) = s.context.compact_at
2987 && !(c > 0.0 && c <= 1.0)
2988 {
2989 err(
2990 &mut d,
2991 format!("context.compact_at must be in (0, 1] (got {c})"),
2992 );
2993 }
2994
2995 let mut wf_names = std::collections::HashSet::new();
2997 for (i, w) in s.workflows.iter().enumerate() {
2998 let Some(obj) = w.as_object() else {
2999 err(&mut d, format!("workflows[{i}] must be an object"));
3000 continue;
3001 };
3002 let name = obj.get("name").and_then(Value::as_str).unwrap_or("");
3003 if name.trim().is_empty() {
3004 err(&mut d, format!("workflows[{i}] has no name"));
3005 } else if !wf_names.insert(name.to_string()) {
3006 err(
3007 &mut d,
3008 format!("workflows[]: duplicate workflow name '{name}'"),
3009 );
3010 }
3011 let has_file = obj.contains_key("file");
3012 let has_uri = obj.contains_key("uri");
3013 let has_steps = obj.contains_key("steps");
3014 if (has_file as u8 + has_uri as u8 + has_steps as u8) != 1 {
3015 err(
3016 &mut d,
3017 format!("workflows['{name}'] must have exactly one of file | uri | steps"),
3018 );
3019 }
3020 if let Some(f) = obj.get("file").and_then(Value::as_str)
3021 && !std::path::Path::new(f).exists()
3022 {
3023 err(
3024 &mut d,
3025 format!("workflows['{name}'].file {f:?} does not exist"),
3026 );
3027 }
3028 }
3029
3030 for (k, v) in &s.lifecycle.exit_code_map {
3032 if k != "3" && k != "7" {
3033 err(
3034 &mut d,
3035 format!(
3036 "lifecycle.exit_code_map: only the policy codes 3 and 7 are remappable (got key {k:?})"
3037 ),
3038 );
3039 }
3040 if !(0..=255).contains(v) {
3041 err(
3042 &mut d,
3043 format!("lifecycle.exit_code_map[{k}] must be 0..=255 (got {v})"),
3044 );
3045 }
3046 }
3047 if s.lifecycle.watch_config && loaded.files.is_empty() {
3048 err(
3049 &mut d,
3050 "lifecycle.watch_config requires a config file (--config / AGENTD_CONFIG)".into(),
3051 );
3052 }
3053
3054 if let Some(l) = &s.a2a.listen {
3056 match super::ServeTarget::parse(l) {
3057 Ok(super::ServeTarget::Http { bind, tls }) => {
3058 let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
3059 if tls && (s.a2a.tls.cert.is_none() || s.a2a.tls.key.is_none()) {
3060 err(
3061 &mut d,
3062 "a2a.listen is https:// but a2a.tls.cert / a2a.tls.key are not set".into(),
3063 );
3064 }
3065 if !loopback
3066 && s.a2a.tls.client_ca.is_none()
3067 && s.a2a.bearer.is_none()
3068 && !s.interface.pairing.enabled
3069 {
3070 err(&mut d, "a2a.listen on a non-loopback address needs client auth: a2a.tls.client_ca, a2a.bearer, and/or interface.pairing".into());
3071 }
3072 if !tls && !loopback {
3073 err(
3074 &mut d,
3075 "a2a.listen plaintext http:// is allowed for loopback only; use https://"
3076 .into(),
3077 );
3078 }
3079 }
3080 Err(e) => err(&mut d, format!("a2a.listen: {e}")),
3081 }
3082 }
3083
3084 if s.interface.enabled && s.a2a.listen.is_none() {
3086 err(
3087 &mut d,
3088 "interface.enabled requires a2a.listen (the interface is served on the A2A listener)"
3089 .into(),
3090 );
3091 }
3092 if s.interface.debug && !s.interface.enabled {
3093 d.warnings
3094 .push("interface.debug has no effect while interface.enabled is false".into());
3095 }
3096 for o in &s.interface.origins {
3097 let ok = o
3099 .split_once("://")
3100 .map(|(scheme, rest)| {
3101 matches!(scheme, "http" | "https") && !rest.is_empty() && !rest.contains('/')
3102 })
3103 .unwrap_or(false);
3104 if !ok {
3105 err(
3106 &mut d,
3107 format!(
3108 "interface.origins: {o:?} is not an origin (want scheme://host[:port], no path)"
3109 ),
3110 );
3111 }
3112 }
3113 for (edge, items) in [
3116 ("top", &s.interface.display.top),
3117 ("bottom", &s.interface.display.bottom),
3118 ] {
3119 for item in items.iter().flatten() {
3120 if !DISPLAY_ITEMS.contains(&item.as_str()) {
3121 d.warnings.push(format!(
3122 "interface.display.{edge}: unknown item {item:?} (clients skip it); known: {}",
3123 DISPLAY_ITEMS.join(", ")
3124 ));
3125 }
3126 }
3127 }
3128 if s.interface.pairing.enabled {
3130 if !s.interface.enabled {
3131 err(
3132 &mut d,
3133 "interface.pairing.enabled requires interface.enabled (pairing rides the interface surface)".into(),
3134 );
3135 }
3136 if let Some(role) = s.interface.pairing.role
3137 && !matches!(role, Role::Operator | Role::User)
3138 {
3139 err(
3140 &mut d,
3141 "interface.pairing.role must be operator or user".into(),
3142 );
3143 }
3144 }
3145
3146 let uses_webhook = s.workflows.iter().any(workflow_uses_webhook);
3148 if uses_webhook && s.webhooks.listen.is_none() {
3149 err(&mut d, "a `webhook` node (start or wait) is used but webhooks.listen is not set — configure webhooks.listen (https://host:port)".into());
3150 }
3151 if let Some(l) = &s.webhooks.listen {
3152 match super::ServeTarget::parse(l) {
3153 Ok(super::ServeTarget::Http { bind, tls }) => {
3154 let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
3155 if tls && (s.webhooks.tls.cert.is_none() || s.webhooks.tls.key.is_none()) {
3156 err(
3157 &mut d,
3158 "webhooks.listen is https:// but webhooks.tls.cert / webhooks.tls.key are not set"
3159 .into(),
3160 );
3161 }
3162 if !tls && !loopback {
3163 err(
3164 &mut d,
3165 "webhooks.listen plaintext http:// is allowed for loopback only; use https://"
3166 .into(),
3167 );
3168 }
3169 if !loopback && !webhook_default_verifies(s.webhooks.default_auth.as_ref()) {
3181 let mut open: Vec<String> = Vec::new();
3182 let mut nodes = 0usize;
3183 for w in &s.workflows {
3184 let wf = w.get("name").and_then(Value::as_str).unwrap_or("?");
3185 for (node, auth) in webhook_nodes(w) {
3186 nodes += 1;
3187 if !webhook_auth_verifies(auth) {
3188 open.push(format!("{wf}/{node}"));
3189 }
3190 }
3191 }
3192 if !open.is_empty() {
3193 err(
3194 &mut d,
3195 format!(
3196 "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: {}",
3197 open.join(", ")
3198 ),
3199 );
3200 } else if nodes == 0 {
3201 d.warnings.push("webhooks.listen is non-loopback with no webhooks.default_auth — every webhook node must declare its own `auth` (HMAC recommended)".into());
3204 }
3205 }
3206 }
3207 Err(e) => err(&mut d, format!("webhooks.listen: {e}")),
3208 }
3209 }
3210
3211 if let Some(g) = &s.goal {
3213 let via = g.check.via.as_deref().unwrap_or("both");
3214 if via == "condition" && g.check.condition.is_none() {
3215 err(
3216 &mut d,
3217 "goal.check.via is 'condition' but goal.check.condition is not set".into(),
3218 );
3219 }
3220 for (label, act) in [("on_achieved", &g.on_achieved), ("on_stuck", &g.on_stuck)] {
3221 if let Some(GoalAction::Workflow(name)) = act
3222 && !s
3223 .workflows
3224 .iter()
3225 .any(|w| w.get("name").and_then(Value::as_str) == Some(name.as_str()))
3226 {
3227 err(
3228 &mut d,
3229 format!(
3230 "goal.{label} references workflow '{name}', which is not defined in workflows"
3231 ),
3232 );
3233 }
3234 }
3235 }
3236
3237 let mut peer_names = std::collections::HashSet::new();
3238 for p in &s.a2a.peers {
3239 if !peer_names.insert(p.name.as_str()) {
3240 err(
3241 &mut d,
3242 format!("a2a.peers[]: duplicate peer name '{}'", p.name),
3243 );
3244 }
3245 if !p.endpoint.starts_with("https://") && !p.endpoint.starts_with("http://") {
3246 err(
3247 &mut d,
3248 format!("a2a peer '{}': endpoint must be http(s)://", p.name),
3249 );
3250 }
3251 if p.client_cert.is_some() != p.client_key.is_some() {
3252 err(
3253 &mut d,
3254 format!(
3255 "a2a peer '{}': client_cert and client_key must be set together",
3256 p.name
3257 ),
3258 );
3259 }
3260 if let Some(auth) = &p.auth {
3261 for e in validate_auth_block(auth, &format!("a2a peer '{}'", p.name)) {
3262 err(&mut d, e);
3263 }
3264 if auth.kind == AuthKind::Aws {
3265 err(
3266 &mut d,
3267 format!(
3268 "a2a peer '{}': SigV4 (auth kind aws) is a follow-up",
3269 p.name
3270 ),
3271 );
3272 }
3273 }
3274 for (h, v) in &p.headers {
3275 if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
3276 err(
3277 &mut d,
3278 format!(
3279 "a2a peer '{}' header '{h}' looks like a credential but has an inline value",
3280 p.name
3281 ),
3282 );
3283 } else if let Some(e) = unresolved_secret_ref(v) {
3284 err(&mut d, format!("a2a peer '{}' header '{h}': {e}", p.name));
3285 }
3286 }
3287 }
3288 for (i, pr) in s.a2a.principals.iter().enumerate() {
3289 let m = &pr.matcher;
3290 if m.san.is_none()
3291 && m.sub.is_none()
3292 && m.bearer_ref.is_none()
3293 && m.aauth_agent.is_none()
3294 && !m.any
3295 {
3296 err(
3297 &mut d,
3298 format!(
3299 "a2a.principals[{i}]: match needs one of san | sub | bearer_ref | aauth_agent | any"
3300 ),
3301 );
3302 }
3303 if m.any && pr.role == Role::Operator {
3304 err(
3305 &mut d,
3306 format!("a2a.principals[{i}]: `any` cannot grant the operator role"),
3307 );
3308 }
3309 }
3310
3311 if let Some(l) = &s.observability.log_level
3313 && crate::obs::log::Level::parse(l).is_none()
3314 {
3315 err(
3316 &mut d,
3317 format!("observability.log_level: {l:?} (want trace|debug|info|warn|error)"),
3318 );
3319 }
3320
3321 for m in secret_violations(&loaded.file_doc) {
3323 err(&mut d, m);
3324 }
3325 for f in &s.observability.audit.sink.clone().unwrap_or_default() {
3326 if *f == AuditSink::Store && s.store.kind == StoreKind::None {
3327 err(
3328 &mut d,
3329 "observability.audit.sink includes `store` but store.kind is none".into(),
3330 );
3331 }
3332 }
3333
3334 let mut tags = Vec::new();
3336 for srv in &s.mcp.servers {
3337 match srv.tag_set() {
3338 Ok(t) if t.is_empty() => tags.push(crate::sec::scope::TrifectaTag::UntrustedInput),
3339 Ok(t) => tags.extend(t),
3340 Err(_) => {}
3341 }
3342 }
3343 #[cfg(feature = "exec")]
3352 if s.security.exec.enabled {
3353 tags.push(crate::sec::scope::TrifectaTag::Sensitive);
3354 tags.push(crate::sec::scope::TrifectaTag::Egress);
3355 }
3356 use crate::sec::scope::{TrifectaVerdict, check_trifecta};
3357 if check_trifecta(tags, s.security.allow_trifecta) == TrifectaVerdict::RefusedTrifecta {
3358 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());
3359 }
3360
3361 for (path, level) in [
3377 ("store.durability.a2a", s.store.durability.a2a),
3378 ("store.durability.steps", s.store.durability.steps),
3379 ] {
3380 if level == Some(DurabilityLevel::Eventual) {
3381 d.errors.push(format!(
3382 "{path}: `eventual` is not implemented — every durable write is strict \
3383 (checkpoint-before-effect). Remove the key; `strict` is the default and \
3384 the only behaviour."
3385 ));
3386 }
3387 }
3388 for w in &s.workflows {
3389 if w.get("steps").is_none() {
3390 continue;
3391 }
3392 if let Err(errs) = crate::engine::model::parse_workflow(w) {
3393 d.errors.extend(errs);
3395 }
3396 let cap = s
3399 .limits
3400 .workflow
3401 .fan_out
3402 .unwrap_or(crate::engine::model::MAX_BATCH_PARALLEL as u32);
3403 let wname = w.get("name").and_then(Value::as_str).unwrap_or("?");
3404 if let Some(steps) = w.get("steps").and_then(Value::as_object) {
3405 for (sid, step) in steps {
3406 let want = step.get("parallel").and_then(Value::as_u64).or_else(|| {
3407 step.get("batch")
3408 .and_then(|b| b.get("parallel"))
3409 .and_then(Value::as_u64)
3410 });
3411 if let Some(want) = want
3412 && want > cap as u64
3413 {
3414 d.errors.push(format!(
3415 "workflow {wname:?} step {sid:?}: parallel {want} exceeds \
3416 limits.workflow.fan_out ({cap}) — raise the limit or lower the step"
3417 ));
3418 }
3419 }
3420 }
3421 }
3422 d
3423}
3424
3425pub const LONG_LIVED_STARTS: &[&str] = &[
3428 "loop",
3429 "schedule",
3430 "subscribe",
3431 "signal",
3432 "event",
3433 "a2a",
3434 "webhook",
3435];
3436
3437pub fn workflow_is_long_lived(w: &Value) -> bool {
3439 w.get("steps")
3440 .and_then(Value::as_object)
3441 .is_some_and(|steps| {
3442 steps.values().any(|st| {
3443 st.get("kind")
3444 .and_then(Value::as_str)
3445 .is_some_and(|k| LONG_LIVED_STARTS.contains(&k))
3446 })
3447 })
3448}
3449
3450pub fn workflow_uses_webhook(w: &Value) -> bool {
3454 w.get("steps")
3455 .and_then(Value::as_object)
3456 .is_some_and(|steps| {
3457 steps.values().any(|st| {
3458 let kind = st.get("kind").and_then(Value::as_str);
3459 kind == Some("webhook")
3460 || (matches!(kind, Some("wait") | Some("await"))
3461 && st.get("on").and_then(Value::as_str) == Some("webhook"))
3462 })
3463 })
3464}
3465
3466fn webhook_nodes(w: &Value) -> Vec<(&str, Option<&Value>)> {
3472 let Some(steps) = w.get("steps").and_then(Value::as_object) else {
3473 return Vec::new();
3474 };
3475 steps
3476 .iter()
3477 .filter_map(|(id, st)| {
3478 let kind = st.get("kind").and_then(Value::as_str);
3479 if kind == Some("webhook") {
3480 Some((id.as_str(), st.get("auth")))
3481 } else if matches!(kind, Some("wait") | Some("await"))
3482 && st.get("on").and_then(Value::as_str) == Some("webhook")
3483 {
3484 Some((id.as_str(), st.get("webhook").and_then(|c| c.get("auth"))))
3485 } else {
3486 None
3487 }
3488 })
3489 .collect()
3490}
3491
3492fn webhook_auth_verifies(auth: Option<&Value>) -> bool {
3499 let Some(a) = auth else { return false };
3500 if a.get("none").and_then(Value::as_bool) == Some(true) {
3501 return false;
3502 }
3503 a.get("hmac").and_then(Value::as_object).is_some()
3504 || a.get("header").and_then(Value::as_object).is_some()
3505 || a.get("bearer").and_then(Value::as_str).is_some()
3506}
3507
3508fn webhook_default_verifies(d: Option<&WebhookAuth>) -> bool {
3513 d.is_some_and(|d| !d.none && (d.hmac.is_some() || d.bearer.is_some() || d.header.is_some()))
3514}
3515
3516fn validate_budget(b: &Budget, at: &str, d: &mut Diagnostics) {
3517 for (i, w) in b.windows.iter().enumerate() {
3518 if w.tokens.is_none() && w.requests.is_none() {
3519 d.errors
3520 .push(format!("{at}.windows[{i}]: set tokens and/or requests"));
3521 }
3522 if let Some(r) = &w.reset {
3523 let ok = r.len() == 6
3528 && r.is_ascii()
3529 && r.ends_with('Z')
3530 && r[..2].parse::<u32>().is_ok_and(|h| h < 24)
3531 && &r[2..3] == ":"
3532 && r[3..5].parse::<u32>().is_ok_and(|m| m < 60);
3533 if !ok {
3534 d.errors.push(format!(
3535 "{at}.windows[{i}].reset must be HH:MMZ (got {r:?})"
3536 ));
3537 }
3538 if !w.per.is_calendar() {
3539 d.warnings.push(format!(
3540 "{at}.windows[{i}].reset is only meaningful for day/week windows"
3541 ));
3542 }
3543 }
3544 }
3545 if let Some(f) = b.slow.factor
3546 && !(f > 0.0 && f <= 1.0)
3547 {
3548 d.errors
3549 .push(format!("{at}.slow.factor must be in (0, 1] (got {f})"));
3550 }
3551 if b.on_exhausted == BudgetTactic::Degrade && b.degrade.model.is_none() {
3552 d.errors.push(format!(
3553 "{at}.on_exhausted is degrade but {at}.degrade.model is not set"
3554 ));
3555 }
3556 if b.reserve.estimate == ReserveEstimate::Fixed && b.reserve.fixed.is_none() {
3557 d.errors.push(format!(
3558 "{at}.reserve.estimate is fixed but {at}.reserve.fixed is not set"
3559 ));
3560 }
3561}
3562
3563const FILE_SECRET_PATHS: &[&str] = &[
3565 "/intelligence/token",
3566 "/a2a/bearer",
3567 "/security/aauth/enroll_token",
3568];
3569
3570fn secret_violations(file_doc: &Value) -> Vec<String> {
3572 let mut out = Vec::new();
3573 for p in FILE_SECRET_PATHS {
3574 if let Some(Value::String(v)) = file_doc.pointer(p)
3575 && !crate::sec::secret::has_secret_ref(v)
3576 {
3577 out.push(format!(
3578 "config file: {} carries an inline credential; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}} (or set it from env/flag)",
3579 p.trim_start_matches('/').replace('/', ".")
3580 ));
3581 }
3582 }
3583 if let Some(servers) = file_doc.pointer("/mcp/servers").and_then(Value::as_array) {
3584 for s in servers {
3585 if let Some(Value::String(v)) = s.pointer("/oauth/client_secret")
3586 && !crate::sec::secret::has_secret_ref(v)
3587 {
3588 out.push(format!(
3589 "config file: mcp server '{}' oauth.client_secret carries an inline credential; use a {{{{secret:…}}}} reference",
3590 s.get("name").and_then(Value::as_str).unwrap_or("?")
3591 ));
3592 }
3593 }
3594 }
3595 out
3596}
3597
3598pub const RESTART_ONLY_PATHS: &[&str] = &[
3605 "config_version",
3606 "agent.name",
3607 "store.kind",
3608 "store.prefix",
3609 "store.mcp",
3610 "store.http",
3611 "store.file",
3614 "lifecycle.run_until",
3615 "lifecycle.drain_timeout",
3616 "lifecycle.run_id",
3617 "lifecycle.exit_code_map",
3618 "lifecycle.watch_config",
3619 "a2a.listen",
3620 "a2a.tls",
3621 "a2a.bearer",
3622 "observability.otel",
3623 "observability.metrics_addr",
3624 "observability.health_file",
3625 "observability.events_ring",
3626 "observability.traceparent",
3627 "security",
3628];
3629
3630pub fn restart_only_diff(running: &Value, candidate: &Value) -> Vec<String> {
3632 RESTART_ONLY_PATHS
3633 .iter()
3634 .filter(|p| {
3635 let ptr = format!("/{}", p.replace('.', "/"));
3636 running.pointer(&ptr) != candidate.pointer(&ptr)
3637 })
3638 .map(|p| (*p).to_string())
3639 .collect()
3640}
3641
3642pub fn help_section() -> String {
3644 paths::help_section_in(&paths::bindings_of(&schema::schema()))
3645}
3646
3647pub fn help_text() -> String {
3650 let mut out = format!(
3651 "agentd {ver} — a durable, workflow-driven agent (config schema v2)\n\
3652 \n\
3653 USAGE:\n\
3654 \x20 agentd --config <settings.yaml> [--config <overlay.yaml> …] [--<path> <value> …]\n\
3655 \x20 agentd --prompt <TEXT> --intelligence <URL> # one-shot: ask, answer, exit\n\
3656 \x20 agentd --instruction <TEXT> --intelligence <URL> [--mcp name=endpoint …] # one-shot sugar\n\
3657 \x20 agentd tui|ui --config <settings.yaml> [--<path> <value> …] # + a display client\n\
3658 \n\
3659 Every setting is a document path (YAML/JSON file, AGENTD_<PATH> env, --<path> flag);\n\
3660 several files merge in order (later wins). Precedence: built-in < files < env < flags.\n\
3661 \n\
3662 ALIASES (legacy spellings of paths):\n",
3663 ver = crate::VERSION
3664 );
3665 for a in ALIASES {
3666 let shape = match a.kind {
3667 AliasKind::Set | AliasKind::SetFromFile => "<value>",
3668 AliasKind::SetTrue => "",
3669 AliasKind::Append => "<value> (adds one)",
3670 AliasKind::Special => "<value>",
3671 };
3672 out.push_str(&format!(" {:<32} {} → {}\n", a.flag, shape, a.path));
3673 }
3674 out.push_str(
3675 "\nSUBCOMMANDS (run the daemon with a display client attached; RFC 0032):\n\
3676 \x20 tui + the terminal UI (fullscreen; --inline for in-place)\n\
3677 \x20 ui + the web UI, opened in a browser\n\
3678 \x20 both need `interface.enabled: true`, which the\n\
3679 \x20 subcommand sets for you; the client exits with the daemon.\n\
3680 \x20 Detached instead: run `agentd -c …`, then `agentd-tui\n\
3681 \x20 --endpoint <url>` (npm i -g @agentd-dev/cli).\n\
3682 \nCONTROL:\n\
3683 \x20 -c, --config <PATH> a settings file (repeatable; `=` form too; or AGENT_CONFIG=a.yaml:b.yaml)\n\
3684 \x20 --validate-config load+validate everything, print the verdict, exit 0/2\n\
3685 \x20 --config-schema=2 print the settings JSON Schema (v2) and exit\n\
3686 \x20 --workflow-schema print the workflow (dialect 3) JSON Schema + node registry and exit\n\
3687 \x20 --capabilities print the capabilities manifest and exit\n\
3688 \x20 --login <target> complete an OAuth device-login for an endpoint (e.g. mcp:<name>) and cache the token\n\
3689 \x20 --logout <target> evict a cached credential\n\
3690 \x20 -h, --help / -V, --version\n\
3691 \nREMOVED IN 2.0:\n",
3692 );
3693 for (flag, hint) in REMOVED_FLAGS {
3694 out.push_str(&format!(" {flag:<32} {hint}\n"));
3695 }
3696 out.push('\n');
3697 out.push_str(&help_section());
3698 out
3699}
3700
3701#[cfg(test)]
3702mod tests {
3703 use super::*;
3704 use std::io::Write;
3705
3706 fn args(v: &[&str]) -> Vec<String> {
3707 v.iter().map(|s| s.to_string()).collect()
3708 }
3709
3710 fn write_tmp(contents: &str, ext: &str) -> tempfile::NamedTempFile {
3711 let mut f = tempfile::Builder::new()
3712 .suffix(&format!(".{ext}"))
3713 .tempfile()
3714 .unwrap();
3715 f.write_all(contents.as_bytes()).unwrap();
3716 f.flush().unwrap();
3717 f
3718 }
3719
3720 fn base_env() -> Vec<(String, String)> {
3721 vec![(
3722 "AGENTD_INTELLIGENCE_ENDPOINTS".into(),
3723 "https://intel.example/v1".into(),
3724 )]
3725 }
3726
3727 fn struct_fields_at(doc_path: &str) -> Vec<String> {
3733 let mut probe = Value::Object(Map::new());
3735 let path = if doc_path.is_empty() {
3736 "__probe__".to_string()
3737 } else {
3738 format!("{doc_path}.__probe__")
3739 };
3740 paths::set_path(&mut probe, &path, json!(1));
3741 let err = Settings::from_document(probe, "t").expect_err("probe must be rejected");
3742 let after = err.split("expected").nth(1).unwrap_or("");
3745 let mut out: Vec<String> = after
3746 .split('`')
3747 .skip(1)
3748 .step_by(2)
3749 .map(str::to_string)
3750 .collect();
3751 out.sort();
3752 out
3753 }
3754
3755 fn schema_props_at(schema: &Value, doc_path: &str) -> Vec<String> {
3756 let mut node = schema.clone();
3757 let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
3758 for seg in doc_path.split('.').filter(|s| !s.is_empty()) {
3759 let props = node.get("properties").cloned().unwrap_or(Value::Null);
3760 node = props.get(seg).cloned().unwrap_or(Value::Null);
3761 if let Some(r) = node.get("$ref").and_then(Value::as_str)
3762 && let Some(name) = r.strip_prefix("#/$defs/")
3763 {
3764 node = defs.get(name).cloned().unwrap_or(Value::Null);
3765 }
3766 }
3767 let mut out: Vec<String> = node
3768 .get("properties")
3769 .and_then(Value::as_object)
3770 .map(|m| m.keys().cloned().collect())
3771 .unwrap_or_default();
3772 out.sort();
3773 out
3774 }
3775
3776 #[test]
3777 fn schema_matches_struct_at_every_object() {
3778 let schema = schema::schema();
3779 for path in [
3780 "",
3781 "agent",
3782 "agent.tools",
3783 "intelligence",
3784 "intelligence.auth",
3785 "intelligence.budget",
3786 "intelligence.budget.slow",
3787 "intelligence.budget.degrade",
3788 "intelligence.budget.reserve",
3789 "mcp",
3790 "tools",
3791 "store",
3792 "store.checkpoint",
3793 "store.durability",
3794 "memory",
3795 "context",
3796 "context.plan",
3797 "knowledge",
3798 "knowledge.auto_context",
3799 "search",
3800 "skills",
3801 "limits",
3802 "limits.run",
3803 "limits.subagents",
3804 "lifecycle",
3805 "a2a",
3806 "a2a.tls",
3807 "observability",
3808 "observability.otel",
3809 "observability.audit",
3810 "security",
3811 "security.cgroup",
3812 "security.exec",
3813 ] {
3814 let s = schema_props_at(&schema, path);
3815 let f = struct_fields_at(path);
3816 assert_eq!(s, f, "schema/struct drift at `{path}`");
3817 }
3818 }
3819
3820 #[test]
3821 fn every_schema_path_deserializes_a_sample() {
3822 for b in paths::bindings_of(&schema::schema()) {
3826 let sample = match &b.kind {
3827 paths::Kind::String => match b.path.as_str() {
3828 "config_version" => json!("2"),
3829 _ => json!("x"),
3830 },
3831 paths::Kind::Integer => json!(1),
3832 paths::Kind::Number => json!(0.5),
3833 paths::Kind::Boolean => json!(true),
3834 paths::Kind::Enum(vs) => json!(vs[0]),
3835 paths::Kind::Array(item) => match (**item).clone() {
3836 paths::Kind::Object => match b.path.as_str() {
3837 "mcp.servers" => {
3838 json!([{"name": "a", "endpoint": "https://a.example/mcp"}])
3839 }
3840 "workflows" => json!([{"name": "w", "steps": {}}]),
3841 "a2a.principals" => json!([{"match": {"any": true}, "role": "user"}]),
3842 "a2a.peers" => json!([{"name": "p", "endpoint": "https://p.example"}]),
3843 "skills.sources" => json!([{"server": "s"}]),
3844 "intelligence.budget.windows" | "agent.conversation_budget.windows" => {
3845 json!([{"per": "hour", "tokens": 1}])
3846 }
3847 other => panic!("no sample for object list {other}"),
3848 },
3849 paths::Kind::Enum(vs) => json!([vs[0]]),
3850 _ => json!(["s"]),
3851 },
3852 paths::Kind::Object => match b.path.as_str() {
3853 "intelligence.pricing" => json!({"m": {"input_per_1k": 1.0}}),
3854 "tools.overrides" => json!({"memory.get": {"server": "s", "tool": "t"}}),
3855 "store.mcp" => json!({"server": "s"}),
3856 "store.http" => json!({"base_url": "https://s"}),
3857 "security.aauth" => json!({"provider": "https://apd"}),
3858 "lifecycle.exit_code_map" => json!({"3": 0}),
3859 _ => json!({"k": "v"}),
3860 },
3861 paths::Kind::Any => match b.path.as_str() {
3862 "intelligence.endpoints" => json!("https://a,https://b"),
3863 "goal.on_achieved" | "goal.on_stuck" => json!("finish"),
3864 p if p.ends_with("timeout")
3865 || p.ends_with("deadline")
3866 || p.ends_with("_grace")
3867 || p.ends_with("ttl")
3868 || p.ends_with("every") =>
3869 {
3870 json!("10s")
3871 }
3872 p if p.starts_with("agent.tools.") => json!("all"),
3873 _ => json!("x"),
3874 },
3875 };
3876 let mut doc = Value::Object(Map::new());
3877 paths::set_path(&mut doc, &b.path, sample);
3878 fill_required(&mut doc, &schema::schema(), &b.path);
3879 Settings::from_document(doc, "t")
3880 .unwrap_or_else(|e| panic!("path {} does not deserialize: {e}", b.path));
3881 }
3882 }
3883
3884 fn fill_required(doc: &mut Value, schema: &Value, path: &str) {
3888 let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
3889 let resolve = |v: &Value| -> Value {
3890 match v
3891 .get("$ref")
3892 .and_then(Value::as_str)
3893 .and_then(|r| r.strip_prefix("#/$defs/"))
3894 {
3895 Some(name) => defs.get(name).cloned().unwrap_or(Value::Null),
3896 None => v.clone(),
3897 }
3898 };
3899 let mut node = schema.clone();
3900 let mut prefix = String::new();
3901 let segs: Vec<&str> = path.split('.').collect();
3902 for (i, seg) in segs.iter().enumerate() {
3903 let props = node.get("properties").cloned().unwrap_or(Value::Null);
3904 node = resolve(&props.get(*seg).cloned().unwrap_or(Value::Null));
3905 prefix = if prefix.is_empty() {
3906 (*seg).to_string()
3907 } else {
3908 format!("{prefix}.{seg}")
3909 };
3910 if i + 1 == segs.len() {
3911 break;
3912 }
3913 if let Some(req) = node.get("required").and_then(Value::as_array) {
3914 let props = node.get("properties").cloned().unwrap_or(Value::Null);
3915 for r in req.iter().filter_map(Value::as_str) {
3916 let p = format!("{prefix}.{r}");
3917 if doc.pointer(&format!("/{}", p.replace('.', "/"))).is_none() {
3918 let sample = match props
3921 .get(r)
3922 .and_then(|f| f.get("enum"))
3923 .and_then(Value::as_array)
3924 .filter(|a| !a.is_empty())
3925 {
3926 Some(vs) => vs[0].clone(),
3927 None => match r {
3928 "provider" | "base_url" | "url" => json!("https://x.example"),
3929 _ => json!("x"),
3930 },
3931 };
3932 paths::set_path(doc, &p, sample);
3933 }
3934 }
3935 }
3936 }
3937 }
3938
3939 #[test]
3940 fn env_and_flag_names_derive_from_the_v2_paths() {
3941 let bs = paths::bindings_of(&schema::schema());
3942 let model = bs.iter().find(|b| b.path == "intelligence.model").unwrap();
3943 assert_eq!(model.env_names()[0], "AGENTD_INTELLIGENCE_MODEL");
3944 assert_eq!(model.env_names()[2], "INTELLIGENCE_MODEL");
3945 assert_eq!(model.flag(), "--intelligence-model");
3946 let steps = bs.iter().find(|b| b.path == "limits.run.steps").unwrap();
3947 assert_eq!(steps.env_names()[0], "AGENTD_LIMITS_RUN_STEPS");
3948 let mut seen = std::collections::HashSet::new();
3950 for b in &bs {
3951 assert!(seen.insert(b.flag()), "duplicate flag {}", b.flag());
3952 }
3953 }
3954
3955 #[test]
3958 fn detects_v1_v2_mixed_and_empty() {
3959 assert_eq!(detect(&json!({})), Detected::Empty);
3960 assert_eq!(detect(&json!({"model": "m"})), Detected::V1);
3961 assert_eq!(detect(&json!({"config_version": "2"})), Detected::V2);
3962 assert_eq!(
3963 detect(&json!({"agent": {"instruction": "x"}})),
3964 Detected::V2
3965 );
3966 assert_eq!(detect(&json!({"agent": {}, "model": "m"})), Detected::Mixed);
3967 assert_eq!(
3968 detect(&json!({"config_version": "1.0", "model": "m"})),
3969 Detected::V1
3970 );
3971 assert_eq!(
3973 detect(&json!({"model": "m", "limits": {"max_steps": 1}})),
3974 Detected::V1
3975 );
3976 assert_eq!(
3977 detect(&json!({"intelligence": "https://x", "limits": {}})),
3978 Detected::V1
3979 );
3980 assert_eq!(
3981 detect(&json!({"intelligence": {"model": "m"}, "limits": {}})),
3982 Detected::V2
3983 );
3984 assert_eq!(detect(&json!({"limits": {"max_steps": 1}})), Detected::V1);
3985 }
3986
3987 #[cfg(feature = "exec")]
3990 #[test]
3991 fn enabling_exec_next_to_untrusted_input_assembles_the_trifecta() {
3992 let cfg = "config_version: \"2\"\nstore: {kind: memory}\n\
3998 mcp:\n servers:\n - name: web\n endpoint: https://mcp-web.internal/mcp\n tags: {\"*\": [untrusted_input]}\n\
3999 security:\n exec: {enabled: true, workdir: /tmp, allow: [git]}\n";
4000 let f = write_tmp(cfg, "yaml");
4001 let e = load(
4002 &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
4003 &base_env(),
4004 )
4005 .unwrap_err();
4006 assert!(format!("{e}").contains("lethal-trifecta refused"), "{e}");
4007
4008 load(
4010 &args(&[
4011 "--config",
4012 f.path().to_str().unwrap(),
4013 "--validate-config",
4014 "--allow-trifecta",
4015 ]),
4016 &base_env(),
4017 )
4018 .expect("--allow-trifecta is the escape hatch");
4019
4020 let alone = write_tmp(
4022 "config_version: \"2\"\nstore: {kind: memory}\n\
4023 security:\n exec: {enabled: true, workdir: /tmp, allow: [git]}\n",
4024 "yaml",
4025 );
4026 load(
4027 &args(&[
4028 "--config",
4029 alone.path().to_str().unwrap(),
4030 "--validate-config",
4031 ]),
4032 &base_env(),
4033 )
4034 .expect("two legs are not the trifecta");
4035 }
4036
4037 #[test]
4038 fn validate_config_catches_workflow_body_errors_the_runtime_would_refuse() {
4039 let f = write_tmp(
4043 "config_version: \"2\"\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",
4044 "yaml",
4045 );
4046 let e = load(
4047 &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
4048 &base_env(),
4049 )
4050 .unwrap_err();
4051 let msg = format!("{e}");
4052 assert!(msg.contains("unknown field"), "{msg}");
4053 assert!(msg.contains("prompt"), "{msg}");
4054 assert!(
4055 msg.contains("instruction"),
4056 "names the allowed fields: {msg}"
4057 );
4058
4059 let ok = write_tmp(
4061 "config_version: \"2\"\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",
4062 "yaml",
4063 );
4064 load(
4065 &args(&["--config", ok.path().to_str().unwrap(), "--validate-config"]),
4066 &base_env(),
4067 )
4068 .expect("a correct workflow validates");
4069 }
4070
4071 #[test]
4072 fn a_prompt_is_a_message_not_a_sugar_workflow() {
4073 let (l, ask) = load(&args(&["--prompt", "do the thing"]), &base_env()).unwrap();
4078 assert_eq!(ask, Ask::Run);
4079 assert_eq!(l.settings.agent.prompt.as_deref(), Some("do the thing"));
4080 assert!(
4081 l.settings.workflows.is_empty(),
4082 "a prompt needs no workflow: {:?}",
4083 l.settings.workflows
4084 );
4085
4086 let (only_instr, _) = load(&args(&["--instruction", "be terse"]), &base_env()).unwrap();
4088 assert_eq!(only_instr.settings.workflows.len(), 1);
4089
4090 let (both, _) = load(
4093 &args(&["--prompt", "do the thing", "--instruction", "be terse"]),
4094 &base_env(),
4095 )
4096 .unwrap();
4097 assert!(both.settings.workflows.is_empty());
4098 assert_eq!(both.settings.agent.instruction.as_deref(), Some("be terse"));
4099
4100 let mut env = base_env();
4102 env.push(("AGENTD_AGENT_PROMPT".into(), "from env".into()));
4103 let (from_env, _) = load(&args(&[]), &env).unwrap();
4104 assert_eq!(from_env.settings.agent.prompt.as_deref(), Some("from env"));
4105 }
4106
4107 #[test]
4108 fn minimal_instruction_run_gets_the_sugar_workflow() {
4109 let (l, ask) = load(&args(&["--instruction", "do it"]), &base_env()).unwrap();
4110 assert_eq!(ask, Ask::Run);
4111 assert_eq!(l.settings.agent.instruction.as_deref(), Some("do it"));
4112 assert_eq!(
4113 l.settings.intelligence.endpoints,
4114 vec!["https://intel.example/v1"]
4115 );
4116 assert_eq!(l.settings.workflows.len(), 1, "sugar workflow synthesized");
4117 assert_eq!(l.settings.workflows[0]["name"], json!("main"));
4118 assert_eq!(
4119 l.settings.workflows[0]["steps"]["start"]["kind"],
4120 json!("once")
4121 );
4122 assert!(
4124 l.warnings.iter().any(|w| w.contains("not durable")),
4125 "{:?}",
4126 l.warnings
4127 );
4128 }
4129
4130 #[test]
4131 fn a_long_lived_instance_defaults_to_the_file_store_but_an_explicit_none_is_refused() {
4132 let (l, _) = load(
4134 &args(&[
4135 "--instruction",
4136 "x",
4137 "--a2a.listen",
4138 "http://127.0.0.1:8443",
4139 ]),
4140 &base_env(),
4141 )
4142 .unwrap();
4143 assert_eq!(l.settings.store.kind, StoreKind::File);
4144 let f = write_tmp(
4146 "config_version: \"2\"\nworkflows:\n - name: w\n steps:\n s: {kind: schedule, cron: \"* * * * *\"}\n f: {kind: finish, depends_on: [s], status: completed}\n",
4147 "yaml",
4148 );
4149 let (l, _) = load(
4150 &args(&["--config", f.path().to_str().unwrap()]),
4151 &base_env(),
4152 )
4153 .unwrap();
4154 assert_eq!(l.settings.store.kind, StoreKind::File);
4155 let e = load(
4158 &args(&[
4159 "--config",
4160 f.path().to_str().unwrap(),
4161 "--store.kind",
4162 "none",
4163 ]),
4164 &base_env(),
4165 )
4166 .unwrap_err();
4167 assert!(format!("{e}").contains("long-lived"), "{e}");
4168 let (l, _) = load(&args(&["--instruction", "x"]), &base_env()).unwrap();
4171 assert_eq!(l.settings.store.kind, StoreKind::None);
4172 let (l, _) = load(
4174 &args(&["--instruction", "x", "--store.kind", "memory"]),
4175 &base_env(),
4176 )
4177 .unwrap();
4178 assert!(
4179 l.warnings.iter().any(|w| w.contains("memory")),
4180 "{:?}",
4181 l.warnings
4182 );
4183 }
4184
4185 #[test]
4188 fn expand_env_str_covers_the_forms() {
4189 let env: HashMap<&str, &str> = [("HOST", "db.internal"), ("PORT", "5432")]
4190 .into_iter()
4191 .collect();
4192 assert_eq!(
4194 expand_env_str("${HOST}:${PORT}", &env).unwrap(),
4195 "db.internal:5432"
4196 );
4197 assert_eq!(
4199 expand_env_str("${MISSING:-fallback}", &env).unwrap(),
4200 "fallback"
4201 );
4202 assert_eq!(
4203 expand_env_str("${HOST:-fallback}", &env).unwrap(),
4204 "db.internal"
4205 );
4206 assert_eq!(
4208 expand_env_str("$HOST costs $5", &env).unwrap(),
4209 "$HOST costs $5"
4210 );
4211 assert_eq!(expand_env_str("$${HOST}", &env).unwrap(), "${HOST}");
4213 assert!(
4215 expand_env_str("${NOPE}", &env)
4216 .unwrap_err()
4217 .contains("NOPE")
4218 );
4219 assert!(expand_env_str("${HOST", &env).is_err());
4221 assert!(expand_env_str("${bad-name}", &env).is_err());
4222 }
4223
4224 #[test]
4225 fn env_substitution_reaches_config_values_and_workflows() {
4226 let file = write_tmp(
4227 "config_version: \"2\"\n\
4228 agent:\n name: ${SVC_NAME}\n instruction: serve\n preflight: never\n\
4229 intelligence:\n endpoints: [https://x/v1]\n model: m\n\
4230 store:\n kind: memory\n\
4231 workflows:\n - name: w\n steps:\n\
4232 \x20 s: {kind: once}\n\
4233 \x20 c: {kind: http, depends_on: [s], url: \"https://api.${REGION:-us}.example/${SVC_NAME}\"}\n\
4234 \x20 f: {kind: finish, depends_on: [c]}\n",
4235 "yaml",
4236 );
4237 let mut env = base_env();
4238 env.push(("SVC_NAME".into(), "billing".into()));
4239 let (l, _) = load(&args(&["--config", file.path().to_str().unwrap()]), &env).unwrap();
4241 assert_eq!(
4243 l.settings.agent.name.as_deref(),
4244 Some("billing"),
4245 "the `${{SVC_NAME}}` in a config value was substituted"
4246 );
4247 let url = l.settings.workflows[0]
4250 .pointer("/steps/c/url")
4251 .and_then(Value::as_str)
4252 .unwrap_or_default();
4253 assert_eq!(
4254 url, "https://api.us.example/billing",
4255 "the workflow value was substituted (default + set var)"
4256 );
4257 }
4258
4259 #[test]
4260 fn mcp_server_oauth_is_carried_to_the_runtime_spec() {
4261 let s = McpServer {
4265 name: "gh".into(),
4266 endpoint: "https://mcp.example".into(),
4267 ns: None,
4268 headers: BTreeMap::new(),
4269 tags: BTreeMap::new(),
4270 aauth: None,
4271 oauth: Some(McpOauth {
4272 token_url: "https://auth.example/token".into(),
4273 client_id: "cid".into(),
4274 client_secret: Secret("{{secret:CS}}".into()),
4275 scope: Some("mcp:read".into()),
4276 }),
4277 auth: None,
4278 timeout: None,
4279 };
4280 let spec = s.to_spec().unwrap();
4281 let o = spec.oauth.expect("oauth reaches the runtime spec");
4282 assert_eq!(o.token_url, "https://auth.example/token");
4283 assert_eq!(o.client_id, "cid");
4284 assert_eq!(o.client_secret, "{{secret:CS}}");
4286 assert_eq!(o.scope.as_deref(), Some("mcp:read"));
4287 }
4288
4289 #[test]
4290 fn files_env_flags_layer_in_order_with_aliases() {
4291 let base = write_tmp(
4292 "config_version: \"2\"\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",
4293 "yaml",
4294 );
4295 let over = write_tmp("intelligence:\n model: over-model\n", "yml");
4296 let mut env = base_env();
4297 env.clear();
4298 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(
4302 &args(&[
4303 "--config",
4304 base.path().to_str().unwrap(),
4305 "--config",
4306 over.path().to_str().unwrap(),
4307 "--max-steps",
4308 "30",
4309 "--mcp",
4310 "fs=https://fs.example/mcp",
4311 "--mcp-tags",
4312 "fs=sensitive",
4313 "--intelligence.headers.x-team",
4314 "ops",
4315 ]),
4316 &env,
4317 )
4318 .unwrap();
4319 let s = &l.settings;
4320 assert_eq!(
4321 s.agent.instruction.as_deref(),
4322 Some("env-instruction"),
4323 "env > file"
4324 );
4325 assert_eq!(
4326 s.intelligence.model.as_deref(),
4327 Some("env-model"),
4328 "env alias > later file"
4329 );
4330 assert_eq!(s.limits.run.steps(), 30, "flag alias > env");
4331 assert_eq!(s.mcp.servers.len(), 1);
4332 assert_eq!(s.mcp.servers[0].name, "fs");
4333 assert_eq!(s.mcp.servers[0].tags["*"], vec!["sensitive"]);
4334 assert_eq!(
4335 s.intelligence.headers.get("x-team").map(String::as_str),
4336 Some("ops")
4337 );
4338 assert_eq!(l.files.len(), 2);
4339 let env2: Vec<(String, String)> = vec![
4341 ("AGENT_MODEL".into(), "legacy".into()),
4342 ("AGENTD_INTELLIGENCE_MODEL".into(), "path".into()),
4343 ("AGENTD_INTELLIGENCE_ENDPOINTS".into(), "https://i".into()),
4344 ];
4345 let (l2, _) = load(
4346 &args(&["--instruction", "x", "--store.kind", "memory"]),
4347 &env2,
4348 )
4349 .unwrap();
4350 assert_eq!(l2.settings.intelligence.model.as_deref(), Some("path"));
4351 }
4352
4353 #[test]
4354 fn removed_flags_name_their_replacement() {
4355 for (flag, _) in REMOVED_FLAGS {
4356 let e = load(&args(&[flag, "x"]), &base_env()).unwrap_err();
4357 assert!(
4358 format!("{e}").contains("removed in agentd 2.0"),
4359 "{flag}: {e}"
4360 );
4361 }
4362 let e = load(&args(&["--mode", "reactive"]), &base_env()).unwrap_err();
4363 assert!(format!("{e}").contains("start node"), "{e}");
4364 }
4365
4366 #[test]
4367 fn mixed_and_v1_files_are_refused_by_the_v2_loader() {
4368 let mixed = write_tmp("agent: {instruction: x}\nmodel: m\n", "yaml");
4369 let e = load(
4370 &args(&["--config", mixed.path().to_str().unwrap()]),
4371 &base_env(),
4372 )
4373 .unwrap_err();
4374 assert!(format!("{e}").contains("mixes v1"), "{e}");
4375 let v1 = write_tmp("model: m\n", "yaml");
4376 let e = load(
4377 &args(&["--config", v1.path().to_str().unwrap()]),
4378 &base_env(),
4379 )
4380 .unwrap_err();
4381 assert!(format!("{e}").contains("v1 schema"), "{e}");
4382 }
4383
4384 #[test]
4385 fn budget_exit_code_and_instruction_file_aliases() {
4386 let f = write_tmp("read me from a file", "txt");
4387 let (l, _) = load(
4388 &args(&[
4389 "--instruction-file",
4390 f.path().to_str().unwrap(),
4391 "--budget-exit-code",
4392 "9",
4393 "--store.kind",
4394 "memory",
4395 ]),
4396 &base_env(),
4397 )
4398 .unwrap();
4399 assert_eq!(
4400 l.settings.agent.instruction.as_deref(),
4401 Some("read me from a file")
4402 );
4403 assert_eq!(l.settings.lifecycle.exit_code_map.get("3"), Some(&9));
4404 assert_eq!(l.settings.lifecycle.exit_code_map.get("7"), Some(&9));
4405 }
4406
4407 fn load_doc(yaml: &str) -> Result<Loaded, ConfigError> {
4410 let f = write_tmp(yaml, "yaml");
4411 load(&args(&["--config", f.path().to_str().unwrap()]), &[]).map(|(l, _)| l)
4412 }
4413
4414 #[test]
4415 fn validation_collects_the_rfc_0030_rules() {
4416 let e = load_doc(
4418 "config_version: \"2\"\nintelligence:\n endpoints: [https://i]\n token: sk-inline\n",
4419 )
4420 .unwrap_err();
4421 assert!(format!("{e}").contains("inline credential"), "{e}");
4422 let (l, _) = load(
4423 &args(&[
4424 "--intelligence",
4425 "https://i",
4426 "--intelligence-token",
4427 "sk-inline",
4428 ]),
4429 &[],
4430 )
4431 .unwrap();
4432 assert_eq!(
4433 l.settings.intelligence.token.as_ref().map(|s| s.0.as_str()),
4434 Some("sk-inline")
4435 );
4436 assert!(
4437 !format!("{:?}", l.settings).contains("sk-inline"),
4438 "Debug redacts"
4439 );
4440
4441 let e = load_doc(
4444 "config_version: \"2\"\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",
4445 )
4446 .unwrap_err();
4447 assert!(matches!(e, ConfigError::Usage(_)), "{e}");
4448
4449 let f = write_tmp(
4451 "config_version: \"2\"\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",
4452 "yaml",
4453 );
4454 let e = load(
4455 &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
4456 &[],
4457 )
4458 .unwrap_err();
4459 let ConfigError::Validate(Err(lines)) = e else {
4460 panic!("expected a validate verdict, got {e:?}")
4461 };
4462 for needle in [
4463 "store.mcp.server 'nope'",
4464 "knowledge.server 'kb'",
4465 "skills.sources[]",
4466 "tools.overrides['memory.get']",
4467 "both disabled and overridden",
4468 "only the policy codes 3 and 7",
4469 "0..=255",
4470 ] {
4471 assert!(lines.contains(needle), "missing {needle} in:\n{lines}");
4472 }
4473
4474 let e = load_doc("config_version: \"2\"\nstore: {kind: memory}\na2a: {listen: \"https://0.0.0.0:8443\"}\n").unwrap_err();
4476 assert!(format!("{e}").contains("a2a.tls.cert"), "{e}");
4477 let e = load_doc("config_version: \"2\"\nstore: {kind: memory}\na2a: {listen: \"http://0.0.0.0:8080\"}\n").unwrap_err();
4478 assert!(format!("{e}").contains("loopback"), "{e}");
4479 let e = load_doc(
4481 "config_version: \"2\"\na2a: {principals: [{match: {any: true}, role: operator}]}\n",
4482 )
4483 .unwrap_err();
4484 assert!(format!("{e}").contains("operator role"), "{e}");
4485 let e = load_doc("config_version: \"2\"\nintelligence: {budget: {windows: [{per: hour}], on_exhausted: degrade}}\n").unwrap_err();
4487 assert!(format!("{e}").contains("tokens and/or requests"), "{e}");
4488 let e = load_doc(
4490 "config_version: \"2\"\nmcp:\n servers:\n - {name: fs, endpoint: https://fs/mcp, tags: {\"*\": [untrusted_input, sensitive, egress]}}\n",
4491 )
4492 .unwrap_err();
4493 assert!(format!("{e}").contains("lethal-trifecta"), "{e}");
4494 }
4495
4496 #[test]
4497 fn restart_only_diff_names_changed_paths() {
4498 let a = json!({"agent": {"name": "x", "instruction": "i"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
4499 let b = json!({"agent": {"name": "y", "instruction": "j"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
4500 assert_eq!(restart_only_diff(&a, &b), vec!["agent.name".to_string()]);
4501 let c = json!({"agent": {"name": "x", "instruction": "changed"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
4502 assert!(
4503 restart_only_diff(&a, &c).is_empty(),
4504 "instruction is reloadable"
4505 );
4506 }
4507
4508 #[test]
4509 fn duration_and_tool_select_scalars() {
4510 let s = Settings::from_document(
4511 json!({"limits": {"run": {"deadline": "90s"}, "step_timeout": 5}, "agent": {"tools": {"mcp": "none", "internal": ["memory.get"]}}}),
4512 "t",
4513 )
4514 .unwrap();
4515 assert_eq!(s.limits.run.deadline(), Duration::from_secs(90));
4516 assert_eq!(s.limits.step_timeout, Some(Dur(Duration::from_secs(5))));
4517 assert!(!s.agent.tools.mcp.allows("fs.read"));
4518 assert!(s.agent.tools.internal.allows("memory.get"));
4519 assert!(!s.agent.tools.internal.allows("finish"));
4520 assert!(s.agent.tools.code.allows("anything"));
4521 assert!(
4522 Settings::from_document(json!({"limits": {"run": {"deadline": "soon"}}}), "t").is_err()
4523 );
4524 }
4525
4526 #[test]
4529 fn file_store_root_walks_the_chain_in_order() {
4530 use std::ffi::OsString;
4531 use std::path::PathBuf;
4532 let env = |pairs: Vec<(&'static str, &'static str)>| {
4533 move |k: &str| -> Option<OsString> {
4534 pairs
4535 .iter()
4536 .find(|(n, _)| *n == k)
4537 .map(|(_, v)| OsString::from(*v))
4538 }
4539 };
4540 let all = vec![
4541 ("AGENTD_STATE_DIR", "/state-dir"),
4542 ("XDG_STATE_HOME", "/xdg"),
4543 ("HOME", "/home/a"),
4544 ];
4545 let with_file = |path: Option<&str>| Store {
4546 file: Some(StoreFile {
4547 path: path.map(str::to_string),
4548 }),
4549 ..Store::default()
4550 };
4551
4552 assert_eq!(
4554 file_store_root_in(&with_file(Some("/var/lib/agentd")), &env(all.clone())),
4555 PathBuf::from("/var/lib/agentd")
4556 );
4557 assert_eq!(
4560 file_store_root_in(&with_file(None), &env(all.clone())),
4561 PathBuf::from("/state-dir")
4562 );
4563 assert_eq!(
4565 file_store_root_in(&Store::default(), &env(all[1..].to_vec())),
4566 PathBuf::from("/xdg/agentd/state")
4567 );
4568 assert_eq!(
4570 file_store_root_in(&Store::default(), &env(all[2..].to_vec())),
4571 PathBuf::from("/home/a/.local/state/agentd/state")
4572 );
4573 assert_eq!(
4575 file_store_root_in(&Store::default(), &env(vec![])),
4576 std::env::temp_dir().join("agentd").join("state")
4577 );
4578 assert!(
4581 file_store_root_in(&Store::default(), &env(all[1..].to_vec()))
4582 .ends_with("agentd/state")
4583 );
4584 }
4585
4586 #[test]
4587 fn file_store_validation_diagnostics() {
4588 let l = load_doc("config_version: \"2\"\nstore: {kind: file}\n").unwrap();
4590 assert_eq!(l.settings.store.kind, StoreKind::File);
4591 assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
4592 let l = load_doc(
4594 "config_version: \"2\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\na2a: {listen: \"http://127.0.0.1:8080\"}\n",
4595 )
4596 .unwrap();
4597 assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
4598 assert_eq!(
4599 file_store_root(&l.settings.store),
4600 std::path::PathBuf::from("/var/lib/agentd")
4601 );
4602
4603 let e = load_doc("config_version: \"2\"\nstore: {kind: file, file: {path: \"\"}}\n")
4605 .unwrap_err();
4606 assert!(format!("{e}").contains("store.file.path is empty"), "{e}");
4607
4608 let l = load_doc(
4611 "config_version: \"2\"\nstore: {kind: memory, file: {path: /var/lib/agentd}}\n",
4612 )
4613 .unwrap();
4614 let d = validate(&l);
4615 assert!(d.errors.is_empty(), "{:?}", d.errors);
4616 assert!(
4617 d.warnings
4618 .iter()
4619 .any(|w| w.contains("store.file is set but store.kind is memory")),
4620 "{:?}",
4621 d.warnings
4622 );
4623 let l =
4625 load_doc("config_version: \"2\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\n")
4626 .unwrap();
4627 assert!(
4628 !validate(&l)
4629 .warnings
4630 .iter()
4631 .any(|w| w.contains("store.file")),
4632 "{:?}",
4633 validate(&l).warnings
4634 );
4635 assert_eq!(
4637 restart_only_diff(
4638 &json!({"store": {"kind": "file", "file": {"path": "/a"}}}),
4639 &json!({"store": {"kind": "file", "file": {"path": "/b"}}})
4640 ),
4641 vec!["store.file".to_string()]
4642 );
4643 }
4644
4645 #[test]
4646 fn instruction_uri_detection() {
4647 assert!(looks_like_resource_uri("mcp://docs/agent-instruction"));
4648 assert!(looks_like_resource_uri("docs://agent"));
4649 assert!(!looks_like_resource_uri("You are a helpful agent."));
4650 assert!(!looks_like_resource_uri(
4651 "see https://x.example for details"
4652 ));
4653 assert!(!looks_like_resource_uri("://nope"));
4654 }
4655
4656 #[test]
4657 fn help_and_schema_asks_short_circuit_validation() {
4658 let (_, ask) = load(&args(&["--help"]), &[]).unwrap();
4659 assert_eq!(ask, Ask::Help);
4660 let (_, ask) = load(&args(&["--config-schema=2"]), &[]).unwrap();
4661 assert_eq!(ask, Ask::Schema);
4662 let (_, ask) = load(&args(&["--workflow-schema"]), &[]).unwrap();
4665 assert_eq!(ask, Ask::WorkflowSchema);
4666 assert!(help_section().contains("intelligence.model"));
4667 }
4668}