1pub mod directives;
20pub mod envfile;
21pub mod file;
22pub mod paths;
23pub mod prompt;
24pub mod templates;
25pub mod v2;
26#[cfg(all(unix, feature = "config-watch"))]
27pub mod watch;
28pub mod yaml;
29
30use crate::obs::log::Level;
31use crate::sec::scope::TrifectaTag;
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use std::fmt;
35use std::path::{Path, PathBuf};
36use std::time::{Duration, SystemTime, UNIX_EPOCH};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Mode {
42 Once,
44 Loop,
46 Reactive,
48 Schedule,
50 #[cfg(feature = "workflow")]
53 Workflow,
54}
55
56impl Mode {
57 pub fn as_str(self) -> &'static str {
58 match self {
59 Mode::Once => "once",
60 Mode::Loop => "loop",
61 Mode::Reactive => "reactive",
62 Mode::Schedule => "schedule",
63 #[cfg(feature = "workflow")]
64 Mode::Workflow => "workflow",
65 }
66 }
67 pub fn parse(s: &str) -> Option<Mode> {
68 match s {
69 "once" => Some(Mode::Once),
70 "loop" => Some(Mode::Loop),
71 "reactive" => Some(Mode::Reactive),
72 "schedule" => Some(Mode::Schedule),
73 #[cfg(feature = "workflow")]
74 "workflow" => Some(Mode::Workflow),
75 _ => None,
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum SwapPolicy {
89 #[default]
93 FinishOnOld,
94 RestartTurn,
99}
100
101impl SwapPolicy {
102 pub fn as_str(self) -> &'static str {
103 match self {
104 SwapPolicy::FinishOnOld => "finish-on-old",
105 SwapPolicy::RestartTurn => "restart-turn",
106 }
107 }
108 pub fn parse(s: &str) -> Option<SwapPolicy> {
109 match s {
110 "finish-on-old" => Some(SwapPolicy::FinishOnOld),
111 "restart-turn" => Some(SwapPolicy::RestartTurn),
112 _ => None,
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum ServeTarget {
123 Http { bind: String, tls: bool },
127 Unix { path: String },
131}
132
133impl ServeTarget {
134 pub fn parse(spec: &str) -> Result<ServeTarget, ConfigError> {
138 if let Some(tls) = spec
143 .strip_prefix("https://")
144 .map(|_| true)
145 .or_else(|| spec.strip_prefix("http://").map(|_| false))
146 {
147 let authority = spec.split("://").nth(1).unwrap_or("");
148 if authority.is_empty() || authority.contains('/') {
149 return Err(usage(format!(
150 "--serve-mcp: want http(s)://HOST:PORT with no path (got: {spec})"
151 )));
152 }
153 let host = serve_host_of(authority);
154 let port_ok = serve_port_of(authority).is_some();
155 if host.is_empty() || !port_ok {
156 return Err(usage(format!(
157 "a2a.listen: HTTP(S) target needs an explicit host:port (got: {spec})"
158 )));
159 }
160 if !tls && !crate::net::http::is_loopback_host(host) {
161 return Err(usage(format!(
162 "--serve-mcp: plaintext http:// is allowed for loopback only; use https:// (got: {spec})"
163 )));
164 }
165 return Ok(ServeTarget::Http {
166 bind: authority.to_string(),
167 tls,
168 });
169 }
170 if let Some(path) = spec
171 .strip_prefix("unix://")
172 .or_else(|| spec.strip_prefix("unix:"))
173 {
174 if path.is_empty() {
175 return Err(usage(format!("unix listener needs a socket path: {spec}")));
176 }
177 if !cfg!(unix) {
178 return Err(usage(format!(
179 "unix:// listeners are unix-only (got: {spec}); use https://"
180 )));
181 }
182 return Ok(ServeTarget::Unix {
183 path: path.to_string(),
184 });
185 }
186 Err(usage(format!(
187 "--serve-mcp: want https://host:port (or loopback http://host:port for dev): {spec}"
188 )))
189 }
190}
191
192impl Config {
193 fn validate_serve_auth(
200 &self,
201 target: &ServeTarget,
202 env: &dyn Fn(&str) -> Option<String>,
203 ) -> Result<(), ConfigError> {
204 let ServeTarget::Http { bind, tls } = target else {
205 return Ok(());
208 };
209 let (bind, tls) = (bind.as_str(), *tls);
210 if tls {
211 match (&self.serve_cert, &self.serve_key) {
212 (Some(cert), Some(key)) => {
213 check_readable("--serve-cert", cert)?;
214 check_readable("--serve-key", key)?;
215 }
216 _ => {
217 return Err(usage(
218 "--serve-mcp https:// requires --serve-cert and --serve-key (PEM file paths)".into(),
219 ));
220 }
221 }
222 } else if self.serve_cert.is_some() || self.serve_key.is_some() {
223 return Err(usage(
224 "--serve-cert/--serve-key need an https:// serve target (plaintext http:// is loopback dev only)".into(),
225 ));
226 }
227 if let Some(ca) = &self.serve_client_ca {
228 check_readable("--serve-client-ca", ca)?;
229 }
230 if let Some(bearer) = &self.serve_bearer {
231 crate::sec::secret::refs_resolvable(bearer, env)
232 .map_err(|e| usage(format!("--serve-bearer: {e}")))?;
233 }
234 let loopback = crate::net::http::is_loopback_host(serve_host_of(bind));
236 if !loopback && self.serve_client_ca.is_none() && self.serve_bearer.is_none() {
237 return Err(usage(
238 "a non-loopback a2a.listen needs client auth: set a2a.tls.client_ca (mTLS) and/or a2a.bearer".into(),
239 ));
240 }
241 Ok(())
242 }
243}
244
245fn check_readable(flag: &str, path: &str) -> Result<(), ConfigError> {
249 std::fs::File::open(path).map_err(|e| usage(format!("{flag}: cannot read {path}: {e}")))?;
250 Ok(())
251}
252
253pub(crate) fn serve_host_of(authority: &str) -> &str {
256 if let Some(rest) = authority.strip_prefix('[') {
257 return rest.split(']').next().unwrap_or(rest);
258 }
259 authority.rsplit_once(':').map_or(authority, |(h, _)| h)
260}
261
262fn serve_port_of(authority: &str) -> Option<u16> {
264 let port_str = if authority.starts_with('[') {
265 authority.rsplit_once("]:").map(|(_, p)| p)?
266 } else {
267 authority.rsplit_once(':').map(|(_, p)| p)?
268 };
269 port_str.parse::<u16>().ok().filter(|p| *p != 0)
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct A2aPeerSpec {
280 pub name: String,
281 pub endpoint: String,
282 #[serde(default, skip_serializing_if = "Vec::is_empty")]
288 pub headers: Vec<(String, String)>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub client_cert: Option<String>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub client_key: Option<String>,
296}
297
298impl A2aPeerSpec {
299 pub fn endpoint_of(&self) -> Result<A2aEndpoint, String> {
304 A2aEndpoint::parse(&self.endpoint).map_err(|e| e.to_string())
305 }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
313pub enum A2aEndpoint {
314 Https(String),
319}
320
321impl A2aEndpoint {
322 pub fn parse(spec: &str) -> Result<A2aEndpoint, ConfigError> {
326 if spec.starts_with("https://") {
327 return Ok(A2aEndpoint::Https(spec.to_string()));
328 }
329 if spec.starts_with("http://") {
330 let host = crate::net::http::Url::parse(spec)
331 .map(|u| u.host)
332 .unwrap_or_default();
333 if !crate::net::http::is_loopback_host(&host) {
334 return Err(usage(format!(
335 "--a2a-peer: plaintext http:// is allowed for loopback only; use https:// (got: {spec})"
336 )));
337 }
338 return Ok(A2aEndpoint::Https(spec.to_string()));
339 }
340 if let Some(path) = spec
345 .strip_prefix("unix://")
346 .or_else(|| spec.strip_prefix("unix:"))
347 {
348 if path.is_empty() || !cfg!(unix) {
349 return Err(usage(format!(
350 "--a2a-peer: unix: endpoint needs a socket path (unix-only): {spec}"
351 )));
352 }
353 return Ok(A2aEndpoint::Https(spec.to_string()));
354 }
355 Err(usage(format!(
356 "--a2a-peer: endpoint must be https://host[:port] (or loopback http:// for dev, or unix:///path for a co-located peer): {spec}"
357 )))
358 }
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
368pub struct McpServerSpec {
369 pub name: String,
370 pub endpoint: String,
373 #[serde(default, skip_serializing_if = "Vec::is_empty")]
377 pub headers: Vec<(String, String)>,
378 #[serde(default, skip_serializing_if = "Vec::is_empty")]
384 pub tags: Vec<TrifectaTag>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub aauth: Option<bool>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub oauth: Option<McpOauthSpec>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub auth: Option<AuthSpec>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub service: Option<String>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub rate: Option<String>,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419pub struct McpOauthSpec {
420 pub token_url: String,
421 pub client_id: String,
422 pub client_secret: String,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub scope: Option<String>,
426}
427
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
433pub struct AuthSpec {
434 pub kind: String,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub grant: Option<String>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub issuer: Option<String>,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub token_url: Option<String>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub device_authorization_url: Option<String>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub authorization_url: Option<String>,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub client_id: Option<String>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub client_secret: Option<String>,
450 #[serde(default, skip_serializing_if = "Vec::is_empty")]
451 pub scopes: Vec<String>,
452 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub audience: Option<String>,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub token: Option<String>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub header: Option<String>,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub value: Option<String>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub region: Option<String>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub service: Option<String>,
468 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub source: Option<String>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub sso_start_url: Option<String>,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub account_id: Option<String>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub role_name: Option<String>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub svid: Option<String>,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub jwt_svid_file: Option<String>,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub svid_file: Option<String>,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub key_file: Option<String>,
486}
487
488#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
494pub struct AAuthSettings {
495 pub provider: String,
497 pub key_file: String,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub enrollment_token: Option<String>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub enroll_assertion_file: Option<String>,
511 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub person_server: Option<String>,
516}
517
518pub fn is_mcp_endpoint(s: &str) -> bool {
521 let s = s.trim();
522 s.starts_with("https://") || s.starts_with("http://")
527}
528
529pub fn mcp_endpoint_scheme_ok(endpoint: &str) -> Result<(), ConfigError> {
536 let e = endpoint.trim();
537 if e.starts_with("https://") {
538 return Ok(());
539 }
540 if let Some(rest) = e.strip_prefix("http://") {
541 let host = rest.split('/').next().unwrap_or(rest);
542 let host = if host.starts_with('[') {
543 host.split(']').next().map_or(host, |h| &h[1..])
544 } else {
545 host.rsplit_once(':').map_or(host, |(h, _)| h)
546 };
547 if crate::net::http::is_loopback_host(host) {
548 return Ok(());
549 }
550 return Err(usage(format!(
551 "mcp endpoint plaintext http:// is allowed for loopback only; use https:// (got: {endpoint})"
552 )));
553 }
554 Err(usage(format!(
555 "mcp endpoint must be https://host[:port][/path] (got: {endpoint})"
556 )))
557}
558
559#[derive(Clone, PartialEq)]
561pub struct Config {
562 pub instruction: Option<String>,
563 pub intelligence: Option<String>,
564 pub intelligence_token: Option<String>,
565 pub intelligence_token_file: Option<String>,
571 pub model: Option<String>,
572 pub model_swap: SwapPolicy,
578 pub mcp_servers: Vec<McpServerSpec>,
579 pub a2a_peers: Vec<A2aPeerSpec>,
583 pub mode: Mode,
584 pub subscribe: Vec<String>,
585 pub continue_subscribe: Vec<String>,
589 pub interval: Option<Duration>,
590 pub max_steps: u32,
591 pub max_tokens: u64,
592 pub budget_tokens_lifetime: u64,
599 pub deadline: Option<Duration>,
600 pub max_depth: u32,
601 pub run_id: String,
602 pub log_level: Level,
603 pub drain_timeout: Duration,
604 #[cfg(feature = "workflow")]
607 pub workflow_file: Option<String>,
608 #[cfg(feature = "workflow")]
612 pub workflow_resume: Option<crate::subagent::protocol::WorkflowResumeRef>,
613 pub serve_mcp: Option<String>,
614 pub serve_cert: Option<String>,
618 pub serve_key: Option<String>,
619 pub serve_client_ca: Option<String>,
623 pub serve_bearer: Option<String>,
628 pub tls_ca: Option<String>,
636 pub aauth: Option<AAuthSettings>,
642 pub health_file: Option<String>,
643 pub traceparent: Option<String>,
646 pub log_content: bool,
651 pub metrics_addr: Option<String>,
654 pub cgroup: Option<String>,
661 pub cgroup_memory_max: Option<String>,
665 pub cgroup_pids_max: Option<String>,
669 pub allow_trifecta: bool,
674 pub cron: Option<String>,
678 pub report_file: Option<String>,
684 pub budget_exit_code: Option<i32>,
695 pub events_ring: usize,
700 pub intelligence_headers: std::collections::BTreeMap<String, String>,
707 pub watch_config: bool,
720 pub config_files: Vec<String>,
726}
727
728impl Default for Config {
729 fn default() -> Self {
730 Config {
731 instruction: None,
732 intelligence: None,
733 intelligence_token: None,
734 intelligence_token_file: None,
735 model: None,
736 model_swap: SwapPolicy::FinishOnOld,
737 mcp_servers: Vec::new(),
738 a2a_peers: Vec::new(),
739 mode: Mode::Once,
740 subscribe: Vec::new(),
741 continue_subscribe: Vec::new(),
742 interval: None,
743 max_steps: 50,
744 max_tokens: 200_000,
745 budget_tokens_lifetime: 0,
746 deadline: Some(Duration::from_secs(600)),
747 max_depth: 4,
748 run_id: String::new(), log_level: Level::Info,
750 drain_timeout: Duration::from_secs(25),
751 #[cfg(feature = "workflow")]
752 workflow_file: None,
753 #[cfg(feature = "workflow")]
754 workflow_resume: None,
755 serve_mcp: None,
756 serve_cert: None,
757 serve_key: None,
758 serve_client_ca: None,
759 serve_bearer: None,
760 tls_ca: None,
761 aauth: None,
762 health_file: None,
763 traceparent: None,
764 log_content: false,
765 metrics_addr: None,
766 cgroup: None,
767 cgroup_memory_max: None,
768 cgroup_pids_max: None,
769 allow_trifecta: false,
770 cron: None,
771 report_file: None,
772 budget_exit_code: None,
773 events_ring: crate::obs::log::EVENTS_RING_DEFAULT,
774 intelligence_headers: std::collections::BTreeMap::new(),
775 watch_config: false,
778 config_files: Vec::new(),
779 }
780 }
781}
782
783impl fmt::Debug for Config {
785 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
786 f.debug_struct("Config")
787 .field("instruction", &self.instruction.as_deref().map(|_| "<set>"))
788 .field(
794 "intelligence",
795 &self
796 .intelligence
797 .as_deref()
798 .map(|u| format!("{}:<redacted>", u.split(':').next().unwrap_or(""))),
799 )
800 .field(
801 "intelligence_token",
802 &self.intelligence_token.as_ref().map(|_| "***"),
803 )
804 .field("intelligence_token_file", &self.intelligence_token_file)
805 .field("model", &self.model)
806 .field("model_swap", &self.model_swap.as_str())
807 .field("mcp_servers", &self.mcp_servers)
808 .field("a2a_peers", &self.a2a_peers)
809 .field("mode", &self.mode)
810 .field("subscribe", &self.subscribe)
811 .field("continue_subscribe", &self.continue_subscribe)
812 .field("interval", &self.interval)
813 .field("max_steps", &self.max_steps)
814 .field("max_tokens", &self.max_tokens)
815 .field("budget_tokens_lifetime", &self.budget_tokens_lifetime)
816 .field("deadline", &self.deadline)
817 .field("max_depth", &self.max_depth)
818 .field("run_id", &self.run_id)
819 .field("log_level", &self.log_level)
820 .field("drain_timeout", &self.drain_timeout)
821 .field("serve_mcp", &self.serve_mcp)
822 .field("serve_cert", &self.serve_cert)
825 .field("serve_key", &self.serve_key)
826 .field("serve_client_ca", &self.serve_client_ca)
827 .field(
828 "serve_bearer",
829 &self.serve_bearer.as_ref().map(|_| "<redacted>"),
830 )
831 .field("tls_ca", &self.tls_ca)
832 .field("health_file", &self.health_file)
833 .field("traceparent", &self.traceparent)
834 .field("log_content", &self.log_content)
835 .field("metrics_addr", &self.metrics_addr)
836 .field("cgroup", &self.cgroup)
837 .field("cgroup_memory_max", &self.cgroup_memory_max)
838 .field("cgroup_pids_max", &self.cgroup_pids_max)
839 .field("allow_trifecta", &self.allow_trifecta)
840 .field("cron", &self.cron)
841 .field("report_file", &self.report_file)
842 .field("events_ring", &self.events_ring)
843 .field(
846 "intelligence_headers",
847 &self.intelligence_headers.keys().collect::<Vec<_>>(),
848 )
849 .field("watch_config", &self.watch_config)
850 .field("config_files", &self.config_files)
851 .finish()
852 }
853}
854
855#[derive(Debug)]
862pub enum ConfigError {
863 Help(String),
864 Version(String),
865 Capabilities(String),
866 Usage(String),
867 Schema(String),
871 Validate(Result<String, String>),
875}
876
877impl fmt::Display for ConfigError {
878 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
879 match self {
880 ConfigError::Help(s)
881 | ConfigError::Version(s)
882 | ConfigError::Capabilities(s)
883 | ConfigError::Schema(s) => {
884 write!(f, "{s}")
885 }
886 ConfigError::Usage(s) => write!(f, "{s}"),
887 ConfigError::Validate(Ok(s)) | ConfigError::Validate(Err(s)) => write!(f, "{s}"),
888 }
889 }
890}
891
892pub(crate) fn debrand_env(env: &[(String, String)]) -> Vec<(String, String)> {
901 let have: std::collections::HashSet<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
902 let mut out: Vec<(String, String)> = env.to_vec();
903 for (k, v) in env {
904 if let Some(suffix) = k.strip_prefix("AGENT_") {
907 let branded = format!("AGENTD_{suffix}");
908 if !have.contains(branded.as_str()) {
909 out.push((branded, v.clone()));
910 }
911 }
912 }
913 out
914}
915
916impl Config {
917 pub fn load(args: &[String], env: &[(String, String)]) -> Result<Config, ConfigError> {
921 let env = debrand_env(env);
928 let envmap: HashMap<&str, &str> =
929 env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
930
931 if args.iter().any(|a| a == "--config-schema") {
935 let schema = crate::config::file::config_schema();
936 let json = serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string());
937 return Err(ConfigError::Schema(format!("{json}\n")));
938 }
939 let validate_config = args.iter().any(|a| a == "--validate-config");
943
944 let mut c = Config::default();
945
946 let config_paths = config_paths_from_map(args, &envmap).paths;
958 let file_present = !config_paths.is_empty();
959 if file_present {
960 let (doc, loaded) = file::read_documents(&config_paths).map_err(usage)?;
961 apply_document(&mut c, doc, "config file", false)?;
962 c.config_files = loaded.into_iter().map(|(p, _)| p).collect();
963 }
964
965 if let Some(v) = envmap
972 .get("AGENTD_INSTRUCTION")
973 .or_else(|| envmap.get("INSTRUCTION"))
974 {
975 c.instruction = Some((*v).to_string());
976 }
977 if let Some(v) = envmap
978 .get("AGENTD_INTELLIGENCE")
979 .or_else(|| envmap.get("INTELLIGENCE"))
980 {
981 c.intelligence = Some((*v).to_string());
982 }
983 if let Some(v) = envmap.get("AGENTD_INTELLIGENCE_TOKEN") {
984 c.intelligence_token = Some((*v).to_string());
985 }
986 if let Some(v) = envmap.get("AGENTD_INTELLIGENCE_TOKEN_FILE") {
987 c.intelligence_token_file = Some((*v).to_string());
988 }
989 if let Some(v) = envmap.get("AGENTD_TLS_CA") {
990 c.tls_ca = Some((*v).to_string());
991 }
992 if let Some(v) = envmap.get("AGENTD_MODEL") {
993 c.model = Some((*v).to_string());
994 }
995 if let Some(v) = envmap.get("AGENTD_MODEL_SWAP") {
996 c.model_swap = SwapPolicy::parse(v).ok_or_else(|| {
997 usage(format!(
998 "invalid AGENTD_MODEL_SWAP: {v} (want finish-on-old|restart-turn)"
999 ))
1000 })?;
1001 }
1002 if let Some(v) = envmap.get("AGENTD_MODE") {
1003 c.mode = Mode::parse(v).ok_or_else(|| usage(format!("invalid AGENTD_MODE: {v}")))?;
1004 }
1005 if let Some(v) = envmap.get("AGENTD_MAX_STEPS") {
1006 c.max_steps = v
1007 .parse()
1008 .map_err(|_| usage(format!("invalid AGENTD_MAX_STEPS: {v}")))?;
1009 }
1010 if let Some(v) = envmap.get("AGENTD_MAX_TOKENS") {
1011 c.max_tokens = v
1012 .parse()
1013 .map_err(|_| usage(format!("invalid AGENTD_MAX_TOKENS: {v}")))?;
1014 }
1015 if let Some(v) = envmap.get("AGENTD_BUDGET_TOKENS") {
1019 c.budget_tokens_lifetime = v
1020 .parse()
1021 .map_err(|_| usage(format!("invalid AGENTD_BUDGET_TOKENS: {v}")))?;
1022 }
1023 if let Some(v) = envmap.get("AGENTD_DEADLINE") {
1024 c.deadline = Some(parse_duration(v).map_err(usage)?);
1025 }
1026 if let Some(v) = envmap.get("AGENTD_RUN_ID") {
1027 c.run_id = (*v).to_string();
1028 }
1029 if let Some(v) = envmap.get("AGENTD_LOG_LEVEL") {
1030 c.log_level =
1031 Level::parse(v).ok_or_else(|| usage(format!("invalid AGENTD_LOG_LEVEL: {v}")))?;
1032 }
1033 if let Some(v) = envmap.get("AGENTD_DRAIN_TIMEOUT") {
1034 c.drain_timeout = parse_duration(v).map_err(usage)?;
1035 }
1036 if let Some(v) = envmap.get("AGENTD_LOG_CONTENT") {
1037 c.log_content = truthy(v);
1038 }
1039 if let Some(v) = envmap.get("AGENTD_METRICS_ADDR") {
1040 c.metrics_addr = Some((*v).to_string());
1041 }
1042 if let Some(v) = envmap.get("AGENTD_CGROUP") {
1043 c.cgroup = Some((*v).to_string());
1044 }
1045 if let Some(v) = envmap.get("AGENTD_CGROUP_MEMORY_MAX") {
1046 c.cgroup_memory_max = Some((*v).to_string());
1047 }
1048 if let Some(v) = envmap.get("AGENTD_CGROUP_PIDS_MAX") {
1049 c.cgroup_pids_max = Some((*v).to_string());
1050 }
1051 if let Some(v) = envmap.get("AGENTD_ALLOW_TRIFECTA") {
1052 c.allow_trifecta = truthy(v);
1053 }
1054 if let Some(v) = envmap.get("AGENTD_CRON") {
1055 c.cron = Some((*v).to_string());
1056 }
1057 if let Some(v) = envmap.get("AGENTD_REPORT_FILE") {
1058 c.report_file = Some((*v).to_string());
1059 }
1060 if let Some(v) = envmap.get("AGENTD_EVENTS_RING") {
1061 c.events_ring = v
1062 .parse()
1063 .map_err(|_| usage(format!("invalid AGENTD_EVENTS_RING: {v}")))?;
1064 }
1065 #[cfg(feature = "workflow")]
1066 if let Some(v) = envmap.get("AGENTD_WORKFLOW") {
1067 c.workflow_file = Some((*v).to_string());
1068 }
1069 #[cfg(feature = "workflow")]
1070 if let Some(v) = envmap.get("AGENTD_WORKFLOW_RESUME") {
1071 c.workflow_resume = Some(parse_workflow_resume(v)?);
1072 }
1073 if let Some(v) = envmap.get("AGENTD_SERVE_MCP") {
1074 c.serve_mcp = Some((*v).to_string());
1075 }
1076 if let Some(v) = envmap.get("AGENTD_SERVE_CERT") {
1078 c.serve_cert = Some((*v).to_string());
1079 }
1080 if let Some(v) = envmap.get("AGENTD_SERVE_KEY") {
1081 c.serve_key = Some((*v).to_string());
1082 }
1083 if let Some(v) = envmap.get("AGENTD_SERVE_CLIENT_CA") {
1084 c.serve_client_ca = Some((*v).to_string());
1085 }
1086 if let Some(v) = envmap.get("AGENTD_SERVE_BEARER") {
1087 c.serve_bearer = Some((*v).to_string());
1088 }
1089 if let Some(v) = envmap.get("AGENTD_WATCH_CONFIG") {
1093 c.watch_config = truthy(v);
1094 }
1095 if let Some(v) = envmap.get("AGENTD_A2A_PEER") {
1098 c.a2a_peers.push(parse_a2a_peer_spec(v)?);
1099 }
1100 if let Some(v) = envmap.get("AGENTD_TRACEPARENT") {
1101 c.traceparent = Some((*v).to_string());
1102 }
1103
1104 {
1113 let (doc, applied) = paths::env_document(&envmap).map_err(usage)?;
1114 if !applied.is_empty() {
1115 apply_document(&mut c, doc, "env", true)?;
1118 }
1119 }
1120
1121 let mut mcp_tags: Vec<(String, Vec<TrifectaTag>)> = Vec::new();
1125 let mut capabilities = false;
1130 let mut aauth_provider: Option<String> = None;
1133 let mut aauth_key_file: Option<String> = None;
1134 let mut aauth_enroll_token: Option<String> = None;
1135 let mut aauth_enroll_assertion_file: Option<String> = None;
1136 let mut aauth_person_server: Option<String> = None;
1137 let mut it = args.iter().peekable();
1138 while let Some(arg) = it.next() {
1139 let mut take = |name: &str| -> Result<String, ConfigError> {
1140 it.next()
1141 .cloned()
1142 .ok_or_else(|| usage(format!("{name} requires a value")))
1143 };
1144 match arg.as_str() {
1145 "-h" | "--help" => return Err(ConfigError::Help(help_text())),
1146 "-V" | "--version" => {
1147 return Err(ConfigError::Version(format!("agentd {}\n", crate::VERSION)));
1148 }
1149 "--capabilities" => capabilities = true,
1150 "--config" | "-c" => {
1153 let _ = take("--config")?;
1154 }
1155 a if matches!(config_flag(a), ConfigFlag::Inline(_)) => {}
1157 "--config-schema" | "--validate-config" => {}
1161 "--instruction" => c.instruction = Some(take("--instruction")?),
1162 "--intelligence-token-file" => {
1163 c.intelligence_token_file = Some(take("--intelligence-token-file")?)
1164 }
1165 "--instruction-file" => {
1166 let p = take("--instruction-file")?;
1167 c.instruction = Some(read_file(&p)?);
1168 }
1169 "--intelligence" => c.intelligence = Some(take("--intelligence")?),
1170 "--intelligence-token" => {
1171 c.intelligence_token = Some(take("--intelligence-token")?)
1172 }
1173 "--model" => c.model = Some(take("--model")?),
1174 "--model-swap" => {
1175 let v = take("--model-swap")?;
1176 c.model_swap = SwapPolicy::parse(&v).ok_or_else(|| {
1177 usage(format!(
1178 "invalid --model-swap: {v} (want finish-on-old|restart-turn)"
1179 ))
1180 })?;
1181 }
1182 "--mcp" => {
1183 let spec = take("--mcp")?;
1184 c.mcp_servers.push(parse_mcp_spec(&spec)?);
1185 }
1186 "--a2a-peer" => {
1187 let spec = take("--a2a-peer")?;
1188 c.a2a_peers.push(parse_a2a_peer_spec(&spec)?);
1189 }
1190 "--mode" => {
1191 let v = take("--mode")?;
1192 c.mode =
1193 Mode::parse(&v).ok_or_else(|| usage(format!("invalid --mode: {v}")))?;
1194 }
1195 "--subscribe" => c.subscribe.push(take("--subscribe")?),
1196 "--continue" => c.continue_subscribe.push(take("--continue")?),
1197 "--interval" => {
1198 c.interval = Some(parse_duration(&take("--interval")?).map_err(usage)?)
1199 }
1200 "--cron" => c.cron = Some(take("--cron")?),
1201 "--max-steps" => {
1202 let v = take("--max-steps")?;
1203 c.max_steps = v
1204 .parse()
1205 .map_err(|_| usage(format!("invalid --max-steps: {v}")))?;
1206 }
1207 "--max-tokens" => {
1208 let v = take("--max-tokens")?;
1209 c.max_tokens = v
1210 .parse()
1211 .map_err(|_| usage(format!("invalid --max-tokens: {v}")))?;
1212 }
1213 "--budget-tokens-lifetime" => {
1214 let v = take("--budget-tokens-lifetime")?;
1215 c.budget_tokens_lifetime = v
1216 .parse()
1217 .map_err(|_| usage(format!("invalid --budget-tokens-lifetime: {v}")))?;
1218 }
1219 "--deadline" => {
1220 c.deadline = Some(parse_duration(&take("--deadline")?).map_err(usage)?)
1221 }
1222 "--max-depth" => {
1223 let v = take("--max-depth")?;
1224 c.max_depth = v
1225 .parse()
1226 .map_err(|_| usage(format!("invalid --max-depth: {v}")))?;
1227 }
1228 "--run-id" => c.run_id = take("--run-id")?,
1229 "--log-level" => {
1230 let v = take("--log-level")?;
1231 c.log_level = Level::parse(&v)
1232 .ok_or_else(|| usage(format!("invalid --log-level: {v}")))?;
1233 }
1234 "--drain-timeout" => {
1235 c.drain_timeout = parse_duration(&take("--drain-timeout")?).map_err(usage)?
1236 }
1237 "--log-content" => c.log_content = true,
1238 "--allow-trifecta" => c.allow_trifecta = true,
1239 "--mcp-tags" => mcp_tags.push(parse_mcp_tags(&take("--mcp-tags")?)?),
1240 "--metrics-addr" => c.metrics_addr = Some(take("--metrics-addr")?),
1241 "--cgroup" => c.cgroup = Some(take("--cgroup")?),
1242 "--cgroup-memory-max" => c.cgroup_memory_max = Some(take("--cgroup-memory-max")?),
1243 "--cgroup-pids-max" => c.cgroup_pids_max = Some(take("--cgroup-pids-max")?),
1244 #[cfg(feature = "workflow")]
1245 "--workflow" => c.workflow_file = Some(take("--workflow")?),
1246 #[cfg(feature = "workflow")]
1247 "--workflow-resume" => {
1248 let force = c.workflow_resume.as_ref().is_some_and(|r| r.force);
1251 let mut r = parse_workflow_resume(&take("--workflow-resume")?)?;
1252 r.force = r.force || force;
1253 c.workflow_resume = Some(r);
1254 }
1255 #[cfg(feature = "workflow")]
1256 "--workflow-resume-force" => {
1257 match c.workflow_resume.as_mut() {
1258 Some(r) => r.force = true,
1259 None => {
1262 c.workflow_resume = Some(crate::subagent::protocol::WorkflowResumeRef {
1263 server: String::new(),
1264 key: String::new(),
1265 seq: None,
1266 force: true,
1267 })
1268 }
1269 }
1270 }
1271 "--serve-mcp" => c.serve_mcp = Some(take("--serve-mcp")?),
1272 "--serve-cert" => c.serve_cert = Some(take("--serve-cert")?),
1273 "--serve-key" => c.serve_key = Some(take("--serve-key")?),
1274 "--serve-client-ca" => c.serve_client_ca = Some(take("--serve-client-ca")?),
1275 "--serve-bearer" => c.serve_bearer = Some(take("--serve-bearer")?),
1276 "--tls-ca" => c.tls_ca = Some(take("--tls-ca")?),
1277 "--aauth-provider" => aauth_provider = Some(take("--aauth-provider")?),
1281 "--aauth-key-file" => aauth_key_file = Some(take("--aauth-key-file")?),
1282 "--aauth-enroll-token" => aauth_enroll_token = Some(take("--aauth-enroll-token")?),
1283 "--aauth-enroll-assertion-file" => {
1284 aauth_enroll_assertion_file = Some(take("--aauth-enroll-assertion-file")?)
1285 }
1286 "--aauth-person-server" => {
1287 aauth_person_server = Some(take("--aauth-person-server")?)
1288 }
1289 "--watch-config" => c.watch_config = true,
1295 "--health-file" => c.health_file = Some(take("--health-file")?),
1296 "--traceparent" => c.traceparent = Some(take("--traceparent")?),
1297 "--report-file" => c.report_file = Some(take("--report-file")?),
1298 "--budget-exit-code" => {
1304 let v = take("--budget-exit-code")?;
1305 let n: i32 = v
1306 .parse()
1307 .ok()
1308 .filter(|n| (0..=255).contains(n))
1309 .ok_or_else(|| {
1310 usage(format!("invalid --budget-exit-code: {v} (want 0..=255)"))
1311 })?;
1312 c.budget_exit_code = Some(n);
1313 }
1314 "--events-ring" => {
1315 let v = take("--events-ring")?;
1316 c.events_ring = v
1317 .parse()
1318 .map_err(|_| usage(format!("invalid --events-ring: {v}")))?;
1319 }
1320 other => match paths::resolve_flag(other).map_err(usage)? {
1327 Some(target) => {
1328 let raw = if matches!(target.value_kind(), paths::Kind::Boolean)
1329 && !it.peek().is_some_and(|n| !n.starts_with("--"))
1330 {
1331 "true".to_string()
1332 } else {
1333 it.next()
1334 .cloned()
1335 .ok_or_else(|| usage(format!("{other} requires a value")))?
1336 };
1337 let value = paths::coerce(target.value_kind(), &raw)
1338 .map_err(|e| usage(format!("invalid {other}: {e}")))?;
1339 let replace = target.entry.is_none();
1342 apply_document(&mut c, target.document(value), other, replace)?;
1343 }
1344 None => return Err(usage(format!("unknown argument: {other}"))),
1345 },
1346 }
1347 }
1348
1349 let aauth_provider =
1353 aauth_provider.or_else(|| envmap.get("AGENT_AAUTH_PROVIDER").map(|v| v.to_string()));
1354 if let Some(provider) = aauth_provider {
1355 c.aauth = Some(AAuthSettings {
1356 provider,
1357 key_file: aauth_key_file
1358 .or_else(|| envmap.get("AGENT_AAUTH_KEY_FILE").map(|v| v.to_string()))
1359 .unwrap_or_else(|| "agent.key".to_string()),
1360 enrollment_token: aauth_enroll_token.or_else(|| {
1361 envmap
1362 .get("AGENT_AAUTH_ENROLL_TOKEN")
1363 .map(|v| v.to_string())
1364 }),
1365 enroll_assertion_file: aauth_enroll_assertion_file.or_else(|| {
1366 envmap
1367 .get("AGENT_AAUTH_ENROLL_ASSERTION_FILE")
1368 .map(|v| v.to_string())
1369 }),
1370 person_server: aauth_person_server.or_else(|| {
1371 envmap
1372 .get("AGENT_AAUTH_PERSON_SERVER")
1373 .map(|v| v.to_string())
1374 }),
1375 });
1376 }
1377
1378 for (name, tags) in mcp_tags {
1380 match c.mcp_servers.iter_mut().find(|s| s.name == name) {
1381 Some(s) => s.tags = tags,
1382 None => {
1383 return Err(usage(format!(
1384 "--mcp-tags references unknown server '{name}'"
1385 )));
1386 }
1387 }
1388 }
1389
1390 if c.run_id.is_empty() {
1391 c.run_id = generate_run_id();
1392 }
1393
1394 if capabilities {
1399 return Err(ConfigError::Capabilities(
1400 "{\"note\":\"--capabilities is served by the agentd loader\"}\n".to_string(),
1401 ));
1402 }
1403
1404 c.resolve_token_file()?;
1411
1412 if validate_config {
1419 return Err(ConfigError::Validate(c.validate_collect_all(file_present)));
1420 }
1421
1422 c.validate()?;
1423 if c.watch_config && !file_present {
1430 return Err(usage(
1431 "--watch-config requires a config file (--config / AGENTD_CONFIG)".into(),
1432 ));
1433 }
1434 Ok(c)
1435 }
1436
1437 pub fn config_paths_from(args: &[String], env: &[(String, String)]) -> Vec<String> {
1443 let env = debrand_env(env);
1444 let envmap: HashMap<&str, &str> =
1445 env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
1446 config_paths_from_map(args, &envmap).paths
1447 }
1448
1449 fn resolve_token_file(&mut self) -> Result<(), ConfigError> {
1454 if self.intelligence_token.is_some() {
1455 return Ok(()); }
1457 if let Some(path) = self.intelligence_token_file.clone() {
1458 let tok = crate::sec::secret::read_token_file(&path).map_err(usage)?;
1459 self.intelligence_token = Some(tok);
1460 }
1461 Ok(())
1462 }
1463
1464 fn validate_collect_all(&self, file_present: bool) -> Result<String, String> {
1473 let mut diags: Vec<String> = Vec::new();
1474 self.collect_header_diags(&mut diags);
1481 if let Err(e) = self.validate() {
1486 let msg = e.to_string();
1487 if !diags.iter().any(|d| msg.ends_with(d.as_str())) {
1488 diags.push(msg);
1489 }
1490 }
1491 if self.watch_config && !file_present {
1495 diags.push("--watch-config requires a config file (--config / AGENTD_CONFIG)".into());
1496 }
1497 match Config::reload_coherence_check(self, None, file_present) {
1504 Ok(()) => {}
1505 Err(coh) => {
1506 for d in coh.into_iter().filter(|d| d.is_error()) {
1507 let line = format!("{}: {}", d.field, d.msg);
1508 if !diags.iter().any(|existing| existing.ends_with(&d.msg)) {
1509 diags.push(line);
1510 }
1511 }
1512 }
1513 }
1514 if diags.is_empty() {
1515 Ok(config_valid_line())
1516 } else {
1517 Err(diags
1518 .into_iter()
1519 .map(|d| config_invalid_line(&d))
1520 .collect::<Vec<_>>()
1521 .join("\n"))
1522 }
1523 }
1524
1525 fn collect_header_diags(&self, diags: &mut Vec<String>) {
1532 let env = |k: &str| std::env::var(k).ok();
1533 for (name, value) in &self.intelligence_headers {
1534 if is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
1537 diags.push(format!(
1538 "intelligence_headers['{name}'] looks like a credential but has an inline value; \
1539 use {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}} (never an inline secret)"
1540 ));
1541 continue;
1542 }
1543 if crate::sec::secret::has_secret_ref(value)
1547 && let Err(e) = crate::sec::secret::refs_resolvable(value, &env)
1548 {
1549 diags.push(format!("intelligence_headers['{name}']: {e}"));
1550 }
1551 }
1552 }
1553
1554 pub fn trifecta_grant_tags(&self) -> Vec<TrifectaTag> {
1560 let mut tags = Vec::new();
1561 for s in &self.mcp_servers {
1562 if s.tags.is_empty() {
1563 tags.push(TrifectaTag::UntrustedInput);
1564 } else {
1565 tags.extend(s.tags.iter().copied());
1566 }
1567 }
1568 tags
1569 }
1570
1571 pub fn validate(&self) -> Result<(), ConfigError> {
1573 #[cfg(feature = "workflow")]
1581 let needs_instruction = self.mode != Mode::Workflow
1582 && !(self.mode == Mode::Reactive
1583 && self.workflow_file.is_some()
1584 && self.subscribe.is_empty()
1585 && self.continue_subscribe.is_empty());
1586 #[cfg(not(feature = "workflow"))]
1587 let needs_instruction = true;
1588 if needs_instruction
1589 && self
1590 .instruction
1591 .as_deref()
1592 .map(str::trim)
1593 .unwrap_or("")
1594 .is_empty()
1595 {
1596 return Err(usage(
1597 "missing instruction (INSTRUCTION env or --instruction)".into(),
1598 ));
1599 }
1600 if self.intelligence.as_deref().unwrap_or("").is_empty() {
1601 return Err(usage(
1602 "missing intelligence endpoint (AGENTD_INTELLIGENCE or --intelligence)".into(),
1603 ));
1604 }
1605 validate_intelligence_uri(self.intelligence.as_deref().unwrap())?;
1606 validate_endpoint_token_files(self.intelligence.as_deref().unwrap())?;
1611 for s in &self.mcp_servers {
1612 if s.name.is_empty() {
1613 return Err(usage("mcp server has an empty name".into()));
1614 }
1615 if s.endpoint.trim().is_empty() {
1616 return Err(usage(format!("mcp server '{}' has no endpoint", s.name)));
1617 }
1618 mcp_endpoint_scheme_ok(&s.endpoint)
1622 .map_err(|e| usage(format!("mcp server '{}': {e}", s.name)))?;
1623 ::mcp::http::McpEndpoint::parse(&s.endpoint)
1627 .map_err(|e| usage(format!("mcp server '{}': {e}", s.name)))?;
1628 for (name, value) in &s.headers {
1629 if is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
1630 return Err(usage(format!(
1631 "mcp server '{}' header '{name}' looks like a credential but has an inline value; use {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}}",
1632 s.name
1633 )));
1634 }
1635 }
1636 crate::mcp::auth::headers_resolvable(&s.headers)
1637 .map_err(|e| usage(format!("mcp server '{}' header: {e}", s.name)))?;
1638 }
1639 if self.max_steps == 0 {
1640 return Err(usage("--max-steps must be > 0".into()));
1641 }
1642 if self.events_ring == 0 {
1646 return Err(usage("--events-ring must be > 0".into()));
1647 }
1648 if self.watch_config && !cfg!(feature = "config-watch") {
1653 return Err(usage(
1654 "--watch-config requires the 'config-watch' build feature".into(),
1655 ));
1656 }
1657 {
1658 #[cfg(feature = "workflow")]
1659 let wait_driven = self.workflow_file.is_some();
1660 #[cfg(not(feature = "workflow"))]
1661 let wait_driven = false;
1662 if self.mode == Mode::Reactive
1665 && self.subscribe.is_empty()
1666 && self.continue_subscribe.is_empty()
1667 && !wait_driven
1668 {
1669 return Err(usage(
1670 "--mode reactive requires at least one --subscribe or --continue <uri> (or --workflow on a workflow build)".into(),
1671 ));
1672 }
1673 }
1674 if !self.continue_subscribe.is_empty() && self.mode != Mode::Reactive {
1675 return Err(usage(
1676 "--continue is only valid with --mode reactive".into(),
1677 ));
1678 }
1679 if self.mode == Mode::Schedule && self.interval.is_none() && self.cron.is_none() {
1680 return Err(usage(
1681 "--mode schedule requires --interval <dur> or --cron <expr>".into(),
1682 ));
1683 }
1684 if self.cron.is_some() && self.mode != Mode::Schedule {
1685 return Err(usage("--cron is only valid with --mode schedule".into()));
1686 }
1687 #[cfg(feature = "workflow")]
1691 {
1692 if self.mode == Mode::Workflow && self.workflow_file.is_none() {
1693 return Err(usage("--mode workflow requires --workflow <file>".into()));
1694 }
1695 if let Some(r) = &self.workflow_resume {
1699 if r.server.is_empty() {
1700 return Err(usage(
1701 "--workflow-resume-force requires --workflow-resume <server>:<key>[@seq]"
1702 .into(),
1703 ));
1704 }
1705 if self.mode != Mode::Workflow {
1706 return Err(usage(
1707 "--workflow-resume is only valid with --mode workflow".into(),
1708 ));
1709 }
1710 if !self.mcp_servers.iter().any(|s| s.name == r.server) {
1711 return Err(usage(format!(
1712 "--workflow-resume names server '{}', which is not a configured --mcp server",
1713 r.server
1714 )));
1715 }
1716 }
1717 if self.workflow_file.is_some()
1718 && self.mode != Mode::Workflow
1719 && self.mode != Mode::Reactive
1720 {
1721 return Err(usage(
1722 "--workflow is only valid with --mode workflow or --mode reactive".into(),
1723 ));
1724 }
1725 }
1726 if (self.cgroup_memory_max.is_some() || self.cgroup_pids_max.is_some())
1730 && self.cgroup.is_none()
1731 {
1732 return Err(usage(
1733 "--cgroup-memory-max/--cgroup-pids-max require --cgroup".into(),
1734 ));
1735 }
1736 if self.cgroup_pids_max.as_deref().map(str::trim) == Some("0") {
1740 return Err(usage(
1741 "--cgroup-pids-max must be > 0 (it counts threads, not just processes) or 'max'"
1742 .into(),
1743 ));
1744 }
1745 if self.cgroup_memory_max.as_deref().map(str::trim) == Some("0") {
1746 return Err(usage("--cgroup-memory-max must be > 0 or 'max'".into()));
1747 }
1748 if let Some(a) = &self.aauth {
1752 if !cfg!(feature = "aauth") {
1753 return Err(usage(
1754 "--aauth-provider needs a build with --features aauth".into(),
1755 ));
1756 }
1757 if crate::net::http::Url::parse(&a.provider).is_err() {
1758 return Err(usage(format!(
1759 "--aauth-provider must be an http(s) URL (got: {})",
1760 a.provider
1761 )));
1762 }
1763 if let Some(ps) = &a.person_server
1764 && crate::net::http::Url::parse(ps).is_err()
1765 {
1766 return Err(usage(format!(
1767 "--aauth-person-server must be an http(s) URL (got: {ps})"
1768 )));
1769 }
1770 }
1771 if let Some(spec) = &self.serve_mcp {
1775 let target = ServeTarget::parse(spec)?;
1776 self.validate_serve_auth(&target, &|k: &str| std::env::var(k).ok())?;
1777 } else if self.serve_cert.is_some()
1778 || self.serve_key.is_some()
1779 || self.serve_client_ca.is_some()
1780 || self.serve_bearer.is_some()
1781 {
1782 return Err(usage(
1783 "a2a.tls.cert / a2a.tls.key / a2a.tls.client_ca / a2a.bearer require a2a.listen"
1784 .into(),
1785 ));
1786 }
1787 if let Some(ca) = &self.tls_ca {
1794 if !cfg!(feature = "tls") {
1795 return Err(usage("--tls-ca requires the 'tls' build feature".into()));
1796 }
1797 check_readable("--tls-ca", ca)?;
1798 #[cfg(feature = "tls")]
1799 {
1800 let pem =
1801 std::fs::read(ca).map_err(|e| usage(format!("--tls-ca {ca}: read: {e}")))?;
1802 crate::net::tls::validate_ca_pem(&pem)
1803 .map_err(|e| usage(format!("--tls-ca {ca}: {e}")))?;
1804 }
1805 }
1806 if !self.a2a_peers.is_empty() && !cfg!(feature = "a2a") {
1810 return Err(usage("--a2a-peer requires the 'a2a' build feature".into()));
1811 }
1812 let mut seen = std::collections::HashSet::new();
1813 for peer in &self.a2a_peers {
1814 if peer.name.is_empty() || peer.endpoint.is_empty() {
1815 return Err(usage(format!(
1816 "--a2a-peer '{}' has an empty name or endpoint",
1817 peer.name
1818 )));
1819 }
1820 if !seen.insert(peer.name.as_str()) {
1821 return Err(usage(format!(
1822 "--a2a-peer name '{}' is declared more than once",
1823 peer.name
1824 )));
1825 }
1826 A2aEndpoint::parse(&peer.endpoint)?;
1827 for (name, value) in &peer.headers {
1832 if is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
1833 return Err(usage(format!(
1834 "a2a peer '{}' header '{name}' looks like a credential but has an inline value; use {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}}",
1835 peer.name
1836 )));
1837 }
1838 }
1839 crate::mcp::auth::headers_resolvable(&peer.headers)
1840 .map_err(|e| usage(format!("a2a peer '{}' header: {e}", peer.name)))?;
1841 match (&peer.client_cert, &peer.client_key) {
1842 (Some(_), None) | (None, Some(_)) => {
1843 return Err(usage(format!(
1844 "a2a peer '{}': client_cert and client_key must be set together",
1845 peer.name
1846 )));
1847 }
1848 (Some(cert), Some(key)) => {
1849 for path in [cert, key] {
1850 if let Err(e) = std::fs::metadata(path) {
1851 return Err(usage(format!(
1852 "a2a peer '{}': cannot read '{path}': {e}",
1853 peer.name
1854 )));
1855 }
1856 }
1857 }
1858 (None, None) => {}
1859 }
1860 }
1861 let mut header_diags = Vec::new();
1866 self.collect_header_diags(&mut header_diags);
1867 if let Some(first) = header_diags.into_iter().next() {
1868 return Err(usage(first));
1869 }
1870 if crate::sec::scope::check_trifecta(self.trifecta_grant_tags(), self.allow_trifecta)
1880 .is_refused()
1881 {
1882 return Err(usage(
1883 "refused — this grant gives one agent all three lethal-trifecta legs \
1884 (untrusted input + sensitive data + egress). Split the capabilities across \
1885 subagents, or relaunch with --allow-trifecta."
1886 .into(),
1887 ));
1888 }
1889 Ok(())
1890 }
1891}
1892
1893#[derive(Debug, Clone, PartialEq, Eq)]
1909pub struct Diag {
1910 pub field: String,
1912 pub level: DiagLevel,
1914 pub msg: String,
1916}
1917
1918#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1919pub enum DiagLevel {
1920 Warn,
1921 Error,
1922}
1923
1924impl Diag {
1925 #[allow(dead_code)]
1932 fn warn(field: &str, msg: impl Into<String>) -> Diag {
1933 Diag {
1934 field: field.to_string(),
1935 level: DiagLevel::Warn,
1936 msg: msg.into(),
1937 }
1938 }
1939 fn error(field: &str, msg: impl Into<String>) -> Diag {
1940 Diag {
1941 field: field.to_string(),
1942 level: DiagLevel::Error,
1943 msg: msg.into(),
1944 }
1945 }
1946 pub fn is_error(&self) -> bool {
1947 self.level == DiagLevel::Error
1948 }
1949 pub fn level_str(&self) -> &'static str {
1950 match self.level {
1951 DiagLevel::Warn => "warn",
1952 DiagLevel::Error => "error",
1953 }
1954 }
1955}
1956
1957pub const RESTART_ONLY_FIELDS: &[&str] = &[
1968 "mode",
1969 "run_id", "serve_mcp", "drain_timeout", "continue_subscribe", ];
1980
1981impl Config {
1982 pub fn reload(args: &[String], env: &[(String, String)]) -> Result<Config, ConfigError> {
1993 Config::load(args, env)
1994 }
1995
1996 fn restart_only_file_warnings(&self, file_present: bool, _diags: &mut Vec<Diag>) {
2005 let _ = file_present; }
2007
2008 pub fn reload_coherence_check(
2020 new: &Config,
2021 running: Option<&Config>,
2022 file_present: bool,
2023 ) -> Result<(), Vec<Diag>> {
2024 let mut diags = Vec::new();
2025 new.restart_only_file_warnings(file_present, &mut diags);
2027 if let Some(run) = running {
2029 for &f in RESTART_ONLY_FIELDS {
2030 if new.restart_only_field_differs(run, f) {
2031 diags.push(Diag::error(
2032 f,
2033 format!(
2034 "restart-only field '{f}' changed on a live reload; reload refused, \
2035 a pod restart is required"
2036 ),
2037 ));
2038 }
2039 }
2040 }
2041 check_unique_server_names(new, &mut diags);
2043 check_subscriptions_reference_declared_servers(new, &mut diags);
2044 if diags.iter().any(Diag::is_error) {
2045 Err(diags)
2046 } else {
2047 Ok(())
2053 }
2054 }
2055
2056 fn restart_only_field_differs(&self, running: &Config, field: &str) -> bool {
2062 match field {
2063 "mode" => self.mode != running.mode,
2064 "run_id" => self.run_id != running.run_id,
2065 "serve_mcp" => self.serve_mcp != running.serve_mcp,
2066 "drain_timeout" => self.drain_timeout != running.drain_timeout,
2067 "continue_subscribe" => self.continue_subscribe != running.continue_subscribe,
2068 _ => false,
2069 }
2070 }
2071
2072 pub fn effective_view(&self) -> serde_json::Value {
2078 serde_json::json!({
2079 "model": self.model,
2080 "swap_policy": self.model_swap.as_str(),
2081 "max_tokens": self.max_tokens,
2082 "limits": {
2083 "max_steps": self.max_steps,
2084 "max_depth": self.max_depth,
2085 "deadline_secs": self.deadline.map(|d| d.as_secs()),
2086 "lifetime_tokens": (self.budget_tokens_lifetime > 0)
2088 .then_some(self.budget_tokens_lifetime),
2089 },
2090 "mcp_servers": self.mcp_servers.iter().map(|s| {
2093 serde_json::json!({"name": s.name, "tags": s.tags})
2094 }).collect::<Vec<_>>(),
2095 "subscribe": self.subscribe,
2096 "log_level": self.log_level.as_str(),
2097 "intelligence_headers": self.intelligence_headers.keys().collect::<Vec<_>>(),
2100 })
2101 }
2102}
2103
2104fn check_unique_server_names(cfg: &Config, diags: &mut Vec<Diag>) {
2107 let mut seen = std::collections::HashSet::new();
2108 for s in &cfg.mcp_servers {
2109 if !seen.insert(s.name.as_str()) {
2110 diags.push(Diag::error(
2111 "mcp_servers",
2112 format!("duplicate MCP server name '{}'", s.name),
2113 ));
2114 }
2115 }
2116}
2117
2118fn check_subscriptions_reference_declared_servers(cfg: &Config, diags: &mut Vec<Diag>) {
2125 let _ = (cfg, diags);
2130}
2131
2132pub(crate) fn is_secret_shaped_key(name: &str) -> bool {
2136 let n = name.to_ascii_lowercase();
2137 n == "authorization"
2138 || n == "x-api-key"
2139 || n == "api-key"
2140 || n == "token"
2141 || n.ends_with("-token")
2142 || n.ends_with("_token")
2143 || n == "password"
2144 || n == "secret"
2145 || n.ends_with("-key")
2146 || n.ends_with("_key")
2147}
2148
2149fn config_valid_line() -> String {
2151 serde_json::json!({"event": "config.valid"}).to_string()
2152}
2153
2154fn config_invalid_line(msg: &str) -> String {
2157 serde_json::json!({"event": "config.invalid", "msg": msg}).to_string()
2158}
2159
2160pub(crate) fn validate_intelligence_uri(uri: &str) -> Result<(), ConfigError> {
2166 let elements: Vec<&str> = uri
2167 .split(',')
2168 .map(str::trim)
2169 .filter(|s| !s.is_empty())
2170 .collect();
2171 if elements.is_empty() {
2172 return Err(usage(
2173 "missing intelligence endpoint (AGENTD_INTELLIGENCE or --intelligence)".into(),
2174 ));
2175 }
2176 for el in elements {
2177 validate_one_intelligence_uri(el)?;
2178 }
2179 Ok(())
2180}
2181
2182fn validate_one_intelligence_uri(uri: &str) -> Result<(), ConfigError> {
2191 if uri.starts_with("https://") {
2192 return Ok(());
2193 }
2194 if uri.starts_with("mock:") {
2198 #[cfg(any(feature = "internal-mocks", debug_assertions))]
2199 return Ok(());
2200 #[cfg(not(any(feature = "internal-mocks", debug_assertions)))]
2201 return Err(usage(format!(
2202 "mock: intelligence needs a build with --features internal-mocks (got: {uri})"
2203 )));
2204 }
2205 if let Some(rest) = uri.strip_prefix("http://") {
2206 let authority = rest.split('/').next().unwrap_or(rest);
2207 let host = if authority.starts_with('[') {
2210 authority.split(']').next().map_or(authority, |h| &h[1..])
2211 } else {
2212 authority.rsplit_once(':').map_or(authority, |(h, _)| h)
2213 };
2214 if crate::net::http::is_loopback_host(host) {
2215 return Ok(());
2216 }
2217 return Err(usage(format!(
2218 "plaintext http:// intelligence is allowed for loopback only (dev); use https:// (got: {uri})"
2219 )));
2220 }
2221 Err(usage(format!(
2222 "intelligence endpoint must be https://host[:port][/path] (got: {uri})"
2223 )))
2224}
2225
2226fn validate_endpoint_token_files(uri: &str) -> Result<(), ConfigError> {
2234 let count = uri
2235 .split(',')
2236 .map(str::trim)
2237 .filter(|s| !s.is_empty())
2238 .count();
2239 for idx in 0..count {
2240 let (inline_var, file_var) = if idx == 0 {
2241 (
2242 "AGENTD_INTELLIGENCE_TOKEN".to_string(),
2243 "AGENTD_INTELLIGENCE_TOKEN_FILE".to_string(),
2244 )
2245 } else {
2246 let n = idx + 1;
2247 (
2248 format!("AGENTD_INTELLIGENCE_TOKEN_{n}"),
2249 format!("AGENTD_INTELLIGENCE_TOKEN_{n}_FILE"),
2250 )
2251 };
2252 if std::env::var(&inline_var).is_ok() {
2254 continue;
2255 }
2256 if let Ok(path) = std::env::var(&file_var) {
2257 crate::sec::secret::read_token_file(&path).map_err(usage)?;
2258 }
2259 }
2260 Ok(())
2261}
2262
2263fn parse_mcp_spec(spec: &str) -> Result<McpServerSpec, ConfigError> {
2267 let (name, rhs) = spec
2268 .split_once('=')
2269 .ok_or_else(|| usage(format!("--mcp must be name=endpoint (got: {spec})")))?;
2270 let endpoint = rhs.trim();
2271 if name.is_empty() || endpoint.is_empty() {
2272 return Err(usage(format!("--mcp '{spec}' has empty name or endpoint")));
2273 }
2274 if name == "code" {
2278 return Err(usage(
2279 "--mcp: the server name 'code' is reserved for code-registered tools".into(),
2280 ));
2281 }
2282 if !is_mcp_endpoint(endpoint) {
2283 return Err(usage(format!(
2284 "--mcp '{spec}': endpoint must be https://host[:port][/path] \
2285 (loopback http:// for dev)"
2286 )));
2287 }
2288 Ok(McpServerSpec {
2289 name: name.to_string(),
2290 endpoint: endpoint.to_string(),
2291 ..Default::default()
2292 })
2293}
2294
2295fn parse_a2a_peer_spec(spec: &str) -> Result<A2aPeerSpec, ConfigError> {
2300 let (name, endpoint) = spec
2301 .split_once('=')
2302 .ok_or_else(|| usage(format!("--a2a-peer must be name=endpoint (got: {spec})")))?;
2303 if name.is_empty() || endpoint.is_empty() {
2304 return Err(usage(format!(
2305 "--a2a-peer '{spec}' has an empty name or endpoint"
2306 )));
2307 }
2308 Ok(A2aPeerSpec {
2309 name: name.to_string(),
2310 endpoint: endpoint.to_string(),
2311 headers: Vec::new(),
2312 client_cert: None,
2313 client_key: None,
2314 })
2315}
2316
2317pub(crate) fn parse_mcp_tags(spec: &str) -> Result<(String, Vec<TrifectaTag>), ConfigError> {
2320 let (name, list) = spec
2321 .split_once('=')
2322 .ok_or_else(|| usage(format!("--mcp-tags must be name=tag,tag (got: {spec})")))?;
2323 if name.is_empty() {
2324 return Err(usage(format!(
2325 "--mcp-tags '{spec}' has an empty server name"
2326 )));
2327 }
2328 let mut tags = Vec::new();
2329 for t in list.split(',').map(str::trim).filter(|t| !t.is_empty()) {
2330 let tag = TrifectaTag::parse(t).ok_or_else(|| {
2331 usage(format!(
2332 "unknown trifecta tag '{t}' (want: untrusted_input|sensitive|egress)"
2333 ))
2334 })?;
2335 tags.push(tag);
2336 }
2337 Ok((name.to_string(), tags))
2338}
2339
2340pub(crate) fn read_file(path: &str) -> Result<String, ConfigError> {
2341 std::fs::read_to_string(path)
2342 .map_err(|e| usage(format!("cannot read instruction file {path}: {e}")))
2343}
2344
2345pub(crate) enum ConfigFlag<'a> {
2349 Separate,
2351 Inline(&'a str),
2353 No,
2355}
2356
2357pub(crate) fn config_flag(arg: &str) -> ConfigFlag<'_> {
2359 match arg {
2360 "--config" | "-c" => ConfigFlag::Separate,
2361 _ => match arg
2362 .strip_prefix("--config=")
2363 .or_else(|| arg.strip_prefix("-c="))
2364 {
2365 Some(v) => ConfigFlag::Inline(v),
2366 None => ConfigFlag::No,
2367 },
2368 }
2369}
2370
2371pub(crate) struct ConfigPaths {
2380 pub paths: Vec<String>,
2382 pub discovered: bool,
2386 pub ambiguous: Option<String>,
2390}
2391
2392pub(crate) fn config_paths_from_map(args: &[String], envmap: &HashMap<&str, &str>) -> ConfigPaths {
2397 let mut paths: Vec<String> = envmap
2398 .get("AGENTD_CONFIG")
2399 .map(|v| {
2400 v.split(':')
2401 .map(str::trim)
2402 .filter(|p| !p.is_empty())
2403 .map(str::to_string)
2404 .collect()
2405 })
2406 .unwrap_or_default();
2407 let mut it = args.iter();
2408 while let Some(a) = it.next() {
2409 match config_flag(a) {
2410 ConfigFlag::Separate => {
2411 if let Some(v) = it.next() {
2412 paths.push(v.clone());
2413 }
2414 }
2415 ConfigFlag::Inline(v) => paths.push(v.to_string()),
2416 ConfigFlag::No => {}
2417 }
2418 }
2419 let mut discovered = false;
2426 let mut ambiguous = None;
2427 if paths.is_empty() && !is_informational(args) {
2428 match discovered_chain(Path::new("."), envmap) {
2429 Ok(found) => {
2430 discovered = !found.is_empty();
2431 paths.extend(found);
2432 }
2433 Err(e) => ambiguous = Some(e),
2434 }
2435 }
2436 ConfigPaths {
2437 paths,
2438 discovered,
2439 ambiguous,
2440 }
2441}
2442
2443pub const USER_CONFIG_NAMES: [&str; 2] = ["config.yml", "config.yaml"];
2451
2452pub const PROJECT_CONFIG_NAMES: [&str; 4] =
2456 ["agentd.yml", "agentd.yaml", ".agentd.yml", ".agentd.yaml"];
2457
2458pub const LOCAL_CONFIG_NAMES: [&str; 2] = ["agentd.local.yml", "agentd.local.yaml"];
2462
2463pub fn user_config_dir(envmap: &HashMap<&str, &str>) -> Option<PathBuf> {
2468 if let Some(x) = envmap.get("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
2469 return Some(Path::new(x).join("agentd"));
2470 }
2471 envmap
2472 .get("HOME")
2473 .filter(|v| !v.is_empty())
2474 .map(|h| Path::new(h).join(".config").join("agentd"))
2475}
2476
2477pub fn present_in(dir: &Path, names: &[&str]) -> Vec<String> {
2483 names
2484 .iter()
2485 .map(|n| dir.join(n))
2486 .filter(|p| p.is_file())
2487 .map(|p| p.to_string_lossy().into_owned())
2488 .collect()
2489}
2490
2491pub fn discovered_chain(cwd: &Path, envmap: &HashMap<&str, &str>) -> Result<Vec<String>, String> {
2502 let mut out = Vec::new();
2503 let mut rungs: Vec<(&str, PathBuf, &[&str])> = Vec::new();
2504 if let Some(d) = user_config_dir(envmap) {
2505 rungs.push(("user", d, &USER_CONFIG_NAMES));
2506 }
2507 rungs.push(("project", cwd.to_path_buf(), &PROJECT_CONFIG_NAMES));
2508 rungs.push(("local", cwd.to_path_buf(), &LOCAL_CONFIG_NAMES));
2509 for (label, dir, names) in rungs {
2510 let found = present_in(&dir, names);
2511 if found.len() > 1 {
2512 return Err(format!(
2513 "the {label} config is ambiguous: {} are both present; keep one (or name the file with --config)",
2514 found.join(" and ")
2515 ));
2516 }
2517 out.extend(found);
2518 }
2519 Ok(out)
2520}
2521
2522fn is_informational(args: &[String]) -> bool {
2529 args.iter().any(|a| {
2530 matches!(
2531 a.as_str(),
2532 "-h" | "--help"
2533 | "-V"
2534 | "--version"
2535 | "--config-schema"
2536 | "--config-schema=2"
2537 | "--workflow-schema"
2538 )
2539 })
2540}
2541
2542fn apply_document(
2551 c: &mut Config,
2552 doc: serde_json::Value,
2553 source: &str,
2554 replace_lists: bool,
2555) -> Result<(), ConfigError> {
2556 let present: Vec<String> = doc
2557 .as_object()
2558 .map(|m| m.keys().cloned().collect())
2559 .unwrap_or_default();
2560 let cf = file::ConfigFile::from_document(doc, source).map_err(usage)?;
2561 if replace_lists {
2562 for key in &present {
2563 match key.as_str() {
2564 "mcp_servers" => c.mcp_servers.clear(),
2565 "subscribe" => c.subscribe.clear(),
2566 "a2a_peers" => c.a2a_peers.clear(),
2567 "intelligence_headers" => c.intelligence_headers.clear(),
2568 _ => {}
2569 }
2570 }
2571 }
2572 apply_config_file(c, cf, source)
2573}
2574
2575fn apply_config_file(
2585 c: &mut Config,
2586 cf: file::ConfigFile,
2587 source: &str,
2588) -> Result<(), ConfigError> {
2589 if let Some(intelligence) = cf.intelligence {
2594 c.intelligence = Some(intelligence);
2595 }
2596 if let Some(policy) = cf.model_swap {
2597 c.model_swap = SwapPolicy::parse(&policy).ok_or_else(|| {
2598 usage(format!(
2599 "{source}: invalid model_swap: {policy} (want finish-on-old|restart-turn)"
2600 ))
2601 })?;
2602 }
2603 if let Some(model) = cf.model {
2604 c.model = Some(model);
2605 }
2606 if let Some(mt) = cf.max_tokens {
2607 c.max_tokens = mt;
2608 }
2609 if let Some(limits) = cf.limits {
2610 if let Some(s) = limits.max_steps {
2611 c.max_steps = s;
2612 }
2613 if let Some(d) = limits.max_depth {
2614 c.max_depth = d;
2615 }
2616 if let Some(secs) = limits.deadline_secs {
2617 c.deadline = Some(Duration::from_secs(secs));
2618 }
2619 if let Some(lt) = limits.lifetime_tokens {
2620 c.budget_tokens_lifetime = lt;
2621 }
2622 }
2623 if let Some(level) = cf.log_level {
2624 c.log_level = Level::parse(&level)
2625 .ok_or_else(|| usage(format!("{source}: invalid log_level: {level}")))?;
2626 }
2627 for s in cf.mcp_servers {
2632 if s.name.is_empty() {
2633 return Err(usage(format!("{source}: an mcp server has an empty name")));
2634 }
2635 let endpoint = match s.endpoint {
2636 Some(ep) if !ep.trim().is_empty() => ep,
2637 _ => {
2638 return Err(usage(format!(
2639 "{source}: mcp server '{}' has no endpoint \
2640 (an MCP server is always a remote endpoint)",
2641 s.name
2642 )));
2643 }
2644 };
2645 let headers = s.headers.into_iter().collect::<Vec<(String, String)>>();
2646 let mut tags: Vec<TrifectaTag> = Vec::new();
2647 for tag_list in s.tags.values() {
2648 for t in tag_list {
2649 let tag = TrifectaTag::parse(t).ok_or_else(|| {
2650 usage(format!(
2651 "{source}: mcp server '{}' has unknown trifecta tag '{t}' \
2652 (want: untrusted_input|sensitive|egress)",
2653 s.name
2654 ))
2655 })?;
2656 if !tags.contains(&tag) {
2657 tags.push(tag);
2658 }
2659 }
2660 }
2661 c.mcp_servers.push(McpServerSpec {
2662 name: s.name,
2663 endpoint,
2664 headers,
2665 tags,
2666 aauth: s.aauth,
2667 oauth: None,
2671 auth: None,
2672 service: None,
2673 rate: None,
2674 });
2675 }
2676 c.subscribe.extend(cf.subscribe);
2677 for p in cf.a2a_peers {
2678 if p.name.is_empty() || p.endpoint.is_empty() {
2679 return Err(usage(format!(
2680 "{source}: a2a peer '{}' has an empty name or endpoint",
2681 p.name
2682 )));
2683 }
2684 c.a2a_peers.push(A2aPeerSpec {
2685 name: p.name,
2686 endpoint: p.endpoint,
2687 headers: p.headers.into_iter().collect(),
2688 client_cert: p.client_cert,
2689 client_key: p.client_key,
2690 });
2691 }
2692 c.intelligence_headers.extend(cf.intelligence_headers);
2694 Ok(())
2695}
2696
2697#[cfg(feature = "workflow")]
2702fn parse_workflow_resume(
2703 spec: &str,
2704) -> Result<crate::subagent::protocol::WorkflowResumeRef, ConfigError> {
2705 let (server, rest) = spec.split_once(':').ok_or_else(|| {
2706 usage(format!(
2707 "--workflow-resume: want <server>:<key>[@seq] (got: {spec})"
2708 ))
2709 })?;
2710 let (key, seq) = match rest.rsplit_once('@') {
2711 Some((k, s)) => {
2712 let seq: u64 = s
2713 .parse()
2714 .map_err(|_| usage(format!("--workflow-resume: bad @seq {s:?} (want a number)")))?;
2715 (k, Some(seq))
2716 }
2717 None => (rest, None),
2718 };
2719 if server.trim().is_empty() || key.trim().is_empty() {
2720 return Err(usage(format!(
2721 "--workflow-resume: server and key must be non-empty (got: {spec})"
2722 )));
2723 }
2724 Ok(crate::subagent::protocol::WorkflowResumeRef {
2725 server: server.to_string(),
2726 key: key.to_string(),
2727 seq,
2728 force: false,
2729 })
2730}
2731
2732pub(crate) fn usage(msg: String) -> ConfigError {
2733 ConfigError::Usage(format!("agentd: {msg}"))
2734}
2735
2736pub(crate) fn truthy(v: &str) -> bool {
2737 matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")
2738}
2739
2740pub fn parse_duration(s: &str) -> Result<Duration, String> {
2745 let s = s.trim();
2746 if s.is_empty() {
2747 return Err("empty duration".into());
2748 }
2749 let (num, unit): (&str, &str) = match s.find(|c: char| c.is_ascii_alphabetic()) {
2750 Some(i) => (&s[..i], &s[i..]),
2751 None => (s, "s"),
2752 };
2753 let n: u64 = num.parse().map_err(|_| format!("invalid duration: {s}"))?;
2754 let d = match unit {
2755 "ms" => Duration::from_millis(n),
2756 "s" => Duration::from_secs(n),
2757 "m" => Duration::from_secs(n * 60),
2758 "h" => Duration::from_secs(n * 3600),
2759 "d" => Duration::from_secs(n * 86_400),
2760 "w" => Duration::from_secs(n * 604_800),
2761 other => return Err(format!("unknown duration unit '{other}' in {s}")),
2762 };
2763 Ok(d)
2764}
2765
2766fn generate_run_id() -> String {
2771 let millis = SystemTime::now()
2772 .duration_since(UNIX_EPOCH)
2773 .unwrap_or_default()
2774 .as_millis();
2775 let pid = std::process::id();
2776 format!("{millis:011x}{pid:04x}")
2777}
2778
2779fn help_text() -> String {
2780 format!(
2781 "agentd {ver} — a minimal, MCP-native, reactive agent\n\
2782 \n\
2783 USAGE:\n\
2784 \x20 agentd --instruction <TEXT> --intelligence <URI> [--mcp name=endpoint ...] [options]\n\
2785 \n\
2786 REQUIRED:\n\
2787 \x20 --instruction <TEXT> the task (or INSTRUCTION / AGENT_INSTRUCTION env)\n\
2788 \x20 --instruction-file <PATH> read the instruction from a file\n\
2789 \x20 --intelligence <URI> https://host[:port][/path] (comma-list = failover order; http:// loopback-only for dev; or INTELLIGENCE / AGENT_INTELLIGENCE env)\n\
2790 \n\
2791 INTELLIGENCE:\n\
2792 \x20 --intelligence-token <T> bearer/key (or AGENT_INTELLIGENCE_TOKEN)\n\
2793 \x20 --intelligence-token-file <PATH> read the token from a mounted file (rotation; or AGENT_INTELLIGENCE_TOKEN_FILE)\n\
2794 \x20 --model <NAME> model id (or AGENT_MODEL)\n\
2795 \x20 --model-swap <finish-on-old|restart-turn> in-flight model-change policy (default finish-on-old; or AGENT_MODEL_SWAP)\n\
2796 \n\
2797 TOOLS / MCP:\n\
2798 \x20 --mcp name=endpoint declare a remote MCP server (repeatable; https://host[:port][/path])\n\
2799 \x20 --tls-ca <PATH> extra PEM CA(s) trusted for outbound https (private/in-cluster PKI; added to the bundled roots)\n\
2800 \x20 --aauth-provider <URL> [DRAFT] Agent Provider — sign every MCP request with an Ed25519 agent identity (needs --features aauth; or AGENT_AAUTH_PROVIDER)\n\
2801 \x20 --aauth-key-file <PATH> durable Ed25519 key file (created 0600 if absent; default agent.key; or AGENT_AAUTH_KEY_FILE)\n\
2802 \x20 --aauth-enroll-token <T> one-time enrollment token ({{secret:…}}; provider `token` mode; or AGENT_AAUTH_ENROLL_TOKEN)\n\
2803 \x20 --aauth-enroll-assertion-file <PATH> enrollment assertion file — e.g. a projected K8s SA token (provider `federated` mode; re-read each enroll; or AGENT_AAUTH_ENROLL_ASSERTION_FILE)\n\
2804 \x20 --aauth-person-server <URL> [DRAFT] Person Server for user-scoped identity (Case C; or AGENT_AAUTH_PERSON_SERVER)\n\
2805 \x20 --serve-mcp <TARGET> serve agentd's own MCP over HTTP(S): https://host:port (or loopback http:// for dev)\n\
2806 \x20 --a2a-peer name=<ENDPOINT> declare a remote A2A delegation peer: https://host[:port] (repeatable; needs --features a2a)\n\
2807 \x20 --mcp-tags name=t,t capability tags: untrusted_input|sensitive|egress\n\
2808 \x20 --allow-trifecta permit all three capability legs in one agent\n\
2809 \n\
2810 MODE / TRIGGERS:\n\
2811 \x20 --mode once|loop|reactive|schedule|workflow (default once)\n\
2812 \x20 --workflow <FILE> pinned workflow JSON, driven by --mode workflow (needs --features workflow; or AGENT_WORKFLOW)\n\
2813 \x20 --workflow-resume <REF> resume from a checkpoint: <server>:<key>[@seq] (needs --mode workflow; or AGENT_WORKFLOW_RESUME)\n\
2814 \x20 --workflow-resume-force override the workflow-hash check (graph-edit-and-continue)\n\
2815 \x20 --subscribe <uri> subscribe to an MCP resource (repeatable)\n\
2816 \x20 --continue <uri> subscribe, routed to one warm session (repeatable)\n\
2817 \x20 --interval <dur> loop/schedule interval (e.g. 5m)\n\
2818 \x20 --cron <5-field> schedule on a UTC cron expr (needs --features cron)\n\
2819 \n\
2820 LIMITS:\n\
2821 \x20 --max-steps <N> per-run step cap (default 50)\n\
2822 \x20 --max-tokens <N> per-run token budget (default 200000)\n\
2823 \x20 --budget-tokens-lifetime <N> per-INSTANCE cumulative token cap across all runs/reactions (0/unset = unbounded; or AGENT_BUDGET_TOKENS)\n\
2824 \x20 --deadline <dur> wall-clock deadline (default 600s)\n\
2825 \x20 --max-depth <N> subagent tree depth cap (default 4)\n\
2826 \n\
2827 RUNTIME:\n\
2828 \x20 --run-id <ID> idempotency key (or AGENT_RUN_ID)\n\
2829 \x20 --log-level <L> trace|debug|info|warn|error (default info)\n\
2830 \x20 --log-content log tool args/results, not just lengths (opt-in)\n\
2831 \x20 --drain-timeout <dur> graceful drain budget (default 25s; < pod grace)\n\
2832 \x20 --health-file <PATH> liveness heartbeat file\n\
2833 \x20 --metrics-addr <host:port> serve /metrics+/healthz+/readyz (`:port` = all IPv4 ifaces; needs --features metrics)\n\
2834 \x20 --cgroup <auto|PATH> per-run cgroup for atomic cgroup.kill teardown (best-effort)\n\
2835 \x20 --cgroup-memory-max <SIZE> per-run memory.max (max|512M|2G|bytes; needs --cgroup + delegation)\n\
2836 \x20 --cgroup-pids-max <N> per-run pids.max (max|count of THREADS; needs --cgroup + delegation)\n\
2837 \x20 --traceparent <W3C> continue an upstream trace (or AGENT_TRACEPARENT)\n\
2838 \x20 --report-file <PATH> write the run-outcome report at terminal (atomic; inert for reactive)\n\
2839 \x20 --budget-exit-code <N> remap the policy budget codes (3/7 only) to N at process exit (0..=255)\n\
2840 \x20 --events-ring <N> agent://events ring size (default 1024; needs --serve-mcp + --features events)\n\
2841 \x20 --capabilities print the capabilities manifest (JSON) and exit\n\
2842 \n\
2843 CONFIG FILE:\n\
2844 \x20 --config <PATH> load a config file, YAML or JSON; repeatable — later files override earlier ones (or AGENT_CONFIG=a.yaml:b.yaml)\n\
2845 \x20 --validate-config load+validate (file+env+flags), print the verdict, exit 0/2\n\
2846 \x20 --config-schema print the config-file JSON Schema and exit\n\
2847 \x20 --watch-config reload on config-file change via inotify (needs --config + --features config-watch; or AGENT_WATCH_CONFIG)\n\
2848 \x20 -h, --help / -V, --version\n\
2849 \n\
2850 {paths}",
2851 ver = crate::VERSION,
2852 paths = paths::help_section(),
2853 )
2854}
2855
2856#[cfg(test)]
2857mod tests {
2858 use super::*;
2859
2860 fn args(v: &[&str]) -> Vec<String> {
2861 v.iter().map(|s| s.to_string()).collect()
2862 }
2863
2864 #[test]
2865 fn config_flag_accepts_short_and_inline_spellings() {
2866 let env: Vec<(String, String)> = vec![];
2869 for spelling in [
2870 args(&["--config", "a.yaml"]),
2871 args(&["-c", "a.yaml"]),
2872 args(&["--config=a.yaml"]),
2873 args(&["-c=a.yaml"]),
2874 ] {
2875 assert_eq!(
2876 Config::config_paths_from(&spelling, &env),
2877 vec!["a.yaml".to_string()],
2878 "spelling {spelling:?}"
2879 );
2880 }
2881 assert_eq!(
2883 Config::config_paths_from(&args(&["-c", "base.yaml", "--config=over.yaml"]), &env),
2884 vec!["base.yaml".to_string(), "over.yaml".to_string()]
2885 );
2886 assert_eq!(
2888 Config::config_paths_from(
2889 &args(&["-c=flag.yaml"]),
2890 &[("AGENTD_CONFIG".into(), "env.yaml".into())]
2891 ),
2892 vec!["env.yaml".to_string(), "flag.yaml".to_string()]
2893 );
2894 assert!(Config::config_paths_from(&args(&["-c"]), &env).is_empty());
2897 assert!(Config::config_paths_from(&args(&["--cluster-shard", "a"]), &env).is_empty());
2899 }
2900
2901 #[test]
2905 fn the_discovery_chain_layers_user_then_project_then_local() {
2906 let root = std::env::temp_dir().join(format!("agentd-chain-{}", std::process::id()));
2907 let _ = std::fs::remove_dir_all(&root);
2908 let (home, cwd) = (root.join("home"), root.join("work"));
2909 std::fs::create_dir_all(home.join(".config").join("agentd")).unwrap();
2910 std::fs::create_dir_all(&cwd).unwrap();
2911 let home_s = home.to_string_lossy().into_owned();
2912 let envmap: HashMap<&str, &str> = [("HOME", home_s.as_str())].into_iter().collect();
2913
2914 assert!(discovered_chain(&cwd, &envmap).unwrap().is_empty());
2916
2917 std::fs::write(home.join(".config/agentd/config.yml"), "a: 1\n").unwrap();
2918 std::fs::write(cwd.join("agentd.yml"), "b: 2\n").unwrap();
2919 std::fs::write(cwd.join("agentd.local.yml"), "c: 3\n").unwrap();
2920 let chain = discovered_chain(&cwd, &envmap).unwrap();
2921 assert_eq!(chain.len(), 3, "{chain:?}");
2922 assert!(chain[0].ends_with("config.yml"), "{chain:?}");
2923 assert!(chain[1].ends_with("agentd.yml"), "{chain:?}");
2924 assert!(chain[2].ends_with("agentd.local.yml"), "{chain:?}");
2925
2926 std::fs::write(cwd.join("agentd.yaml"), "b: 9\n").unwrap();
2928 let e = discovered_chain(&cwd, &envmap).unwrap_err();
2929 assert!(e.contains("project config is ambiguous"), "{e}");
2930 std::fs::remove_file(cwd.join("agentd.yaml")).unwrap();
2931
2932 let xdg = root.join("xdg");
2934 std::fs::create_dir_all(xdg.join("agentd")).unwrap();
2935 std::fs::write(xdg.join("agentd/config.yml"), "a: 7\n").unwrap();
2936 let xdg_s = xdg.to_string_lossy().into_owned();
2937 let envmap2: HashMap<&str, &str> = [
2938 ("HOME", home_s.as_str()),
2939 ("XDG_CONFIG_HOME", xdg_s.as_str()),
2940 ]
2941 .into_iter()
2942 .collect();
2943 let chain = discovered_chain(&cwd, &envmap2).unwrap();
2944 assert!(chain[0].starts_with(&xdg_s), "{chain:?}");
2945
2946 let bare: HashMap<&str, &str> = HashMap::new();
2948 assert_eq!(discovered_chain(&cwd, &bare).unwrap().len(), 2);
2949
2950 let _ = std::fs::remove_dir_all(&root);
2951 }
2952
2953 #[test]
2956 fn a_dotfile_is_discovered_only_when_nothing_else_named_a_config() {
2957 let dir = std::env::temp_dir().join(format!("agentd-discover-{}", std::process::id()));
2958 let _ = std::fs::remove_dir_all(&dir);
2959 std::fs::create_dir_all(&dir).unwrap();
2960
2961 assert!(present_in(&dir, &PROJECT_CONFIG_NAMES).is_empty());
2963
2964 std::fs::write(dir.join(".agentd.yml"), "config_version: \"1\"\n").unwrap();
2965 let found = present_in(&dir, &PROJECT_CONFIG_NAMES);
2966 assert_eq!(found.len(), 1);
2967 assert!(found[0].ends_with(".agentd.yml"), "{found:?}");
2968
2969 std::fs::write(dir.join(".agentd.yaml"), "config_version: \"1\"\n").unwrap();
2973 assert_eq!(present_in(&dir, &PROJECT_CONFIG_NAMES).len(), 2);
2974
2975 let _ = std::fs::remove_dir_all(&dir);
2976 }
2977
2978 #[test]
2981 fn help_and_version_do_not_discover_a_config() {
2982 for a in [
2983 "--help",
2984 "-h",
2985 "--version",
2986 "-V",
2987 "--config-schema",
2988 "--workflow-schema",
2989 ] {
2990 assert!(is_informational(&args(&[a])), "{a} should be informational");
2991 }
2992 assert!(!is_informational(&args(&["--validate-config"])));
2993 assert!(!is_informational(&args(&[])));
2994 }
2995
2996 #[test]
2997 fn flags_override_env() {
2998 let env = vec![
2999 ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3000 ("INSTRUCTION".into(), "from-env".into()),
3001 ];
3002 let c = Config::load(&args(&["--instruction", "from-flag"]), &env).unwrap();
3003 assert_eq!(c.instruction.as_deref(), Some("from-flag"));
3004 assert_eq!(c.intelligence.as_deref(), Some("https://intel.example"));
3005 }
3006
3007 #[cfg(feature = "workflow")]
3008 #[test]
3009 fn workflow_mode_and_workflow_file_are_inseparable() {
3010 let intel_only = vec![(
3011 "AGENTD_INTELLIGENCE".to_string(),
3012 "https://intel.example".to_string(),
3013 )];
3014 let e = Config::load(
3016 &args(&["--mode", "workflow", "--instruction", "x"]),
3017 &intel_only,
3018 )
3019 .unwrap_err();
3020 assert!(
3021 format!("{e}").contains("--mode workflow requires --workflow"),
3022 "{e}"
3023 );
3024 let e = Config::load(&args(&["--workflow", "/tmp/g.json"]), &base_env()).unwrap_err();
3026 assert!(format!("{e}").contains("--workflow is only valid"), "{e}");
3027 }
3028
3029 #[cfg(feature = "workflow")]
3030 #[test]
3031 fn a_reactive_workflow_daemon_needs_no_subscribe_or_instruction() {
3032 let c = Config::load(
3035 &args(&["--mode", "reactive", "--workflow", "/tmp/wf.json"]),
3036 &base_env(),
3037 )
3038 .unwrap();
3039 assert_eq!(c.mode, Mode::Reactive);
3040 assert_eq!(c.workflow_file.as_deref(), Some("/tmp/wf.json"));
3041 let e = Config::load(&args(&["--mode", "reactive"]), &base_env()).unwrap_err();
3043 assert!(matches!(e, ConfigError::Usage(_)));
3044 let e = Config::load(
3046 &args(&[
3047 "--mode",
3048 "loop",
3049 "--interval",
3050 "5m",
3051 "--workflow",
3052 "/tmp/wf.json",
3053 "--instruction",
3054 "x",
3055 ]),
3056 &base_env(),
3057 )
3058 .unwrap_err();
3059 assert!(format!("{e}").contains("--workflow is only valid"), "{e}");
3060 }
3061
3062 #[cfg(feature = "workflow")]
3063 #[test]
3064 fn a_reactive_workflow_with_subscriptions_still_requires_an_instruction() {
3065 let intel_only = vec![(
3068 "AGENTD_INTELLIGENCE".to_string(),
3069 "https://intel.example".to_string(),
3070 )];
3071 let e = Config::load(
3072 &args(&[
3073 "--mode",
3074 "reactive",
3075 "--workflow",
3076 "/tmp/wf.json",
3077 "--subscribe",
3078 "file:///inbox",
3079 ]),
3080 &intel_only,
3081 )
3082 .unwrap_err();
3083 assert!(format!("{e}").contains("missing instruction"), "{e}");
3084 let c = Config::load(
3086 &args(&["--mode", "reactive", "--workflow", "/tmp/wf.json"]),
3087 &intel_only,
3088 )
3089 .unwrap();
3090 assert!(c.instruction.as_deref().unwrap_or("").is_empty());
3091 let c = Config::load(
3093 &args(&[
3094 "--mode",
3095 "reactive",
3096 "--workflow",
3097 "/tmp/wf.json",
3098 "--subscribe",
3099 "file:///inbox",
3100 "--instruction",
3101 "triage it",
3102 ]),
3103 &base_env(),
3104 )
3105 .unwrap();
3106 assert_eq!(c.subscribe.len(), 1);
3107 assert!(c.workflow_file.is_some());
3108 }
3109
3110 #[cfg(feature = "workflow")]
3111 #[test]
3112 fn workflow_mode_does_not_require_an_instruction() {
3113 let intel_only = vec![(
3116 "AGENTD_INTELLIGENCE".to_string(),
3117 "https://intel.example".to_string(),
3118 )];
3119 let c = Config::load(
3120 &args(&["--mode", "workflow", "--workflow", "/tmp/g.json"]),
3121 &intel_only,
3122 )
3123 .unwrap();
3124 assert_eq!(c.mode, Mode::Workflow);
3125 assert_eq!(c.workflow_file.as_deref(), Some("/tmp/g.json"));
3126 assert!(c.instruction.as_deref().unwrap_or("").is_empty());
3127 }
3128
3129 #[cfg(feature = "workflow")]
3130 #[test]
3131 fn workflow_resume_parses_and_validates() {
3132 let intel_only = vec![(
3133 "AGENTD_INTELLIGENCE".to_string(),
3134 "https://intel.example".to_string(),
3135 )];
3136 let c = Config::load(
3138 &args(&[
3139 "--mode",
3140 "workflow",
3141 "--workflow",
3142 "/tmp/g.json",
3143 "--mcp",
3144 "state=https://ckpt.internal/mcp",
3145 "--workflow-resume",
3146 "state:run/abc@17",
3147 "--workflow-resume-force",
3148 ]),
3149 &intel_only,
3150 )
3151 .unwrap();
3152 let r = c.workflow_resume.expect("parsed");
3153 assert_eq!(r.server, "state");
3154 assert_eq!(r.key, "run/abc");
3155 assert_eq!(r.seq, Some(17));
3156 assert!(r.force);
3157
3158 let c = Config::load(
3160 &args(&[
3161 "--mode",
3162 "workflow",
3163 "--workflow",
3164 "/tmp/g.json",
3165 "--mcp",
3166 "state=https://ckpt.internal/mcp",
3167 "--workflow-resume-force",
3168 "--workflow-resume",
3169 "state:run/abc",
3170 ]),
3171 &intel_only,
3172 )
3173 .unwrap();
3174 assert!(c.workflow_resume.unwrap().force);
3175
3176 for bad in [
3179 vec![
3180 "--mode",
3181 "workflow",
3182 "--workflow",
3183 "/g",
3184 "--workflow-resume",
3185 "nocolon",
3186 ],
3187 vec![
3188 "--mode",
3189 "workflow",
3190 "--workflow",
3191 "/g",
3192 "--workflow-resume-force",
3193 ],
3194 vec!["--instruction", "x", "--workflow-resume", "s:k"],
3195 vec![
3196 "--mode",
3197 "workflow",
3198 "--workflow",
3199 "/g",
3200 "--workflow-resume",
3201 "ghost:k",
3202 ],
3203 ] {
3204 assert!(
3205 Config::load(&args(&bad), &base_env()).is_err(),
3206 "{bad:?} must be refused"
3207 );
3208 }
3209 let mut env = intel_only.clone();
3211 env.push(("AGENT_WORKFLOW_RESUME".into(), "state:run/xyz".into()));
3212 let c = Config::load(
3213 &args(&[
3214 "--mode",
3215 "workflow",
3216 "--workflow",
3217 "/g",
3218 "--mcp",
3219 "state=https://ckpt.internal/mcp",
3220 ]),
3221 &env,
3222 )
3223 .unwrap();
3224 assert_eq!(c.workflow_resume.unwrap().key, "run/xyz");
3225 }
3226
3227 fn base_env() -> Vec<(String, String)> {
3228 vec![
3229 ("INSTRUCTION".into(), "x".into()),
3230 ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3231 ]
3232 }
3233
3234 #[test]
3235 fn neutral_agent_env_prefix_is_accepted_as_an_alias() {
3236 let env = vec![
3239 ("INSTRUCTION".into(), "x".into()),
3240 (
3241 "AGENT_INTELLIGENCE".into(),
3242 "https://neutral.example".into(),
3243 ),
3244 ("AGENT_RUN_ID".into(), "run-neutral".into()),
3245 ("AGENT_MAX_STEPS".into(), "42".into()),
3246 ];
3247 let c = Config::load(&args(&[]), &env).unwrap();
3248 assert_eq!(c.intelligence.as_deref(), Some("https://neutral.example"));
3249 assert_eq!(c.run_id, "run-neutral");
3250 assert_eq!(c.max_steps, 42);
3251 }
3252
3253 #[test]
3254 fn branded_env_wins_over_neutral_on_conflict() {
3255 let env = vec![
3258 ("INSTRUCTION".into(), "x".into()),
3259 (
3260 "AGENTD_INTELLIGENCE".into(),
3261 "https://branded.example".into(),
3262 ),
3263 (
3264 "AGENT_INTELLIGENCE".into(),
3265 "https://neutral.example".into(),
3266 ),
3267 ];
3268 let c = Config::load(&args(&[]), &env).unwrap();
3269 assert_eq!(c.intelligence.as_deref(), Some("https://branded.example"));
3270 }
3271
3272 #[test]
3273 fn bare_env_spellings_work_for_the_two_required_inputs() {
3274 let env = vec![
3277 ("INSTRUCTION".into(), "x".into()),
3278 ("INTELLIGENCE".into(), "https://bare.example".into()),
3279 ];
3280 let c = Config::load(&args(&[]), &env).unwrap();
3281 assert_eq!(c.intelligence.as_deref(), Some("https://bare.example"));
3282 }
3283
3284 #[test]
3285 fn prefixed_env_wins_over_the_bare_spelling() {
3286 let env = vec![
3290 ("INSTRUCTION".into(), "bare-task".into()),
3291 ("AGENT_INSTRUCTION".into(), "neutral-task".into()),
3292 ("INTELLIGENCE".into(), "https://bare.example".into()),
3293 (
3294 "AGENT_INTELLIGENCE".into(),
3295 "https://neutral.example".into(),
3296 ),
3297 ];
3298 let c = Config::load(&args(&[]), &env).unwrap();
3299 assert_eq!(c.instruction.as_deref(), Some("neutral-task"));
3300 assert_eq!(c.intelligence.as_deref(), Some("https://neutral.example"));
3301 }
3302
3303 #[test]
3304 fn debrand_env_synthesizes_branded_only_when_absent() {
3305 let env = vec![
3308 ("AGENT_MODE".into(), "loop".into()),
3309 ("AGENTD_RUN_ID".into(), "kept".into()),
3310 ("AGENT_RUN_ID".into(), "ignored".into()),
3311 ("INSTRUCTION".into(), "x".into()),
3312 ];
3313 let out = debrand_env(&env);
3314 let get = |k: &str| {
3315 out.iter()
3316 .filter(|(n, _)| n == k)
3317 .map(|(_, v)| v.as_str())
3318 .collect::<Vec<_>>()
3319 };
3320 assert_eq!(get("AGENTD_MODE"), vec!["loop"]);
3322 assert_eq!(get("AGENTD_RUN_ID"), vec!["kept"]);
3324 assert_eq!(get("INSTRUCTION"), vec!["x"]);
3326 }
3327
3328 #[test]
3329 fn report_file_and_events_ring_parse_from_flag_and_env() {
3330 let c = Config::load(&args(&[]), &base_env()).unwrap();
3332 assert_eq!(c.report_file, None);
3333 assert_eq!(c.events_ring, crate::obs::log::EVENTS_RING_DEFAULT);
3334
3335 let c = Config::load(
3337 &args(&["--report-file", "/out/report.json", "--events-ring", "256"]),
3338 &base_env(),
3339 )
3340 .unwrap();
3341 assert_eq!(c.report_file.as_deref(), Some("/out/report.json"));
3342 assert_eq!(c.events_ring, 256);
3343
3344 let mut env = base_env();
3346 env.push(("AGENTD_REPORT_FILE".into(), "/env/report.json".into()));
3347 env.push(("AGENTD_EVENTS_RING".into(), "64".into()));
3348 let c = Config::load(&args(&["--events-ring", "512"]), &env).unwrap();
3349 assert_eq!(c.report_file.as_deref(), Some("/env/report.json"));
3350 assert_eq!(c.events_ring, 512);
3351 }
3352
3353 #[test]
3354 fn budget_exit_code_flag_parses_and_range_checks() {
3355 let c = Config::load(&args(&[]), &base_env()).unwrap();
3357 assert_eq!(c.budget_exit_code, None);
3358 let c = Config::load(&args(&["--budget-exit-code", "0"]), &base_env()).unwrap();
3360 assert_eq!(c.budget_exit_code, Some(0));
3361 let c = Config::load(&args(&["--budget-exit-code", "42"]), &base_env()).unwrap();
3362 assert_eq!(c.budget_exit_code, Some(42));
3363 for bad in ["256", "-1", "nope"] {
3365 let e = Config::load(&args(&["--budget-exit-code", bad]), &base_env()).unwrap_err();
3366 assert!(
3367 matches!(e, ConfigError::Usage(_)),
3368 "{bad} must be a usage error"
3369 );
3370 }
3371 }
3372
3373 #[test]
3374 fn events_ring_zero_and_bad_value_are_usage_errors() {
3375 let zero = Config::load(&args(&["--events-ring", "0"]), &base_env()).unwrap_err();
3376 assert!(matches!(zero, ConfigError::Usage(_)));
3377 let bad = Config::load(&args(&["--events-ring", "lots"]), &base_env()).unwrap_err();
3378 assert!(matches!(bad, ConfigError::Usage(_)));
3379 }
3380
3381 #[test]
3382 fn mcp_tags_attach_to_their_server_order_independent() {
3383 let c = Config::load(
3385 &args(&[
3386 "--mcp-tags",
3387 "fs=sensitive,egress",
3388 "--mcp",
3389 "fs=https://fs.example",
3390 ]),
3391 &base_env(),
3392 )
3393 .unwrap();
3394 assert_eq!(
3395 c.mcp_servers[0].tags,
3396 vec![TrifectaTag::Sensitive, TrifectaTag::Egress]
3397 );
3398 }
3399
3400 #[test]
3401 fn mcp_tags_unknown_server_or_tag_is_usage_error() {
3402 let bad_server = Config::load(
3403 &args(&[
3404 "--mcp",
3405 "fs=https://fs.example",
3406 "--mcp-tags",
3407 "ghost=egress",
3408 ]),
3409 &base_env(),
3410 )
3411 .unwrap_err();
3412 assert!(matches!(bad_server, ConfigError::Usage(_)));
3413 let bad_tag = Config::load(
3414 &args(&["--mcp", "fs=https://fs.example", "--mcp-tags", "fs=bogus"]),
3415 &base_env(),
3416 )
3417 .unwrap_err();
3418 assert!(matches!(bad_tag, ConfigError::Usage(_)));
3419 }
3420
3421 #[test]
3422 fn cgroup_limits_require_cgroup_and_reject_zero() {
3423 let e = Config::load(&args(&["--cgroup-memory-max", "512M"]), &base_env()).unwrap_err();
3425 assert!(matches!(e, ConfigError::Usage(_)));
3426 let e2 = Config::load(&args(&["--cgroup-pids-max", "64"]), &base_env()).unwrap_err();
3427 assert!(matches!(e2, ConfigError::Usage(_)));
3428 let c = Config::load(
3430 &args(&[
3431 "--cgroup",
3432 "auto",
3433 "--cgroup-memory-max",
3434 "512M",
3435 "--cgroup-pids-max",
3436 "64",
3437 ]),
3438 &base_env(),
3439 )
3440 .unwrap();
3441 assert_eq!(c.cgroup_memory_max.as_deref(), Some("512M"));
3442 assert_eq!(c.cgroup_pids_max.as_deref(), Some("64"));
3443 let z = Config::load(
3445 &args(&["--cgroup", "auto", "--cgroup-pids-max", "0"]),
3446 &base_env(),
3447 )
3448 .unwrap_err();
3449 assert!(matches!(z, ConfigError::Usage(_)));
3450 let zm = Config::load(
3451 &args(&["--cgroup", "auto", "--cgroup-memory-max", "0"]),
3452 &base_env(),
3453 )
3454 .unwrap_err();
3455 assert!(matches!(zm, ConfigError::Usage(_)));
3456 }
3457
3458 #[test]
3459 fn cron_requires_schedule_mode() {
3460 let e = Config::load(
3462 &args(&[
3463 "--mode",
3464 "reactive",
3465 "--subscribe",
3466 "x://y",
3467 "--cron",
3468 "* * * * *",
3469 ]),
3470 &base_env(),
3471 )
3472 .unwrap_err();
3473 assert!(matches!(e, ConfigError::Usage(_)));
3474 let c = Config::load(
3476 &args(&["--mode", "schedule", "--cron", "0 9 * * 1-5"]),
3477 &base_env(),
3478 )
3479 .unwrap();
3480 assert_eq!(c.cron.as_deref(), Some("0 9 * * 1-5"));
3481 let e2 = Config::load(&args(&["--mode", "schedule"]), &base_env()).unwrap_err();
3483 assert!(matches!(e2, ConfigError::Usage(_)));
3484 }
3485
3486 #[test]
3487 fn trifecta_grant_tags_defaults_untagged_to_untrusted() {
3488 let c = Config::load(&args(&["--mcp", "fs=https://fs.example"]), &base_env()).unwrap();
3489 let tags = c.trifecta_grant_tags();
3490 assert!(tags.contains(&TrifectaTag::UntrustedInput)); assert!(!tags.contains(&TrifectaTag::Sensitive)); }
3493
3494 #[test]
3495 fn missing_instruction_is_usage_error() {
3496 let env = vec![("AGENTD_INTELLIGENCE".into(), "https://intel.example".into())];
3497 let e = Config::load(&[], &env).unwrap_err();
3498 assert!(matches!(e, ConfigError::Usage(_)));
3499 }
3500
3501 #[test]
3502 fn help_short_circuits() {
3503 let e = Config::load(&args(&["--help"]), &[]).unwrap_err();
3504 assert!(matches!(e, ConfigError::Help(_)));
3505 }
3506
3507 #[test]
3508 fn reactive_requires_subscribe() {
3509 let env = vec![
3510 ("INSTRUCTION".into(), "x".into()),
3511 ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3512 ];
3513 let e = Config::load(&args(&["--mode", "reactive"]), &env).unwrap_err();
3514 assert!(matches!(e, ConfigError::Usage(_)));
3515 let c = Config::load(
3517 &args(&["--mode", "reactive", "--subscribe", "file://a"]),
3518 &env,
3519 )
3520 .unwrap();
3521 assert_eq!(c.mode, Mode::Reactive);
3522 }
3523
3524 #[test]
3525 fn mcp_spec_parsing() {
3526 let env = vec![
3527 ("INSTRUCTION".into(), "x".into()),
3528 ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3529 ];
3530 let c = Config::load(&args(&["--mcp", "fs=https://mcp.example.com/mcp"]), &env).unwrap();
3532 assert_eq!(c.mcp_servers.len(), 1);
3533 assert_eq!(c.mcp_servers[0].name, "fs");
3534 assert_eq!(c.mcp_servers[0].endpoint, "https://mcp.example.com/mcp");
3535 }
3536
3537 #[test]
3538 fn mcp_endpoint_spec_parsing() {
3539 assert!(is_mcp_endpoint("https://mcp.example.com/mcp"));
3541 assert!(is_mcp_endpoint("http://localhost:8080/mcp"));
3542 assert!(!is_mcp_endpoint("unix:/run/mcp.sock"));
3544 assert!(!is_mcp_endpoint("vsock:3:5000"));
3545 assert!(!is_mcp_endpoint("mcp-server-fs --root /data"));
3546 assert!(parse_mcp_spec("fs=mcp-server-fs --root /data").is_err());
3547 assert!(parse_mcp_spec("fs=unix:/run/mcp.sock").is_err());
3548
3549 for ep in ["https://mcp.example.com/mcp", "http://127.0.0.1:8080/mcp"] {
3550 let spec = parse_mcp_spec(&format!("fs={ep}")).unwrap();
3551 assert_eq!(spec.name, "fs");
3552 assert_eq!(spec.endpoint, ep);
3553 }
3554 }
3555
3556 #[test]
3557 fn mcp_endpoint_scheme_gate_is_https_only() {
3558 assert!(mcp_endpoint_scheme_ok("https://mcp.example/mcp").is_ok());
3560 assert!(mcp_endpoint_scheme_ok("http://127.0.0.1:8080/mcp").is_ok());
3561 for bad in [
3562 "unix:/run/mcp.sock",
3563 "vsock:3:5000",
3564 "http://mcp.example:8080/mcp",
3565 ] {
3566 assert!(
3567 mcp_endpoint_scheme_ok(bad).is_err(),
3568 "{bad} must be rejected"
3569 );
3570 }
3571 }
3572
3573 #[test]
3574 fn mcp_endpoint_is_required_and_validated() {
3575 let env = vec![
3576 ("INSTRUCTION".into(), "x".into()),
3577 ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3578 ];
3579 let mut c =
3581 Config::load(&args(&["--mcp", "fs=https://mcp.example.com/mcp"]), &env).unwrap();
3582 assert!(c.validate().is_ok());
3583 c.mcp_servers[0].endpoint.clear();
3585 assert!(c.validate().is_err(), "an empty endpoint must fail");
3586 c.mcp_servers[0].endpoint = "ftp://nope/".into();
3588 assert!(
3589 c.validate().is_err(),
3590 "an unsupported endpoint scheme must fail"
3591 );
3592 }
3593
3594 #[test]
3595 fn duration_units() {
3596 assert_eq!(parse_duration("600s").unwrap(), Duration::from_secs(600));
3597 assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
3598 assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
3599 assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250));
3600 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
3601 assert!(parse_duration("nope").is_err());
3602 }
3603
3604 #[test]
3605 fn invalid_intelligence_uri_rejected() {
3606 let env = vec![("INSTRUCTION".into(), "x".into())];
3607 let e = Config::load(&args(&["--intelligence", "ftp://x"]), &env).unwrap_err();
3608 assert!(matches!(e, ConfigError::Usage(_)));
3609 }
3610
3611 #[test]
3612 fn multi_endpoint_list_accepts_ordered_comma_list() {
3613 let env = vec![("INSTRUCTION".into(), "x".into())];
3615 let c = Config::load(
3616 &args(&[
3617 "--intelligence",
3618 "https://a.example,https://b.example,https://c.example",
3619 ]),
3620 &env,
3621 )
3622 .unwrap();
3623 assert_eq!(
3625 c.intelligence.as_deref(),
3626 Some("https://a.example,https://b.example,https://c.example")
3627 );
3628 }
3629
3630 #[test]
3631 fn multi_endpoint_bad_element_scheme_is_exit_2() {
3632 let env = vec![("INSTRUCTION".into(), "x".into())];
3634 let e = Config::load(
3635 &args(&[
3636 "--intelligence",
3637 "https://a.example,ftp://nope,https://c.example",
3638 ]),
3639 &env,
3640 )
3641 .unwrap_err();
3642 assert!(matches!(e, ConfigError::Usage(_)));
3643 }
3644
3645 #[test]
3646 fn empty_endpoint_list_is_exit_2() {
3647 let env = vec![("INSTRUCTION".into(), "x".into())];
3649 let e = Config::load(&args(&["--intelligence", " , , "]), &env).unwrap_err();
3650 assert!(matches!(e, ConfigError::Usage(_)));
3651 }
3652
3653 #[test]
3654 fn serve_target_http_parses() {
3655 assert_eq!(
3656 ServeTarget::parse("https://0.0.0.0:8443").unwrap(),
3657 ServeTarget::Http {
3658 bind: "0.0.0.0:8443".into(),
3659 tls: true
3660 }
3661 );
3662 assert_eq!(
3664 ServeTarget::parse("http://127.0.0.1:9000").unwrap(),
3665 ServeTarget::Http {
3666 bind: "127.0.0.1:9000".into(),
3667 tls: false
3668 }
3669 );
3670 assert!(matches!(
3671 ServeTarget::parse("http://[::1]:9000"),
3672 Ok(ServeTarget::Http { tls: false, .. })
3673 ));
3674 for bad in [
3676 "http://10.0.0.5:9000",
3677 "https://host:8443/mcp",
3678 "https://host",
3679 ] {
3680 assert!(
3681 matches!(ServeTarget::parse(bad), Err(ConfigError::Usage(_))),
3682 "{bad} must be rejected"
3683 );
3684 }
3685 }
3686
3687 #[test]
3688 fn serve_auth_gates_the_control_plane() {
3689 let base = |extra: &[&str]| {
3690 let mut a = vec!["--instruction", "x", "--intelligence", "https://i.example"];
3691 a.extend_from_slice(extra);
3692 let args: Vec<String> = a.iter().map(|s| s.to_string()).collect();
3693 Config::load(&args, &[]).and_then(|c| c.validate().map(|_| c))
3694 };
3695 assert!(base(&["--serve-mcp", "https://0.0.0.0:8443"]).is_err());
3697 assert!(base(&["--serve-mcp", "https://127.0.0.1:8443"]).is_err());
3699 assert!(base(&["--serve-mcp", "http://127.0.0.1:9000"]).is_ok());
3701 assert!(base(&["--serve-mcp", "unix:/x.sock", "--serve-bearer", "t"]).is_ok());
3704 assert!(base(&["--serve-mcp", "http://127.0.0.1:9000", "--serve-cert", "/x"]).is_err());
3705 assert!(base(&["--serve-bearer", "t"]).is_err());
3707 }
3708
3709 #[test]
3710 fn serve_target_rejects_unsupported_socket_schemes() {
3711 assert!(matches!(
3714 ServeTarget::parse("unix:/run/agentd.sock"),
3715 Ok(ServeTarget::Unix { ref path }) if path == "/run/agentd.sock"
3716 ));
3717 assert!(matches!(
3718 ServeTarget::parse("unix:///run/agentd.sock"),
3719 Ok(ServeTarget::Unix { ref path }) if path == "/run/agentd.sock"
3720 ));
3721 for bad in ["vsock:5005", "vsock:2:5005", "tcp:1234"] {
3722 assert!(
3723 matches!(ServeTarget::parse(bad), Err(ConfigError::Usage(_))),
3724 "{bad} must be a usage error"
3725 );
3726 }
3727 }
3728
3729 #[test]
3730 fn serve_mcp_validation_runs_at_load() {
3731 let c = Config::load(
3733 &args(&["--serve-mcp", "http://127.0.0.1:9000"]),
3734 &base_env(),
3735 )
3736 .unwrap();
3737 assert_eq!(c.serve_mcp.as_deref(), Some("http://127.0.0.1:9000"));
3738 let e = Config::load(&args(&["--serve-mcp", "tcp:9000"]), &base_env()).unwrap_err();
3740 assert!(matches!(e, ConfigError::Usage(_)));
3741 }
3742
3743 #[test]
3744 fn a2a_peer_spec_parses_name_and_endpoint() {
3745 let spec = parse_a2a_peer_spec("mesh=https://peer.example").unwrap();
3748 assert_eq!(spec.name, "mesh");
3749 assert_eq!(spec.endpoint, "https://peer.example");
3750 assert!(matches!(
3752 parse_a2a_peer_spec("noequals"),
3753 Err(ConfigError::Usage(_))
3754 ));
3755 assert!(matches!(
3756 parse_a2a_peer_spec("=https://x"),
3757 Err(ConfigError::Usage(_))
3758 ));
3759 assert!(matches!(
3760 parse_a2a_peer_spec("mesh="),
3761 Err(ConfigError::Usage(_))
3762 ));
3763 }
3764
3765 #[test]
3766 fn a2a_endpoint_https_parses_and_gates_plaintext() {
3767 assert_eq!(
3768 A2aEndpoint::parse("https://peer.example:8443/a2a").unwrap(),
3769 A2aEndpoint::Https("https://peer.example:8443/a2a".into())
3770 );
3771 assert!(matches!(
3773 A2aEndpoint::parse("http://127.0.0.1:9000"),
3774 Ok(A2aEndpoint::Https(_))
3775 ));
3776 assert!(matches!(
3777 A2aEndpoint::parse("http://peer.example:9000"),
3778 Err(ConfigError::Usage(_))
3779 ));
3780 }
3781
3782 #[cfg(feature = "a2a")]
3783 #[test]
3784 fn a2a_peer_flag_parses_and_validates_on_a2a_build() {
3785 let c = Config::load(
3787 &args(&["--a2a-peer", "mesh=https://peer.example:8443/a2a"]),
3788 &base_env(),
3789 )
3790 .unwrap();
3791 assert_eq!(c.a2a_peers.len(), 1);
3792 assert_eq!(c.a2a_peers[0].name, "mesh");
3793 assert_eq!(c.a2a_peers[0].endpoint, "https://peer.example:8443/a2a");
3794
3795 assert!(
3797 Config::load(
3798 &args(&["--a2a-peer", "mesh=unix:/run/peer.sock"]),
3799 &base_env()
3800 )
3801 .is_ok()
3802 );
3803 for bad in [
3806 "mesh=vsock:2:5005",
3807 "mesh=http://peer.example:9000",
3808 "mesh=tcp:9000",
3809 ] {
3810 let e = Config::load(&args(&["--a2a-peer", bad]), &base_env()).unwrap_err();
3811 assert!(matches!(e, ConfigError::Usage(_)), "{bad} must be exit 2");
3812 }
3813
3814 let dup = Config::load(
3816 &args(&[
3817 "--a2a-peer",
3818 "mesh=https://a.example",
3819 "--a2a-peer",
3820 "mesh=https://b.example",
3821 ]),
3822 &base_env(),
3823 )
3824 .unwrap_err();
3825 assert!(matches!(dup, ConfigError::Usage(_)));
3826 }
3827
3828 #[cfg(feature = "a2a")]
3829 #[test]
3830 fn a2a_peer_client_auth_is_validated_at_startup() {
3831 let file = write_tmp(
3834 r#"{ "a2a_peers": [{ "name": "mesh", "endpoint": "https://peer.example/a2a",
3835 "headers": { "authorization": "Bearer sk-live-inline-oops" } }] }"#,
3836 );
3837 let e = Config::load(
3838 &args(&["--config", file.path().to_str().unwrap()]),
3839 &base_env(),
3840 )
3841 .unwrap_err();
3842 assert!(matches!(e, ConfigError::Usage(_)), "{e}");
3843 assert!(format!("{e}").contains("a2a peer 'mesh' header"), "{e}");
3844
3845 let file = write_tmp(
3848 r#"{ "a2a_peers": [{ "name": "mesh", "endpoint": "https://peer.example/a2a",
3849 "headers": { "authorization": "Bearer {{secret:A2A_PEER_AUTH_TEST_TOKEN}}" } }] }"#,
3850 );
3851 unsafe { std::env::set_var("A2A_PEER_AUTH_TEST_TOKEN", "tok") };
3853 let c = Config::load(
3854 &args(&["--config", file.path().to_str().unwrap()]),
3855 &base_env(),
3856 )
3857 .unwrap();
3858 unsafe { std::env::remove_var("A2A_PEER_AUTH_TEST_TOKEN") };
3859 assert_eq!(
3860 c.a2a_peers[0].headers.len(),
3861 1,
3862 "template stored, not resolved"
3863 );
3864 assert!(
3865 c.a2a_peers[0].headers[0].1.contains("{{secret:"),
3866 "the SPEC keeps the template, never the material"
3867 );
3868
3869 let file = write_tmp(
3871 r#"{ "a2a_peers": [{ "name": "mesh", "endpoint": "https://peer.example/a2a",
3872 "client_cert": "/tls/cert.pem" }] }"#,
3873 );
3874 let e = Config::load(
3875 &args(&["--config", file.path().to_str().unwrap()]),
3876 &base_env(),
3877 )
3878 .unwrap_err();
3879 assert!(
3880 format!("{e}").contains("client_cert and client_key must be set together"),
3881 "{e}"
3882 );
3883 }
3884
3885 #[cfg(not(feature = "a2a"))]
3886 #[test]
3887 fn a2a_peer_requires_the_a2a_feature() {
3888 let e = Config::load(
3890 &args(&["--a2a-peer", "mesh=https://peer.example"]),
3891 &base_env(),
3892 )
3893 .unwrap_err();
3894 match e {
3895 ConfigError::Usage(msg) => assert!(
3896 msg.contains("--a2a-peer requires the 'a2a' build feature"),
3897 "got: {msg}"
3898 ),
3899 other => panic!("expected a Usage error, got {other:?}"),
3900 }
3901 }
3902
3903 #[test]
3904 fn token_redacted_in_debug() {
3905 let env = vec![
3906 ("INSTRUCTION".into(), "x".into()),
3907 (
3908 "AGENTD_INTELLIGENCE".into(),
3909 "https://api.example/v1".into(),
3910 ),
3911 ("AGENTD_INTELLIGENCE_TOKEN".into(), "super-secret".into()),
3912 ];
3913 let c = Config::load(&[], &env).unwrap();
3914 let dbg = format!("{c:?}");
3915 assert!(!dbg.contains("super-secret"));
3916 assert!(dbg.contains("***"));
3917 }
3918
3919 #[test]
3920 fn debug_redacts_credential_bearing_intelligence_uri() {
3921 let env = vec![
3925 ("INSTRUCTION".into(), "x".into()),
3926 (
3927 "AGENTD_INTELLIGENCE".into(),
3928 "https://alice:hunter2@internal.example/v1".into(),
3929 ),
3930 ];
3931 let c = Config::load(&[], &env).unwrap();
3932 let dbg = format!("{c:?}");
3933 assert!(!dbg.contains("hunter2"), "creds leaked: {dbg}");
3934 assert!(!dbg.contains("internal.example"), "host leaked: {dbg}");
3935 assert!(dbg.contains("https:<redacted>"), "scheme missing: {dbg}");
3936 }
3937
3938 #[test]
3939 fn help_text_lists_model_swap() {
3940 let h = match Config::load(&args(&["--help"]), &[]).unwrap_err() {
3942 ConfigError::Help(s) => s,
3943 other => panic!("expected Help, got {other:?}"),
3944 };
3945 assert!(h.contains("--model-swap"), "help omits --model-swap");
3946 assert!(h.contains("finish-on-old|restart-turn"));
3947 }
3948
3949 use std::io::Write as _;
3952
3953 fn write_tmp(contents: &str) -> tempfile::NamedTempFile {
3954 let mut f = tempfile::NamedTempFile::new().unwrap();
3955 f.write_all(contents.as_bytes()).unwrap();
3956 f.flush().unwrap();
3957 f
3958 }
3959
3960 #[test]
3961 fn config_file_loads_mcp_subscribe_a2a_and_limits() {
3962 let file = write_tmp(
3963 r#"{
3964 "model": "claude-from-file",
3965 "max_tokens": 1234567,
3966 "limits": { "max_steps": 77, "max_depth": 3, "deadline_secs": 120 },
3967 "mcp_servers": [
3968 { "name": "web", "endpoint": "https://web.example.com/mcp",
3969 "tags": { "*": ["untrusted_input"] } }
3970 ],
3971 "subscribe": ["fs:file:///watch/inbox"]
3972 }"#,
3973 );
3974 let c = Config::load(
3975 &args(&["--config", file.path().to_str().unwrap()]),
3976 &base_env(),
3977 )
3978 .unwrap();
3979 assert_eq!(c.model.as_deref(), Some("claude-from-file"));
3980 assert_eq!(c.max_tokens, 1_234_567);
3981 assert_eq!(c.max_steps, 77);
3982 assert_eq!(c.max_depth, 3);
3983 assert_eq!(c.deadline, Some(Duration::from_secs(120)));
3984 assert_eq!(c.mcp_servers.len(), 1);
3985 assert_eq!(c.mcp_servers[0].name, "web");
3986 assert_eq!(c.mcp_servers[0].endpoint, "https://web.example.com/mcp");
3987 assert_eq!(c.mcp_servers[0].tags, vec![TrifectaTag::UntrustedInput]);
3988 assert_eq!(c.subscribe, vec!["fs:file:///watch/inbox"]);
3989 }
3990
3991 #[test]
3992 fn budget_tokens_lifetime_parses_from_flag_env_and_file() {
3993 assert_eq!(
3995 Config::load(&args(&[]), &base_env())
3996 .unwrap()
3997 .budget_tokens_lifetime,
3998 0
3999 );
4000 let c = Config::load(&args(&["--budget-tokens-lifetime", "2000000"]), &base_env()).unwrap();
4002 assert_eq!(c.budget_tokens_lifetime, 2_000_000);
4003 let mut env = base_env();
4005 env.push(("AGENT_BUDGET_TOKENS".into(), "500000".into()));
4006 assert_eq!(
4007 Config::load(&args(&[]), &env)
4008 .unwrap()
4009 .budget_tokens_lifetime,
4010 500_000
4011 );
4012 let file = write_tmp(r#"{ "model": "m", "limits": { "lifetime_tokens": 111 } }"#);
4014 let path = file.path().to_str().unwrap().to_string();
4015 assert_eq!(
4016 Config::load(&args(&["--config", &path]), &base_env())
4017 .unwrap()
4018 .budget_tokens_lifetime,
4019 111
4020 );
4021 assert_eq!(
4022 Config::load(
4023 &args(&["--config", &path, "--budget-tokens-lifetime", "222"]),
4024 &base_env()
4025 )
4026 .unwrap()
4027 .budget_tokens_lifetime,
4028 222
4029 );
4030 }
4031
4032 #[test]
4033 fn env_and_flag_override_file_per_precedence() {
4034 let file = write_tmp(r#"{ "model": "from-file", "max_tokens": 100 }"#);
4036 let mut env = base_env();
4037 env.push(("AGENTD_MODEL".into(), "from-env".into()));
4038 let c = Config::load(
4040 &args(&[
4041 "--config",
4042 file.path().to_str().unwrap(),
4043 "--max-tokens",
4044 "999",
4045 ]),
4046 &env,
4047 )
4048 .unwrap();
4049 assert_eq!(c.model.as_deref(), Some("from-env")); assert_eq!(c.max_tokens, 999); let c2 = Config::load(
4053 &args(&["--config", file.path().to_str().unwrap()]),
4054 &base_env(),
4055 )
4056 .unwrap();
4057 assert_eq!(c2.model.as_deref(), Some("from-file"));
4058 assert_eq!(c2.max_tokens, 100);
4059 }
4060
4061 fn write_tmp_ext(contents: &str, ext: &str) -> tempfile::NamedTempFile {
4063 let mut f = tempfile::Builder::new()
4064 .suffix(&format!(".{ext}"))
4065 .tempfile()
4066 .unwrap();
4067 f.write_all(contents.as_bytes()).unwrap();
4068 f.flush().unwrap();
4069 f
4070 }
4071
4072 #[test]
4073 fn config_file_may_be_yaml() {
4074 let yaml = write_tmp_ext(
4077 r#"
4078# agentd config
4079model: claude-from-yaml
4080max_tokens: 1234567
4081limits:
4082 max_steps: 77
4083 max_depth: 3
4084 deadline_secs: 120
4085mcp_servers:
4086 - name: web
4087 endpoint: https://web.example.com/mcp
4088 tags:
4089 "*": [untrusted_input]
4090subscribe:
4091 - fs:file:///watch/inbox
4092log_level: warn
4093"#,
4094 "yaml",
4095 );
4096 let c = Config::load(
4097 &args(&["--config", yaml.path().to_str().unwrap()]),
4098 &base_env(),
4099 )
4100 .unwrap();
4101 assert_eq!(c.model.as_deref(), Some("claude-from-yaml"));
4102 assert_eq!(c.max_tokens, 1_234_567);
4103 assert_eq!(c.max_steps, 77);
4104 assert_eq!(c.max_depth, 3);
4105 assert_eq!(c.deadline, Some(Duration::from_secs(120)));
4106 assert_eq!(c.mcp_servers.len(), 1);
4107 assert_eq!(c.mcp_servers[0].name, "web");
4108 assert_eq!(c.mcp_servers[0].tags, vec![TrifectaTag::UntrustedInput]);
4109 assert_eq!(c.subscribe, vec!["fs:file:///watch/inbox"]);
4110 assert_eq!(c.log_level, Level::Warn);
4111
4112 let bad = write_tmp_ext("model: m\nmax_token: 5\n", "yml");
4114 let e = Config::load(
4115 &args(&["--config", bad.path().to_str().unwrap()]),
4116 &base_env(),
4117 )
4118 .unwrap_err();
4119 assert!(matches!(e, ConfigError::Usage(_)), "{e}");
4120 assert!(format!("{e}").contains("max_token"), "{e}");
4121 let bad = write_tmp_ext("model: m\n\tlimits: {}\n", "yaml");
4123 let e = Config::load(
4124 &args(&["--config", bad.path().to_str().unwrap()]),
4125 &base_env(),
4126 )
4127 .unwrap_err();
4128 assert!(format!("{e}").contains("line 2"), "{e}");
4129 }
4130
4131 #[test]
4132 fn path_env_vars_set_config_paths() {
4133 let file = write_tmp_ext(
4136 "limits:\n max_steps: 1\n max_depth: 9\nmodel: f\n",
4137 "yaml",
4138 );
4139 let mut env = base_env();
4140 env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "5".into()));
4141 env.push(("AGENT_LIMITS_DEADLINE_SECS".into(), "30".into())); env.push(("LIMITS_LIFETIME_TOKENS".into(), "4000".into())); env.push(("MODEL_SWAP".into(), "restart-turn".into())); env.push(("AGENTD_SUBSCRIBE".into(), "a://1, a://2".into())); env.push((
4146 "AGENTD_INTELLIGENCE_HEADERS".into(),
4147 "{x-team: ops}".into(), ));
4149 let c = Config::load(&args(&["--config", file.path().to_str().unwrap()]), &env).unwrap();
4150 assert_eq!(c.max_steps, 5, "env path beats the file");
4151 assert_eq!(
4152 c.max_depth, 9,
4153 "untouched sibling path keeps the file value"
4154 );
4155 assert_eq!(c.deadline, Some(Duration::from_secs(30)));
4156 assert_eq!(c.budget_tokens_lifetime, 4000);
4157 assert_eq!(c.model_swap, SwapPolicy::RestartTurn);
4158 assert_eq!(c.subscribe, vec!["a://1", "a://2"]);
4159 assert_eq!(
4160 c.intelligence_headers.get("x-team").map(String::as_str),
4161 Some("ops")
4162 );
4163 assert_eq!(c.model.as_deref(), Some("f"));
4164
4165 let mut env = base_env();
4167 env.push(("LIMITS_MAX_STEPS".into(), "1".into()));
4168 env.push(("AGENT_LIMITS_MAX_STEPS".into(), "2".into()));
4169 assert_eq!(Config::load(&args(&[]), &env).unwrap().max_steps, 2);
4170 env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "3".into()));
4171 assert_eq!(Config::load(&args(&[]), &env).unwrap().max_steps, 3);
4172
4173 let mut env = base_env();
4175 env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "many".into()));
4176 let e = Config::load(&args(&[]), &env).unwrap_err();
4177 assert!(matches!(e, ConfigError::Usage(_)));
4178 assert!(format!("{e}").contains("AGENTD_LIMITS_MAX_STEPS"), "{e}");
4179 let mut env = base_env();
4180 env.push(("AGENTD_LOG_LEVEL".into(), "loud".into()));
4181 let e = Config::load(&args(&[]), &env).unwrap_err();
4182 assert!(format!("{e}").contains("AGENTD_LOG_LEVEL"), "{e}");
4183 }
4184
4185 #[test]
4186 fn generic_path_flags_override_env_and_file() {
4187 let file = write_tmp_ext("limits:\n max_steps: 1\n", "yaml");
4191 let mut env = base_env();
4192 env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "2".into()));
4193 for spelling in [
4194 "--limits.max-steps",
4195 "--limits-max-steps",
4196 "--limits.max_steps",
4197 ] {
4198 let c = Config::load(
4199 &args(&["--config", file.path().to_str().unwrap(), spelling, "3"]),
4200 &env,
4201 )
4202 .unwrap();
4203 assert_eq!(c.max_steps, 3, "{spelling}");
4204 }
4205 let c = Config::load(
4207 &args(&["--max-steps", "4", "--limits.max_steps", "5"]),
4208 &base_env(),
4209 )
4210 .unwrap();
4211 assert_eq!(c.max_steps, 5);
4212 let c = Config::load(
4213 &args(&["--limits.max_steps", "5", "--max-steps", "6"]),
4214 &base_env(),
4215 )
4216 .unwrap();
4217 assert_eq!(c.max_steps, 6);
4218 let c = Config::load(
4220 &args(&["--subscribe", "a://1", "--subscribe", "[a://2, a://3]"]),
4221 &base_env(),
4222 )
4223 .unwrap();
4224 assert_eq!(c.subscribe, vec!["a://1", "[a://2, a://3]"]);
4226 let c = Config::load(
4227 &args(&[
4228 "--mcp-servers",
4229 "[{name: q, endpoint: https://q.example/mcp}]",
4230 ]),
4231 &base_env(),
4232 )
4233 .unwrap();
4234 assert_eq!(c.mcp_servers.len(), 1);
4235 assert_eq!(c.mcp_servers[0].name, "q");
4236 let e = Config::load(&args(&["--limits.max-steps", "lots"]), &base_env()).unwrap_err();
4238 assert!(format!("{e}").contains("--limits.max-steps"), "{e}");
4239 let e = Config::load(&args(&["--model-swap", "sideways"]), &base_env()).unwrap_err();
4241 assert!(matches!(e, ConfigError::Usage(_)), "{e}");
4242 let e = Config::load(&args(&["--no-such-thing", "1"]), &base_env()).unwrap_err();
4244 assert!(format!("{e}").contains("unknown argument"), "{e}");
4245 let e = Config::load(&args(&["--limits.max-steps"]), &base_env()).unwrap_err();
4247 assert!(format!("{e}").contains("requires a value"), "{e}");
4248 }
4249
4250 #[test]
4251 fn multiple_config_files_merge_in_order_later_wins() {
4252 let base = write_tmp_ext(
4257 "model: base
4258log_level: warn
4259limits:
4260 max_steps: 1
4261 max_depth: 2
4262subscribe: [a://1, a://2]
4263",
4264 "yaml",
4265 );
4266 let site = write_tmp_ext(
4267 r#"{ "model": "site", "limits": { "max_steps": 5 }, "subscribe": ["a://3"] }"#,
4268 "json",
4269 );
4270 let over = write_tmp_ext(
4271 "model: over
4272log_level: null
4273limits:
4274 max_depth: 7
4275",
4276 "yml",
4277 );
4278 let mut env = base_env();
4279 env.push((
4280 "AGENT_CONFIG".into(), format!("{}:{}", base.path().display(), site.path().display()),
4282 ));
4283 let c = Config::load(&args(&["--config", over.path().to_str().unwrap()]), &env).unwrap();
4284 assert_eq!(
4285 c.model.as_deref(),
4286 Some("over"),
4287 "last file wins on a scalar"
4288 );
4289 assert_eq!(
4290 c.max_steps, 5,
4291 "site's limits.max_steps survives (objects merge)"
4292 );
4293 assert_eq!(c.max_depth, 7, "over's limits.max_depth wins");
4294 assert_eq!(c.subscribe, vec!["a://3"], "a later file REPLACES a list");
4295 assert_eq!(
4296 c.log_level,
4297 Level::Info,
4298 "`null` unsets → back to the default"
4299 );
4300 assert_eq!(c.config_files.len(), 3);
4301 assert!(c.config_files[0].ends_with(".yaml") && c.config_files[2].ends_with(".yml"));
4302 let c = Config::load(
4304 &args(&[
4305 "--config",
4306 over.path().to_str().unwrap(),
4307 "--config",
4308 base.path().to_str().unwrap(),
4309 ]),
4310 &base_env(),
4311 )
4312 .unwrap();
4313 assert_eq!(c.model.as_deref(), Some("base"));
4314 assert_eq!(c.max_depth, 2);
4315 let mut env2 = base_env();
4317 env2.push(("AGENTD_MODEL".into(), "from-env".into()));
4318 let c = Config::load(
4319 &args(&[
4320 "--config",
4321 base.path().to_str().unwrap(),
4322 "--config",
4323 over.path().to_str().unwrap(),
4324 "--limits.max-depth",
4325 "9",
4326 ]),
4327 &env2,
4328 )
4329 .unwrap();
4330 assert_eq!(c.model.as_deref(), Some("from-env"));
4331 assert_eq!(c.max_depth, 9);
4332 let e = Config::load(
4334 &args(&[
4335 "--config",
4336 base.path().to_str().unwrap(),
4337 "--config",
4338 "/no/such/overlay.yaml",
4339 ]),
4340 &base_env(),
4341 )
4342 .unwrap_err();
4343 assert!(format!("{e}").contains("/no/such/overlay.yaml"), "{e}");
4344 let paths = Config::config_paths_from(
4345 &args(&["--config", "b.yaml"]),
4346 &[("AGENT_CONFIG".into(), "x.yaml::y.json:".into())],
4347 );
4348 assert_eq!(paths, vec!["x.yaml", "y.json", "b.yaml"]);
4349 }
4350
4351 #[test]
4352 fn setting_a_path_replaces_the_value_while_named_flags_add() {
4353 let file = write_tmp_ext(
4357 "subscribe: [a://file]
4358mcp_servers:
4359 - name: web
4360 endpoint: https://web.example/mcp
4361intelligence_headers:
4362 keep: me
4363 x-team: file
4364",
4365 "yaml",
4366 );
4367 let path = file.path().to_str().unwrap().to_string();
4368 let mut env = base_env();
4370 env.push(("AGENTD_SUBSCRIBE".into(), "a://env".into()));
4371 let c = Config::load(&args(&["--config", &path]), &env).unwrap();
4372 assert_eq!(c.subscribe, vec!["a://env"]);
4373 let c = Config::load(&args(&["--config", &path, "--subscribe", "a://flag"]), &env).unwrap();
4375 assert_eq!(c.subscribe, vec!["a://env", "a://flag"]);
4376 let c = Config::load(
4378 &args(&[
4379 "--config",
4380 &path,
4381 "--mcp-servers",
4382 "[{name: q, endpoint: https://q.example/mcp}]",
4383 "--mcp",
4384 "x=https://x.example/mcp",
4385 ]),
4386 &base_env(),
4387 )
4388 .unwrap();
4389 let names: Vec<&str> = c.mcp_servers.iter().map(|s| s.name.as_str()).collect();
4390 assert_eq!(names, vec!["q", "x"]);
4391 let c = Config::load(
4393 &args(&["--config", &path, "--intelligence_headers.x-team", "ops"]),
4394 &base_env(),
4395 )
4396 .unwrap();
4397 assert_eq!(
4398 c.intelligence_headers.get("x-team").map(String::as_str),
4399 Some("ops")
4400 );
4401 assert_eq!(
4402 c.intelligence_headers.get("keep").map(String::as_str),
4403 Some("me")
4404 );
4405 let c = Config::load(
4407 &args(&["--config", &path, "--intelligence-headers", "{only: this}"]),
4408 &base_env(),
4409 )
4410 .unwrap();
4411 assert_eq!(c.intelligence_headers.len(), 1);
4412 assert_eq!(
4413 c.intelligence_headers.get("only").map(String::as_str),
4414 Some("this")
4415 );
4416 let e = Config::load(&args(&["--mcp-servers.0.aauth", "true"]), &base_env()).unwrap_err();
4418 assert!(format!("{e}").contains("array elements"), "{e}");
4419 }
4420
4421 #[test]
4422 fn help_lists_every_config_path_with_flag_and_env() {
4423 let h = match Config::load(&args(&["--help"]), &[]) {
4424 Err(ConfigError::Help(h)) => h,
4425 other => panic!("expected help, got {other:?}"),
4426 };
4427 assert!(h.contains("CONFIG PATHS"), "{h}");
4428 assert!(h.contains("limits.max_steps"), "{h}");
4429 assert!(h.contains("--limits-max-steps"), "{h}");
4430 assert!(h.contains("AGENTD_LIMITS_MAX_STEPS"), "{h}");
4431 assert!(h.contains("YAML or JSON"), "{h}");
4432 }
4433
4434 #[test]
4435 fn reload_re_reads_a_yaml_file() {
4436 let file = write_tmp_ext("model: v1\nlimits:\n max_steps: 10\n", "yaml");
4439 let path = file.path().to_str().unwrap().to_string();
4440 let a = args(&["--config", &path, "--max-depth", "2"]);
4441 let env = base_env();
4442 let running = Config::load(&a, &env).unwrap();
4443 assert_eq!(running.model.as_deref(), Some("v1"));
4444 assert_eq!(running.max_steps, 10);
4445 std::fs::write(
4446 &path,
4447 "model: v2\nlimits:\n max_steps: 20\n max_depth: 7\n",
4448 )
4449 .unwrap();
4450 let reloaded = Config::reload(&a, &env).unwrap();
4451 assert_eq!(reloaded.model.as_deref(), Some("v2"));
4452 assert_eq!(reloaded.max_steps, 20);
4453 assert_eq!(
4454 reloaded.max_depth, 2,
4455 "the flag still overrides the new file"
4456 );
4457 std::fs::write(&path, "model: [unterminated\n").unwrap();
4459 assert!(matches!(
4460 Config::reload(&a, &env),
4461 Err(ConfigError::Usage(_))
4462 ));
4463 }
4464
4465 #[test]
4466 fn flag_mcp_and_subscribe_add_to_the_file_list() {
4467 let file = write_tmp(
4470 r#"{ "mcp_servers": [{ "name": "web", "endpoint": "https://web.example.com/mcp" }],
4471 "subscribe": ["fs:file:///a"] }"#,
4472 );
4473 let c = Config::load(
4474 &args(&[
4475 "--config",
4476 file.path().to_str().unwrap(),
4477 "--mcp",
4478 "fs=https://fs.example",
4479 "--subscribe",
4480 "fs:file:///b",
4481 ]),
4482 &base_env(),
4483 )
4484 .unwrap();
4485 let names: Vec<&str> = c.mcp_servers.iter().map(|s| s.name.as_str()).collect();
4486 assert_eq!(names, vec!["web", "fs"]); assert_eq!(c.subscribe, vec!["fs:file:///a", "fs:file:///b"]);
4488 }
4489
4490 #[test]
4491 fn config_via_env_alias() {
4492 let file = write_tmp(r#"{ "model": "env-config" }"#);
4493 let mut env = base_env();
4494 env.push(("AGENTD_CONFIG".into(), file.path().to_str().unwrap().into()));
4495 let c = Config::load(&args(&[]), &env).unwrap();
4496 assert_eq!(c.model.as_deref(), Some("env-config"));
4497 }
4498
4499 #[test]
4500 fn malformed_config_file_is_usage_error() {
4501 let file = write_tmp("{ this is not json ");
4502 let e = Config::load(
4503 &args(&["--config", file.path().to_str().unwrap()]),
4504 &base_env(),
4505 )
4506 .unwrap_err();
4507 assert!(matches!(e, ConfigError::Usage(_)));
4508 }
4509
4510 #[test]
4511 fn unreadable_config_file_is_usage_error() {
4512 let e =
4513 Config::load(&args(&["--config", "/no/such/config.json"]), &base_env()).unwrap_err();
4514 assert!(matches!(e, ConfigError::Usage(_)));
4515 }
4516
4517 #[test]
4518 fn config_file_unknown_key_is_usage_error() {
4519 let file = write_tmp(r#"{ "max_token": 5 }"#);
4521 let e = Config::load(
4522 &args(&["--config", file.path().to_str().unwrap()]),
4523 &base_env(),
4524 )
4525 .unwrap_err();
4526 assert!(matches!(e, ConfigError::Usage(_)));
4527 }
4528
4529 #[cfg(not(feature = "config-watch"))]
4535 #[test]
4536 fn watch_config_requires_config_watch_feature() {
4537 let file = write_tmp(r#"{ "model": "m" }"#);
4538 let e = Config::load(
4539 &args(&["--config", file.path().to_str().unwrap(), "--watch-config"]),
4540 &base_env(),
4541 )
4542 .unwrap_err();
4543 match e {
4544 ConfigError::Usage(msg) => assert!(
4545 msg.contains("--watch-config requires the 'config-watch' build feature"),
4546 "got: {msg}"
4547 ),
4548 other => panic!("expected Usage, got {other:?}"),
4549 }
4550 }
4551
4552 #[cfg(feature = "config-watch")]
4555 #[test]
4556 fn watch_config_parses_with_a_config_file() {
4557 let file = write_tmp(r#"{ "model": "m" }"#);
4558 let c = Config::load(
4559 &args(&["--config", file.path().to_str().unwrap(), "--watch-config"]),
4560 &base_env(),
4561 )
4562 .unwrap();
4563 assert!(c.watch_config);
4564 }
4565
4566 #[cfg(feature = "config-watch")]
4568 #[test]
4569 fn watch_config_parses_from_env() {
4570 let file = write_tmp(r#"{ "model": "m" }"#);
4571 let mut env = base_env();
4572 env.push(("AGENTD_CONFIG".into(), file.path().to_str().unwrap().into()));
4573 env.push(("AGENTD_WATCH_CONFIG".into(), "true".into()));
4574 let c = Config::load(&args(&[]), &env).unwrap();
4575 assert!(c.watch_config);
4576 }
4577
4578 #[cfg(feature = "config-watch")]
4582 #[test]
4583 fn watch_config_requires_a_config_file() {
4584 let e = Config::load(&args(&["--watch-config"]), &base_env()).unwrap_err();
4585 match e {
4586 ConfigError::Usage(msg) => assert!(
4587 msg.contains("--watch-config requires a config file"),
4588 "got: {msg}"
4589 ),
4590 other => panic!("expected Usage, got {other:?}"),
4591 }
4592 }
4593
4594 #[cfg(feature = "config-watch")]
4597 #[test]
4598 fn validate_config_flags_watch_config_without_a_file() {
4599 let v = validate_verdict(&["--validate-config", "--watch-config"], &base_env());
4600 let lines = v.expect_err("watch-config without a file is invalid");
4601 assert!(
4602 lines.contains("--watch-config requires a config file"),
4603 "got: {lines}"
4604 );
4605 }
4606
4607 fn validate_verdict(args_: &[&str], env: &[(String, String)]) -> Result<String, String> {
4610 match Config::load(&args(args_), env).unwrap_err() {
4611 ConfigError::Validate(v) => v,
4612 other => panic!("expected Validate, got {other:?}"),
4613 }
4614 }
4615
4616 #[test]
4617 fn validate_config_valid_returns_ok_with_no_instruction_needed() {
4618 let v = validate_verdict(&["--validate-config"], &base_env());
4621 let line = v.expect("a complete config validates");
4622 assert!(line.contains("config.valid"));
4623 let _: serde_json::Value = serde_json::from_str(&line).unwrap();
4624 }
4625
4626 #[test]
4627 fn validate_config_invalid_returns_err_exit2_shape() {
4628 let v = validate_verdict(&["--validate-config", "--mode", "reactive"], &base_env());
4630 let lines = v.unwrap_err();
4631 assert!(lines.contains("config.invalid"));
4632 for line in lines.lines() {
4634 let _: serde_json::Value = serde_json::from_str(line).unwrap();
4635 }
4636 }
4637
4638 #[test]
4639 fn validate_config_refuses_a_trifecta_only_config_exit2() {
4640 let v = validate_verdict(
4646 &[
4647 "--validate-config",
4648 "--mcp",
4649 "s=https://s.example",
4650 "--mcp-tags",
4651 "s=untrusted_input,sensitive,egress",
4652 ],
4653 &base_env(),
4654 );
4655 let lines = v.expect_err("a trifecta-only config must be invalid");
4656 assert!(lines.contains("config.invalid"), "got: {lines}");
4657 assert!(lines.contains("lethal-trifecta"), "got: {lines}");
4658 for line in lines.lines() {
4659 let _: serde_json::Value = serde_json::from_str(line).unwrap();
4660 }
4661 }
4662
4663 #[test]
4664 fn validate_config_and_startup_agree_on_trifecta() {
4665 let trifecta = [
4668 "--mcp",
4669 "s=https://s.example",
4670 "--mcp-tags",
4671 "s=untrusted_input,sensitive,egress",
4672 ];
4673 let startup = Config::load(&args(&trifecta), &base_env()).unwrap_err();
4675 assert!(matches!(startup, ConfigError::Usage(_)));
4676 let mut allowed = vec!["--allow-trifecta"];
4678 allowed.extend_from_slice(&trifecta);
4679 assert!(Config::load(&args(&allowed), &base_env()).is_ok());
4680 let mut allowed_vc = vec!["--validate-config", "--allow-trifecta"];
4681 allowed_vc.extend_from_slice(&trifecta);
4682 assert!(validate_verdict(&allowed_vc, &base_env()).is_ok());
4683 }
4684
4685 #[test]
4686 fn validate_config_runs_without_an_instruction() {
4687 let env = vec![("AGENTD_INTELLIGENCE".into(), "https://intel.example".into())];
4691 let v = match Config::load(&args(&["--validate-config"]), &env).unwrap_err() {
4692 ConfigError::Validate(v) => v,
4693 other => panic!("expected Validate, got {other:?}"),
4694 };
4695 let lines = v.unwrap_err();
4696 assert!(lines.contains("config.invalid"));
4697 assert!(lines.contains("instruction"));
4698 }
4699
4700 #[test]
4701 fn validate_config_rejects_bad_intelligence_scheme() {
4702 let mut env = base_env();
4703 env.retain(|(k, _)| k != "AGENTD_INTELLIGENCE");
4704 let v = validate_verdict(&["--validate-config", "--intelligence", "ftp://nope"], &env);
4705 assert!(v.unwrap_err().contains("config.invalid"));
4706 }
4707
4708 #[test]
4711 fn config_schema_emits_parseable_json_schema() {
4712 let s = match Config::load(&args(&["--config-schema"]), &[]).unwrap_err() {
4713 ConfigError::Schema(s) => s,
4714 other => panic!("expected Schema, got {other:?}"),
4715 };
4716 let v: serde_json::Value = serde_json::from_str(&s).expect("schema is valid JSON");
4717 assert_eq!(
4718 v["$schema"],
4719 serde_json::json!("https://json-schema.org/draft/2020-12/schema")
4720 );
4721 assert!(v["properties"].is_object());
4722 }
4724
4725 #[test]
4728 fn intelligence_token_file_reads_and_trims() {
4729 let tok = write_tmp("file-token\n");
4730 let mut env = base_env();
4731 env.push((
4732 "AGENTD_INTELLIGENCE_TOKEN_FILE".into(),
4733 tok.path().to_str().unwrap().into(),
4734 ));
4735 let c = Config::load(&args(&[]), &env).unwrap();
4736 assert_eq!(c.intelligence_token.as_deref(), Some("file-token"));
4737 let dbg = format!("{c:?}");
4739 assert!(!dbg.contains("file-token"));
4740 assert!(dbg.contains("***"));
4741 }
4742
4743 #[test]
4744 fn inline_token_wins_over_token_file() {
4745 let tok = write_tmp("from-file\n");
4746 let mut env = base_env();
4747 env.push(("AGENTD_INTELLIGENCE_TOKEN".into(), "from-inline".into()));
4748 env.push((
4749 "AGENTD_INTELLIGENCE_TOKEN_FILE".into(),
4750 tok.path().to_str().unwrap().into(),
4751 ));
4752 let c = Config::load(&args(&[]), &env).unwrap();
4753 assert_eq!(c.intelligence_token.as_deref(), Some("from-inline"));
4754 }
4755
4756 #[test]
4757 #[cfg(feature = "aauth")]
4758 fn aauth_flags_and_validation() {
4759 let c = Config::load(
4761 &args(&[
4762 "--aauth-key-file",
4763 "/tmp/id.key",
4764 "--aauth-provider",
4765 "https://apd.example",
4766 "--aauth-enroll-token",
4767 "{{secret:ENROLL}}",
4768 "--aauth-enroll-assertion-file",
4769 "/var/run/secrets/aauth/token",
4770 "--aauth-person-server",
4771 "https://ps.example",
4772 ]),
4773 &base_env(),
4774 )
4775 .unwrap();
4776 let a = c.aauth.expect("aauth configured");
4777 assert_eq!(a.provider, "https://apd.example");
4778 assert_eq!(a.key_file, "/tmp/id.key");
4779 assert_eq!(a.enrollment_token.as_deref(), Some("{{secret:ENROLL}}"));
4780 assert_eq!(
4781 a.enroll_assertion_file.as_deref(),
4782 Some("/var/run/secrets/aauth/token")
4783 );
4784 assert_eq!(a.person_server.as_deref(), Some("https://ps.example"));
4785
4786 let mut env = base_env();
4788 env.push(("AGENT_AAUTH_PROVIDER".into(), "https://apd.example".into()));
4789 env.push((
4790 "AGENT_AAUTH_ENROLL_ASSERTION_FILE".into(),
4791 "/var/run/secrets/aauth/token".into(),
4792 ));
4793 let a = Config::load(&args(&[]), &env).unwrap().aauth.unwrap();
4794 assert_eq!(
4795 a.enroll_assertion_file.as_deref(),
4796 Some("/var/run/secrets/aauth/token")
4797 );
4798
4799 let mut env = base_env();
4801 env.push(("AGENT_AAUTH_PROVIDER".into(), "https://apd.example".into()));
4802 let c = Config::load(&args(&[]), &env).unwrap();
4803 assert_eq!(c.aauth.unwrap().key_file, "agent.key");
4804 assert!(Config::load(&args(&["--aauth-provider", "not-a-url"]), &base_env()).is_err());
4805 assert!(
4806 Config::load(
4807 &args(&[
4808 "--aauth-provider",
4809 "https://apd.example",
4810 "--aauth-person-server",
4811 "nope"
4812 ]),
4813 &base_env()
4814 )
4815 .is_err()
4816 );
4817 assert!(
4819 Config::load(&args(&["--aauth-key-file", "/x"]), &base_env())
4820 .unwrap()
4821 .aauth
4822 .is_none()
4823 );
4824 }
4825
4826 #[test]
4827 #[cfg(feature = "tls")]
4828 fn tls_ca_flag_env_and_content_validation() {
4829 let ca = write_tmp(include_str!("../../../net/tests/fixtures/ca.pem"));
4831 let ca_path = ca.path().to_str().unwrap().to_string();
4832
4833 let c = Config::load(&args(&["--tls-ca", &ca_path]), &base_env()).unwrap();
4835 assert_eq!(c.tls_ca.as_deref(), Some(ca_path.as_str()));
4836 assert!(format!("{c:?}").contains(&ca_path));
4838
4839 for key in ["AGENTD_TLS_CA", "AGENT_TLS_CA"] {
4841 let mut env = base_env();
4842 env.push((key.into(), ca_path.clone()));
4843 let c = Config::load(&args(&[]), &env).unwrap();
4844 assert_eq!(c.tls_ca.as_deref(), Some(ca_path.as_str()), "via {key}");
4845 }
4846
4847 let err = Config::load(&args(&["--tls-ca", "/nonexistent/ca.pem"]), &base_env());
4849 assert!(matches!(err, Err(ConfigError::Usage(_))));
4850
4851 let junk = write_tmp("not a pem");
4853 let err = Config::load(
4854 &args(&["--tls-ca", junk.path().to_str().unwrap()]),
4855 &base_env(),
4856 );
4857 assert!(matches!(err, Err(ConfigError::Usage(_))));
4858 }
4859
4860 #[test]
4861 fn token_file_flag_reads_via_cli() {
4862 let tok = write_tmp("flag-token");
4863 let c = Config::load(
4864 &args(&["--intelligence-token-file", tok.path().to_str().unwrap()]),
4865 &base_env(),
4866 )
4867 .unwrap();
4868 assert_eq!(c.intelligence_token.as_deref(), Some("flag-token"));
4869 }
4870
4871 #[test]
4872 fn missing_token_file_is_usage_error() {
4873 let mut env = base_env();
4874 env.push((
4875 "AGENTD_INTELLIGENCE_TOKEN_FILE".into(),
4876 "/no/such/token".into(),
4877 ));
4878 let e = Config::load(&args(&[]), &env).unwrap_err();
4879 assert!(matches!(e, ConfigError::Usage(_)));
4880 }
4881
4882 #[test]
4883 fn secret_file_ref_resolves_and_does_not_leak() {
4884 let secret = write_tmp("RESOLVED-SECRET-VALUE\n");
4888 let path = secret.path().to_str().unwrap().to_string();
4889 let file = write_tmp(&format!(
4890 r#"{{ "intelligence_headers": {{
4891 "authorization": "Bearer {{{{secret-file:{path}}}}}" }} }}"#
4892 ));
4893 let c = Config::load(
4894 &args(&["--config", file.path().to_str().unwrap()]),
4895 &base_env(),
4896 )
4897 .unwrap();
4898 assert_eq!(
4900 c.intelligence_headers
4901 .get("authorization")
4902 .map(String::as_str),
4903 Some(format!("Bearer {{{{secret-file:{path}}}}}").as_str())
4904 );
4905 let dbg = format!("{c:?}");
4907 assert!(!dbg.contains("RESOLVED-SECRET-VALUE"));
4908 let env = |_: &str| None;
4910 let resolved =
4911 crate::sec::secret::resolve(c.intelligence_headers.get("authorization").unwrap(), &env)
4912 .unwrap();
4913 assert_eq!(resolved, "Bearer RESOLVED-SECRET-VALUE");
4914 }
4915
4916 #[test]
4917 fn inline_secret_shaped_header_is_rejected() {
4918 let file = write_tmp(r#"{ "intelligence_headers": { "x-api-key": "sk-inline-literal" } }"#);
4921 let e = Config::load(
4922 &args(&["--config", file.path().to_str().unwrap()]),
4923 &base_env(),
4924 )
4925 .unwrap_err();
4926 assert!(matches!(e, ConfigError::Usage(_)));
4927 unsafe {
4932 std::env::set_var("AGENTD_TEST_HDR_KEY_0017", "k");
4933 }
4934 let file_ok = write_tmp(
4935 r#"{ "intelligence_headers": { "x-api-key": "{{secret:AGENTD_TEST_HDR_KEY_0017}}" } }"#,
4936 );
4937 let c = Config::load(
4938 &args(&["--config", file_ok.path().to_str().unwrap()]),
4939 &base_env(),
4940 )
4941 .unwrap();
4942 assert!(c.intelligence_headers.contains_key("x-api-key"));
4943 unsafe {
4944 std::env::remove_var("AGENTD_TEST_HDR_KEY_0017");
4945 }
4946 }
4947
4948 #[test]
4949 fn unresolvable_secret_ref_in_header_is_rejected_at_validation() {
4950 let file = write_tmp(
4952 r#"{ "intelligence_headers": { "x-api-key": "{{secret:DEFINITELY_UNSET_VAR_XYZ}}" } }"#,
4953 );
4954 let e = Config::load(
4955 &args(&["--config", file.path().to_str().unwrap()]),
4956 &base_env(),
4957 )
4958 .unwrap_err();
4959 assert!(matches!(e, ConfigError::Usage(_)));
4960 }
4961
4962 fn reactive_base() -> Config {
4966 Config::load(
4967 &args(&["--mode", "reactive", "--subscribe", "file:///in.json"]),
4968 &base_env(),
4969 )
4970 .unwrap()
4971 }
4972
4973 #[test]
4974 fn coherence_rejects_a_differing_restart_only_field() {
4975 let running = reactive_base();
4978 for mutate in [
4979 (|c: &mut Config| c.mode = Mode::Loop) as fn(&mut Config),
4980 |c: &mut Config| c.run_id = "different-run-id".into(),
4981 |c: &mut Config| c.serve_mcp = Some("https://a.example:8443".into()),
4982 |c: &mut Config| c.drain_timeout = Duration::from_secs(99),
4983 ] {
4984 let mut new = running.clone();
4985 mutate(&mut new);
4986 let diags = Config::reload_coherence_check(&new, Some(&running), true)
4987 .expect_err("a restart-only diff must be rejected");
4988 assert!(
4989 diags
4990 .iter()
4991 .any(|d| d.is_error() && d.msg.contains("restart-only")),
4992 "expected a restart-only error, got {diags:?}"
4993 );
4994 }
4995 }
4996
4997 #[test]
4998 fn coherence_accepts_a_reloadable_diff() {
4999 let running = reactive_base();
5003 for mutate in [
5004 (|c: &mut Config| c.log_level = Level::Debug) as fn(&mut Config),
5005 |c: &mut Config| c.model = Some("claude-opus-4".into()),
5006 |c: &mut Config| c.max_tokens = 999_999,
5007 |c: &mut Config| c.max_steps = 123,
5008 |c: &mut Config| c.subscribe = vec!["file:///in.json".into(), "file:///b.json".into()],
5009 |c: &mut Config| {
5011 c.mcp_servers = vec![McpServerSpec {
5012 name: "added".into(),
5013 endpoint: "unix:/mcp-new.sock".into(),
5014 ..Default::default()
5015 }]
5016 },
5017 |c: &mut Config| c.intelligence = Some("https://other.example".into()),
5019 |c: &mut Config| c.model_swap = SwapPolicy::RestartTurn,
5020 ] {
5021 let mut new = running.clone();
5022 mutate(&mut new);
5023 assert!(
5024 Config::reload_coherence_check(&new, Some(&running), true).is_ok(),
5025 "a reloadable diff must be accepted",
5026 );
5027 }
5028 }
5029
5030 #[test]
5031 fn mcp_servers_is_reloadable_not_restart_only() {
5032 assert!(
5036 !RESTART_ONLY_FIELDS.contains(&"mcp_servers"),
5037 "mcp_servers must NOT be restart-only"
5038 );
5039 let running = reactive_base();
5040 let mut added = running.clone();
5042 added.mcp_servers.push(McpServerSpec {
5043 name: "extra".into(),
5044 endpoint: "unix:/mcp-extra.sock".into(),
5045 ..Default::default()
5046 });
5047 assert!(
5048 Config::reload_coherence_check(&added, Some(&running), true).is_ok(),
5049 "adding an MCP server must pass the coherence check (it is reloadable)"
5050 );
5051 let mut with_server = running.clone();
5053 with_server.mcp_servers = vec![McpServerSpec {
5054 name: "s".into(),
5055 endpoint: "unix:/mcp-orig.sock".into(),
5056 ..Default::default()
5057 }];
5058 let mut edited = with_server.clone();
5059 edited.mcp_servers[0].endpoint = "https://mcp-edited.example".into();
5060 assert!(
5061 Config::reload_coherence_check(&edited, Some(&with_server), true).is_ok(),
5062 "editing an MCP server must pass the coherence check (it is reloadable)"
5063 );
5064 }
5065
5066 #[test]
5067 fn model_swap_flag_and_env_parse_and_default() {
5068 let def = Config::load(&args(&[]), &base_env()).unwrap();
5071 assert_eq!(def.model_swap, SwapPolicy::FinishOnOld);
5072 let flag = Config::load(&args(&["--model-swap", "restart-turn"]), &base_env()).unwrap();
5073 assert_eq!(flag.model_swap, SwapPolicy::RestartTurn);
5074 let mut env = base_env();
5075 env.push(("AGENTD_MODEL_SWAP".into(), "restart-turn".into()));
5076 let e = Config::load(&args(&[]), &env).unwrap();
5077 assert_eq!(e.model_swap, SwapPolicy::RestartTurn);
5078 assert!(matches!(
5080 Config::load(&args(&["--model-swap", "nope"]), &base_env()),
5081 Err(ConfigError::Usage(_))
5082 ));
5083 }
5084
5085 #[test]
5086 fn intelligence_is_reloadable_not_restart_only() {
5087 assert!(
5090 !RESTART_ONLY_FIELDS.contains(&"intelligence"),
5091 "intelligence must NOT be restart-only"
5092 );
5093 let running = reactive_base();
5094 let mut new = running.clone();
5095 new.intelligence = Some("https://gw-b.example:1234".into());
5096 assert!(
5097 Config::reload_coherence_check(&new, Some(&running), true).is_ok(),
5098 "an endpoint repoint must pass the coherence check (it is reloadable)"
5099 );
5100 }
5101
5102 #[test]
5103 fn coherence_rejects_duplicate_server_names() {
5104 let mut cfg = reactive_base();
5105 cfg.mcp_servers = vec![
5106 McpServerSpec {
5107 name: "dup".into(),
5108 endpoint: "unix:/a.sock".into(),
5109 ..Default::default()
5110 },
5111 McpServerSpec {
5112 name: "dup".into(),
5113 endpoint: "unix:/b.sock".into(),
5114 ..Default::default()
5115 },
5116 ];
5117 let diags = Config::reload_coherence_check(&cfg, None, false)
5118 .expect_err("duplicate server names must be an error");
5119 assert!(
5120 diags
5121 .iter()
5122 .any(|d| d.is_error() && d.msg.contains("duplicate"))
5123 );
5124 }
5125
5126 #[test]
5127 fn restart_only_set_pins_the_immutable_fields() {
5128 for &f in RESTART_ONLY_FIELDS {
5133 let mut a = reactive_base();
5134 let b = a.clone();
5135 match f {
5137 "mode" => a.mode = Mode::Loop,
5138 "run_id" => a.run_id = "x".into(),
5139 "serve_mcp" => a.serve_mcp = Some("https://s.example:8443".into()),
5140 "drain_timeout" => a.drain_timeout = Duration::from_secs(123),
5141 "continue_subscribe" => a.continue_subscribe = vec!["u".into()],
5142 other => panic!("RESTART_ONLY_FIELDS has an unmapped field '{other}'"),
5143 }
5144 assert!(
5145 a.restart_only_field_differs(&b, f),
5146 "restart-only field '{f}' must be diff-detected"
5147 );
5148 }
5149 }
5150
5151 #[test]
5152 fn effective_view_carries_no_secret_or_url() {
5153 const TOKEN: &str = "super-secret-effective-token";
5156 let mut env = base_env();
5157 env.push(("AGENTD_INTELLIGENCE_TOKEN".into(), TOKEN.into()));
5158 env.push((
5159 "AGENTD_INTELLIGENCE".into(),
5160 "https://user:embedded-cred@api.example/v1".into(),
5161 ));
5162 let mut cfg =
5163 Config::load(&args(&["--mcp", "vault=https://vault.example/mcp"]), &env).unwrap();
5164 cfg.intelligence_headers
5165 .insert("x-api-key".into(), "{{secret:SOME_NAME}}".into());
5166 let view = cfg.effective_view();
5167 let blob = serde_json::to_string(&view).unwrap();
5168 assert!(!blob.contains(TOKEN), "token leaked into effective view");
5169 assert!(!blob.contains("embedded-cred"), "URL creds leaked");
5170 assert!(!blob.contains("api.example"), "endpoint host leaked");
5171 assert!(!blob.contains("SOME_NAME"), "header ref value leaked");
5172 assert!(!blob.contains("vault-secret.sock"), "mcp endpoint leaked");
5173 assert_eq!(view["mcp_servers"][0]["name"], serde_json::json!("vault"));
5175 assert_eq!(
5176 view["intelligence_headers"],
5177 serde_json::json!(["x-api-key"])
5178 );
5179 }
5180}