pub mod directives;
pub mod envfile;
pub mod file;
pub mod paths;
pub mod prompt;
pub mod templates;
pub mod v2;
#[cfg(all(unix, feature = "config-watch"))]
pub mod watch;
pub mod yaml;
use crate::obs::log::Level;
use crate::sec::scope::TrifectaTag;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Once,
Loop,
Reactive,
Schedule,
#[cfg(feature = "workflow")]
Workflow,
}
impl Mode {
pub fn as_str(self) -> &'static str {
match self {
Mode::Once => "once",
Mode::Loop => "loop",
Mode::Reactive => "reactive",
Mode::Schedule => "schedule",
#[cfg(feature = "workflow")]
Mode::Workflow => "workflow",
}
}
pub fn parse(s: &str) -> Option<Mode> {
match s {
"once" => Some(Mode::Once),
"loop" => Some(Mode::Loop),
"reactive" => Some(Mode::Reactive),
"schedule" => Some(Mode::Schedule),
#[cfg(feature = "workflow")]
"workflow" => Some(Mode::Workflow),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SwapPolicy {
#[default]
FinishOnOld,
RestartTurn,
}
impl SwapPolicy {
pub fn as_str(self) -> &'static str {
match self {
SwapPolicy::FinishOnOld => "finish-on-old",
SwapPolicy::RestartTurn => "restart-turn",
}
}
pub fn parse(s: &str) -> Option<SwapPolicy> {
match s {
"finish-on-old" => Some(SwapPolicy::FinishOnOld),
"restart-turn" => Some(SwapPolicy::RestartTurn),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServeTarget {
Http { bind: String, tls: bool },
Unix { path: String },
}
impl ServeTarget {
pub fn parse(spec: &str) -> Result<ServeTarget, ConfigError> {
if let Some(tls) = spec
.strip_prefix("https://")
.map(|_| true)
.or_else(|| spec.strip_prefix("http://").map(|_| false))
{
let authority = spec.split("://").nth(1).unwrap_or("");
if authority.is_empty() || authority.contains('/') {
return Err(usage(format!(
"--serve-mcp: want http(s)://HOST:PORT with no path (got: {spec})"
)));
}
let host = serve_host_of(authority);
let port_ok = serve_port_of(authority).is_some();
if host.is_empty() || !port_ok {
return Err(usage(format!(
"a2a.listen: HTTP(S) target needs an explicit host:port (got: {spec})"
)));
}
if !tls && !crate::net::http::is_loopback_host(host) {
return Err(usage(format!(
"--serve-mcp: plaintext http:// is allowed for loopback only; use https:// (got: {spec})"
)));
}
return Ok(ServeTarget::Http {
bind: authority.to_string(),
tls,
});
}
if let Some(path) = spec
.strip_prefix("unix://")
.or_else(|| spec.strip_prefix("unix:"))
{
if path.is_empty() {
return Err(usage(format!("unix listener needs a socket path: {spec}")));
}
if !cfg!(unix) {
return Err(usage(format!(
"unix:// listeners are unix-only (got: {spec}); use https://"
)));
}
return Ok(ServeTarget::Unix {
path: path.to_string(),
});
}
Err(usage(format!(
"--serve-mcp: want https://host:port (or loopback http://host:port for dev): {spec}"
)))
}
}
impl Config {
fn validate_serve_auth(
&self,
target: &ServeTarget,
env: &dyn Fn(&str) -> Option<String>,
) -> Result<(), ConfigError> {
let ServeTarget::Http { bind, tls } = target else {
return Ok(());
};
let (bind, tls) = (bind.as_str(), *tls);
if tls {
match (&self.serve_cert, &self.serve_key) {
(Some(cert), Some(key)) => {
check_readable("--serve-cert", cert)?;
check_readable("--serve-key", key)?;
}
_ => {
return Err(usage(
"--serve-mcp https:// requires --serve-cert and --serve-key (PEM file paths)".into(),
));
}
}
} else if self.serve_cert.is_some() || self.serve_key.is_some() {
return Err(usage(
"--serve-cert/--serve-key need an https:// serve target (plaintext http:// is loopback dev only)".into(),
));
}
if let Some(ca) = &self.serve_client_ca {
check_readable("--serve-client-ca", ca)?;
}
if let Some(bearer) = &self.serve_bearer {
crate::sec::secret::refs_resolvable(bearer, env)
.map_err(|e| usage(format!("--serve-bearer: {e}")))?;
}
let loopback = crate::net::http::is_loopback_host(serve_host_of(bind));
if !loopback && self.serve_client_ca.is_none() && self.serve_bearer.is_none() {
return Err(usage(
"a non-loopback a2a.listen needs client auth: set a2a.tls.client_ca (mTLS) and/or a2a.bearer".into(),
));
}
Ok(())
}
}
fn check_readable(flag: &str, path: &str) -> Result<(), ConfigError> {
std::fs::File::open(path).map_err(|e| usage(format!("{flag}: cannot read {path}: {e}")))?;
Ok(())
}
pub(crate) fn serve_host_of(authority: &str) -> &str {
if let Some(rest) = authority.strip_prefix('[') {
return rest.split(']').next().unwrap_or(rest);
}
authority.rsplit_once(':').map_or(authority, |(h, _)| h)
}
fn serve_port_of(authority: &str) -> Option<u16> {
let port_str = if authority.starts_with('[') {
authority.rsplit_once("]:").map(|(_, p)| p)?
} else {
authority.rsplit_once(':').map(|(_, p)| p)?
};
port_str.parse::<u16>().ok().filter(|p| *p != 0)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct A2aPeerSpec {
pub name: String,
pub endpoint: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub headers: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_cert: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_key: Option<String>,
}
impl A2aPeerSpec {
pub fn endpoint_of(&self) -> Result<A2aEndpoint, String> {
A2aEndpoint::parse(&self.endpoint).map_err(|e| e.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum A2aEndpoint {
Https(String),
}
impl A2aEndpoint {
pub fn parse(spec: &str) -> Result<A2aEndpoint, ConfigError> {
if spec.starts_with("https://") {
return Ok(A2aEndpoint::Https(spec.to_string()));
}
if spec.starts_with("http://") {
let host = crate::net::http::Url::parse(spec)
.map(|u| u.host)
.unwrap_or_default();
if !crate::net::http::is_loopback_host(&host) {
return Err(usage(format!(
"--a2a-peer: plaintext http:// is allowed for loopback only; use https:// (got: {spec})"
)));
}
return Ok(A2aEndpoint::Https(spec.to_string()));
}
if let Some(path) = spec
.strip_prefix("unix://")
.or_else(|| spec.strip_prefix("unix:"))
{
if path.is_empty() || !cfg!(unix) {
return Err(usage(format!(
"--a2a-peer: unix: endpoint needs a socket path (unix-only): {spec}"
)));
}
return Ok(A2aEndpoint::Https(spec.to_string()));
}
Err(usage(format!(
"--a2a-peer: endpoint must be https://host[:port] (or loopback http:// for dev, or unix:///path for a co-located peer): {spec}"
)))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct McpServerSpec {
pub name: String,
pub endpoint: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub headers: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<TrifectaTag>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aauth: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth: Option<McpOauthSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<AuthSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpOauthSpec {
pub token_url: String,
pub client_id: String,
pub client_secret: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AuthSpec {
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grant: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device_authorization_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audience: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub header: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sso_start_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub svid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jwt_svid_file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub svid_file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_file: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AAuthSettings {
pub provider: String,
pub key_file: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enrollment_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enroll_assertion_file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub person_server: Option<String>,
}
pub fn is_mcp_endpoint(s: &str) -> bool {
let s = s.trim();
s.starts_with("https://") || s.starts_with("http://")
}
pub fn mcp_endpoint_scheme_ok(endpoint: &str) -> Result<(), ConfigError> {
let e = endpoint.trim();
if e.starts_with("https://") {
return Ok(());
}
if let Some(rest) = e.strip_prefix("http://") {
let host = rest.split('/').next().unwrap_or(rest);
let host = if host.starts_with('[') {
host.split(']').next().map_or(host, |h| &h[1..])
} else {
host.rsplit_once(':').map_or(host, |(h, _)| h)
};
if crate::net::http::is_loopback_host(host) {
return Ok(());
}
return Err(usage(format!(
"mcp endpoint plaintext http:// is allowed for loopback only; use https:// (got: {endpoint})"
)));
}
Err(usage(format!(
"mcp endpoint must be https://host[:port][/path] (got: {endpoint})"
)))
}
#[derive(Clone, PartialEq)]
pub struct Config {
pub instruction: Option<String>,
pub intelligence: Option<String>,
pub intelligence_token: Option<String>,
pub intelligence_token_file: Option<String>,
pub model: Option<String>,
pub model_swap: SwapPolicy,
pub mcp_servers: Vec<McpServerSpec>,
pub a2a_peers: Vec<A2aPeerSpec>,
pub mode: Mode,
pub subscribe: Vec<String>,
pub continue_subscribe: Vec<String>,
pub interval: Option<Duration>,
pub max_steps: u32,
pub max_tokens: u64,
pub budget_tokens_lifetime: u64,
pub deadline: Option<Duration>,
pub max_depth: u32,
pub run_id: String,
pub log_level: Level,
pub drain_timeout: Duration,
#[cfg(feature = "workflow")]
pub workflow_file: Option<String>,
#[cfg(feature = "workflow")]
pub workflow_resume: Option<crate::subagent::protocol::WorkflowResumeRef>,
pub serve_mcp: Option<String>,
pub serve_cert: Option<String>,
pub serve_key: Option<String>,
pub serve_client_ca: Option<String>,
pub serve_bearer: Option<String>,
pub tls_ca: Option<String>,
pub aauth: Option<AAuthSettings>,
pub health_file: Option<String>,
pub traceparent: Option<String>,
pub log_content: bool,
pub metrics_addr: Option<String>,
pub cgroup: Option<String>,
pub cgroup_memory_max: Option<String>,
pub cgroup_pids_max: Option<String>,
pub allow_trifecta: bool,
pub cron: Option<String>,
pub report_file: Option<String>,
pub budget_exit_code: Option<i32>,
pub events_ring: usize,
pub intelligence_headers: std::collections::BTreeMap<String, String>,
pub watch_config: bool,
pub config_files: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Config {
instruction: None,
intelligence: None,
intelligence_token: None,
intelligence_token_file: None,
model: None,
model_swap: SwapPolicy::FinishOnOld,
mcp_servers: Vec::new(),
a2a_peers: Vec::new(),
mode: Mode::Once,
subscribe: Vec::new(),
continue_subscribe: Vec::new(),
interval: None,
max_steps: 50,
max_tokens: 200_000,
budget_tokens_lifetime: 0,
deadline: Some(Duration::from_secs(600)),
max_depth: 4,
run_id: String::new(), log_level: Level::Info,
drain_timeout: Duration::from_secs(25),
#[cfg(feature = "workflow")]
workflow_file: None,
#[cfg(feature = "workflow")]
workflow_resume: None,
serve_mcp: None,
serve_cert: None,
serve_key: None,
serve_client_ca: None,
serve_bearer: None,
tls_ca: None,
aauth: None,
health_file: None,
traceparent: None,
log_content: false,
metrics_addr: None,
cgroup: None,
cgroup_memory_max: None,
cgroup_pids_max: None,
allow_trifecta: false,
cron: None,
report_file: None,
budget_exit_code: None,
events_ring: crate::obs::log::EVENTS_RING_DEFAULT,
intelligence_headers: std::collections::BTreeMap::new(),
watch_config: false,
config_files: Vec::new(),
}
}
}
impl fmt::Debug for Config {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Config")
.field("instruction", &self.instruction.as_deref().map(|_| "<set>"))
.field(
"intelligence",
&self
.intelligence
.as_deref()
.map(|u| format!("{}:<redacted>", u.split(':').next().unwrap_or(""))),
)
.field(
"intelligence_token",
&self.intelligence_token.as_ref().map(|_| "***"),
)
.field("intelligence_token_file", &self.intelligence_token_file)
.field("model", &self.model)
.field("model_swap", &self.model_swap.as_str())
.field("mcp_servers", &self.mcp_servers)
.field("a2a_peers", &self.a2a_peers)
.field("mode", &self.mode)
.field("subscribe", &self.subscribe)
.field("continue_subscribe", &self.continue_subscribe)
.field("interval", &self.interval)
.field("max_steps", &self.max_steps)
.field("max_tokens", &self.max_tokens)
.field("budget_tokens_lifetime", &self.budget_tokens_lifetime)
.field("deadline", &self.deadline)
.field("max_depth", &self.max_depth)
.field("run_id", &self.run_id)
.field("log_level", &self.log_level)
.field("drain_timeout", &self.drain_timeout)
.field("serve_mcp", &self.serve_mcp)
.field("serve_cert", &self.serve_cert)
.field("serve_key", &self.serve_key)
.field("serve_client_ca", &self.serve_client_ca)
.field(
"serve_bearer",
&self.serve_bearer.as_ref().map(|_| "<redacted>"),
)
.field("tls_ca", &self.tls_ca)
.field("health_file", &self.health_file)
.field("traceparent", &self.traceparent)
.field("log_content", &self.log_content)
.field("metrics_addr", &self.metrics_addr)
.field("cgroup", &self.cgroup)
.field("cgroup_memory_max", &self.cgroup_memory_max)
.field("cgroup_pids_max", &self.cgroup_pids_max)
.field("allow_trifecta", &self.allow_trifecta)
.field("cron", &self.cron)
.field("report_file", &self.report_file)
.field("events_ring", &self.events_ring)
.field(
"intelligence_headers",
&self.intelligence_headers.keys().collect::<Vec<_>>(),
)
.field("watch_config", &self.watch_config)
.field("config_files", &self.config_files)
.finish()
}
}
#[derive(Debug)]
pub enum ConfigError {
Help(String),
Version(String),
Capabilities(String),
Usage(String),
Schema(String),
Validate(Result<String, String>),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Help(s)
| ConfigError::Version(s)
| ConfigError::Capabilities(s)
| ConfigError::Schema(s) => {
write!(f, "{s}")
}
ConfigError::Usage(s) => write!(f, "{s}"),
ConfigError::Validate(Ok(s)) | ConfigError::Validate(Err(s)) => write!(f, "{s}"),
}
}
}
pub(crate) fn debrand_env(env: &[(String, String)]) -> Vec<(String, String)> {
let have: std::collections::HashSet<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
let mut out: Vec<(String, String)> = env.to_vec();
for (k, v) in env {
if let Some(suffix) = k.strip_prefix("AGENT_") {
let branded = format!("AGENTD_{suffix}");
if !have.contains(branded.as_str()) {
out.push((branded, v.clone()));
}
}
}
out
}
impl Config {
pub fn load(args: &[String], env: &[(String, String)]) -> Result<Config, ConfigError> {
let env = debrand_env(env);
let envmap: HashMap<&str, &str> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
if args.iter().any(|a| a == "--config-schema") {
let schema = crate::config::file::config_schema();
let json = serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string());
return Err(ConfigError::Schema(format!("{json}\n")));
}
let validate_config = args.iter().any(|a| a == "--validate-config");
let mut c = Config::default();
let config_paths = config_paths_from_map(args, &envmap).paths;
let file_present = !config_paths.is_empty();
if file_present {
let (doc, loaded) = file::read_documents(&config_paths).map_err(usage)?;
apply_document(&mut c, doc, "config file", false)?;
c.config_files = loaded.into_iter().map(|(p, _)| p).collect();
}
if let Some(v) = envmap
.get("AGENTD_INSTRUCTION")
.or_else(|| envmap.get("INSTRUCTION"))
{
c.instruction = Some((*v).to_string());
}
if let Some(v) = envmap
.get("AGENTD_INTELLIGENCE")
.or_else(|| envmap.get("INTELLIGENCE"))
{
c.intelligence = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_INTELLIGENCE_TOKEN") {
c.intelligence_token = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_INTELLIGENCE_TOKEN_FILE") {
c.intelligence_token_file = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_TLS_CA") {
c.tls_ca = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_MODEL") {
c.model = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_MODEL_SWAP") {
c.model_swap = SwapPolicy::parse(v).ok_or_else(|| {
usage(format!(
"invalid AGENTD_MODEL_SWAP: {v} (want finish-on-old|restart-turn)"
))
})?;
}
if let Some(v) = envmap.get("AGENTD_MODE") {
c.mode = Mode::parse(v).ok_or_else(|| usage(format!("invalid AGENTD_MODE: {v}")))?;
}
if let Some(v) = envmap.get("AGENTD_MAX_STEPS") {
c.max_steps = v
.parse()
.map_err(|_| usage(format!("invalid AGENTD_MAX_STEPS: {v}")))?;
}
if let Some(v) = envmap.get("AGENTD_MAX_TOKENS") {
c.max_tokens = v
.parse()
.map_err(|_| usage(format!("invalid AGENTD_MAX_TOKENS: {v}")))?;
}
if let Some(v) = envmap.get("AGENTD_BUDGET_TOKENS") {
c.budget_tokens_lifetime = v
.parse()
.map_err(|_| usage(format!("invalid AGENTD_BUDGET_TOKENS: {v}")))?;
}
if let Some(v) = envmap.get("AGENTD_DEADLINE") {
c.deadline = Some(parse_duration(v).map_err(usage)?);
}
if let Some(v) = envmap.get("AGENTD_RUN_ID") {
c.run_id = (*v).to_string();
}
if let Some(v) = envmap.get("AGENTD_LOG_LEVEL") {
c.log_level =
Level::parse(v).ok_or_else(|| usage(format!("invalid AGENTD_LOG_LEVEL: {v}")))?;
}
if let Some(v) = envmap.get("AGENTD_DRAIN_TIMEOUT") {
c.drain_timeout = parse_duration(v).map_err(usage)?;
}
if let Some(v) = envmap.get("AGENTD_LOG_CONTENT") {
c.log_content = truthy(v);
}
if let Some(v) = envmap.get("AGENTD_METRICS_ADDR") {
c.metrics_addr = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_CGROUP") {
c.cgroup = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_CGROUP_MEMORY_MAX") {
c.cgroup_memory_max = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_CGROUP_PIDS_MAX") {
c.cgroup_pids_max = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_ALLOW_TRIFECTA") {
c.allow_trifecta = truthy(v);
}
if let Some(v) = envmap.get("AGENTD_CRON") {
c.cron = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_REPORT_FILE") {
c.report_file = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_EVENTS_RING") {
c.events_ring = v
.parse()
.map_err(|_| usage(format!("invalid AGENTD_EVENTS_RING: {v}")))?;
}
#[cfg(feature = "workflow")]
if let Some(v) = envmap.get("AGENTD_WORKFLOW") {
c.workflow_file = Some((*v).to_string());
}
#[cfg(feature = "workflow")]
if let Some(v) = envmap.get("AGENTD_WORKFLOW_RESUME") {
c.workflow_resume = Some(parse_workflow_resume(v)?);
}
if let Some(v) = envmap.get("AGENTD_SERVE_MCP") {
c.serve_mcp = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_SERVE_CERT") {
c.serve_cert = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_SERVE_KEY") {
c.serve_key = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_SERVE_CLIENT_CA") {
c.serve_client_ca = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_SERVE_BEARER") {
c.serve_bearer = Some((*v).to_string());
}
if let Some(v) = envmap.get("AGENTD_WATCH_CONFIG") {
c.watch_config = truthy(v);
}
if let Some(v) = envmap.get("AGENTD_A2A_PEER") {
c.a2a_peers.push(parse_a2a_peer_spec(v)?);
}
if let Some(v) = envmap.get("AGENTD_TRACEPARENT") {
c.traceparent = Some((*v).to_string());
}
{
let (doc, applied) = paths::env_document(&envmap).map_err(usage)?;
if !applied.is_empty() {
apply_document(&mut c, doc, "env", true)?;
}
}
let mut mcp_tags: Vec<(String, Vec<TrifectaTag>)> = Vec::new();
let mut capabilities = false;
let mut aauth_provider: Option<String> = None;
let mut aauth_key_file: Option<String> = None;
let mut aauth_enroll_token: Option<String> = None;
let mut aauth_enroll_assertion_file: Option<String> = None;
let mut aauth_person_server: Option<String> = None;
let mut it = args.iter().peekable();
while let Some(arg) = it.next() {
let mut take = |name: &str| -> Result<String, ConfigError> {
it.next()
.cloned()
.ok_or_else(|| usage(format!("{name} requires a value")))
};
match arg.as_str() {
"-h" | "--help" => return Err(ConfigError::Help(help_text())),
"-V" | "--version" => {
return Err(ConfigError::Version(format!("agentd {}\n", crate::VERSION)));
}
"--capabilities" => capabilities = true,
"--config" | "-c" => {
let _ = take("--config")?;
}
a if matches!(config_flag(a), ConfigFlag::Inline(_)) => {}
"--config-schema" | "--validate-config" => {}
"--instruction" => c.instruction = Some(take("--instruction")?),
"--intelligence-token-file" => {
c.intelligence_token_file = Some(take("--intelligence-token-file")?)
}
"--instruction-file" => {
let p = take("--instruction-file")?;
c.instruction = Some(read_file(&p)?);
}
"--intelligence" => c.intelligence = Some(take("--intelligence")?),
"--intelligence-token" => {
c.intelligence_token = Some(take("--intelligence-token")?)
}
"--model" => c.model = Some(take("--model")?),
"--model-swap" => {
let v = take("--model-swap")?;
c.model_swap = SwapPolicy::parse(&v).ok_or_else(|| {
usage(format!(
"invalid --model-swap: {v} (want finish-on-old|restart-turn)"
))
})?;
}
"--mcp" => {
let spec = take("--mcp")?;
c.mcp_servers.push(parse_mcp_spec(&spec)?);
}
"--a2a-peer" => {
let spec = take("--a2a-peer")?;
c.a2a_peers.push(parse_a2a_peer_spec(&spec)?);
}
"--mode" => {
let v = take("--mode")?;
c.mode =
Mode::parse(&v).ok_or_else(|| usage(format!("invalid --mode: {v}")))?;
}
"--subscribe" => c.subscribe.push(take("--subscribe")?),
"--continue" => c.continue_subscribe.push(take("--continue")?),
"--interval" => {
c.interval = Some(parse_duration(&take("--interval")?).map_err(usage)?)
}
"--cron" => c.cron = Some(take("--cron")?),
"--max-steps" => {
let v = take("--max-steps")?;
c.max_steps = v
.parse()
.map_err(|_| usage(format!("invalid --max-steps: {v}")))?;
}
"--max-tokens" => {
let v = take("--max-tokens")?;
c.max_tokens = v
.parse()
.map_err(|_| usage(format!("invalid --max-tokens: {v}")))?;
}
"--budget-tokens-lifetime" => {
let v = take("--budget-tokens-lifetime")?;
c.budget_tokens_lifetime = v
.parse()
.map_err(|_| usage(format!("invalid --budget-tokens-lifetime: {v}")))?;
}
"--deadline" => {
c.deadline = Some(parse_duration(&take("--deadline")?).map_err(usage)?)
}
"--max-depth" => {
let v = take("--max-depth")?;
c.max_depth = v
.parse()
.map_err(|_| usage(format!("invalid --max-depth: {v}")))?;
}
"--run-id" => c.run_id = take("--run-id")?,
"--log-level" => {
let v = take("--log-level")?;
c.log_level = Level::parse(&v)
.ok_or_else(|| usage(format!("invalid --log-level: {v}")))?;
}
"--drain-timeout" => {
c.drain_timeout = parse_duration(&take("--drain-timeout")?).map_err(usage)?
}
"--log-content" => c.log_content = true,
"--allow-trifecta" => c.allow_trifecta = true,
"--mcp-tags" => mcp_tags.push(parse_mcp_tags(&take("--mcp-tags")?)?),
"--metrics-addr" => c.metrics_addr = Some(take("--metrics-addr")?),
"--cgroup" => c.cgroup = Some(take("--cgroup")?),
"--cgroup-memory-max" => c.cgroup_memory_max = Some(take("--cgroup-memory-max")?),
"--cgroup-pids-max" => c.cgroup_pids_max = Some(take("--cgroup-pids-max")?),
#[cfg(feature = "workflow")]
"--workflow" => c.workflow_file = Some(take("--workflow")?),
#[cfg(feature = "workflow")]
"--workflow-resume" => {
let force = c.workflow_resume.as_ref().is_some_and(|r| r.force);
let mut r = parse_workflow_resume(&take("--workflow-resume")?)?;
r.force = r.force || force;
c.workflow_resume = Some(r);
}
#[cfg(feature = "workflow")]
"--workflow-resume-force" => {
match c.workflow_resume.as_mut() {
Some(r) => r.force = true,
None => {
c.workflow_resume = Some(crate::subagent::protocol::WorkflowResumeRef {
server: String::new(),
key: String::new(),
seq: None,
force: true,
})
}
}
}
"--serve-mcp" => c.serve_mcp = Some(take("--serve-mcp")?),
"--serve-cert" => c.serve_cert = Some(take("--serve-cert")?),
"--serve-key" => c.serve_key = Some(take("--serve-key")?),
"--serve-client-ca" => c.serve_client_ca = Some(take("--serve-client-ca")?),
"--serve-bearer" => c.serve_bearer = Some(take("--serve-bearer")?),
"--tls-ca" => c.tls_ca = Some(take("--tls-ca")?),
"--aauth-provider" => aauth_provider = Some(take("--aauth-provider")?),
"--aauth-key-file" => aauth_key_file = Some(take("--aauth-key-file")?),
"--aauth-enroll-token" => aauth_enroll_token = Some(take("--aauth-enroll-token")?),
"--aauth-enroll-assertion-file" => {
aauth_enroll_assertion_file = Some(take("--aauth-enroll-assertion-file")?)
}
"--aauth-person-server" => {
aauth_person_server = Some(take("--aauth-person-server")?)
}
"--watch-config" => c.watch_config = true,
"--health-file" => c.health_file = Some(take("--health-file")?),
"--traceparent" => c.traceparent = Some(take("--traceparent")?),
"--report-file" => c.report_file = Some(take("--report-file")?),
"--budget-exit-code" => {
let v = take("--budget-exit-code")?;
let n: i32 = v
.parse()
.ok()
.filter(|n| (0..=255).contains(n))
.ok_or_else(|| {
usage(format!("invalid --budget-exit-code: {v} (want 0..=255)"))
})?;
c.budget_exit_code = Some(n);
}
"--events-ring" => {
let v = take("--events-ring")?;
c.events_ring = v
.parse()
.map_err(|_| usage(format!("invalid --events-ring: {v}")))?;
}
other => match paths::resolve_flag(other).map_err(usage)? {
Some(target) => {
let raw = if matches!(target.value_kind(), paths::Kind::Boolean)
&& !it.peek().is_some_and(|n| !n.starts_with("--"))
{
"true".to_string()
} else {
it.next()
.cloned()
.ok_or_else(|| usage(format!("{other} requires a value")))?
};
let value = paths::coerce(target.value_kind(), &raw)
.map_err(|e| usage(format!("invalid {other}: {e}")))?;
let replace = target.entry.is_none();
apply_document(&mut c, target.document(value), other, replace)?;
}
None => return Err(usage(format!("unknown argument: {other}"))),
},
}
}
let aauth_provider =
aauth_provider.or_else(|| envmap.get("AGENT_AAUTH_PROVIDER").map(|v| v.to_string()));
if let Some(provider) = aauth_provider {
c.aauth = Some(AAuthSettings {
provider,
key_file: aauth_key_file
.or_else(|| envmap.get("AGENT_AAUTH_KEY_FILE").map(|v| v.to_string()))
.unwrap_or_else(|| "agent.key".to_string()),
enrollment_token: aauth_enroll_token.or_else(|| {
envmap
.get("AGENT_AAUTH_ENROLL_TOKEN")
.map(|v| v.to_string())
}),
enroll_assertion_file: aauth_enroll_assertion_file.or_else(|| {
envmap
.get("AGENT_AAUTH_ENROLL_ASSERTION_FILE")
.map(|v| v.to_string())
}),
person_server: aauth_person_server.or_else(|| {
envmap
.get("AGENT_AAUTH_PERSON_SERVER")
.map(|v| v.to_string())
}),
});
}
for (name, tags) in mcp_tags {
match c.mcp_servers.iter_mut().find(|s| s.name == name) {
Some(s) => s.tags = tags,
None => {
return Err(usage(format!(
"--mcp-tags references unknown server '{name}'"
)));
}
}
}
if c.run_id.is_empty() {
c.run_id = generate_run_id();
}
if capabilities {
return Err(ConfigError::Capabilities(
"{\"note\":\"--capabilities is served by the agentd loader\"}\n".to_string(),
));
}
c.resolve_token_file()?;
if validate_config {
return Err(ConfigError::Validate(c.validate_collect_all(file_present)));
}
c.validate()?;
if c.watch_config && !file_present {
return Err(usage(
"--watch-config requires a config file (--config / AGENTD_CONFIG)".into(),
));
}
Ok(c)
}
pub fn config_paths_from(args: &[String], env: &[(String, String)]) -> Vec<String> {
let env = debrand_env(env);
let envmap: HashMap<&str, &str> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
config_paths_from_map(args, &envmap).paths
}
fn resolve_token_file(&mut self) -> Result<(), ConfigError> {
if self.intelligence_token.is_some() {
return Ok(()); }
if let Some(path) = self.intelligence_token_file.clone() {
let tok = crate::sec::secret::read_token_file(&path).map_err(usage)?;
self.intelligence_token = Some(tok);
}
Ok(())
}
fn validate_collect_all(&self, file_present: bool) -> Result<String, String> {
let mut diags: Vec<String> = Vec::new();
self.collect_header_diags(&mut diags);
if let Err(e) = self.validate() {
let msg = e.to_string();
if !diags.iter().any(|d| msg.ends_with(d.as_str())) {
diags.push(msg);
}
}
if self.watch_config && !file_present {
diags.push("--watch-config requires a config file (--config / AGENTD_CONFIG)".into());
}
match Config::reload_coherence_check(self, None, file_present) {
Ok(()) => {}
Err(coh) => {
for d in coh.into_iter().filter(|d| d.is_error()) {
let line = format!("{}: {}", d.field, d.msg);
if !diags.iter().any(|existing| existing.ends_with(&d.msg)) {
diags.push(line);
}
}
}
}
if diags.is_empty() {
Ok(config_valid_line())
} else {
Err(diags
.into_iter()
.map(|d| config_invalid_line(&d))
.collect::<Vec<_>>()
.join("\n"))
}
}
fn collect_header_diags(&self, diags: &mut Vec<String>) {
let env = |k: &str| std::env::var(k).ok();
for (name, value) in &self.intelligence_headers {
if is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
diags.push(format!(
"intelligence_headers['{name}'] looks like a credential but has an inline value; \
use {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}} (never an inline secret)"
));
continue;
}
if crate::sec::secret::has_secret_ref(value)
&& let Err(e) = crate::sec::secret::refs_resolvable(value, &env)
{
diags.push(format!("intelligence_headers['{name}']: {e}"));
}
}
}
pub fn trifecta_grant_tags(&self) -> Vec<TrifectaTag> {
let mut tags = Vec::new();
for s in &self.mcp_servers {
if s.tags.is_empty() {
tags.push(TrifectaTag::UntrustedInput);
} else {
tags.extend(s.tags.iter().copied());
}
}
tags
}
pub fn validate(&self) -> Result<(), ConfigError> {
#[cfg(feature = "workflow")]
let needs_instruction = self.mode != Mode::Workflow
&& !(self.mode == Mode::Reactive
&& self.workflow_file.is_some()
&& self.subscribe.is_empty()
&& self.continue_subscribe.is_empty());
#[cfg(not(feature = "workflow"))]
let needs_instruction = true;
if needs_instruction
&& self
.instruction
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
{
return Err(usage(
"missing instruction (INSTRUCTION env or --instruction)".into(),
));
}
if self.intelligence.as_deref().unwrap_or("").is_empty() {
return Err(usage(
"missing intelligence endpoint (AGENTD_INTELLIGENCE or --intelligence)".into(),
));
}
validate_intelligence_uri(self.intelligence.as_deref().unwrap())?;
validate_endpoint_token_files(self.intelligence.as_deref().unwrap())?;
for s in &self.mcp_servers {
if s.name.is_empty() {
return Err(usage("mcp server has an empty name".into()));
}
if s.endpoint.trim().is_empty() {
return Err(usage(format!("mcp server '{}' has no endpoint", s.name)));
}
mcp_endpoint_scheme_ok(&s.endpoint)
.map_err(|e| usage(format!("mcp server '{}': {e}", s.name)))?;
::mcp::http::McpEndpoint::parse(&s.endpoint)
.map_err(|e| usage(format!("mcp server '{}': {e}", s.name)))?;
for (name, value) in &s.headers {
if is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
return Err(usage(format!(
"mcp server '{}' header '{name}' looks like a credential but has an inline value; use {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}}",
s.name
)));
}
}
crate::mcp::auth::headers_resolvable(&s.headers)
.map_err(|e| usage(format!("mcp server '{}' header: {e}", s.name)))?;
}
if self.max_steps == 0 {
return Err(usage("--max-steps must be > 0".into()));
}
if self.events_ring == 0 {
return Err(usage("--events-ring must be > 0".into()));
}
if self.watch_config && !cfg!(feature = "config-watch") {
return Err(usage(
"--watch-config requires the 'config-watch' build feature".into(),
));
}
{
#[cfg(feature = "workflow")]
let wait_driven = self.workflow_file.is_some();
#[cfg(not(feature = "workflow"))]
let wait_driven = false;
if self.mode == Mode::Reactive
&& self.subscribe.is_empty()
&& self.continue_subscribe.is_empty()
&& !wait_driven
{
return Err(usage(
"--mode reactive requires at least one --subscribe or --continue <uri> (or --workflow on a workflow build)".into(),
));
}
}
if !self.continue_subscribe.is_empty() && self.mode != Mode::Reactive {
return Err(usage(
"--continue is only valid with --mode reactive".into(),
));
}
if self.mode == Mode::Schedule && self.interval.is_none() && self.cron.is_none() {
return Err(usage(
"--mode schedule requires --interval <dur> or --cron <expr>".into(),
));
}
if self.cron.is_some() && self.mode != Mode::Schedule {
return Err(usage("--cron is only valid with --mode schedule".into()));
}
#[cfg(feature = "workflow")]
{
if self.mode == Mode::Workflow && self.workflow_file.is_none() {
return Err(usage("--mode workflow requires --workflow <file>".into()));
}
if let Some(r) = &self.workflow_resume {
if r.server.is_empty() {
return Err(usage(
"--workflow-resume-force requires --workflow-resume <server>:<key>[@seq]"
.into(),
));
}
if self.mode != Mode::Workflow {
return Err(usage(
"--workflow-resume is only valid with --mode workflow".into(),
));
}
if !self.mcp_servers.iter().any(|s| s.name == r.server) {
return Err(usage(format!(
"--workflow-resume names server '{}', which is not a configured --mcp server",
r.server
)));
}
}
if self.workflow_file.is_some()
&& self.mode != Mode::Workflow
&& self.mode != Mode::Reactive
{
return Err(usage(
"--workflow is only valid with --mode workflow or --mode reactive".into(),
));
}
}
if (self.cgroup_memory_max.is_some() || self.cgroup_pids_max.is_some())
&& self.cgroup.is_none()
{
return Err(usage(
"--cgroup-memory-max/--cgroup-pids-max require --cgroup".into(),
));
}
if self.cgroup_pids_max.as_deref().map(str::trim) == Some("0") {
return Err(usage(
"--cgroup-pids-max must be > 0 (it counts threads, not just processes) or 'max'"
.into(),
));
}
if self.cgroup_memory_max.as_deref().map(str::trim) == Some("0") {
return Err(usage("--cgroup-memory-max must be > 0 or 'max'".into()));
}
if let Some(a) = &self.aauth {
if !cfg!(feature = "aauth") {
return Err(usage(
"--aauth-provider needs a build with --features aauth".into(),
));
}
if crate::net::http::Url::parse(&a.provider).is_err() {
return Err(usage(format!(
"--aauth-provider must be an http(s) URL (got: {})",
a.provider
)));
}
if let Some(ps) = &a.person_server
&& crate::net::http::Url::parse(ps).is_err()
{
return Err(usage(format!(
"--aauth-person-server must be an http(s) URL (got: {ps})"
)));
}
}
if let Some(spec) = &self.serve_mcp {
let target = ServeTarget::parse(spec)?;
self.validate_serve_auth(&target, &|k: &str| std::env::var(k).ok())?;
} else if self.serve_cert.is_some()
|| self.serve_key.is_some()
|| self.serve_client_ca.is_some()
|| self.serve_bearer.is_some()
{
return Err(usage(
"a2a.tls.cert / a2a.tls.key / a2a.tls.client_ca / a2a.bearer require a2a.listen"
.into(),
));
}
if let Some(ca) = &self.tls_ca {
if !cfg!(feature = "tls") {
return Err(usage("--tls-ca requires the 'tls' build feature".into()));
}
check_readable("--tls-ca", ca)?;
#[cfg(feature = "tls")]
{
let pem =
std::fs::read(ca).map_err(|e| usage(format!("--tls-ca {ca}: read: {e}")))?;
crate::net::tls::validate_ca_pem(&pem)
.map_err(|e| usage(format!("--tls-ca {ca}: {e}")))?;
}
}
if !self.a2a_peers.is_empty() && !cfg!(feature = "a2a") {
return Err(usage("--a2a-peer requires the 'a2a' build feature".into()));
}
let mut seen = std::collections::HashSet::new();
for peer in &self.a2a_peers {
if peer.name.is_empty() || peer.endpoint.is_empty() {
return Err(usage(format!(
"--a2a-peer '{}' has an empty name or endpoint",
peer.name
)));
}
if !seen.insert(peer.name.as_str()) {
return Err(usage(format!(
"--a2a-peer name '{}' is declared more than once",
peer.name
)));
}
A2aEndpoint::parse(&peer.endpoint)?;
for (name, value) in &peer.headers {
if is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
return Err(usage(format!(
"a2a peer '{}' header '{name}' looks like a credential but has an inline value; use {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}}",
peer.name
)));
}
}
crate::mcp::auth::headers_resolvable(&peer.headers)
.map_err(|e| usage(format!("a2a peer '{}' header: {e}", peer.name)))?;
match (&peer.client_cert, &peer.client_key) {
(Some(_), None) | (None, Some(_)) => {
return Err(usage(format!(
"a2a peer '{}': client_cert and client_key must be set together",
peer.name
)));
}
(Some(cert), Some(key)) => {
for path in [cert, key] {
if let Err(e) = std::fs::metadata(path) {
return Err(usage(format!(
"a2a peer '{}': cannot read '{path}': {e}",
peer.name
)));
}
}
}
(None, None) => {}
}
}
let mut header_diags = Vec::new();
self.collect_header_diags(&mut header_diags);
if let Some(first) = header_diags.into_iter().next() {
return Err(usage(first));
}
if crate::sec::scope::check_trifecta(self.trifecta_grant_tags(), self.allow_trifecta)
.is_refused()
{
return Err(usage(
"refused — this grant gives one agent all three lethal-trifecta legs \
(untrusted input + sensitive data + egress). Split the capabilities across \
subagents, or relaunch with --allow-trifecta."
.into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diag {
pub field: String,
pub level: DiagLevel,
pub msg: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagLevel {
Warn,
Error,
}
impl Diag {
#[allow(dead_code)]
fn warn(field: &str, msg: impl Into<String>) -> Diag {
Diag {
field: field.to_string(),
level: DiagLevel::Warn,
msg: msg.into(),
}
}
fn error(field: &str, msg: impl Into<String>) -> Diag {
Diag {
field: field.to_string(),
level: DiagLevel::Error,
msg: msg.into(),
}
}
pub fn is_error(&self) -> bool {
self.level == DiagLevel::Error
}
pub fn level_str(&self) -> &'static str {
match self.level {
DiagLevel::Warn => "warn",
DiagLevel::Error => "error",
}
}
}
pub const RESTART_ONLY_FIELDS: &[&str] = &[
"mode",
"run_id", "serve_mcp", "drain_timeout", "continue_subscribe", ];
impl Config {
pub fn reload(args: &[String], env: &[(String, String)]) -> Result<Config, ConfigError> {
Config::load(args, env)
}
fn restart_only_file_warnings(&self, file_present: bool, _diags: &mut Vec<Diag>) {
let _ = file_present; }
pub fn reload_coherence_check(
new: &Config,
running: Option<&Config>,
file_present: bool,
) -> Result<(), Vec<Diag>> {
let mut diags = Vec::new();
new.restart_only_file_warnings(file_present, &mut diags);
if let Some(run) = running {
for &f in RESTART_ONLY_FIELDS {
if new.restart_only_field_differs(run, f) {
diags.push(Diag::error(
f,
format!(
"restart-only field '{f}' changed on a live reload; reload refused, \
a pod restart is required"
),
));
}
}
}
check_unique_server_names(new, &mut diags);
check_subscriptions_reference_declared_servers(new, &mut diags);
if diags.iter().any(Diag::is_error) {
Err(diags)
} else {
Ok(())
}
}
fn restart_only_field_differs(&self, running: &Config, field: &str) -> bool {
match field {
"mode" => self.mode != running.mode,
"run_id" => self.run_id != running.run_id,
"serve_mcp" => self.serve_mcp != running.serve_mcp,
"drain_timeout" => self.drain_timeout != running.drain_timeout,
"continue_subscribe" => self.continue_subscribe != running.continue_subscribe,
_ => false,
}
}
pub fn effective_view(&self) -> serde_json::Value {
serde_json::json!({
"model": self.model,
"swap_policy": self.model_swap.as_str(),
"max_tokens": self.max_tokens,
"limits": {
"max_steps": self.max_steps,
"max_depth": self.max_depth,
"deadline_secs": self.deadline.map(|d| d.as_secs()),
"lifetime_tokens": (self.budget_tokens_lifetime > 0)
.then_some(self.budget_tokens_lifetime),
},
"mcp_servers": self.mcp_servers.iter().map(|s| {
serde_json::json!({"name": s.name, "tags": s.tags})
}).collect::<Vec<_>>(),
"subscribe": self.subscribe,
"log_level": self.log_level.as_str(),
"intelligence_headers": self.intelligence_headers.keys().collect::<Vec<_>>(),
})
}
}
fn check_unique_server_names(cfg: &Config, diags: &mut Vec<Diag>) {
let mut seen = std::collections::HashSet::new();
for s in &cfg.mcp_servers {
if !seen.insert(s.name.as_str()) {
diags.push(Diag::error(
"mcp_servers",
format!("duplicate MCP server name '{}'", s.name),
));
}
}
}
fn check_subscriptions_reference_declared_servers(cfg: &Config, diags: &mut Vec<Diag>) {
let _ = (cfg, diags);
}
pub(crate) fn is_secret_shaped_key(name: &str) -> bool {
let n = name.to_ascii_lowercase();
n == "authorization"
|| n == "x-api-key"
|| n == "api-key"
|| n == "token"
|| n.ends_with("-token")
|| n.ends_with("_token")
|| n == "password"
|| n == "secret"
|| n.ends_with("-key")
|| n.ends_with("_key")
}
fn config_valid_line() -> String {
serde_json::json!({"event": "config.valid"}).to_string()
}
fn config_invalid_line(msg: &str) -> String {
serde_json::json!({"event": "config.invalid", "msg": msg}).to_string()
}
pub(crate) fn validate_intelligence_uri(uri: &str) -> Result<(), ConfigError> {
let elements: Vec<&str> = uri
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if elements.is_empty() {
return Err(usage(
"missing intelligence endpoint (AGENTD_INTELLIGENCE or --intelligence)".into(),
));
}
for el in elements {
validate_one_intelligence_uri(el)?;
}
Ok(())
}
fn validate_one_intelligence_uri(uri: &str) -> Result<(), ConfigError> {
if uri.starts_with("https://") {
return Ok(());
}
if uri.starts_with("mock:") {
#[cfg(any(feature = "internal-mocks", debug_assertions))]
return Ok(());
#[cfg(not(any(feature = "internal-mocks", debug_assertions)))]
return Err(usage(format!(
"mock: intelligence needs a build with --features internal-mocks (got: {uri})"
)));
}
if let Some(rest) = uri.strip_prefix("http://") {
let authority = rest.split('/').next().unwrap_or(rest);
let host = if authority.starts_with('[') {
authority.split(']').next().map_or(authority, |h| &h[1..])
} else {
authority.rsplit_once(':').map_or(authority, |(h, _)| h)
};
if crate::net::http::is_loopback_host(host) {
return Ok(());
}
return Err(usage(format!(
"plaintext http:// intelligence is allowed for loopback only (dev); use https:// (got: {uri})"
)));
}
Err(usage(format!(
"intelligence endpoint must be https://host[:port][/path] (got: {uri})"
)))
}
fn validate_endpoint_token_files(uri: &str) -> Result<(), ConfigError> {
let count = uri
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.count();
for idx in 0..count {
let (inline_var, file_var) = if idx == 0 {
(
"AGENTD_INTELLIGENCE_TOKEN".to_string(),
"AGENTD_INTELLIGENCE_TOKEN_FILE".to_string(),
)
} else {
let n = idx + 1;
(
format!("AGENTD_INTELLIGENCE_TOKEN_{n}"),
format!("AGENTD_INTELLIGENCE_TOKEN_{n}_FILE"),
)
};
if std::env::var(&inline_var).is_ok() {
continue;
}
if let Ok(path) = std::env::var(&file_var) {
crate::sec::secret::read_token_file(&path).map_err(usage)?;
}
}
Ok(())
}
fn parse_mcp_spec(spec: &str) -> Result<McpServerSpec, ConfigError> {
let (name, rhs) = spec
.split_once('=')
.ok_or_else(|| usage(format!("--mcp must be name=endpoint (got: {spec})")))?;
let endpoint = rhs.trim();
if name.is_empty() || endpoint.is_empty() {
return Err(usage(format!("--mcp '{spec}' has empty name or endpoint")));
}
if name == "code" {
return Err(usage(
"--mcp: the server name 'code' is reserved for code-registered tools".into(),
));
}
if !is_mcp_endpoint(endpoint) {
return Err(usage(format!(
"--mcp '{spec}': endpoint must be https://host[:port][/path] \
(loopback http:// for dev)"
)));
}
Ok(McpServerSpec {
name: name.to_string(),
endpoint: endpoint.to_string(),
..Default::default()
})
}
fn parse_a2a_peer_spec(spec: &str) -> Result<A2aPeerSpec, ConfigError> {
let (name, endpoint) = spec
.split_once('=')
.ok_or_else(|| usage(format!("--a2a-peer must be name=endpoint (got: {spec})")))?;
if name.is_empty() || endpoint.is_empty() {
return Err(usage(format!(
"--a2a-peer '{spec}' has an empty name or endpoint"
)));
}
Ok(A2aPeerSpec {
name: name.to_string(),
endpoint: endpoint.to_string(),
headers: Vec::new(),
client_cert: None,
client_key: None,
})
}
pub(crate) fn parse_mcp_tags(spec: &str) -> Result<(String, Vec<TrifectaTag>), ConfigError> {
let (name, list) = spec
.split_once('=')
.ok_or_else(|| usage(format!("--mcp-tags must be name=tag,tag (got: {spec})")))?;
if name.is_empty() {
return Err(usage(format!(
"--mcp-tags '{spec}' has an empty server name"
)));
}
let mut tags = Vec::new();
for t in list.split(',').map(str::trim).filter(|t| !t.is_empty()) {
let tag = TrifectaTag::parse(t).ok_or_else(|| {
usage(format!(
"unknown trifecta tag '{t}' (want: untrusted_input|sensitive|egress)"
))
})?;
tags.push(tag);
}
Ok((name.to_string(), tags))
}
pub(crate) fn read_file(path: &str) -> Result<String, ConfigError> {
std::fs::read_to_string(path)
.map_err(|e| usage(format!("cannot read instruction file {path}: {e}")))
}
pub(crate) enum ConfigFlag<'a> {
Separate,
Inline(&'a str),
No,
}
pub(crate) fn config_flag(arg: &str) -> ConfigFlag<'_> {
match arg {
"--config" | "-c" => ConfigFlag::Separate,
_ => match arg
.strip_prefix("--config=")
.or_else(|| arg.strip_prefix("-c="))
{
Some(v) => ConfigFlag::Inline(v),
None => ConfigFlag::No,
},
}
}
pub(crate) struct ConfigPaths {
pub paths: Vec<String>,
pub discovered: bool,
pub ambiguous: Option<String>,
}
pub(crate) fn config_paths_from_map(args: &[String], envmap: &HashMap<&str, &str>) -> ConfigPaths {
let mut paths: Vec<String> = envmap
.get("AGENTD_CONFIG")
.map(|v| {
v.split(':')
.map(str::trim)
.filter(|p| !p.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let mut it = args.iter();
while let Some(a) = it.next() {
match config_flag(a) {
ConfigFlag::Separate => {
if let Some(v) = it.next() {
paths.push(v.clone());
}
}
ConfigFlag::Inline(v) => paths.push(v.to_string()),
ConfigFlag::No => {}
}
}
let mut discovered = false;
let mut ambiguous = None;
if paths.is_empty() && !is_informational(args) {
match discovered_chain(Path::new("."), envmap) {
Ok(found) => {
discovered = !found.is_empty();
paths.extend(found);
}
Err(e) => ambiguous = Some(e),
}
}
ConfigPaths {
paths,
discovered,
ambiguous,
}
}
pub const USER_CONFIG_NAMES: [&str; 2] = ["config.yml", "config.yaml"];
pub const PROJECT_CONFIG_NAMES: [&str; 4] =
["agentd.yml", "agentd.yaml", ".agentd.yml", ".agentd.yaml"];
pub const LOCAL_CONFIG_NAMES: [&str; 2] = ["agentd.local.yml", "agentd.local.yaml"];
pub fn user_config_dir(envmap: &HashMap<&str, &str>) -> Option<PathBuf> {
if let Some(x) = envmap.get("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
return Some(Path::new(x).join("agentd"));
}
envmap
.get("HOME")
.filter(|v| !v.is_empty())
.map(|h| Path::new(h).join(".config").join("agentd"))
}
pub fn present_in(dir: &Path, names: &[&str]) -> Vec<String> {
names
.iter()
.map(|n| dir.join(n))
.filter(|p| p.is_file())
.map(|p| p.to_string_lossy().into_owned())
.collect()
}
pub fn discovered_chain(cwd: &Path, envmap: &HashMap<&str, &str>) -> Result<Vec<String>, String> {
let mut out = Vec::new();
let mut rungs: Vec<(&str, PathBuf, &[&str])> = Vec::new();
if let Some(d) = user_config_dir(envmap) {
rungs.push(("user", d, &USER_CONFIG_NAMES));
}
rungs.push(("project", cwd.to_path_buf(), &PROJECT_CONFIG_NAMES));
rungs.push(("local", cwd.to_path_buf(), &LOCAL_CONFIG_NAMES));
for (label, dir, names) in rungs {
let found = present_in(&dir, names);
if found.len() > 1 {
return Err(format!(
"the {label} config is ambiguous: {} are both present; keep one (or name the file with --config)",
found.join(" and ")
));
}
out.extend(found);
}
Ok(out)
}
fn is_informational(args: &[String]) -> bool {
args.iter().any(|a| {
matches!(
a.as_str(),
"-h" | "--help"
| "-V"
| "--version"
| "--config-schema"
| "--config-schema=2"
| "--workflow-schema"
)
})
}
fn apply_document(
c: &mut Config,
doc: serde_json::Value,
source: &str,
replace_lists: bool,
) -> Result<(), ConfigError> {
let present: Vec<String> = doc
.as_object()
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
let cf = file::ConfigFile::from_document(doc, source).map_err(usage)?;
if replace_lists {
for key in &present {
match key.as_str() {
"mcp_servers" => c.mcp_servers.clear(),
"subscribe" => c.subscribe.clear(),
"a2a_peers" => c.a2a_peers.clear(),
"intelligence_headers" => c.intelligence_headers.clear(),
_ => {}
}
}
}
apply_config_file(c, cf, source)
}
fn apply_config_file(
c: &mut Config,
cf: file::ConfigFile,
source: &str,
) -> Result<(), ConfigError> {
if let Some(intelligence) = cf.intelligence {
c.intelligence = Some(intelligence);
}
if let Some(policy) = cf.model_swap {
c.model_swap = SwapPolicy::parse(&policy).ok_or_else(|| {
usage(format!(
"{source}: invalid model_swap: {policy} (want finish-on-old|restart-turn)"
))
})?;
}
if let Some(model) = cf.model {
c.model = Some(model);
}
if let Some(mt) = cf.max_tokens {
c.max_tokens = mt;
}
if let Some(limits) = cf.limits {
if let Some(s) = limits.max_steps {
c.max_steps = s;
}
if let Some(d) = limits.max_depth {
c.max_depth = d;
}
if let Some(secs) = limits.deadline_secs {
c.deadline = Some(Duration::from_secs(secs));
}
if let Some(lt) = limits.lifetime_tokens {
c.budget_tokens_lifetime = lt;
}
}
if let Some(level) = cf.log_level {
c.log_level = Level::parse(&level)
.ok_or_else(|| usage(format!("{source}: invalid log_level: {level}")))?;
}
for s in cf.mcp_servers {
if s.name.is_empty() {
return Err(usage(format!("{source}: an mcp server has an empty name")));
}
let endpoint = match s.endpoint {
Some(ep) if !ep.trim().is_empty() => ep,
_ => {
return Err(usage(format!(
"{source}: mcp server '{}' has no endpoint \
(an MCP server is always a remote endpoint)",
s.name
)));
}
};
let headers = s.headers.into_iter().collect::<Vec<(String, String)>>();
let mut tags: Vec<TrifectaTag> = Vec::new();
for tag_list in s.tags.values() {
for t in tag_list {
let tag = TrifectaTag::parse(t).ok_or_else(|| {
usage(format!(
"{source}: mcp server '{}' has unknown trifecta tag '{t}' \
(want: untrusted_input|sensitive|egress)",
s.name
))
})?;
if !tags.contains(&tag) {
tags.push(tag);
}
}
}
c.mcp_servers.push(McpServerSpec {
name: s.name,
endpoint,
headers,
tags,
aauth: s.aauth,
oauth: None,
auth: None,
service: None,
rate: None,
});
}
c.subscribe.extend(cf.subscribe);
for p in cf.a2a_peers {
if p.name.is_empty() || p.endpoint.is_empty() {
return Err(usage(format!(
"{source}: a2a peer '{}' has an empty name or endpoint",
p.name
)));
}
c.a2a_peers.push(A2aPeerSpec {
name: p.name,
endpoint: p.endpoint,
headers: p.headers.into_iter().collect(),
client_cert: p.client_cert,
client_key: p.client_key,
});
}
c.intelligence_headers.extend(cf.intelligence_headers);
Ok(())
}
#[cfg(feature = "workflow")]
fn parse_workflow_resume(
spec: &str,
) -> Result<crate::subagent::protocol::WorkflowResumeRef, ConfigError> {
let (server, rest) = spec.split_once(':').ok_or_else(|| {
usage(format!(
"--workflow-resume: want <server>:<key>[@seq] (got: {spec})"
))
})?;
let (key, seq) = match rest.rsplit_once('@') {
Some((k, s)) => {
let seq: u64 = s
.parse()
.map_err(|_| usage(format!("--workflow-resume: bad @seq {s:?} (want a number)")))?;
(k, Some(seq))
}
None => (rest, None),
};
if server.trim().is_empty() || key.trim().is_empty() {
return Err(usage(format!(
"--workflow-resume: server and key must be non-empty (got: {spec})"
)));
}
Ok(crate::subagent::protocol::WorkflowResumeRef {
server: server.to_string(),
key: key.to_string(),
seq,
force: false,
})
}
pub(crate) fn usage(msg: String) -> ConfigError {
ConfigError::Usage(format!("agentd: {msg}"))
}
pub(crate) fn truthy(v: &str) -> bool {
matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")
}
pub fn parse_duration(s: &str) -> Result<Duration, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty duration".into());
}
let (num, unit): (&str, &str) = match s.find(|c: char| c.is_ascii_alphabetic()) {
Some(i) => (&s[..i], &s[i..]),
None => (s, "s"),
};
let n: u64 = num.parse().map_err(|_| format!("invalid duration: {s}"))?;
let d = match unit {
"ms" => Duration::from_millis(n),
"s" => Duration::from_secs(n),
"m" => Duration::from_secs(n * 60),
"h" => Duration::from_secs(n * 3600),
"d" => Duration::from_secs(n * 86_400),
"w" => Duration::from_secs(n * 604_800),
other => return Err(format!("unknown duration unit '{other}' in {s}")),
};
Ok(d)
}
fn generate_run_id() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let pid = std::process::id();
format!("{millis:011x}{pid:04x}")
}
fn help_text() -> String {
format!(
"agentd {ver} — a minimal, MCP-native, reactive agent\n\
\n\
USAGE:\n\
\x20 agentd --instruction <TEXT> --intelligence <URI> [--mcp name=endpoint ...] [options]\n\
\n\
REQUIRED:\n\
\x20 --instruction <TEXT> the task (or INSTRUCTION / AGENT_INSTRUCTION env)\n\
\x20 --instruction-file <PATH> read the instruction from a file\n\
\x20 --intelligence <URI> https://host[:port][/path] (comma-list = failover order; http:// loopback-only for dev; or INTELLIGENCE / AGENT_INTELLIGENCE env)\n\
\n\
INTELLIGENCE:\n\
\x20 --intelligence-token <T> bearer/key (or AGENT_INTELLIGENCE_TOKEN)\n\
\x20 --intelligence-token-file <PATH> read the token from a mounted file (rotation; or AGENT_INTELLIGENCE_TOKEN_FILE)\n\
\x20 --model <NAME> model id (or AGENT_MODEL)\n\
\x20 --model-swap <finish-on-old|restart-turn> in-flight model-change policy (default finish-on-old; or AGENT_MODEL_SWAP)\n\
\n\
TOOLS / MCP:\n\
\x20 --mcp name=endpoint declare a remote MCP server (repeatable; https://host[:port][/path])\n\
\x20 --tls-ca <PATH> extra PEM CA(s) trusted for outbound https (private/in-cluster PKI; added to the bundled roots)\n\
\x20 --aauth-provider <URL> [DRAFT] Agent Provider — sign every MCP request with an Ed25519 agent identity (needs --features aauth; or AGENT_AAUTH_PROVIDER)\n\
\x20 --aauth-key-file <PATH> durable Ed25519 key file (created 0600 if absent; default agent.key; or AGENT_AAUTH_KEY_FILE)\n\
\x20 --aauth-enroll-token <T> one-time enrollment token ({{secret:…}}; provider `token` mode; or AGENT_AAUTH_ENROLL_TOKEN)\n\
\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\
\x20 --aauth-person-server <URL> [DRAFT] Person Server for user-scoped identity (Case C; or AGENT_AAUTH_PERSON_SERVER)\n\
\x20 --serve-mcp <TARGET> serve agentd's own MCP over HTTP(S): https://host:port (or loopback http:// for dev)\n\
\x20 --a2a-peer name=<ENDPOINT> declare a remote A2A delegation peer: https://host[:port] (repeatable; needs --features a2a)\n\
\x20 --mcp-tags name=t,t capability tags: untrusted_input|sensitive|egress\n\
\x20 --allow-trifecta permit all three capability legs in one agent\n\
\n\
MODE / TRIGGERS:\n\
\x20 --mode once|loop|reactive|schedule|workflow (default once)\n\
\x20 --workflow <FILE> pinned workflow JSON, driven by --mode workflow (needs --features workflow; or AGENT_WORKFLOW)\n\
\x20 --workflow-resume <REF> resume from a checkpoint: <server>:<key>[@seq] (needs --mode workflow; or AGENT_WORKFLOW_RESUME)\n\
\x20 --workflow-resume-force override the workflow-hash check (graph-edit-and-continue)\n\
\x20 --subscribe <uri> subscribe to an MCP resource (repeatable)\n\
\x20 --continue <uri> subscribe, routed to one warm session (repeatable)\n\
\x20 --interval <dur> loop/schedule interval (e.g. 5m)\n\
\x20 --cron <5-field> schedule on a UTC cron expr (needs --features cron)\n\
\n\
LIMITS:\n\
\x20 --max-steps <N> per-run step cap (default 50)\n\
\x20 --max-tokens <N> per-run token budget (default 200000)\n\
\x20 --budget-tokens-lifetime <N> per-INSTANCE cumulative token cap across all runs/reactions (0/unset = unbounded; or AGENT_BUDGET_TOKENS)\n\
\x20 --deadline <dur> wall-clock deadline (default 600s)\n\
\x20 --max-depth <N> subagent tree depth cap (default 4)\n\
\n\
RUNTIME:\n\
\x20 --run-id <ID> idempotency key (or AGENT_RUN_ID)\n\
\x20 --log-level <L> trace|debug|info|warn|error (default info)\n\
\x20 --log-content log tool args/results, not just lengths (opt-in)\n\
\x20 --drain-timeout <dur> graceful drain budget (default 25s; < pod grace)\n\
\x20 --health-file <PATH> liveness heartbeat file\n\
\x20 --metrics-addr <host:port> serve /metrics+/healthz+/readyz (`:port` = all IPv4 ifaces; needs --features metrics)\n\
\x20 --cgroup <auto|PATH> per-run cgroup for atomic cgroup.kill teardown (best-effort)\n\
\x20 --cgroup-memory-max <SIZE> per-run memory.max (max|512M|2G|bytes; needs --cgroup + delegation)\n\
\x20 --cgroup-pids-max <N> per-run pids.max (max|count of THREADS; needs --cgroup + delegation)\n\
\x20 --traceparent <W3C> continue an upstream trace (or AGENT_TRACEPARENT)\n\
\x20 --report-file <PATH> write the run-outcome report at terminal (atomic; inert for reactive)\n\
\x20 --budget-exit-code <N> remap the policy budget codes (3/7 only) to N at process exit (0..=255)\n\
\x20 --events-ring <N> agent://events ring size (default 1024; needs --serve-mcp + --features events)\n\
\x20 --capabilities print the capabilities manifest (JSON) and exit\n\
\n\
CONFIG FILE:\n\
\x20 --config <PATH> load a config file, YAML or JSON; repeatable — later files override earlier ones (or AGENT_CONFIG=a.yaml:b.yaml)\n\
\x20 --validate-config load+validate (file+env+flags), print the verdict, exit 0/2\n\
\x20 --config-schema print the config-file JSON Schema and exit\n\
\x20 --watch-config reload on config-file change via inotify (needs --config + --features config-watch; or AGENT_WATCH_CONFIG)\n\
\x20 -h, --help / -V, --version\n\
\n\
{paths}",
ver = crate::VERSION,
paths = paths::help_section(),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn args(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn config_flag_accepts_short_and_inline_spellings() {
let env: Vec<(String, String)> = vec![];
for spelling in [
args(&["--config", "a.yaml"]),
args(&["-c", "a.yaml"]),
args(&["--config=a.yaml"]),
args(&["-c=a.yaml"]),
] {
assert_eq!(
Config::config_paths_from(&spelling, &env),
vec!["a.yaml".to_string()],
"spelling {spelling:?}"
);
}
assert_eq!(
Config::config_paths_from(&args(&["-c", "base.yaml", "--config=over.yaml"]), &env),
vec!["base.yaml".to_string(), "over.yaml".to_string()]
);
assert_eq!(
Config::config_paths_from(
&args(&["-c=flag.yaml"]),
&[("AGENTD_CONFIG".into(), "env.yaml".into())]
),
vec!["env.yaml".to_string(), "flag.yaml".to_string()]
);
assert!(Config::config_paths_from(&args(&["-c"]), &env).is_empty());
assert!(Config::config_paths_from(&args(&["--cluster-shard", "a"]), &env).is_empty());
}
#[test]
fn the_discovery_chain_layers_user_then_project_then_local() {
let root = std::env::temp_dir().join(format!("agentd-chain-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (home, cwd) = (root.join("home"), root.join("work"));
std::fs::create_dir_all(home.join(".config").join("agentd")).unwrap();
std::fs::create_dir_all(&cwd).unwrap();
let home_s = home.to_string_lossy().into_owned();
let envmap: HashMap<&str, &str> = [("HOME", home_s.as_str())].into_iter().collect();
assert!(discovered_chain(&cwd, &envmap).unwrap().is_empty());
std::fs::write(home.join(".config/agentd/config.yml"), "a: 1\n").unwrap();
std::fs::write(cwd.join("agentd.yml"), "b: 2\n").unwrap();
std::fs::write(cwd.join("agentd.local.yml"), "c: 3\n").unwrap();
let chain = discovered_chain(&cwd, &envmap).unwrap();
assert_eq!(chain.len(), 3, "{chain:?}");
assert!(chain[0].ends_with("config.yml"), "{chain:?}");
assert!(chain[1].ends_with("agentd.yml"), "{chain:?}");
assert!(chain[2].ends_with("agentd.local.yml"), "{chain:?}");
std::fs::write(cwd.join("agentd.yaml"), "b: 9\n").unwrap();
let e = discovered_chain(&cwd, &envmap).unwrap_err();
assert!(e.contains("project config is ambiguous"), "{e}");
std::fs::remove_file(cwd.join("agentd.yaml")).unwrap();
let xdg = root.join("xdg");
std::fs::create_dir_all(xdg.join("agentd")).unwrap();
std::fs::write(xdg.join("agentd/config.yml"), "a: 7\n").unwrap();
let xdg_s = xdg.to_string_lossy().into_owned();
let envmap2: HashMap<&str, &str> = [
("HOME", home_s.as_str()),
("XDG_CONFIG_HOME", xdg_s.as_str()),
]
.into_iter()
.collect();
let chain = discovered_chain(&cwd, &envmap2).unwrap();
assert!(chain[0].starts_with(&xdg_s), "{chain:?}");
let bare: HashMap<&str, &str> = HashMap::new();
assert_eq!(discovered_chain(&cwd, &bare).unwrap().len(), 2);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_dotfile_is_discovered_only_when_nothing_else_named_a_config() {
let dir = std::env::temp_dir().join(format!("agentd-discover-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(present_in(&dir, &PROJECT_CONFIG_NAMES).is_empty());
std::fs::write(dir.join(".agentd.yml"), "config_version: \"1\"\n").unwrap();
let found = present_in(&dir, &PROJECT_CONFIG_NAMES);
assert_eq!(found.len(), 1);
assert!(found[0].ends_with(".agentd.yml"), "{found:?}");
std::fs::write(dir.join(".agentd.yaml"), "config_version: \"1\"\n").unwrap();
assert_eq!(present_in(&dir, &PROJECT_CONFIG_NAMES).len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn help_and_version_do_not_discover_a_config() {
for a in [
"--help",
"-h",
"--version",
"-V",
"--config-schema",
"--workflow-schema",
] {
assert!(is_informational(&args(&[a])), "{a} should be informational");
}
assert!(!is_informational(&args(&["--validate-config"])));
assert!(!is_informational(&args(&[])));
}
#[test]
fn flags_override_env() {
let env = vec![
("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
("INSTRUCTION".into(), "from-env".into()),
];
let c = Config::load(&args(&["--instruction", "from-flag"]), &env).unwrap();
assert_eq!(c.instruction.as_deref(), Some("from-flag"));
assert_eq!(c.intelligence.as_deref(), Some("https://intel.example"));
}
#[cfg(feature = "workflow")]
#[test]
fn workflow_mode_and_workflow_file_are_inseparable() {
let intel_only = vec![(
"AGENTD_INTELLIGENCE".to_string(),
"https://intel.example".to_string(),
)];
let e = Config::load(
&args(&["--mode", "workflow", "--instruction", "x"]),
&intel_only,
)
.unwrap_err();
assert!(
format!("{e}").contains("--mode workflow requires --workflow"),
"{e}"
);
let e = Config::load(&args(&["--workflow", "/tmp/g.json"]), &base_env()).unwrap_err();
assert!(format!("{e}").contains("--workflow is only valid"), "{e}");
}
#[cfg(feature = "workflow")]
#[test]
fn a_reactive_workflow_daemon_needs_no_subscribe_or_instruction() {
let c = Config::load(
&args(&["--mode", "reactive", "--workflow", "/tmp/wf.json"]),
&base_env(),
)
.unwrap();
assert_eq!(c.mode, Mode::Reactive);
assert_eq!(c.workflow_file.as_deref(), Some("/tmp/wf.json"));
let e = Config::load(&args(&["--mode", "reactive"]), &base_env()).unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
let e = Config::load(
&args(&[
"--mode",
"loop",
"--interval",
"5m",
"--workflow",
"/tmp/wf.json",
"--instruction",
"x",
]),
&base_env(),
)
.unwrap_err();
assert!(format!("{e}").contains("--workflow is only valid"), "{e}");
}
#[cfg(feature = "workflow")]
#[test]
fn a_reactive_workflow_with_subscriptions_still_requires_an_instruction() {
let intel_only = vec![(
"AGENTD_INTELLIGENCE".to_string(),
"https://intel.example".to_string(),
)];
let e = Config::load(
&args(&[
"--mode",
"reactive",
"--workflow",
"/tmp/wf.json",
"--subscribe",
"file:///inbox",
]),
&intel_only,
)
.unwrap_err();
assert!(format!("{e}").contains("missing instruction"), "{e}");
let c = Config::load(
&args(&["--mode", "reactive", "--workflow", "/tmp/wf.json"]),
&intel_only,
)
.unwrap();
assert!(c.instruction.as_deref().unwrap_or("").is_empty());
let c = Config::load(
&args(&[
"--mode",
"reactive",
"--workflow",
"/tmp/wf.json",
"--subscribe",
"file:///inbox",
"--instruction",
"triage it",
]),
&base_env(),
)
.unwrap();
assert_eq!(c.subscribe.len(), 1);
assert!(c.workflow_file.is_some());
}
#[cfg(feature = "workflow")]
#[test]
fn workflow_mode_does_not_require_an_instruction() {
let intel_only = vec![(
"AGENTD_INTELLIGENCE".to_string(),
"https://intel.example".to_string(),
)];
let c = Config::load(
&args(&["--mode", "workflow", "--workflow", "/tmp/g.json"]),
&intel_only,
)
.unwrap();
assert_eq!(c.mode, Mode::Workflow);
assert_eq!(c.workflow_file.as_deref(), Some("/tmp/g.json"));
assert!(c.instruction.as_deref().unwrap_or("").is_empty());
}
#[cfg(feature = "workflow")]
#[test]
fn workflow_resume_parses_and_validates() {
let intel_only = vec![(
"AGENTD_INTELLIGENCE".to_string(),
"https://intel.example".to_string(),
)];
let c = Config::load(
&args(&[
"--mode",
"workflow",
"--workflow",
"/tmp/g.json",
"--mcp",
"state=https://ckpt.internal/mcp",
"--workflow-resume",
"state:run/abc@17",
"--workflow-resume-force",
]),
&intel_only,
)
.unwrap();
let r = c.workflow_resume.expect("parsed");
assert_eq!(r.server, "state");
assert_eq!(r.key, "run/abc");
assert_eq!(r.seq, Some(17));
assert!(r.force);
let c = Config::load(
&args(&[
"--mode",
"workflow",
"--workflow",
"/tmp/g.json",
"--mcp",
"state=https://ckpt.internal/mcp",
"--workflow-resume-force",
"--workflow-resume",
"state:run/abc",
]),
&intel_only,
)
.unwrap();
assert!(c.workflow_resume.unwrap().force);
for bad in [
vec![
"--mode",
"workflow",
"--workflow",
"/g",
"--workflow-resume",
"nocolon",
],
vec![
"--mode",
"workflow",
"--workflow",
"/g",
"--workflow-resume-force",
],
vec!["--instruction", "x", "--workflow-resume", "s:k"],
vec![
"--mode",
"workflow",
"--workflow",
"/g",
"--workflow-resume",
"ghost:k",
],
] {
assert!(
Config::load(&args(&bad), &base_env()).is_err(),
"{bad:?} must be refused"
);
}
let mut env = intel_only.clone();
env.push(("AGENT_WORKFLOW_RESUME".into(), "state:run/xyz".into()));
let c = Config::load(
&args(&[
"--mode",
"workflow",
"--workflow",
"/g",
"--mcp",
"state=https://ckpt.internal/mcp",
]),
&env,
)
.unwrap();
assert_eq!(c.workflow_resume.unwrap().key, "run/xyz");
}
fn base_env() -> Vec<(String, String)> {
vec![
("INSTRUCTION".into(), "x".into()),
("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
]
}
#[test]
fn neutral_agent_env_prefix_is_accepted_as_an_alias() {
let env = vec![
("INSTRUCTION".into(), "x".into()),
(
"AGENT_INTELLIGENCE".into(),
"https://neutral.example".into(),
),
("AGENT_RUN_ID".into(), "run-neutral".into()),
("AGENT_MAX_STEPS".into(), "42".into()),
];
let c = Config::load(&args(&[]), &env).unwrap();
assert_eq!(c.intelligence.as_deref(), Some("https://neutral.example"));
assert_eq!(c.run_id, "run-neutral");
assert_eq!(c.max_steps, 42);
}
#[test]
fn branded_env_wins_over_neutral_on_conflict() {
let env = vec![
("INSTRUCTION".into(), "x".into()),
(
"AGENTD_INTELLIGENCE".into(),
"https://branded.example".into(),
),
(
"AGENT_INTELLIGENCE".into(),
"https://neutral.example".into(),
),
];
let c = Config::load(&args(&[]), &env).unwrap();
assert_eq!(c.intelligence.as_deref(), Some("https://branded.example"));
}
#[test]
fn bare_env_spellings_work_for_the_two_required_inputs() {
let env = vec![
("INSTRUCTION".into(), "x".into()),
("INTELLIGENCE".into(), "https://bare.example".into()),
];
let c = Config::load(&args(&[]), &env).unwrap();
assert_eq!(c.intelligence.as_deref(), Some("https://bare.example"));
}
#[test]
fn prefixed_env_wins_over_the_bare_spelling() {
let env = vec![
("INSTRUCTION".into(), "bare-task".into()),
("AGENT_INSTRUCTION".into(), "neutral-task".into()),
("INTELLIGENCE".into(), "https://bare.example".into()),
(
"AGENT_INTELLIGENCE".into(),
"https://neutral.example".into(),
),
];
let c = Config::load(&args(&[]), &env).unwrap();
assert_eq!(c.instruction.as_deref(), Some("neutral-task"));
assert_eq!(c.intelligence.as_deref(), Some("https://neutral.example"));
}
#[test]
fn debrand_env_synthesizes_branded_only_when_absent() {
let env = vec![
("AGENT_MODE".into(), "loop".into()),
("AGENTD_RUN_ID".into(), "kept".into()),
("AGENT_RUN_ID".into(), "ignored".into()),
("INSTRUCTION".into(), "x".into()),
];
let out = debrand_env(&env);
let get = |k: &str| {
out.iter()
.filter(|(n, _)| n == k)
.map(|(_, v)| v.as_str())
.collect::<Vec<_>>()
};
assert_eq!(get("AGENTD_MODE"), vec!["loop"]);
assert_eq!(get("AGENTD_RUN_ID"), vec!["kept"]);
assert_eq!(get("INSTRUCTION"), vec!["x"]);
}
#[test]
fn report_file_and_events_ring_parse_from_flag_and_env() {
let c = Config::load(&args(&[]), &base_env()).unwrap();
assert_eq!(c.report_file, None);
assert_eq!(c.events_ring, crate::obs::log::EVENTS_RING_DEFAULT);
let c = Config::load(
&args(&["--report-file", "/out/report.json", "--events-ring", "256"]),
&base_env(),
)
.unwrap();
assert_eq!(c.report_file.as_deref(), Some("/out/report.json"));
assert_eq!(c.events_ring, 256);
let mut env = base_env();
env.push(("AGENTD_REPORT_FILE".into(), "/env/report.json".into()));
env.push(("AGENTD_EVENTS_RING".into(), "64".into()));
let c = Config::load(&args(&["--events-ring", "512"]), &env).unwrap();
assert_eq!(c.report_file.as_deref(), Some("/env/report.json"));
assert_eq!(c.events_ring, 512);
}
#[test]
fn budget_exit_code_flag_parses_and_range_checks() {
let c = Config::load(&args(&[]), &base_env()).unwrap();
assert_eq!(c.budget_exit_code, None);
let c = Config::load(&args(&["--budget-exit-code", "0"]), &base_env()).unwrap();
assert_eq!(c.budget_exit_code, Some(0));
let c = Config::load(&args(&["--budget-exit-code", "42"]), &base_env()).unwrap();
assert_eq!(c.budget_exit_code, Some(42));
for bad in ["256", "-1", "nope"] {
let e = Config::load(&args(&["--budget-exit-code", bad]), &base_env()).unwrap_err();
assert!(
matches!(e, ConfigError::Usage(_)),
"{bad} must be a usage error"
);
}
}
#[test]
fn events_ring_zero_and_bad_value_are_usage_errors() {
let zero = Config::load(&args(&["--events-ring", "0"]), &base_env()).unwrap_err();
assert!(matches!(zero, ConfigError::Usage(_)));
let bad = Config::load(&args(&["--events-ring", "lots"]), &base_env()).unwrap_err();
assert!(matches!(bad, ConfigError::Usage(_)));
}
#[test]
fn mcp_tags_attach_to_their_server_order_independent() {
let c = Config::load(
&args(&[
"--mcp-tags",
"fs=sensitive,egress",
"--mcp",
"fs=https://fs.example",
]),
&base_env(),
)
.unwrap();
assert_eq!(
c.mcp_servers[0].tags,
vec![TrifectaTag::Sensitive, TrifectaTag::Egress]
);
}
#[test]
fn mcp_tags_unknown_server_or_tag_is_usage_error() {
let bad_server = Config::load(
&args(&[
"--mcp",
"fs=https://fs.example",
"--mcp-tags",
"ghost=egress",
]),
&base_env(),
)
.unwrap_err();
assert!(matches!(bad_server, ConfigError::Usage(_)));
let bad_tag = Config::load(
&args(&["--mcp", "fs=https://fs.example", "--mcp-tags", "fs=bogus"]),
&base_env(),
)
.unwrap_err();
assert!(matches!(bad_tag, ConfigError::Usage(_)));
}
#[test]
fn cgroup_limits_require_cgroup_and_reject_zero() {
let e = Config::load(&args(&["--cgroup-memory-max", "512M"]), &base_env()).unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
let e2 = Config::load(&args(&["--cgroup-pids-max", "64"]), &base_env()).unwrap_err();
assert!(matches!(e2, ConfigError::Usage(_)));
let c = Config::load(
&args(&[
"--cgroup",
"auto",
"--cgroup-memory-max",
"512M",
"--cgroup-pids-max",
"64",
]),
&base_env(),
)
.unwrap();
assert_eq!(c.cgroup_memory_max.as_deref(), Some("512M"));
assert_eq!(c.cgroup_pids_max.as_deref(), Some("64"));
let z = Config::load(
&args(&["--cgroup", "auto", "--cgroup-pids-max", "0"]),
&base_env(),
)
.unwrap_err();
assert!(matches!(z, ConfigError::Usage(_)));
let zm = Config::load(
&args(&["--cgroup", "auto", "--cgroup-memory-max", "0"]),
&base_env(),
)
.unwrap_err();
assert!(matches!(zm, ConfigError::Usage(_)));
}
#[test]
fn cron_requires_schedule_mode() {
let e = Config::load(
&args(&[
"--mode",
"reactive",
"--subscribe",
"x://y",
"--cron",
"* * * * *",
]),
&base_env(),
)
.unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
let c = Config::load(
&args(&["--mode", "schedule", "--cron", "0 9 * * 1-5"]),
&base_env(),
)
.unwrap();
assert_eq!(c.cron.as_deref(), Some("0 9 * * 1-5"));
let e2 = Config::load(&args(&["--mode", "schedule"]), &base_env()).unwrap_err();
assert!(matches!(e2, ConfigError::Usage(_)));
}
#[test]
fn trifecta_grant_tags_defaults_untagged_to_untrusted() {
let c = Config::load(&args(&["--mcp", "fs=https://fs.example"]), &base_env()).unwrap();
let tags = c.trifecta_grant_tags();
assert!(tags.contains(&TrifectaTag::UntrustedInput)); assert!(!tags.contains(&TrifectaTag::Sensitive)); }
#[test]
fn missing_instruction_is_usage_error() {
let env = vec![("AGENTD_INTELLIGENCE".into(), "https://intel.example".into())];
let e = Config::load(&[], &env).unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
}
#[test]
fn help_short_circuits() {
let e = Config::load(&args(&["--help"]), &[]).unwrap_err();
assert!(matches!(e, ConfigError::Help(_)));
}
#[test]
fn reactive_requires_subscribe() {
let env = vec![
("INSTRUCTION".into(), "x".into()),
("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
];
let e = Config::load(&args(&["--mode", "reactive"]), &env).unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
let c = Config::load(
&args(&["--mode", "reactive", "--subscribe", "file://a"]),
&env,
)
.unwrap();
assert_eq!(c.mode, Mode::Reactive);
}
#[test]
fn mcp_spec_parsing() {
let env = vec![
("INSTRUCTION".into(), "x".into()),
("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
];
let c = Config::load(&args(&["--mcp", "fs=https://mcp.example.com/mcp"]), &env).unwrap();
assert_eq!(c.mcp_servers.len(), 1);
assert_eq!(c.mcp_servers[0].name, "fs");
assert_eq!(c.mcp_servers[0].endpoint, "https://mcp.example.com/mcp");
}
#[test]
fn mcp_endpoint_spec_parsing() {
assert!(is_mcp_endpoint("https://mcp.example.com/mcp"));
assert!(is_mcp_endpoint("http://localhost:8080/mcp"));
assert!(!is_mcp_endpoint("unix:/run/mcp.sock"));
assert!(!is_mcp_endpoint("vsock:3:5000"));
assert!(!is_mcp_endpoint("mcp-server-fs --root /data"));
assert!(parse_mcp_spec("fs=mcp-server-fs --root /data").is_err());
assert!(parse_mcp_spec("fs=unix:/run/mcp.sock").is_err());
for ep in ["https://mcp.example.com/mcp", "http://127.0.0.1:8080/mcp"] {
let spec = parse_mcp_spec(&format!("fs={ep}")).unwrap();
assert_eq!(spec.name, "fs");
assert_eq!(spec.endpoint, ep);
}
}
#[test]
fn mcp_endpoint_scheme_gate_is_https_only() {
assert!(mcp_endpoint_scheme_ok("https://mcp.example/mcp").is_ok());
assert!(mcp_endpoint_scheme_ok("http://127.0.0.1:8080/mcp").is_ok());
for bad in [
"unix:/run/mcp.sock",
"vsock:3:5000",
"http://mcp.example:8080/mcp",
] {
assert!(
mcp_endpoint_scheme_ok(bad).is_err(),
"{bad} must be rejected"
);
}
}
#[test]
fn mcp_endpoint_is_required_and_validated() {
let env = vec![
("INSTRUCTION".into(), "x".into()),
("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
];
let mut c =
Config::load(&args(&["--mcp", "fs=https://mcp.example.com/mcp"]), &env).unwrap();
assert!(c.validate().is_ok());
c.mcp_servers[0].endpoint.clear();
assert!(c.validate().is_err(), "an empty endpoint must fail");
c.mcp_servers[0].endpoint = "ftp://nope/".into();
assert!(
c.validate().is_err(),
"an unsupported endpoint scheme must fail"
);
}
#[test]
fn duration_units() {
assert_eq!(parse_duration("600s").unwrap(), Duration::from_secs(600));
assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250));
assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
assert!(parse_duration("nope").is_err());
}
#[test]
fn invalid_intelligence_uri_rejected() {
let env = vec![("INSTRUCTION".into(), "x".into())];
let e = Config::load(&args(&["--intelligence", "ftp://x"]), &env).unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
}
#[test]
fn multi_endpoint_list_accepts_ordered_comma_list() {
let env = vec![("INSTRUCTION".into(), "x".into())];
let c = Config::load(
&args(&[
"--intelligence",
"https://a.example,https://b.example,https://c.example",
]),
&env,
)
.unwrap();
assert_eq!(
c.intelligence.as_deref(),
Some("https://a.example,https://b.example,https://c.example")
);
}
#[test]
fn multi_endpoint_bad_element_scheme_is_exit_2() {
let env = vec![("INSTRUCTION".into(), "x".into())];
let e = Config::load(
&args(&[
"--intelligence",
"https://a.example,ftp://nope,https://c.example",
]),
&env,
)
.unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
}
#[test]
fn empty_endpoint_list_is_exit_2() {
let env = vec![("INSTRUCTION".into(), "x".into())];
let e = Config::load(&args(&["--intelligence", " , , "]), &env).unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
}
#[test]
fn serve_target_http_parses() {
assert_eq!(
ServeTarget::parse("https://0.0.0.0:8443").unwrap(),
ServeTarget::Http {
bind: "0.0.0.0:8443".into(),
tls: true
}
);
assert_eq!(
ServeTarget::parse("http://127.0.0.1:9000").unwrap(),
ServeTarget::Http {
bind: "127.0.0.1:9000".into(),
tls: false
}
);
assert!(matches!(
ServeTarget::parse("http://[::1]:9000"),
Ok(ServeTarget::Http { tls: false, .. })
));
for bad in [
"http://10.0.0.5:9000",
"https://host:8443/mcp",
"https://host",
] {
assert!(
matches!(ServeTarget::parse(bad), Err(ConfigError::Usage(_))),
"{bad} must be rejected"
);
}
}
#[test]
fn serve_auth_gates_the_control_plane() {
let base = |extra: &[&str]| {
let mut a = vec!["--instruction", "x", "--intelligence", "https://i.example"];
a.extend_from_slice(extra);
let args: Vec<String> = a.iter().map(|s| s.to_string()).collect();
Config::load(&args, &[]).and_then(|c| c.validate().map(|_| c))
};
assert!(base(&["--serve-mcp", "https://0.0.0.0:8443"]).is_err());
assert!(base(&["--serve-mcp", "https://127.0.0.1:8443"]).is_err());
assert!(base(&["--serve-mcp", "http://127.0.0.1:9000"]).is_ok());
assert!(base(&["--serve-mcp", "unix:/x.sock", "--serve-bearer", "t"]).is_ok());
assert!(base(&["--serve-mcp", "http://127.0.0.1:9000", "--serve-cert", "/x"]).is_err());
assert!(base(&["--serve-bearer", "t"]).is_err());
}
#[test]
fn serve_target_rejects_unsupported_socket_schemes() {
assert!(matches!(
ServeTarget::parse("unix:/run/agentd.sock"),
Ok(ServeTarget::Unix { ref path }) if path == "/run/agentd.sock"
));
assert!(matches!(
ServeTarget::parse("unix:///run/agentd.sock"),
Ok(ServeTarget::Unix { ref path }) if path == "/run/agentd.sock"
));
for bad in ["vsock:5005", "vsock:2:5005", "tcp:1234"] {
assert!(
matches!(ServeTarget::parse(bad), Err(ConfigError::Usage(_))),
"{bad} must be a usage error"
);
}
}
#[test]
fn serve_mcp_validation_runs_at_load() {
let c = Config::load(
&args(&["--serve-mcp", "http://127.0.0.1:9000"]),
&base_env(),
)
.unwrap();
assert_eq!(c.serve_mcp.as_deref(), Some("http://127.0.0.1:9000"));
let e = Config::load(&args(&["--serve-mcp", "tcp:9000"]), &base_env()).unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
}
#[test]
fn a2a_peer_spec_parses_name_and_endpoint() {
let spec = parse_a2a_peer_spec("mesh=https://peer.example").unwrap();
assert_eq!(spec.name, "mesh");
assert_eq!(spec.endpoint, "https://peer.example");
assert!(matches!(
parse_a2a_peer_spec("noequals"),
Err(ConfigError::Usage(_))
));
assert!(matches!(
parse_a2a_peer_spec("=https://x"),
Err(ConfigError::Usage(_))
));
assert!(matches!(
parse_a2a_peer_spec("mesh="),
Err(ConfigError::Usage(_))
));
}
#[test]
fn a2a_endpoint_https_parses_and_gates_plaintext() {
assert_eq!(
A2aEndpoint::parse("https://peer.example:8443/a2a").unwrap(),
A2aEndpoint::Https("https://peer.example:8443/a2a".into())
);
assert!(matches!(
A2aEndpoint::parse("http://127.0.0.1:9000"),
Ok(A2aEndpoint::Https(_))
));
assert!(matches!(
A2aEndpoint::parse("http://peer.example:9000"),
Err(ConfigError::Usage(_))
));
}
#[cfg(feature = "a2a")]
#[test]
fn a2a_peer_flag_parses_and_validates_on_a2a_build() {
let c = Config::load(
&args(&["--a2a-peer", "mesh=https://peer.example:8443/a2a"]),
&base_env(),
)
.unwrap();
assert_eq!(c.a2a_peers.len(), 1);
assert_eq!(c.a2a_peers[0].name, "mesh");
assert_eq!(c.a2a_peers[0].endpoint, "https://peer.example:8443/a2a");
assert!(
Config::load(
&args(&["--a2a-peer", "mesh=unix:/run/peer.sock"]),
&base_env()
)
.is_ok()
);
for bad in [
"mesh=vsock:2:5005",
"mesh=http://peer.example:9000",
"mesh=tcp:9000",
] {
let e = Config::load(&args(&["--a2a-peer", bad]), &base_env()).unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)), "{bad} must be exit 2");
}
let dup = Config::load(
&args(&[
"--a2a-peer",
"mesh=https://a.example",
"--a2a-peer",
"mesh=https://b.example",
]),
&base_env(),
)
.unwrap_err();
assert!(matches!(dup, ConfigError::Usage(_)));
}
#[cfg(feature = "a2a")]
#[test]
fn a2a_peer_client_auth_is_validated_at_startup() {
let file = write_tmp(
r#"{ "a2a_peers": [{ "name": "mesh", "endpoint": "https://peer.example/a2a",
"headers": { "authorization": "Bearer sk-live-inline-oops" } }] }"#,
);
let e = Config::load(
&args(&["--config", file.path().to_str().unwrap()]),
&base_env(),
)
.unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)), "{e}");
assert!(format!("{e}").contains("a2a peer 'mesh' header"), "{e}");
let file = write_tmp(
r#"{ "a2a_peers": [{ "name": "mesh", "endpoint": "https://peer.example/a2a",
"headers": { "authorization": "Bearer {{secret:A2A_PEER_AUTH_TEST_TOKEN}}" } }] }"#,
);
unsafe { std::env::set_var("A2A_PEER_AUTH_TEST_TOKEN", "tok") };
let c = Config::load(
&args(&["--config", file.path().to_str().unwrap()]),
&base_env(),
)
.unwrap();
unsafe { std::env::remove_var("A2A_PEER_AUTH_TEST_TOKEN") };
assert_eq!(
c.a2a_peers[0].headers.len(),
1,
"template stored, not resolved"
);
assert!(
c.a2a_peers[0].headers[0].1.contains("{{secret:"),
"the SPEC keeps the template, never the material"
);
let file = write_tmp(
r#"{ "a2a_peers": [{ "name": "mesh", "endpoint": "https://peer.example/a2a",
"client_cert": "/tls/cert.pem" }] }"#,
);
let e = Config::load(
&args(&["--config", file.path().to_str().unwrap()]),
&base_env(),
)
.unwrap_err();
assert!(
format!("{e}").contains("client_cert and client_key must be set together"),
"{e}"
);
}
#[cfg(not(feature = "a2a"))]
#[test]
fn a2a_peer_requires_the_a2a_feature() {
let e = Config::load(
&args(&["--a2a-peer", "mesh=https://peer.example"]),
&base_env(),
)
.unwrap_err();
match e {
ConfigError::Usage(msg) => assert!(
msg.contains("--a2a-peer requires the 'a2a' build feature"),
"got: {msg}"
),
other => panic!("expected a Usage error, got {other:?}"),
}
}
#[test]
fn token_redacted_in_debug() {
let env = vec![
("INSTRUCTION".into(), "x".into()),
(
"AGENTD_INTELLIGENCE".into(),
"https://api.example/v1".into(),
),
("AGENTD_INTELLIGENCE_TOKEN".into(), "super-secret".into()),
];
let c = Config::load(&[], &env).unwrap();
let dbg = format!("{c:?}");
assert!(!dbg.contains("super-secret"));
assert!(dbg.contains("***"));
}
#[test]
fn debug_redacts_credential_bearing_intelligence_uri() {
let env = vec![
("INSTRUCTION".into(), "x".into()),
(
"AGENTD_INTELLIGENCE".into(),
"https://alice:hunter2@internal.example/v1".into(),
),
];
let c = Config::load(&[], &env).unwrap();
let dbg = format!("{c:?}");
assert!(!dbg.contains("hunter2"), "creds leaked: {dbg}");
assert!(!dbg.contains("internal.example"), "host leaked: {dbg}");
assert!(dbg.contains("https:<redacted>"), "scheme missing: {dbg}");
}
#[test]
fn help_text_lists_model_swap() {
let h = match Config::load(&args(&["--help"]), &[]).unwrap_err() {
ConfigError::Help(s) => s,
other => panic!("expected Help, got {other:?}"),
};
assert!(h.contains("--model-swap"), "help omits --model-swap");
assert!(h.contains("finish-on-old|restart-turn"));
}
use std::io::Write as _;
fn write_tmp(contents: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(contents.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn config_file_loads_mcp_subscribe_a2a_and_limits() {
let file = write_tmp(
r#"{
"model": "claude-from-file",
"max_tokens": 1234567,
"limits": { "max_steps": 77, "max_depth": 3, "deadline_secs": 120 },
"mcp_servers": [
{ "name": "web", "endpoint": "https://web.example.com/mcp",
"tags": { "*": ["untrusted_input"] } }
],
"subscribe": ["fs:file:///watch/inbox"]
}"#,
);
let c = Config::load(
&args(&["--config", file.path().to_str().unwrap()]),
&base_env(),
)
.unwrap();
assert_eq!(c.model.as_deref(), Some("claude-from-file"));
assert_eq!(c.max_tokens, 1_234_567);
assert_eq!(c.max_steps, 77);
assert_eq!(c.max_depth, 3);
assert_eq!(c.deadline, Some(Duration::from_secs(120)));
assert_eq!(c.mcp_servers.len(), 1);
assert_eq!(c.mcp_servers[0].name, "web");
assert_eq!(c.mcp_servers[0].endpoint, "https://web.example.com/mcp");
assert_eq!(c.mcp_servers[0].tags, vec![TrifectaTag::UntrustedInput]);
assert_eq!(c.subscribe, vec!["fs:file:///watch/inbox"]);
}
#[test]
fn budget_tokens_lifetime_parses_from_flag_env_and_file() {
assert_eq!(
Config::load(&args(&[]), &base_env())
.unwrap()
.budget_tokens_lifetime,
0
);
let c = Config::load(&args(&["--budget-tokens-lifetime", "2000000"]), &base_env()).unwrap();
assert_eq!(c.budget_tokens_lifetime, 2_000_000);
let mut env = base_env();
env.push(("AGENT_BUDGET_TOKENS".into(), "500000".into()));
assert_eq!(
Config::load(&args(&[]), &env)
.unwrap()
.budget_tokens_lifetime,
500_000
);
let file = write_tmp(r#"{ "model": "m", "limits": { "lifetime_tokens": 111 } }"#);
let path = file.path().to_str().unwrap().to_string();
assert_eq!(
Config::load(&args(&["--config", &path]), &base_env())
.unwrap()
.budget_tokens_lifetime,
111
);
assert_eq!(
Config::load(
&args(&["--config", &path, "--budget-tokens-lifetime", "222"]),
&base_env()
)
.unwrap()
.budget_tokens_lifetime,
222
);
}
#[test]
fn env_and_flag_override_file_per_precedence() {
let file = write_tmp(r#"{ "model": "from-file", "max_tokens": 100 }"#);
let mut env = base_env();
env.push(("AGENTD_MODEL".into(), "from-env".into()));
let c = Config::load(
&args(&[
"--config",
file.path().to_str().unwrap(),
"--max-tokens",
"999",
]),
&env,
)
.unwrap();
assert_eq!(c.model.as_deref(), Some("from-env")); assert_eq!(c.max_tokens, 999); let c2 = Config::load(
&args(&["--config", file.path().to_str().unwrap()]),
&base_env(),
)
.unwrap();
assert_eq!(c2.model.as_deref(), Some("from-file"));
assert_eq!(c2.max_tokens, 100);
}
fn write_tmp_ext(contents: &str, ext: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::Builder::new()
.suffix(&format!(".{ext}"))
.tempfile()
.unwrap();
f.write_all(contents.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn config_file_may_be_yaml() {
let yaml = write_tmp_ext(
r#"
# agentd config
model: claude-from-yaml
max_tokens: 1234567
limits:
max_steps: 77
max_depth: 3
deadline_secs: 120
mcp_servers:
- name: web
endpoint: https://web.example.com/mcp
tags:
"*": [untrusted_input]
subscribe:
- fs:file:///watch/inbox
log_level: warn
"#,
"yaml",
);
let c = Config::load(
&args(&["--config", yaml.path().to_str().unwrap()]),
&base_env(),
)
.unwrap();
assert_eq!(c.model.as_deref(), Some("claude-from-yaml"));
assert_eq!(c.max_tokens, 1_234_567);
assert_eq!(c.max_steps, 77);
assert_eq!(c.max_depth, 3);
assert_eq!(c.deadline, Some(Duration::from_secs(120)));
assert_eq!(c.mcp_servers.len(), 1);
assert_eq!(c.mcp_servers[0].name, "web");
assert_eq!(c.mcp_servers[0].tags, vec![TrifectaTag::UntrustedInput]);
assert_eq!(c.subscribe, vec!["fs:file:///watch/inbox"]);
assert_eq!(c.log_level, Level::Warn);
let bad = write_tmp_ext("model: m\nmax_token: 5\n", "yml");
let e = Config::load(
&args(&["--config", bad.path().to_str().unwrap()]),
&base_env(),
)
.unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)), "{e}");
assert!(format!("{e}").contains("max_token"), "{e}");
let bad = write_tmp_ext("model: m\n\tlimits: {}\n", "yaml");
let e = Config::load(
&args(&["--config", bad.path().to_str().unwrap()]),
&base_env(),
)
.unwrap_err();
assert!(format!("{e}").contains("line 2"), "{e}");
}
#[test]
fn path_env_vars_set_config_paths() {
let file = write_tmp_ext(
"limits:\n max_steps: 1\n max_depth: 9\nmodel: f\n",
"yaml",
);
let mut env = base_env();
env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "5".into()));
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((
"AGENTD_INTELLIGENCE_HEADERS".into(),
"{x-team: ops}".into(), ));
let c = Config::load(&args(&["--config", file.path().to_str().unwrap()]), &env).unwrap();
assert_eq!(c.max_steps, 5, "env path beats the file");
assert_eq!(
c.max_depth, 9,
"untouched sibling path keeps the file value"
);
assert_eq!(c.deadline, Some(Duration::from_secs(30)));
assert_eq!(c.budget_tokens_lifetime, 4000);
assert_eq!(c.model_swap, SwapPolicy::RestartTurn);
assert_eq!(c.subscribe, vec!["a://1", "a://2"]);
assert_eq!(
c.intelligence_headers.get("x-team").map(String::as_str),
Some("ops")
);
assert_eq!(c.model.as_deref(), Some("f"));
let mut env = base_env();
env.push(("LIMITS_MAX_STEPS".into(), "1".into()));
env.push(("AGENT_LIMITS_MAX_STEPS".into(), "2".into()));
assert_eq!(Config::load(&args(&[]), &env).unwrap().max_steps, 2);
env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "3".into()));
assert_eq!(Config::load(&args(&[]), &env).unwrap().max_steps, 3);
let mut env = base_env();
env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "many".into()));
let e = Config::load(&args(&[]), &env).unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
assert!(format!("{e}").contains("AGENTD_LIMITS_MAX_STEPS"), "{e}");
let mut env = base_env();
env.push(("AGENTD_LOG_LEVEL".into(), "loud".into()));
let e = Config::load(&args(&[]), &env).unwrap_err();
assert!(format!("{e}").contains("AGENTD_LOG_LEVEL"), "{e}");
}
#[test]
fn generic_path_flags_override_env_and_file() {
let file = write_tmp_ext("limits:\n max_steps: 1\n", "yaml");
let mut env = base_env();
env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "2".into()));
for spelling in [
"--limits.max-steps",
"--limits-max-steps",
"--limits.max_steps",
] {
let c = Config::load(
&args(&["--config", file.path().to_str().unwrap(), spelling, "3"]),
&env,
)
.unwrap();
assert_eq!(c.max_steps, 3, "{spelling}");
}
let c = Config::load(
&args(&["--max-steps", "4", "--limits.max_steps", "5"]),
&base_env(),
)
.unwrap();
assert_eq!(c.max_steps, 5);
let c = Config::load(
&args(&["--limits.max_steps", "5", "--max-steps", "6"]),
&base_env(),
)
.unwrap();
assert_eq!(c.max_steps, 6);
let c = Config::load(
&args(&["--subscribe", "a://1", "--subscribe", "[a://2, a://3]"]),
&base_env(),
)
.unwrap();
assert_eq!(c.subscribe, vec!["a://1", "[a://2, a://3]"]);
let c = Config::load(
&args(&[
"--mcp-servers",
"[{name: q, endpoint: https://q.example/mcp}]",
]),
&base_env(),
)
.unwrap();
assert_eq!(c.mcp_servers.len(), 1);
assert_eq!(c.mcp_servers[0].name, "q");
let e = Config::load(&args(&["--limits.max-steps", "lots"]), &base_env()).unwrap_err();
assert!(format!("{e}").contains("--limits.max-steps"), "{e}");
let e = Config::load(&args(&["--model-swap", "sideways"]), &base_env()).unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)), "{e}");
let e = Config::load(&args(&["--no-such-thing", "1"]), &base_env()).unwrap_err();
assert!(format!("{e}").contains("unknown argument"), "{e}");
let e = Config::load(&args(&["--limits.max-steps"]), &base_env()).unwrap_err();
assert!(format!("{e}").contains("requires a value"), "{e}");
}
#[test]
fn multiple_config_files_merge_in_order_later_wins() {
let base = write_tmp_ext(
"model: base
log_level: warn
limits:
max_steps: 1
max_depth: 2
subscribe: [a://1, a://2]
",
"yaml",
);
let site = write_tmp_ext(
r#"{ "model": "site", "limits": { "max_steps": 5 }, "subscribe": ["a://3"] }"#,
"json",
);
let over = write_tmp_ext(
"model: over
log_level: null
limits:
max_depth: 7
",
"yml",
);
let mut env = base_env();
env.push((
"AGENT_CONFIG".into(), format!("{}:{}", base.path().display(), site.path().display()),
));
let c = Config::load(&args(&["--config", over.path().to_str().unwrap()]), &env).unwrap();
assert_eq!(
c.model.as_deref(),
Some("over"),
"last file wins on a scalar"
);
assert_eq!(
c.max_steps, 5,
"site's limits.max_steps survives (objects merge)"
);
assert_eq!(c.max_depth, 7, "over's limits.max_depth wins");
assert_eq!(c.subscribe, vec!["a://3"], "a later file REPLACES a list");
assert_eq!(
c.log_level,
Level::Info,
"`null` unsets → back to the default"
);
assert_eq!(c.config_files.len(), 3);
assert!(c.config_files[0].ends_with(".yaml") && c.config_files[2].ends_with(".yml"));
let c = Config::load(
&args(&[
"--config",
over.path().to_str().unwrap(),
"--config",
base.path().to_str().unwrap(),
]),
&base_env(),
)
.unwrap();
assert_eq!(c.model.as_deref(), Some("base"));
assert_eq!(c.max_depth, 2);
let mut env2 = base_env();
env2.push(("AGENTD_MODEL".into(), "from-env".into()));
let c = Config::load(
&args(&[
"--config",
base.path().to_str().unwrap(),
"--config",
over.path().to_str().unwrap(),
"--limits.max-depth",
"9",
]),
&env2,
)
.unwrap();
assert_eq!(c.model.as_deref(), Some("from-env"));
assert_eq!(c.max_depth, 9);
let e = Config::load(
&args(&[
"--config",
base.path().to_str().unwrap(),
"--config",
"/no/such/overlay.yaml",
]),
&base_env(),
)
.unwrap_err();
assert!(format!("{e}").contains("/no/such/overlay.yaml"), "{e}");
let paths = Config::config_paths_from(
&args(&["--config", "b.yaml"]),
&[("AGENT_CONFIG".into(), "x.yaml::y.json:".into())],
);
assert_eq!(paths, vec!["x.yaml", "y.json", "b.yaml"]);
}
#[test]
fn setting_a_path_replaces_the_value_while_named_flags_add() {
let file = write_tmp_ext(
"subscribe: [a://file]
mcp_servers:
- name: web
endpoint: https://web.example/mcp
intelligence_headers:
keep: me
x-team: file
",
"yaml",
);
let path = file.path().to_str().unwrap().to_string();
let mut env = base_env();
env.push(("AGENTD_SUBSCRIBE".into(), "a://env".into()));
let c = Config::load(&args(&["--config", &path]), &env).unwrap();
assert_eq!(c.subscribe, vec!["a://env"]);
let c = Config::load(&args(&["--config", &path, "--subscribe", "a://flag"]), &env).unwrap();
assert_eq!(c.subscribe, vec!["a://env", "a://flag"]);
let c = Config::load(
&args(&[
"--config",
&path,
"--mcp-servers",
"[{name: q, endpoint: https://q.example/mcp}]",
"--mcp",
"x=https://x.example/mcp",
]),
&base_env(),
)
.unwrap();
let names: Vec<&str> = c.mcp_servers.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["q", "x"]);
let c = Config::load(
&args(&["--config", &path, "--intelligence_headers.x-team", "ops"]),
&base_env(),
)
.unwrap();
assert_eq!(
c.intelligence_headers.get("x-team").map(String::as_str),
Some("ops")
);
assert_eq!(
c.intelligence_headers.get("keep").map(String::as_str),
Some("me")
);
let c = Config::load(
&args(&["--config", &path, "--intelligence-headers", "{only: this}"]),
&base_env(),
)
.unwrap();
assert_eq!(c.intelligence_headers.len(), 1);
assert_eq!(
c.intelligence_headers.get("only").map(String::as_str),
Some("this")
);
let e = Config::load(&args(&["--mcp-servers.0.aauth", "true"]), &base_env()).unwrap_err();
assert!(format!("{e}").contains("array elements"), "{e}");
}
#[test]
fn help_lists_every_config_path_with_flag_and_env() {
let h = match Config::load(&args(&["--help"]), &[]) {
Err(ConfigError::Help(h)) => h,
other => panic!("expected help, got {other:?}"),
};
assert!(h.contains("CONFIG PATHS"), "{h}");
assert!(h.contains("limits.max_steps"), "{h}");
assert!(h.contains("--limits-max-steps"), "{h}");
assert!(h.contains("AGENTD_LIMITS_MAX_STEPS"), "{h}");
assert!(h.contains("YAML or JSON"), "{h}");
}
#[test]
fn reload_re_reads_a_yaml_file() {
let file = write_tmp_ext("model: v1\nlimits:\n max_steps: 10\n", "yaml");
let path = file.path().to_str().unwrap().to_string();
let a = args(&["--config", &path, "--max-depth", "2"]);
let env = base_env();
let running = Config::load(&a, &env).unwrap();
assert_eq!(running.model.as_deref(), Some("v1"));
assert_eq!(running.max_steps, 10);
std::fs::write(
&path,
"model: v2\nlimits:\n max_steps: 20\n max_depth: 7\n",
)
.unwrap();
let reloaded = Config::reload(&a, &env).unwrap();
assert_eq!(reloaded.model.as_deref(), Some("v2"));
assert_eq!(reloaded.max_steps, 20);
assert_eq!(
reloaded.max_depth, 2,
"the flag still overrides the new file"
);
std::fs::write(&path, "model: [unterminated\n").unwrap();
assert!(matches!(
Config::reload(&a, &env),
Err(ConfigError::Usage(_))
));
}
#[test]
fn flag_mcp_and_subscribe_add_to_the_file_list() {
let file = write_tmp(
r#"{ "mcp_servers": [{ "name": "web", "endpoint": "https://web.example.com/mcp" }],
"subscribe": ["fs:file:///a"] }"#,
);
let c = Config::load(
&args(&[
"--config",
file.path().to_str().unwrap(),
"--mcp",
"fs=https://fs.example",
"--subscribe",
"fs:file:///b",
]),
&base_env(),
)
.unwrap();
let names: Vec<&str> = c.mcp_servers.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["web", "fs"]); assert_eq!(c.subscribe, vec!["fs:file:///a", "fs:file:///b"]);
}
#[test]
fn config_via_env_alias() {
let file = write_tmp(r#"{ "model": "env-config" }"#);
let mut env = base_env();
env.push(("AGENTD_CONFIG".into(), file.path().to_str().unwrap().into()));
let c = Config::load(&args(&[]), &env).unwrap();
assert_eq!(c.model.as_deref(), Some("env-config"));
}
#[test]
fn malformed_config_file_is_usage_error() {
let file = write_tmp("{ this is not json ");
let e = Config::load(
&args(&["--config", file.path().to_str().unwrap()]),
&base_env(),
)
.unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
}
#[test]
fn unreadable_config_file_is_usage_error() {
let e =
Config::load(&args(&["--config", "/no/such/config.json"]), &base_env()).unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
}
#[test]
fn config_file_unknown_key_is_usage_error() {
let file = write_tmp(r#"{ "max_token": 5 }"#);
let e = Config::load(
&args(&["--config", file.path().to_str().unwrap()]),
&base_env(),
)
.unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
}
#[cfg(not(feature = "config-watch"))]
#[test]
fn watch_config_requires_config_watch_feature() {
let file = write_tmp(r#"{ "model": "m" }"#);
let e = Config::load(
&args(&["--config", file.path().to_str().unwrap(), "--watch-config"]),
&base_env(),
)
.unwrap_err();
match e {
ConfigError::Usage(msg) => assert!(
msg.contains("--watch-config requires the 'config-watch' build feature"),
"got: {msg}"
),
other => panic!("expected Usage, got {other:?}"),
}
}
#[cfg(feature = "config-watch")]
#[test]
fn watch_config_parses_with_a_config_file() {
let file = write_tmp(r#"{ "model": "m" }"#);
let c = Config::load(
&args(&["--config", file.path().to_str().unwrap(), "--watch-config"]),
&base_env(),
)
.unwrap();
assert!(c.watch_config);
}
#[cfg(feature = "config-watch")]
#[test]
fn watch_config_parses_from_env() {
let file = write_tmp(r#"{ "model": "m" }"#);
let mut env = base_env();
env.push(("AGENTD_CONFIG".into(), file.path().to_str().unwrap().into()));
env.push(("AGENTD_WATCH_CONFIG".into(), "true".into()));
let c = Config::load(&args(&[]), &env).unwrap();
assert!(c.watch_config);
}
#[cfg(feature = "config-watch")]
#[test]
fn watch_config_requires_a_config_file() {
let e = Config::load(&args(&["--watch-config"]), &base_env()).unwrap_err();
match e {
ConfigError::Usage(msg) => assert!(
msg.contains("--watch-config requires a config file"),
"got: {msg}"
),
other => panic!("expected Usage, got {other:?}"),
}
}
#[cfg(feature = "config-watch")]
#[test]
fn validate_config_flags_watch_config_without_a_file() {
let v = validate_verdict(&["--validate-config", "--watch-config"], &base_env());
let lines = v.expect_err("watch-config without a file is invalid");
assert!(
lines.contains("--watch-config requires a config file"),
"got: {lines}"
);
}
fn validate_verdict(args_: &[&str], env: &[(String, String)]) -> Result<String, String> {
match Config::load(&args(args_), env).unwrap_err() {
ConfigError::Validate(v) => v,
other => panic!("expected Validate, got {other:?}"),
}
}
#[test]
fn validate_config_valid_returns_ok_with_no_instruction_needed() {
let v = validate_verdict(&["--validate-config"], &base_env());
let line = v.expect("a complete config validates");
assert!(line.contains("config.valid"));
let _: serde_json::Value = serde_json::from_str(&line).unwrap();
}
#[test]
fn validate_config_invalid_returns_err_exit2_shape() {
let v = validate_verdict(&["--validate-config", "--mode", "reactive"], &base_env());
let lines = v.unwrap_err();
assert!(lines.contains("config.invalid"));
for line in lines.lines() {
let _: serde_json::Value = serde_json::from_str(line).unwrap();
}
}
#[test]
fn validate_config_refuses_a_trifecta_only_config_exit2() {
let v = validate_verdict(
&[
"--validate-config",
"--mcp",
"s=https://s.example",
"--mcp-tags",
"s=untrusted_input,sensitive,egress",
],
&base_env(),
);
let lines = v.expect_err("a trifecta-only config must be invalid");
assert!(lines.contains("config.invalid"), "got: {lines}");
assert!(lines.contains("lethal-trifecta"), "got: {lines}");
for line in lines.lines() {
let _: serde_json::Value = serde_json::from_str(line).unwrap();
}
}
#[test]
fn validate_config_and_startup_agree_on_trifecta() {
let trifecta = [
"--mcp",
"s=https://s.example",
"--mcp-tags",
"s=untrusted_input,sensitive,egress",
];
let startup = Config::load(&args(&trifecta), &base_env()).unwrap_err();
assert!(matches!(startup, ConfigError::Usage(_)));
let mut allowed = vec!["--allow-trifecta"];
allowed.extend_from_slice(&trifecta);
assert!(Config::load(&args(&allowed), &base_env()).is_ok());
let mut allowed_vc = vec!["--validate-config", "--allow-trifecta"];
allowed_vc.extend_from_slice(&trifecta);
assert!(validate_verdict(&allowed_vc, &base_env()).is_ok());
}
#[test]
fn validate_config_runs_without_an_instruction() {
let env = vec![("AGENTD_INTELLIGENCE".into(), "https://intel.example".into())];
let v = match Config::load(&args(&["--validate-config"]), &env).unwrap_err() {
ConfigError::Validate(v) => v,
other => panic!("expected Validate, got {other:?}"),
};
let lines = v.unwrap_err();
assert!(lines.contains("config.invalid"));
assert!(lines.contains("instruction"));
}
#[test]
fn validate_config_rejects_bad_intelligence_scheme() {
let mut env = base_env();
env.retain(|(k, _)| k != "AGENTD_INTELLIGENCE");
let v = validate_verdict(&["--validate-config", "--intelligence", "ftp://nope"], &env);
assert!(v.unwrap_err().contains("config.invalid"));
}
#[test]
fn config_schema_emits_parseable_json_schema() {
let s = match Config::load(&args(&["--config-schema"]), &[]).unwrap_err() {
ConfigError::Schema(s) => s,
other => panic!("expected Schema, got {other:?}"),
};
let v: serde_json::Value = serde_json::from_str(&s).expect("schema is valid JSON");
assert_eq!(
v["$schema"],
serde_json::json!("https://json-schema.org/draft/2020-12/schema")
);
assert!(v["properties"].is_object());
}
#[test]
fn intelligence_token_file_reads_and_trims() {
let tok = write_tmp("file-token\n");
let mut env = base_env();
env.push((
"AGENTD_INTELLIGENCE_TOKEN_FILE".into(),
tok.path().to_str().unwrap().into(),
));
let c = Config::load(&args(&[]), &env).unwrap();
assert_eq!(c.intelligence_token.as_deref(), Some("file-token"));
let dbg = format!("{c:?}");
assert!(!dbg.contains("file-token"));
assert!(dbg.contains("***"));
}
#[test]
fn inline_token_wins_over_token_file() {
let tok = write_tmp("from-file\n");
let mut env = base_env();
env.push(("AGENTD_INTELLIGENCE_TOKEN".into(), "from-inline".into()));
env.push((
"AGENTD_INTELLIGENCE_TOKEN_FILE".into(),
tok.path().to_str().unwrap().into(),
));
let c = Config::load(&args(&[]), &env).unwrap();
assert_eq!(c.intelligence_token.as_deref(), Some("from-inline"));
}
#[test]
#[cfg(feature = "aauth")]
fn aauth_flags_and_validation() {
let c = Config::load(
&args(&[
"--aauth-key-file",
"/tmp/id.key",
"--aauth-provider",
"https://apd.example",
"--aauth-enroll-token",
"{{secret:ENROLL}}",
"--aauth-enroll-assertion-file",
"/var/run/secrets/aauth/token",
"--aauth-person-server",
"https://ps.example",
]),
&base_env(),
)
.unwrap();
let a = c.aauth.expect("aauth configured");
assert_eq!(a.provider, "https://apd.example");
assert_eq!(a.key_file, "/tmp/id.key");
assert_eq!(a.enrollment_token.as_deref(), Some("{{secret:ENROLL}}"));
assert_eq!(
a.enroll_assertion_file.as_deref(),
Some("/var/run/secrets/aauth/token")
);
assert_eq!(a.person_server.as_deref(), Some("https://ps.example"));
let mut env = base_env();
env.push(("AGENT_AAUTH_PROVIDER".into(), "https://apd.example".into()));
env.push((
"AGENT_AAUTH_ENROLL_ASSERTION_FILE".into(),
"/var/run/secrets/aauth/token".into(),
));
let a = Config::load(&args(&[]), &env).unwrap().aauth.unwrap();
assert_eq!(
a.enroll_assertion_file.as_deref(),
Some("/var/run/secrets/aauth/token")
);
let mut env = base_env();
env.push(("AGENT_AAUTH_PROVIDER".into(), "https://apd.example".into()));
let c = Config::load(&args(&[]), &env).unwrap();
assert_eq!(c.aauth.unwrap().key_file, "agent.key");
assert!(Config::load(&args(&["--aauth-provider", "not-a-url"]), &base_env()).is_err());
assert!(
Config::load(
&args(&[
"--aauth-provider",
"https://apd.example",
"--aauth-person-server",
"nope"
]),
&base_env()
)
.is_err()
);
assert!(
Config::load(&args(&["--aauth-key-file", "/x"]), &base_env())
.unwrap()
.aauth
.is_none()
);
}
#[test]
#[cfg(feature = "tls")]
fn tls_ca_flag_env_and_content_validation() {
let ca = write_tmp(include_str!("../../../net/tests/fixtures/ca.pem"));
let ca_path = ca.path().to_str().unwrap().to_string();
let c = Config::load(&args(&["--tls-ca", &ca_path]), &base_env()).unwrap();
assert_eq!(c.tls_ca.as_deref(), Some(ca_path.as_str()));
assert!(format!("{c:?}").contains(&ca_path));
for key in ["AGENTD_TLS_CA", "AGENT_TLS_CA"] {
let mut env = base_env();
env.push((key.into(), ca_path.clone()));
let c = Config::load(&args(&[]), &env).unwrap();
assert_eq!(c.tls_ca.as_deref(), Some(ca_path.as_str()), "via {key}");
}
let err = Config::load(&args(&["--tls-ca", "/nonexistent/ca.pem"]), &base_env());
assert!(matches!(err, Err(ConfigError::Usage(_))));
let junk = write_tmp("not a pem");
let err = Config::load(
&args(&["--tls-ca", junk.path().to_str().unwrap()]),
&base_env(),
);
assert!(matches!(err, Err(ConfigError::Usage(_))));
}
#[test]
fn token_file_flag_reads_via_cli() {
let tok = write_tmp("flag-token");
let c = Config::load(
&args(&["--intelligence-token-file", tok.path().to_str().unwrap()]),
&base_env(),
)
.unwrap();
assert_eq!(c.intelligence_token.as_deref(), Some("flag-token"));
}
#[test]
fn missing_token_file_is_usage_error() {
let mut env = base_env();
env.push((
"AGENTD_INTELLIGENCE_TOKEN_FILE".into(),
"/no/such/token".into(),
));
let e = Config::load(&args(&[]), &env).unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
}
#[test]
fn secret_file_ref_resolves_and_does_not_leak() {
let secret = write_tmp("RESOLVED-SECRET-VALUE\n");
let path = secret.path().to_str().unwrap().to_string();
let file = write_tmp(&format!(
r#"{{ "intelligence_headers": {{
"authorization": "Bearer {{{{secret-file:{path}}}}}" }} }}"#
));
let c = Config::load(
&args(&["--config", file.path().to_str().unwrap()]),
&base_env(),
)
.unwrap();
assert_eq!(
c.intelligence_headers
.get("authorization")
.map(String::as_str),
Some(format!("Bearer {{{{secret-file:{path}}}}}").as_str())
);
let dbg = format!("{c:?}");
assert!(!dbg.contains("RESOLVED-SECRET-VALUE"));
let env = |_: &str| None;
let resolved =
crate::sec::secret::resolve(c.intelligence_headers.get("authorization").unwrap(), &env)
.unwrap();
assert_eq!(resolved, "Bearer RESOLVED-SECRET-VALUE");
}
#[test]
fn inline_secret_shaped_header_is_rejected() {
let file = write_tmp(r#"{ "intelligence_headers": { "x-api-key": "sk-inline-literal" } }"#);
let e = Config::load(
&args(&["--config", file.path().to_str().unwrap()]),
&base_env(),
)
.unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
unsafe {
std::env::set_var("AGENTD_TEST_HDR_KEY_0017", "k");
}
let file_ok = write_tmp(
r#"{ "intelligence_headers": { "x-api-key": "{{secret:AGENTD_TEST_HDR_KEY_0017}}" } }"#,
);
let c = Config::load(
&args(&["--config", file_ok.path().to_str().unwrap()]),
&base_env(),
)
.unwrap();
assert!(c.intelligence_headers.contains_key("x-api-key"));
unsafe {
std::env::remove_var("AGENTD_TEST_HDR_KEY_0017");
}
}
#[test]
fn unresolvable_secret_ref_in_header_is_rejected_at_validation() {
let file = write_tmp(
r#"{ "intelligence_headers": { "x-api-key": "{{secret:DEFINITELY_UNSET_VAR_XYZ}}" } }"#,
);
let e = Config::load(
&args(&["--config", file.path().to_str().unwrap()]),
&base_env(),
)
.unwrap_err();
assert!(matches!(e, ConfigError::Usage(_)));
}
fn reactive_base() -> Config {
Config::load(
&args(&["--mode", "reactive", "--subscribe", "file:///in.json"]),
&base_env(),
)
.unwrap()
}
#[test]
fn coherence_rejects_a_differing_restart_only_field() {
let running = reactive_base();
for mutate in [
(|c: &mut Config| c.mode = Mode::Loop) as fn(&mut Config),
|c: &mut Config| c.run_id = "different-run-id".into(),
|c: &mut Config| c.serve_mcp = Some("https://a.example:8443".into()),
|c: &mut Config| c.drain_timeout = Duration::from_secs(99),
] {
let mut new = running.clone();
mutate(&mut new);
let diags = Config::reload_coherence_check(&new, Some(&running), true)
.expect_err("a restart-only diff must be rejected");
assert!(
diags
.iter()
.any(|d| d.is_error() && d.msg.contains("restart-only")),
"expected a restart-only error, got {diags:?}"
);
}
}
#[test]
fn coherence_accepts_a_reloadable_diff() {
let running = reactive_base();
for mutate in [
(|c: &mut Config| c.log_level = Level::Debug) as fn(&mut Config),
|c: &mut Config| c.model = Some("claude-opus-4".into()),
|c: &mut Config| c.max_tokens = 999_999,
|c: &mut Config| c.max_steps = 123,
|c: &mut Config| c.subscribe = vec!["file:///in.json".into(), "file:///b.json".into()],
|c: &mut Config| {
c.mcp_servers = vec![McpServerSpec {
name: "added".into(),
endpoint: "unix:/mcp-new.sock".into(),
..Default::default()
}]
},
|c: &mut Config| c.intelligence = Some("https://other.example".into()),
|c: &mut Config| c.model_swap = SwapPolicy::RestartTurn,
] {
let mut new = running.clone();
mutate(&mut new);
assert!(
Config::reload_coherence_check(&new, Some(&running), true).is_ok(),
"a reloadable diff must be accepted",
);
}
}
#[test]
fn mcp_servers_is_reloadable_not_restart_only() {
assert!(
!RESTART_ONLY_FIELDS.contains(&"mcp_servers"),
"mcp_servers must NOT be restart-only"
);
let running = reactive_base();
let mut added = running.clone();
added.mcp_servers.push(McpServerSpec {
name: "extra".into(),
endpoint: "unix:/mcp-extra.sock".into(),
..Default::default()
});
assert!(
Config::reload_coherence_check(&added, Some(&running), true).is_ok(),
"adding an MCP server must pass the coherence check (it is reloadable)"
);
let mut with_server = running.clone();
with_server.mcp_servers = vec![McpServerSpec {
name: "s".into(),
endpoint: "unix:/mcp-orig.sock".into(),
..Default::default()
}];
let mut edited = with_server.clone();
edited.mcp_servers[0].endpoint = "https://mcp-edited.example".into();
assert!(
Config::reload_coherence_check(&edited, Some(&with_server), true).is_ok(),
"editing an MCP server must pass the coherence check (it is reloadable)"
);
}
#[test]
fn model_swap_flag_and_env_parse_and_default() {
let def = Config::load(&args(&[]), &base_env()).unwrap();
assert_eq!(def.model_swap, SwapPolicy::FinishOnOld);
let flag = Config::load(&args(&["--model-swap", "restart-turn"]), &base_env()).unwrap();
assert_eq!(flag.model_swap, SwapPolicy::RestartTurn);
let mut env = base_env();
env.push(("AGENTD_MODEL_SWAP".into(), "restart-turn".into()));
let e = Config::load(&args(&[]), &env).unwrap();
assert_eq!(e.model_swap, SwapPolicy::RestartTurn);
assert!(matches!(
Config::load(&args(&["--model-swap", "nope"]), &base_env()),
Err(ConfigError::Usage(_))
));
}
#[test]
fn intelligence_is_reloadable_not_restart_only() {
assert!(
!RESTART_ONLY_FIELDS.contains(&"intelligence"),
"intelligence must NOT be restart-only"
);
let running = reactive_base();
let mut new = running.clone();
new.intelligence = Some("https://gw-b.example:1234".into());
assert!(
Config::reload_coherence_check(&new, Some(&running), true).is_ok(),
"an endpoint repoint must pass the coherence check (it is reloadable)"
);
}
#[test]
fn coherence_rejects_duplicate_server_names() {
let mut cfg = reactive_base();
cfg.mcp_servers = vec![
McpServerSpec {
name: "dup".into(),
endpoint: "unix:/a.sock".into(),
..Default::default()
},
McpServerSpec {
name: "dup".into(),
endpoint: "unix:/b.sock".into(),
..Default::default()
},
];
let diags = Config::reload_coherence_check(&cfg, None, false)
.expect_err("duplicate server names must be an error");
assert!(
diags
.iter()
.any(|d| d.is_error() && d.msg.contains("duplicate"))
);
}
#[test]
fn restart_only_set_pins_the_immutable_fields() {
for &f in RESTART_ONLY_FIELDS {
let mut a = reactive_base();
let b = a.clone();
match f {
"mode" => a.mode = Mode::Loop,
"run_id" => a.run_id = "x".into(),
"serve_mcp" => a.serve_mcp = Some("https://s.example:8443".into()),
"drain_timeout" => a.drain_timeout = Duration::from_secs(123),
"continue_subscribe" => a.continue_subscribe = vec!["u".into()],
other => panic!("RESTART_ONLY_FIELDS has an unmapped field '{other}'"),
}
assert!(
a.restart_only_field_differs(&b, f),
"restart-only field '{f}' must be diff-detected"
);
}
}
#[test]
fn effective_view_carries_no_secret_or_url() {
const TOKEN: &str = "super-secret-effective-token";
let mut env = base_env();
env.push(("AGENTD_INTELLIGENCE_TOKEN".into(), TOKEN.into()));
env.push((
"AGENTD_INTELLIGENCE".into(),
"https://user:embedded-cred@api.example/v1".into(),
));
let mut cfg =
Config::load(&args(&["--mcp", "vault=https://vault.example/mcp"]), &env).unwrap();
cfg.intelligence_headers
.insert("x-api-key".into(), "{{secret:SOME_NAME}}".into());
let view = cfg.effective_view();
let blob = serde_json::to_string(&view).unwrap();
assert!(!blob.contains(TOKEN), "token leaked into effective view");
assert!(!blob.contains("embedded-cred"), "URL creds leaked");
assert!(!blob.contains("api.example"), "endpoint host leaked");
assert!(!blob.contains("SOME_NAME"), "header ref value leaked");
assert!(!blob.contains("vault-secret.sock"), "mcp endpoint leaked");
assert_eq!(view["mcp_servers"][0]["name"], serde_json::json!("vault"));
assert_eq!(
view["intelligence_headers"],
serde_json::json!(["x-api-key"])
);
}
}