1pub mod file;
20pub mod paths;
21pub mod v2;
22#[cfg(all(unix, feature = "config-watch"))]
23pub mod watch;
24pub mod yaml;
25
26use crate::obs::log::Level;
27use crate::sec::scope::TrifectaTag;
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::fmt;
31use std::path::Path;
32use std::time::{Duration, SystemTime, UNIX_EPOCH};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Mode {
37 Once,
39 Loop,
41 Reactive,
43 Schedule,
45 #[cfg(feature = "workflow")]
48 Workflow,
49}
50
51impl Mode {
52 pub fn as_str(self) -> &'static str {
53 match self {
54 Mode::Once => "once",
55 Mode::Loop => "loop",
56 Mode::Reactive => "reactive",
57 Mode::Schedule => "schedule",
58 #[cfg(feature = "workflow")]
59 Mode::Workflow => "workflow",
60 }
61 }
62 pub fn parse(s: &str) -> Option<Mode> {
63 match s {
64 "once" => Some(Mode::Once),
65 "loop" => Some(Mode::Loop),
66 "reactive" => Some(Mode::Reactive),
67 "schedule" => Some(Mode::Schedule),
68 #[cfg(feature = "workflow")]
69 "workflow" => Some(Mode::Workflow),
70 _ => None,
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
82#[serde(rename_all = "kebab-case")]
83pub enum SwapPolicy {
84 #[default]
88 FinishOnOld,
89 RestartTurn,
94}
95
96impl SwapPolicy {
97 pub fn as_str(self) -> &'static str {
98 match self {
99 SwapPolicy::FinishOnOld => "finish-on-old",
100 SwapPolicy::RestartTurn => "restart-turn",
101 }
102 }
103 pub fn parse(s: &str) -> Option<SwapPolicy> {
104 match s {
105 "finish-on-old" => Some(SwapPolicy::FinishOnOld),
106 "restart-turn" => Some(SwapPolicy::RestartTurn),
107 _ => None,
108 }
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum ServeTarget {
118 Http { bind: String, tls: bool },
122}
123
124impl ServeTarget {
125 pub fn parse(spec: &str) -> Result<ServeTarget, ConfigError> {
129 if let Some(tls) = spec
134 .strip_prefix("https://")
135 .map(|_| true)
136 .or_else(|| spec.strip_prefix("http://").map(|_| false))
137 {
138 let authority = spec.split("://").nth(1).unwrap_or("");
139 if authority.is_empty() || authority.contains('/') {
140 return Err(usage(format!(
141 "--serve-mcp: want http(s)://HOST:PORT with no path (got: {spec})"
142 )));
143 }
144 let host = serve_host_of(authority);
145 let port_ok = serve_port_of(authority).is_some();
146 if host.is_empty() || !port_ok {
147 return Err(usage(format!(
148 "a2a.listen: HTTP(S) target needs an explicit host:port (got: {spec})"
149 )));
150 }
151 if !tls && !crate::net::http::is_loopback_host(host) {
152 return Err(usage(format!(
153 "--serve-mcp: plaintext http:// is allowed for loopback only; use https:// (got: {spec})"
154 )));
155 }
156 return Ok(ServeTarget::Http {
157 bind: authority.to_string(),
158 tls,
159 });
160 }
161 Err(usage(format!(
162 "--serve-mcp: want https://host:port (or loopback http://host:port for dev): {spec}"
163 )))
164 }
165}
166
167impl Config {
168 fn validate_serve_auth(
175 &self,
176 target: &ServeTarget,
177 env: &dyn Fn(&str) -> Option<String>,
178 ) -> Result<(), ConfigError> {
179 let ServeTarget::Http { bind, tls } = target;
180 let (bind, tls) = (bind.as_str(), *tls);
181 if tls {
182 match (&self.serve_cert, &self.serve_key) {
183 (Some(cert), Some(key)) => {
184 check_readable("--serve-cert", cert)?;
185 check_readable("--serve-key", key)?;
186 }
187 _ => {
188 return Err(usage(
189 "--serve-mcp https:// requires --serve-cert and --serve-key (PEM file paths)".into(),
190 ));
191 }
192 }
193 } else if self.serve_cert.is_some() || self.serve_key.is_some() {
194 return Err(usage(
195 "--serve-cert/--serve-key need an https:// serve target (plaintext http:// is loopback dev only)".into(),
196 ));
197 }
198 if let Some(ca) = &self.serve_client_ca {
199 check_readable("--serve-client-ca", ca)?;
200 }
201 if let Some(bearer) = &self.serve_bearer {
202 crate::sec::secret::refs_resolvable(bearer, env)
203 .map_err(|e| usage(format!("--serve-bearer: {e}")))?;
204 }
205 let loopback = crate::net::http::is_loopback_host(serve_host_of(bind));
207 if !loopback && self.serve_client_ca.is_none() && self.serve_bearer.is_none() {
208 return Err(usage(
209 "a non-loopback a2a.listen needs client auth: set a2a.tls.client_ca (mTLS) and/or a2a.bearer".into(),
210 ));
211 }
212 Ok(())
213 }
214}
215
216fn check_readable(flag: &str, path: &str) -> Result<(), ConfigError> {
220 std::fs::File::open(path).map_err(|e| usage(format!("{flag}: cannot read {path}: {e}")))?;
221 Ok(())
222}
223
224pub(crate) fn serve_host_of(authority: &str) -> &str {
227 if let Some(rest) = authority.strip_prefix('[') {
228 return rest.split(']').next().unwrap_or(rest);
229 }
230 authority.rsplit_once(':').map_or(authority, |(h, _)| h)
231}
232
233fn serve_port_of(authority: &str) -> Option<u16> {
235 let port_str = if authority.starts_with('[') {
236 authority.rsplit_once("]:").map(|(_, p)| p)?
237 } else {
238 authority.rsplit_once(':').map(|(_, p)| p)?
239 };
240 port_str.parse::<u16>().ok().filter(|p| *p != 0)
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct A2aPeerSpec {
253 pub name: String,
254 pub endpoint: String,
255 #[serde(default, skip_serializing_if = "Vec::is_empty")]
260 pub headers: Vec<(String, String)>,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub client_cert: Option<String>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub client_key: Option<String>,
268}
269
270impl A2aPeerSpec {
271 pub fn endpoint_of(&self) -> Result<A2aEndpoint, String> {
276 A2aEndpoint::parse(&self.endpoint).map_err(|e| e.to_string())
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
285pub enum A2aEndpoint {
286 Https(String),
290}
291
292impl A2aEndpoint {
293 pub fn parse(spec: &str) -> Result<A2aEndpoint, ConfigError> {
297 if spec.starts_with("https://") {
298 return Ok(A2aEndpoint::Https(spec.to_string()));
299 }
300 if spec.starts_with("http://") {
301 let host = crate::net::http::Url::parse(spec)
302 .map(|u| u.host)
303 .unwrap_or_default();
304 if !crate::net::http::is_loopback_host(&host) {
305 return Err(usage(format!(
306 "--a2a-peer: plaintext http:// is allowed for loopback only; use https:// (got: {spec})"
307 )));
308 }
309 return Ok(A2aEndpoint::Https(spec.to_string()));
310 }
311 Err(usage(format!(
312 "--a2a-peer: endpoint must be https://host[:port] (or loopback http:// for dev): {spec}"
313 )))
314 }
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
323pub struct McpServerSpec {
324 pub name: String,
325 pub endpoint: String,
328 #[serde(default, skip_serializing_if = "Vec::is_empty")]
332 pub headers: Vec<(String, String)>,
333 #[serde(default, skip_serializing_if = "Vec::is_empty")]
338 pub tags: Vec<TrifectaTag>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub aauth: Option<bool>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub oauth: Option<McpOauthSpec>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub auth: Option<AuthSpec>,
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct McpOauthSpec {
364 pub token_url: String,
365 pub client_id: String,
366 pub client_secret: String,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub scope: Option<String>,
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
376pub struct AuthSpec {
377 pub kind: String,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub grant: Option<String>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub issuer: Option<String>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub token_url: Option<String>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub device_authorization_url: Option<String>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub authorization_url: Option<String>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub client_id: Option<String>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub client_secret: Option<String>,
393 #[serde(default, skip_serializing_if = "Vec::is_empty")]
394 pub scopes: Vec<String>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub audience: Option<String>,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub token: Option<String>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub header: Option<String>,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub value: Option<String>,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub region: Option<String>,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub service: Option<String>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub source: Option<String>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub sso_start_url: Option<String>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub account_id: Option<String>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub role_name: Option<String>,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub svid: Option<String>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub jwt_svid_file: Option<String>,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub svid_file: Option<String>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub key_file: Option<String>,
429}
430
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct AAuthSettings {
437 pub provider: String,
439 pub key_file: String,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub enrollment_token: Option<String>,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub enroll_assertion_file: Option<String>,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub person_server: Option<String>,
457}
458
459pub fn is_mcp_endpoint(s: &str) -> bool {
462 let s = s.trim();
463 s.starts_with("https://") || s.starts_with("http://")
468}
469
470pub fn mcp_endpoint_scheme_ok(endpoint: &str) -> Result<(), ConfigError> {
477 let e = endpoint.trim();
478 if e.starts_with("https://") {
479 return Ok(());
480 }
481 if let Some(rest) = e.strip_prefix("http://") {
482 let host = rest.split('/').next().unwrap_or(rest);
483 let host = if host.starts_with('[') {
484 host.split(']').next().map_or(host, |h| &h[1..])
485 } else {
486 host.rsplit_once(':').map_or(host, |(h, _)| h)
487 };
488 if crate::net::http::is_loopback_host(host) {
489 return Ok(());
490 }
491 return Err(usage(format!(
492 "mcp endpoint plaintext http:// is allowed for loopback only; use https:// (got: {endpoint})"
493 )));
494 }
495 Err(usage(format!(
496 "mcp endpoint must be https://host[:port][/path] (got: {endpoint})"
497 )))
498}
499
500#[derive(Clone, PartialEq)]
502pub struct Config {
503 pub instruction: Option<String>,
504 pub intelligence: Option<String>,
505 pub intelligence_token: Option<String>,
506 pub intelligence_token_file: Option<String>,
512 pub model: Option<String>,
513 pub model_swap: SwapPolicy,
519 pub mcp_servers: Vec<McpServerSpec>,
520 pub a2a_peers: Vec<A2aPeerSpec>,
524 pub mode: Mode,
525 pub subscribe: Vec<String>,
526 pub continue_subscribe: Vec<String>,
530 pub interval: Option<Duration>,
531 pub max_steps: u32,
532 pub max_tokens: u64,
533 pub budget_tokens_lifetime: u64,
540 pub deadline: Option<Duration>,
541 pub max_depth: u32,
542 pub run_id: String,
543 pub log_level: Level,
544 pub drain_timeout: Duration,
545 #[cfg(feature = "workflow")]
548 pub workflow_file: Option<String>,
549 #[cfg(feature = "workflow")]
553 pub workflow_resume: Option<crate::subagent::protocol::WorkflowResumeRef>,
554 pub serve_mcp: Option<String>,
555 pub serve_cert: Option<String>,
559 pub serve_key: Option<String>,
560 pub serve_client_ca: Option<String>,
564 pub serve_bearer: Option<String>,
569 pub tls_ca: Option<String>,
577 pub aauth: Option<AAuthSettings>,
583 pub health_file: Option<String>,
584 pub traceparent: Option<String>,
587 pub log_content: bool,
591 pub metrics_addr: Option<String>,
594 pub cgroup: Option<String>,
601 pub cgroup_memory_max: Option<String>,
605 pub cgroup_pids_max: Option<String>,
609 pub allow_trifecta: bool,
613 pub cron: Option<String>,
617 pub report_file: Option<String>,
623 pub budget_exit_code: Option<i32>,
634 pub events_ring: usize,
639 pub intelligence_headers: std::collections::BTreeMap<String, String>,
646 pub watch_config: bool,
658 pub config_files: Vec<String>,
664}
665
666impl Default for Config {
667 fn default() -> Self {
668 Config {
669 instruction: None,
670 intelligence: None,
671 intelligence_token: None,
672 intelligence_token_file: None,
673 model: None,
674 model_swap: SwapPolicy::FinishOnOld,
675 mcp_servers: Vec::new(),
676 a2a_peers: Vec::new(),
677 mode: Mode::Once,
678 subscribe: Vec::new(),
679 continue_subscribe: Vec::new(),
680 interval: None,
681 max_steps: 50,
682 max_tokens: 200_000,
683 budget_tokens_lifetime: 0,
684 deadline: Some(Duration::from_secs(600)),
685 max_depth: 4,
686 run_id: String::new(), log_level: Level::Info,
688 drain_timeout: Duration::from_secs(25),
689 #[cfg(feature = "workflow")]
690 workflow_file: None,
691 #[cfg(feature = "workflow")]
692 workflow_resume: None,
693 serve_mcp: None,
694 serve_cert: None,
695 serve_key: None,
696 serve_client_ca: None,
697 serve_bearer: None,
698 tls_ca: None,
699 aauth: None,
700 health_file: None,
701 traceparent: None,
702 log_content: false,
703 metrics_addr: None,
704 cgroup: None,
705 cgroup_memory_max: None,
706 cgroup_pids_max: None,
707 allow_trifecta: false,
708 cron: None,
709 report_file: None,
710 budget_exit_code: None,
711 events_ring: crate::obs::log::EVENTS_RING_DEFAULT,
712 intelligence_headers: std::collections::BTreeMap::new(),
713 watch_config: false,
716 config_files: Vec::new(),
717 }
718 }
719}
720
721impl fmt::Debug for Config {
723 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
724 f.debug_struct("Config")
725 .field("instruction", &self.instruction.as_deref().map(|_| "<set>"))
726 .field(
732 "intelligence",
733 &self
734 .intelligence
735 .as_deref()
736 .map(|u| format!("{}:<redacted>", u.split(':').next().unwrap_or(""))),
737 )
738 .field(
739 "intelligence_token",
740 &self.intelligence_token.as_ref().map(|_| "***"),
741 )
742 .field("intelligence_token_file", &self.intelligence_token_file)
743 .field("model", &self.model)
744 .field("model_swap", &self.model_swap.as_str())
745 .field("mcp_servers", &self.mcp_servers)
746 .field("a2a_peers", &self.a2a_peers)
747 .field("mode", &self.mode)
748 .field("subscribe", &self.subscribe)
749 .field("continue_subscribe", &self.continue_subscribe)
750 .field("interval", &self.interval)
751 .field("max_steps", &self.max_steps)
752 .field("max_tokens", &self.max_tokens)
753 .field("budget_tokens_lifetime", &self.budget_tokens_lifetime)
754 .field("deadline", &self.deadline)
755 .field("max_depth", &self.max_depth)
756 .field("run_id", &self.run_id)
757 .field("log_level", &self.log_level)
758 .field("drain_timeout", &self.drain_timeout)
759 .field("serve_mcp", &self.serve_mcp)
760 .field("serve_cert", &self.serve_cert)
763 .field("serve_key", &self.serve_key)
764 .field("serve_client_ca", &self.serve_client_ca)
765 .field(
766 "serve_bearer",
767 &self.serve_bearer.as_ref().map(|_| "<redacted>"),
768 )
769 .field("tls_ca", &self.tls_ca)
770 .field("health_file", &self.health_file)
771 .field("traceparent", &self.traceparent)
772 .field("log_content", &self.log_content)
773 .field("metrics_addr", &self.metrics_addr)
774 .field("cgroup", &self.cgroup)
775 .field("cgroup_memory_max", &self.cgroup_memory_max)
776 .field("cgroup_pids_max", &self.cgroup_pids_max)
777 .field("allow_trifecta", &self.allow_trifecta)
778 .field("cron", &self.cron)
779 .field("report_file", &self.report_file)
780 .field("events_ring", &self.events_ring)
781 .field(
784 "intelligence_headers",
785 &self.intelligence_headers.keys().collect::<Vec<_>>(),
786 )
787 .field("watch_config", &self.watch_config)
788 .field("config_files", &self.config_files)
789 .finish()
790 }
791}
792
793#[derive(Debug)]
800pub enum ConfigError {
801 Help(String),
802 Version(String),
803 Capabilities(String),
804 Usage(String),
805 Schema(String),
809 Validate(Result<String, String>),
813}
814
815impl fmt::Display for ConfigError {
816 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
817 match self {
818 ConfigError::Help(s)
819 | ConfigError::Version(s)
820 | ConfigError::Capabilities(s)
821 | ConfigError::Schema(s) => {
822 write!(f, "{s}")
823 }
824 ConfigError::Usage(s) => write!(f, "{s}"),
825 ConfigError::Validate(Ok(s)) | ConfigError::Validate(Err(s)) => write!(f, "{s}"),
826 }
827 }
828}
829
830pub(crate) fn debrand_env(env: &[(String, String)]) -> Vec<(String, String)> {
838 let have: std::collections::HashSet<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
839 let mut out: Vec<(String, String)> = env.to_vec();
840 for (k, v) in env {
841 if let Some(suffix) = k.strip_prefix("AGENT_") {
844 let branded = format!("AGENTD_{suffix}");
845 if !have.contains(branded.as_str()) {
846 out.push((branded, v.clone()));
847 }
848 }
849 }
850 out
851}
852
853impl Config {
854 pub fn load(args: &[String], env: &[(String, String)]) -> Result<Config, ConfigError> {
858 let env = debrand_env(env);
865 let envmap: HashMap<&str, &str> =
866 env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
867
868 if args.iter().any(|a| a == "--config-schema") {
872 let schema = crate::config::file::config_schema();
873 let json = serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string());
874 return Err(ConfigError::Schema(format!("{json}\n")));
875 }
876 let validate_config = args.iter().any(|a| a == "--validate-config");
880
881 let mut c = Config::default();
882
883 let config_paths = config_paths_from_map(args, &envmap).paths;
894 let file_present = !config_paths.is_empty();
895 if file_present {
896 let (doc, loaded) = file::read_documents(&config_paths).map_err(usage)?;
897 apply_document(&mut c, doc, "config file", false)?;
898 c.config_files = loaded.into_iter().map(|(p, _)| p).collect();
899 }
900
901 if let Some(v) = envmap
908 .get("AGENTD_INSTRUCTION")
909 .or_else(|| envmap.get("INSTRUCTION"))
910 {
911 c.instruction = Some((*v).to_string());
912 }
913 if let Some(v) = envmap
914 .get("AGENTD_INTELLIGENCE")
915 .or_else(|| envmap.get("INTELLIGENCE"))
916 {
917 c.intelligence = Some((*v).to_string());
918 }
919 if let Some(v) = envmap.get("AGENTD_INTELLIGENCE_TOKEN") {
920 c.intelligence_token = Some((*v).to_string());
921 }
922 if let Some(v) = envmap.get("AGENTD_INTELLIGENCE_TOKEN_FILE") {
923 c.intelligence_token_file = Some((*v).to_string());
924 }
925 if let Some(v) = envmap.get("AGENTD_TLS_CA") {
926 c.tls_ca = Some((*v).to_string());
927 }
928 if let Some(v) = envmap.get("AGENTD_MODEL") {
929 c.model = Some((*v).to_string());
930 }
931 if let Some(v) = envmap.get("AGENTD_MODEL_SWAP") {
932 c.model_swap = SwapPolicy::parse(v).ok_or_else(|| {
933 usage(format!(
934 "invalid AGENTD_MODEL_SWAP: {v} (want finish-on-old|restart-turn)"
935 ))
936 })?;
937 }
938 if let Some(v) = envmap.get("AGENTD_MODE") {
939 c.mode = Mode::parse(v).ok_or_else(|| usage(format!("invalid AGENTD_MODE: {v}")))?;
940 }
941 if let Some(v) = envmap.get("AGENTD_MAX_STEPS") {
942 c.max_steps = v
943 .parse()
944 .map_err(|_| usage(format!("invalid AGENTD_MAX_STEPS: {v}")))?;
945 }
946 if let Some(v) = envmap.get("AGENTD_MAX_TOKENS") {
947 c.max_tokens = v
948 .parse()
949 .map_err(|_| usage(format!("invalid AGENTD_MAX_TOKENS: {v}")))?;
950 }
951 if let Some(v) = envmap.get("AGENTD_BUDGET_TOKENS") {
954 c.budget_tokens_lifetime = v
955 .parse()
956 .map_err(|_| usage(format!("invalid AGENTD_BUDGET_TOKENS: {v}")))?;
957 }
958 if let Some(v) = envmap.get("AGENTD_DEADLINE") {
959 c.deadline = Some(parse_duration(v).map_err(usage)?);
960 }
961 if let Some(v) = envmap.get("AGENTD_RUN_ID") {
962 c.run_id = (*v).to_string();
963 }
964 if let Some(v) = envmap.get("AGENTD_LOG_LEVEL") {
965 c.log_level =
966 Level::parse(v).ok_or_else(|| usage(format!("invalid AGENTD_LOG_LEVEL: {v}")))?;
967 }
968 if let Some(v) = envmap.get("AGENTD_DRAIN_TIMEOUT") {
969 c.drain_timeout = parse_duration(v).map_err(usage)?;
970 }
971 if let Some(v) = envmap.get("AGENTD_LOG_CONTENT") {
972 c.log_content = truthy(v);
973 }
974 if let Some(v) = envmap.get("AGENTD_METRICS_ADDR") {
975 c.metrics_addr = Some((*v).to_string());
976 }
977 if let Some(v) = envmap.get("AGENTD_CGROUP") {
978 c.cgroup = Some((*v).to_string());
979 }
980 if let Some(v) = envmap.get("AGENTD_CGROUP_MEMORY_MAX") {
981 c.cgroup_memory_max = Some((*v).to_string());
982 }
983 if let Some(v) = envmap.get("AGENTD_CGROUP_PIDS_MAX") {
984 c.cgroup_pids_max = Some((*v).to_string());
985 }
986 if let Some(v) = envmap.get("AGENTD_ALLOW_TRIFECTA") {
987 c.allow_trifecta = truthy(v);
988 }
989 if let Some(v) = envmap.get("AGENTD_CRON") {
990 c.cron = Some((*v).to_string());
991 }
992 if let Some(v) = envmap.get("AGENTD_REPORT_FILE") {
993 c.report_file = Some((*v).to_string());
994 }
995 if let Some(v) = envmap.get("AGENTD_EVENTS_RING") {
996 c.events_ring = v
997 .parse()
998 .map_err(|_| usage(format!("invalid AGENTD_EVENTS_RING: {v}")))?;
999 }
1000 #[cfg(feature = "workflow")]
1001 if let Some(v) = envmap.get("AGENTD_WORKFLOW") {
1002 c.workflow_file = Some((*v).to_string());
1003 }
1004 #[cfg(feature = "workflow")]
1005 if let Some(v) = envmap.get("AGENTD_WORKFLOW_RESUME") {
1006 c.workflow_resume = Some(parse_workflow_resume(v)?);
1007 }
1008 if let Some(v) = envmap.get("AGENTD_SERVE_MCP") {
1009 c.serve_mcp = Some((*v).to_string());
1010 }
1011 if let Some(v) = envmap.get("AGENTD_SERVE_CERT") {
1013 c.serve_cert = Some((*v).to_string());
1014 }
1015 if let Some(v) = envmap.get("AGENTD_SERVE_KEY") {
1016 c.serve_key = Some((*v).to_string());
1017 }
1018 if let Some(v) = envmap.get("AGENTD_SERVE_CLIENT_CA") {
1019 c.serve_client_ca = Some((*v).to_string());
1020 }
1021 if let Some(v) = envmap.get("AGENTD_SERVE_BEARER") {
1022 c.serve_bearer = Some((*v).to_string());
1023 }
1024 if let Some(v) = envmap.get("AGENTD_WATCH_CONFIG") {
1028 c.watch_config = truthy(v);
1029 }
1030 if let Some(v) = envmap.get("AGENTD_A2A_PEER") {
1033 c.a2a_peers.push(parse_a2a_peer_spec(v)?);
1034 }
1035 if let Some(v) = envmap.get("AGENTD_TRACEPARENT") {
1036 c.traceparent = Some((*v).to_string());
1037 }
1038
1039 {
1048 let (doc, applied) = paths::env_document(&envmap).map_err(usage)?;
1049 if !applied.is_empty() {
1050 apply_document(&mut c, doc, "env", true)?;
1053 }
1054 }
1055
1056 let mut mcp_tags: Vec<(String, Vec<TrifectaTag>)> = Vec::new();
1060 let mut capabilities = false;
1065 let mut aauth_provider: Option<String> = None;
1068 let mut aauth_key_file: Option<String> = None;
1069 let mut aauth_enroll_token: Option<String> = None;
1070 let mut aauth_enroll_assertion_file: Option<String> = None;
1071 let mut aauth_person_server: Option<String> = None;
1072 let mut it = args.iter().peekable();
1073 while let Some(arg) = it.next() {
1074 let mut take = |name: &str| -> Result<String, ConfigError> {
1075 it.next()
1076 .cloned()
1077 .ok_or_else(|| usage(format!("{name} requires a value")))
1078 };
1079 match arg.as_str() {
1080 "-h" | "--help" => return Err(ConfigError::Help(help_text())),
1081 "-V" | "--version" => {
1082 return Err(ConfigError::Version(format!("agentd {}\n", crate::VERSION)));
1083 }
1084 "--capabilities" => capabilities = true,
1085 "--config" | "-c" => {
1088 let _ = take("--config")?;
1089 }
1090 a if matches!(config_flag(a), ConfigFlag::Inline(_)) => {}
1092 "--config-schema" | "--validate-config" => {}
1096 "--instruction" => c.instruction = Some(take("--instruction")?),
1097 "--intelligence-token-file" => {
1098 c.intelligence_token_file = Some(take("--intelligence-token-file")?)
1099 }
1100 "--instruction-file" => {
1101 let p = take("--instruction-file")?;
1102 c.instruction = Some(read_file(&p)?);
1103 }
1104 "--intelligence" => c.intelligence = Some(take("--intelligence")?),
1105 "--intelligence-token" => {
1106 c.intelligence_token = Some(take("--intelligence-token")?)
1107 }
1108 "--model" => c.model = Some(take("--model")?),
1109 "--model-swap" => {
1110 let v = take("--model-swap")?;
1111 c.model_swap = SwapPolicy::parse(&v).ok_or_else(|| {
1112 usage(format!(
1113 "invalid --model-swap: {v} (want finish-on-old|restart-turn)"
1114 ))
1115 })?;
1116 }
1117 "--mcp" => {
1118 let spec = take("--mcp")?;
1119 c.mcp_servers.push(parse_mcp_spec(&spec)?);
1120 }
1121 "--a2a-peer" => {
1122 let spec = take("--a2a-peer")?;
1123 c.a2a_peers.push(parse_a2a_peer_spec(&spec)?);
1124 }
1125 "--mode" => {
1126 let v = take("--mode")?;
1127 c.mode =
1128 Mode::parse(&v).ok_or_else(|| usage(format!("invalid --mode: {v}")))?;
1129 }
1130 "--subscribe" => c.subscribe.push(take("--subscribe")?),
1131 "--continue" => c.continue_subscribe.push(take("--continue")?),
1132 "--interval" => {
1133 c.interval = Some(parse_duration(&take("--interval")?).map_err(usage)?)
1134 }
1135 "--cron" => c.cron = Some(take("--cron")?),
1136 "--max-steps" => {
1137 let v = take("--max-steps")?;
1138 c.max_steps = v
1139 .parse()
1140 .map_err(|_| usage(format!("invalid --max-steps: {v}")))?;
1141 }
1142 "--max-tokens" => {
1143 let v = take("--max-tokens")?;
1144 c.max_tokens = v
1145 .parse()
1146 .map_err(|_| usage(format!("invalid --max-tokens: {v}")))?;
1147 }
1148 "--budget-tokens-lifetime" => {
1149 let v = take("--budget-tokens-lifetime")?;
1150 c.budget_tokens_lifetime = v
1151 .parse()
1152 .map_err(|_| usage(format!("invalid --budget-tokens-lifetime: {v}")))?;
1153 }
1154 "--deadline" => {
1155 c.deadline = Some(parse_duration(&take("--deadline")?).map_err(usage)?)
1156 }
1157 "--max-depth" => {
1158 let v = take("--max-depth")?;
1159 c.max_depth = v
1160 .parse()
1161 .map_err(|_| usage(format!("invalid --max-depth: {v}")))?;
1162 }
1163 "--run-id" => c.run_id = take("--run-id")?,
1164 "--log-level" => {
1165 let v = take("--log-level")?;
1166 c.log_level = Level::parse(&v)
1167 .ok_or_else(|| usage(format!("invalid --log-level: {v}")))?;
1168 }
1169 "--drain-timeout" => {
1170 c.drain_timeout = parse_duration(&take("--drain-timeout")?).map_err(usage)?
1171 }
1172 "--log-content" => c.log_content = true,
1173 "--allow-trifecta" => c.allow_trifecta = true,
1174 "--mcp-tags" => mcp_tags.push(parse_mcp_tags(&take("--mcp-tags")?)?),
1175 "--metrics-addr" => c.metrics_addr = Some(take("--metrics-addr")?),
1176 "--cgroup" => c.cgroup = Some(take("--cgroup")?),
1177 "--cgroup-memory-max" => c.cgroup_memory_max = Some(take("--cgroup-memory-max")?),
1178 "--cgroup-pids-max" => c.cgroup_pids_max = Some(take("--cgroup-pids-max")?),
1179 #[cfg(feature = "workflow")]
1180 "--workflow" => c.workflow_file = Some(take("--workflow")?),
1181 #[cfg(feature = "workflow")]
1182 "--workflow-resume" => {
1183 let force = c.workflow_resume.as_ref().is_some_and(|r| r.force);
1186 let mut r = parse_workflow_resume(&take("--workflow-resume")?)?;
1187 r.force = r.force || force;
1188 c.workflow_resume = Some(r);
1189 }
1190 #[cfg(feature = "workflow")]
1191 "--workflow-resume-force" => {
1192 match c.workflow_resume.as_mut() {
1193 Some(r) => r.force = true,
1194 None => {
1197 c.workflow_resume = Some(crate::subagent::protocol::WorkflowResumeRef {
1198 server: String::new(),
1199 key: String::new(),
1200 seq: None,
1201 force: true,
1202 })
1203 }
1204 }
1205 }
1206 "--serve-mcp" => c.serve_mcp = Some(take("--serve-mcp")?),
1207 "--serve-cert" => c.serve_cert = Some(take("--serve-cert")?),
1208 "--serve-key" => c.serve_key = Some(take("--serve-key")?),
1209 "--serve-client-ca" => c.serve_client_ca = Some(take("--serve-client-ca")?),
1210 "--serve-bearer" => c.serve_bearer = Some(take("--serve-bearer")?),
1211 "--tls-ca" => c.tls_ca = Some(take("--tls-ca")?),
1212 "--aauth-provider" => aauth_provider = Some(take("--aauth-provider")?),
1216 "--aauth-key-file" => aauth_key_file = Some(take("--aauth-key-file")?),
1217 "--aauth-enroll-token" => aauth_enroll_token = Some(take("--aauth-enroll-token")?),
1218 "--aauth-enroll-assertion-file" => {
1219 aauth_enroll_assertion_file = Some(take("--aauth-enroll-assertion-file")?)
1220 }
1221 "--aauth-person-server" => {
1222 aauth_person_server = Some(take("--aauth-person-server")?)
1223 }
1224 "--watch-config" => c.watch_config = true,
1230 "--health-file" => c.health_file = Some(take("--health-file")?),
1231 "--traceparent" => c.traceparent = Some(take("--traceparent")?),
1232 "--report-file" => c.report_file = Some(take("--report-file")?),
1233 "--budget-exit-code" => {
1238 let v = take("--budget-exit-code")?;
1239 let n: i32 = v
1240 .parse()
1241 .ok()
1242 .filter(|n| (0..=255).contains(n))
1243 .ok_or_else(|| {
1244 usage(format!("invalid --budget-exit-code: {v} (want 0..=255)"))
1245 })?;
1246 c.budget_exit_code = Some(n);
1247 }
1248 "--events-ring" => {
1249 let v = take("--events-ring")?;
1250 c.events_ring = v
1251 .parse()
1252 .map_err(|_| usage(format!("invalid --events-ring: {v}")))?;
1253 }
1254 other => match paths::resolve_flag(other).map_err(usage)? {
1261 Some(target) => {
1262 let raw = if matches!(target.value_kind(), paths::Kind::Boolean)
1263 && !it.peek().is_some_and(|n| !n.starts_with("--"))
1264 {
1265 "true".to_string()
1266 } else {
1267 it.next()
1268 .cloned()
1269 .ok_or_else(|| usage(format!("{other} requires a value")))?
1270 };
1271 let value = paths::coerce(target.value_kind(), &raw)
1272 .map_err(|e| usage(format!("invalid {other}: {e}")))?;
1273 let replace = target.entry.is_none();
1276 apply_document(&mut c, target.document(value), other, replace)?;
1277 }
1278 None => return Err(usage(format!("unknown argument: {other}"))),
1279 },
1280 }
1281 }
1282
1283 let aauth_provider =
1287 aauth_provider.or_else(|| envmap.get("AGENT_AAUTH_PROVIDER").map(|v| v.to_string()));
1288 if let Some(provider) = aauth_provider {
1289 c.aauth = Some(AAuthSettings {
1290 provider,
1291 key_file: aauth_key_file
1292 .or_else(|| envmap.get("AGENT_AAUTH_KEY_FILE").map(|v| v.to_string()))
1293 .unwrap_or_else(|| "agent.key".to_string()),
1294 enrollment_token: aauth_enroll_token.or_else(|| {
1295 envmap
1296 .get("AGENT_AAUTH_ENROLL_TOKEN")
1297 .map(|v| v.to_string())
1298 }),
1299 enroll_assertion_file: aauth_enroll_assertion_file.or_else(|| {
1300 envmap
1301 .get("AGENT_AAUTH_ENROLL_ASSERTION_FILE")
1302 .map(|v| v.to_string())
1303 }),
1304 person_server: aauth_person_server.or_else(|| {
1305 envmap
1306 .get("AGENT_AAUTH_PERSON_SERVER")
1307 .map(|v| v.to_string())
1308 }),
1309 });
1310 }
1311
1312 for (name, tags) in mcp_tags {
1314 match c.mcp_servers.iter_mut().find(|s| s.name == name) {
1315 Some(s) => s.tags = tags,
1316 None => {
1317 return Err(usage(format!(
1318 "--mcp-tags references unknown server '{name}'"
1319 )));
1320 }
1321 }
1322 }
1323
1324 if c.run_id.is_empty() {
1325 c.run_id = generate_run_id();
1326 }
1327
1328 if capabilities {
1332 return Err(ConfigError::Capabilities(
1333 "{\"note\":\"--capabilities is served by the agentd 2.0 loader\"}\n".to_string(),
1334 ));
1335 }
1336
1337 c.resolve_token_file()?;
1343
1344 if validate_config {
1351 return Err(ConfigError::Validate(c.validate_collect_all(file_present)));
1352 }
1353
1354 c.validate()?;
1355 if c.watch_config && !file_present {
1362 return Err(usage(
1363 "--watch-config requires a config file (--config / AGENTD_CONFIG)".into(),
1364 ));
1365 }
1366 Ok(c)
1367 }
1368
1369 pub fn config_paths_from(args: &[String], env: &[(String, String)]) -> Vec<String> {
1375 let env = debrand_env(env);
1376 let envmap: HashMap<&str, &str> =
1377 env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
1378 config_paths_from_map(args, &envmap).paths
1379 }
1380
1381 fn resolve_token_file(&mut self) -> Result<(), ConfigError> {
1386 if self.intelligence_token.is_some() {
1387 return Ok(()); }
1389 if let Some(path) = self.intelligence_token_file.clone() {
1390 let tok = crate::sec::secret::read_token_file(&path).map_err(usage)?;
1391 self.intelligence_token = Some(tok);
1392 }
1393 Ok(())
1394 }
1395
1396 fn validate_collect_all(&self, file_present: bool) -> Result<String, String> {
1405 let mut diags: Vec<String> = Vec::new();
1406 self.collect_header_diags(&mut diags);
1413 if let Err(e) = self.validate() {
1418 let msg = e.to_string();
1419 if !diags.iter().any(|d| msg.ends_with(d.as_str())) {
1420 diags.push(msg);
1421 }
1422 }
1423 if self.watch_config && !file_present {
1427 diags.push("--watch-config requires a config file (--config / AGENTD_CONFIG)".into());
1428 }
1429 match Config::reload_coherence_check(self, None, file_present) {
1436 Ok(()) => {}
1437 Err(coh) => {
1438 for d in coh.into_iter().filter(|d| d.is_error()) {
1439 let line = format!("{}: {}", d.field, d.msg);
1440 if !diags.iter().any(|existing| existing.ends_with(&d.msg)) {
1441 diags.push(line);
1442 }
1443 }
1444 }
1445 }
1446 if diags.is_empty() {
1447 Ok(config_valid_line())
1448 } else {
1449 Err(diags
1450 .into_iter()
1451 .map(|d| config_invalid_line(&d))
1452 .collect::<Vec<_>>()
1453 .join("\n"))
1454 }
1455 }
1456
1457 fn collect_header_diags(&self, diags: &mut Vec<String>) {
1464 let env = |k: &str| std::env::var(k).ok();
1465 for (name, value) in &self.intelligence_headers {
1466 if is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
1469 diags.push(format!(
1470 "intelligence_headers['{name}'] looks like a credential but has an inline value; \
1471 use {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}} (never an inline secret)"
1472 ));
1473 continue;
1474 }
1475 if crate::sec::secret::has_secret_ref(value)
1478 && let Err(e) = crate::sec::secret::refs_resolvable(value, &env)
1479 {
1480 diags.push(format!("intelligence_headers['{name}']: {e}"));
1481 }
1482 }
1483 }
1484
1485 pub fn trifecta_grant_tags(&self) -> Vec<TrifectaTag> {
1491 let mut tags = Vec::new();
1492 for s in &self.mcp_servers {
1493 if s.tags.is_empty() {
1494 tags.push(TrifectaTag::UntrustedInput);
1495 } else {
1496 tags.extend(s.tags.iter().copied());
1497 }
1498 }
1499 tags
1500 }
1501
1502 pub fn validate(&self) -> Result<(), ConfigError> {
1504 #[cfg(feature = "workflow")]
1512 let needs_instruction = self.mode != Mode::Workflow
1513 && !(self.mode == Mode::Reactive
1514 && self.workflow_file.is_some()
1515 && self.subscribe.is_empty()
1516 && self.continue_subscribe.is_empty());
1517 #[cfg(not(feature = "workflow"))]
1518 let needs_instruction = true;
1519 if needs_instruction
1520 && self
1521 .instruction
1522 .as_deref()
1523 .map(str::trim)
1524 .unwrap_or("")
1525 .is_empty()
1526 {
1527 return Err(usage(
1528 "missing instruction (INSTRUCTION env or --instruction)".into(),
1529 ));
1530 }
1531 if self.intelligence.as_deref().unwrap_or("").is_empty() {
1532 return Err(usage(
1533 "missing intelligence endpoint (AGENTD_INTELLIGENCE or --intelligence)".into(),
1534 ));
1535 }
1536 validate_intelligence_uri(self.intelligence.as_deref().unwrap())?;
1537 validate_endpoint_token_files(self.intelligence.as_deref().unwrap())?;
1541 for s in &self.mcp_servers {
1542 if s.name.is_empty() {
1543 return Err(usage("mcp server has an empty name".into()));
1544 }
1545 if s.endpoint.trim().is_empty() {
1546 return Err(usage(format!("mcp server '{}' has no endpoint", s.name)));
1547 }
1548 mcp_endpoint_scheme_ok(&s.endpoint)
1552 .map_err(|e| usage(format!("mcp server '{}': {e}", s.name)))?;
1553 ::mcp::http::McpEndpoint::parse(&s.endpoint)
1557 .map_err(|e| usage(format!("mcp server '{}': {e}", s.name)))?;
1558 for (name, value) in &s.headers {
1559 if is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
1560 return Err(usage(format!(
1561 "mcp server '{}' header '{name}' looks like a credential but has an inline value; use {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}}",
1562 s.name
1563 )));
1564 }
1565 }
1566 crate::mcp::auth::headers_resolvable(&s.headers)
1567 .map_err(|e| usage(format!("mcp server '{}' header: {e}", s.name)))?;
1568 }
1569 if self.max_steps == 0 {
1570 return Err(usage("--max-steps must be > 0".into()));
1571 }
1572 if self.events_ring == 0 {
1576 return Err(usage("--events-ring must be > 0".into()));
1577 }
1578 if self.watch_config && !cfg!(feature = "config-watch") {
1583 return Err(usage(
1584 "--watch-config requires the 'config-watch' build feature".into(),
1585 ));
1586 }
1587 {
1588 #[cfg(feature = "workflow")]
1589 let wait_driven = self.workflow_file.is_some();
1590 #[cfg(not(feature = "workflow"))]
1591 let wait_driven = false;
1592 if self.mode == Mode::Reactive
1595 && self.subscribe.is_empty()
1596 && self.continue_subscribe.is_empty()
1597 && !wait_driven
1598 {
1599 return Err(usage(
1600 "--mode reactive requires at least one --subscribe or --continue <uri> (or --workflow on a workflow build)".into(),
1601 ));
1602 }
1603 }
1604 if !self.continue_subscribe.is_empty() && self.mode != Mode::Reactive {
1605 return Err(usage(
1606 "--continue is only valid with --mode reactive".into(),
1607 ));
1608 }
1609 if self.mode == Mode::Schedule && self.interval.is_none() && self.cron.is_none() {
1610 return Err(usage(
1611 "--mode schedule requires --interval <dur> or --cron <expr>".into(),
1612 ));
1613 }
1614 if self.cron.is_some() && self.mode != Mode::Schedule {
1615 return Err(usage("--cron is only valid with --mode schedule".into()));
1616 }
1617 #[cfg(feature = "workflow")]
1620 {
1621 if self.mode == Mode::Workflow && self.workflow_file.is_none() {
1622 return Err(usage("--mode workflow requires --workflow <file>".into()));
1623 }
1624 if let Some(r) = &self.workflow_resume {
1628 if r.server.is_empty() {
1629 return Err(usage(
1630 "--workflow-resume-force requires --workflow-resume <server>:<key>[@seq]"
1631 .into(),
1632 ));
1633 }
1634 if self.mode != Mode::Workflow {
1635 return Err(usage(
1636 "--workflow-resume is only valid with --mode workflow".into(),
1637 ));
1638 }
1639 if !self.mcp_servers.iter().any(|s| s.name == r.server) {
1640 return Err(usage(format!(
1641 "--workflow-resume names server '{}', which is not a configured --mcp server",
1642 r.server
1643 )));
1644 }
1645 }
1646 if self.workflow_file.is_some()
1647 && self.mode != Mode::Workflow
1648 && self.mode != Mode::Reactive
1649 {
1650 return Err(usage(
1651 "--workflow is only valid with --mode workflow or --mode reactive".into(),
1652 ));
1653 }
1654 }
1655 if (self.cgroup_memory_max.is_some() || self.cgroup_pids_max.is_some())
1659 && self.cgroup.is_none()
1660 {
1661 return Err(usage(
1662 "--cgroup-memory-max/--cgroup-pids-max require --cgroup".into(),
1663 ));
1664 }
1665 if self.cgroup_pids_max.as_deref().map(str::trim) == Some("0") {
1669 return Err(usage(
1670 "--cgroup-pids-max must be > 0 (it counts threads, not just processes) or 'max'"
1671 .into(),
1672 ));
1673 }
1674 if self.cgroup_memory_max.as_deref().map(str::trim) == Some("0") {
1675 return Err(usage("--cgroup-memory-max must be > 0 or 'max'".into()));
1676 }
1677 if let Some(a) = &self.aauth {
1681 if !cfg!(feature = "aauth") {
1682 return Err(usage(
1683 "--aauth-provider needs a build with --features aauth".into(),
1684 ));
1685 }
1686 if crate::net::http::Url::parse(&a.provider).is_err() {
1687 return Err(usage(format!(
1688 "--aauth-provider must be an http(s) URL (got: {})",
1689 a.provider
1690 )));
1691 }
1692 if let Some(ps) = &a.person_server
1693 && crate::net::http::Url::parse(ps).is_err()
1694 {
1695 return Err(usage(format!(
1696 "--aauth-person-server must be an http(s) URL (got: {ps})"
1697 )));
1698 }
1699 }
1700 if let Some(spec) = &self.serve_mcp {
1704 let target = ServeTarget::parse(spec)?;
1705 self.validate_serve_auth(&target, &|k: &str| std::env::var(k).ok())?;
1706 } else if self.serve_cert.is_some()
1707 || self.serve_key.is_some()
1708 || self.serve_client_ca.is_some()
1709 || self.serve_bearer.is_some()
1710 {
1711 return Err(usage(
1712 "a2a.tls.cert / a2a.tls.key / a2a.tls.client_ca / a2a.bearer require a2a.listen"
1713 .into(),
1714 ));
1715 }
1716 if let Some(ca) = &self.tls_ca {
1723 if !cfg!(feature = "tls") {
1724 return Err(usage("--tls-ca requires the 'tls' build feature".into()));
1725 }
1726 check_readable("--tls-ca", ca)?;
1727 #[cfg(feature = "tls")]
1728 {
1729 let pem =
1730 std::fs::read(ca).map_err(|e| usage(format!("--tls-ca {ca}: read: {e}")))?;
1731 crate::net::tls::validate_ca_pem(&pem)
1732 .map_err(|e| usage(format!("--tls-ca {ca}: {e}")))?;
1733 }
1734 }
1735 if !self.a2a_peers.is_empty() && !cfg!(feature = "a2a") {
1739 return Err(usage("--a2a-peer requires the 'a2a' build feature".into()));
1740 }
1741 let mut seen = std::collections::HashSet::new();
1742 for peer in &self.a2a_peers {
1743 if peer.name.is_empty() || peer.endpoint.is_empty() {
1744 return Err(usage(format!(
1745 "--a2a-peer '{}' has an empty name or endpoint",
1746 peer.name
1747 )));
1748 }
1749 if !seen.insert(peer.name.as_str()) {
1750 return Err(usage(format!(
1751 "--a2a-peer name '{}' is declared more than once",
1752 peer.name
1753 )));
1754 }
1755 A2aEndpoint::parse(&peer.endpoint)?;
1756 for (name, value) in &peer.headers {
1761 if is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
1762 return Err(usage(format!(
1763 "a2a peer '{}' header '{name}' looks like a credential but has an inline value; use {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}}",
1764 peer.name
1765 )));
1766 }
1767 }
1768 crate::mcp::auth::headers_resolvable(&peer.headers)
1769 .map_err(|e| usage(format!("a2a peer '{}' header: {e}", peer.name)))?;
1770 match (&peer.client_cert, &peer.client_key) {
1771 (Some(_), None) | (None, Some(_)) => {
1772 return Err(usage(format!(
1773 "a2a peer '{}': client_cert and client_key must be set together",
1774 peer.name
1775 )));
1776 }
1777 (Some(cert), Some(key)) => {
1778 for path in [cert, key] {
1779 if let Err(e) = std::fs::metadata(path) {
1780 return Err(usage(format!(
1781 "a2a peer '{}': cannot read '{path}': {e}",
1782 peer.name
1783 )));
1784 }
1785 }
1786 }
1787 (None, None) => {}
1788 }
1789 }
1790 let mut header_diags = Vec::new();
1795 self.collect_header_diags(&mut header_diags);
1796 if let Some(first) = header_diags.into_iter().next() {
1797 return Err(usage(first));
1798 }
1799 if crate::sec::scope::check_trifecta(self.trifecta_grant_tags(), self.allow_trifecta)
1809 .is_refused()
1810 {
1811 return Err(usage(
1812 "refused — this grant gives one agent all three lethal-trifecta legs \
1813 (untrusted input + sensitive data + egress). Split the capabilities across \
1814 subagents, or relaunch with --allow-trifecta."
1815 .into(),
1816 ));
1817 }
1818 Ok(())
1819 }
1820}
1821
1822#[derive(Debug, Clone, PartialEq, Eq)]
1838pub struct Diag {
1839 pub field: String,
1841 pub level: DiagLevel,
1843 pub msg: String,
1845}
1846
1847#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1848pub enum DiagLevel {
1849 Warn,
1850 Error,
1851}
1852
1853impl Diag {
1854 #[allow(dead_code)]
1862 fn warn(field: &str, msg: impl Into<String>) -> Diag {
1863 Diag {
1864 field: field.to_string(),
1865 level: DiagLevel::Warn,
1866 msg: msg.into(),
1867 }
1868 }
1869 fn error(field: &str, msg: impl Into<String>) -> Diag {
1870 Diag {
1871 field: field.to_string(),
1872 level: DiagLevel::Error,
1873 msg: msg.into(),
1874 }
1875 }
1876 pub fn is_error(&self) -> bool {
1877 self.level == DiagLevel::Error
1878 }
1879 pub fn level_str(&self) -> &'static str {
1880 match self.level {
1881 DiagLevel::Warn => "warn",
1882 DiagLevel::Error => "error",
1883 }
1884 }
1885}
1886
1887pub const RESTART_ONLY_FIELDS: &[&str] = &[
1900 "mode",
1901 "run_id", "serve_mcp", "drain_timeout", "continue_subscribe", ];
1912
1913impl Config {
1914 pub fn reload(args: &[String], env: &[(String, String)]) -> Result<Config, ConfigError> {
1925 Config::load(args, env)
1926 }
1927
1928 fn restart_only_file_warnings(&self, file_present: bool, _diags: &mut Vec<Diag>) {
1938 let _ = file_present; }
1940
1941 pub fn reload_coherence_check(
1953 new: &Config,
1954 running: Option<&Config>,
1955 file_present: bool,
1956 ) -> Result<(), Vec<Diag>> {
1957 let mut diags = Vec::new();
1958 new.restart_only_file_warnings(file_present, &mut diags);
1960 if let Some(run) = running {
1962 for &f in RESTART_ONLY_FIELDS {
1963 if new.restart_only_field_differs(run, f) {
1964 diags.push(Diag::error(
1965 f,
1966 format!(
1967 "restart-only field '{f}' changed on a live reload; reload refused, \
1968 a pod restart is required (RFC 0017 §5.1)"
1969 ),
1970 ));
1971 }
1972 }
1973 }
1974 check_unique_server_names(new, &mut diags);
1976 check_subscriptions_reference_declared_servers(new, &mut diags);
1977 if diags.iter().any(Diag::is_error) {
1978 Err(diags)
1979 } else {
1980 Ok(())
1986 }
1987 }
1988
1989 fn restart_only_field_differs(&self, running: &Config, field: &str) -> bool {
1995 match field {
1996 "mode" => self.mode != running.mode,
1997 "run_id" => self.run_id != running.run_id,
1998 "serve_mcp" => self.serve_mcp != running.serve_mcp,
1999 "drain_timeout" => self.drain_timeout != running.drain_timeout,
2000 "continue_subscribe" => self.continue_subscribe != running.continue_subscribe,
2001 _ => false,
2002 }
2003 }
2004
2005 pub fn effective_view(&self) -> serde_json::Value {
2011 serde_json::json!({
2012 "model": self.model,
2013 "swap_policy": self.model_swap.as_str(),
2014 "max_tokens": self.max_tokens,
2015 "limits": {
2016 "max_steps": self.max_steps,
2017 "max_depth": self.max_depth,
2018 "deadline_secs": self.deadline.map(|d| d.as_secs()),
2019 "lifetime_tokens": (self.budget_tokens_lifetime > 0)
2021 .then_some(self.budget_tokens_lifetime),
2022 },
2023 "mcp_servers": self.mcp_servers.iter().map(|s| {
2026 serde_json::json!({"name": s.name, "tags": s.tags})
2027 }).collect::<Vec<_>>(),
2028 "subscribe": self.subscribe,
2029 "log_level": self.log_level.as_str(),
2030 "intelligence_headers": self.intelligence_headers.keys().collect::<Vec<_>>(),
2033 })
2034 }
2035}
2036
2037fn check_unique_server_names(cfg: &Config, diags: &mut Vec<Diag>) {
2040 let mut seen = std::collections::HashSet::new();
2041 for s in &cfg.mcp_servers {
2042 if !seen.insert(s.name.as_str()) {
2043 diags.push(Diag::error(
2044 "mcp_servers",
2045 format!("duplicate MCP server name '{}'", s.name),
2046 ));
2047 }
2048 }
2049}
2050
2051fn check_subscriptions_reference_declared_servers(cfg: &Config, diags: &mut Vec<Diag>) {
2058 let _ = (cfg, diags);
2063}
2064
2065pub(crate) fn is_secret_shaped_key(name: &str) -> bool {
2069 let n = name.to_ascii_lowercase();
2070 n == "authorization"
2071 || n == "x-api-key"
2072 || n == "api-key"
2073 || n == "token"
2074 || n.ends_with("-token")
2075 || n.ends_with("_token")
2076 || n == "password"
2077 || n == "secret"
2078 || n.ends_with("-key")
2079 || n.ends_with("_key")
2080}
2081
2082fn config_valid_line() -> String {
2084 serde_json::json!({"event": "config.valid"}).to_string()
2085}
2086
2087fn config_invalid_line(msg: &str) -> String {
2090 serde_json::json!({"event": "config.invalid", "msg": msg}).to_string()
2091}
2092
2093pub(crate) fn validate_intelligence_uri(uri: &str) -> Result<(), ConfigError> {
2100 let elements: Vec<&str> = uri
2101 .split(',')
2102 .map(str::trim)
2103 .filter(|s| !s.is_empty())
2104 .collect();
2105 if elements.is_empty() {
2106 return Err(usage(
2107 "missing intelligence endpoint (AGENTD_INTELLIGENCE or --intelligence)".into(),
2108 ));
2109 }
2110 for el in elements {
2111 validate_one_intelligence_uri(el)?;
2112 }
2113 Ok(())
2114}
2115
2116fn validate_one_intelligence_uri(uri: &str) -> Result<(), ConfigError> {
2125 if uri.starts_with("https://") {
2126 return Ok(());
2127 }
2128 if let Some(rest) = uri.strip_prefix("http://") {
2129 let authority = rest.split('/').next().unwrap_or(rest);
2130 let host = if authority.starts_with('[') {
2133 authority.split(']').next().map_or(authority, |h| &h[1..])
2134 } else {
2135 authority.rsplit_once(':').map_or(authority, |(h, _)| h)
2136 };
2137 if crate::net::http::is_loopback_host(host) {
2138 return Ok(());
2139 }
2140 return Err(usage(format!(
2141 "plaintext http:// intelligence is allowed for loopback only (dev); use https:// (got: {uri})"
2142 )));
2143 }
2144 Err(usage(format!(
2145 "intelligence endpoint must be https://host[:port][/path] (got: {uri})"
2146 )))
2147}
2148
2149fn validate_endpoint_token_files(uri: &str) -> Result<(), ConfigError> {
2157 let count = uri
2158 .split(',')
2159 .map(str::trim)
2160 .filter(|s| !s.is_empty())
2161 .count();
2162 for idx in 0..count {
2163 let (inline_var, file_var) = if idx == 0 {
2164 (
2165 "AGENTD_INTELLIGENCE_TOKEN".to_string(),
2166 "AGENTD_INTELLIGENCE_TOKEN_FILE".to_string(),
2167 )
2168 } else {
2169 let n = idx + 1;
2170 (
2171 format!("AGENTD_INTELLIGENCE_TOKEN_{n}"),
2172 format!("AGENTD_INTELLIGENCE_TOKEN_{n}_FILE"),
2173 )
2174 };
2175 if std::env::var(&inline_var).is_ok() {
2177 continue;
2178 }
2179 if let Ok(path) = std::env::var(&file_var) {
2180 crate::sec::secret::read_token_file(&path).map_err(usage)?;
2181 }
2182 }
2183 Ok(())
2184}
2185
2186fn parse_mcp_spec(spec: &str) -> Result<McpServerSpec, ConfigError> {
2190 let (name, rhs) = spec
2191 .split_once('=')
2192 .ok_or_else(|| usage(format!("--mcp must be name=endpoint (got: {spec})")))?;
2193 let endpoint = rhs.trim();
2194 if name.is_empty() || endpoint.is_empty() {
2195 return Err(usage(format!("--mcp '{spec}' has empty name or endpoint")));
2196 }
2197 if name == "code" {
2201 return Err(usage(
2202 "--mcp: the server name 'code' is reserved for code-registered tools (RFC 0022)".into(),
2203 ));
2204 }
2205 if !is_mcp_endpoint(endpoint) {
2206 return Err(usage(format!(
2207 "--mcp '{spec}': endpoint must be https://host[:port][/path] \
2208 (loopback http:// for dev)"
2209 )));
2210 }
2211 Ok(McpServerSpec {
2212 name: name.to_string(),
2213 endpoint: endpoint.to_string(),
2214 ..Default::default()
2215 })
2216}
2217
2218fn parse_a2a_peer_spec(spec: &str) -> Result<A2aPeerSpec, ConfigError> {
2223 let (name, endpoint) = spec
2224 .split_once('=')
2225 .ok_or_else(|| usage(format!("--a2a-peer must be name=endpoint (got: {spec})")))?;
2226 if name.is_empty() || endpoint.is_empty() {
2227 return Err(usage(format!(
2228 "--a2a-peer '{spec}' has an empty name or endpoint"
2229 )));
2230 }
2231 Ok(A2aPeerSpec {
2232 name: name.to_string(),
2233 endpoint: endpoint.to_string(),
2234 headers: Vec::new(),
2235 client_cert: None,
2236 client_key: None,
2237 })
2238}
2239
2240pub(crate) fn parse_mcp_tags(spec: &str) -> Result<(String, Vec<TrifectaTag>), ConfigError> {
2243 let (name, list) = spec
2244 .split_once('=')
2245 .ok_or_else(|| usage(format!("--mcp-tags must be name=tag,tag (got: {spec})")))?;
2246 if name.is_empty() {
2247 return Err(usage(format!(
2248 "--mcp-tags '{spec}' has an empty server name"
2249 )));
2250 }
2251 let mut tags = Vec::new();
2252 for t in list.split(',').map(str::trim).filter(|t| !t.is_empty()) {
2253 let tag = TrifectaTag::parse(t).ok_or_else(|| {
2254 usage(format!(
2255 "unknown trifecta tag '{t}' (want: untrusted_input|sensitive|egress)"
2256 ))
2257 })?;
2258 tags.push(tag);
2259 }
2260 Ok((name.to_string(), tags))
2261}
2262
2263pub(crate) fn read_file(path: &str) -> Result<String, ConfigError> {
2264 std::fs::read_to_string(path)
2265 .map_err(|e| usage(format!("cannot read instruction file {path}: {e}")))
2266}
2267
2268pub(crate) enum ConfigFlag<'a> {
2276 Separate,
2278 Inline(&'a str),
2280 No,
2282}
2283
2284pub(crate) fn config_flag(arg: &str) -> ConfigFlag<'_> {
2286 match arg {
2287 "--config" | "-c" => ConfigFlag::Separate,
2288 _ => match arg
2289 .strip_prefix("--config=")
2290 .or_else(|| arg.strip_prefix("-c="))
2291 {
2292 Some(v) => ConfigFlag::Inline(v),
2293 None => ConfigFlag::No,
2294 },
2295 }
2296}
2297
2298pub(crate) struct ConfigPaths {
2307 pub paths: Vec<String>,
2309 pub discovered: bool,
2313}
2314
2315pub(crate) fn config_paths_from_map(args: &[String], envmap: &HashMap<&str, &str>) -> ConfigPaths {
2320 let mut paths: Vec<String> = envmap
2321 .get("AGENTD_CONFIG")
2322 .map(|v| {
2323 v.split(':')
2324 .map(str::trim)
2325 .filter(|p| !p.is_empty())
2326 .map(str::to_string)
2327 .collect()
2328 })
2329 .unwrap_or_default();
2330 let mut it = args.iter();
2331 while let Some(a) = it.next() {
2332 match config_flag(a) {
2333 ConfigFlag::Separate => {
2334 if let Some(v) = it.next() {
2335 paths.push(v.clone());
2336 }
2337 }
2338 ConfigFlag::Inline(v) => paths.push(v.to_string()),
2339 ConfigFlag::No => {}
2340 }
2341 }
2342 let mut discovered = false;
2347 if paths.is_empty() && !is_informational(args) {
2348 paths.extend(discovered_config_in(Path::new(".")));
2349 discovered = !paths.is_empty();
2350 }
2351 ConfigPaths { paths, discovered }
2352}
2353
2354pub const DISCOVERED_CONFIG_NAMES: [&str; 2] = [".agentd.yml", ".agentd.yaml"];
2360
2361pub fn discovered_config_in(dir: &Path) -> Vec<String> {
2367 DISCOVERED_CONFIG_NAMES
2368 .iter()
2369 .map(|n| dir.join(n))
2370 .filter(|p| p.is_file())
2371 .map(|p| p.to_string_lossy().into_owned())
2372 .collect()
2373}
2374
2375fn is_informational(args: &[String]) -> bool {
2382 args.iter().any(|a| {
2383 matches!(
2384 a.as_str(),
2385 "-h" | "--help"
2386 | "-V"
2387 | "--version"
2388 | "--config-schema"
2389 | "--config-schema=2"
2390 | "--workflow-schema"
2391 )
2392 })
2393}
2394
2395fn apply_document(
2404 c: &mut Config,
2405 doc: serde_json::Value,
2406 source: &str,
2407 replace_lists: bool,
2408) -> Result<(), ConfigError> {
2409 let present: Vec<String> = doc
2410 .as_object()
2411 .map(|m| m.keys().cloned().collect())
2412 .unwrap_or_default();
2413 let cf = file::ConfigFile::from_document(doc, source).map_err(usage)?;
2414 if replace_lists {
2415 for key in &present {
2416 match key.as_str() {
2417 "mcp_servers" => c.mcp_servers.clear(),
2418 "subscribe" => c.subscribe.clear(),
2419 "a2a_peers" => c.a2a_peers.clear(),
2420 "intelligence_headers" => c.intelligence_headers.clear(),
2421 _ => {}
2422 }
2423 }
2424 }
2425 apply_config_file(c, cf, source)
2426}
2427
2428fn apply_config_file(
2438 c: &mut Config,
2439 cf: file::ConfigFile,
2440 source: &str,
2441) -> Result<(), ConfigError> {
2442 if let Some(intelligence) = cf.intelligence {
2447 c.intelligence = Some(intelligence);
2448 }
2449 if let Some(policy) = cf.model_swap {
2450 c.model_swap = SwapPolicy::parse(&policy).ok_or_else(|| {
2451 usage(format!(
2452 "{source}: invalid model_swap: {policy} (want finish-on-old|restart-turn)"
2453 ))
2454 })?;
2455 }
2456 if let Some(model) = cf.model {
2457 c.model = Some(model);
2458 }
2459 if let Some(mt) = cf.max_tokens {
2460 c.max_tokens = mt;
2461 }
2462 if let Some(limits) = cf.limits {
2463 if let Some(s) = limits.max_steps {
2464 c.max_steps = s;
2465 }
2466 if let Some(d) = limits.max_depth {
2467 c.max_depth = d;
2468 }
2469 if let Some(secs) = limits.deadline_secs {
2470 c.deadline = Some(Duration::from_secs(secs));
2471 }
2472 if let Some(lt) = limits.lifetime_tokens {
2473 c.budget_tokens_lifetime = lt;
2474 }
2475 }
2476 if let Some(level) = cf.log_level {
2477 c.log_level = Level::parse(&level)
2478 .ok_or_else(|| usage(format!("{source}: invalid log_level: {level}")))?;
2479 }
2480 for s in cf.mcp_servers {
2485 if s.name.is_empty() {
2486 return Err(usage(format!("{source}: an mcp server has an empty name")));
2487 }
2488 let endpoint = match s.endpoint {
2489 Some(ep) if !ep.trim().is_empty() => ep,
2490 _ => {
2491 return Err(usage(format!(
2492 "{source}: mcp server '{}' has no endpoint \
2493 (stdio MCP servers were removed in v2.0.0)",
2494 s.name
2495 )));
2496 }
2497 };
2498 let headers = s.headers.into_iter().collect::<Vec<(String, String)>>();
2499 let mut tags: Vec<TrifectaTag> = Vec::new();
2500 for tag_list in s.tags.values() {
2501 for t in tag_list {
2502 let tag = TrifectaTag::parse(t).ok_or_else(|| {
2503 usage(format!(
2504 "{source}: mcp server '{}' has unknown trifecta tag '{t}' \
2505 (want: untrusted_input|sensitive|egress)",
2506 s.name
2507 ))
2508 })?;
2509 if !tags.contains(&tag) {
2510 tags.push(tag);
2511 }
2512 }
2513 }
2514 c.mcp_servers.push(McpServerSpec {
2515 name: s.name,
2516 endpoint,
2517 headers,
2518 tags,
2519 aauth: s.aauth,
2520 oauth: None,
2523 auth: None,
2524 });
2525 }
2526 c.subscribe.extend(cf.subscribe);
2527 for p in cf.a2a_peers {
2528 if p.name.is_empty() || p.endpoint.is_empty() {
2529 return Err(usage(format!(
2530 "{source}: a2a peer '{}' has an empty name or endpoint",
2531 p.name
2532 )));
2533 }
2534 c.a2a_peers.push(A2aPeerSpec {
2535 name: p.name,
2536 endpoint: p.endpoint,
2537 headers: p.headers.into_iter().collect(),
2538 client_cert: p.client_cert,
2539 client_key: p.client_key,
2540 });
2541 }
2542 c.intelligence_headers.extend(cf.intelligence_headers);
2544 Ok(())
2545}
2546
2547#[cfg(feature = "workflow")]
2552fn parse_workflow_resume(
2553 spec: &str,
2554) -> Result<crate::subagent::protocol::WorkflowResumeRef, ConfigError> {
2555 let (server, rest) = spec.split_once(':').ok_or_else(|| {
2556 usage(format!(
2557 "--workflow-resume: want <server>:<key>[@seq] (got: {spec})"
2558 ))
2559 })?;
2560 let (key, seq) = match rest.rsplit_once('@') {
2561 Some((k, s)) => {
2562 let seq: u64 = s
2563 .parse()
2564 .map_err(|_| usage(format!("--workflow-resume: bad @seq {s:?} (want a number)")))?;
2565 (k, Some(seq))
2566 }
2567 None => (rest, None),
2568 };
2569 if server.trim().is_empty() || key.trim().is_empty() {
2570 return Err(usage(format!(
2571 "--workflow-resume: server and key must be non-empty (got: {spec})"
2572 )));
2573 }
2574 Ok(crate::subagent::protocol::WorkflowResumeRef {
2575 server: server.to_string(),
2576 key: key.to_string(),
2577 seq,
2578 force: false,
2579 })
2580}
2581
2582pub(crate) fn usage(msg: String) -> ConfigError {
2583 ConfigError::Usage(format!("agentd: {msg}"))
2584}
2585
2586pub(crate) fn truthy(v: &str) -> bool {
2587 matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")
2588}
2589
2590pub fn parse_duration(s: &str) -> Result<Duration, String> {
2592 let s = s.trim();
2593 if s.is_empty() {
2594 return Err("empty duration".into());
2595 }
2596 let (num, unit): (&str, &str) = match s.find(|c: char| c.is_ascii_alphabetic()) {
2597 Some(i) => (&s[..i], &s[i..]),
2598 None => (s, "s"),
2599 };
2600 let n: u64 = num.parse().map_err(|_| format!("invalid duration: {s}"))?;
2601 let d = match unit {
2602 "ms" => Duration::from_millis(n),
2603 "s" => Duration::from_secs(n),
2604 "m" => Duration::from_secs(n * 60),
2605 "h" => Duration::from_secs(n * 3600),
2606 other => return Err(format!("unknown duration unit '{other}' in {s}")),
2607 };
2608 Ok(d)
2609}
2610
2611fn generate_run_id() -> String {
2615 let millis = SystemTime::now()
2616 .duration_since(UNIX_EPOCH)
2617 .unwrap_or_default()
2618 .as_millis();
2619 let pid = std::process::id();
2620 format!("{millis:011x}{pid:04x}")
2621}
2622
2623fn help_text() -> String {
2624 format!(
2625 "agentd {ver} — a minimal, MCP-native, reactive agent\n\
2626 \n\
2627 USAGE:\n\
2628 \x20 agentd --instruction <TEXT> --intelligence <URI> [--mcp name=endpoint ...] [options]\n\
2629 \n\
2630 REQUIRED:\n\
2631 \x20 --instruction <TEXT> the task (or INSTRUCTION / AGENT_INSTRUCTION env)\n\
2632 \x20 --instruction-file <PATH> read the instruction from a file\n\
2633 \x20 --intelligence <URI> https://host[:port][/path] (comma-list = failover order; http:// loopback-only for dev; or INTELLIGENCE / AGENT_INTELLIGENCE env)\n\
2634 \n\
2635 INTELLIGENCE:\n\
2636 \x20 --intelligence-token <T> bearer/key (or AGENT_INTELLIGENCE_TOKEN)\n\
2637 \x20 --intelligence-token-file <PATH> read the token from a mounted file (rotation; or AGENT_INTELLIGENCE_TOKEN_FILE)\n\
2638 \x20 --model <NAME> model id (or AGENT_MODEL)\n\
2639 \x20 --model-swap <finish-on-old|restart-turn> in-flight model-change policy (default finish-on-old; or AGENT_MODEL_SWAP)\n\
2640 \n\
2641 TOOLS / MCP:\n\
2642 \x20 --mcp name=endpoint declare a remote MCP server (repeatable; https://host[:port][/path])\n\
2643 \x20 --tls-ca <PATH> extra PEM CA(s) trusted for outbound https (private/in-cluster PKI; added to the bundled roots)\n\
2644 \x20 --aauth-provider <URL> [DRAFT] Agent Provider — sign every MCP request with an Ed25519 agent identity (needs --features aauth; or AGENT_AAUTH_PROVIDER)\n\
2645 \x20 --aauth-key-file <PATH> durable Ed25519 key file (created 0600 if absent; default agent.key; or AGENT_AAUTH_KEY_FILE)\n\
2646 \x20 --aauth-enroll-token <T> one-time enrollment token ({{secret:…}}; provider `token` mode; or AGENT_AAUTH_ENROLL_TOKEN)\n\
2647 \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\
2648 \x20 --aauth-person-server <URL> [DRAFT] Person Server for user-scoped identity (Case C; or AGENT_AAUTH_PERSON_SERVER)\n\
2649 \x20 --serve-mcp <TARGET> serve agentd's own MCP over HTTP(S): https://host:port (or loopback http:// for dev)\n\
2650 \x20 --a2a-peer name=<ENDPOINT> declare a remote A2A delegation peer: https://host[:port] (repeatable; needs --features a2a)\n\
2651 \x20 --mcp-tags name=t,t capability tags: untrusted_input|sensitive|egress\n\
2652 \x20 --allow-trifecta permit all three capability legs in one agent\n\
2653 \n\
2654 MODE / TRIGGERS:\n\
2655 \x20 --mode once|loop|reactive|schedule|workflow (default once)\n\
2656 \x20 --workflow <FILE> pinned workflow JSON, driven by --mode workflow (needs --features workflow; or AGENT_WORKFLOW)\n\
2657 \x20 --workflow-resume <REF> resume from a checkpoint: <server>:<key>[@seq] (needs --mode workflow; or AGENT_WORKFLOW_RESUME)\n\
2658 \x20 --workflow-resume-force override the workflow-hash check (graph-edit-and-continue)\n\
2659 \x20 --subscribe <uri> subscribe to an MCP resource (repeatable)\n\
2660 \x20 --continue <uri> subscribe, routed to one warm session (repeatable)\n\
2661 \x20 --interval <dur> loop/schedule interval (e.g. 5m)\n\
2662 \x20 --cron <5-field> schedule on a UTC cron expr (needs --features cron)\n\
2663 \n\
2664 LIMITS:\n\
2665 \x20 --max-steps <N> per-run step cap (default 50)\n\
2666 \x20 --max-tokens <N> per-run token budget (default 200000)\n\
2667 \x20 --budget-tokens-lifetime <N> per-INSTANCE cumulative token cap across all runs/reactions (RFC 0025; 0/unset = unbounded; or AGENT_BUDGET_TOKENS)\n\
2668 \x20 --deadline <dur> wall-clock deadline (default 600s)\n\
2669 \x20 --max-depth <N> subagent tree depth cap (default 4)\n\
2670 \n\
2671 RUNTIME:\n\
2672 \x20 --run-id <ID> idempotency key (or AGENT_RUN_ID)\n\
2673 \x20 --log-level <L> trace|debug|info|warn|error (default info)\n\
2674 \x20 --log-content log tool args/results, not just lengths (opt-in)\n\
2675 \x20 --drain-timeout <dur> graceful drain budget (default 25s; < pod grace)\n\
2676 \x20 --health-file <PATH> liveness heartbeat file\n\
2677 \x20 --metrics-addr <host:port> serve /metrics+/healthz+/readyz (`:port` = all IPv4 ifaces; needs --features metrics)\n\
2678 \x20 --cgroup <auto|PATH> per-run cgroup for atomic cgroup.kill teardown (best-effort)\n\
2679 \x20 --cgroup-memory-max <SIZE> per-run memory.max (max|512M|2G|bytes; needs --cgroup + delegation)\n\
2680 \x20 --cgroup-pids-max <N> per-run pids.max (max|count of THREADS; needs --cgroup + delegation)\n\
2681 \x20 --traceparent <W3C> continue an upstream trace (or AGENT_TRACEPARENT)\n\
2682 \x20 --report-file <PATH> write the run-outcome report at terminal (atomic; inert for reactive)\n\
2683 \x20 --budget-exit-code <N> remap the policy budget codes (3/7 only) to N at process exit (0..=255)\n\
2684 \x20 --events-ring <N> agent://events ring size (default 1024; needs --serve-mcp + --features events)\n\
2685 \x20 --capabilities print the capabilities manifest (JSON) and exit\n\
2686 \n\
2687 CONFIG FILE (RFC 0017):\n\
2688 \x20 --config <PATH> load a config file, YAML or JSON; repeatable — later files override earlier ones (or AGENT_CONFIG=a.yaml:b.yaml)\n\
2689 \x20 --validate-config load+validate (file+env+flags), print the verdict, exit 0/2\n\
2690 \x20 --config-schema print the config-file JSON Schema and exit\n\
2691 \x20 --watch-config reload on config-file change via inotify (needs --config + --features config-watch; or AGENT_WATCH_CONFIG)\n\
2692 \x20 -h, --help / -V, --version\n\
2693 \n\
2694 {paths}",
2695 ver = crate::VERSION,
2696 paths = paths::help_section(),
2697 )
2698}
2699
2700#[cfg(test)]
2701mod tests {
2702 use super::*;
2703
2704 fn args(v: &[&str]) -> Vec<String> {
2705 v.iter().map(|s| s.to_string()).collect()
2706 }
2707
2708 #[test]
2709 fn config_flag_accepts_short_and_inline_spellings() {
2710 let env: Vec<(String, String)> = vec![];
2713 for spelling in [
2714 args(&["--config", "a.yaml"]),
2715 args(&["-c", "a.yaml"]),
2716 args(&["--config=a.yaml"]),
2717 args(&["-c=a.yaml"]),
2718 ] {
2719 assert_eq!(
2720 Config::config_paths_from(&spelling, &env),
2721 vec!["a.yaml".to_string()],
2722 "spelling {spelling:?}"
2723 );
2724 }
2725 assert_eq!(
2727 Config::config_paths_from(&args(&["-c", "base.yaml", "--config=over.yaml"]), &env),
2728 vec!["base.yaml".to_string(), "over.yaml".to_string()]
2729 );
2730 assert_eq!(
2732 Config::config_paths_from(
2733 &args(&["-c=flag.yaml"]),
2734 &[("AGENTD_CONFIG".into(), "env.yaml".into())]
2735 ),
2736 vec!["env.yaml".to_string(), "flag.yaml".to_string()]
2737 );
2738 assert!(Config::config_paths_from(&args(&["-c"]), &env).is_empty());
2741 assert!(Config::config_paths_from(&args(&["--cluster-shard", "a"]), &env).is_empty());
2743 }
2744
2745 #[test]
2748 fn a_dotfile_is_discovered_only_when_nothing_else_named_a_config() {
2749 let dir = std::env::temp_dir().join(format!("agentd-discover-{}", std::process::id()));
2750 let _ = std::fs::remove_dir_all(&dir);
2751 std::fs::create_dir_all(&dir).unwrap();
2752
2753 assert!(discovered_config_in(&dir).is_empty());
2755
2756 std::fs::write(dir.join(".agentd.yml"), "config_version: \"2\"\n").unwrap();
2757 let found = discovered_config_in(&dir);
2758 assert_eq!(found.len(), 1);
2759 assert!(found[0].ends_with(".agentd.yml"), "{found:?}");
2760
2761 std::fs::write(dir.join(".agentd.yaml"), "config_version: \"2\"\n").unwrap();
2765 assert_eq!(discovered_config_in(&dir).len(), 2);
2766
2767 let _ = std::fs::remove_dir_all(&dir);
2768 }
2769
2770 #[test]
2773 fn help_and_version_do_not_discover_a_config() {
2774 for a in [
2775 "--help",
2776 "-h",
2777 "--version",
2778 "-V",
2779 "--config-schema",
2780 "--workflow-schema",
2781 ] {
2782 assert!(is_informational(&args(&[a])), "{a} should be informational");
2783 }
2784 assert!(!is_informational(&args(&["--validate-config"])));
2785 assert!(!is_informational(&args(&[])));
2786 }
2787
2788 #[test]
2789 fn flags_override_env() {
2790 let env = vec![
2791 ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
2792 ("INSTRUCTION".into(), "from-env".into()),
2793 ];
2794 let c = Config::load(&args(&["--instruction", "from-flag"]), &env).unwrap();
2795 assert_eq!(c.instruction.as_deref(), Some("from-flag"));
2796 assert_eq!(c.intelligence.as_deref(), Some("https://intel.example"));
2797 }
2798
2799 #[cfg(feature = "workflow")]
2800 #[test]
2801 fn workflow_mode_and_workflow_file_are_inseparable() {
2802 let intel_only = vec![(
2803 "AGENTD_INTELLIGENCE".to_string(),
2804 "https://intel.example".to_string(),
2805 )];
2806 let e = Config::load(
2808 &args(&["--mode", "workflow", "--instruction", "x"]),
2809 &intel_only,
2810 )
2811 .unwrap_err();
2812 assert!(
2813 format!("{e}").contains("--mode workflow requires --workflow"),
2814 "{e}"
2815 );
2816 let e = Config::load(&args(&["--workflow", "/tmp/g.json"]), &base_env()).unwrap_err();
2818 assert!(format!("{e}").contains("--workflow is only valid"), "{e}");
2819 }
2820
2821 #[cfg(feature = "workflow")]
2822 #[test]
2823 fn a_reactive_workflow_daemon_needs_no_subscribe_or_instruction() {
2824 let c = Config::load(
2827 &args(&["--mode", "reactive", "--workflow", "/tmp/wf.json"]),
2828 &base_env(),
2829 )
2830 .unwrap();
2831 assert_eq!(c.mode, Mode::Reactive);
2832 assert_eq!(c.workflow_file.as_deref(), Some("/tmp/wf.json"));
2833 let e = Config::load(&args(&["--mode", "reactive"]), &base_env()).unwrap_err();
2835 assert!(matches!(e, ConfigError::Usage(_)));
2836 let e = Config::load(
2838 &args(&[
2839 "--mode",
2840 "loop",
2841 "--interval",
2842 "5m",
2843 "--workflow",
2844 "/tmp/wf.json",
2845 "--instruction",
2846 "x",
2847 ]),
2848 &base_env(),
2849 )
2850 .unwrap_err();
2851 assert!(format!("{e}").contains("--workflow is only valid"), "{e}");
2852 }
2853
2854 #[cfg(feature = "workflow")]
2855 #[test]
2856 fn a_reactive_workflow_with_subscriptions_still_requires_an_instruction() {
2857 let intel_only = vec![(
2860 "AGENTD_INTELLIGENCE".to_string(),
2861 "https://intel.example".to_string(),
2862 )];
2863 let e = Config::load(
2864 &args(&[
2865 "--mode",
2866 "reactive",
2867 "--workflow",
2868 "/tmp/wf.json",
2869 "--subscribe",
2870 "file:///inbox",
2871 ]),
2872 &intel_only,
2873 )
2874 .unwrap_err();
2875 assert!(format!("{e}").contains("missing instruction"), "{e}");
2876 let c = Config::load(
2878 &args(&["--mode", "reactive", "--workflow", "/tmp/wf.json"]),
2879 &intel_only,
2880 )
2881 .unwrap();
2882 assert!(c.instruction.as_deref().unwrap_or("").is_empty());
2883 let c = Config::load(
2885 &args(&[
2886 "--mode",
2887 "reactive",
2888 "--workflow",
2889 "/tmp/wf.json",
2890 "--subscribe",
2891 "file:///inbox",
2892 "--instruction",
2893 "triage it",
2894 ]),
2895 &base_env(),
2896 )
2897 .unwrap();
2898 assert_eq!(c.subscribe.len(), 1);
2899 assert!(c.workflow_file.is_some());
2900 }
2901
2902 #[cfg(feature = "workflow")]
2903 #[test]
2904 fn workflow_mode_does_not_require_an_instruction() {
2905 let intel_only = vec![(
2908 "AGENTD_INTELLIGENCE".to_string(),
2909 "https://intel.example".to_string(),
2910 )];
2911 let c = Config::load(
2912 &args(&["--mode", "workflow", "--workflow", "/tmp/g.json"]),
2913 &intel_only,
2914 )
2915 .unwrap();
2916 assert_eq!(c.mode, Mode::Workflow);
2917 assert_eq!(c.workflow_file.as_deref(), Some("/tmp/g.json"));
2918 assert!(c.instruction.as_deref().unwrap_or("").is_empty());
2919 }
2920
2921 #[cfg(feature = "workflow")]
2922 #[test]
2923 fn workflow_resume_parses_and_validates() {
2924 let intel_only = vec![(
2925 "AGENTD_INTELLIGENCE".to_string(),
2926 "https://intel.example".to_string(),
2927 )];
2928 let c = Config::load(
2930 &args(&[
2931 "--mode",
2932 "workflow",
2933 "--workflow",
2934 "/tmp/g.json",
2935 "--mcp",
2936 "state=https://ckpt.internal/mcp",
2937 "--workflow-resume",
2938 "state:run/abc@17",
2939 "--workflow-resume-force",
2940 ]),
2941 &intel_only,
2942 )
2943 .unwrap();
2944 let r = c.workflow_resume.expect("parsed");
2945 assert_eq!(r.server, "state");
2946 assert_eq!(r.key, "run/abc");
2947 assert_eq!(r.seq, Some(17));
2948 assert!(r.force);
2949
2950 let c = Config::load(
2952 &args(&[
2953 "--mode",
2954 "workflow",
2955 "--workflow",
2956 "/tmp/g.json",
2957 "--mcp",
2958 "state=https://ckpt.internal/mcp",
2959 "--workflow-resume-force",
2960 "--workflow-resume",
2961 "state:run/abc",
2962 ]),
2963 &intel_only,
2964 )
2965 .unwrap();
2966 assert!(c.workflow_resume.unwrap().force);
2967
2968 for bad in [
2971 vec![
2972 "--mode",
2973 "workflow",
2974 "--workflow",
2975 "/g",
2976 "--workflow-resume",
2977 "nocolon",
2978 ],
2979 vec![
2980 "--mode",
2981 "workflow",
2982 "--workflow",
2983 "/g",
2984 "--workflow-resume-force",
2985 ],
2986 vec!["--instruction", "x", "--workflow-resume", "s:k"],
2987 vec![
2988 "--mode",
2989 "workflow",
2990 "--workflow",
2991 "/g",
2992 "--workflow-resume",
2993 "ghost:k",
2994 ],
2995 ] {
2996 assert!(
2997 Config::load(&args(&bad), &base_env()).is_err(),
2998 "{bad:?} must be refused"
2999 );
3000 }
3001 let mut env = intel_only.clone();
3003 env.push(("AGENT_WORKFLOW_RESUME".into(), "state:run/xyz".into()));
3004 let c = Config::load(
3005 &args(&[
3006 "--mode",
3007 "workflow",
3008 "--workflow",
3009 "/g",
3010 "--mcp",
3011 "state=https://ckpt.internal/mcp",
3012 ]),
3013 &env,
3014 )
3015 .unwrap();
3016 assert_eq!(c.workflow_resume.unwrap().key, "run/xyz");
3017 }
3018
3019 fn base_env() -> Vec<(String, String)> {
3020 vec![
3021 ("INSTRUCTION".into(), "x".into()),
3022 ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3023 ]
3024 }
3025
3026 #[test]
3027 fn neutral_agent_env_prefix_is_accepted_as_an_alias() {
3028 let env = vec![
3031 ("INSTRUCTION".into(), "x".into()),
3032 (
3033 "AGENT_INTELLIGENCE".into(),
3034 "https://neutral.example".into(),
3035 ),
3036 ("AGENT_RUN_ID".into(), "run-neutral".into()),
3037 ("AGENT_MAX_STEPS".into(), "42".into()),
3038 ];
3039 let c = Config::load(&args(&[]), &env).unwrap();
3040 assert_eq!(c.intelligence.as_deref(), Some("https://neutral.example"));
3041 assert_eq!(c.run_id, "run-neutral");
3042 assert_eq!(c.max_steps, 42);
3043 }
3044
3045 #[test]
3046 fn branded_env_wins_over_neutral_on_conflict() {
3047 let env = vec![
3050 ("INSTRUCTION".into(), "x".into()),
3051 (
3052 "AGENTD_INTELLIGENCE".into(),
3053 "https://branded.example".into(),
3054 ),
3055 (
3056 "AGENT_INTELLIGENCE".into(),
3057 "https://neutral.example".into(),
3058 ),
3059 ];
3060 let c = Config::load(&args(&[]), &env).unwrap();
3061 assert_eq!(c.intelligence.as_deref(), Some("https://branded.example"));
3062 }
3063
3064 #[test]
3065 fn bare_env_spellings_work_for_the_two_required_inputs() {
3066 let env = vec![
3069 ("INSTRUCTION".into(), "x".into()),
3070 ("INTELLIGENCE".into(), "https://bare.example".into()),
3071 ];
3072 let c = Config::load(&args(&[]), &env).unwrap();
3073 assert_eq!(c.intelligence.as_deref(), Some("https://bare.example"));
3074 }
3075
3076 #[test]
3077 fn prefixed_env_wins_over_the_bare_spelling() {
3078 let env = vec![
3083 ("INSTRUCTION".into(), "bare-task".into()),
3084 ("AGENT_INSTRUCTION".into(), "neutral-task".into()),
3085 ("INTELLIGENCE".into(), "https://bare.example".into()),
3086 (
3087 "AGENT_INTELLIGENCE".into(),
3088 "https://neutral.example".into(),
3089 ),
3090 ];
3091 let c = Config::load(&args(&[]), &env).unwrap();
3092 assert_eq!(c.instruction.as_deref(), Some("neutral-task"));
3093 assert_eq!(c.intelligence.as_deref(), Some("https://neutral.example"));
3094 }
3095
3096 #[test]
3097 fn debrand_env_synthesizes_branded_only_when_absent() {
3098 let env = vec![
3101 ("AGENT_MODE".into(), "loop".into()),
3102 ("AGENTD_RUN_ID".into(), "kept".into()),
3103 ("AGENT_RUN_ID".into(), "ignored".into()),
3104 ("INSTRUCTION".into(), "x".into()),
3105 ];
3106 let out = debrand_env(&env);
3107 let get = |k: &str| {
3108 out.iter()
3109 .filter(|(n, _)| n == k)
3110 .map(|(_, v)| v.as_str())
3111 .collect::<Vec<_>>()
3112 };
3113 assert_eq!(get("AGENTD_MODE"), vec!["loop"]);
3115 assert_eq!(get("AGENTD_RUN_ID"), vec!["kept"]);
3117 assert_eq!(get("INSTRUCTION"), vec!["x"]);
3119 }
3120
3121 #[test]
3122 fn report_file_and_events_ring_parse_from_flag_and_env() {
3123 let c = Config::load(&args(&[]), &base_env()).unwrap();
3125 assert_eq!(c.report_file, None);
3126 assert_eq!(c.events_ring, crate::obs::log::EVENTS_RING_DEFAULT);
3127
3128 let c = Config::load(
3130 &args(&["--report-file", "/out/report.json", "--events-ring", "256"]),
3131 &base_env(),
3132 )
3133 .unwrap();
3134 assert_eq!(c.report_file.as_deref(), Some("/out/report.json"));
3135 assert_eq!(c.events_ring, 256);
3136
3137 let mut env = base_env();
3139 env.push(("AGENTD_REPORT_FILE".into(), "/env/report.json".into()));
3140 env.push(("AGENTD_EVENTS_RING".into(), "64".into()));
3141 let c = Config::load(&args(&["--events-ring", "512"]), &env).unwrap();
3142 assert_eq!(c.report_file.as_deref(), Some("/env/report.json"));
3143 assert_eq!(c.events_ring, 512);
3144 }
3145
3146 #[test]
3147 fn budget_exit_code_flag_parses_and_range_checks() {
3148 let c = Config::load(&args(&[]), &base_env()).unwrap();
3150 assert_eq!(c.budget_exit_code, None);
3151 let c = Config::load(&args(&["--budget-exit-code", "0"]), &base_env()).unwrap();
3153 assert_eq!(c.budget_exit_code, Some(0));
3154 let c = Config::load(&args(&["--budget-exit-code", "42"]), &base_env()).unwrap();
3155 assert_eq!(c.budget_exit_code, Some(42));
3156 for bad in ["256", "-1", "nope"] {
3158 let e = Config::load(&args(&["--budget-exit-code", bad]), &base_env()).unwrap_err();
3159 assert!(
3160 matches!(e, ConfigError::Usage(_)),
3161 "{bad} must be a usage error"
3162 );
3163 }
3164 }
3165
3166 #[test]
3167 fn events_ring_zero_and_bad_value_are_usage_errors() {
3168 let zero = Config::load(&args(&["--events-ring", "0"]), &base_env()).unwrap_err();
3169 assert!(matches!(zero, ConfigError::Usage(_)));
3170 let bad = Config::load(&args(&["--events-ring", "lots"]), &base_env()).unwrap_err();
3171 assert!(matches!(bad, ConfigError::Usage(_)));
3172 }
3173
3174 #[test]
3175 fn mcp_tags_attach_to_their_server_order_independent() {
3176 let c = Config::load(
3178 &args(&[
3179 "--mcp-tags",
3180 "fs=sensitive,egress",
3181 "--mcp",
3182 "fs=https://fs.example",
3183 ]),
3184 &base_env(),
3185 )
3186 .unwrap();
3187 assert_eq!(
3188 c.mcp_servers[0].tags,
3189 vec![TrifectaTag::Sensitive, TrifectaTag::Egress]
3190 );
3191 }
3192
3193 #[test]
3194 fn mcp_tags_unknown_server_or_tag_is_usage_error() {
3195 let bad_server = Config::load(
3196 &args(&[
3197 "--mcp",
3198 "fs=https://fs.example",
3199 "--mcp-tags",
3200 "ghost=egress",
3201 ]),
3202 &base_env(),
3203 )
3204 .unwrap_err();
3205 assert!(matches!(bad_server, ConfigError::Usage(_)));
3206 let bad_tag = Config::load(
3207 &args(&["--mcp", "fs=https://fs.example", "--mcp-tags", "fs=bogus"]),
3208 &base_env(),
3209 )
3210 .unwrap_err();
3211 assert!(matches!(bad_tag, ConfigError::Usage(_)));
3212 }
3213
3214 #[test]
3215 fn cgroup_limits_require_cgroup_and_reject_zero() {
3216 let e = Config::load(&args(&["--cgroup-memory-max", "512M"]), &base_env()).unwrap_err();
3218 assert!(matches!(e, ConfigError::Usage(_)));
3219 let e2 = Config::load(&args(&["--cgroup-pids-max", "64"]), &base_env()).unwrap_err();
3220 assert!(matches!(e2, ConfigError::Usage(_)));
3221 let c = Config::load(
3223 &args(&[
3224 "--cgroup",
3225 "auto",
3226 "--cgroup-memory-max",
3227 "512M",
3228 "--cgroup-pids-max",
3229 "64",
3230 ]),
3231 &base_env(),
3232 )
3233 .unwrap();
3234 assert_eq!(c.cgroup_memory_max.as_deref(), Some("512M"));
3235 assert_eq!(c.cgroup_pids_max.as_deref(), Some("64"));
3236 let z = Config::load(
3238 &args(&["--cgroup", "auto", "--cgroup-pids-max", "0"]),
3239 &base_env(),
3240 )
3241 .unwrap_err();
3242 assert!(matches!(z, ConfigError::Usage(_)));
3243 let zm = Config::load(
3244 &args(&["--cgroup", "auto", "--cgroup-memory-max", "0"]),
3245 &base_env(),
3246 )
3247 .unwrap_err();
3248 assert!(matches!(zm, ConfigError::Usage(_)));
3249 }
3250
3251 #[test]
3252 fn cron_requires_schedule_mode() {
3253 let e = Config::load(
3255 &args(&[
3256 "--mode",
3257 "reactive",
3258 "--subscribe",
3259 "x://y",
3260 "--cron",
3261 "* * * * *",
3262 ]),
3263 &base_env(),
3264 )
3265 .unwrap_err();
3266 assert!(matches!(e, ConfigError::Usage(_)));
3267 let c = Config::load(
3269 &args(&["--mode", "schedule", "--cron", "0 9 * * 1-5"]),
3270 &base_env(),
3271 )
3272 .unwrap();
3273 assert_eq!(c.cron.as_deref(), Some("0 9 * * 1-5"));
3274 let e2 = Config::load(&args(&["--mode", "schedule"]), &base_env()).unwrap_err();
3276 assert!(matches!(e2, ConfigError::Usage(_)));
3277 }
3278
3279 #[test]
3286 fn trifecta_grant_tags_defaults_untagged_to_untrusted() {
3287 let c = Config::load(&args(&["--mcp", "fs=https://fs.example"]), &base_env()).unwrap();
3288 let tags = c.trifecta_grant_tags();
3289 assert!(tags.contains(&TrifectaTag::UntrustedInput)); assert!(!tags.contains(&TrifectaTag::Sensitive)); }
3292
3293 #[test]
3294 fn missing_instruction_is_usage_error() {
3295 let env = vec![("AGENTD_INTELLIGENCE".into(), "https://intel.example".into())];
3296 let e = Config::load(&[], &env).unwrap_err();
3297 assert!(matches!(e, ConfigError::Usage(_)));
3298 }
3299
3300 #[test]
3301 fn help_short_circuits() {
3302 let e = Config::load(&args(&["--help"]), &[]).unwrap_err();
3303 assert!(matches!(e, ConfigError::Help(_)));
3304 }
3305
3306 #[test]
3307 fn reactive_requires_subscribe() {
3308 let env = vec![
3309 ("INSTRUCTION".into(), "x".into()),
3310 ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3311 ];
3312 let e = Config::load(&args(&["--mode", "reactive"]), &env).unwrap_err();
3313 assert!(matches!(e, ConfigError::Usage(_)));
3314 let c = Config::load(
3316 &args(&["--mode", "reactive", "--subscribe", "file://a"]),
3317 &env,
3318 )
3319 .unwrap();
3320 assert_eq!(c.mode, Mode::Reactive);
3321 }
3322
3323 #[test]
3324 fn mcp_spec_parsing() {
3325 let env = vec![
3326 ("INSTRUCTION".into(), "x".into()),
3327 ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3328 ];
3329 let c = Config::load(&args(&["--mcp", "fs=https://mcp.example.com/mcp"]), &env).unwrap();
3331 assert_eq!(c.mcp_servers.len(), 1);
3332 assert_eq!(c.mcp_servers[0].name, "fs");
3333 assert_eq!(c.mcp_servers[0].endpoint, "https://mcp.example.com/mcp");
3334 }
3335
3336 #[test]
3337 fn mcp_endpoint_spec_parsing() {
3338 assert!(is_mcp_endpoint("https://mcp.example.com/mcp"));
3340 assert!(is_mcp_endpoint("http://localhost:8080/mcp"));
3341 assert!(!is_mcp_endpoint("unix:/run/mcp.sock"));
3343 assert!(!is_mcp_endpoint("vsock:3:5000"));
3344 assert!(!is_mcp_endpoint("mcp-server-fs --root /data"));
3345 assert!(parse_mcp_spec("fs=mcp-server-fs --root /data").is_err());
3346 assert!(parse_mcp_spec("fs=unix:/run/mcp.sock").is_err());
3347
3348 for ep in ["https://mcp.example.com/mcp", "http://127.0.0.1:8080/mcp"] {
3349 let spec = parse_mcp_spec(&format!("fs={ep}")).unwrap();
3350 assert_eq!(spec.name, "fs");
3351 assert_eq!(spec.endpoint, ep);
3352 }
3353 }
3354
3355 #[test]
3356 fn mcp_endpoint_scheme_gate_is_https_only() {
3357 assert!(mcp_endpoint_scheme_ok("https://mcp.example/mcp").is_ok());
3359 assert!(mcp_endpoint_scheme_ok("http://127.0.0.1:8080/mcp").is_ok());
3360 for bad in [
3361 "unix:/run/mcp.sock",
3362 "vsock:3:5000",
3363 "http://mcp.example:8080/mcp",
3364 ] {
3365 assert!(
3366 mcp_endpoint_scheme_ok(bad).is_err(),
3367 "{bad} must be rejected"
3368 );
3369 }
3370 }
3371
3372 #[test]
3373 fn mcp_endpoint_is_required_and_validated() {
3374 let env = vec![
3375 ("INSTRUCTION".into(), "x".into()),
3376 ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3377 ];
3378 let mut c =
3380 Config::load(&args(&["--mcp", "fs=https://mcp.example.com/mcp"]), &env).unwrap();
3381 assert!(c.validate().is_ok());
3382 c.mcp_servers[0].endpoint.clear();
3384 assert!(c.validate().is_err(), "an empty endpoint must fail");
3385 c.mcp_servers[0].endpoint = "ftp://nope/".into();
3387 assert!(
3388 c.validate().is_err(),
3389 "an unsupported endpoint scheme must fail"
3390 );
3391 }
3392
3393 #[test]
3394 fn duration_units() {
3395 assert_eq!(parse_duration("600s").unwrap(), Duration::from_secs(600));
3396 assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
3397 assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
3398 assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250));
3399 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
3400 assert!(parse_duration("nope").is_err());
3401 }
3402
3403 #[test]
3404 fn invalid_intelligence_uri_rejected() {
3405 let env = vec![("INSTRUCTION".into(), "x".into())];
3406 let e = Config::load(&args(&["--intelligence", "ftp://x"]), &env).unwrap_err();
3407 assert!(matches!(e, ConfigError::Usage(_)));
3408 }
3409
3410 #[test]
3411 fn multi_endpoint_list_accepts_ordered_comma_list() {
3412 let env = vec![("INSTRUCTION".into(), "x".into())];
3414 let c = Config::load(
3415 &args(&[
3416 "--intelligence",
3417 "https://a.example,https://b.example,https://c.example",
3418 ]),
3419 &env,
3420 )
3421 .unwrap();
3422 assert_eq!(
3424 c.intelligence.as_deref(),
3425 Some("https://a.example,https://b.example,https://c.example")
3426 );
3427 }
3428
3429 #[test]
3430 fn multi_endpoint_bad_element_scheme_is_exit_2() {
3431 let env = vec![("INSTRUCTION".into(), "x".into())];
3433 let e = Config::load(
3434 &args(&[
3435 "--intelligence",
3436 "https://a.example,ftp://nope,https://c.example",
3437 ]),
3438 &env,
3439 )
3440 .unwrap_err();
3441 assert!(matches!(e, ConfigError::Usage(_)));
3442 }
3443
3444 #[test]
3445 fn empty_endpoint_list_is_exit_2() {
3446 let env = vec![("INSTRUCTION".into(), "x".into())];
3448 let e = Config::load(&args(&["--intelligence", " , , "]), &env).unwrap_err();
3449 assert!(matches!(e, ConfigError::Usage(_)));
3450 }
3451
3452 #[test]
3453 fn serve_target_http_parses() {
3454 assert_eq!(
3455 ServeTarget::parse("https://0.0.0.0:8443").unwrap(),
3456 ServeTarget::Http {
3457 bind: "0.0.0.0:8443".into(),
3458 tls: true
3459 }
3460 );
3461 assert_eq!(
3463 ServeTarget::parse("http://127.0.0.1:9000").unwrap(),
3464 ServeTarget::Http {
3465 bind: "127.0.0.1:9000".into(),
3466 tls: false
3467 }
3468 );
3469 assert!(matches!(
3470 ServeTarget::parse("http://[::1]:9000"),
3471 Ok(ServeTarget::Http { tls: false, .. })
3472 ));
3473 for bad in [
3475 "http://10.0.0.5:9000",
3476 "https://host:8443/mcp",
3477 "https://host",
3478 ] {
3479 assert!(
3480 matches!(ServeTarget::parse(bad), Err(ConfigError::Usage(_))),
3481 "{bad} must be rejected"
3482 );
3483 }
3484 }
3485
3486 #[test]
3487 fn serve_auth_gates_the_control_plane() {
3488 let base = |extra: &[&str]| {
3489 let mut a = vec!["--instruction", "x", "--intelligence", "https://i.example"];
3490 a.extend_from_slice(extra);
3491 let args: Vec<String> = a.iter().map(|s| s.to_string()).collect();
3492 Config::load(&args, &[]).and_then(|c| c.validate().map(|_| c))
3493 };
3494 assert!(base(&["--serve-mcp", "https://0.0.0.0:8443"]).is_err());
3496 assert!(base(&["--serve-mcp", "https://127.0.0.1:8443"]).is_err());
3498 assert!(base(&["--serve-mcp", "http://127.0.0.1:9000"]).is_ok());
3500 assert!(base(&["--serve-mcp", "unix:/x.sock", "--serve-bearer", "t"]).is_err());
3502 assert!(base(&["--serve-mcp", "http://127.0.0.1:9000", "--serve-cert", "/x"]).is_err());
3503 assert!(base(&["--serve-bearer", "t"]).is_err());
3505 }
3506
3507 #[test]
3508 fn serve_target_retired_socket_schemes_are_rejected() {
3509 for bad in [
3511 "unix:/run/agentd.sock",
3512 "vsock:5005",
3513 "vsock:2:5005",
3514 "tcp:1234",
3515 ] {
3516 assert!(
3517 matches!(ServeTarget::parse(bad), Err(ConfigError::Usage(_))),
3518 "{bad} must be a usage error"
3519 );
3520 }
3521 }
3522
3523 #[test]
3524 fn serve_mcp_validation_runs_at_load() {
3525 let c = Config::load(
3527 &args(&["--serve-mcp", "http://127.0.0.1:9000"]),
3528 &base_env(),
3529 )
3530 .unwrap();
3531 assert_eq!(c.serve_mcp.as_deref(), Some("http://127.0.0.1:9000"));
3532 let e = Config::load(&args(&["--serve-mcp", "tcp:9000"]), &base_env()).unwrap_err();
3534 assert!(matches!(e, ConfigError::Usage(_)));
3535 }
3536
3537 #[test]
3538 fn a2a_peer_spec_parses_name_and_endpoint() {
3539 let spec = parse_a2a_peer_spec("mesh=https://peer.example").unwrap();
3542 assert_eq!(spec.name, "mesh");
3543 assert_eq!(spec.endpoint, "https://peer.example");
3544 assert!(matches!(
3546 parse_a2a_peer_spec("noequals"),
3547 Err(ConfigError::Usage(_))
3548 ));
3549 assert!(matches!(
3550 parse_a2a_peer_spec("=https://x"),
3551 Err(ConfigError::Usage(_))
3552 ));
3553 assert!(matches!(
3554 parse_a2a_peer_spec("mesh="),
3555 Err(ConfigError::Usage(_))
3556 ));
3557 }
3558
3559 #[test]
3560 fn a2a_endpoint_https_parses_and_gates_plaintext() {
3561 assert_eq!(
3562 A2aEndpoint::parse("https://peer.example:8443/a2a").unwrap(),
3563 A2aEndpoint::Https("https://peer.example:8443/a2a".into())
3564 );
3565 assert!(matches!(
3567 A2aEndpoint::parse("http://127.0.0.1:9000"),
3568 Ok(A2aEndpoint::Https(_))
3569 ));
3570 assert!(matches!(
3571 A2aEndpoint::parse("http://peer.example:9000"),
3572 Err(ConfigError::Usage(_))
3573 ));
3574 }
3575
3576 #[cfg(feature = "a2a")]
3577 #[test]
3578 fn a2a_peer_flag_parses_and_validates_on_a2a_build() {
3579 let c = Config::load(
3581 &args(&["--a2a-peer", "mesh=https://peer.example:8443/a2a"]),
3582 &base_env(),
3583 )
3584 .unwrap();
3585 assert_eq!(c.a2a_peers.len(), 1);
3586 assert_eq!(c.a2a_peers[0].name, "mesh");
3587 assert_eq!(c.a2a_peers[0].endpoint, "https://peer.example:8443/a2a");
3588
3589 for bad in [
3592 "mesh=unix:/run/peer.sock",
3593 "mesh=vsock:2:5005",
3594 "mesh=http://peer.example:9000",
3595 "mesh=tcp:9000",
3596 ] {
3597 let e = Config::load(&args(&["--a2a-peer", bad]), &base_env()).unwrap_err();
3598 assert!(matches!(e, ConfigError::Usage(_)), "{bad} must be exit 2");
3599 }
3600
3601 let dup = Config::load(
3603 &args(&[
3604 "--a2a-peer",
3605 "mesh=https://a.example",
3606 "--a2a-peer",
3607 "mesh=https://b.example",
3608 ]),
3609 &base_env(),
3610 )
3611 .unwrap_err();
3612 assert!(matches!(dup, ConfigError::Usage(_)));
3613 }
3614
3615 #[cfg(feature = "a2a")]
3616 #[test]
3617 fn a2a_peer_client_auth_is_validated_at_startup() {
3618 let file = write_tmp(
3621 r#"{ "a2a_peers": [{ "name": "mesh", "endpoint": "https://peer.example/a2a",
3622 "headers": { "authorization": "Bearer sk-live-inline-oops" } }] }"#,
3623 );
3624 let e = Config::load(
3625 &args(&["--config", file.path().to_str().unwrap()]),
3626 &base_env(),
3627 )
3628 .unwrap_err();
3629 assert!(matches!(e, ConfigError::Usage(_)), "{e}");
3630 assert!(format!("{e}").contains("a2a peer 'mesh' header"), "{e}");
3631
3632 let file = write_tmp(
3635 r#"{ "a2a_peers": [{ "name": "mesh", "endpoint": "https://peer.example/a2a",
3636 "headers": { "authorization": "Bearer {{secret:A2A_PEER_AUTH_TEST_TOKEN}}" } }] }"#,
3637 );
3638 unsafe { std::env::set_var("A2A_PEER_AUTH_TEST_TOKEN", "tok") };
3640 let c = Config::load(
3641 &args(&["--config", file.path().to_str().unwrap()]),
3642 &base_env(),
3643 )
3644 .unwrap();
3645 unsafe { std::env::remove_var("A2A_PEER_AUTH_TEST_TOKEN") };
3646 assert_eq!(
3647 c.a2a_peers[0].headers.len(),
3648 1,
3649 "template stored, not resolved"
3650 );
3651 assert!(
3652 c.a2a_peers[0].headers[0].1.contains("{{secret:"),
3653 "the SPEC keeps the template, never the material"
3654 );
3655
3656 let file = write_tmp(
3658 r#"{ "a2a_peers": [{ "name": "mesh", "endpoint": "https://peer.example/a2a",
3659 "client_cert": "/tls/cert.pem" }] }"#,
3660 );
3661 let e = Config::load(
3662 &args(&["--config", file.path().to_str().unwrap()]),
3663 &base_env(),
3664 )
3665 .unwrap_err();
3666 assert!(
3667 format!("{e}").contains("client_cert and client_key must be set together"),
3668 "{e}"
3669 );
3670 }
3671
3672 #[cfg(not(feature = "a2a"))]
3673 #[test]
3674 fn a2a_peer_requires_the_a2a_feature() {
3675 let e = Config::load(
3677 &args(&["--a2a-peer", "mesh=https://peer.example"]),
3678 &base_env(),
3679 )
3680 .unwrap_err();
3681 match e {
3682 ConfigError::Usage(msg) => assert!(
3683 msg.contains("--a2a-peer requires the 'a2a' build feature"),
3684 "got: {msg}"
3685 ),
3686 other => panic!("expected a Usage error, got {other:?}"),
3687 }
3688 }
3689
3690 #[test]
3691 fn token_redacted_in_debug() {
3692 let env = vec![
3693 ("INSTRUCTION".into(), "x".into()),
3694 (
3695 "AGENTD_INTELLIGENCE".into(),
3696 "https://api.example/v1".into(),
3697 ),
3698 ("AGENTD_INTELLIGENCE_TOKEN".into(), "super-secret".into()),
3699 ];
3700 let c = Config::load(&[], &env).unwrap();
3701 let dbg = format!("{c:?}");
3702 assert!(!dbg.contains("super-secret"));
3703 assert!(dbg.contains("***"));
3704 }
3705
3706 #[test]
3707 fn debug_redacts_credential_bearing_intelligence_uri() {
3708 let env = vec![
3712 ("INSTRUCTION".into(), "x".into()),
3713 (
3714 "AGENTD_INTELLIGENCE".into(),
3715 "https://alice:hunter2@internal.example/v1".into(),
3716 ),
3717 ];
3718 let c = Config::load(&[], &env).unwrap();
3719 let dbg = format!("{c:?}");
3720 assert!(!dbg.contains("hunter2"), "creds leaked: {dbg}");
3721 assert!(!dbg.contains("internal.example"), "host leaked: {dbg}");
3722 assert!(dbg.contains("https:<redacted>"), "scheme missing: {dbg}");
3723 }
3724
3725 #[test]
3726 fn help_text_lists_model_swap() {
3727 let h = match Config::load(&args(&["--help"]), &[]).unwrap_err() {
3729 ConfigError::Help(s) => s,
3730 other => panic!("expected Help, got {other:?}"),
3731 };
3732 assert!(h.contains("--model-swap"), "help omits --model-swap");
3733 assert!(h.contains("finish-on-old|restart-turn"));
3734 }
3735
3736 use std::io::Write as _;
3739
3740 fn write_tmp(contents: &str) -> tempfile::NamedTempFile {
3741 let mut f = tempfile::NamedTempFile::new().unwrap();
3742 f.write_all(contents.as_bytes()).unwrap();
3743 f.flush().unwrap();
3744 f
3745 }
3746
3747 #[test]
3748 fn config_file_loads_mcp_subscribe_a2a_and_limits() {
3749 let file = write_tmp(
3750 r#"{
3751 "model": "claude-from-file",
3752 "max_tokens": 1234567,
3753 "limits": { "max_steps": 77, "max_depth": 3, "deadline_secs": 120 },
3754 "mcp_servers": [
3755 { "name": "web", "endpoint": "https://web.example.com/mcp",
3756 "tags": { "*": ["untrusted_input"] } }
3757 ],
3758 "subscribe": ["fs:file:///watch/inbox"]
3759 }"#,
3760 );
3761 let c = Config::load(
3762 &args(&["--config", file.path().to_str().unwrap()]),
3763 &base_env(),
3764 )
3765 .unwrap();
3766 assert_eq!(c.model.as_deref(), Some("claude-from-file"));
3767 assert_eq!(c.max_tokens, 1_234_567);
3768 assert_eq!(c.max_steps, 77);
3769 assert_eq!(c.max_depth, 3);
3770 assert_eq!(c.deadline, Some(Duration::from_secs(120)));
3771 assert_eq!(c.mcp_servers.len(), 1);
3772 assert_eq!(c.mcp_servers[0].name, "web");
3773 assert_eq!(c.mcp_servers[0].endpoint, "https://web.example.com/mcp");
3774 assert_eq!(c.mcp_servers[0].tags, vec![TrifectaTag::UntrustedInput]);
3775 assert_eq!(c.subscribe, vec!["fs:file:///watch/inbox"]);
3776 }
3777
3778 #[test]
3779 fn budget_tokens_lifetime_parses_from_flag_env_and_file() {
3780 assert_eq!(
3782 Config::load(&args(&[]), &base_env())
3783 .unwrap()
3784 .budget_tokens_lifetime,
3785 0
3786 );
3787 let c = Config::load(&args(&["--budget-tokens-lifetime", "2000000"]), &base_env()).unwrap();
3789 assert_eq!(c.budget_tokens_lifetime, 2_000_000);
3790 let mut env = base_env();
3792 env.push(("AGENT_BUDGET_TOKENS".into(), "500000".into()));
3793 assert_eq!(
3794 Config::load(&args(&[]), &env)
3795 .unwrap()
3796 .budget_tokens_lifetime,
3797 500_000
3798 );
3799 let file = write_tmp(r#"{ "model": "m", "limits": { "lifetime_tokens": 111 } }"#);
3801 let path = file.path().to_str().unwrap().to_string();
3802 assert_eq!(
3803 Config::load(&args(&["--config", &path]), &base_env())
3804 .unwrap()
3805 .budget_tokens_lifetime,
3806 111
3807 );
3808 assert_eq!(
3809 Config::load(
3810 &args(&["--config", &path, "--budget-tokens-lifetime", "222"]),
3811 &base_env()
3812 )
3813 .unwrap()
3814 .budget_tokens_lifetime,
3815 222
3816 );
3817 }
3818
3819 #[test]
3820 fn env_and_flag_override_file_per_precedence() {
3821 let file = write_tmp(r#"{ "model": "from-file", "max_tokens": 100 }"#);
3823 let mut env = base_env();
3824 env.push(("AGENTD_MODEL".into(), "from-env".into()));
3825 let c = Config::load(
3827 &args(&[
3828 "--config",
3829 file.path().to_str().unwrap(),
3830 "--max-tokens",
3831 "999",
3832 ]),
3833 &env,
3834 )
3835 .unwrap();
3836 assert_eq!(c.model.as_deref(), Some("from-env")); assert_eq!(c.max_tokens, 999); let c2 = Config::load(
3840 &args(&["--config", file.path().to_str().unwrap()]),
3841 &base_env(),
3842 )
3843 .unwrap();
3844 assert_eq!(c2.model.as_deref(), Some("from-file"));
3845 assert_eq!(c2.max_tokens, 100);
3846 }
3847
3848 fn write_tmp_ext(contents: &str, ext: &str) -> tempfile::NamedTempFile {
3850 let mut f = tempfile::Builder::new()
3851 .suffix(&format!(".{ext}"))
3852 .tempfile()
3853 .unwrap();
3854 f.write_all(contents.as_bytes()).unwrap();
3855 f.flush().unwrap();
3856 f
3857 }
3858
3859 #[test]
3860 fn config_file_may_be_yaml() {
3861 let yaml = write_tmp_ext(
3864 r#"
3865# agentd config
3866model: claude-from-yaml
3867max_tokens: 1234567
3868limits:
3869 max_steps: 77
3870 max_depth: 3
3871 deadline_secs: 120
3872mcp_servers:
3873 - name: web
3874 endpoint: https://web.example.com/mcp
3875 tags:
3876 "*": [untrusted_input]
3877subscribe:
3878 - fs:file:///watch/inbox
3879log_level: warn
3880"#,
3881 "yaml",
3882 );
3883 let c = Config::load(
3884 &args(&["--config", yaml.path().to_str().unwrap()]),
3885 &base_env(),
3886 )
3887 .unwrap();
3888 assert_eq!(c.model.as_deref(), Some("claude-from-yaml"));
3889 assert_eq!(c.max_tokens, 1_234_567);
3890 assert_eq!(c.max_steps, 77);
3891 assert_eq!(c.max_depth, 3);
3892 assert_eq!(c.deadline, Some(Duration::from_secs(120)));
3893 assert_eq!(c.mcp_servers.len(), 1);
3894 assert_eq!(c.mcp_servers[0].name, "web");
3895 assert_eq!(c.mcp_servers[0].tags, vec![TrifectaTag::UntrustedInput]);
3896 assert_eq!(c.subscribe, vec!["fs:file:///watch/inbox"]);
3897 assert_eq!(c.log_level, Level::Warn);
3898
3899 let bad = write_tmp_ext("model: m\nmax_token: 5\n", "yml");
3901 let e = Config::load(
3902 &args(&["--config", bad.path().to_str().unwrap()]),
3903 &base_env(),
3904 )
3905 .unwrap_err();
3906 assert!(matches!(e, ConfigError::Usage(_)), "{e}");
3907 assert!(format!("{e}").contains("max_token"), "{e}");
3908 let bad = write_tmp_ext("model: m\n\tlimits: {}\n", "yaml");
3910 let e = Config::load(
3911 &args(&["--config", bad.path().to_str().unwrap()]),
3912 &base_env(),
3913 )
3914 .unwrap_err();
3915 assert!(format!("{e}").contains("line 2"), "{e}");
3916 }
3917
3918 #[test]
3919 fn path_env_vars_set_config_paths() {
3920 let file = write_tmp_ext(
3923 "limits:\n max_steps: 1\n max_depth: 9\nmodel: f\n",
3924 "yaml",
3925 );
3926 let mut env = base_env();
3927 env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "5".into()));
3928 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((
3933 "AGENTD_INTELLIGENCE_HEADERS".into(),
3934 "{x-team: ops}".into(), ));
3936 let c = Config::load(&args(&["--config", file.path().to_str().unwrap()]), &env).unwrap();
3937 assert_eq!(c.max_steps, 5, "env path beats the file");
3938 assert_eq!(
3939 c.max_depth, 9,
3940 "untouched sibling path keeps the file value"
3941 );
3942 assert_eq!(c.deadline, Some(Duration::from_secs(30)));
3943 assert_eq!(c.budget_tokens_lifetime, 4000);
3944 assert_eq!(c.model_swap, SwapPolicy::RestartTurn);
3945 assert_eq!(c.subscribe, vec!["a://1", "a://2"]);
3946 assert_eq!(
3947 c.intelligence_headers.get("x-team").map(String::as_str),
3948 Some("ops")
3949 );
3950 assert_eq!(c.model.as_deref(), Some("f"));
3951
3952 let mut env = base_env();
3954 env.push(("LIMITS_MAX_STEPS".into(), "1".into()));
3955 env.push(("AGENT_LIMITS_MAX_STEPS".into(), "2".into()));
3956 assert_eq!(Config::load(&args(&[]), &env).unwrap().max_steps, 2);
3957 env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "3".into()));
3958 assert_eq!(Config::load(&args(&[]), &env).unwrap().max_steps, 3);
3959
3960 let mut env = base_env();
3962 env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "many".into()));
3963 let e = Config::load(&args(&[]), &env).unwrap_err();
3964 assert!(matches!(e, ConfigError::Usage(_)));
3965 assert!(format!("{e}").contains("AGENTD_LIMITS_MAX_STEPS"), "{e}");
3966 let mut env = base_env();
3967 env.push(("AGENTD_LOG_LEVEL".into(), "loud".into()));
3968 let e = Config::load(&args(&[]), &env).unwrap_err();
3969 assert!(format!("{e}").contains("AGENTD_LOG_LEVEL"), "{e}");
3970 }
3971
3972 #[test]
3973 fn generic_path_flags_override_env_and_file() {
3974 let file = write_tmp_ext("limits:\n max_steps: 1\n", "yaml");
3978 let mut env = base_env();
3979 env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "2".into()));
3980 for spelling in [
3981 "--limits.max-steps",
3982 "--limits-max-steps",
3983 "--limits.max_steps",
3984 ] {
3985 let c = Config::load(
3986 &args(&["--config", file.path().to_str().unwrap(), spelling, "3"]),
3987 &env,
3988 )
3989 .unwrap();
3990 assert_eq!(c.max_steps, 3, "{spelling}");
3991 }
3992 let c = Config::load(
3994 &args(&["--max-steps", "4", "--limits.max_steps", "5"]),
3995 &base_env(),
3996 )
3997 .unwrap();
3998 assert_eq!(c.max_steps, 5);
3999 let c = Config::load(
4000 &args(&["--limits.max_steps", "5", "--max-steps", "6"]),
4001 &base_env(),
4002 )
4003 .unwrap();
4004 assert_eq!(c.max_steps, 6);
4005 let c = Config::load(
4007 &args(&["--subscribe", "a://1", "--subscribe", "[a://2, a://3]"]),
4008 &base_env(),
4009 )
4010 .unwrap();
4011 assert_eq!(c.subscribe, vec!["a://1", "[a://2, a://3]"]);
4013 let c = Config::load(
4014 &args(&[
4015 "--mcp-servers",
4016 "[{name: q, endpoint: https://q.example/mcp}]",
4017 ]),
4018 &base_env(),
4019 )
4020 .unwrap();
4021 assert_eq!(c.mcp_servers.len(), 1);
4022 assert_eq!(c.mcp_servers[0].name, "q");
4023 let e = Config::load(&args(&["--limits.max-steps", "lots"]), &base_env()).unwrap_err();
4025 assert!(format!("{e}").contains("--limits.max-steps"), "{e}");
4026 let e = Config::load(&args(&["--model-swap", "sideways"]), &base_env()).unwrap_err();
4028 assert!(matches!(e, ConfigError::Usage(_)), "{e}");
4029 let e = Config::load(&args(&["--no-such-thing", "1"]), &base_env()).unwrap_err();
4031 assert!(format!("{e}").contains("unknown argument"), "{e}");
4032 let e = Config::load(&args(&["--limits.max-steps"]), &base_env()).unwrap_err();
4034 assert!(format!("{e}").contains("requires a value"), "{e}");
4035 }
4036
4037 #[test]
4038 fn multiple_config_files_merge_in_order_later_wins() {
4039 let base = write_tmp_ext(
4044 "model: base
4045log_level: warn
4046limits:
4047 max_steps: 1
4048 max_depth: 2
4049subscribe: [a://1, a://2]
4050",
4051 "yaml",
4052 );
4053 let site = write_tmp_ext(
4054 r#"{ "model": "site", "limits": { "max_steps": 5 }, "subscribe": ["a://3"] }"#,
4055 "json",
4056 );
4057 let over = write_tmp_ext(
4058 "model: over
4059log_level: null
4060limits:
4061 max_depth: 7
4062",
4063 "yml",
4064 );
4065 let mut env = base_env();
4066 env.push((
4067 "AGENT_CONFIG".into(), format!("{}:{}", base.path().display(), site.path().display()),
4069 ));
4070 let c = Config::load(&args(&["--config", over.path().to_str().unwrap()]), &env).unwrap();
4071 assert_eq!(
4072 c.model.as_deref(),
4073 Some("over"),
4074 "last file wins on a scalar"
4075 );
4076 assert_eq!(
4077 c.max_steps, 5,
4078 "site's limits.max_steps survives (objects merge)"
4079 );
4080 assert_eq!(c.max_depth, 7, "over's limits.max_depth wins");
4081 assert_eq!(c.subscribe, vec!["a://3"], "a later file REPLACES a list");
4082 assert_eq!(
4083 c.log_level,
4084 Level::Info,
4085 "`null` unsets → back to the default"
4086 );
4087 assert_eq!(c.config_files.len(), 3);
4088 assert!(c.config_files[0].ends_with(".yaml") && c.config_files[2].ends_with(".yml"));
4089 let c = Config::load(
4091 &args(&[
4092 "--config",
4093 over.path().to_str().unwrap(),
4094 "--config",
4095 base.path().to_str().unwrap(),
4096 ]),
4097 &base_env(),
4098 )
4099 .unwrap();
4100 assert_eq!(c.model.as_deref(), Some("base"));
4101 assert_eq!(c.max_depth, 2);
4102 let mut env2 = base_env();
4104 env2.push(("AGENTD_MODEL".into(), "from-env".into()));
4105 let c = Config::load(
4106 &args(&[
4107 "--config",
4108 base.path().to_str().unwrap(),
4109 "--config",
4110 over.path().to_str().unwrap(),
4111 "--limits.max-depth",
4112 "9",
4113 ]),
4114 &env2,
4115 )
4116 .unwrap();
4117 assert_eq!(c.model.as_deref(), Some("from-env"));
4118 assert_eq!(c.max_depth, 9);
4119 let e = Config::load(
4121 &args(&[
4122 "--config",
4123 base.path().to_str().unwrap(),
4124 "--config",
4125 "/no/such/overlay.yaml",
4126 ]),
4127 &base_env(),
4128 )
4129 .unwrap_err();
4130 assert!(format!("{e}").contains("/no/such/overlay.yaml"), "{e}");
4131 let paths = Config::config_paths_from(
4132 &args(&["--config", "b.yaml"]),
4133 &[("AGENT_CONFIG".into(), "x.yaml::y.json:".into())],
4134 );
4135 assert_eq!(paths, vec!["x.yaml", "y.json", "b.yaml"]);
4136 }
4137
4138 #[test]
4139 fn setting_a_path_replaces_the_value_while_named_flags_add() {
4140 let file = write_tmp_ext(
4144 "subscribe: [a://file]
4145mcp_servers:
4146 - name: web
4147 endpoint: https://web.example/mcp
4148intelligence_headers:
4149 keep: me
4150 x-team: file
4151",
4152 "yaml",
4153 );
4154 let path = file.path().to_str().unwrap().to_string();
4155 let mut env = base_env();
4157 env.push(("AGENTD_SUBSCRIBE".into(), "a://env".into()));
4158 let c = Config::load(&args(&["--config", &path]), &env).unwrap();
4159 assert_eq!(c.subscribe, vec!["a://env"]);
4160 let c = Config::load(&args(&["--config", &path, "--subscribe", "a://flag"]), &env).unwrap();
4162 assert_eq!(c.subscribe, vec!["a://env", "a://flag"]);
4163 let c = Config::load(
4165 &args(&[
4166 "--config",
4167 &path,
4168 "--mcp-servers",
4169 "[{name: q, endpoint: https://q.example/mcp}]",
4170 "--mcp",
4171 "x=https://x.example/mcp",
4172 ]),
4173 &base_env(),
4174 )
4175 .unwrap();
4176 let names: Vec<&str> = c.mcp_servers.iter().map(|s| s.name.as_str()).collect();
4177 assert_eq!(names, vec!["q", "x"]);
4178 let c = Config::load(
4180 &args(&["--config", &path, "--intelligence_headers.x-team", "ops"]),
4181 &base_env(),
4182 )
4183 .unwrap();
4184 assert_eq!(
4185 c.intelligence_headers.get("x-team").map(String::as_str),
4186 Some("ops")
4187 );
4188 assert_eq!(
4189 c.intelligence_headers.get("keep").map(String::as_str),
4190 Some("me")
4191 );
4192 let c = Config::load(
4194 &args(&["--config", &path, "--intelligence-headers", "{only: this}"]),
4195 &base_env(),
4196 )
4197 .unwrap();
4198 assert_eq!(c.intelligence_headers.len(), 1);
4199 assert_eq!(
4200 c.intelligence_headers.get("only").map(String::as_str),
4201 Some("this")
4202 );
4203 let e = Config::load(&args(&["--mcp-servers.0.aauth", "true"]), &base_env()).unwrap_err();
4205 assert!(format!("{e}").contains("array elements"), "{e}");
4206 }
4207
4208 #[test]
4209 fn help_lists_every_config_path_with_flag_and_env() {
4210 let h = match Config::load(&args(&["--help"]), &[]) {
4211 Err(ConfigError::Help(h)) => h,
4212 other => panic!("expected help, got {other:?}"),
4213 };
4214 assert!(h.contains("CONFIG PATHS"), "{h}");
4215 assert!(h.contains("limits.max_steps"), "{h}");
4216 assert!(h.contains("--limits-max-steps"), "{h}");
4217 assert!(h.contains("AGENTD_LIMITS_MAX_STEPS"), "{h}");
4218 assert!(h.contains("YAML or JSON"), "{h}");
4219 }
4220
4221 #[test]
4222 fn reload_re_reads_a_yaml_file() {
4223 let file = write_tmp_ext("model: v1\nlimits:\n max_steps: 10\n", "yaml");
4226 let path = file.path().to_str().unwrap().to_string();
4227 let a = args(&["--config", &path, "--max-depth", "2"]);
4228 let env = base_env();
4229 let running = Config::load(&a, &env).unwrap();
4230 assert_eq!(running.model.as_deref(), Some("v1"));
4231 assert_eq!(running.max_steps, 10);
4232 std::fs::write(
4233 &path,
4234 "model: v2\nlimits:\n max_steps: 20\n max_depth: 7\n",
4235 )
4236 .unwrap();
4237 let reloaded = Config::reload(&a, &env).unwrap();
4238 assert_eq!(reloaded.model.as_deref(), Some("v2"));
4239 assert_eq!(reloaded.max_steps, 20);
4240 assert_eq!(
4241 reloaded.max_depth, 2,
4242 "the flag still overrides the new file"
4243 );
4244 std::fs::write(&path, "model: [unterminated\n").unwrap();
4246 assert!(matches!(
4247 Config::reload(&a, &env),
4248 Err(ConfigError::Usage(_))
4249 ));
4250 }
4251
4252 #[test]
4253 fn flag_mcp_and_subscribe_add_to_the_file_list() {
4254 let file = write_tmp(
4257 r#"{ "mcp_servers": [{ "name": "web", "endpoint": "https://web.example.com/mcp" }],
4258 "subscribe": ["fs:file:///a"] }"#,
4259 );
4260 let c = Config::load(
4261 &args(&[
4262 "--config",
4263 file.path().to_str().unwrap(),
4264 "--mcp",
4265 "fs=https://fs.example",
4266 "--subscribe",
4267 "fs:file:///b",
4268 ]),
4269 &base_env(),
4270 )
4271 .unwrap();
4272 let names: Vec<&str> = c.mcp_servers.iter().map(|s| s.name.as_str()).collect();
4273 assert_eq!(names, vec!["web", "fs"]); assert_eq!(c.subscribe, vec!["fs:file:///a", "fs:file:///b"]);
4275 }
4276
4277 #[test]
4278 fn config_via_env_alias() {
4279 let file = write_tmp(r#"{ "model": "env-config" }"#);
4280 let mut env = base_env();
4281 env.push(("AGENTD_CONFIG".into(), file.path().to_str().unwrap().into()));
4282 let c = Config::load(&args(&[]), &env).unwrap();
4283 assert_eq!(c.model.as_deref(), Some("env-config"));
4284 }
4285
4286 #[test]
4287 fn malformed_config_file_is_usage_error() {
4288 let file = write_tmp("{ this is not json ");
4289 let e = Config::load(
4290 &args(&["--config", file.path().to_str().unwrap()]),
4291 &base_env(),
4292 )
4293 .unwrap_err();
4294 assert!(matches!(e, ConfigError::Usage(_)));
4295 }
4296
4297 #[test]
4298 fn unreadable_config_file_is_usage_error() {
4299 let e =
4300 Config::load(&args(&["--config", "/no/such/config.json"]), &base_env()).unwrap_err();
4301 assert!(matches!(e, ConfigError::Usage(_)));
4302 }
4303
4304 #[test]
4305 fn config_file_unknown_key_is_usage_error() {
4306 let file = write_tmp(r#"{ "max_token": 5 }"#);
4308 let e = Config::load(
4309 &args(&["--config", file.path().to_str().unwrap()]),
4310 &base_env(),
4311 )
4312 .unwrap_err();
4313 assert!(matches!(e, ConfigError::Usage(_)));
4314 }
4315
4316 #[cfg(not(feature = "config-watch"))]
4322 #[test]
4323 fn watch_config_requires_config_watch_feature() {
4324 let file = write_tmp(r#"{ "model": "m" }"#);
4325 let e = Config::load(
4326 &args(&["--config", file.path().to_str().unwrap(), "--watch-config"]),
4327 &base_env(),
4328 )
4329 .unwrap_err();
4330 match e {
4331 ConfigError::Usage(msg) => assert!(
4332 msg.contains("--watch-config requires the 'config-watch' build feature"),
4333 "got: {msg}"
4334 ),
4335 other => panic!("expected Usage, got {other:?}"),
4336 }
4337 }
4338
4339 #[cfg(feature = "config-watch")]
4342 #[test]
4343 fn watch_config_parses_with_a_config_file() {
4344 let file = write_tmp(r#"{ "model": "m" }"#);
4345 let c = Config::load(
4346 &args(&["--config", file.path().to_str().unwrap(), "--watch-config"]),
4347 &base_env(),
4348 )
4349 .unwrap();
4350 assert!(c.watch_config);
4351 }
4352
4353 #[cfg(feature = "config-watch")]
4355 #[test]
4356 fn watch_config_parses_from_env() {
4357 let file = write_tmp(r#"{ "model": "m" }"#);
4358 let mut env = base_env();
4359 env.push(("AGENTD_CONFIG".into(), file.path().to_str().unwrap().into()));
4360 env.push(("AGENTD_WATCH_CONFIG".into(), "true".into()));
4361 let c = Config::load(&args(&[]), &env).unwrap();
4362 assert!(c.watch_config);
4363 }
4364
4365 #[cfg(feature = "config-watch")]
4369 #[test]
4370 fn watch_config_requires_a_config_file() {
4371 let e = Config::load(&args(&["--watch-config"]), &base_env()).unwrap_err();
4372 match e {
4373 ConfigError::Usage(msg) => assert!(
4374 msg.contains("--watch-config requires a config file"),
4375 "got: {msg}"
4376 ),
4377 other => panic!("expected Usage, got {other:?}"),
4378 }
4379 }
4380
4381 #[cfg(feature = "config-watch")]
4384 #[test]
4385 fn validate_config_flags_watch_config_without_a_file() {
4386 let v = validate_verdict(&["--validate-config", "--watch-config"], &base_env());
4387 let lines = v.expect_err("watch-config without a file is invalid");
4388 assert!(
4389 lines.contains("--watch-config requires a config file"),
4390 "got: {lines}"
4391 );
4392 }
4393
4394 fn validate_verdict(args_: &[&str], env: &[(String, String)]) -> Result<String, String> {
4397 match Config::load(&args(args_), env).unwrap_err() {
4398 ConfigError::Validate(v) => v,
4399 other => panic!("expected Validate, got {other:?}"),
4400 }
4401 }
4402
4403 #[test]
4404 fn validate_config_valid_returns_ok_with_no_instruction_needed() {
4405 let v = validate_verdict(&["--validate-config"], &base_env());
4408 let line = v.expect("a complete config validates");
4409 assert!(line.contains("config.valid"));
4410 let _: serde_json::Value = serde_json::from_str(&line).unwrap();
4411 }
4412
4413 #[test]
4414 fn validate_config_invalid_returns_err_exit2_shape() {
4415 let v = validate_verdict(&["--validate-config", "--mode", "reactive"], &base_env());
4417 let lines = v.unwrap_err();
4418 assert!(lines.contains("config.invalid"));
4419 for line in lines.lines() {
4421 let _: serde_json::Value = serde_json::from_str(line).unwrap();
4422 }
4423 }
4424
4425 #[test]
4426 fn validate_config_refuses_a_trifecta_only_config_exit2() {
4427 let v = validate_verdict(
4432 &[
4433 "--validate-config",
4434 "--mcp",
4435 "s=https://s.example",
4436 "--mcp-tags",
4437 "s=untrusted_input,sensitive,egress",
4438 ],
4439 &base_env(),
4440 );
4441 let lines = v.expect_err("a trifecta-only config must be invalid");
4442 assert!(lines.contains("config.invalid"), "got: {lines}");
4443 assert!(lines.contains("lethal-trifecta"), "got: {lines}");
4444 for line in lines.lines() {
4445 let _: serde_json::Value = serde_json::from_str(line).unwrap();
4446 }
4447 }
4448
4449 #[test]
4450 fn validate_config_and_startup_agree_on_trifecta() {
4451 let trifecta = [
4454 "--mcp",
4455 "s=https://s.example",
4456 "--mcp-tags",
4457 "s=untrusted_input,sensitive,egress",
4458 ];
4459 let startup = Config::load(&args(&trifecta), &base_env()).unwrap_err();
4461 assert!(matches!(startup, ConfigError::Usage(_)));
4462 let mut allowed = vec!["--allow-trifecta"];
4464 allowed.extend_from_slice(&trifecta);
4465 assert!(Config::load(&args(&allowed), &base_env()).is_ok());
4466 let mut allowed_vc = vec!["--validate-config", "--allow-trifecta"];
4467 allowed_vc.extend_from_slice(&trifecta);
4468 assert!(validate_verdict(&allowed_vc, &base_env()).is_ok());
4469 }
4470
4471 #[test]
4472 fn validate_config_runs_without_an_instruction() {
4473 let env = vec![("AGENTD_INTELLIGENCE".into(), "https://intel.example".into())];
4477 let v = match Config::load(&args(&["--validate-config"]), &env).unwrap_err() {
4478 ConfigError::Validate(v) => v,
4479 other => panic!("expected Validate, got {other:?}"),
4480 };
4481 let lines = v.unwrap_err();
4482 assert!(lines.contains("config.invalid"));
4483 assert!(lines.contains("instruction"));
4484 }
4485
4486 #[test]
4487 fn validate_config_rejects_bad_intelligence_scheme() {
4488 let mut env = base_env();
4489 env.retain(|(k, _)| k != "AGENTD_INTELLIGENCE");
4490 let v = validate_verdict(&["--validate-config", "--intelligence", "ftp://nope"], &env);
4491 assert!(v.unwrap_err().contains("config.invalid"));
4492 }
4493
4494 #[test]
4497 fn config_schema_emits_parseable_json_schema() {
4498 let s = match Config::load(&args(&["--config-schema"]), &[]).unwrap_err() {
4499 ConfigError::Schema(s) => s,
4500 other => panic!("expected Schema, got {other:?}"),
4501 };
4502 let v: serde_json::Value = serde_json::from_str(&s).expect("schema is valid JSON");
4503 assert_eq!(
4504 v["$schema"],
4505 serde_json::json!("https://json-schema.org/draft/2020-12/schema")
4506 );
4507 assert!(v["properties"].is_object());
4508 }
4510
4511 #[test]
4514 fn intelligence_token_file_reads_and_trims() {
4515 let tok = write_tmp("file-token\n");
4516 let mut env = base_env();
4517 env.push((
4518 "AGENTD_INTELLIGENCE_TOKEN_FILE".into(),
4519 tok.path().to_str().unwrap().into(),
4520 ));
4521 let c = Config::load(&args(&[]), &env).unwrap();
4522 assert_eq!(c.intelligence_token.as_deref(), Some("file-token"));
4523 let dbg = format!("{c:?}");
4525 assert!(!dbg.contains("file-token"));
4526 assert!(dbg.contains("***"));
4527 }
4528
4529 #[test]
4530 fn inline_token_wins_over_token_file() {
4531 let tok = write_tmp("from-file\n");
4532 let mut env = base_env();
4533 env.push(("AGENTD_INTELLIGENCE_TOKEN".into(), "from-inline".into()));
4534 env.push((
4535 "AGENTD_INTELLIGENCE_TOKEN_FILE".into(),
4536 tok.path().to_str().unwrap().into(),
4537 ));
4538 let c = Config::load(&args(&[]), &env).unwrap();
4539 assert_eq!(c.intelligence_token.as_deref(), Some("from-inline"));
4540 }
4541
4542 #[test]
4543 #[cfg(feature = "aauth")]
4544 fn aauth_flags_and_validation() {
4545 let c = Config::load(
4547 &args(&[
4548 "--aauth-key-file",
4549 "/tmp/id.key",
4550 "--aauth-provider",
4551 "https://apd.example",
4552 "--aauth-enroll-token",
4553 "{{secret:ENROLL}}",
4554 "--aauth-enroll-assertion-file",
4555 "/var/run/secrets/aauth/token",
4556 "--aauth-person-server",
4557 "https://ps.example",
4558 ]),
4559 &base_env(),
4560 )
4561 .unwrap();
4562 let a = c.aauth.expect("aauth configured");
4563 assert_eq!(a.provider, "https://apd.example");
4564 assert_eq!(a.key_file, "/tmp/id.key");
4565 assert_eq!(a.enrollment_token.as_deref(), Some("{{secret:ENROLL}}"));
4566 assert_eq!(
4567 a.enroll_assertion_file.as_deref(),
4568 Some("/var/run/secrets/aauth/token")
4569 );
4570 assert_eq!(a.person_server.as_deref(), Some("https://ps.example"));
4571
4572 let mut env = base_env();
4574 env.push(("AGENT_AAUTH_PROVIDER".into(), "https://apd.example".into()));
4575 env.push((
4576 "AGENT_AAUTH_ENROLL_ASSERTION_FILE".into(),
4577 "/var/run/secrets/aauth/token".into(),
4578 ));
4579 let a = Config::load(&args(&[]), &env).unwrap().aauth.unwrap();
4580 assert_eq!(
4581 a.enroll_assertion_file.as_deref(),
4582 Some("/var/run/secrets/aauth/token")
4583 );
4584
4585 let mut env = base_env();
4587 env.push(("AGENT_AAUTH_PROVIDER".into(), "https://apd.example".into()));
4588 let c = Config::load(&args(&[]), &env).unwrap();
4589 assert_eq!(c.aauth.unwrap().key_file, "agent.key");
4590 assert!(Config::load(&args(&["--aauth-provider", "not-a-url"]), &base_env()).is_err());
4591 assert!(
4592 Config::load(
4593 &args(&[
4594 "--aauth-provider",
4595 "https://apd.example",
4596 "--aauth-person-server",
4597 "nope"
4598 ]),
4599 &base_env()
4600 )
4601 .is_err()
4602 );
4603 assert!(
4605 Config::load(&args(&["--aauth-key-file", "/x"]), &base_env())
4606 .unwrap()
4607 .aauth
4608 .is_none()
4609 );
4610 }
4611
4612 #[test]
4613 #[cfg(feature = "tls")]
4614 fn tls_ca_flag_env_and_content_validation() {
4615 let ca = write_tmp(include_str!("../../../net/tests/fixtures/ca.pem"));
4617 let ca_path = ca.path().to_str().unwrap().to_string();
4618
4619 let c = Config::load(&args(&["--tls-ca", &ca_path]), &base_env()).unwrap();
4621 assert_eq!(c.tls_ca.as_deref(), Some(ca_path.as_str()));
4622 assert!(format!("{c:?}").contains(&ca_path));
4624
4625 for key in ["AGENTD_TLS_CA", "AGENT_TLS_CA"] {
4627 let mut env = base_env();
4628 env.push((key.into(), ca_path.clone()));
4629 let c = Config::load(&args(&[]), &env).unwrap();
4630 assert_eq!(c.tls_ca.as_deref(), Some(ca_path.as_str()), "via {key}");
4631 }
4632
4633 let err = Config::load(&args(&["--tls-ca", "/nonexistent/ca.pem"]), &base_env());
4635 assert!(matches!(err, Err(ConfigError::Usage(_))));
4636
4637 let junk = write_tmp("not a pem");
4639 let err = Config::load(
4640 &args(&["--tls-ca", junk.path().to_str().unwrap()]),
4641 &base_env(),
4642 );
4643 assert!(matches!(err, Err(ConfigError::Usage(_))));
4644 }
4645
4646 #[test]
4647 fn token_file_flag_reads_via_cli() {
4648 let tok = write_tmp("flag-token");
4649 let c = Config::load(
4650 &args(&["--intelligence-token-file", tok.path().to_str().unwrap()]),
4651 &base_env(),
4652 )
4653 .unwrap();
4654 assert_eq!(c.intelligence_token.as_deref(), Some("flag-token"));
4655 }
4656
4657 #[test]
4658 fn missing_token_file_is_usage_error() {
4659 let mut env = base_env();
4660 env.push((
4661 "AGENTD_INTELLIGENCE_TOKEN_FILE".into(),
4662 "/no/such/token".into(),
4663 ));
4664 let e = Config::load(&args(&[]), &env).unwrap_err();
4665 assert!(matches!(e, ConfigError::Usage(_)));
4666 }
4667
4668 #[test]
4669 fn secret_file_ref_resolves_and_does_not_leak() {
4670 let secret = write_tmp("RESOLVED-SECRET-VALUE\n");
4674 let path = secret.path().to_str().unwrap().to_string();
4675 let file = write_tmp(&format!(
4676 r#"{{ "intelligence_headers": {{
4677 "authorization": "Bearer {{{{secret-file:{path}}}}}" }} }}"#
4678 ));
4679 let c = Config::load(
4680 &args(&["--config", file.path().to_str().unwrap()]),
4681 &base_env(),
4682 )
4683 .unwrap();
4684 assert_eq!(
4686 c.intelligence_headers
4687 .get("authorization")
4688 .map(String::as_str),
4689 Some(format!("Bearer {{{{secret-file:{path}}}}}").as_str())
4690 );
4691 let dbg = format!("{c:?}");
4693 assert!(!dbg.contains("RESOLVED-SECRET-VALUE"));
4694 let env = |_: &str| None;
4696 let resolved =
4697 crate::sec::secret::resolve(c.intelligence_headers.get("authorization").unwrap(), &env)
4698 .unwrap();
4699 assert_eq!(resolved, "Bearer RESOLVED-SECRET-VALUE");
4700 }
4701
4702 #[test]
4703 fn inline_secret_shaped_header_is_rejected() {
4704 let file = write_tmp(r#"{ "intelligence_headers": { "x-api-key": "sk-inline-literal" } }"#);
4707 let e = Config::load(
4708 &args(&["--config", file.path().to_str().unwrap()]),
4709 &base_env(),
4710 )
4711 .unwrap_err();
4712 assert!(matches!(e, ConfigError::Usage(_)));
4713 unsafe {
4718 std::env::set_var("AGENTD_TEST_HDR_KEY_0017", "k");
4719 }
4720 let file_ok = write_tmp(
4721 r#"{ "intelligence_headers": { "x-api-key": "{{secret:AGENTD_TEST_HDR_KEY_0017}}" } }"#,
4722 );
4723 let c = Config::load(
4724 &args(&["--config", file_ok.path().to_str().unwrap()]),
4725 &base_env(),
4726 )
4727 .unwrap();
4728 assert!(c.intelligence_headers.contains_key("x-api-key"));
4729 unsafe {
4730 std::env::remove_var("AGENTD_TEST_HDR_KEY_0017");
4731 }
4732 }
4733
4734 #[test]
4735 fn unresolvable_secret_ref_in_header_is_rejected_at_validation() {
4736 let file = write_tmp(
4738 r#"{ "intelligence_headers": { "x-api-key": "{{secret:DEFINITELY_UNSET_VAR_XYZ}}" } }"#,
4739 );
4740 let e = Config::load(
4741 &args(&["--config", file.path().to_str().unwrap()]),
4742 &base_env(),
4743 )
4744 .unwrap_err();
4745 assert!(matches!(e, ConfigError::Usage(_)));
4746 }
4747
4748 fn reactive_base() -> Config {
4752 Config::load(
4753 &args(&["--mode", "reactive", "--subscribe", "file:///in.json"]),
4754 &base_env(),
4755 )
4756 .unwrap()
4757 }
4758
4759 #[test]
4760 fn coherence_rejects_a_differing_restart_only_field() {
4761 let running = reactive_base();
4764 for mutate in [
4765 (|c: &mut Config| c.mode = Mode::Loop) as fn(&mut Config),
4766 |c: &mut Config| c.run_id = "different-run-id".into(),
4767 |c: &mut Config| c.serve_mcp = Some("https://a.example:8443".into()),
4768 |c: &mut Config| c.drain_timeout = Duration::from_secs(99),
4769 ] {
4770 let mut new = running.clone();
4771 mutate(&mut new);
4772 let diags = Config::reload_coherence_check(&new, Some(&running), true)
4773 .expect_err("a restart-only diff must be rejected");
4774 assert!(
4775 diags
4776 .iter()
4777 .any(|d| d.is_error() && d.msg.contains("restart-only")),
4778 "expected a restart-only error, got {diags:?}"
4779 );
4780 }
4781 }
4782
4783 #[test]
4784 fn coherence_accepts_a_reloadable_diff() {
4785 let running = reactive_base();
4789 for mutate in [
4790 (|c: &mut Config| c.log_level = Level::Debug) as fn(&mut Config),
4791 |c: &mut Config| c.model = Some("claude-opus-4".into()),
4792 |c: &mut Config| c.max_tokens = 999_999,
4793 |c: &mut Config| c.max_steps = 123,
4794 |c: &mut Config| c.subscribe = vec!["file:///in.json".into(), "file:///b.json".into()],
4795 |c: &mut Config| {
4797 c.mcp_servers = vec![McpServerSpec {
4798 name: "added".into(),
4799 endpoint: "unix:/mcp-new.sock".into(),
4800 ..Default::default()
4801 }]
4802 },
4803 |c: &mut Config| c.intelligence = Some("https://other.example".into()),
4805 |c: &mut Config| c.model_swap = SwapPolicy::RestartTurn,
4806 ] {
4807 let mut new = running.clone();
4808 mutate(&mut new);
4809 assert!(
4810 Config::reload_coherence_check(&new, Some(&running), true).is_ok(),
4811 "a reloadable diff must be accepted",
4812 );
4813 }
4814 }
4815
4816 #[test]
4817 fn mcp_servers_is_reloadable_not_restart_only() {
4818 assert!(
4822 !RESTART_ONLY_FIELDS.contains(&"mcp_servers"),
4823 "mcp_servers must NOT be restart-only (RFC 0017 §5.1)"
4824 );
4825 let running = reactive_base();
4826 let mut added = running.clone();
4828 added.mcp_servers.push(McpServerSpec {
4829 name: "extra".into(),
4830 endpoint: "unix:/mcp-extra.sock".into(),
4831 ..Default::default()
4832 });
4833 assert!(
4834 Config::reload_coherence_check(&added, Some(&running), true).is_ok(),
4835 "adding an MCP server must pass the coherence check (it is reloadable)"
4836 );
4837 let mut with_server = running.clone();
4839 with_server.mcp_servers = vec![McpServerSpec {
4840 name: "s".into(),
4841 endpoint: "unix:/mcp-orig.sock".into(),
4842 ..Default::default()
4843 }];
4844 let mut edited = with_server.clone();
4845 edited.mcp_servers[0].endpoint = "https://mcp-edited.example".into();
4846 assert!(
4847 Config::reload_coherence_check(&edited, Some(&with_server), true).is_ok(),
4848 "editing an MCP server must pass the coherence check (it is reloadable)"
4849 );
4850 }
4851
4852 #[test]
4853 fn model_swap_flag_and_env_parse_and_default() {
4854 let def = Config::load(&args(&[]), &base_env()).unwrap();
4857 assert_eq!(def.model_swap, SwapPolicy::FinishOnOld);
4858 let flag = Config::load(&args(&["--model-swap", "restart-turn"]), &base_env()).unwrap();
4859 assert_eq!(flag.model_swap, SwapPolicy::RestartTurn);
4860 let mut env = base_env();
4861 env.push(("AGENTD_MODEL_SWAP".into(), "restart-turn".into()));
4862 let e = Config::load(&args(&[]), &env).unwrap();
4863 assert_eq!(e.model_swap, SwapPolicy::RestartTurn);
4864 assert!(matches!(
4866 Config::load(&args(&["--model-swap", "nope"]), &base_env()),
4867 Err(ConfigError::Usage(_))
4868 ));
4869 }
4870
4871 #[test]
4872 fn intelligence_is_reloadable_not_restart_only() {
4873 assert!(
4876 !RESTART_ONLY_FIELDS.contains(&"intelligence"),
4877 "intelligence must NOT be restart-only (RFC 0018 §5.1)"
4878 );
4879 let running = reactive_base();
4880 let mut new = running.clone();
4881 new.intelligence = Some("https://gw-b.example:1234".into());
4882 assert!(
4883 Config::reload_coherence_check(&new, Some(&running), true).is_ok(),
4884 "an endpoint repoint must pass the coherence check (it is reloadable)"
4885 );
4886 }
4887
4888 #[test]
4889 fn coherence_rejects_duplicate_server_names() {
4890 let mut cfg = reactive_base();
4891 cfg.mcp_servers = vec![
4892 McpServerSpec {
4893 name: "dup".into(),
4894 endpoint: "unix:/a.sock".into(),
4895 ..Default::default()
4896 },
4897 McpServerSpec {
4898 name: "dup".into(),
4899 endpoint: "unix:/b.sock".into(),
4900 ..Default::default()
4901 },
4902 ];
4903 let diags = Config::reload_coherence_check(&cfg, None, false)
4904 .expect_err("duplicate server names must be an error");
4905 assert!(
4906 diags
4907 .iter()
4908 .any(|d| d.is_error() && d.msg.contains("duplicate"))
4909 );
4910 }
4911
4912 #[test]
4913 fn restart_only_set_pins_the_immutable_fields() {
4914 for &f in RESTART_ONLY_FIELDS {
4919 let mut a = reactive_base();
4920 let b = a.clone();
4921 match f {
4923 "mode" => a.mode = Mode::Loop,
4924 "run_id" => a.run_id = "x".into(),
4925 "serve_mcp" => a.serve_mcp = Some("https://s.example:8443".into()),
4926 "drain_timeout" => a.drain_timeout = Duration::from_secs(123),
4927 "continue_subscribe" => a.continue_subscribe = vec!["u".into()],
4928 other => panic!("RESTART_ONLY_FIELDS has an unmapped field '{other}'"),
4929 }
4930 assert!(
4931 a.restart_only_field_differs(&b, f),
4932 "restart-only field '{f}' must be diff-detected"
4933 );
4934 }
4935 }
4936
4937 #[test]
4938 fn effective_view_carries_no_secret_or_url() {
4939 const TOKEN: &str = "super-secret-effective-token";
4942 let mut env = base_env();
4943 env.push(("AGENTD_INTELLIGENCE_TOKEN".into(), TOKEN.into()));
4944 env.push((
4945 "AGENTD_INTELLIGENCE".into(),
4946 "https://user:embedded-cred@api.example/v1".into(),
4947 ));
4948 let mut cfg =
4949 Config::load(&args(&["--mcp", "vault=https://vault.example/mcp"]), &env).unwrap();
4950 cfg.intelligence_headers
4951 .insert("x-api-key".into(), "{{secret:SOME_NAME}}".into());
4952 let view = cfg.effective_view();
4953 let blob = serde_json::to_string(&view).unwrap();
4954 assert!(!blob.contains(TOKEN), "token leaked into effective view");
4955 assert!(!blob.contains("embedded-cred"), "URL creds leaked");
4956 assert!(!blob.contains("api.example"), "endpoint host leaked");
4957 assert!(!blob.contains("SOME_NAME"), "header ref value leaked");
4958 assert!(!blob.contains("vault-secret.sock"), "mcp endpoint leaked");
4959 assert_eq!(view["mcp_servers"][0]["name"], serde_json::json!("vault"));
4961 assert_eq!(
4962 view["intelligence_headers"],
4963 serde_json::json!(["x-api-key"])
4964 );
4965 }
4966}