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 pub approval: Approval,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
205#[serde(rename_all = "lowercase")]
206pub enum Approval {
207 #[default]
210 #[serde(alias = "await", alias = "human")]
211 Ask,
212 Auto,
215 #[serde(alias = "accept_all", alias = "yes")]
222 Accept,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
227#[serde(rename_all = "lowercase")]
228pub enum AskHumanFallback {
229 #[serde(alias = "pause", alias = "idle")]
231 Wait,
232 #[default]
234 #[serde(alias = "finish", alias = "stop")]
235 Fail,
236 Auto,
239}
240
241impl Agent {
242 pub fn wake_on(&self) -> Vec<WakeEvent> {
244 self.wake_on.clone().unwrap_or_else(|| {
245 vec![
246 WakeEvent::A2aMessage,
247 WakeEvent::HumanReply,
248 WakeEvent::SubagentResult,
249 WakeEvent::WorkflowFailed,
250 ]
251 })
252 }
253 pub fn max_parallel_turns(&self) -> u32 {
254 self.max_parallel_turns.unwrap_or(4)
255 }
256 pub fn instruction_is_uri(&self) -> bool {
258 self.instruction
259 .as_deref()
260 .is_some_and(looks_like_resource_uri)
261 }
262}
263
264pub fn looks_like_resource_uri(s: &str) -> bool {
268 let t = s.trim();
269 if t.contains(char::is_whitespace) {
270 return false;
271 }
272 let Some((scheme, rest)) = t.split_once("://") else {
273 return false;
274 };
275 !scheme.is_empty()
276 && scheme
277 .chars()
278 .next()
279 .is_some_and(|c| c.is_ascii_alphabetic())
280 && scheme
281 .chars()
282 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
283 && !rest.is_empty()
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
287#[serde(rename_all = "lowercase")]
288pub enum Preflight {
289 Never,
290 #[default]
291 Auto,
292 Always,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
296#[serde(rename_all = "snake_case")]
297pub enum WakeEvent {
298 A2aMessage,
299 HumanReply,
300 SubagentResult,
301 WorkflowFinished,
302 WorkflowFailed,
303 InstructionUpdated,
304 BudgetResumed,
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
308#[serde(rename_all = "lowercase")]
309pub enum OnWorkflowFinished {
310 Ignore,
311 #[default]
312 Note,
313 Think,
314}
315
316#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
317#[serde(deny_unknown_fields, default)]
318pub struct AgentTools {
319 pub internal: ToolSelect,
320 pub mcp: ToolSelect,
321 pub code: ToolSelect,
322}
323
324#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
325#[serde(deny_unknown_fields, default)]
326pub struct Intelligence {
327 #[serde(deserialize_with = "string_or_list")]
328 pub endpoints: Vec<String>,
329 pub model: Option<String>,
330 pub dialect: Option<String>,
334 pub token: Option<Secret>,
335 pub token_file: Option<String>,
336 pub headers: BTreeMap<String, String>,
337 pub auth: Option<Auth>,
341 pub swap_policy: Option<String>,
342 pub structured_output: StructuredOutput,
343 pub budget: Budget,
344 pub pricing: BTreeMap<String, Pricing>,
345 pub timeout: Option<Dur>,
346}
347
348impl Intelligence {
349 pub fn timeout(&self) -> Duration {
350 self.timeout.map(|d| d.0).unwrap_or(Duration::from_secs(60))
351 }
352 pub fn endpoint_list(&self) -> Option<String> {
354 if self.endpoints.is_empty() {
355 None
356 } else {
357 Some(self.endpoints.join(","))
358 }
359 }
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
363#[serde(rename_all = "snake_case")]
364pub enum StructuredOutput {
365 #[default]
366 Auto,
367 JsonSchema,
368 Tool,
369 Prompt,
370}
371
372#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
373#[serde(deny_unknown_fields, default)]
374pub struct Budget {
375 pub windows: Vec<BudgetWindow>,
376 pub lifetime_tokens: Option<u64>,
377 pub scope: Option<Vec<BudgetScope>>,
378 pub on_exhausted: BudgetTactic,
379 pub slow: Slow,
380 pub degrade: Degrade,
381 pub reserve: Reserve,
382}
383
384#[derive(Debug, Clone, Deserialize, PartialEq)]
385#[serde(deny_unknown_fields)]
386pub struct BudgetWindow {
387 pub per: WindowUnit,
388 #[serde(default)]
389 pub tokens: Option<u64>,
390 #[serde(default)]
391 pub requests: Option<u64>,
392 #[serde(default)]
393 pub reset: Option<String>,
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
397#[serde(rename_all = "lowercase")]
398pub enum WindowUnit {
399 Second,
400 Minute,
401 Hour,
402 Day,
403 Week,
404}
405
406impl WindowUnit {
407 pub fn duration(self) -> Duration {
408 match self {
409 WindowUnit::Second => Duration::from_secs(1),
410 WindowUnit::Minute => Duration::from_secs(60),
411 WindowUnit::Hour => Duration::from_secs(3600),
412 WindowUnit::Day => Duration::from_secs(86_400),
413 WindowUnit::Week => Duration::from_secs(7 * 86_400),
414 }
415 }
416 pub fn is_calendar(self) -> bool {
418 matches!(self, WindowUnit::Day | WindowUnit::Week)
419 }
420}
421
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
423#[serde(rename_all = "lowercase")]
424pub enum BudgetScope {
425 Instance,
426 Run,
427 Conversation,
428 Principal,
429}
430
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
432#[serde(rename_all = "lowercase")]
433pub enum BudgetTactic {
434 #[default]
435 Wait,
436 Slow,
437 Degrade,
438 Refuse,
439 Fail,
440}
441
442#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
443#[serde(deny_unknown_fields, default)]
444pub struct Slow {
445 pub factor: Option<f64>,
446}
447
448#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
449#[serde(deny_unknown_fields, default)]
450pub struct Degrade {
451 pub model: Option<String>,
452}
453
454#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
455#[serde(deny_unknown_fields, default)]
456pub struct Reserve {
457 pub estimate: ReserveEstimate,
458 pub fixed: Option<u64>,
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
462#[serde(rename_all = "lowercase")]
463pub enum ReserveEstimate {
464 #[default]
465 Context,
466 Fixed,
467 None,
468}
469
470#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
471#[serde(deny_unknown_fields, default)]
472pub struct Pricing {
473 pub input_per_1k: Option<f64>,
474 pub output_per_1k: Option<f64>,
475 pub currency: Option<String>,
476}
477
478#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
479#[serde(deny_unknown_fields, default)]
480pub struct Mcp {
481 pub servers: Vec<McpServer>,
482 pub default_timeout: Option<Dur>,
483}
484
485#[derive(Debug, Clone, Deserialize, PartialEq)]
486#[serde(deny_unknown_fields)]
487pub struct McpServer {
488 pub name: String,
489 pub endpoint: String,
490 #[serde(default)]
491 pub ns: Option<String>,
492 #[serde(default)]
493 pub headers: BTreeMap<String, String>,
494 #[serde(default)]
495 pub tags: BTreeMap<String, Vec<String>>,
496 #[serde(default)]
497 pub aauth: Option<bool>,
498 #[serde(default)]
499 pub oauth: Option<McpOauth>,
500 #[serde(default)]
505 pub auth: Option<Auth>,
506 #[serde(default)]
507 pub timeout: Option<Dur>,
508}
509
510impl McpServer {
511 pub fn tag_set(&self) -> Result<Vec<crate::sec::scope::TrifectaTag>, String> {
513 let mut out = Vec::new();
514 for list in self.tags.values() {
515 for t in list {
516 let tag = crate::sec::scope::TrifectaTag::parse(t).ok_or_else(|| {
517 format!("mcp server '{}' has unknown trifecta tag '{t}'", self.name)
518 })?;
519 if !out.contains(&tag) {
520 out.push(tag);
521 }
522 }
523 }
524 Ok(out)
525 }
526
527 pub fn to_spec(&self) -> Result<super::McpServerSpec, String> {
529 Ok(super::McpServerSpec {
530 name: self.name.clone(),
531 endpoint: self.endpoint.clone(),
532 headers: self
533 .headers
534 .iter()
535 .map(|(k, v)| (k.clone(), v.clone()))
536 .collect(),
537 tags: self.tag_set()?,
538 aauth: self.aauth,
539 oauth: self.oauth.as_ref().map(|o| super::McpOauthSpec {
542 token_url: o.token_url.clone(),
543 client_id: o.client_id.clone(),
544 client_secret: o.client_secret.0.clone(),
545 scope: o.scope.clone(),
546 }),
547 auth: self.auth.as_ref().map(|a| a.to_spec()),
548 })
549 }
550}
551
552#[derive(Debug, Clone, Deserialize, PartialEq)]
553#[serde(deny_unknown_fields)]
554pub struct McpOauth {
555 pub token_url: String,
556 pub client_id: String,
557 pub client_secret: Secret,
558 #[serde(default)]
559 pub scope: Option<String>,
560}
561
562#[derive(Debug, Clone, Deserialize, PartialEq)]
567#[serde(deny_unknown_fields)]
568pub struct Auth {
569 pub kind: AuthKind,
570 #[serde(default)]
574 pub issuer: Option<String>,
575 #[serde(default)]
576 pub token_url: Option<String>,
577 #[serde(default)]
578 pub device_authorization_url: Option<String>,
579 #[serde(default)]
580 pub authorization_url: Option<String>,
581 #[serde(default)]
582 pub client_id: Option<String>,
583 #[serde(default)]
586 pub client_secret: Option<Secret>,
587 #[serde(default)]
590 pub grant: Option<OAuthGrant>,
591 #[serde(default)]
592 pub scopes: Vec<String>,
593 #[serde(default)]
594 pub audience: Option<String>,
595 #[serde(default)]
598 pub token: Option<Secret>,
599 #[serde(default)]
601 pub header: Option<String>,
602 #[serde(default)]
603 pub value: Option<Secret>,
604 #[serde(default)]
606 pub region: Option<String>,
607 #[serde(default)]
609 pub service: Option<String>,
610 #[serde(default)]
613 pub source: Option<String>,
614 #[serde(default)]
617 pub sso_start_url: Option<String>,
618 #[serde(default)]
619 pub account_id: Option<String>,
620 #[serde(default)]
621 pub role_name: Option<String>,
622 #[serde(default)]
626 pub svid: Option<String>,
627 #[serde(default)]
630 pub jwt_svid_file: Option<String>,
631 #[serde(default)]
633 pub svid_file: Option<String>,
634 #[serde(default)]
635 pub key_file: Option<String>,
636}
637
638impl Auth {
639 pub fn to_spec(&self) -> super::AuthSpec {
642 super::AuthSpec {
643 kind: match self.kind {
644 AuthKind::Static => "static",
645 AuthKind::Oauth2 => "oauth2",
646 AuthKind::Aws => "aws",
647 AuthKind::Spiffe => "spiffe",
648 }
649 .to_string(),
650 grant: self.grant.map(|g| {
651 match g {
652 OAuthGrant::Device => "device",
653 OAuthGrant::AuthorizationCode => "authorization_code",
654 OAuthGrant::ClientCredentials => "client_credentials",
655 }
656 .to_string()
657 }),
658 issuer: self.issuer.clone(),
659 token_url: self.token_url.clone(),
660 device_authorization_url: self.device_authorization_url.clone(),
661 authorization_url: self.authorization_url.clone(),
662 client_id: self.client_id.clone(),
663 client_secret: self.client_secret.as_ref().map(|s| s.0.clone()),
664 scopes: self.scopes.clone(),
665 audience: self.audience.clone(),
666 token: self.token.as_ref().map(|s| s.0.clone()),
667 header: self.header.clone(),
668 value: self.value.as_ref().map(|s| s.0.clone()),
669 region: self.region.clone(),
670 service: self.service.clone(),
671 source: self.source.clone(),
672 sso_start_url: self.sso_start_url.clone(),
673 account_id: self.account_id.clone(),
674 role_name: self.role_name.clone(),
675 svid: self.svid.clone(),
676 jwt_svid_file: self.jwt_svid_file.clone(),
677 svid_file: self.svid_file.clone(),
678 key_file: self.key_file.clone(),
679 }
680 }
681}
682
683#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
685#[serde(rename_all = "snake_case")]
686pub enum AuthKind {
687 Static,
689 Oauth2,
691 Aws,
693 Spiffe,
696}
697
698#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
700#[serde(rename_all = "snake_case")]
701pub enum OAuthGrant {
702 Device,
704 AuthorizationCode,
706 ClientCredentials,
708}
709
710#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
711#[serde(deny_unknown_fields, default)]
712pub struct Tools {
713 pub disabled: Vec<String>,
714 pub overrides: BTreeMap<String, ToolOverride>,
715}
716
717#[derive(Debug, Clone, Deserialize, PartialEq)]
718#[serde(deny_unknown_fields)]
719pub struct ToolOverride {
720 pub server: String,
721 pub tool: String,
722 #[serde(default)]
723 pub args: Option<String>,
724 #[serde(default)]
725 pub result: Option<String>,
726}
727
728#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
729#[serde(deny_unknown_fields, default)]
730pub struct Store {
731 pub kind: StoreKind,
732 pub prefix: Option<String>,
733 pub mcp: Option<StoreMcp>,
734 pub http: Option<StoreHttp>,
735 pub file: Option<StoreFile>,
736 pub checkpoint: Checkpoint,
737 pub durability: Durability,
738 pub retention: Retention,
739 pub on_error: StoreOnError,
740 pub audit: bool,
741 pub timeout: Option<Dur>,
742}
743
744impl Store {
745 pub fn prefix(&self) -> &str {
746 self.prefix.as_deref().unwrap_or("agentd")
747 }
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
751#[serde(rename_all = "lowercase")]
752pub enum StoreKind {
753 Mcp,
754 Http,
755 File,
758 Memory,
759 #[default]
760 None,
761}
762
763#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
767#[serde(deny_unknown_fields)]
768pub struct StoreFile {
769 #[serde(default)]
770 pub path: Option<String>,
771}
772
773pub fn file_store_root(store: &Store) -> std::path::PathBuf {
790 file_store_root_in(store, &|k| std::env::var_os(k))
791}
792
793fn file_store_root_in(
797 store: &Store,
798 env: &dyn Fn(&str) -> Option<std::ffi::OsString>,
799) -> std::path::PathBuf {
800 use std::path::PathBuf;
801 if let Some(p) = store.file.as_ref().and_then(|f| f.path.as_deref()) {
802 return PathBuf::from(p);
803 }
804 if let Some(d) = env("AGENTD_STATE_DIR") {
805 return PathBuf::from(d);
806 }
807 if let Some(d) = env("XDG_STATE_HOME") {
808 return PathBuf::from(d).join("agentd").join("state");
809 }
810 if let Some(h) = env("HOME") {
811 return PathBuf::from(h)
812 .join(".local")
813 .join("state")
814 .join("agentd")
815 .join("state");
816 }
817 std::env::temp_dir().join("agentd").join("state")
818}
819
820#[derive(Debug, Clone, Deserialize, PartialEq)]
821#[serde(deny_unknown_fields)]
822pub struct StoreMcp {
823 pub server: String,
824 #[serde(default)]
825 pub put: Option<StoreOp>,
826 #[serde(default)]
827 pub get: Option<StoreOp>,
828 #[serde(default)]
829 pub list: Option<StoreOp>,
830 #[serde(default)]
831 pub delete: Option<StoreOp>,
832}
833
834#[derive(Debug, Clone, Deserialize, PartialEq)]
835#[serde(deny_unknown_fields)]
836pub struct StoreOp {
837 pub tool: String,
838 #[serde(default)]
839 pub args: Option<String>,
840 #[serde(default)]
841 pub ok: Option<String>,
842 #[serde(default)]
843 pub conflict: Option<String>,
844 #[serde(default)]
845 pub value: Option<String>,
846 #[serde(default)]
847 pub keys: Option<String>,
848}
849
850#[derive(Debug, Clone, Deserialize, PartialEq)]
851#[serde(deny_unknown_fields)]
852pub struct StoreHttp {
853 pub base_url: String,
854 #[serde(default)]
855 pub headers: BTreeMap<String, String>,
856 #[serde(default)]
857 pub get: Option<HttpOp>,
858 #[serde(default)]
859 pub put: Option<HttpOp>,
860 #[serde(default)]
861 pub list: Option<HttpOp>,
862 #[serde(default)]
863 pub delete: Option<HttpOp>,
864}
865
866#[derive(Debug, Clone, Deserialize, PartialEq)]
867#[serde(deny_unknown_fields)]
868pub struct HttpOp {
869 #[serde(default)]
870 pub method: Option<String>,
871 pub url: String,
872 #[serde(default)]
873 pub body: Option<String>,
874 #[serde(default)]
875 pub value: Option<String>,
876 #[serde(default)]
877 pub keys: Option<String>,
878 #[serde(default)]
879 pub conflict_status: Option<u16>,
880}
881
882#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
883#[serde(deny_unknown_fields, default)]
884pub struct Checkpoint {
885 pub debounce_ms: Option<u64>,
886}
887
888#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
895#[serde(deny_unknown_fields, default)]
896pub struct Retention {
897 pub runs: RunRetention,
898}
899
900#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
901#[serde(deny_unknown_fields, default)]
902pub struct RunRetention {
903 pub keep_last: Option<u32>,
905 pub ttl: Option<Dur>,
907}
908
909#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
910#[serde(deny_unknown_fields, default)]
911pub struct Durability {
912 pub a2a: Option<DurabilityLevel>,
913 pub steps: Option<DurabilityLevel>,
914}
915
916#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
917#[serde(rename_all = "lowercase")]
918pub enum DurabilityLevel {
919 Strict,
920 Eventual,
921}
922
923#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
924#[serde(rename_all = "lowercase")]
925pub enum StoreOnError {
926 #[default]
927 Halt,
928 Degrade,
929}
930
931#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
932#[serde(deny_unknown_fields, default)]
933pub struct Memory {
934 pub max_value_bytes: Option<u64>,
935 pub list_default_limit: Option<u64>,
936}
937
938#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
939#[serde(deny_unknown_fields, default)]
940pub struct Context {
941 pub compact_at: Option<f64>,
942 pub keep_last: Option<u32>,
943 pub model_window: Option<u64>,
946 pub plan: Plan,
947}
948
949#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
950#[serde(deny_unknown_fields, default)]
951pub struct Plan {
952 pub max_items: Option<u32>,
953}
954
955#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
956#[serde(deny_unknown_fields, default)]
957pub struct Knowledge {
958 pub server: Option<String>,
959 pub auto_context: AutoContext,
960}
961
962#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
963#[serde(deny_unknown_fields, default)]
964pub struct AutoContext {
965 pub on: AutoContextOn,
966 pub top_k: Option<u32>,
967 pub max_bytes: Option<u64>,
968}
969
970#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
971#[serde(rename_all = "lowercase")]
972pub enum AutoContextOn {
973 Turn,
974 #[default]
975 Never,
976}
977
978#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
979#[serde(deny_unknown_fields, default)]
980pub struct Search {
981 pub server: Option<String>,
982}
983
984#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
985#[serde(deny_unknown_fields, default)]
986pub struct Skills {
987 pub sources: Vec<SkillSource>,
988 pub reference_prefix: Option<String>,
989 pub max_loaded: Option<u32>,
990 pub max_bytes: Option<u64>,
991}
992
993#[derive(Debug, Clone, Deserialize, PartialEq)]
994#[serde(deny_unknown_fields)]
995pub struct SkillSource {
996 pub server: String,
997 #[serde(default)]
998 pub discover: Discover,
999 #[serde(default)]
1000 pub filter: Option<String>,
1001}
1002
1003#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1004#[serde(rename_all = "lowercase")]
1005pub enum Discover {
1006 Prompts,
1007 Resources,
1008 #[default]
1009 Auto,
1010}
1011
1012#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1013#[serde(deny_unknown_fields, default)]
1014pub struct Limits {
1015 pub max_runs: Option<u32>,
1016 pub run: RunLimits,
1017 pub subagents: SubagentLimits,
1018 pub inline_max_bytes: Option<u64>,
1019 pub step_timeout: Option<Dur>,
1020 pub workflow: WorkflowLimits,
1021}
1022
1023#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1025#[serde(deny_unknown_fields, default)]
1026pub struct WorkflowLimits {
1027 pub fan_out: Option<u32>,
1033}
1034
1035#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1036#[serde(deny_unknown_fields, default)]
1037pub struct RunLimits {
1038 pub steps: Option<u32>,
1039 pub tokens: Option<u64>,
1040 pub deadline: Option<Dur>,
1041}
1042
1043impl RunLimits {
1044 pub fn steps(&self) -> u32 {
1045 self.steps.unwrap_or(500)
1046 }
1047 pub fn tokens(&self) -> u64 {
1048 self.tokens.unwrap_or(2_000_000)
1049 }
1050 pub fn deadline(&self) -> Duration {
1051 self.deadline
1052 .map(|d| d.0)
1053 .unwrap_or(Duration::from_secs(3600))
1054 }
1055}
1056
1057#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1058#[serde(deny_unknown_fields, default)]
1059pub struct SubagentLimits {
1060 pub depth: Option<u32>,
1061 pub breadth: Option<u32>,
1062 pub total: Option<u32>,
1063 pub rate: Option<String>,
1064}
1065
1066#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1067#[serde(deny_unknown_fields, default)]
1068pub struct Lifecycle {
1069 pub run_until: RunUntil,
1070 pub idle_grace: Option<Dur>,
1071 pub drain_timeout: Option<Dur>,
1072 pub run_id: Option<String>,
1073 pub exit_code_map: BTreeMap<String, i32>,
1074 pub watch_config: bool,
1075}
1076
1077impl Lifecycle {
1078 pub fn drain_timeout(&self) -> Duration {
1079 self.drain_timeout
1080 .map(|d| d.0)
1081 .unwrap_or(Duration::from_secs(25))
1082 }
1083 pub fn idle_grace(&self) -> Duration {
1084 self.idle_grace
1085 .map(|d| d.0)
1086 .unwrap_or(Duration::from_secs(5))
1087 }
1088}
1089
1090#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1091#[serde(rename_all = "lowercase")]
1092pub enum RunUntil {
1093 #[default]
1094 Auto,
1095 Idle,
1096 Drained,
1097}
1098
1099#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1100#[serde(deny_unknown_fields, default)]
1101pub struct A2a {
1102 pub listen: Option<String>,
1103 pub tls: A2aTls,
1104 pub bearer: Option<Secret>,
1105 pub principals: Vec<Principal>,
1106 pub peers: Vec<A2aPeer>,
1107 pub conversation_ttl: Option<Dur>,
1108 pub push: A2aPush,
1109}
1110
1111#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1121#[serde(deny_unknown_fields, default)]
1122pub struct A2aPush {
1123 pub enabled: bool,
1125 pub allow_private: bool,
1127}
1128
1129#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1130#[serde(deny_unknown_fields, default)]
1131pub struct A2aTls {
1132 pub cert: Option<String>,
1133 pub key: Option<String>,
1134 pub client_ca: Option<String>,
1135}
1136
1137#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1148#[serde(deny_unknown_fields, default)]
1149pub struct Interface {
1150 pub enabled: bool,
1152 pub debug: bool,
1156 pub origins: Vec<String>,
1159 pub display: Display,
1162 pub pairing: Pairing,
1166}
1167
1168#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1173#[serde(deny_unknown_fields, default)]
1174pub struct Display {
1175 pub top: Option<Vec<String>>,
1176 pub bottom: Option<Vec<String>>,
1177}
1178
1179pub const DISPLAY_ITEMS: &[&str] = &[
1181 "name", "version", "instance", "model", "endpoint", "conn", "debug", "draining", "active", "turns", "tokens", "tool_calls",
1193 "runs", "subagents", "conversations", "screen", "keys", "clock", ];
1200
1201#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1207#[serde(deny_unknown_fields, default)]
1208pub struct Pairing {
1209 pub enabled: bool,
1210 pub role: Option<Role>,
1213 pub ttl: Option<Dur>,
1215}
1216
1217#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1222#[serde(deny_unknown_fields, default)]
1223pub struct Webhooks {
1224 pub listen: Option<String>,
1227 pub tls: A2aTls,
1228 pub default_auth: Option<WebhookAuth>,
1230}
1231
1232#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1236#[serde(deny_unknown_fields, default)]
1237pub struct WebhookAuth {
1238 pub hmac: Option<Hmac>,
1240 pub bearer: Option<Secret>,
1242 pub header: Option<HeaderMatch>,
1244 pub none: bool,
1246}
1247
1248#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1249#[serde(deny_unknown_fields, default)]
1250pub struct Hmac {
1251 pub secret: Option<Secret>,
1252 pub header: Option<String>,
1254 pub algo: Option<String>,
1256 pub prefix: Option<String>,
1258}
1259
1260#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1261#[serde(deny_unknown_fields, default)]
1262pub struct HeaderMatch {
1263 pub name: Option<String>,
1264 pub equals: Option<Secret>,
1265}
1266
1267#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1271#[serde(deny_unknown_fields, default)]
1272pub struct Goal {
1273 pub statement: Option<String>,
1275 pub check: GoalCheck,
1276 pub stuck_after: Option<u32>,
1278 pub on_achieved: Option<GoalAction>,
1280 pub on_stuck: Option<GoalAction>,
1282}
1283
1284#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1285#[serde(deny_unknown_fields, default)]
1286pub struct GoalCheck {
1287 pub every: Option<Dur>,
1289 pub condition: Option<String>,
1291 pub via: Option<String>,
1293}
1294
1295#[derive(Debug, Clone, PartialEq)]
1298pub enum GoalAction {
1299 Finish,
1300 Idle,
1301 Replan,
1302 Escalate,
1303 Workflow(String),
1304}
1305
1306impl<'de> Deserialize<'de> for GoalAction {
1307 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1308 use serde::de::Error;
1309 match Value::deserialize(d)? {
1310 Value::String(s) => match s.as_str() {
1311 "finish" => Ok(GoalAction::Finish),
1312 "idle" => Ok(GoalAction::Idle),
1313 "replan" => Ok(GoalAction::Replan),
1314 "escalate" => Ok(GoalAction::Escalate),
1315 other => Err(D::Error::custom(format!(
1316 "unknown goal action '{other}' (want finish|idle|replan|escalate|{{workflow: <name>}})"
1317 ))),
1318 },
1319 Value::Object(m) => match m.get("workflow").and_then(Value::as_str) {
1320 Some(w) => Ok(GoalAction::Workflow(w.to_string())),
1321 None => Err(D::Error::custom(
1322 "a goal action object must be { workflow: <name> }",
1323 )),
1324 },
1325 _ => Err(D::Error::custom(
1326 "a goal action must be a string or { workflow: <name> }",
1327 )),
1328 }
1329 }
1330}
1331
1332#[derive(Debug, Clone, Deserialize, PartialEq)]
1333#[serde(deny_unknown_fields)]
1334pub struct Principal {
1335 #[serde(rename = "match")]
1336 pub matcher: PrincipalMatch,
1337 pub role: Role,
1338 #[serde(default)]
1339 pub grants: Vec<String>,
1340 #[serde(default)]
1341 pub quotas: Option<Quotas>,
1342}
1343
1344#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1345#[serde(deny_unknown_fields, default)]
1346pub struct PrincipalMatch {
1347 pub san: Option<String>,
1348 pub sub: Option<String>,
1349 pub bearer_ref: Option<String>,
1350 pub aauth_agent: Option<String>,
1351 pub any: bool,
1352}
1353
1354#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1355#[serde(rename_all = "lowercase")]
1356pub enum Role {
1357 Operator,
1358 User,
1359 Agent,
1360 Anonymous,
1361}
1362
1363#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1364#[serde(deny_unknown_fields, default)]
1365pub struct Quotas {
1366 pub rate: Option<String>,
1367 pub budget: Option<Budget>,
1368}
1369
1370#[derive(Debug, Clone, Deserialize, PartialEq)]
1371#[serde(deny_unknown_fields)]
1372pub struct A2aPeer {
1373 pub name: String,
1374 pub endpoint: String,
1375 #[serde(default)]
1376 pub headers: BTreeMap<String, String>,
1377 #[serde(default)]
1378 pub client_cert: Option<String>,
1379 #[serde(default)]
1380 pub client_key: Option<String>,
1381 #[serde(default)]
1385 pub auth: Option<Auth>,
1386}
1387
1388#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1389#[serde(deny_unknown_fields, default)]
1390pub struct Observability {
1391 pub log_level: Option<String>,
1392 pub log_content: bool,
1393 pub otel: Otel,
1394 pub metrics_addr: Option<String>,
1395 pub health_file: Option<String>,
1396 pub report_file: Option<String>,
1397 pub events_ring: Option<u32>,
1398 pub audit: Audit,
1399 pub traceparent: Option<String>,
1400}
1401
1402#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1403#[serde(deny_unknown_fields, default)]
1404pub struct Otel {
1405 pub endpoint: Option<String>,
1406 pub traces: Option<bool>,
1407 pub metrics: Option<bool>,
1408 pub logs: Option<bool>,
1409}
1410
1411#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1412#[serde(deny_unknown_fields, default)]
1413pub struct Audit {
1414 pub sink: Option<Vec<AuditSink>>,
1415}
1416
1417#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1418#[serde(rename_all = "lowercase")]
1419pub enum AuditSink {
1420 Log,
1421 Store,
1422}
1423
1424#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1425#[serde(deny_unknown_fields, default)]
1426pub struct Security {
1427 pub allow_trifecta: bool,
1428 pub tls_ca: Option<String>,
1429 pub aauth: Option<AAuth>,
1430 pub cgroup: Cgroup,
1431 pub exec: Exec,
1432}
1433
1434#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1441#[serde(deny_unknown_fields, default)]
1442pub struct Exec {
1443 pub enabled: bool,
1445 pub allow: Vec<String>,
1448 pub workdir: Option<String>,
1450 pub timeout: Option<Dur>,
1452 pub max_output: Option<u64>,
1454 pub env: Vec<String>,
1457}
1458
1459#[derive(Debug, Clone, Deserialize, PartialEq)]
1460#[serde(deny_unknown_fields)]
1461pub struct AAuth {
1462 pub provider: String,
1463 #[serde(default)]
1464 pub key_file: Option<String>,
1465 #[serde(default)]
1466 pub enroll_token: Option<Secret>,
1467 #[serde(default)]
1468 pub enroll_assertion_file: Option<String>,
1469 #[serde(default)]
1470 pub person_server: Option<String>,
1471}
1472
1473#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1474#[serde(deny_unknown_fields, default)]
1475pub struct Cgroup {
1476 pub spec: Option<String>,
1477 pub memory_max: Option<String>,
1478 pub pids_max: Option<String>,
1479}
1480
1481impl Settings {
1482 pub fn from_document(doc: Value, source: &str) -> Result<Settings, String> {
1484 serde_json::from_value(doc).map_err(|e| format!("{source} parse error: {e}"))
1485 }
1486
1487 pub fn instance_name(&self) -> String {
1490 if let Some(n) = &self.agent.name {
1491 return n.clone();
1492 }
1493 let id =
1494 crate::identity::Identity::from_env(self.lifecycle.run_id.as_deref().unwrap_or(""));
1495 if let Some(inst) = id.instance.filter(|i| !i.trim().is_empty()) {
1496 return inst;
1497 }
1498 std::env::var("HOSTNAME")
1499 .ok()
1500 .filter(|h| !h.trim().is_empty())
1501 .unwrap_or_else(|| "agentd".to_string())
1502 }
1503
1504 pub fn is_long_lived(&self) -> bool {
1515 self.a2a.listen.is_some()
1516 || self.webhooks.listen.is_some()
1517 || self.goal.is_some()
1518 || self.workflows.iter().any(workflow_is_long_lived)
1519 }
1520}
1521
1522pub const V2_KEYS: &[&str] = &[
1530 "agent",
1531 "store",
1532 "workflows",
1533 "tools",
1534 "a2a",
1535 "lifecycle",
1536 "observability",
1537 "security",
1538 "knowledge",
1539 "search",
1540 "skills",
1541 "memory",
1542 "context",
1543];
1544
1545pub const V1_KEYS: &[&str] = &[
1547 "intelligence_headers",
1548 "model_swap",
1549 "model",
1550 "max_tokens",
1551 "mcp_servers",
1552 "subscribe",
1553 "a2a_peers",
1554 "log_level",
1555];
1556
1557#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1558pub enum Detected {
1559 Empty,
1561 V1,
1563 V2,
1565 Mixed,
1567}
1568
1569pub fn detect(doc: &Value) -> Detected {
1571 let Some(obj) = doc.as_object() else {
1572 return Detected::Empty;
1573 };
1574 if obj.is_empty() {
1575 return Detected::Empty;
1576 }
1577 let version = obj.get("config_version").and_then(Value::as_str);
1578 let intel_is_object = obj.get("intelligence").is_some_and(Value::is_object);
1579 let intel_is_string = obj.get("intelligence").is_some_and(Value::is_string);
1580 let has_v2 = version == Some(schema::CONFIG_VERSION)
1581 || intel_is_object
1582 || obj.keys().any(|k| V2_KEYS.contains(&k.as_str()));
1583 let has_v1 = intel_is_string
1584 || obj.keys().any(|k| V1_KEYS.contains(&k.as_str()))
1585 || matches!(version, Some(v) if v != schema::CONFIG_VERSION);
1586 match (has_v1, has_v2) {
1587 (true, true) => Detected::Mixed,
1588 (false, true) => Detected::V2,
1589 (true, false) => Detected::V1,
1590 (false, false) => Detected::V1,
1594 }
1595}
1596
1597#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1603pub enum AliasKind {
1604 Set,
1606 SetTrue,
1608 Append,
1610 SetFromFile,
1612 Special,
1614}
1615
1616#[derive(Debug, Clone, Copy)]
1618pub struct Alias {
1619 pub flag: &'static str,
1620 pub path: &'static str,
1621 pub kind: AliasKind,
1622}
1623
1624pub const ALIASES: &[Alias] = &[
1627 Alias {
1628 flag: "--instruction",
1629 path: "agent.instruction",
1630 kind: AliasKind::Set,
1631 },
1632 Alias {
1633 flag: "--instruction-file",
1634 path: "agent.instruction",
1635 kind: AliasKind::SetFromFile,
1636 },
1637 Alias {
1638 flag: "--prompt",
1639 path: "agent.prompt",
1640 kind: AliasKind::Set,
1641 },
1642 Alias {
1643 flag: "--prompt-file",
1644 path: "agent.prompt",
1645 kind: AliasKind::SetFromFile,
1646 },
1647 Alias {
1648 flag: "--intelligence",
1649 path: "intelligence.endpoints",
1650 kind: AliasKind::Set,
1651 },
1652 Alias {
1653 flag: "--intelligence-token",
1654 path: "intelligence.token",
1655 kind: AliasKind::Set,
1656 },
1657 Alias {
1658 flag: "--intelligence-token-file",
1659 path: "intelligence.token_file",
1660 kind: AliasKind::Set,
1661 },
1662 Alias {
1663 flag: "--model",
1664 path: "intelligence.model",
1665 kind: AliasKind::Set,
1666 },
1667 Alias {
1668 flag: "--model-swap",
1669 path: "intelligence.swap_policy",
1670 kind: AliasKind::Set,
1671 },
1672 Alias {
1673 flag: "--budget-tokens-lifetime",
1674 path: "intelligence.budget.lifetime_tokens",
1675 kind: AliasKind::Set,
1676 },
1677 Alias {
1678 flag: "--mcp",
1679 path: "mcp.servers",
1680 kind: AliasKind::Append,
1681 },
1682 Alias {
1683 flag: "--mcp-tags",
1684 path: "mcp.servers",
1685 kind: AliasKind::Special,
1686 },
1687 Alias {
1688 flag: "--a2a-peer",
1689 path: "a2a.peers",
1690 kind: AliasKind::Append,
1691 },
1692 Alias {
1693 flag: "--workflow",
1694 path: "workflows",
1695 kind: AliasKind::Append,
1696 },
1697 Alias {
1698 flag: "--max-steps",
1699 path: "limits.run.steps",
1700 kind: AliasKind::Set,
1701 },
1702 Alias {
1703 flag: "--max-tokens",
1704 path: "limits.run.tokens",
1705 kind: AliasKind::Set,
1706 },
1707 Alias {
1708 flag: "--deadline",
1709 path: "limits.run.deadline",
1710 kind: AliasKind::Set,
1711 },
1712 Alias {
1713 flag: "--max-depth",
1714 path: "limits.subagents.depth",
1715 kind: AliasKind::Set,
1716 },
1717 Alias {
1718 flag: "--run-id",
1719 path: "lifecycle.run_id",
1720 kind: AliasKind::Set,
1721 },
1722 Alias {
1723 flag: "--drain-timeout",
1724 path: "lifecycle.drain_timeout",
1725 kind: AliasKind::Set,
1726 },
1727 Alias {
1728 flag: "--watch-config",
1729 path: "lifecycle.watch_config",
1730 kind: AliasKind::SetTrue,
1731 },
1732 Alias {
1733 flag: "--budget-exit-code",
1734 path: "lifecycle.exit_code_map",
1735 kind: AliasKind::Special,
1736 },
1737 Alias {
1738 flag: "--listen",
1739 path: "a2a.listen",
1740 kind: AliasKind::Set,
1741 },
1742 Alias {
1743 flag: "--serve-mcp",
1744 path: "a2a.listen",
1745 kind: AliasKind::Set,
1746 },
1747 Alias {
1748 flag: "--serve-cert",
1749 path: "a2a.tls.cert",
1750 kind: AliasKind::Set,
1751 },
1752 Alias {
1753 flag: "--serve-key",
1754 path: "a2a.tls.key",
1755 kind: AliasKind::Set,
1756 },
1757 Alias {
1758 flag: "--serve-client-ca",
1759 path: "a2a.tls.client_ca",
1760 kind: AliasKind::Set,
1761 },
1762 Alias {
1763 flag: "--serve-bearer",
1764 path: "a2a.bearer",
1765 kind: AliasKind::Set,
1766 },
1767 Alias {
1768 flag: "--log-level",
1769 path: "observability.log_level",
1770 kind: AliasKind::Set,
1771 },
1772 Alias {
1773 flag: "--log-content",
1774 path: "observability.log_content",
1775 kind: AliasKind::SetTrue,
1776 },
1777 Alias {
1778 flag: "--metrics-addr",
1779 path: "observability.metrics_addr",
1780 kind: AliasKind::Set,
1781 },
1782 Alias {
1783 flag: "--health-file",
1784 path: "observability.health_file",
1785 kind: AliasKind::Set,
1786 },
1787 Alias {
1788 flag: "--report-file",
1789 path: "observability.report_file",
1790 kind: AliasKind::Set,
1791 },
1792 Alias {
1793 flag: "--events-ring",
1794 path: "observability.events_ring",
1795 kind: AliasKind::Set,
1796 },
1797 Alias {
1798 flag: "--traceparent",
1799 path: "observability.traceparent",
1800 kind: AliasKind::Set,
1801 },
1802 Alias {
1803 flag: "--allow-trifecta",
1804 path: "security.allow_trifecta",
1805 kind: AliasKind::SetTrue,
1806 },
1807 Alias {
1808 flag: "--tls-ca",
1809 path: "security.tls_ca",
1810 kind: AliasKind::Set,
1811 },
1812 Alias {
1813 flag: "--aauth-provider",
1814 path: "security.aauth.provider",
1815 kind: AliasKind::Set,
1816 },
1817 Alias {
1818 flag: "--aauth-key-file",
1819 path: "security.aauth.key_file",
1820 kind: AliasKind::Set,
1821 },
1822 Alias {
1823 flag: "--aauth-enroll-token",
1824 path: "security.aauth.enroll_token",
1825 kind: AliasKind::Set,
1826 },
1827 Alias {
1828 flag: "--aauth-enroll-assertion-file",
1829 path: "security.aauth.enroll_assertion_file",
1830 kind: AliasKind::Set,
1831 },
1832 Alias {
1833 flag: "--aauth-person-server",
1834 path: "security.aauth.person_server",
1835 kind: AliasKind::Set,
1836 },
1837 Alias {
1838 flag: "--cgroup",
1839 path: "security.cgroup.spec",
1840 kind: AliasKind::Set,
1841 },
1842 Alias {
1843 flag: "--cgroup-memory-max",
1844 path: "security.cgroup.memory_max",
1845 kind: AliasKind::Set,
1846 },
1847 Alias {
1848 flag: "--cgroup-pids-max",
1849 path: "security.cgroup.pids_max",
1850 kind: AliasKind::Set,
1851 },
1852];
1853
1854pub const ENV_ALIASES: &[(&str, &str)] = &[
1858 ("INSTRUCTION", "agent.instruction"),
1859 ("PROMPT", "agent.prompt"),
1860 ("INTELLIGENCE", "intelligence.endpoints"),
1861 ("INTELLIGENCE_TOKEN", "intelligence.token"),
1862 ("INTELLIGENCE_TOKEN_FILE", "intelligence.token_file"),
1863 ("MODEL", "intelligence.model"),
1864 ("MODEL_SWAP", "intelligence.swap_policy"),
1865 ("BUDGET_TOKENS", "intelligence.budget.lifetime_tokens"),
1866 ("MAX_STEPS", "limits.run.steps"),
1867 ("MAX_TOKENS", "limits.run.tokens"),
1868 ("DEADLINE", "limits.run.deadline"),
1869 ("RUN_ID", "lifecycle.run_id"),
1870 ("DRAIN_TIMEOUT", "lifecycle.drain_timeout"),
1871 ("LOG_LEVEL", "observability.log_level"),
1872 ("LOG_CONTENT", "observability.log_content"),
1873 ("METRICS_ADDR", "observability.metrics_addr"),
1874 ("TRACEPARENT", "observability.traceparent"),
1875 ("SERVE_MCP", "a2a.listen"),
1876 ("SERVE_BEARER", "a2a.bearer"),
1877 ("TLS_CA", "security.tls_ca"),
1878 ("ALLOW_TRIFECTA", "security.allow_trifecta"),
1879 ("WATCH_CONFIG", "lifecycle.watch_config"),
1880];
1881
1882pub const REMOVED_FLAGS: &[(&str, &str)] = &[
1884 (
1885 "--mode",
1886 "modes are gone: give the workflow a start node (`once` | `loop` | `schedule` | `subscribe` | `signal` | `event` | `a2a` | `manual`) and set `lifecycle.run_until` if needed",
1887 ),
1888 (
1889 "--subscribe",
1890 "use a `subscribe` start node: `{kind: subscribe, server: <name>, uri: <uri>}`",
1891 ),
1892 (
1893 "--continue",
1894 "use a `subscribe` start node with `deliver: wait` (or a warm subagent)",
1895 ),
1896 (
1897 "--interval",
1898 "use a `loop` start node with `interval`, or a `schedule` start node with `every`",
1899 ),
1900 ("--cron", "use a `schedule` start node with `cron`"),
1901 (
1905 "--shard",
1906 "agentd does not partition work; give each replica its own subscription (docs/scaling.md)",
1907 ),
1908 (
1909 "--claim",
1910 "call the queue's own claim/lease tools from a workflow step (docs/scaling.md §2c)",
1911 ),
1912 ("--claim-ttl", "it went with --claim"),
1913 ("--claim-renew-fraction", "it went with --claim"),
1914 (
1915 "--standby",
1916 "there is no standby pool; a worker replica is an ordinary instance with its own subscription",
1917 ),
1918 ("--assign-from", "it went with --standby"),
1919 (
1920 "--workflow-resume",
1921 "automatic: runs resume from the store on restart (`resume_policy` per workflow)",
1922 ),
1923 (
1924 "--workflow-resume-force",
1925 "set `resume_policy: force` on the workflow",
1926 ),
1927];
1928
1929#[derive(Debug, Clone)]
1937pub struct Loaded {
1938 pub settings: Settings,
1939 pub doc: Value,
1941 pub file_doc: Value,
1943 pub files: Vec<(String, Format)>,
1944 pub warnings: Vec<String>,
1947}
1948
1949#[derive(Debug, Clone, PartialEq, Eq)]
1952pub enum Ask {
1953 Run,
1954 Help,
1955 Version,
1956 Schema,
1957 WorkflowSchema,
1958 Validate,
1959 Capabilities,
1960 Login(String),
1963 Logout(String),
1965}
1966
1967pub fn probe(args: &[String], env: &[(String, String)]) -> Result<Detected, ConfigError> {
1970 let env = super::debrand_env(env);
1971 let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
1972 let flag_v2 = args
1975 .windows(2)
1976 .any(|w| matches!(w[0].as_str(), "--config-version" | "--config_version") && w[1] == "2")
1977 || args
1978 .iter()
1979 .any(|a| a == "--config-version=2" || a == "--config_version=2")
1980 || envmap
1981 .get("AGENTD_CONFIG_VERSION")
1982 .or_else(|| envmap.get("CONFIG_VERSION"))
1983 .is_some_and(|v| *v == "2");
1984 let paths = super::config_paths_from_map(args, &envmap).paths;
1985 if paths.is_empty() {
1986 return Ok(if flag_v2 {
1987 Detected::V2
1988 } else {
1989 Detected::Empty
1990 });
1991 }
1992 let (doc, _) = file::read_documents_checked(&paths, &|_, _| Ok(())).map_err(usage)?;
1993 let d = detect(&doc);
1994 Ok(match (d, flag_v2) {
1995 (Detected::Empty, true) => Detected::V2,
1996 (Detected::V1, true) => Detected::Mixed,
1997 (d, _) => d,
1998 })
1999}
2000
2001pub fn load(args: &[String], env: &[(String, String)]) -> Result<(Loaded, Ask), ConfigError> {
2006 let env = super::debrand_env(env);
2007 let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
2008 let schema = schema::schema();
2009 let bindings = paths::bindings_of(&schema);
2010 let mut warnings = Vec::new();
2011
2012 let super::ConfigPaths {
2014 paths: config_paths,
2015 discovered,
2016 } = super::config_paths_from_map(args, &envmap);
2017 if discovered && config_paths.len() > 1 {
2023 return Err(usage(format!(
2024 "both {} and {} are present; keep one (or name the file with --config)",
2025 super::DISCOVERED_CONFIG_NAMES[0],
2026 super::DISCOVERED_CONFIG_NAMES[1]
2027 )));
2028 }
2029 let (file_doc, files) = if config_paths.is_empty() {
2030 (Value::Object(Map::new()), Vec::new())
2031 } else {
2032 file::read_documents_checked(&config_paths, &|doc, source| {
2033 match detect(doc) {
2036 Detected::V2 | Detected::Empty => {
2037 Settings::from_document(doc.clone(), source).map(|_| ())
2038 }
2039 _ => Ok(()),
2040 }
2041 })
2042 .map_err(usage)?
2043 };
2044 match detect(&file_doc) {
2045 Detected::Mixed => {
2046 return Err(usage(
2047 "config file mixes v1 keys (model/subscribe/mcp_servers/…) with v2 sections (agent/intelligence/…); \
2048 migrate the v1 keys (docs/configuration.md §migration)"
2049 .into(),
2050 ));
2051 }
2052 Detected::V1 => {
2053 return Err(usage(
2054 "config file speaks the v1 schema; the 2.0 loader needs `config_version: \"2\"` or v2 sections".into(),
2055 ));
2056 }
2057 _ => {}
2058 }
2059 if discovered {
2067 let file = config_paths.first().map_or("", String::as_str);
2068 if let Some((_, label)) = DISCOVERY_FORBIDDEN_RELAXATIONS
2069 .iter()
2070 .find(|(ptr, _)| file_doc.pointer(ptr).and_then(Value::as_bool) == Some(true))
2071 {
2072 return Err(usage(format!(
2073 "{file} was discovered, not named, and it sets {label}: a config found in the \
2074 working directory may not relax a security control. Pass `--config {file}` if \
2075 you meant to run under that file's grant."
2076 )));
2077 }
2078 let touched = discovered_security_settings(&file_doc);
2082 if !touched.is_empty() {
2083 warnings.push(format!(
2084 "adopted the discovered config {file} (no --config given); it sets {}",
2085 touched.join(", ")
2086 ));
2087 }
2088 }
2089 let mut doc = file_doc.clone();
2090
2091 let mut env_doc = Value::Object(Map::new());
2093 for (name, path) in ENV_ALIASES {
2094 let candidates = [
2095 format!("AGENTD_{name}"),
2096 format!("AGENT_{name}"),
2097 (*name).to_string(),
2098 ];
2099 if let Some(raw) = candidates.iter().find_map(|k| envmap.get(k.as_str())) {
2100 let binding = binding_for(&bindings, path)
2101 .ok_or_else(|| usage(format!("internal: alias path {path} not in schema")))?;
2102 let v = binding
2103 .coerce(raw)
2104 .map_err(|e| usage(format!("invalid {}: {e}", candidates[0])))?;
2105 paths::set_path(&mut env_doc, path, v);
2106 }
2107 }
2108 let (derived, _applied) = paths::env_document_in(&bindings, &envmap).map_err(usage)?;
2109 file::merge_into(&mut env_doc, derived);
2110 file::merge_into(&mut doc, env_doc);
2111
2112 let mut ask = Ask::Run;
2114 let mut mcp_tags: Vec<(String, Vec<String>)> = Vec::new();
2115 let mut it = args.iter().peekable();
2116 while let Some(arg) = it.next() {
2117 let a = arg.as_str();
2118 match a {
2119 "-h" | "--help" => ask = Ask::Help,
2120 "-V" | "--version" => ask = Ask::Version,
2121 "--config-schema" | "--config-schema=2" => ask = Ask::Schema,
2122 "--workflow-schema" => ask = Ask::WorkflowSchema,
2123 "--validate-config" => ask = Ask::Validate,
2124 "--capabilities" => ask = Ask::Capabilities,
2125 "--login" => {
2126 let t = it
2127 .next()
2128 .cloned()
2129 .ok_or_else(|| usage("--login requires a target (e.g. mcp:<name>)".into()))?;
2130 ask = Ask::Login(t);
2131 }
2132 "--logout" => {
2133 let t = it
2134 .next()
2135 .cloned()
2136 .ok_or_else(|| usage("--logout requires a target (e.g. mcp:<name>)".into()))?;
2137 ask = Ask::Logout(t);
2138 }
2139 "--config" | "-c" => {
2140 it.next(); }
2142 _ if matches!(
2144 crate::config::config_flag(a),
2145 crate::config::ConfigFlag::Inline(_)
2146 ) => {}
2147 _ => {
2148 if let Some((flag, hint)) = REMOVED_FLAGS.iter().find(|(f, _)| *f == a) {
2149 return Err(usage(format!("{flag} was removed in agentd 2.0: {hint}")));
2150 }
2151 if let Some(alias) = ALIASES.iter().find(|al| al.flag == a) {
2152 apply_alias(&mut doc, &bindings, alias, &mut it, &mut mcp_tags)?;
2153 continue;
2154 }
2155 match paths::resolve_flag_in(&bindings, a).map_err(usage)? {
2156 Some(target) => {
2157 let raw = if matches!(target.value_kind(), paths::Kind::Boolean)
2158 && !it.peek().is_some_and(|n| !n.starts_with("--"))
2159 {
2160 "true".to_string()
2161 } else {
2162 it.next()
2163 .cloned()
2164 .ok_or_else(|| usage(format!("{a} requires a value")))?
2165 };
2166 let value = paths::coerce(target.value_kind(), &raw)
2167 .map_err(|e| usage(format!("invalid {a}: {e}")))?;
2168 file::merge_into(&mut doc, target.document(value));
2169 }
2170 None => return Err(usage(format!("unknown argument: {a}"))),
2171 }
2172 }
2173 }
2174 }
2175 for (name, tags) in mcp_tags {
2177 let Some(servers) = doc
2178 .pointer_mut("/mcp/servers")
2179 .and_then(Value::as_array_mut)
2180 else {
2181 return Err(usage(format!(
2182 "--mcp-tags references unknown server '{name}'"
2183 )));
2184 };
2185 match servers
2186 .iter_mut()
2187 .find(|s| s.get("name").and_then(Value::as_str) == Some(name.as_str()))
2188 {
2189 Some(s) => {
2190 s["tags"] = json!({ "*": tags });
2191 }
2192 None => {
2193 return Err(usage(format!(
2194 "--mcp-tags references unknown server '{name}'"
2195 )));
2196 }
2197 }
2198 }
2199
2200 if ask == Ask::Run || ask == Ask::Validate {
2202 apply_instruction_sugar(&mut doc);
2203 }
2204
2205 if let Err(e) = substitute_env(&mut doc, &envmap) {
2209 return Err(usage(e));
2210 }
2211
2212 let mut settings = Settings::from_document(doc.clone(), "config").map_err(usage)?;
2214 if doc.pointer("/store/kind").is_none() && settings.is_long_lived() {
2234 settings.store.kind = StoreKind::File;
2235 }
2236 let mut loaded = Loaded {
2237 settings,
2238 doc,
2239 file_doc,
2240 files,
2241 warnings: Vec::new(),
2242 };
2243 let diags = validate(&loaded);
2244 warnings.extend(diags.warnings);
2245 loaded.warnings = warnings;
2246 if ask != Ask::Validate
2247 && ask != Ask::Help
2248 && ask != Ask::Version
2249 && ask != Ask::Schema
2250 && ask != Ask::WorkflowSchema
2251 && !matches!(ask, Ask::Login(_) | Ask::Logout(_))
2252 && let Some(first) = diags.errors.first()
2253 {
2254 return Err(usage(first.clone()));
2255 }
2256 if ask == Ask::Validate && !diags.errors.is_empty() {
2257 return Err(ConfigError::Validate(Err(diags
2258 .errors
2259 .iter()
2260 .map(|d| super::config_invalid_line(d))
2261 .collect::<Vec<_>>()
2262 .join("\n"))));
2263 }
2264 Ok((loaded, ask))
2265}
2266
2267const DISCOVERY_FORBIDDEN_RELAXATIONS: [(&str, &str); 2] = [
2274 ("/security/allow_trifecta", "security.allow_trifecta"),
2275 ("/security/exec/enabled", "security.exec.enabled"),
2276];
2277
2278const DISCOVERY_SECURITY_SETTINGS: [(&str, &str); 12] = [
2285 ("/intelligence/endpoints", "intelligence.endpoints"),
2286 ("/intelligence/token", "intelligence.token"),
2287 ("/intelligence/token_file", "intelligence.token_file"),
2288 ("/intelligence/headers", "intelligence.headers"),
2289 ("/intelligence/auth", "intelligence.auth"),
2290 ("/mcp/servers", "mcp.servers"),
2291 ("/tools/overrides", "tools.overrides"),
2292 ("/store", "store"),
2293 ("/a2a/listen", "a2a.listen"),
2294 ("/a2a/peers", "a2a.peers"),
2295 ("/webhooks/listen", "webhooks.listen"),
2296 ("/security", "security"),
2297];
2298
2299fn discovered_security_settings(file_doc: &Value) -> Vec<&'static str> {
2302 DISCOVERY_SECURITY_SETTINGS
2303 .iter()
2304 .filter(|(ptr, _)| file_doc.pointer(ptr).is_some_and(|v| !v.is_null()))
2305 .map(|(_, label)| *label)
2306 .collect()
2307}
2308
2309fn binding_for<'a>(bindings: &'a [Binding], path: &str) -> Option<&'a Binding> {
2310 bindings.iter().find(|b| b.path == path)
2311}
2312
2313fn apply_alias(
2314 doc: &mut Value,
2315 bindings: &[Binding],
2316 alias: &Alias,
2317 it: &mut std::iter::Peekable<std::slice::Iter<'_, String>>,
2318 mcp_tags: &mut Vec<(String, Vec<String>)>,
2319) -> Result<(), ConfigError> {
2320 let mut take = || -> Result<String, ConfigError> {
2321 it.next()
2322 .cloned()
2323 .ok_or_else(|| usage(format!("{} requires a value", alias.flag)))
2324 };
2325 match alias.kind {
2326 AliasKind::Set => {
2327 let raw = take()?;
2328 let b = binding_for(bindings, alias.path).ok_or_else(|| {
2329 usage(format!("internal: alias path {} not in schema", alias.path))
2330 })?;
2331 let v = b
2332 .coerce(&raw)
2333 .map_err(|e| usage(format!("invalid {}: {e}", alias.flag)))?;
2334 let mut patch = Value::Object(Map::new());
2335 paths::set_path(&mut patch, alias.path, v);
2336 file::merge_into(doc, patch);
2337 }
2338 AliasKind::SetTrue => {
2339 let mut patch = Value::Object(Map::new());
2340 paths::set_path(&mut patch, alias.path, Value::Bool(true));
2341 file::merge_into(doc, patch);
2342 }
2343 AliasKind::SetFromFile => {
2344 let path = take()?;
2345 let text = super::read_file(&path)?;
2346 let mut patch = Value::Object(Map::new());
2347 paths::set_path(&mut patch, alias.path, Value::String(text));
2348 file::merge_into(doc, patch);
2349 }
2350 AliasKind::Append => {
2351 let raw = take()?;
2352 let element = match alias.flag {
2353 "--mcp" => {
2354 let (name, endpoint) = raw
2355 .split_once('=')
2356 .ok_or_else(|| usage(format!("--mcp: want name=endpoint (got: {raw})")))?;
2357 json!({ "name": name.trim(), "endpoint": endpoint.trim() })
2358 }
2359 "--a2a-peer" => {
2360 let (name, endpoint) = raw.split_once('=').ok_or_else(|| {
2361 usage(format!("--a2a-peer: want name=endpoint (got: {raw})"))
2362 })?;
2363 json!({ "name": name.trim(), "endpoint": endpoint.trim() })
2364 }
2365 "--workflow" => {
2366 let name = std::path::Path::new(&raw)
2367 .file_stem()
2368 .and_then(|s| s.to_str())
2369 .unwrap_or("workflow")
2370 .to_string();
2371 json!({ "name": name, "file": raw })
2372 }
2373 other => return Err(usage(format!("internal: no append rule for {other}"))),
2374 };
2375 append_at(doc, alias.path, element);
2376 }
2377 AliasKind::Special => match alias.flag {
2378 "--mcp-tags" => {
2379 let raw = take()?;
2380 let (name, tags) = raw
2381 .split_once('=')
2382 .ok_or_else(|| usage(format!("--mcp-tags: want name=tag,tag (got: {raw})")))?;
2383 mcp_tags.push((
2384 name.trim().to_string(),
2385 tags.split(',')
2386 .map(str::trim)
2387 .filter(|t| !t.is_empty())
2388 .map(str::to_string)
2389 .collect(),
2390 ));
2391 }
2392 "--budget-exit-code" => {
2393 let raw = take()?;
2394 let n: i64 = raw
2395 .trim()
2396 .parse()
2397 .ok()
2398 .filter(|n| (0..=255).contains(n))
2399 .ok_or_else(|| {
2400 usage(format!("invalid --budget-exit-code: {raw} (want 0..=255)"))
2401 })?;
2402 let mut patch = Value::Object(Map::new());
2403 paths::set_path(
2404 &mut patch,
2405 "lifecycle.exit_code_map",
2406 json!({ "3": n, "7": n }),
2407 );
2408 file::merge_into(doc, patch);
2409 }
2410 other => return Err(usage(format!("internal: no special rule for {other}"))),
2411 },
2412 }
2413 Ok(())
2414}
2415
2416fn append_at(doc: &mut Value, path: &str, element: Value) {
2418 let pointer = format!("/{}", path.replace('.', "/"));
2419 if doc.pointer(&pointer).is_none() {
2420 let mut patch = Value::Object(Map::new());
2421 paths::set_path(&mut patch, path, Value::Array(Vec::new()));
2422 file::merge_into(doc, patch);
2423 }
2424 if let Some(arr) = doc.pointer_mut(&pointer) {
2425 if !arr.is_array() {
2426 *arr = Value::Array(Vec::new());
2427 }
2428 arr.as_array_mut().expect("array").push(element);
2429 }
2430}
2431
2432fn apply_instruction_sugar(doc: &mut Value) {
2442 let has_workflows = doc
2443 .pointer("/workflows")
2444 .and_then(Value::as_array)
2445 .is_some_and(|w| !w.is_empty());
2446 let nonblank = |p: &str| {
2447 doc.pointer(p)
2448 .and_then(Value::as_str)
2449 .is_some_and(|s| !s.trim().is_empty())
2450 };
2451 let has_instruction = nonblank("/agent/instruction");
2452 if has_workflows || !has_instruction || nonblank("/agent/prompt") {
2455 return;
2456 }
2457 let work = json!({
2458 "kind": "agent",
2459 "depends_on": ["start"],
2460 "instruction": "{{env.instruction}}",
2461 });
2462 let mut patch = Value::Object(Map::new());
2463 paths::set_path(
2464 &mut patch,
2465 "workflows",
2466 json!([{
2467 "name": "main",
2468 "version": 3,
2469 "steps": {
2470 "start": { "kind": "once" },
2471 "work": work,
2472 "done": { "kind": "finish", "depends_on": ["work"], "status": "completed", "output": "{{steps.work.output}}" }
2473 }
2474 }]),
2475 );
2476 file::merge_into(doc, patch);
2477}
2478
2479fn substitute_env(v: &mut Value, env: &HashMap<&str, &str>) -> Result<(), String> {
2489 match v {
2490 Value::String(s) => {
2491 if s.as_bytes().contains(&b'$') {
2492 *s = expand_env_str(s, env)?;
2493 }
2494 Ok(())
2495 }
2496 Value::Array(a) => a.iter_mut().try_for_each(|item| substitute_env(item, env)),
2497 Value::Object(m) => m.values_mut().try_for_each(|val| substitute_env(val, env)),
2498 _ => Ok(()),
2499 }
2500}
2501
2502fn expand_env_str(s: &str, env: &HashMap<&str, &str>) -> Result<String, String> {
2504 let mut out = String::with_capacity(s.len());
2505 let b = s.as_bytes();
2506 let mut i = 0;
2507 while i < b.len() {
2508 if b[i] == b'$' {
2512 if b.get(i + 1) == Some(&b'$') {
2513 out.push('$'); i += 2;
2515 continue;
2516 }
2517 if b.get(i + 1) == Some(&b'{') {
2518 let start = i + 2;
2519 let Some(rel) = s[start..].find('}') else {
2520 return Err(format!("unterminated `${{` in config value {s:?}"));
2521 };
2522 let end = start + rel;
2523 let expr = &s[start..end];
2524 let (name, default) = match expr.split_once(":-") {
2525 Some((n, d)) => (n.trim(), Some(d)),
2526 None => (expr.trim(), None),
2527 };
2528 if name.is_empty() {
2529 return Err(format!("empty `${{}}` reference in config value {s:?}"));
2530 }
2531 if !name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') {
2532 return Err(format!(
2533 "invalid environment variable name {name:?} in `${{{expr}}}`"
2534 ));
2535 }
2536 match env.get(name) {
2537 Some(val) => out.push_str(val),
2538 None => match default {
2539 Some(d) => out.push_str(d),
2540 None => {
2541 return Err(format!(
2542 "environment variable ${{{name}}} is not set (referenced in config); \
2543 set it or write ${{{name}:-default}}"
2544 ));
2545 }
2546 },
2547 }
2548 i = end + 1;
2549 continue;
2550 }
2551 }
2552 let ch = s[i..].chars().next().unwrap();
2553 out.push(ch);
2554 i += ch.len_utf8();
2555 }
2556 Ok(out)
2557}
2558
2559#[derive(Debug, Default, Clone)]
2566pub struct Diagnostics {
2567 pub errors: Vec<String>,
2568 pub warnings: Vec<String>,
2569}
2570
2571fn validate_auth_block(auth: &Auth, ctx: &str) -> Vec<String> {
2577 let mut out = Vec::new();
2578 for (field, s) in [
2580 ("client_secret", &auth.client_secret),
2581 ("token", &auth.token),
2582 ("value", &auth.value),
2583 ] {
2584 if let Some(sec) = s
2585 && !sec.0.trim().is_empty()
2586 && !crate::sec::secret::has_secret_ref(&sec.0)
2587 {
2588 out.push(format!(
2589 "{ctx}: auth.{field} carries an inline credential; use a {{{{secret:…}}}} reference"
2590 ));
2591 }
2592 }
2593 match auth.kind {
2594 AuthKind::Static => {
2595 let has_bearer = auth.token.is_some();
2596 let has_header = auth.header.is_some() && auth.value.is_some();
2597 if !has_bearer && !has_header {
2598 out.push(format!(
2599 "{ctx}: auth.kind static needs `token` (a bearer) or `header` + `value`"
2600 ));
2601 }
2602 }
2603 AuthKind::Aws => {
2604 if auth.region.is_none() {
2605 out.push(format!("{ctx}: auth.kind aws needs `region`"));
2606 }
2607 if auth.service.is_none() {
2608 out.push(format!(
2609 "{ctx}: auth.kind aws needs `service` (e.g. bedrock, execute-api)"
2610 ));
2611 }
2612 match auth.source.as_deref() {
2613 Some("sso") => {
2614 if auth.sso_start_url.is_none()
2615 || auth.account_id.is_none()
2616 || auth.role_name.is_none()
2617 {
2618 out.push(format!(
2619 "{ctx}: aws source sso needs `sso_start_url` + `account_id` + `role_name`"
2620 ));
2621 }
2622 }
2623 Some(src) if !matches!(src, "env" | "static" | "imds" | "irsa") => {
2624 out.push(format!(
2625 "{ctx}: auth.source '{src}' is not a known AWS source (env|static|imds|irsa|sso)"
2626 ));
2627 }
2628 _ => {}
2629 }
2630 }
2631 AuthKind::Spiffe => match auth.svid.as_deref().unwrap_or("jwt") {
2632 "jwt" => {
2633 if auth.jwt_svid_file.is_none() {
2634 out.push(format!(
2635 "{ctx}: auth.kind spiffe (svid jwt) needs `jwt_svid_file`"
2636 ));
2637 }
2638 }
2639 "x509" => {
2640 if auth.svid_file.is_none() || auth.key_file.is_none() {
2641 out.push(format!(
2642 "{ctx}: auth.kind spiffe (svid x509) needs `svid_file` + `key_file`"
2643 ));
2644 }
2645 }
2646 other => out.push(format!("{ctx}: auth.svid '{other}' (want jwt|x509)")),
2647 },
2648 AuthKind::Oauth2 => {
2649 if auth.client_id.is_none() {
2650 out.push(format!("{ctx}: auth.kind oauth2 needs `client_id`"));
2651 }
2652 if auth.token_url.is_none() && auth.issuer.is_none() {
2653 out.push(format!(
2654 "{ctx}: auth oauth2 needs `token_url` or `issuer` (for discovery)"
2655 ));
2656 }
2657 match auth.grant.unwrap_or(OAuthGrant::Device) {
2658 OAuthGrant::Device => {
2659 if auth.device_authorization_url.is_none() && auth.issuer.is_none() {
2660 out.push(format!(
2661 "{ctx}: the device grant needs `device_authorization_url` or `issuer`"
2662 ));
2663 }
2664 }
2665 OAuthGrant::ClientCredentials => {
2666 if auth.client_secret.is_none() {
2667 out.push(format!(
2668 "{ctx}: the client_credentials grant needs `client_secret`"
2669 ));
2670 }
2671 }
2672 OAuthGrant::AuthorizationCode => {
2673 if auth.authorization_url.is_none() && auth.issuer.is_none() {
2674 out.push(format!(
2675 "{ctx}: the authorization_code grant needs `authorization_url` or `issuer`"
2676 ));
2677 }
2678 }
2679 }
2680 }
2681 }
2682 out
2683}
2684
2685fn unresolved_secret_ref(value: &str) -> Option<String> {
2696 if !crate::sec::secret::has_secret_ref(value) {
2697 return None;
2698 }
2699 crate::sec::secret::refs_resolvable(value, &|k| std::env::var(k).ok()).err()
2700}
2701
2702pub fn validate(loaded: &Loaded) -> Diagnostics {
2703 let s = &loaded.settings;
2704 let mut d = Diagnostics::default();
2705 let err = |d: &mut Diagnostics, m: String| d.errors.push(m);
2706
2707 if let Some(v) = &s.config_version
2709 && v != schema::CONFIG_VERSION
2710 {
2711 err(
2712 &mut d,
2713 format!(
2714 "config_version must be \"{}\" (got {v:?})",
2715 schema::CONFIG_VERSION
2716 ),
2717 );
2718 }
2719
2720 for e in &s.intelligence.endpoints {
2722 if let Err(e) = super::validate_intelligence_uri(e) {
2723 err(&mut d, e.to_string());
2724 }
2725 }
2726 if let Some(p) = &s.intelligence.swap_policy
2727 && super::SwapPolicy::parse(p).is_none()
2728 {
2729 err(
2730 &mut d,
2731 format!("intelligence.swap_policy: {p:?} (want finish-on-old|restart-turn)"),
2732 );
2733 }
2734 if s.intelligence.token.is_some() && s.intelligence.token_file.is_some() {
2735 d.warnings.push(
2736 "intelligence.token and intelligence.token_file are both set; the inline token wins"
2737 .into(),
2738 );
2739 }
2740 if let Some(auth) = &s.intelligence.auth {
2741 for e in validate_auth_block(auth, "intelligence") {
2742 err(&mut d, e);
2743 }
2744 }
2745 if let Some(dialect) = &s.intelligence.dialect {
2746 if crate::intel::client::Provider::from_dialect(Some(dialect)).is_none() {
2747 err(
2748 &mut d,
2749 format!("intelligence.dialect: {dialect:?} (want openai|anthropic|bedrock)"),
2750 );
2751 }
2752 if dialect == "bedrock"
2755 && !matches!(
2756 s.intelligence.auth.as_ref().map(|a| a.kind),
2757 Some(AuthKind::Aws)
2758 )
2759 {
2760 err(
2761 &mut d,
2762 "intelligence.dialect: bedrock requires intelligence.auth.kind = aws (SigV4)"
2763 .into(),
2764 );
2765 }
2766 }
2767 validate_budget(&s.intelligence.budget, "intelligence.budget", &mut d);
2768 if let Some(b) = &s.agent.conversation_budget {
2769 validate_budget(b, "agent.conversation_budget", &mut d);
2770 }
2771 for (name, value) in &s.intelligence.headers {
2772 if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
2773 err(
2774 &mut d,
2775 format!(
2776 "intelligence.headers['{name}'] looks like a credential but has an inline value; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}}"
2777 ),
2778 );
2779 } else if let Some(e) = unresolved_secret_ref(value) {
2780 err(&mut d, format!("intelligence.headers['{name}']: {e}"));
2781 }
2782 }
2783
2784 let mut names = std::collections::HashSet::new();
2786 for srv in &s.mcp.servers {
2787 if srv.name.trim().is_empty() {
2788 err(&mut d, "mcp.servers[]: a server has an empty name".into());
2789 }
2790 if !names.insert(srv.name.as_str()) {
2791 err(
2792 &mut d,
2793 format!("mcp.servers[]: duplicate server name '{}'", srv.name),
2794 );
2795 }
2796 if srv.name == "code" {
2797 err(
2798 &mut d,
2799 "mcp.servers[]: the server name 'code' is reserved for code-registered tools"
2800 .into(),
2801 );
2802 }
2803 if let Err(e) = super::mcp_endpoint_scheme_ok(&srv.endpoint) {
2804 err(&mut d, format!("mcp server '{}': {e}", srv.name));
2805 }
2806 if let Err(e) = srv.tag_set() {
2807 err(&mut d, e);
2808 }
2809 for (h, v) in &srv.headers {
2810 if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
2811 err(
2812 &mut d,
2813 format!(
2814 "mcp server '{}' header '{h}' looks like a credential but has an inline value; use a {{{{secret:…}}}} reference",
2815 srv.name
2816 ),
2817 );
2818 } else if let Some(e) = unresolved_secret_ref(v) {
2819 err(
2820 &mut d,
2821 format!("mcp server '{}' header '{h}': {e}", srv.name),
2822 );
2823 }
2824 }
2825 if let Some(auth) = &srv.auth {
2826 for e in validate_auth_block(auth, &format!("mcp server '{}'", srv.name)) {
2827 err(&mut d, e);
2828 }
2829 }
2830 }
2831 let server_known = |n: &str| s.mcp.servers.iter().any(|x| x.name == n);
2832
2833 for (name, ov) in &s.tools.overrides {
2835 if !server_known(&ov.server) {
2836 err(
2837 &mut d,
2838 format!(
2839 "tools.overrides['{name}'] references undeclared MCP server '{}'",
2840 ov.server
2841 ),
2842 );
2843 }
2844 if s.tools.disabled.iter().any(|x| x == name) {
2845 err(
2846 &mut d,
2847 format!("tool '{name}' is both disabled and overridden"),
2848 );
2849 }
2850 for (label, tpl) in [("args", &ov.args), ("result", &ov.result)] {
2851 if let Some(t) = tpl
2852 && let Some(expr) = t.strip_prefix("CEL:")
2853 && let Err(e) = crate::cel::compile_check(expr.trim())
2854 {
2855 err(&mut d, format!("tools.overrides['{name}'].{label}: {e}"));
2856 }
2857 }
2858 }
2859
2860 match s.store.kind {
2862 StoreKind::Mcp => match &s.store.mcp {
2863 None => err(&mut d, "store.kind is mcp but store.mcp is not set".into()),
2864 Some(m) => {
2865 if !server_known(&m.server) {
2866 err(
2867 &mut d,
2868 format!(
2869 "store.mcp.server '{}' is not a declared MCP server",
2870 m.server
2871 ),
2872 );
2873 }
2874 for (label, op) in [
2875 ("put", &m.put),
2876 ("get", &m.get),
2877 ("list", &m.list),
2878 ("delete", &m.delete),
2879 ] {
2880 if let Some(op) = op {
2881 for (f, t) in [
2882 ("args", &op.args),
2883 ("ok", &op.ok),
2884 ("conflict", &op.conflict),
2885 ("value", &op.value),
2886 ("keys", &op.keys),
2887 ] {
2888 if let Some(t) = t
2889 && let Some(expr) = t.strip_prefix("CEL:")
2890 && let Err(e) = crate::cel::compile_check(expr.trim())
2891 {
2892 err(&mut d, format!("store.mcp.{label}.{f}: {e}"));
2893 }
2894 }
2895 }
2896 }
2897 }
2898 },
2899 StoreKind::Http => match &s.store.http {
2900 None => err(
2901 &mut d,
2902 "store.kind is http but store.http is not set".into(),
2903 ),
2904 Some(h) => {
2905 if !(h.base_url.starts_with("https://") || h.base_url.starts_with("http://")) {
2906 err(
2907 &mut d,
2908 format!(
2909 "store.http.base_url must be an http(s) URL (got {})",
2910 h.base_url
2911 ),
2912 );
2913 }
2914 if h.get.is_none() || h.put.is_none() {
2915 err(
2916 &mut d,
2917 "store.http needs at least `get` and `put` operations".into(),
2918 );
2919 }
2920 for (name, v) in &h.headers {
2921 if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(v) {
2922 err(
2923 &mut d,
2924 format!(
2925 "store.http.headers['{name}'] looks like a credential but has an inline value"
2926 ),
2927 );
2928 } else if let Some(e) = unresolved_secret_ref(v) {
2929 err(&mut d, format!("store.http.headers['{name}']: {e}"));
2930 }
2931 }
2932 }
2933 },
2934 StoreKind::File => {
2935 if let Some(f) = &s.store.file
2941 && f.path.as_deref().is_some_and(|p| p.trim().is_empty())
2942 {
2943 err(
2944 &mut d,
2945 "store.file.path is empty — set a directory, or omit the field to use $AGENTD_STATE_DIR / $XDG_STATE_HOME/agentd/state".into(),
2946 );
2947 }
2948 }
2949 StoreKind::Memory => {
2950 d.warnings.push(
2951 "store.kind is memory: state does not survive the process (dev/test only)".into(),
2952 );
2953 }
2954 StoreKind::None => {
2955 if s.is_long_lived() {
2964 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());
2965 } else if !s.workflows.is_empty() {
2966 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());
2967 }
2968 }
2969 }
2970 if s.store.file.is_some() && s.store.kind != StoreKind::File {
2976 d.warnings.push(format!(
2977 "store.file is set but store.kind is {} — the file adapter is not in use and the block is ignored",
2978 format!("{:?}", s.store.kind).to_lowercase()
2982 ));
2983 }
2984 if let Some(ms) = s.store.checkpoint.debounce_ms
2985 && ms > 60_000
2986 {
2987 d.warnings.push(format!(
2988 "store.checkpoint.debounce_ms is {ms} (> 60s): progress may lag far behind reality"
2989 ));
2990 }
2991
2992 if let Some(k) = &s.knowledge.server
2994 && !server_known(k)
2995 {
2996 err(
2997 &mut d,
2998 format!("knowledge.server '{k}' is not a declared MCP server"),
2999 );
3000 }
3001 if let Some(k) = &s.search.server
3002 && !server_known(k)
3003 {
3004 err(
3005 &mut d,
3006 format!("search.server '{k}' is not a declared MCP server"),
3007 );
3008 }
3009 for src in &s.skills.sources {
3010 if !server_known(&src.server) {
3011 err(
3012 &mut d,
3013 format!(
3014 "skills.sources[] references undeclared MCP server '{}'",
3015 src.server
3016 ),
3017 );
3018 }
3019 }
3020 if let Some(c) = s.context.compact_at
3021 && !(c > 0.0 && c <= 1.0)
3022 {
3023 err(
3024 &mut d,
3025 format!("context.compact_at must be in (0, 1] (got {c})"),
3026 );
3027 }
3028
3029 let mut wf_names = std::collections::HashSet::new();
3031 for (i, w) in s.workflows.iter().enumerate() {
3032 let Some(obj) = w.as_object() else {
3033 err(&mut d, format!("workflows[{i}] must be an object"));
3034 continue;
3035 };
3036 let name = obj.get("name").and_then(Value::as_str).unwrap_or("");
3037 if name.trim().is_empty() {
3038 err(&mut d, format!("workflows[{i}] has no name"));
3039 } else if !wf_names.insert(name.to_string()) {
3040 err(
3041 &mut d,
3042 format!("workflows[]: duplicate workflow name '{name}'"),
3043 );
3044 }
3045 let has_file = obj.contains_key("file");
3046 let has_uri = obj.contains_key("uri");
3047 let has_steps = obj.contains_key("steps");
3048 if (has_file as u8 + has_uri as u8 + has_steps as u8) != 1 {
3049 err(
3050 &mut d,
3051 format!("workflows['{name}'] must have exactly one of file | uri | steps"),
3052 );
3053 }
3054 if let Some(f) = obj.get("file").and_then(Value::as_str)
3055 && !std::path::Path::new(f).exists()
3056 {
3057 err(
3058 &mut d,
3059 format!("workflows['{name}'].file {f:?} does not exist"),
3060 );
3061 }
3062 }
3063
3064 for (k, v) in &s.lifecycle.exit_code_map {
3066 if k != "3" && k != "7" {
3067 err(
3068 &mut d,
3069 format!(
3070 "lifecycle.exit_code_map: only the policy codes 3 and 7 are remappable (got key {k:?})"
3071 ),
3072 );
3073 }
3074 if !(0..=255).contains(v) {
3075 err(
3076 &mut d,
3077 format!("lifecycle.exit_code_map[{k}] must be 0..=255 (got {v})"),
3078 );
3079 }
3080 }
3081 if s.lifecycle.watch_config && loaded.files.is_empty() {
3082 err(
3083 &mut d,
3084 "lifecycle.watch_config requires a config file (--config / AGENTD_CONFIG)".into(),
3085 );
3086 }
3087
3088 if let Some(l) = &s.a2a.listen {
3090 match super::ServeTarget::parse(l) {
3091 Ok(super::ServeTarget::Http { bind, tls }) => {
3092 let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
3093 if tls && (s.a2a.tls.cert.is_none() || s.a2a.tls.key.is_none()) {
3094 err(
3095 &mut d,
3096 "a2a.listen is https:// but a2a.tls.cert / a2a.tls.key are not set".into(),
3097 );
3098 }
3099 if !loopback
3100 && s.a2a.tls.client_ca.is_none()
3101 && s.a2a.bearer.is_none()
3102 && !s.interface.pairing.enabled
3103 {
3104 err(&mut d, "a2a.listen on a non-loopback address needs client auth: a2a.tls.client_ca, a2a.bearer, and/or interface.pairing".into());
3105 }
3106 if !tls && !loopback {
3107 err(
3108 &mut d,
3109 "a2a.listen plaintext http:// is allowed for loopback only; use https://"
3110 .into(),
3111 );
3112 }
3113 }
3114 Err(e) => err(&mut d, format!("a2a.listen: {e}")),
3115 }
3116 }
3117
3118 if s.interface.enabled && s.a2a.listen.is_none() {
3120 err(
3121 &mut d,
3122 "interface.enabled requires a2a.listen (the interface is served on the A2A listener)"
3123 .into(),
3124 );
3125 }
3126 if s.interface.debug && !s.interface.enabled {
3127 d.warnings
3128 .push("interface.debug has no effect while interface.enabled is false".into());
3129 }
3130 for o in &s.interface.origins {
3131 let ok = o
3133 .split_once("://")
3134 .map(|(scheme, rest)| {
3135 matches!(scheme, "http" | "https") && !rest.is_empty() && !rest.contains('/')
3136 })
3137 .unwrap_or(false);
3138 if !ok {
3139 err(
3140 &mut d,
3141 format!(
3142 "interface.origins: {o:?} is not an origin (want scheme://host[:port], no path)"
3143 ),
3144 );
3145 }
3146 }
3147 for (edge, items) in [
3150 ("top", &s.interface.display.top),
3151 ("bottom", &s.interface.display.bottom),
3152 ] {
3153 for item in items.iter().flatten() {
3154 if let Some(key) = item.strip_prefix("memory:") {
3160 if key.is_empty() {
3161 d.errors.push(format!(
3162 "interface.display.{edge}: {item:?} names no memory key"
3163 ));
3164 } else if let Err(e) = crate::context::memory::Memory::check_key(key) {
3165 d.errors
3166 .push(format!("interface.display.{edge}: {item:?}: {e}"));
3167 }
3168 continue;
3169 }
3170 if !DISPLAY_ITEMS.contains(&item.as_str()) {
3171 d.warnings.push(format!(
3172 "interface.display.{edge}: unknown item {item:?} (clients skip it); known: {}, \
3173 or memory:<key> for a value a workflow maintains",
3174 DISPLAY_ITEMS.join(", ")
3175 ));
3176 }
3177 }
3178 }
3179 if s.interface.pairing.enabled {
3181 if !s.interface.enabled {
3182 err(
3183 &mut d,
3184 "interface.pairing.enabled requires interface.enabled (pairing rides the interface surface)".into(),
3185 );
3186 }
3187 if let Some(role) = s.interface.pairing.role
3188 && !matches!(role, Role::Operator | Role::User)
3189 {
3190 err(
3191 &mut d,
3192 "interface.pairing.role must be operator or user".into(),
3193 );
3194 }
3195 }
3196
3197 let uses_webhook = s.workflows.iter().any(workflow_uses_webhook);
3199 if uses_webhook && s.webhooks.listen.is_none() {
3200 err(&mut d, "a `webhook` node (start or wait) is used but webhooks.listen is not set — configure webhooks.listen (https://host:port)".into());
3201 }
3202 if let Some(l) = &s.webhooks.listen {
3203 match super::ServeTarget::parse(l) {
3204 Ok(super::ServeTarget::Http { bind, tls }) => {
3205 let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
3206 if tls && (s.webhooks.tls.cert.is_none() || s.webhooks.tls.key.is_none()) {
3207 err(
3208 &mut d,
3209 "webhooks.listen is https:// but webhooks.tls.cert / webhooks.tls.key are not set"
3210 .into(),
3211 );
3212 }
3213 if !tls && !loopback {
3214 err(
3215 &mut d,
3216 "webhooks.listen plaintext http:// is allowed for loopback only; use https://"
3217 .into(),
3218 );
3219 }
3220 if !loopback && !webhook_default_verifies(s.webhooks.default_auth.as_ref()) {
3232 let mut open: Vec<String> = Vec::new();
3233 let mut nodes = 0usize;
3234 for w in &s.workflows {
3235 let wf = w.get("name").and_then(Value::as_str).unwrap_or("?");
3236 for (node, auth) in webhook_nodes(w) {
3237 nodes += 1;
3238 if !webhook_auth_verifies(auth) {
3239 open.push(format!("{wf}/{node}"));
3240 }
3241 }
3242 }
3243 if !open.is_empty() {
3244 err(
3245 &mut d,
3246 format!(
3247 "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: {}",
3248 open.join(", ")
3249 ),
3250 );
3251 } else if nodes == 0 {
3252 d.warnings.push("webhooks.listen is non-loopback with no webhooks.default_auth — every webhook node must declare its own `auth` (HMAC recommended)".into());
3255 }
3256 }
3257 }
3258 Err(e) => err(&mut d, format!("webhooks.listen: {e}")),
3259 }
3260 }
3261
3262 if let Some(g) = &s.goal {
3264 let via = g.check.via.as_deref().unwrap_or("both");
3265 if via == "condition" && g.check.condition.is_none() {
3266 err(
3267 &mut d,
3268 "goal.check.via is 'condition' but goal.check.condition is not set".into(),
3269 );
3270 }
3271 for (label, act) in [("on_achieved", &g.on_achieved), ("on_stuck", &g.on_stuck)] {
3272 if let Some(GoalAction::Workflow(name)) = act
3273 && !s
3274 .workflows
3275 .iter()
3276 .any(|w| w.get("name").and_then(Value::as_str) == Some(name.as_str()))
3277 {
3278 err(
3279 &mut d,
3280 format!(
3281 "goal.{label} references workflow '{name}', which is not defined in workflows"
3282 ),
3283 );
3284 }
3285 }
3286 }
3287
3288 let mut peer_names = std::collections::HashSet::new();
3289 for p in &s.a2a.peers {
3290 if !peer_names.insert(p.name.as_str()) {
3291 err(
3292 &mut d,
3293 format!("a2a.peers[]: duplicate peer name '{}'", p.name),
3294 );
3295 }
3296 if !p.endpoint.starts_with("https://") && !p.endpoint.starts_with("http://") {
3297 err(
3298 &mut d,
3299 format!("a2a peer '{}': endpoint must be http(s)://", p.name),
3300 );
3301 }
3302 if p.client_cert.is_some() != p.client_key.is_some() {
3303 err(
3304 &mut d,
3305 format!(
3306 "a2a peer '{}': client_cert and client_key must be set together",
3307 p.name
3308 ),
3309 );
3310 }
3311 if let Some(auth) = &p.auth {
3312 for e in validate_auth_block(auth, &format!("a2a peer '{}'", p.name)) {
3313 err(&mut d, e);
3314 }
3315 if auth.kind == AuthKind::Aws {
3316 err(
3317 &mut d,
3318 format!(
3319 "a2a peer '{}': SigV4 (auth kind aws) is a follow-up",
3320 p.name
3321 ),
3322 );
3323 }
3324 }
3325 for (h, v) in &p.headers {
3326 if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
3327 err(
3328 &mut d,
3329 format!(
3330 "a2a peer '{}' header '{h}' looks like a credential but has an inline value",
3331 p.name
3332 ),
3333 );
3334 } else if let Some(e) = unresolved_secret_ref(v) {
3335 err(&mut d, format!("a2a peer '{}' header '{h}': {e}", p.name));
3336 }
3337 }
3338 }
3339 for (i, pr) in s.a2a.principals.iter().enumerate() {
3340 let m = &pr.matcher;
3341 if m.san.is_none()
3342 && m.sub.is_none()
3343 && m.bearer_ref.is_none()
3344 && m.aauth_agent.is_none()
3345 && !m.any
3346 {
3347 err(
3348 &mut d,
3349 format!(
3350 "a2a.principals[{i}]: match needs one of san | sub | bearer_ref | aauth_agent | any"
3351 ),
3352 );
3353 }
3354 if m.any && pr.role == Role::Operator {
3355 err(
3356 &mut d,
3357 format!("a2a.principals[{i}]: `any` cannot grant the operator role"),
3358 );
3359 }
3360 }
3361
3362 if let Some(l) = &s.observability.log_level
3364 && crate::obs::log::Level::parse(l).is_none()
3365 {
3366 err(
3367 &mut d,
3368 format!("observability.log_level: {l:?} (want trace|debug|info|warn|error)"),
3369 );
3370 }
3371
3372 for m in secret_violations(&loaded.file_doc) {
3374 err(&mut d, m);
3375 }
3376 for f in &s.observability.audit.sink.clone().unwrap_or_default() {
3377 if *f == AuditSink::Store && s.store.kind == StoreKind::None {
3378 err(
3379 &mut d,
3380 "observability.audit.sink includes `store` but store.kind is none".into(),
3381 );
3382 }
3383 }
3384
3385 let mut tags = Vec::new();
3387 for srv in &s.mcp.servers {
3388 match srv.tag_set() {
3389 Ok(t) if t.is_empty() => tags.push(crate::sec::scope::TrifectaTag::UntrustedInput),
3390 Ok(t) => tags.extend(t),
3391 Err(_) => {}
3392 }
3393 }
3394 #[cfg(feature = "exec")]
3403 if s.security.exec.enabled {
3404 tags.push(crate::sec::scope::TrifectaTag::Sensitive);
3405 tags.push(crate::sec::scope::TrifectaTag::Egress);
3406 }
3407 use crate::sec::scope::{TrifectaVerdict, check_trifecta};
3408 if check_trifecta(tags, s.security.allow_trifecta) == TrifectaVerdict::RefusedTrifecta {
3409 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());
3410 }
3411
3412 for (path, level) in [
3428 ("store.durability.a2a", s.store.durability.a2a),
3429 ("store.durability.steps", s.store.durability.steps),
3430 ] {
3431 if level == Some(DurabilityLevel::Eventual) {
3432 d.errors.push(format!(
3433 "{path}: `eventual` is not implemented — every durable write is strict \
3434 (checkpoint-before-effect). Remove the key; `strict` is the default and \
3435 the only behaviour."
3436 ));
3437 }
3438 }
3439 for w in &s.workflows {
3440 if w.get("steps").is_none() {
3441 continue;
3442 }
3443 if let Err(errs) = crate::engine::model::parse_workflow(w) {
3444 d.errors.extend(errs);
3446 }
3447 let cap = s
3450 .limits
3451 .workflow
3452 .fan_out
3453 .unwrap_or(crate::engine::model::MAX_BATCH_PARALLEL as u32);
3454 let wname = w.get("name").and_then(Value::as_str).unwrap_or("?");
3455 if let Some(steps) = w.get("steps").and_then(Value::as_object) {
3456 for (sid, step) in steps {
3457 let want = step.get("parallel").and_then(Value::as_u64).or_else(|| {
3458 step.get("batch")
3459 .and_then(|b| b.get("parallel"))
3460 .and_then(Value::as_u64)
3461 });
3462 if let Some(want) = want
3463 && want > cap as u64
3464 {
3465 d.errors.push(format!(
3466 "workflow {wname:?} step {sid:?}: parallel {want} exceeds \
3467 limits.workflow.fan_out ({cap}) — raise the limit or lower the step"
3468 ));
3469 }
3470 }
3471 }
3472 }
3473 d
3474}
3475
3476pub const LONG_LIVED_STARTS: &[&str] = &[
3479 "loop",
3480 "schedule",
3481 "subscribe",
3482 "signal",
3483 "event",
3484 "a2a",
3485 "webhook",
3486];
3487
3488pub fn workflow_is_long_lived(w: &Value) -> bool {
3490 w.get("steps")
3491 .and_then(Value::as_object)
3492 .is_some_and(|steps| {
3493 steps.values().any(|st| {
3494 st.get("kind")
3495 .and_then(Value::as_str)
3496 .is_some_and(|k| LONG_LIVED_STARTS.contains(&k))
3497 })
3498 })
3499}
3500
3501pub fn workflow_uses_webhook(w: &Value) -> bool {
3505 w.get("steps")
3506 .and_then(Value::as_object)
3507 .is_some_and(|steps| {
3508 steps.values().any(|st| {
3509 let kind = st.get("kind").and_then(Value::as_str);
3510 kind == Some("webhook")
3511 || (matches!(kind, Some("wait") | Some("await"))
3512 && st.get("on").and_then(Value::as_str) == Some("webhook"))
3513 })
3514 })
3515}
3516
3517fn webhook_nodes(w: &Value) -> Vec<(&str, Option<&Value>)> {
3523 let Some(steps) = w.get("steps").and_then(Value::as_object) else {
3524 return Vec::new();
3525 };
3526 steps
3527 .iter()
3528 .filter_map(|(id, st)| {
3529 let kind = st.get("kind").and_then(Value::as_str);
3530 if kind == Some("webhook") {
3531 Some((id.as_str(), st.get("auth")))
3532 } else if matches!(kind, Some("wait") | Some("await"))
3533 && st.get("on").and_then(Value::as_str) == Some("webhook")
3534 {
3535 Some((id.as_str(), st.get("webhook").and_then(|c| c.get("auth"))))
3536 } else {
3537 None
3538 }
3539 })
3540 .collect()
3541}
3542
3543fn webhook_auth_verifies(auth: Option<&Value>) -> bool {
3550 let Some(a) = auth else { return false };
3551 if a.get("none").and_then(Value::as_bool) == Some(true) {
3552 return false;
3553 }
3554 a.get("hmac").and_then(Value::as_object).is_some()
3555 || a.get("header").and_then(Value::as_object).is_some()
3556 || a.get("bearer").and_then(Value::as_str).is_some()
3557}
3558
3559fn webhook_default_verifies(d: Option<&WebhookAuth>) -> bool {
3564 d.is_some_and(|d| !d.none && (d.hmac.is_some() || d.bearer.is_some() || d.header.is_some()))
3565}
3566
3567fn validate_budget(b: &Budget, at: &str, d: &mut Diagnostics) {
3568 for (i, w) in b.windows.iter().enumerate() {
3569 if w.tokens.is_none() && w.requests.is_none() {
3570 d.errors
3571 .push(format!("{at}.windows[{i}]: set tokens and/or requests"));
3572 }
3573 if let Some(r) = &w.reset {
3574 let ok = r.len() == 6
3579 && r.is_ascii()
3580 && r.ends_with('Z')
3581 && r[..2].parse::<u32>().is_ok_and(|h| h < 24)
3582 && &r[2..3] == ":"
3583 && r[3..5].parse::<u32>().is_ok_and(|m| m < 60);
3584 if !ok {
3585 d.errors.push(format!(
3586 "{at}.windows[{i}].reset must be HH:MMZ (got {r:?})"
3587 ));
3588 }
3589 if !w.per.is_calendar() {
3590 d.warnings.push(format!(
3591 "{at}.windows[{i}].reset is only meaningful for day/week windows"
3592 ));
3593 }
3594 }
3595 }
3596 if let Some(f) = b.slow.factor
3597 && !(f > 0.0 && f <= 1.0)
3598 {
3599 d.errors
3600 .push(format!("{at}.slow.factor must be in (0, 1] (got {f})"));
3601 }
3602 if b.on_exhausted == BudgetTactic::Degrade && b.degrade.model.is_none() {
3603 d.errors.push(format!(
3604 "{at}.on_exhausted is degrade but {at}.degrade.model is not set"
3605 ));
3606 }
3607 if b.reserve.estimate == ReserveEstimate::Fixed && b.reserve.fixed.is_none() {
3608 d.errors.push(format!(
3609 "{at}.reserve.estimate is fixed but {at}.reserve.fixed is not set"
3610 ));
3611 }
3612}
3613
3614const FILE_SECRET_PATHS: &[&str] = &[
3616 "/intelligence/token",
3617 "/a2a/bearer",
3618 "/security/aauth/enroll_token",
3619];
3620
3621fn secret_violations(file_doc: &Value) -> Vec<String> {
3623 let mut out = Vec::new();
3624 for p in FILE_SECRET_PATHS {
3625 if let Some(Value::String(v)) = file_doc.pointer(p)
3626 && !crate::sec::secret::has_secret_ref(v)
3627 {
3628 out.push(format!(
3629 "config file: {} carries an inline credential; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}} (or set it from env/flag)",
3630 p.trim_start_matches('/').replace('/', ".")
3631 ));
3632 }
3633 }
3634 if let Some(servers) = file_doc.pointer("/mcp/servers").and_then(Value::as_array) {
3635 for s in servers {
3636 if let Some(Value::String(v)) = s.pointer("/oauth/client_secret")
3637 && !crate::sec::secret::has_secret_ref(v)
3638 {
3639 out.push(format!(
3640 "config file: mcp server '{}' oauth.client_secret carries an inline credential; use a {{{{secret:…}}}} reference",
3641 s.get("name").and_then(Value::as_str).unwrap_or("?")
3642 ));
3643 }
3644 }
3645 }
3646 out
3647}
3648
3649pub const RESTART_ONLY_PATHS: &[&str] = &[
3656 "config_version",
3657 "agent.name",
3658 "store.kind",
3659 "store.prefix",
3660 "store.mcp",
3661 "store.http",
3662 "store.file",
3665 "lifecycle.run_until",
3666 "lifecycle.drain_timeout",
3667 "lifecycle.run_id",
3668 "lifecycle.exit_code_map",
3669 "lifecycle.watch_config",
3670 "a2a.listen",
3671 "a2a.tls",
3672 "a2a.bearer",
3673 "observability.otel",
3674 "observability.metrics_addr",
3675 "observability.health_file",
3676 "observability.events_ring",
3677 "observability.traceparent",
3678 "security",
3679];
3680
3681pub fn restart_only_diff(running: &Value, candidate: &Value) -> Vec<String> {
3683 RESTART_ONLY_PATHS
3684 .iter()
3685 .filter(|p| {
3686 let ptr = format!("/{}", p.replace('.', "/"));
3687 running.pointer(&ptr) != candidate.pointer(&ptr)
3688 })
3689 .map(|p| (*p).to_string())
3690 .collect()
3691}
3692
3693pub fn help_section() -> String {
3695 paths::help_section_in(&paths::bindings_of(&schema::schema()))
3696}
3697
3698pub fn help_text() -> String {
3701 let mut out = format!(
3702 "agentd {ver} — a durable, workflow-driven agent (config schema v2)\n\
3703 \n\
3704 USAGE:\n\
3705 \x20 agentd --config <settings.yaml> [--config <overlay.yaml> …] [--<path> <value> …]\n\
3706 \x20 agentd --prompt <TEXT> --intelligence <URL> # one-shot: ask, answer, exit\n\
3707 \x20 agentd --instruction <TEXT> --intelligence <URL> [--mcp name=endpoint …] # one-shot sugar\n\
3708 \x20 agentd tui|ui --config <settings.yaml> [--<path> <value> …] # + a display client\n\
3709 \n\
3710 Every setting is a document path (YAML/JSON file, AGENTD_<PATH> env, --<path> flag);\n\
3711 several files merge in order (later wins). Precedence: built-in < files < env < flags.\n\
3712 \n\
3713 ALIASES (legacy spellings of paths):\n",
3714 ver = crate::VERSION
3715 );
3716 for a in ALIASES {
3717 let shape = match a.kind {
3718 AliasKind::Set | AliasKind::SetFromFile => "<value>",
3719 AliasKind::SetTrue => "",
3720 AliasKind::Append => "<value> (adds one)",
3721 AliasKind::Special => "<value>",
3722 };
3723 out.push_str(&format!(" {:<32} {} → {}\n", a.flag, shape, a.path));
3724 }
3725 out.push_str(
3726 "\nSUBCOMMANDS (run the daemon with a display client attached; RFC 0032):\n\
3727 \x20 tui + the terminal UI (fullscreen; --inline for in-place)\n\
3728 \x20 ui + the web UI, opened in a browser\n\
3729 \x20 both need `interface.enabled: true`, which the\n\
3730 \x20 subcommand sets for you; the client exits with the daemon.\n\
3731 \x20 Detached instead: run `agentd -c …`, then `agentd-tui\n\
3732 \x20 --endpoint <url>` (npm i -g @agentd-dev/cli).\n\
3733 \nCONTROL:\n\
3734 \x20 -c, --config <PATH> a settings file (repeatable; `=` form too; or AGENT_CONFIG=a.yaml:b.yaml)\n\
3735 \x20 --validate-config load+validate everything, print the verdict, exit 0/2\n\
3736 \x20 --config-schema=2 print the settings JSON Schema (v2) and exit\n\
3737 \x20 --workflow-schema print the workflow (dialect 3) JSON Schema + node registry and exit\n\
3738 \x20 --capabilities print the capabilities manifest and exit\n\
3739 \x20 --login <target> complete an OAuth device-login for an endpoint (e.g. mcp:<name>) and cache the token\n\
3740 \x20 --logout <target> evict a cached credential\n\
3741 \x20 -h, --help / -V, --version\n\
3742 \nREMOVED IN 2.0:\n",
3743 );
3744 for (flag, hint) in REMOVED_FLAGS {
3745 out.push_str(&format!(" {flag:<32} {hint}\n"));
3746 }
3747 out.push('\n');
3748 out.push_str(&help_section());
3749 out
3750}
3751
3752#[cfg(test)]
3753mod tests {
3754 use super::*;
3755 use std::io::Write;
3756
3757 fn args(v: &[&str]) -> Vec<String> {
3758 v.iter().map(|s| s.to_string()).collect()
3759 }
3760
3761 fn write_tmp(contents: &str, ext: &str) -> tempfile::NamedTempFile {
3762 let mut f = tempfile::Builder::new()
3763 .suffix(&format!(".{ext}"))
3764 .tempfile()
3765 .unwrap();
3766 f.write_all(contents.as_bytes()).unwrap();
3767 f.flush().unwrap();
3768 f
3769 }
3770
3771 fn base_env() -> Vec<(String, String)> {
3772 vec![(
3773 "AGENTD_INTELLIGENCE_ENDPOINTS".into(),
3774 "https://intel.example/v1".into(),
3775 )]
3776 }
3777
3778 fn struct_fields_at(doc_path: &str) -> Vec<String> {
3784 let mut probe = Value::Object(Map::new());
3786 let path = if doc_path.is_empty() {
3787 "__probe__".to_string()
3788 } else {
3789 format!("{doc_path}.__probe__")
3790 };
3791 paths::set_path(&mut probe, &path, json!(1));
3792 let err = Settings::from_document(probe, "t").expect_err("probe must be rejected");
3793 let after = err.split("expected").nth(1).unwrap_or("");
3796 let mut out: Vec<String> = after
3797 .split('`')
3798 .skip(1)
3799 .step_by(2)
3800 .map(str::to_string)
3801 .collect();
3802 out.sort();
3803 out
3804 }
3805
3806 fn schema_props_at(schema: &Value, doc_path: &str) -> Vec<String> {
3807 let mut node = schema.clone();
3808 let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
3809 for seg in doc_path.split('.').filter(|s| !s.is_empty()) {
3810 let props = node.get("properties").cloned().unwrap_or(Value::Null);
3811 node = props.get(seg).cloned().unwrap_or(Value::Null);
3812 if let Some(r) = node.get("$ref").and_then(Value::as_str)
3813 && let Some(name) = r.strip_prefix("#/$defs/")
3814 {
3815 node = defs.get(name).cloned().unwrap_or(Value::Null);
3816 }
3817 }
3818 let mut out: Vec<String> = node
3819 .get("properties")
3820 .and_then(Value::as_object)
3821 .map(|m| m.keys().cloned().collect())
3822 .unwrap_or_default();
3823 out.sort();
3824 out
3825 }
3826
3827 #[test]
3828 fn schema_matches_struct_at_every_object() {
3829 let schema = schema::schema();
3830 for path in [
3831 "",
3832 "agent",
3833 "agent.tools",
3834 "intelligence",
3835 "intelligence.auth",
3836 "intelligence.budget",
3837 "intelligence.budget.slow",
3838 "intelligence.budget.degrade",
3839 "intelligence.budget.reserve",
3840 "mcp",
3841 "tools",
3842 "store",
3843 "store.checkpoint",
3844 "store.durability",
3845 "memory",
3846 "context",
3847 "context.plan",
3848 "knowledge",
3849 "knowledge.auto_context",
3850 "search",
3851 "skills",
3852 "limits",
3853 "limits.run",
3854 "limits.subagents",
3855 "lifecycle",
3856 "a2a",
3857 "a2a.tls",
3858 "observability",
3859 "observability.otel",
3860 "observability.audit",
3861 "security",
3862 "security.cgroup",
3863 "security.exec",
3864 ] {
3865 let s = schema_props_at(&schema, path);
3866 let f = struct_fields_at(path);
3867 assert_eq!(s, f, "schema/struct drift at `{path}`");
3868 }
3869 }
3870
3871 #[test]
3872 fn every_schema_path_deserializes_a_sample() {
3873 for b in paths::bindings_of(&schema::schema()) {
3877 let sample = match &b.kind {
3878 paths::Kind::String => match b.path.as_str() {
3879 "config_version" => json!("2"),
3880 _ => json!("x"),
3881 },
3882 paths::Kind::Integer => json!(1),
3883 paths::Kind::Number => json!(0.5),
3884 paths::Kind::Boolean => json!(true),
3885 paths::Kind::Enum(vs) => json!(vs[0]),
3886 paths::Kind::Array(item) => match (**item).clone() {
3887 paths::Kind::Object => match b.path.as_str() {
3888 "mcp.servers" => {
3889 json!([{"name": "a", "endpoint": "https://a.example/mcp"}])
3890 }
3891 "workflows" => json!([{"name": "w", "steps": {}}]),
3892 "a2a.principals" => json!([{"match": {"any": true}, "role": "user"}]),
3893 "a2a.peers" => json!([{"name": "p", "endpoint": "https://p.example"}]),
3894 "skills.sources" => json!([{"server": "s"}]),
3895 "intelligence.budget.windows" | "agent.conversation_budget.windows" => {
3896 json!([{"per": "hour", "tokens": 1}])
3897 }
3898 other => panic!("no sample for object list {other}"),
3899 },
3900 paths::Kind::Enum(vs) => json!([vs[0]]),
3901 _ => json!(["s"]),
3902 },
3903 paths::Kind::Object => match b.path.as_str() {
3904 "intelligence.pricing" => json!({"m": {"input_per_1k": 1.0}}),
3905 "tools.overrides" => json!({"memory.get": {"server": "s", "tool": "t"}}),
3906 "store.mcp" => json!({"server": "s"}),
3907 "store.http" => json!({"base_url": "https://s"}),
3908 "security.aauth" => json!({"provider": "https://apd"}),
3909 "lifecycle.exit_code_map" => json!({"3": 0}),
3910 _ => json!({"k": "v"}),
3911 },
3912 paths::Kind::Any => match b.path.as_str() {
3913 "intelligence.endpoints" => json!("https://a,https://b"),
3914 "goal.on_achieved" | "goal.on_stuck" => json!("finish"),
3915 p if p.ends_with("timeout")
3916 || p.ends_with("deadline")
3917 || p.ends_with("_grace")
3918 || p.ends_with("ttl")
3919 || p.ends_with("every") =>
3920 {
3921 json!("10s")
3922 }
3923 p if p.starts_with("agent.tools.") => json!("all"),
3924 _ => json!("x"),
3925 },
3926 };
3927 let mut doc = Value::Object(Map::new());
3928 paths::set_path(&mut doc, &b.path, sample);
3929 fill_required(&mut doc, &schema::schema(), &b.path);
3930 Settings::from_document(doc, "t")
3931 .unwrap_or_else(|e| panic!("path {} does not deserialize: {e}", b.path));
3932 }
3933 }
3934
3935 fn fill_required(doc: &mut Value, schema: &Value, path: &str) {
3939 let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
3940 let resolve = |v: &Value| -> Value {
3941 match v
3942 .get("$ref")
3943 .and_then(Value::as_str)
3944 .and_then(|r| r.strip_prefix("#/$defs/"))
3945 {
3946 Some(name) => defs.get(name).cloned().unwrap_or(Value::Null),
3947 None => v.clone(),
3948 }
3949 };
3950 let mut node = schema.clone();
3951 let mut prefix = String::new();
3952 let segs: Vec<&str> = path.split('.').collect();
3953 for (i, seg) in segs.iter().enumerate() {
3954 let props = node.get("properties").cloned().unwrap_or(Value::Null);
3955 node = resolve(&props.get(*seg).cloned().unwrap_or(Value::Null));
3956 prefix = if prefix.is_empty() {
3957 (*seg).to_string()
3958 } else {
3959 format!("{prefix}.{seg}")
3960 };
3961 if i + 1 == segs.len() {
3962 break;
3963 }
3964 if let Some(req) = node.get("required").and_then(Value::as_array) {
3965 let props = node.get("properties").cloned().unwrap_or(Value::Null);
3966 for r in req.iter().filter_map(Value::as_str) {
3967 let p = format!("{prefix}.{r}");
3968 if doc.pointer(&format!("/{}", p.replace('.', "/"))).is_none() {
3969 let sample = match props
3972 .get(r)
3973 .and_then(|f| f.get("enum"))
3974 .and_then(Value::as_array)
3975 .filter(|a| !a.is_empty())
3976 {
3977 Some(vs) => vs[0].clone(),
3978 None => match r {
3979 "provider" | "base_url" | "url" => json!("https://x.example"),
3980 _ => json!("x"),
3981 },
3982 };
3983 paths::set_path(doc, &p, sample);
3984 }
3985 }
3986 }
3987 }
3988 }
3989
3990 #[test]
3991 fn env_and_flag_names_derive_from_the_v2_paths() {
3992 let bs = paths::bindings_of(&schema::schema());
3993 let model = bs.iter().find(|b| b.path == "intelligence.model").unwrap();
3994 assert_eq!(model.env_names()[0], "AGENTD_INTELLIGENCE_MODEL");
3995 assert_eq!(model.env_names()[2], "INTELLIGENCE_MODEL");
3996 assert_eq!(model.flag(), "--intelligence-model");
3997 let steps = bs.iter().find(|b| b.path == "limits.run.steps").unwrap();
3998 assert_eq!(steps.env_names()[0], "AGENTD_LIMITS_RUN_STEPS");
3999 let mut seen = std::collections::HashSet::new();
4001 for b in &bs {
4002 assert!(seen.insert(b.flag()), "duplicate flag {}", b.flag());
4003 }
4004 }
4005
4006 #[test]
4009 fn detects_v1_v2_mixed_and_empty() {
4010 assert_eq!(detect(&json!({})), Detected::Empty);
4011 assert_eq!(detect(&json!({"model": "m"})), Detected::V1);
4012 assert_eq!(detect(&json!({"config_version": "2"})), Detected::V2);
4013 assert_eq!(
4014 detect(&json!({"agent": {"instruction": "x"}})),
4015 Detected::V2
4016 );
4017 assert_eq!(detect(&json!({"agent": {}, "model": "m"})), Detected::Mixed);
4018 assert_eq!(
4019 detect(&json!({"config_version": "1.0", "model": "m"})),
4020 Detected::V1
4021 );
4022 assert_eq!(
4024 detect(&json!({"model": "m", "limits": {"max_steps": 1}})),
4025 Detected::V1
4026 );
4027 assert_eq!(
4028 detect(&json!({"intelligence": "https://x", "limits": {}})),
4029 Detected::V1
4030 );
4031 assert_eq!(
4032 detect(&json!({"intelligence": {"model": "m"}, "limits": {}})),
4033 Detected::V2
4034 );
4035 assert_eq!(detect(&json!({"limits": {"max_steps": 1}})), Detected::V1);
4036 }
4037
4038 #[cfg(feature = "exec")]
4041 #[test]
4042 fn enabling_exec_next_to_untrusted_input_assembles_the_trifecta() {
4043 let cfg = "config_version: \"2\"\nstore: {kind: memory}\n\
4049 mcp:\n servers:\n - name: web\n endpoint: https://mcp-web.internal/mcp\n tags: {\"*\": [untrusted_input]}\n\
4050 security:\n exec: {enabled: true, workdir: /tmp, allow: [git]}\n";
4051 let f = write_tmp(cfg, "yaml");
4052 let e = load(
4053 &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
4054 &base_env(),
4055 )
4056 .unwrap_err();
4057 assert!(format!("{e}").contains("lethal-trifecta refused"), "{e}");
4058
4059 load(
4061 &args(&[
4062 "--config",
4063 f.path().to_str().unwrap(),
4064 "--validate-config",
4065 "--allow-trifecta",
4066 ]),
4067 &base_env(),
4068 )
4069 .expect("--allow-trifecta is the escape hatch");
4070
4071 let alone = write_tmp(
4073 "config_version: \"2\"\nstore: {kind: memory}\n\
4074 security:\n exec: {enabled: true, workdir: /tmp, allow: [git]}\n",
4075 "yaml",
4076 );
4077 load(
4078 &args(&[
4079 "--config",
4080 alone.path().to_str().unwrap(),
4081 "--validate-config",
4082 ]),
4083 &base_env(),
4084 )
4085 .expect("two legs are not the trifecta");
4086 }
4087
4088 #[test]
4089 fn validate_config_catches_workflow_body_errors_the_runtime_would_refuse() {
4090 let f = write_tmp(
4094 "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",
4095 "yaml",
4096 );
4097 let e = load(
4098 &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
4099 &base_env(),
4100 )
4101 .unwrap_err();
4102 let msg = format!("{e}");
4103 assert!(msg.contains("unknown field"), "{msg}");
4104 assert!(msg.contains("prompt"), "{msg}");
4105 assert!(
4106 msg.contains("instruction"),
4107 "names the allowed fields: {msg}"
4108 );
4109
4110 let ok = write_tmp(
4112 "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",
4113 "yaml",
4114 );
4115 load(
4116 &args(&["--config", ok.path().to_str().unwrap(), "--validate-config"]),
4117 &base_env(),
4118 )
4119 .expect("a correct workflow validates");
4120 }
4121
4122 #[test]
4123 fn a_prompt_is_a_message_not_a_sugar_workflow() {
4124 let (l, ask) = load(&args(&["--prompt", "do the thing"]), &base_env()).unwrap();
4129 assert_eq!(ask, Ask::Run);
4130 assert_eq!(l.settings.agent.prompt.as_deref(), Some("do the thing"));
4131 assert!(
4132 l.settings.workflows.is_empty(),
4133 "a prompt needs no workflow: {:?}",
4134 l.settings.workflows
4135 );
4136
4137 let (only_instr, _) = load(&args(&["--instruction", "be terse"]), &base_env()).unwrap();
4139 assert_eq!(only_instr.settings.workflows.len(), 1);
4140
4141 let (both, _) = load(
4144 &args(&["--prompt", "do the thing", "--instruction", "be terse"]),
4145 &base_env(),
4146 )
4147 .unwrap();
4148 assert!(both.settings.workflows.is_empty());
4149 assert_eq!(both.settings.agent.instruction.as_deref(), Some("be terse"));
4150
4151 let mut env = base_env();
4153 env.push(("AGENTD_AGENT_PROMPT".into(), "from env".into()));
4154 let (from_env, _) = load(&args(&[]), &env).unwrap();
4155 assert_eq!(from_env.settings.agent.prompt.as_deref(), Some("from env"));
4156 }
4157
4158 #[test]
4159 fn minimal_instruction_run_gets_the_sugar_workflow() {
4160 let (l, ask) = load(&args(&["--instruction", "do it"]), &base_env()).unwrap();
4161 assert_eq!(ask, Ask::Run);
4162 assert_eq!(l.settings.agent.instruction.as_deref(), Some("do it"));
4163 assert_eq!(
4164 l.settings.intelligence.endpoints,
4165 vec!["https://intel.example/v1"]
4166 );
4167 assert_eq!(l.settings.workflows.len(), 1, "sugar workflow synthesized");
4168 assert_eq!(l.settings.workflows[0]["name"], json!("main"));
4169 assert_eq!(
4170 l.settings.workflows[0]["steps"]["start"]["kind"],
4171 json!("once")
4172 );
4173 assert!(
4175 l.warnings.iter().any(|w| w.contains("not durable")),
4176 "{:?}",
4177 l.warnings
4178 );
4179 }
4180
4181 #[test]
4182 fn a_long_lived_instance_defaults_to_the_file_store_but_an_explicit_none_is_refused() {
4183 let (l, _) = load(
4185 &args(&[
4186 "--instruction",
4187 "x",
4188 "--a2a.listen",
4189 "http://127.0.0.1:8443",
4190 ]),
4191 &base_env(),
4192 )
4193 .unwrap();
4194 assert_eq!(l.settings.store.kind, StoreKind::File);
4195 let f = write_tmp(
4197 "config_version: \"2\"\nworkflows:\n - name: w\n steps:\n s: {kind: schedule, cron: \"* * * * *\"}\n f: {kind: finish, depends_on: [s], status: completed}\n",
4198 "yaml",
4199 );
4200 let (l, _) = load(
4201 &args(&["--config", f.path().to_str().unwrap()]),
4202 &base_env(),
4203 )
4204 .unwrap();
4205 assert_eq!(l.settings.store.kind, StoreKind::File);
4206 let e = load(
4209 &args(&[
4210 "--config",
4211 f.path().to_str().unwrap(),
4212 "--store.kind",
4213 "none",
4214 ]),
4215 &base_env(),
4216 )
4217 .unwrap_err();
4218 assert!(format!("{e}").contains("long-lived"), "{e}");
4219 let (l, _) = load(&args(&["--instruction", "x"]), &base_env()).unwrap();
4222 assert_eq!(l.settings.store.kind, StoreKind::None);
4223 let (l, _) = load(
4225 &args(&["--instruction", "x", "--store.kind", "memory"]),
4226 &base_env(),
4227 )
4228 .unwrap();
4229 assert!(
4230 l.warnings.iter().any(|w| w.contains("memory")),
4231 "{:?}",
4232 l.warnings
4233 );
4234 }
4235
4236 #[test]
4239 fn expand_env_str_covers_the_forms() {
4240 let env: HashMap<&str, &str> = [("HOST", "db.internal"), ("PORT", "5432")]
4241 .into_iter()
4242 .collect();
4243 assert_eq!(
4245 expand_env_str("${HOST}:${PORT}", &env).unwrap(),
4246 "db.internal:5432"
4247 );
4248 assert_eq!(
4250 expand_env_str("${MISSING:-fallback}", &env).unwrap(),
4251 "fallback"
4252 );
4253 assert_eq!(
4254 expand_env_str("${HOST:-fallback}", &env).unwrap(),
4255 "db.internal"
4256 );
4257 assert_eq!(
4259 expand_env_str("$HOST costs $5", &env).unwrap(),
4260 "$HOST costs $5"
4261 );
4262 assert_eq!(expand_env_str("$${HOST}", &env).unwrap(), "${HOST}");
4264 assert!(
4266 expand_env_str("${NOPE}", &env)
4267 .unwrap_err()
4268 .contains("NOPE")
4269 );
4270 assert!(expand_env_str("${HOST", &env).is_err());
4272 assert!(expand_env_str("${bad-name}", &env).is_err());
4273 }
4274
4275 #[test]
4276 fn env_substitution_reaches_config_values_and_workflows() {
4277 let file = write_tmp(
4278 "config_version: \"2\"\n\
4279 agent:\n name: ${SVC_NAME}\n instruction: serve\n preflight: never\n\
4280 intelligence:\n endpoints: [https://x/v1]\n model: m\n\
4281 store:\n kind: memory\n\
4282 workflows:\n - name: w\n steps:\n\
4283 \x20 s: {kind: once}\n\
4284 \x20 c: {kind: http, depends_on: [s], url: \"https://api.${REGION:-us}.example/${SVC_NAME}\"}\n\
4285 \x20 f: {kind: finish, depends_on: [c]}\n",
4286 "yaml",
4287 );
4288 let mut env = base_env();
4289 env.push(("SVC_NAME".into(), "billing".into()));
4290 let (l, _) = load(&args(&["--config", file.path().to_str().unwrap()]), &env).unwrap();
4292 assert_eq!(
4294 l.settings.agent.name.as_deref(),
4295 Some("billing"),
4296 "the `${{SVC_NAME}}` in a config value was substituted"
4297 );
4298 let url = l.settings.workflows[0]
4301 .pointer("/steps/c/url")
4302 .and_then(Value::as_str)
4303 .unwrap_or_default();
4304 assert_eq!(
4305 url, "https://api.us.example/billing",
4306 "the workflow value was substituted (default + set var)"
4307 );
4308 }
4309
4310 #[test]
4311 fn mcp_server_oauth_is_carried_to_the_runtime_spec() {
4312 let s = McpServer {
4316 name: "gh".into(),
4317 endpoint: "https://mcp.example".into(),
4318 ns: None,
4319 headers: BTreeMap::new(),
4320 tags: BTreeMap::new(),
4321 aauth: None,
4322 oauth: Some(McpOauth {
4323 token_url: "https://auth.example/token".into(),
4324 client_id: "cid".into(),
4325 client_secret: Secret("{{secret:CS}}".into()),
4326 scope: Some("mcp:read".into()),
4327 }),
4328 auth: None,
4329 timeout: None,
4330 };
4331 let spec = s.to_spec().unwrap();
4332 let o = spec.oauth.expect("oauth reaches the runtime spec");
4333 assert_eq!(o.token_url, "https://auth.example/token");
4334 assert_eq!(o.client_id, "cid");
4335 assert_eq!(o.client_secret, "{{secret:CS}}");
4337 assert_eq!(o.scope.as_deref(), Some("mcp:read"));
4338 }
4339
4340 #[test]
4341 fn files_env_flags_layer_in_order_with_aliases() {
4342 let base = write_tmp(
4343 "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",
4344 "yaml",
4345 );
4346 let over = write_tmp("intelligence:\n model: over-model\n", "yml");
4347 let mut env = base_env();
4348 env.clear();
4349 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(
4353 &args(&[
4354 "--config",
4355 base.path().to_str().unwrap(),
4356 "--config",
4357 over.path().to_str().unwrap(),
4358 "--max-steps",
4359 "30",
4360 "--mcp",
4361 "fs=https://fs.example/mcp",
4362 "--mcp-tags",
4363 "fs=sensitive",
4364 "--intelligence.headers.x-team",
4365 "ops",
4366 ]),
4367 &env,
4368 )
4369 .unwrap();
4370 let s = &l.settings;
4371 assert_eq!(
4372 s.agent.instruction.as_deref(),
4373 Some("env-instruction"),
4374 "env > file"
4375 );
4376 assert_eq!(
4377 s.intelligence.model.as_deref(),
4378 Some("env-model"),
4379 "env alias > later file"
4380 );
4381 assert_eq!(s.limits.run.steps(), 30, "flag alias > env");
4382 assert_eq!(s.mcp.servers.len(), 1);
4383 assert_eq!(s.mcp.servers[0].name, "fs");
4384 assert_eq!(s.mcp.servers[0].tags["*"], vec!["sensitive"]);
4385 assert_eq!(
4386 s.intelligence.headers.get("x-team").map(String::as_str),
4387 Some("ops")
4388 );
4389 assert_eq!(l.files.len(), 2);
4390 let env2: Vec<(String, String)> = vec![
4392 ("AGENT_MODEL".into(), "legacy".into()),
4393 ("AGENTD_INTELLIGENCE_MODEL".into(), "path".into()),
4394 ("AGENTD_INTELLIGENCE_ENDPOINTS".into(), "https://i".into()),
4395 ];
4396 let (l2, _) = load(
4397 &args(&["--instruction", "x", "--store.kind", "memory"]),
4398 &env2,
4399 )
4400 .unwrap();
4401 assert_eq!(l2.settings.intelligence.model.as_deref(), Some("path"));
4402 }
4403
4404 #[test]
4405 fn removed_flags_name_their_replacement() {
4406 for (flag, _) in REMOVED_FLAGS {
4407 let e = load(&args(&[flag, "x"]), &base_env()).unwrap_err();
4408 assert!(
4409 format!("{e}").contains("removed in agentd 2.0"),
4410 "{flag}: {e}"
4411 );
4412 }
4413 let e = load(&args(&["--mode", "reactive"]), &base_env()).unwrap_err();
4414 assert!(format!("{e}").contains("start node"), "{e}");
4415 }
4416
4417 #[test]
4418 fn mixed_and_v1_files_are_refused_by_the_v2_loader() {
4419 let mixed = write_tmp("agent: {instruction: x}\nmodel: m\n", "yaml");
4420 let e = load(
4421 &args(&["--config", mixed.path().to_str().unwrap()]),
4422 &base_env(),
4423 )
4424 .unwrap_err();
4425 assert!(format!("{e}").contains("mixes v1"), "{e}");
4426 let v1 = write_tmp("model: m\n", "yaml");
4427 let e = load(
4428 &args(&["--config", v1.path().to_str().unwrap()]),
4429 &base_env(),
4430 )
4431 .unwrap_err();
4432 assert!(format!("{e}").contains("v1 schema"), "{e}");
4433 }
4434
4435 #[test]
4436 fn budget_exit_code_and_instruction_file_aliases() {
4437 let f = write_tmp("read me from a file", "txt");
4438 let (l, _) = load(
4439 &args(&[
4440 "--instruction-file",
4441 f.path().to_str().unwrap(),
4442 "--budget-exit-code",
4443 "9",
4444 "--store.kind",
4445 "memory",
4446 ]),
4447 &base_env(),
4448 )
4449 .unwrap();
4450 assert_eq!(
4451 l.settings.agent.instruction.as_deref(),
4452 Some("read me from a file")
4453 );
4454 assert_eq!(l.settings.lifecycle.exit_code_map.get("3"), Some(&9));
4455 assert_eq!(l.settings.lifecycle.exit_code_map.get("7"), Some(&9));
4456 }
4457
4458 fn load_doc(yaml: &str) -> Result<Loaded, ConfigError> {
4461 let f = write_tmp(yaml, "yaml");
4462 load(&args(&["--config", f.path().to_str().unwrap()]), &[]).map(|(l, _)| l)
4463 }
4464
4465 #[test]
4466 fn validation_collects_the_rfc_0030_rules() {
4467 let e = load_doc(
4469 "config_version: \"2\"\nintelligence:\n endpoints: [https://i]\n token: sk-inline\n",
4470 )
4471 .unwrap_err();
4472 assert!(format!("{e}").contains("inline credential"), "{e}");
4473 let (l, _) = load(
4474 &args(&[
4475 "--intelligence",
4476 "https://i",
4477 "--intelligence-token",
4478 "sk-inline",
4479 ]),
4480 &[],
4481 )
4482 .unwrap();
4483 assert_eq!(
4484 l.settings.intelligence.token.as_ref().map(|s| s.0.as_str()),
4485 Some("sk-inline")
4486 );
4487 assert!(
4488 !format!("{:?}", l.settings).contains("sk-inline"),
4489 "Debug redacts"
4490 );
4491
4492 let e = load_doc(
4495 "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",
4496 )
4497 .unwrap_err();
4498 assert!(matches!(e, ConfigError::Usage(_)), "{e}");
4499
4500 let f = write_tmp(
4502 "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",
4503 "yaml",
4504 );
4505 let e = load(
4506 &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
4507 &[],
4508 )
4509 .unwrap_err();
4510 let ConfigError::Validate(Err(lines)) = e else {
4511 panic!("expected a validate verdict, got {e:?}")
4512 };
4513 for needle in [
4514 "store.mcp.server 'nope'",
4515 "knowledge.server 'kb'",
4516 "skills.sources[]",
4517 "tools.overrides['memory.get']",
4518 "both disabled and overridden",
4519 "only the policy codes 3 and 7",
4520 "0..=255",
4521 ] {
4522 assert!(lines.contains(needle), "missing {needle} in:\n{lines}");
4523 }
4524
4525 let e = load_doc("config_version: \"2\"\nstore: {kind: memory}\na2a: {listen: \"https://0.0.0.0:8443\"}\n").unwrap_err();
4527 assert!(format!("{e}").contains("a2a.tls.cert"), "{e}");
4528 let e = load_doc("config_version: \"2\"\nstore: {kind: memory}\na2a: {listen: \"http://0.0.0.0:8080\"}\n").unwrap_err();
4529 assert!(format!("{e}").contains("loopback"), "{e}");
4530 let e = load_doc(
4532 "config_version: \"2\"\na2a: {principals: [{match: {any: true}, role: operator}]}\n",
4533 )
4534 .unwrap_err();
4535 assert!(format!("{e}").contains("operator role"), "{e}");
4536 let e = load_doc("config_version: \"2\"\nintelligence: {budget: {windows: [{per: hour}], on_exhausted: degrade}}\n").unwrap_err();
4538 assert!(format!("{e}").contains("tokens and/or requests"), "{e}");
4539 let e = load_doc(
4541 "config_version: \"2\"\nmcp:\n servers:\n - {name: fs, endpoint: https://fs/mcp, tags: {\"*\": [untrusted_input, sensitive, egress]}}\n",
4542 )
4543 .unwrap_err();
4544 assert!(format!("{e}").contains("lethal-trifecta"), "{e}");
4545 }
4546
4547 #[test]
4548 fn restart_only_diff_names_changed_paths() {
4549 let a = json!({"agent": {"name": "x", "instruction": "i"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
4550 let b = json!({"agent": {"name": "y", "instruction": "j"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
4551 assert_eq!(restart_only_diff(&a, &b), vec!["agent.name".to_string()]);
4552 let c = json!({"agent": {"name": "x", "instruction": "changed"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
4553 assert!(
4554 restart_only_diff(&a, &c).is_empty(),
4555 "instruction is reloadable"
4556 );
4557 }
4558
4559 #[test]
4560 fn duration_and_tool_select_scalars() {
4561 let s = Settings::from_document(
4562 json!({"limits": {"run": {"deadline": "90s"}, "step_timeout": 5}, "agent": {"tools": {"mcp": "none", "internal": ["memory.get"]}}}),
4563 "t",
4564 )
4565 .unwrap();
4566 assert_eq!(s.limits.run.deadline(), Duration::from_secs(90));
4567 assert_eq!(s.limits.step_timeout, Some(Dur(Duration::from_secs(5))));
4568 assert!(!s.agent.tools.mcp.allows("fs.read"));
4569 assert!(s.agent.tools.internal.allows("memory.get"));
4570 assert!(!s.agent.tools.internal.allows("finish"));
4571 assert!(s.agent.tools.code.allows("anything"));
4572 assert!(
4573 Settings::from_document(json!({"limits": {"run": {"deadline": "soon"}}}), "t").is_err()
4574 );
4575 }
4576
4577 #[test]
4580 fn file_store_root_walks_the_chain_in_order() {
4581 use std::ffi::OsString;
4582 use std::path::PathBuf;
4583 let env = |pairs: Vec<(&'static str, &'static str)>| {
4584 move |k: &str| -> Option<OsString> {
4585 pairs
4586 .iter()
4587 .find(|(n, _)| *n == k)
4588 .map(|(_, v)| OsString::from(*v))
4589 }
4590 };
4591 let all = vec![
4592 ("AGENTD_STATE_DIR", "/state-dir"),
4593 ("XDG_STATE_HOME", "/xdg"),
4594 ("HOME", "/home/a"),
4595 ];
4596 let with_file = |path: Option<&str>| Store {
4597 file: Some(StoreFile {
4598 path: path.map(str::to_string),
4599 }),
4600 ..Store::default()
4601 };
4602
4603 assert_eq!(
4605 file_store_root_in(&with_file(Some("/var/lib/agentd")), &env(all.clone())),
4606 PathBuf::from("/var/lib/agentd")
4607 );
4608 assert_eq!(
4611 file_store_root_in(&with_file(None), &env(all.clone())),
4612 PathBuf::from("/state-dir")
4613 );
4614 assert_eq!(
4616 file_store_root_in(&Store::default(), &env(all[1..].to_vec())),
4617 PathBuf::from("/xdg/agentd/state")
4618 );
4619 assert_eq!(
4621 file_store_root_in(&Store::default(), &env(all[2..].to_vec())),
4622 PathBuf::from("/home/a/.local/state/agentd/state")
4623 );
4624 assert_eq!(
4626 file_store_root_in(&Store::default(), &env(vec![])),
4627 std::env::temp_dir().join("agentd").join("state")
4628 );
4629 assert!(
4632 file_store_root_in(&Store::default(), &env(all[1..].to_vec()))
4633 .ends_with("agentd/state")
4634 );
4635 }
4636
4637 #[test]
4638 fn file_store_validation_diagnostics() {
4639 let l = load_doc("config_version: \"2\"\nstore: {kind: file}\n").unwrap();
4641 assert_eq!(l.settings.store.kind, StoreKind::File);
4642 assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
4643 let l = load_doc(
4645 "config_version: \"2\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\na2a: {listen: \"http://127.0.0.1:8080\"}\n",
4646 )
4647 .unwrap();
4648 assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
4649 assert_eq!(
4650 file_store_root(&l.settings.store),
4651 std::path::PathBuf::from("/var/lib/agentd")
4652 );
4653
4654 let e = load_doc("config_version: \"2\"\nstore: {kind: file, file: {path: \"\"}}\n")
4656 .unwrap_err();
4657 assert!(format!("{e}").contains("store.file.path is empty"), "{e}");
4658
4659 let l = load_doc(
4662 "config_version: \"2\"\nstore: {kind: memory, file: {path: /var/lib/agentd}}\n",
4663 )
4664 .unwrap();
4665 let d = validate(&l);
4666 assert!(d.errors.is_empty(), "{:?}", d.errors);
4667 assert!(
4668 d.warnings
4669 .iter()
4670 .any(|w| w.contains("store.file is set but store.kind is memory")),
4671 "{:?}",
4672 d.warnings
4673 );
4674 let l =
4676 load_doc("config_version: \"2\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\n")
4677 .unwrap();
4678 assert!(
4679 !validate(&l)
4680 .warnings
4681 .iter()
4682 .any(|w| w.contains("store.file")),
4683 "{:?}",
4684 validate(&l).warnings
4685 );
4686 assert_eq!(
4688 restart_only_diff(
4689 &json!({"store": {"kind": "file", "file": {"path": "/a"}}}),
4690 &json!({"store": {"kind": "file", "file": {"path": "/b"}}})
4691 ),
4692 vec!["store.file".to_string()]
4693 );
4694 }
4695
4696 #[test]
4697 fn instruction_uri_detection() {
4698 assert!(looks_like_resource_uri("mcp://docs/agent-instruction"));
4699 assert!(looks_like_resource_uri("docs://agent"));
4700 assert!(!looks_like_resource_uri("You are a helpful agent."));
4701 assert!(!looks_like_resource_uri(
4702 "see https://x.example for details"
4703 ));
4704 assert!(!looks_like_resource_uri("://nope"));
4705 }
4706
4707 #[test]
4708 fn help_and_schema_asks_short_circuit_validation() {
4709 let (_, ask) = load(&args(&["--help"]), &[]).unwrap();
4710 assert_eq!(ask, Ask::Help);
4711 let (_, ask) = load(&args(&["--config-schema=2"]), &[]).unwrap();
4712 assert_eq!(ask, Ask::Schema);
4713 let (_, ask) = load(&args(&["--workflow-schema"]), &[]).unwrap();
4716 assert_eq!(ask, Ask::WorkflowSchema);
4717 assert!(help_section().contains("intelligence.model"));
4718 }
4719}