Skip to main content

agentd/config/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Configuration: precedence, then validate-at-startup.
3//!
4//! Precedence, top wins: `built-in default < config FILE < env var < CLI flag`.
5//! Everything is env-settable (12-factor). The optional
6//! declarative file ([`file`] — YAML or JSON, `--config`/`AGENTD_CONFIG`)
7//! carries only verbose structural config (MCP-server inventory, declared
8//! subscriptions, A2A peers, limits, model/log knobs) and **never** secrets —
9//! those stay env/flag only. The whole config is validated **before any side
10//! effect** — a bad config exits `2` in milliseconds, not after an LLM
11//! round-trip.
12//!
13//! Module layout: [`file`] (the config document: format detection, the typed
14//! `ConfigFile` shape, the JSON Schema), [`yaml`] (the hand-rolled YAML-subset
15//! reader), [`paths`] (schema-derived path bindings: `AGENTD_<PATH>` env names
16//! and `--<path>` flags for every config-file path), [`watch`] (the inotify
17//! reload trigger).
18
19pub mod directives;
20pub mod envfile;
21pub mod file;
22pub mod paths;
23pub mod prompt;
24pub mod templates;
25pub mod v2;
26#[cfg(all(unix, feature = "config-watch"))]
27pub mod watch;
28pub mod yaml;
29
30use crate::obs::log::Level;
31use crate::sec::scope::TrifectaTag;
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use std::fmt;
35use std::path::{Path, PathBuf};
36use std::time::{Duration, SystemTime, UNIX_EPOCH};
37
38/// Execution mode. There is one supervisor loop; the mode only chooses the
39/// predicate that decides when it is finished.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Mode {
42    /// Run the instruction once to a terminal status, then exit.
43    Once,
44    /// Keep working until a bound (iterations/deadline/tree-token) or signal.
45    Loop,
46    /// Idle; wake on MCP resource updates. Exits only on signal/fatal.
47    Reactive,
48    /// Per-fire identical to `once`, driven by an internal interval/cron.
49    Schedule,
50    /// Drive a pinned workflow (`--workflow <file>`) to a terminal graph
51    /// status, then exit — the operator entry for deterministic DAGs.
52    #[cfg(feature = "workflow")]
53    Workflow,
54}
55
56impl Mode {
57    pub fn as_str(self) -> &'static str {
58        match self {
59            Mode::Once => "once",
60            Mode::Loop => "loop",
61            Mode::Reactive => "reactive",
62            Mode::Schedule => "schedule",
63            #[cfg(feature = "workflow")]
64            Mode::Workflow => "workflow",
65        }
66    }
67    pub fn parse(s: &str) -> Option<Mode> {
68        match s {
69            "once" => Some(Mode::Once),
70            "loop" => Some(Mode::Loop),
71            "reactive" => Some(Mode::Reactive),
72            "schedule" => Some(Mode::Schedule),
73            #[cfg(feature = "workflow")]
74            "workflow" => Some(Mode::Workflow),
75            _ => None,
76        }
77    }
78}
79
80/// Model hot-swap policy (`--model-swap` / `AGENTD_MODEL_SWAP`): what an
81/// in-flight run does when a reload changes the `model` under it. An endpoint
82/// repoint that leaves the model unchanged is ALWAYS finish-on-old and
83/// invisible, whatever this policy says — nothing about the turn changed.
84/// Default `FinishOnOld`. Serialized into the `ControlMsg::SwapIntel` frame so
85/// the child applies the same policy the supervisor was configured with.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum SwapPolicy {
89    /// The turn in flight when the reload lands completes on the OLD model; the
90    /// NEXT turn uses the new model over the full existing transcript. The
91    /// natural turn-boundary behaviour — cheapest, and no work is thrown away.
92    #[default]
93    FinishOnOld,
94    /// The turn in flight finishes (we never tear a `complete_once`) but its
95    /// result is DISCARDED and the turn is RE-RUN on the new model from the same
96    /// pre-turn transcript state. Costs one turn, and the step budget bounds
97    /// how often it can happen. Opt-in.
98    RestartTurn,
99}
100
101impl SwapPolicy {
102    pub fn as_str(self) -> &'static str {
103        match self {
104            SwapPolicy::FinishOnOld => "finish-on-old",
105            SwapPolicy::RestartTurn => "restart-turn",
106        }
107    }
108    pub fn parse(s: &str) -> Option<SwapPolicy> {
109        match s {
110            "finish-on-old" => Some(SwapPolicy::FinishOnOld),
111            "restart-turn" => Some(SwapPolicy::RestartTurn),
112            _ => None,
113        }
114    }
115}
116
117/// Where `--serve-mcp` binds the served self-MCP. `Stdio` is the implicit
118/// default (no `--serve-mcp`). The sole transport is
119/// [`Http`](ServeTarget::Http) — `https://HOST:PORT` (TLS, the control plane) or
120/// `http://LOOPBACK:PORT` (plaintext, loopback-only dev/tests).
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum ServeTarget {
123    /// Bind an HTTP(S) listener at `bind` (a `host:port` authority). `tls` is the
124    /// production control plane (`https://`); plaintext (`http://`) is admitted
125    /// only for a loopback host (dev/tests).
126    Http { bind: String, tls: bool },
127    /// Bind a **unix domain socket** at `path` (`unix:///run/agentd/a2a.sock`) —
128    /// the co-located-peers transport: same HTTP/1.1 + JSON-RPC over the socket,
129    /// no TLS (the kernel authenticates the peer by uid), no TCP overhead.
130    Unix { path: String },
131}
132
133impl ServeTarget {
134    /// Parse a `--serve-mcp` value: `https://host:port` (or loopback
135    /// `http://host:port` for dev). Returns a [`ConfigError::Usage`] (exit 2,
136    /// before any side effect) on a bad scheme / missing port / a path.
137    pub fn parse(spec: &str) -> Result<ServeTarget, ConfigError> {
138        // The transport: `https://HOST:PORT` (TLS control plane) or
139        // `http://LOOPBACK:PORT` (plaintext, loopback-only dev/tests). The bind is
140        // the `host:port` authority (path/query rejected — this is a listener, not
141        // a URL to fetch).
142        if let Some(tls) = spec
143            .strip_prefix("https://")
144            .map(|_| true)
145            .or_else(|| spec.strip_prefix("http://").map(|_| false))
146        {
147            let authority = spec.split("://").nth(1).unwrap_or("");
148            if authority.is_empty() || authority.contains('/') {
149                return Err(usage(format!(
150                    "--serve-mcp: want http(s)://HOST:PORT with no path (got: {spec})"
151                )));
152            }
153            let host = serve_host_of(authority);
154            let port_ok = serve_port_of(authority).is_some();
155            if host.is_empty() || !port_ok {
156                return Err(usage(format!(
157                    "a2a.listen: HTTP(S) target needs an explicit host:port (got: {spec})"
158                )));
159            }
160            if !tls && !crate::net::http::is_loopback_host(host) {
161                return Err(usage(format!(
162                    "--serve-mcp: plaintext http:// is allowed for loopback only; use https:// (got: {spec})"
163                )));
164            }
165            return Ok(ServeTarget::Http {
166                bind: authority.to_string(),
167                tls,
168            });
169        }
170        if let Some(path) = spec
171            .strip_prefix("unix://")
172            .or_else(|| spec.strip_prefix("unix:"))
173        {
174            if path.is_empty() {
175                return Err(usage(format!("unix listener needs a socket path: {spec}")));
176            }
177            if !cfg!(unix) {
178                return Err(usage(format!(
179                    "unix:// listeners are unix-only (got: {spec}); use https://"
180                )));
181            }
182            return Ok(ServeTarget::Unix {
183                path: path.to_string(),
184            });
185        }
186        Err(usage(format!(
187            "--serve-mcp: want https://host:port (or loopback http://host:port for dev): {spec}"
188        )))
189    }
190}
191
192impl Config {
193    /// Validate the TLS material + auth for a `--serve-mcp` target. The
194    /// cert/key/CA/bearer fields apply ONLY to an `https://` target; TLS needs
195    /// `--serve-cert`+`--serve-key`; and a **non-loopback** listener MUST
196    /// authenticate, by mTLS (`--serve-client-ca`) and/or a `--serve-bearer`
197    /// token. Reaching the listener is never itself proof of trust, so an open
198    /// control plane is refused at startup (exit 2) rather than served.
199    fn validate_serve_auth(
200        &self,
201        target: &ServeTarget,
202        env: &dyn Fn(&str) -> Option<String>,
203    ) -> Result<(), ConfigError> {
204        let ServeTarget::Http { bind, tls } = target else {
205            // A unix listener authenticates by kernel peer credentials
206            // (same-uid); the TLS/bearer material below does not apply.
207            return Ok(());
208        };
209        let (bind, tls) = (bind.as_str(), *tls);
210        if tls {
211            match (&self.serve_cert, &self.serve_key) {
212                (Some(cert), Some(key)) => {
213                    check_readable("--serve-cert", cert)?;
214                    check_readable("--serve-key", key)?;
215                }
216                _ => {
217                    return Err(usage(
218                        "--serve-mcp https:// requires --serve-cert and --serve-key (PEM file paths)".into(),
219                    ));
220                }
221            }
222        } else if self.serve_cert.is_some() || self.serve_key.is_some() {
223            return Err(usage(
224                "--serve-cert/--serve-key need an https:// serve target (plaintext http:// is loopback dev only)".into(),
225            ));
226        }
227        if let Some(ca) = &self.serve_client_ca {
228            check_readable("--serve-client-ca", ca)?;
229        }
230        if let Some(bearer) = &self.serve_bearer {
231            crate::sec::secret::refs_resolvable(bearer, env)
232                .map_err(|e| usage(format!("--serve-bearer: {e}")))?;
233        }
234        // Never an open control plane: a listener reachable off-box must gate trust.
235        let loopback = crate::net::http::is_loopback_host(serve_host_of(bind));
236        if !loopback && self.serve_client_ca.is_none() && self.serve_bearer.is_none() {
237            return Err(usage(
238                "a non-loopback a2a.listen needs client auth: set a2a.tls.client_ca (mTLS) and/or a2a.bearer".into(),
239            ));
240        }
241        Ok(())
242    }
243}
244
245/// Confirm a file is present + readable (open checks read permission) without
246/// retaining its contents — for cert/key/CA PEM paths, checked at startup so a
247/// missing/unreadable file is exit 2, not a bind-time surprise.
248fn check_readable(flag: &str, path: &str) -> Result<(), ConfigError> {
249    std::fs::File::open(path).map_err(|e| usage(format!("{flag}: cannot read {path}: {e}")))?;
250    Ok(())
251}
252
253/// The host part of a `host:port` authority, unbracketing an IPv6 literal
254/// (`[::1]:8443` → `::1`). Never resolves — classifies the written form.
255pub(crate) fn serve_host_of(authority: &str) -> &str {
256    if let Some(rest) = authority.strip_prefix('[') {
257        return rest.split(']').next().unwrap_or(rest);
258    }
259    authority.rsplit_once(':').map_or(authority, |(h, _)| h)
260}
261
262/// The port of a `host:port` authority (`Some` iff a non-zero `u16` is present).
263fn serve_port_of(authority: &str) -> Option<u16> {
264    let port_str = if authority.starts_with('[') {
265        authority.rsplit_once("]:").map(|(_, p)| p)?
266    } else {
267        authority.rsplit_once(':').map(|(_, p)| p)?
268    };
269    port_str.parse::<u16>().ok().filter(|p| *p != 0)
270}
271
272/// A declared **A2A peer**: a name and a client transport endpoint to reach a
273/// remote A2A agent (or the on-node gateway that forwards into the mesh).
274/// `a2a.delegate` looks a peer up here and runs the A2A client against
275/// `endpoint`, which is `https://host[:port]` (loopback `http://` for dev) or
276/// `unix:/path` for a co-located peer. No secrets live here. Serializable so it
277/// travels in the spawn payload to subagents, exactly like `mcp_servers`.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct A2aPeerSpec {
280    pub name: String,
281    pub endpoint: String,
282    /// Secret-FREE auth header templates presented TO the peer (e.g.
283    /// `("authorization", "Bearer {{secret:PEER_TOKEN}}")`), resolved at dial
284    /// time exactly like an MCP server's, so no credential is ever present in
285    /// the spec, the manifest, the spawn payload or the logs. This is the
286    /// bearer leg of peer client-auth.
287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
288    pub headers: Vec<(String, String)>,
289    /// Client-certificate PEM **file paths** for mutual TLS to the peer (the
290    /// mTLS leg of peer client-auth). Both or neither; contents are loaded at
291    /// dial time and never inlined.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub client_cert: Option<String>,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub client_key: Option<String>,
296}
297
298impl A2aPeerSpec {
299    /// Resolve this peer's endpoint string to a parsed [`A2aEndpoint`] for the
300    /// A2A client to dial. Returns the validation message (without the `agentd:`
301    /// prefix) on a bad scheme. The endpoint is validated at startup, so at run
302    /// time this is expected to succeed; the `Result` keeps the call total.
303    pub fn endpoint_of(&self) -> Result<A2aEndpoint, String> {
304        A2aEndpoint::parse(&self.endpoint).map_err(|e| e.to_string())
305    }
306}
307
308/// The client transport an [`A2aPeerSpec`] endpoint resolves to. Parsed once
309/// (scheme-validated at startup), then the A2A client dials it. `vsock:CID:PORT`
310/// requires both forms of a cid+port (no wildcard — a client dials a concrete
311/// peer, unlike the `--serve-mcp` listen form which may wildcard).
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub enum A2aEndpoint {
314    /// Dial an A2A peer over HTTP(S):
315    /// `https://host[:port][/path]` (or loopback `http://` for dev/tests). The
316    /// raw URL, parsed by the A2A client's HTTP dialer. A co-located peer may
317    /// instead be dialled by `unix:///path` (same URL string, socket dial).
318    Https(String),
319}
320
321impl A2aEndpoint {
322    /// Parse an `--a2a-peer` endpoint. HTTPS-only: an `https://` peer URL, or
323    /// a loopback `http://` for dev/tests. Returns a
324    /// [`ConfigError::Usage`] (exit 2, before any side effect) on any problem.
325    pub fn parse(spec: &str) -> Result<A2aEndpoint, ConfigError> {
326        if spec.starts_with("https://") {
327            return Ok(A2aEndpoint::Https(spec.to_string()));
328        }
329        if spec.starts_with("http://") {
330            let host = crate::net::http::Url::parse(spec)
331                .map(|u| u.host)
332                .unwrap_or_default();
333            if !crate::net::http::is_loopback_host(&host) {
334                return Err(usage(format!(
335                    "--a2a-peer: plaintext http:// is allowed for loopback only; use https:// (got: {spec})"
336                )));
337            }
338            return Ok(A2aEndpoint::Https(spec.to_string()));
339        }
340        // `unix:///run/agentd/peer.sock` — the co-located fast lane: same A2A
341        // protocol over a unix socket, authenticated by the kernel (uid) and
342        // the socket file's mode instead of TLS. The client dialer branches on
343        // the same string, so the variant stays one.
344        if let Some(path) = spec
345            .strip_prefix("unix://")
346            .or_else(|| spec.strip_prefix("unix:"))
347        {
348            if path.is_empty() || !cfg!(unix) {
349                return Err(usage(format!(
350                    "--a2a-peer: unix: endpoint needs a socket path (unix-only): {spec}"
351                )));
352            }
353            return Ok(A2aEndpoint::Https(spec.to_string()));
354        }
355        Err(usage(format!(
356            "--a2a-peer: endpoint must be https://host[:port] (or loopback http:// for dev, or unix:///path for a co-located peer): {spec}"
357        )))
358    }
359}
360
361/// A declared MCP server. Serializable because it travels in the subagent spawn
362/// payload as the child's scoped server subset.
363///
364/// The sole transport is a remote [`endpoint`](Self::endpoint) reached over
365/// Streamable HTTP. There is no local process spawn, so no configuration path
366/// can turn an MCP server into command execution on this host.
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
368pub struct McpServerSpec {
369    pub name: String,
370    /// Remote MCP endpoint — `https://host[:port][/path]` (loopback `http://`
371    /// for dev), reached over Streamable HTTP.
372    pub endpoint: String,
373    /// Secret-FREE auth/framing header templates (e.g. `("Authorization", "Bearer
374    /// {{secret:MCP_TOKEN}}")`), resolved at connect time — no credential is
375    /// ever present in the spec, manifest, spawn payload or logs.
376    #[serde(default, skip_serializing_if = "Vec::is_empty")]
377    pub headers: Vec<(String, String)>,
378    /// Operator-declared capability tags (`--mcp-tags`) for the Rule-of-Two
379    /// trifecta check. Travels in the spawn payload so a child's narrowed grant
380    /// carries the same tags. Empty = untagged, and the check treats an
381    /// untagged server conservatively as `untrusted_input` — so forgetting to
382    /// tag a server can only tighten the gate, never loosen it.
383    #[serde(default, skip_serializing_if = "Vec::is_empty")]
384    pub tags: Vec<TrifectaTag>,
385    /// Sign requests to THIS server with the AAuth agent identity.
386    /// Per-server opt-in: `None` inherits the global default (sign all when an
387    /// `--aauth-provider` is configured); `Some(false)` opts out; `Some(true)`
388    /// opts in even if the global default were off. Travels in the spawn payload.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub aauth: Option<bool>,
391    /// OAuth 2.1 client-credentials for an endpoint behind an OAuth gateway:
392    /// a refreshing `Authorization: Bearer …` fetched from the
393    /// token endpoint. Secret-free (`client_secret` is a `{{secret:…}}`
394    /// template). Travels in the spawn payload; takes the request-signer seam
395    /// when set (mutually exclusive with per-server AAuth signing).
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub oauth: Option<McpOauthSpec>,
398    /// The unified credential provider. When set it takes precedence over the
399    /// narrower `oauth` / `aauth` settings. Travels in the spawn payload.
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub auth: Option<AuthSpec>,
402    /// The `services:` catalog entry this server references. The credential
403    /// cache key becomes `service:<name>`, so every consumer of the
404    /// entry shares one cached login, and the per-instance `rate:` bucket is
405    /// keyed by it. Travels in the spawn payload.
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub service: Option<String>,
408    /// The entry's `rate:` (resolved at config load) — seeds the per-process
409    /// pace registry at connect time, so worker and subagent processes pace
410    /// their own in-loop calls too. Travels in the spawn payload.
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub rate: Option<String>,
413}
414
415/// The runtime shape of an MCP server's OAuth 2.1 client-credentials config.
416/// Serializable so it rides the spawn payload verbatim; `client_secret` stays a
417/// `{{secret:…}}` template and is resolved only at token-fetch time.
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419pub struct McpOauthSpec {
420    pub token_url: String,
421    pub client_id: String,
422    /// A `{{secret:NAME}}` / `{{secret-file:PATH}}` template (never inline).
423    pub client_secret: String,
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub scope: Option<String>,
426}
427
428/// The runtime shape of a unified `auth:` credential provider. Every credential
429/// input stays a `{{secret:…}}` template, so this struct rides the spawn payload
430/// and appears in logs without ever carrying a live credential. `kind` is one of
431/// `static` / `oauth2` / `aws` / `spiffe`.
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
433pub struct AuthSpec {
434    pub kind: String,
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub grant: Option<String>,
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub issuer: Option<String>,
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub token_url: Option<String>,
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub device_authorization_url: Option<String>,
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub authorization_url: Option<String>,
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub client_id: Option<String>,
447    /// A `{{secret:…}}` template for a confidential client (never inline).
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub client_secret: Option<String>,
450    #[serde(default, skip_serializing_if = "Vec::is_empty")]
451    pub scopes: Vec<String>,
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub audience: Option<String>,
454    /// static: a bearer token template.
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub token: Option<String>,
457    /// static: an arbitrary header name (with `value`).
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub header: Option<String>,
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub value: Option<String>,
462    /// aws (SigV4): region, service (e.g. `bedrock`), and credential source
463    /// (`env` / `static` / `sso` / `imds` / `irsa`).
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub region: Option<String>,
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub service: Option<String>,
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub source: Option<String>,
470    /// aws `source: sso`: IAM Identity Center start URL, account, role.
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub sso_start_url: Option<String>,
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub account_id: Option<String>,
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub role_name: Option<String>,
477    /// spiffe: SVID type (`jwt`/`x509`) + the SPIRE-written file paths.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub svid: Option<String>,
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub jwt_svid_file: Option<String>,
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub svid_file: Option<String>,
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub key_file: Option<String>,
486}
487
488/// AAuth agent-identity settings. Serde-serializable so it rides the spawn
489/// payload verbatim, giving one identity per process tree. The struct is always
490/// defined rather than feature-gated, so the payload plumbing compiles the same
491/// either way; the CLI flags that populate it require `--features aauth` at
492/// validation.
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
494pub struct AAuthSettings {
495    /// The Agent Provider base URL (`https://apd.example`) — enroll + agent-token.
496    pub provider: String,
497    /// The durable Ed25519 key file (created 0600 if absent). A SHARED-FS path,
498    /// like `--tls-ca`, so a re-exec'd subagent resolves the same identity.
499    pub key_file: String,
500    /// A one-time enrollment token template (`{{secret:…}}`), if the provider is
501    /// in `token` mode. Secret-free (a reference, never an inline secret).
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub enrollment_token: Option<String>,
504    /// Path to an **enrollment assertion** file the provider federates against
505    /// — e.g. a Kubernetes projected ServiceAccount token whose audience is the
506    /// provider. Re-read fresh on every enroll (projected tokens rotate), so this
507    /// is a PATH, not the assertion itself; it rides the spawn payload like
508    /// `key_file`. Presented in the `/enroll` body; never logged.
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub enroll_assertion_file: Option<String>,
511    /// The user's Person Server (`ps` claim), which scopes the identity to a
512    /// user. It is carried through enrollment; agentd does not run the
513    /// interactive consent flow itself.
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub person_server: Option<String>,
516}
517
518/// Does `s` name a remote MCP endpoint? True for the Streamable HTTP schemes
519/// agentd dials.
520pub fn is_mcp_endpoint(s: &str) -> bool {
521    let s = s.trim();
522    // This is a SHAPE test only, so plain `http://` passes here. Whether a
523    // given `http://` host is admissible (loopback only) and whether socket
524    // schemes are refused is decided by `mcp_endpoint_scheme_ok`, the single
525    // gate every server — CLI or config file — flows through at validation.
526    s.starts_with("https://") || s.starts_with("http://")
527}
528
529/// Whether an MCP-server endpoint scheme is admissible: `https://`, or a
530/// loopback `http://` for dev. Socket schemes (`unix:`, `vsock:`) and
531/// non-loopback plaintext are rejected. This gate runs BEFORE the reusable
532/// crate's `McpEndpoint::parse`, which is more permissive, so that a
533/// config-file server — which never goes through `is_mcp_endpoint` or CLI
534/// parsing — is held to the same HTTPS-only rule as a flag.
535pub fn mcp_endpoint_scheme_ok(endpoint: &str) -> Result<(), ConfigError> {
536    let e = endpoint.trim();
537    if e.starts_with("https://") {
538        return Ok(());
539    }
540    if let Some(rest) = e.strip_prefix("http://") {
541        let host = rest.split('/').next().unwrap_or(rest);
542        let host = if host.starts_with('[') {
543            host.split(']').next().map_or(host, |h| &h[1..])
544        } else {
545            host.rsplit_once(':').map_or(host, |(h, _)| h)
546        };
547        if crate::net::http::is_loopback_host(host) {
548            return Ok(());
549        }
550        return Err(usage(format!(
551            "mcp endpoint plaintext http:// is allowed for loopback only; use https:// (got: {endpoint})"
552        )));
553    }
554    Err(usage(format!(
555        "mcp endpoint must be https://host[:port][/path] (got: {endpoint})"
556    )))
557}
558
559/// The fully-resolved, validated configuration.
560#[derive(Clone, PartialEq)]
561pub struct Config {
562    pub instruction: Option<String>,
563    pub intelligence: Option<String>,
564    pub intelligence_token: Option<String>,
565    /// Path to a mounted file holding the intelligence credential
566    /// (`--intelligence-token-file` / `AGENTD_INTELLIGENCE_TOKEN_FILE`). The
567    /// token is read and trimmed from this file at load, and re-readable so a
568    /// rotation is picked up; the resolved value lands in `intelligence_token`
569    /// and never in a log. `--intelligence-token` is the inline alternative.
570    pub intelligence_token_file: Option<String>,
571    pub model: Option<String>,
572    /// Model hot-swap policy (`--model-swap` / `AGENTD_MODEL_SWAP`): what an
573    /// in-flight run does when a reload changes `model` under it.
574    /// `finish-on-old` (default) | `restart-turn`. An endpoint repoint that
575    /// leaves the model unchanged is always finish-on-old regardless.
576    /// Reloadable: the reload fans the new policy down with the swap.
577    pub model_swap: SwapPolicy,
578    pub mcp_servers: Vec<McpServerSpec>,
579    /// Declared remote-A2A delegation peers (`--a2a-peer name=endpoint`) —
580    /// what `a2a.delegate` dials. Only honoured in `--features a2a` builds,
581    /// which startup validation enforces.
582    pub a2a_peers: Vec<A2aPeerSpec>,
583    pub mode: Mode,
584    pub subscribe: Vec<String>,
585    /// Subscriptions routed to a **warm continue-session** rather than a fresh
586    /// spawn per event: all events on the URI re-enter one live session, in
587    /// order. Repeatable `--continue <uri>`.
588    pub continue_subscribe: Vec<String>,
589    pub interval: Option<Duration>,
590    pub max_steps: u32,
591    pub max_tokens: u64,
592    /// Per-**instance** cumulative token budget across ALL runs/reactions
593    /// (`--budget-tokens-lifetime` / `AGENT_BUDGET_TOKENS`). `0` = unbounded.
594    /// Distinct from `max_tokens`, which boxes a
595    /// single run: a bounded run folds `min(max_tokens, lifetime)` and trips
596    /// `EXIT_BUDGET(7)`; a reactive instance stops accepting new reactions and
597    /// drains when the cumulative cap is reached.
598    pub budget_tokens_lifetime: u64,
599    pub deadline: Option<Duration>,
600    pub max_depth: u32,
601    pub run_id: String,
602    pub log_level: Level,
603    pub drain_timeout: Duration,
604    /// Path to a pinned workflow JSON file (`--workflow`), driven by
605    /// `--mode workflow`. `None` unless a workflow is pinned.
606    #[cfg(feature = "workflow")]
607    pub workflow_file: Option<String>,
608    /// Resume a pinned workflow from a checkpoint:
609    /// `--workflow-resume <server>:<key>[@seq]` (+ `--workflow-resume-force`).
610    /// The child fetches and verifies the envelope after connecting.
611    #[cfg(feature = "workflow")]
612    pub workflow_resume: Option<crate::subagent::protocol::WorkflowResumeRef>,
613    pub serve_mcp: Option<String>,
614    /// TLS server cert / key PEM **file paths** for an `https://` serve target.
615    /// Required when serving TLS. Only the PATHS live here; the contents — one
616    /// of them a private key — are read at bind time and never logged.
617    pub serve_cert: Option<String>,
618    pub serve_key: Option<String>,
619    /// Client-CA PEM **file path** enabling mutual TLS on the serve target: peers
620    /// must present a certificate chaining to it. This is the primary way the
621    /// `Management` trust domain is minted.
622    pub serve_client_ca: Option<String>,
623    /// Bearer-token secret for the serve target — the ALTERNATIVE auth to mTLS
624    /// (`Authorization: Bearer <token>` mints `Management`). A `sec::secret`
625    /// template (`{{secret-file:PATH}}` / `{{secret:ENV}}`) or a literal; resolved
626    /// at bind time, never logged.
627    pub serve_bearer: Option<String>,
628    /// Extra PEM CA **file path** trusted for OUTBOUND `https://` dials
629    /// (intelligence, MCP servers, A2A peers, OAuth), ADDED to the bundled
630    /// webpki roots — the private/in-cluster PKI trust anchor (`--tls-ca` /
631    /// `AGENTD_TLS_CA`). Public material (a CA certificate, never a key);
632    /// installed process-wide at startup ([`crate::net::tls::install_extra_ca`])
633    /// and inherited by every subagent via the spawn payload. Set-once
634    /// (restart-only): trust anchors must not move under a live run.
635    pub tls_ca: Option<String>,
636    /// AAuth agent-identity config: when the provider URL is set, agentd gets
637    /// an Ed25519 identity + agent token and SIGNS every
638    /// outbound MCP request. `None` = no AAuth (the default). Rides the spawn
639    /// payload to subagents (one identity per process tree). Needs
640    /// `--features aauth`.
641    pub aauth: Option<AAuthSettings>,
642    pub health_file: Option<String>,
643    /// Inbound W3C `traceparent` to continue; with none set, a trace is minted
644    /// from the run id so a run always has one.
645    pub traceparent: Option<String>,
646    /// Opt-in content capture. Off by default: telemetry logs hashes and
647    /// lengths only, so a trace backend never becomes an unreviewed copy of
648    /// every tool argument. `--log-content` adds the actual tool args/results,
649    /// truncated. Propagates to children via the telemetry block.
650    pub log_content: bool,
651    /// Opt-in HTTP probe/scrape surface (`/metrics` + `/healthz` + `/readyz`).
652    /// Off unless set; only honoured in `--features metrics` builds.
653    pub metrics_addr: Option<String>,
654    /// Opt-in cgroup-v2 active enforcement: `auto` (derive `<own-cgroup>/agentd`)
655    /// or an absolute path under `/sys/fs/cgroup`. Each run gets a child cgroup
656    /// for atomic `cgroup.kill` teardown. Best-effort — disabled if not writable;
657    /// agentd stays cgroup-aware, never cgroup-requiring.
658    /// Note: if hard limits are requested and the path points at a shared/existing
659    /// cgroup, delegating its controllers also enables them for its other children.
660    pub cgroup: Option<String>,
661    /// Optional hard `memory.max` for each run's cgroup (`max` or a size like
662    /// `512M`/`2G`/bytes). Needs `--cgroup` + a parent that can delegate the
663    /// `memory` controller; otherwise it no-ops (teardown still works).
664    pub cgroup_memory_max: Option<String>,
665    /// Optional hard `pids.max` for each run's cgroup (`max` or a count). Counts
666    /// *threads*, not just processes, so set it generously (the root subagent is
667    /// multi-threaded). Same delegation requirement as `cgroup_memory_max`.
668    pub cgroup_pids_max: Option<String>,
669    /// Allow a lethal-trifecta grant (all three capability legs in one agent)
670    /// instead of refusing at startup. A process-global operator override,
671    /// deliberately NOT carried in the spawn payload — a child must be granted
672    /// the exception on its own terms rather than inheriting it silently.
673    pub allow_trifecta: bool,
674    /// Optional 5-field UTC cron schedule for `--mode schedule`.
675    /// Only honoured in `--features cron` builds; the production path is an
676    /// external CronJob → `--mode once`.
677    pub cron: Option<String>,
678    /// Where to write the run-outcome report at the terminal transition
679    /// (`--report-file PATH` / `AGENTD_REPORT_FILE`). Written atomically via a
680    /// temp file and rename, so a reader never sees a half-written report. Off
681    /// for a bare CLI run, and inert for `--mode reactive` — a reactive daemon
682    /// has no single terminal outcome, which startup warns about.
683    pub report_file: Option<String>,
684    /// Operator remap for the two *policy* budget exit codes
685    /// (`--budget-exit-code N`).
686    /// `None` ⇒ no remap (the canonical table applies). When set, a final process
687    /// exit of `EXIT_PARTIAL` (3) **or** `EXIT_BUDGET` (7) — and ONLY those two,
688    /// the operator-tunable `policy`-intent codes — is returned to the OS as `N`
689    /// instead, so a Job's `podFailurePolicy` can treat a budget/partial outcome
690    /// as success-or-fail per operator policy. Every other code (a deadline 124, a
691    /// refusal 5, a clean 0) is NEVER remapped. The run **report** still records
692    /// the canonical 3/7 projection + the precise `status`, so the durable record
693    /// stays truthful (and schema-valid) regardless of the remap.
694    pub budget_exit_code: Option<i32>,
695    /// Capacity of the bounded `agentd://events` ring (`--events-ring N` /
696    /// `AGENTD_EVENTS_RING`): the last N emitted lines held in
697    /// memory for the live-tail resource. Default 1024. Only consumed when the
698    /// `events` surface is served (`--serve-mcp` + the `events` feature).
699    pub events_ring: usize,
700    /// Declared intelligence HTTP headers, settable only via the config file's
701    /// `intelligence_headers`. Values are **templates** that may carry
702    /// `{{secret:NAME}}` / `{{secret-file:PATH}}` refs: the names and refs are
703    /// structural, while the resolved secret is never stored here or logged. An
704    /// inline secret-shaped value is rejected at validation. A `BTreeMap`, so
705    /// header order is deterministic.
706    pub intelligence_headers: std::collections::BTreeMap<String, String>,
707    /// Watch the config file for changes and reload (`--watch-config` /
708    /// `AGENTD_WATCH_CONFIG`). When set, the reactive supervisor arms a raw
709    /// `inotify` watch on the config file's PARENT DIRECTORY — a Kubernetes
710    /// ConfigMap volume swap is an atomic directory-symlink rename, which a
711    /// watch on the file itself would miss — and, on a change to the watched
712    /// file, sets the SAME RELOAD latch SIGHUP sets, so there is exactly one
713    /// reload routine to reason about. Always compiled (a uniform `Config`);
714    /// `true` needs the
715    /// `config-watch` build feature (validated, exit 2) AND a config file to
716    /// watch (`--config`/`AGENTD_CONFIG`, else exit 2 — watching nothing is a
717    /// usage error). Off by default; SIGHUP is the portable, dependency-free
718    /// default trigger.
719    pub watch_config: bool,
720    /// The config files that were merged into the FILE layer, in order
721    /// (`AGENTD_CONFIG` entries first, then each `--config`); empty when no file
722    /// is in play. Informational — logged at startup, watched by
723    /// `--watch-config`; never a reload diff (args/env are fixed for the
724    /// process's life).
725    pub config_files: Vec<String>,
726}
727
728impl Default for Config {
729    fn default() -> Self {
730        Config {
731            instruction: None,
732            intelligence: None,
733            intelligence_token: None,
734            intelligence_token_file: None,
735            model: None,
736            model_swap: SwapPolicy::FinishOnOld,
737            mcp_servers: Vec::new(),
738            a2a_peers: Vec::new(),
739            mode: Mode::Once,
740            subscribe: Vec::new(),
741            continue_subscribe: Vec::new(),
742            interval: None,
743            max_steps: 50,
744            max_tokens: 200_000,
745            budget_tokens_lifetime: 0,
746            deadline: Some(Duration::from_secs(600)),
747            max_depth: 4,
748            run_id: String::new(), // filled in load() if unset
749            log_level: Level::Info,
750            drain_timeout: Duration::from_secs(25),
751            #[cfg(feature = "workflow")]
752            workflow_file: None,
753            #[cfg(feature = "workflow")]
754            workflow_resume: None,
755            serve_mcp: None,
756            serve_cert: None,
757            serve_key: None,
758            serve_client_ca: None,
759            serve_bearer: None,
760            tls_ca: None,
761            aauth: None,
762            health_file: None,
763            traceparent: None,
764            log_content: false,
765            metrics_addr: None,
766            cgroup: None,
767            cgroup_memory_max: None,
768            cgroup_pids_max: None,
769            allow_trifecta: false,
770            cron: None,
771            report_file: None,
772            budget_exit_code: None,
773            events_ring: crate::obs::log::EVENTS_RING_DEFAULT,
774            intelligence_headers: std::collections::BTreeMap::new(),
775            // Off by default; flipped to `true` when `--standby` is set unless
776            // `AGENTD_WARM_INTEL` explicitly overrides (resolved in `load`).
777            watch_config: false,
778            config_files: Vec::new(),
779        }
780    }
781}
782
783// Redact the credential — never let it reach a log or a panic message.
784impl fmt::Debug for Config {
785    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
786        f.debug_struct("Config")
787            .field("instruction", &self.instruction.as_deref().map(|_| "<set>"))
788            // The raw `--intelligence` URI can be credential-bearing
789            // (`http://user:pass@host`), so redact it to its transport SCHEME
790            // only — matching `effective_view()` and the `config.loaded` event,
791            // which are already scheme-only — and a Debug render can never leak
792            // an inline endpoint credential.
793            .field(
794                "intelligence",
795                &self
796                    .intelligence
797                    .as_deref()
798                    .map(|u| format!("{}:<redacted>", u.split(':').next().unwrap_or(""))),
799            )
800            .field(
801                "intelligence_token",
802                &self.intelligence_token.as_ref().map(|_| "***"),
803            )
804            .field("intelligence_token_file", &self.intelligence_token_file)
805            .field("model", &self.model)
806            .field("model_swap", &self.model_swap.as_str())
807            .field("mcp_servers", &self.mcp_servers)
808            .field("a2a_peers", &self.a2a_peers)
809            .field("mode", &self.mode)
810            .field("subscribe", &self.subscribe)
811            .field("continue_subscribe", &self.continue_subscribe)
812            .field("interval", &self.interval)
813            .field("max_steps", &self.max_steps)
814            .field("max_tokens", &self.max_tokens)
815            .field("budget_tokens_lifetime", &self.budget_tokens_lifetime)
816            .field("deadline", &self.deadline)
817            .field("max_depth", &self.max_depth)
818            .field("run_id", &self.run_id)
819            .field("log_level", &self.log_level)
820            .field("drain_timeout", &self.drain_timeout)
821            .field("serve_mcp", &self.serve_mcp)
822            // Cert/key/CA are file PATHS, not secrets, so they are safe to
823            // show; the bearer IS a credential, so only its presence appears.
824            .field("serve_cert", &self.serve_cert)
825            .field("serve_key", &self.serve_key)
826            .field("serve_client_ca", &self.serve_client_ca)
827            .field(
828                "serve_bearer",
829                &self.serve_bearer.as_ref().map(|_| "<redacted>"),
830            )
831            .field("tls_ca", &self.tls_ca)
832            .field("health_file", &self.health_file)
833            .field("traceparent", &self.traceparent)
834            .field("log_content", &self.log_content)
835            .field("metrics_addr", &self.metrics_addr)
836            .field("cgroup", &self.cgroup)
837            .field("cgroup_memory_max", &self.cgroup_memory_max)
838            .field("cgroup_pids_max", &self.cgroup_pids_max)
839            .field("allow_trifecta", &self.allow_trifecta)
840            .field("cron", &self.cron)
841            .field("report_file", &self.report_file)
842            .field("events_ring", &self.events_ring)
843            // Header NAMES only: a value may carry a {{secret:…}} ref, and a
844            // rendered config is not a place a secret may reach.
845            .field(
846                "intelligence_headers",
847                &self.intelligence_headers.keys().collect::<Vec<_>>(),
848            )
849            .field("watch_config", &self.watch_config)
850            .field("config_files", &self.config_files)
851            .finish()
852    }
853}
854
855/// What `load()` can short-circuit with. `Help`/`Version`/`Capabilities` are
856/// *not* errors (exit 0); `Usage` is a validation or parse failure (exit 2).
857/// `Capabilities` carries the pretty-printed manifest JSON — the
858/// side-effect-free admission probe (`agentd --capabilities`), short-circuited
859/// before run-required validation so it succeeds even with no instruction,
860/// which is what lets agentctl probe an image that has no run config yet.
861#[derive(Debug)]
862pub enum ConfigError {
863    Help(String),
864    Version(String),
865    Capabilities(String),
866    Usage(String),
867    /// `--config-schema`: the JSON Schema of the config file,
868    /// printed to **stdout**, exit 0 — a side-effect-free schema export so
869    /// agentctl can validate a CR before applying it.
870    Schema(String),
871    /// `--validate-config`: the admission verdict. `Ok(line)` is
872    /// a valid config (one `config.valid` line, exit 0); `Err(lines)` is one or
873    /// more `config.invalid` diagnostics (exit 2). The caller prints to stderr.
874    Validate(Result<String, String>),
875}
876
877impl fmt::Display for ConfigError {
878    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
879        match self {
880            ConfigError::Help(s)
881            | ConfigError::Version(s)
882            | ConfigError::Capabilities(s)
883            | ConfigError::Schema(s) => {
884                write!(f, "{s}")
885            }
886            ConfigError::Usage(s) => write!(f, "{s}"),
887            ConfigError::Validate(Ok(s)) | ConfigError::Validate(Err(s)) => write!(f, "{s}"),
888        }
889    }
890}
891
892/// De-branding normalization: accept the neutral `AGENT_*` env prefix as an
893/// input alias for the branded `AGENTD_*` one. Returns the env list with a
894/// synthesized `AGENTD_<X>` entry for every `AGENT_<X>` whose branded form is
895/// ABSENT — the branded spelling WINS when both are present, since it is the
896/// more specific of the two. Branded keys are never dropped, and a
897/// non-prefixed key (e.g. `INSTRUCTION`) is untouched. Done once, here, so
898/// every downstream `AGENTD_*` read transparently honours `AGENT_*` too
899/// without a per-read change.
900pub(crate) fn debrand_env(env: &[(String, String)]) -> Vec<(String, String)> {
901    let have: std::collections::HashSet<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
902    let mut out: Vec<(String, String)> = env.to_vec();
903    for (k, v) in env {
904        // `AGENTD_*` itself does NOT match `AGENT_` (the 6th char is `D`, not `_`),
905        // so branded keys are never re-aliased; only true neutral keys are.
906        if let Some(suffix) = k.strip_prefix("AGENT_") {
907            let branded = format!("AGENTD_{suffix}");
908            if !have.contains(branded.as_str()) {
909                out.push((branded, v.clone()));
910            }
911        }
912    }
913    out
914}
915
916impl Config {
917    /// Resolve config from CLI args (excluding the leading program name) and
918    /// the environment, applying precedence — `built-in default < FILE < env <
919    /// flag` — and validating the result before any side effect.
920    pub fn load(args: &[String], env: &[(String, String)]) -> Result<Config, ConfigError> {
921        // De-branding: every branded `AGENTD_*` env var also accepts
922        // its neutral `AGENT_*` spelling on input. Normalize ONCE here — for any
923        // `AGENT_<X>` present, synthesize an `AGENTD_<X>` entry iff the branded form
924        // is absent (branded WINS on conflict, preserving back-compat) — so every
925        // downstream `AGENTD_*` read below transparently honours `AGENT_*` too, with
926        // no per-read change. The branded spelling is never dropped, only aliased.
927        let env = debrand_env(env);
928        let envmap: HashMap<&str, &str> =
929            env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
930
931        // `--config-schema`: a side-effect-free schema export.
932        // The schema is static (generated from the `ConfigFile` types), so it
933        // short-circuits BEFORE the file is even read — exit 0, JSON to stdout.
934        if args.iter().any(|a| a == "--config-schema") {
935            let schema = crate::config::file::config_schema();
936            let json = serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string());
937            return Err(ConfigError::Schema(format!("{json}\n")));
938        }
939        // `--validate-config`: captured here, acted on at the end.
940        // It is the side-effect-free admission verdict — it validates whatever
941        // config is given and never requires an --instruction to *validate*.
942        let validate_config = args.iter().any(|a| a == "--validate-config");
943
944        let mut c = Config::default();
945
946        // --- FILE layer (precedence layer 1) ---
947        // `--config <path>` / `AGENTD_CONFIG`. The file is the lowest
948        // non-default layer: env and flags below override it, while repeatable
949        // list flags ADD to the file's lists. A malformed or unreadable file is
950        // exit 2 BEFORE any side effect — it is parsed before the env and flag
951        // layers touch `c`. Several files compose into ONE document, in order:
952        // `AGENTD_CONFIG` (a `:`-separated list) first, then each `--config`,
953        // with each later file merged over the earlier ones by RFC 7396 JSON
954        // Merge Patch rules (objects merge, scalars and lists replace, `null`
955        // unsets). Each file is YAML or JSON
956        // by extension, else sniffed (`file::Format`).
957        let config_paths = config_paths_from_map(args, &envmap).paths;
958        let file_present = !config_paths.is_empty();
959        if file_present {
960            let (doc, loaded) = file::read_documents(&config_paths).map_err(usage)?;
961            apply_document(&mut c, doc, "config file", false)?;
962            c.config_files = loaded.into_iter().map(|(p, _)| p).collect();
963        }
964
965        // --- env layer ---
966        // The two REQUIRED inputs each accept a bare spelling alongside the
967        // prefixed one (`INSTRUCTION`/`INTELLIGENCE` next to `AGENT[D]_*`), so the
968        // minimal quickstart is `INSTRUCTION=… INTELLIGENCE=… agentd`. Precedence
969        // within the env layer is by specificity: branded > neutral (debrand_env
970        // above) > bare — a prefixed spelling always wins over the bare one.
971        if let Some(v) = envmap
972            .get("AGENTD_INSTRUCTION")
973            .or_else(|| envmap.get("INSTRUCTION"))
974        {
975            c.instruction = Some((*v).to_string());
976        }
977        if let Some(v) = envmap
978            .get("AGENTD_INTELLIGENCE")
979            .or_else(|| envmap.get("INTELLIGENCE"))
980        {
981            c.intelligence = Some((*v).to_string());
982        }
983        if let Some(v) = envmap.get("AGENTD_INTELLIGENCE_TOKEN") {
984            c.intelligence_token = Some((*v).to_string());
985        }
986        if let Some(v) = envmap.get("AGENTD_INTELLIGENCE_TOKEN_FILE") {
987            c.intelligence_token_file = Some((*v).to_string());
988        }
989        if let Some(v) = envmap.get("AGENTD_TLS_CA") {
990            c.tls_ca = Some((*v).to_string());
991        }
992        if let Some(v) = envmap.get("AGENTD_MODEL") {
993            c.model = Some((*v).to_string());
994        }
995        if let Some(v) = envmap.get("AGENTD_MODEL_SWAP") {
996            c.model_swap = SwapPolicy::parse(v).ok_or_else(|| {
997                usage(format!(
998                    "invalid AGENTD_MODEL_SWAP: {v} (want finish-on-old|restart-turn)"
999                ))
1000            })?;
1001        }
1002        if let Some(v) = envmap.get("AGENTD_MODE") {
1003            c.mode = Mode::parse(v).ok_or_else(|| usage(format!("invalid AGENTD_MODE: {v}")))?;
1004        }
1005        if let Some(v) = envmap.get("AGENTD_MAX_STEPS") {
1006            c.max_steps = v
1007                .parse()
1008                .map_err(|_| usage(format!("invalid AGENTD_MAX_STEPS: {v}")))?;
1009        }
1010        if let Some(v) = envmap.get("AGENTD_MAX_TOKENS") {
1011            c.max_tokens = v
1012                .parse()
1013                .map_err(|_| usage(format!("invalid AGENTD_MAX_TOKENS: {v}")))?;
1014        }
1015        // The per-instance lifetime budget. The neutral `AGENT_BUDGET_TOKENS`
1016        // is auto-aliased to `AGENTD_BUDGET_TOKENS` by the debranding pass
1017        // above, so only the branded name is read here.
1018        if let Some(v) = envmap.get("AGENTD_BUDGET_TOKENS") {
1019            c.budget_tokens_lifetime = v
1020                .parse()
1021                .map_err(|_| usage(format!("invalid AGENTD_BUDGET_TOKENS: {v}")))?;
1022        }
1023        if let Some(v) = envmap.get("AGENTD_DEADLINE") {
1024            c.deadline = Some(parse_duration(v).map_err(usage)?);
1025        }
1026        if let Some(v) = envmap.get("AGENTD_RUN_ID") {
1027            c.run_id = (*v).to_string();
1028        }
1029        if let Some(v) = envmap.get("AGENTD_LOG_LEVEL") {
1030            c.log_level =
1031                Level::parse(v).ok_or_else(|| usage(format!("invalid AGENTD_LOG_LEVEL: {v}")))?;
1032        }
1033        if let Some(v) = envmap.get("AGENTD_DRAIN_TIMEOUT") {
1034            c.drain_timeout = parse_duration(v).map_err(usage)?;
1035        }
1036        if let Some(v) = envmap.get("AGENTD_LOG_CONTENT") {
1037            c.log_content = truthy(v);
1038        }
1039        if let Some(v) = envmap.get("AGENTD_METRICS_ADDR") {
1040            c.metrics_addr = Some((*v).to_string());
1041        }
1042        if let Some(v) = envmap.get("AGENTD_CGROUP") {
1043            c.cgroup = Some((*v).to_string());
1044        }
1045        if let Some(v) = envmap.get("AGENTD_CGROUP_MEMORY_MAX") {
1046            c.cgroup_memory_max = Some((*v).to_string());
1047        }
1048        if let Some(v) = envmap.get("AGENTD_CGROUP_PIDS_MAX") {
1049            c.cgroup_pids_max = Some((*v).to_string());
1050        }
1051        if let Some(v) = envmap.get("AGENTD_ALLOW_TRIFECTA") {
1052            c.allow_trifecta = truthy(v);
1053        }
1054        if let Some(v) = envmap.get("AGENTD_CRON") {
1055            c.cron = Some((*v).to_string());
1056        }
1057        if let Some(v) = envmap.get("AGENTD_REPORT_FILE") {
1058            c.report_file = Some((*v).to_string());
1059        }
1060        if let Some(v) = envmap.get("AGENTD_EVENTS_RING") {
1061            c.events_ring = v
1062                .parse()
1063                .map_err(|_| usage(format!("invalid AGENTD_EVENTS_RING: {v}")))?;
1064        }
1065        #[cfg(feature = "workflow")]
1066        if let Some(v) = envmap.get("AGENTD_WORKFLOW") {
1067            c.workflow_file = Some((*v).to_string());
1068        }
1069        #[cfg(feature = "workflow")]
1070        if let Some(v) = envmap.get("AGENTD_WORKFLOW_RESUME") {
1071            c.workflow_resume = Some(parse_workflow_resume(v)?);
1072        }
1073        if let Some(v) = envmap.get("AGENTD_SERVE_MCP") {
1074            c.serve_mcp = Some((*v).to_string());
1075        }
1076        // TLS material + auth for an `https://` serve target.
1077        if let Some(v) = envmap.get("AGENTD_SERVE_CERT") {
1078            c.serve_cert = Some((*v).to_string());
1079        }
1080        if let Some(v) = envmap.get("AGENTD_SERVE_KEY") {
1081            c.serve_key = Some((*v).to_string());
1082        }
1083        if let Some(v) = envmap.get("AGENTD_SERVE_CLIENT_CA") {
1084            c.serve_client_ca = Some((*v).to_string());
1085        }
1086        if let Some(v) = envmap.get("AGENTD_SERVE_BEARER") {
1087            c.serve_bearer = Some((*v).to_string());
1088        }
1089        // File-watch reload trigger. `AGENTD_WATCH_CONFIG` is a bool; a
1090        // `--watch-config` flag below overrides it. Needs the `config-watch`
1091        // build feature and a config file to watch — both validated, exit 2.
1092        if let Some(v) = envmap.get("AGENTD_WATCH_CONFIG") {
1093            c.watch_config = truthy(v);
1094        }
1095        // A single `AGENTD_A2A_PEER` env declares one peer: the env channel
1096        // carries one value, so more peers need repeated `--a2a-peer` flags.
1097        if let Some(v) = envmap.get("AGENTD_A2A_PEER") {
1098            c.a2a_peers.push(parse_a2a_peer_spec(v)?);
1099        }
1100        if let Some(v) = envmap.get("AGENTD_TRACEPARENT") {
1101            c.traceparent = Some((*v).to_string());
1102        }
1103
1104        // --- env layer, path-derived names (config::paths) ---
1105        // Every config-file path is settable as `AGENTD_<PATH>` / `AGENT_<PATH>`
1106        // / bare `<PATH>` (`.` → `_`, upper-cased): `limits.max_steps` ⇒
1107        // `AGENTD_LIMITS_MAX_STEPS`. The names derive from the schema, so a
1108        // re-defined parameter set needs no plumbing here. Applied AFTER the
1109        // named env reads above, so where a short spelling and a path spelling
1110        // both name one field, the path spelling — the canonical form — wins
1111        // within the env layer. Flags below still override both.
1112        {
1113            let (doc, applied) = paths::env_document(&envmap).map_err(usage)?;
1114            if !applied.is_empty() {
1115                // Setting a path SETS its value: a list/map path from env
1116                // replaces the file's (the named `AGENTD_A2A_PEER` etc. add).
1117                apply_document(&mut c, doc, "env", true)?;
1118            }
1119        }
1120
1121        // --- flag layer (overrides env) ---
1122        // `--mcp-tags` may precede or follow its `--mcp`; collect and apply once
1123        // every server is known.
1124        let mut mcp_tags: Vec<(String, Vec<TrifectaTag>)> = Vec::new();
1125        // `--capabilities` is the admission probe: captured here and resolved
1126        // after the whole config is parsed but BEFORE run-required validation,
1127        // so it reflects whatever config is present and still succeeds when
1128        // there is no instruction to run.
1129        let mut capabilities = false;
1130        // AAuth sub-flags accumulate here (order-independent) and are
1131        // assembled into `c.aauth` after the loop.
1132        let mut aauth_provider: Option<String> = None;
1133        let mut aauth_key_file: Option<String> = None;
1134        let mut aauth_enroll_token: Option<String> = None;
1135        let mut aauth_enroll_assertion_file: Option<String> = None;
1136        let mut aauth_person_server: Option<String> = None;
1137        let mut it = args.iter().peekable();
1138        while let Some(arg) = it.next() {
1139            let mut take = |name: &str| -> Result<String, ConfigError> {
1140                it.next()
1141                    .cloned()
1142                    .ok_or_else(|| usage(format!("{name} requires a value")))
1143            };
1144            match arg.as_str() {
1145                "-h" | "--help" => return Err(ConfigError::Help(help_text())),
1146                "-V" | "--version" => {
1147                    return Err(ConfigError::Version(format!("agentd {}\n", crate::VERSION)));
1148                }
1149                "--capabilities" => capabilities = true,
1150                // Already resolved into the FILE layer above; consume its value
1151                // here so the arg-loop doesn't reject it as unknown.
1152                "--config" | "-c" => {
1153                    let _ = take("--config")?;
1154                }
1155                // `--config=a.yaml` / `-c=a.yaml`: value already attached.
1156                a if matches!(config_flag(a), ConfigFlag::Inline(_)) => {}
1157                // Flags acted on outside the arg loop (schema short-circuits at the
1158                // top of load; validate is acted on after full resolution). They
1159                // take no value — accept and ignore here.
1160                "--config-schema" | "--validate-config" => {}
1161                "--instruction" => c.instruction = Some(take("--instruction")?),
1162                "--intelligence-token-file" => {
1163                    c.intelligence_token_file = Some(take("--intelligence-token-file")?)
1164                }
1165                "--instruction-file" => {
1166                    let p = take("--instruction-file")?;
1167                    c.instruction = Some(read_file(&p)?);
1168                }
1169                "--intelligence" => c.intelligence = Some(take("--intelligence")?),
1170                "--intelligence-token" => {
1171                    c.intelligence_token = Some(take("--intelligence-token")?)
1172                }
1173                "--model" => c.model = Some(take("--model")?),
1174                "--model-swap" => {
1175                    let v = take("--model-swap")?;
1176                    c.model_swap = SwapPolicy::parse(&v).ok_or_else(|| {
1177                        usage(format!(
1178                            "invalid --model-swap: {v} (want finish-on-old|restart-turn)"
1179                        ))
1180                    })?;
1181                }
1182                "--mcp" => {
1183                    let spec = take("--mcp")?;
1184                    c.mcp_servers.push(parse_mcp_spec(&spec)?);
1185                }
1186                "--a2a-peer" => {
1187                    let spec = take("--a2a-peer")?;
1188                    c.a2a_peers.push(parse_a2a_peer_spec(&spec)?);
1189                }
1190                "--mode" => {
1191                    let v = take("--mode")?;
1192                    c.mode =
1193                        Mode::parse(&v).ok_or_else(|| usage(format!("invalid --mode: {v}")))?;
1194                }
1195                "--subscribe" => c.subscribe.push(take("--subscribe")?),
1196                "--continue" => c.continue_subscribe.push(take("--continue")?),
1197                "--interval" => {
1198                    c.interval = Some(parse_duration(&take("--interval")?).map_err(usage)?)
1199                }
1200                "--cron" => c.cron = Some(take("--cron")?),
1201                "--max-steps" => {
1202                    let v = take("--max-steps")?;
1203                    c.max_steps = v
1204                        .parse()
1205                        .map_err(|_| usage(format!("invalid --max-steps: {v}")))?;
1206                }
1207                "--max-tokens" => {
1208                    let v = take("--max-tokens")?;
1209                    c.max_tokens = v
1210                        .parse()
1211                        .map_err(|_| usage(format!("invalid --max-tokens: {v}")))?;
1212                }
1213                "--budget-tokens-lifetime" => {
1214                    let v = take("--budget-tokens-lifetime")?;
1215                    c.budget_tokens_lifetime = v
1216                        .parse()
1217                        .map_err(|_| usage(format!("invalid --budget-tokens-lifetime: {v}")))?;
1218                }
1219                "--deadline" => {
1220                    c.deadline = Some(parse_duration(&take("--deadline")?).map_err(usage)?)
1221                }
1222                "--max-depth" => {
1223                    let v = take("--max-depth")?;
1224                    c.max_depth = v
1225                        .parse()
1226                        .map_err(|_| usage(format!("invalid --max-depth: {v}")))?;
1227                }
1228                "--run-id" => c.run_id = take("--run-id")?,
1229                "--log-level" => {
1230                    let v = take("--log-level")?;
1231                    c.log_level = Level::parse(&v)
1232                        .ok_or_else(|| usage(format!("invalid --log-level: {v}")))?;
1233                }
1234                "--drain-timeout" => {
1235                    c.drain_timeout = parse_duration(&take("--drain-timeout")?).map_err(usage)?
1236                }
1237                "--log-content" => c.log_content = true,
1238                "--allow-trifecta" => c.allow_trifecta = true,
1239                "--mcp-tags" => mcp_tags.push(parse_mcp_tags(&take("--mcp-tags")?)?),
1240                "--metrics-addr" => c.metrics_addr = Some(take("--metrics-addr")?),
1241                "--cgroup" => c.cgroup = Some(take("--cgroup")?),
1242                "--cgroup-memory-max" => c.cgroup_memory_max = Some(take("--cgroup-memory-max")?),
1243                "--cgroup-pids-max" => c.cgroup_pids_max = Some(take("--cgroup-pids-max")?),
1244                #[cfg(feature = "workflow")]
1245                "--workflow" => c.workflow_file = Some(take("--workflow")?),
1246                #[cfg(feature = "workflow")]
1247                "--workflow-resume" => {
1248                    // Order-independent with --workflow-resume-force: a force
1249                    // remembered from either side survives.
1250                    let force = c.workflow_resume.as_ref().is_some_and(|r| r.force);
1251                    let mut r = parse_workflow_resume(&take("--workflow-resume")?)?;
1252                    r.force = r.force || force;
1253                    c.workflow_resume = Some(r);
1254                }
1255                #[cfg(feature = "workflow")]
1256                "--workflow-resume-force" => {
1257                    match c.workflow_resume.as_mut() {
1258                        Some(r) => r.force = true,
1259                        // Order-independent: remember the force for a later
1260                        // --workflow-resume (validated below to require one).
1261                        None => {
1262                            c.workflow_resume = Some(crate::subagent::protocol::WorkflowResumeRef {
1263                                server: String::new(),
1264                                key: String::new(),
1265                                seq: None,
1266                                force: true,
1267                            })
1268                        }
1269                    }
1270                }
1271                "--serve-mcp" => c.serve_mcp = Some(take("--serve-mcp")?),
1272                "--serve-cert" => c.serve_cert = Some(take("--serve-cert")?),
1273                "--serve-key" => c.serve_key = Some(take("--serve-key")?),
1274                "--serve-client-ca" => c.serve_client_ca = Some(take("--serve-client-ca")?),
1275                "--serve-bearer" => c.serve_bearer = Some(take("--serve-bearer")?),
1276                "--tls-ca" => c.tls_ca = Some(take("--tls-ca")?),
1277                // AAuth: --aauth-provider is what turns it on; the rest fill
1278                // AAuthSettings. Gathered into `c.aauth` after the loop, so the
1279                // sub-flags may appear in any order.
1280                "--aauth-provider" => aauth_provider = Some(take("--aauth-provider")?),
1281                "--aauth-key-file" => aauth_key_file = Some(take("--aauth-key-file")?),
1282                "--aauth-enroll-token" => aauth_enroll_token = Some(take("--aauth-enroll-token")?),
1283                "--aauth-enroll-assertion-file" => {
1284                    aauth_enroll_assertion_file = Some(take("--aauth-enroll-assertion-file")?)
1285                }
1286                "--aauth-person-server" => {
1287                    aauth_person_server = Some(take("--aauth-person-server")?)
1288                }
1289                // File-watch reload trigger: watch the config file's
1290                // directory and reload on a change. Needs the
1291                // `config-watch` build feature + a `--config`/`AGENTD_CONFIG`
1292                // file (both validated, exit 2). Off by default; SIGHUP is the
1293                // portable default trigger.
1294                "--watch-config" => c.watch_config = true,
1295                "--health-file" => c.health_file = Some(take("--health-file")?),
1296                "--traceparent" => c.traceparent = Some(take("--traceparent")?),
1297                "--report-file" => c.report_file = Some(take("--report-file")?),
1298                // Remap the two operator-tunable `policy` budget codes
1299                // (EXIT_PARTIAL 3 / EXIT_BUDGET 7) to N at the final process
1300                // exit. N must be a valid POSIX exit byte (0..=255), and only
1301                // 3 and 7 are ever remapped — every other code carries a
1302                // meaning the operator does not get to redefine.
1303                "--budget-exit-code" => {
1304                    let v = take("--budget-exit-code")?;
1305                    let n: i32 = v
1306                        .parse()
1307                        .ok()
1308                        .filter(|n| (0..=255).contains(n))
1309                        .ok_or_else(|| {
1310                            usage(format!("invalid --budget-exit-code: {v} (want 0..=255)"))
1311                        })?;
1312                    c.budget_exit_code = Some(n);
1313                }
1314                "--events-ring" => {
1315                    let v = take("--events-ring")?;
1316                    c.events_ring = v
1317                        .parse()
1318                        .map_err(|_| usage(format!("invalid --events-ring: {v}")))?;
1319                }
1320                // Generic path flags (config::paths): any config-file path is a
1321                // flag — `--limits.max-steps 5` / `--limits-max-steps 5` — typed
1322                // by the schema and applied in argument order like every other
1323                // flag (last writer wins; lists add). A boolean path takes an
1324                // optional value (`--x` alone means true). Anything that is not
1325                // a known flag NOR a config path is the usual usage error.
1326                other => match paths::resolve_flag(other).map_err(usage)? {
1327                    Some(target) => {
1328                        let raw = if matches!(target.value_kind(), paths::Kind::Boolean)
1329                            && !it.peek().is_some_and(|n| !n.starts_with("--"))
1330                        {
1331                            "true".to_string()
1332                        } else {
1333                            it.next()
1334                                .cloned()
1335                                .ok_or_else(|| usage(format!("{other} requires a value")))?
1336                        };
1337                        let value = paths::coerce(target.value_kind(), &raw)
1338                            .map_err(|e| usage(format!("invalid {other}: {e}")))?;
1339                        // Setting a path SETS its value (a list path replaces the
1340                        // list); a `--<map>.<key>` entry flag merges ONE key.
1341                        let replace = target.entry.is_none();
1342                        apply_document(&mut c, target.document(value), other, replace)?;
1343                    }
1344                    None => return Err(usage(format!("unknown argument: {other}"))),
1345                },
1346            }
1347        }
1348
1349        // Assemble the AAuth settings. The provider (flag or
1350        // AGENT_AAUTH_PROVIDER env) is what turns it on; a key file defaults to
1351        // `./agent.key` in the process cwd (a durable, shared-fs identity).
1352        let aauth_provider =
1353            aauth_provider.or_else(|| envmap.get("AGENT_AAUTH_PROVIDER").map(|v| v.to_string()));
1354        if let Some(provider) = aauth_provider {
1355            c.aauth = Some(AAuthSettings {
1356                provider,
1357                key_file: aauth_key_file
1358                    .or_else(|| envmap.get("AGENT_AAUTH_KEY_FILE").map(|v| v.to_string()))
1359                    .unwrap_or_else(|| "agent.key".to_string()),
1360                enrollment_token: aauth_enroll_token.or_else(|| {
1361                    envmap
1362                        .get("AGENT_AAUTH_ENROLL_TOKEN")
1363                        .map(|v| v.to_string())
1364                }),
1365                enroll_assertion_file: aauth_enroll_assertion_file.or_else(|| {
1366                    envmap
1367                        .get("AGENT_AAUTH_ENROLL_ASSERTION_FILE")
1368                        .map(|v| v.to_string())
1369                }),
1370                person_server: aauth_person_server.or_else(|| {
1371                    envmap
1372                        .get("AGENT_AAUTH_PERSON_SERVER")
1373                        .map(|v| v.to_string())
1374                }),
1375            });
1376        }
1377
1378        // Apply collected `--mcp-tags` to their servers (order-independent).
1379        for (name, tags) in mcp_tags {
1380            match c.mcp_servers.iter_mut().find(|s| s.name == name) {
1381                Some(s) => s.tags = tags,
1382                None => {
1383                    return Err(usage(format!(
1384                        "--mcp-tags references unknown server '{name}'"
1385                    )));
1386                }
1387            }
1388        }
1389
1390        if c.run_id.is_empty() {
1391            c.run_id = generate_run_id();
1392        }
1393
1394        // `--capabilities` is owned by the settings loader
1395        // (`config::v2` / `runtime::capabilities`), which the binary routes to
1396        // first, so this branch is unreachable in the shipped binary and only
1397        // exists so the flat path answers something coherent if it is called.
1398        if capabilities {
1399            return Err(ConfigError::Capabilities(
1400                "{\"note\":\"--capabilities is served by the agentd loader\"}\n".to_string(),
1401            ));
1402        }
1403
1404        // Resolve `--intelligence-token-file` into the token. An inline
1405        // `--intelligence-token` or env wins, being the higher-precedence
1406        // source; the file is the fallback. Read and trimmed here, but a
1407        // missing file is reported through `validate()` so `--validate-config`
1408        // collects it with the rest, and the resolved value never reaches a
1409        // log.
1410        c.resolve_token_file()?;
1411
1412        // `--validate-config`: the side-effect-free admission verdict. Run the
1413        // FULL validation pipeline, collecting EVERY diagnostic
1414        // (not fast-failing on the first, unlike startup) so an operator/CI sees
1415        // all problems in one pass, then short-circuit with the verdict. It does
1416        // NOT require an --instruction to *validate* — it validates whatever it is
1417        // given. The caller prints to stderr and maps the result to exit 0/2.
1418        if validate_config {
1419            return Err(ConfigError::Validate(c.validate_collect_all(file_present)));
1420        }
1421
1422        c.validate()?;
1423        // `--watch-config` requires a config FILE to watch: watching nothing
1424        // is a usage error. This is the one check that needs the
1425        // resolved file-presence (not a `Config` field), so it lives here in
1426        // `load` (and is mirrored in `validate_collect_all` for the admission
1427        // gate). Checked after `validate()` so the feature-gate error (in
1428        // `validate()`) surfaces first when both are wrong.
1429        if c.watch_config && !file_present {
1430            return Err(usage(
1431                "--watch-config requires a config file (--config / AGENTD_CONFIG)".into(),
1432            ));
1433        }
1434        Ok(c)
1435    }
1436
1437    /// The config files in play for `args`/`env`, in merge order: the
1438    /// `AGENTD_CONFIG` / `AGENT_CONFIG` list (`:`-separated, PATH-style) first,
1439    /// then every `--config <path>` in argument order. Empty when none. Shared
1440    /// by `load`, the reload path, and the file watcher (which arms one watch
1441    /// per file). Pure.
1442    pub fn config_paths_from(args: &[String], env: &[(String, String)]) -> Vec<String> {
1443        let env = debrand_env(env);
1444        let envmap: HashMap<&str, &str> =
1445            env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
1446        config_paths_from_map(args, &envmap).paths
1447    }
1448
1449    /// Resolve `--intelligence-token-file` into `intelligence_token` when no
1450    /// inline token is set. A read failure surfaces as a usage error (exit 2 at
1451    /// startup; collected by `--validate-config`). The token is never logged —
1452    /// the error carries only the path.
1453    fn resolve_token_file(&mut self) -> Result<(), ConfigError> {
1454        if self.intelligence_token.is_some() {
1455            return Ok(()); // inline source wins (higher precedence)
1456        }
1457        if let Some(path) = self.intelligence_token_file.clone() {
1458            let tok = crate::sec::secret::read_token_file(&path).map_err(usage)?;
1459            self.intelligence_token = Some(tok);
1460        }
1461        Ok(())
1462    }
1463
1464    /// Run the full validation pipeline, collecting EVERY diagnostic as one
1465    /// NDJSON `config.{valid,invalid}` line set. `Ok(line)` ⇒ valid (exit 0);
1466    /// `Err(lines)` ⇒ one or more `config.invalid` lines (exit 2).
1467    ///
1468    /// Each independent check is run and its message collected, so the operator
1469    /// sees all problems at once. The check SET is exactly `validate()`'s: there
1470    /// is one validation authority, so the admission gate can never accept a
1471    /// config the startup path would refuse.
1472    fn validate_collect_all(&self, file_present: bool) -> Result<String, String> {
1473        let mut diags: Vec<String> = Vec::new();
1474        // `validate()` is fast-fail, so it cannot report everything on its own,
1475        // and re-running it once per fixed error would be O(n²) and brittle.
1476        // Instead the independent declarative checks run directly here and each
1477        // failing one is appended; the header/secret checks plus one final
1478        // `validate()` pass — which catches anything not separately enumerated
1479        // — give complete coverage from a single source of truth.
1480        self.collect_header_diags(&mut diags);
1481        // Run the authoritative validate() and, if it fails, record its message
1482        // (it is fast-fail, so this is the first non-header structural problem).
1483        // `validate()` also runs the header check, so skip a duplicate when the
1484        // failure is a header diag we already collected.
1485        if let Err(e) = self.validate() {
1486            let msg = e.to_string();
1487            if !diags.iter().any(|d| msg.ends_with(d.as_str())) {
1488                diags.push(msg);
1489            }
1490        }
1491        // `--watch-config` needs a config FILE to watch — the one check that
1492        // depends on file presence, mirrored from `load`'s startup path so
1493        // the admission gate (`--validate-config`) rejects it too.
1494        if self.watch_config && !file_present {
1495            diags.push("--watch-config requires a config file (--config / AGENTD_CONFIG)".into());
1496        }
1497        // The reload-coherence check, with no running config at the admission
1498        // gate (`running = None`), so this reports the restart-only-field-in-
1499        // file WARNINGS and the reloadable-subset consistency ERRORS. An
1500        // admission webhook sees both; a coherence ERROR makes the verdict invalid.
1501        // (Internal-consistency errors here largely overlap with `validate()`'s
1502        // own checks, so dedup by message suffix to avoid a double line.)
1503        match Config::reload_coherence_check(self, None, file_present) {
1504            Ok(()) => {}
1505            Err(coh) => {
1506                for d in coh.into_iter().filter(|d| d.is_error()) {
1507                    let line = format!("{}: {}", d.field, d.msg);
1508                    if !diags.iter().any(|existing| existing.ends_with(&d.msg)) {
1509                        diags.push(line);
1510                    }
1511                }
1512            }
1513        }
1514        if diags.is_empty() {
1515            Ok(config_valid_line())
1516        } else {
1517            Err(diags
1518                .into_iter()
1519                .map(|d| config_invalid_line(&d))
1520                .collect::<Vec<_>>()
1521                .join("\n"))
1522        }
1523    }
1524
1525    /// Validate the declared `intelligence_headers`: a value may be a plain
1526    /// scalar or carry `{{secret:NAME}}` / `{{secret-file:PATH}}` refs, but an
1527    /// **inline secret-shaped value** — a header named like a credential whose
1528    /// value is NOT a ref — is rejected, because a secret must be a reference
1529    /// rather than a literal in the file. Every ref must also resolve (the env
1530    /// var is set, the file exists), else exit 2.
1531    fn collect_header_diags(&self, diags: &mut Vec<String>) {
1532        let env = |k: &str| std::env::var(k).ok();
1533        for (name, value) in &self.intelligence_headers {
1534            // A credential-shaped header carrying a literal (non-ref) value
1535            // is the "inline secret in the file" footgun — reject it.
1536            if is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
1537                diags.push(format!(
1538                    "intelligence_headers['{name}'] looks like a credential but has an inline value; \
1539                     use {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}} (never an inline secret)"
1540                ));
1541                continue;
1542            }
1543            // Every secret ref must resolve at startup: a missing env var or
1544            // an unreadable file is exit 2 before any side effect, because a
1545            // ref that does not resolve means the header is simply not sent.
1546            if crate::sec::secret::has_secret_ref(value)
1547                && let Err(e) = crate::sec::secret::refs_resolvable(value, &env)
1548            {
1549                diags.push(format!("intelligence_headers['{name}']: {e}"));
1550            }
1551        }
1552    }
1553
1554    /// The capability-tag union of the root agent's grant, for the Rule-of-Two
1555    /// trifecta check. An untagged MCP server contributes `untrusted_input`,
1556    /// the conservative default. Because a subagent's scope can only narrow,
1557    /// never widen, enforcing on this root union bounds the whole subagent
1558    /// tree.
1559    pub fn trifecta_grant_tags(&self) -> Vec<TrifectaTag> {
1560        let mut tags = Vec::new();
1561        for s in &self.mcp_servers {
1562            if s.tags.is_empty() {
1563                tags.push(TrifectaTag::UntrustedInput);
1564            } else {
1565                tags.extend(s.tags.iter().copied());
1566            }
1567        }
1568        tags
1569    }
1570
1571    /// Reject inconsistent config before any side effect runs.
1572    pub fn validate(&self) -> Result<(), ConfigError> {
1573        // A pinned workflow run (`--mode workflow`) carries its instructions in the
1574        // graph nodes, so it needs no top-level `--instruction` — and neither does
1575        // a PURE reactive WORKFLOW daemon (`--mode reactive --workflow` with no
1576        // subscription routes: its only reactions are the workflow's own
1577        // suspend/resume steps). A daemon that ALSO has --subscribe/--continue
1578        // routes spawns instruction reactions, so those still require one — an
1579        // empty-instruction reaction would hand the model a blank task.
1580        #[cfg(feature = "workflow")]
1581        let needs_instruction = self.mode != Mode::Workflow
1582            && !(self.mode == Mode::Reactive
1583                && self.workflow_file.is_some()
1584                && self.subscribe.is_empty()
1585                && self.continue_subscribe.is_empty());
1586        #[cfg(not(feature = "workflow"))]
1587        let needs_instruction = true;
1588        if needs_instruction
1589            && self
1590                .instruction
1591                .as_deref()
1592                .map(str::trim)
1593                .unwrap_or("")
1594                .is_empty()
1595        {
1596            return Err(usage(
1597                "missing instruction (INSTRUCTION env or --instruction)".into(),
1598            ));
1599        }
1600        if self.intelligence.as_deref().unwrap_or("").is_empty() {
1601            return Err(usage(
1602                "missing intelligence endpoint (AGENTD_INTELLIGENCE or --intelligence)".into(),
1603            ));
1604        }
1605        validate_intelligence_uri(self.intelligence.as_deref().unwrap())?;
1606        // Per-endpoint credential probe: a named-but-unset per-endpoint token
1607        // *file* on ANY listed endpoint is exit 2. Failing fast at startup
1608        // beats discovering an unreadable secret at the moment of failover,
1609        // when the primary endpoint is already down.
1610        validate_endpoint_token_files(self.intelligence.as_deref().unwrap())?;
1611        for s in &self.mcp_servers {
1612            if s.name.is_empty() {
1613                return Err(usage("mcp server has an empty name".into()));
1614            }
1615            if s.endpoint.trim().is_empty() {
1616                return Err(usage(format!("mcp server '{}' has no endpoint", s.name)));
1617            }
1618            // The HTTPS-only gate runs FIRST: the reusable crate's parser also
1619            // accepts unix:/vsock:, so every server — CLI and config-file alike
1620            // — is held to http(s) here before it is delegated to.
1621            mcp_endpoint_scheme_ok(&s.endpoint)
1622                .map_err(|e| usage(format!("mcp server '{}': {e}", s.name)))?;
1623            // Validate that the endpoint parses and its auth header templates
1624            // resolve at startup, so an unreadable secret is a startup failure
1625            // rather than a surprise on first use.
1626            ::mcp::http::McpEndpoint::parse(&s.endpoint)
1627                .map_err(|e| usage(format!("mcp server '{}': {e}", s.name)))?;
1628            for (name, value) in &s.headers {
1629                if is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
1630                    return Err(usage(format!(
1631                        "mcp server '{}' header '{name}' looks like a credential but has an inline value; use {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}}",
1632                        s.name
1633                    )));
1634                }
1635            }
1636            crate::mcp::auth::headers_resolvable(&s.headers)
1637                .map_err(|e| usage(format!("mcp server '{}' header: {e}", s.name)))?;
1638        }
1639        if self.max_steps == 0 {
1640            return Err(usage("--max-steps must be > 0".into()));
1641        }
1642        // A zero events ring would hold nothing (every push instantly evicts) —
1643        // reject it so an operator who wants the live-tail surface gets a usable
1644        // window. Off by default; only consumed when serving.
1645        if self.events_ring == 0 {
1646            return Err(usage("--events-ring must be > 0".into()));
1647        }
1648        // The file-watch reload trigger (`--watch-config`) needs the
1649        // `config-watch` build feature. A silently-ignored `--watch-config`
1650        // would leave the operator believing a ConfigMap swap reloads the
1651        // daemon when in fact only SIGHUP does.
1652        if self.watch_config && !cfg!(feature = "config-watch") {
1653            return Err(usage(
1654                "--watch-config requires the 'config-watch' build feature".into(),
1655            ));
1656        }
1657        {
1658            #[cfg(feature = "workflow")]
1659            let wait_driven = self.workflow_file.is_some();
1660            #[cfg(not(feature = "workflow"))]
1661            let wait_driven = false;
1662            // A reactive WORKFLOW daemon's subscriptions come from its Wait nodes
1663            // dynamically — the workflow file stands in for a static --subscribe.
1664            if self.mode == Mode::Reactive
1665                && self.subscribe.is_empty()
1666                && self.continue_subscribe.is_empty()
1667                && !wait_driven
1668            {
1669                return Err(usage(
1670                    "--mode reactive requires at least one --subscribe or --continue <uri> (or --workflow on a workflow build)".into(),
1671                ));
1672            }
1673        }
1674        if !self.continue_subscribe.is_empty() && self.mode != Mode::Reactive {
1675            return Err(usage(
1676                "--continue is only valid with --mode reactive".into(),
1677            ));
1678        }
1679        if self.mode == Mode::Schedule && self.interval.is_none() && self.cron.is_none() {
1680            return Err(usage(
1681                "--mode schedule requires --interval <dur> or --cron <expr>".into(),
1682            ));
1683        }
1684        if self.cron.is_some() && self.mode != Mode::Schedule {
1685            return Err(usage("--cron is only valid with --mode schedule".into()));
1686        }
1687        // A pinned workflow run needs a workflow file, and the file needs
1688        // workflow mode: the two are inseparable, like --cron ⟺ --mode
1689        // schedule.
1690        #[cfg(feature = "workflow")]
1691        {
1692            if self.mode == Mode::Workflow && self.workflow_file.is_none() {
1693                return Err(usage("--mode workflow requires --workflow <file>".into()));
1694            }
1695            // Checkpoint resume is only meaningful for a pinned workflow run,
1696            // and the named checkpointer must be a configured server. Both
1697            // mistakes fail here, in milliseconds, before any network call.
1698            if let Some(r) = &self.workflow_resume {
1699                if r.server.is_empty() {
1700                    return Err(usage(
1701                        "--workflow-resume-force requires --workflow-resume <server>:<key>[@seq]"
1702                            .into(),
1703                    ));
1704                }
1705                if self.mode != Mode::Workflow {
1706                    return Err(usage(
1707                        "--workflow-resume is only valid with --mode workflow".into(),
1708                    ));
1709                }
1710                if !self.mcp_servers.iter().any(|s| s.name == r.server) {
1711                    return Err(usage(format!(
1712                        "--workflow-resume names server '{}', which is not a configured --mcp server",
1713                        r.server
1714                    )));
1715                }
1716            }
1717            if self.workflow_file.is_some()
1718                && self.mode != Mode::Workflow
1719                && self.mode != Mode::Reactive
1720            {
1721                return Err(usage(
1722                    "--workflow is only valid with --mode workflow or --mode reactive".into(),
1723                ));
1724            }
1725        }
1726        // The per-run limits do nothing without a cgroup to apply them to, so a
1727        // limit set alone is a misconfiguration (the operator believes the run is
1728        // bounded when it isn't) — surface it, like --cron/--continue.
1729        if (self.cgroup_memory_max.is_some() || self.cgroup_pids_max.is_some())
1730            && self.cgroup.is_none()
1731        {
1732            return Err(usage(
1733                "--cgroup-memory-max/--cgroup-pids-max require --cgroup".into(),
1734            ));
1735        }
1736        // A zero limit can never let the agent run: pids.max=0 refuses placement
1737        // (the run loses both limits and the cgroup.kill backstop) and memory.max=0
1738        // OOM-kills instantly. Reject it outright (use a real value or `max`).
1739        if self.cgroup_pids_max.as_deref().map(str::trim) == Some("0") {
1740            return Err(usage(
1741                "--cgroup-pids-max must be > 0 (it counts threads, not just processes) or 'max'"
1742                    .into(),
1743            ));
1744        }
1745        if self.cgroup_memory_max.as_deref().map(str::trim) == Some("0") {
1746            return Err(usage("--cgroup-memory-max must be > 0 or 'max'".into()));
1747        }
1748        // AAuth: the feature-gated flag must be present in the build, and the
1749        // provider must be a real http(s) URL — both exit 2 before any
1750        // network I/O (like every other feature/URL check).
1751        if let Some(a) = &self.aauth {
1752            if !cfg!(feature = "aauth") {
1753                return Err(usage(
1754                    "--aauth-provider needs a build with --features aauth".into(),
1755                ));
1756            }
1757            if crate::net::http::Url::parse(&a.provider).is_err() {
1758                return Err(usage(format!(
1759                    "--aauth-provider must be an http(s) URL (got: {})",
1760                    a.provider
1761                )));
1762            }
1763            if let Some(ps) = &a.person_server
1764                && crate::net::http::Url::parse(ps).is_err()
1765            {
1766                return Err(usage(format!(
1767                    "--aauth-person-server must be an http(s) URL (got: {ps})"
1768                )));
1769            }
1770        }
1771        // Validate the served-MCP target up front: a bad scheme, a missing
1772        // port, or a non-loopback plaintext bind exits 2 before any
1773        // listener is bound — mirroring the intelligence-URI check.
1774        if let Some(spec) = &self.serve_mcp {
1775            let target = ServeTarget::parse(spec)?;
1776            self.validate_serve_auth(&target, &|k: &str| std::env::var(k).ok())?;
1777        } else if self.serve_cert.is_some()
1778            || self.serve_key.is_some()
1779            || self.serve_client_ca.is_some()
1780            || self.serve_bearer.is_some()
1781        {
1782            return Err(usage(
1783                "a2a.tls.cert / a2a.tls.key / a2a.tls.client_ca / a2a.bearer require a2a.listen"
1784                    .into(),
1785            ));
1786        }
1787        // Outbound extra trust anchor (`--tls-ca`): needs the `tls` build feature
1788        // (a plaintext-only build has no dial to trust it on — silently ignoring
1789        // it would leave the operator believing the private CA is honored), and
1790        // the bundle must be present + a valid, addable CA PEM up front (exit 2,
1791        // not a first-dial surprise). Content check is side-effect-free here;
1792        // `main` installs the same bundle process-wide before the first dial.
1793        if let Some(ca) = &self.tls_ca {
1794            if !cfg!(feature = "tls") {
1795                return Err(usage("--tls-ca requires the 'tls' build feature".into()));
1796            }
1797            check_readable("--tls-ca", ca)?;
1798            #[cfg(feature = "tls")]
1799            {
1800                let pem =
1801                    std::fs::read(ca).map_err(|e| usage(format!("--tls-ca {ca}: read: {e}")))?;
1802                crate::net::tls::validate_ca_pem(&pem)
1803                    .map_err(|e| usage(format!("--tls-ca {ca}: {e}")))?;
1804            }
1805        }
1806        // Declared A2A delegation peers need the `a2a` build feature, and each
1807        // endpoint scheme is validated up front (exit 2 before
1808        // any side effect) — mirroring the served-MCP target check.
1809        if !self.a2a_peers.is_empty() && !cfg!(feature = "a2a") {
1810            return Err(usage("--a2a-peer requires the 'a2a' build feature".into()));
1811        }
1812        let mut seen = std::collections::HashSet::new();
1813        for peer in &self.a2a_peers {
1814            if peer.name.is_empty() || peer.endpoint.is_empty() {
1815                return Err(usage(format!(
1816                    "--a2a-peer '{}' has an empty name or endpoint",
1817                    peer.name
1818                )));
1819            }
1820            if !seen.insert(peer.name.as_str()) {
1821                return Err(usage(format!(
1822                    "--a2a-peer name '{}' is declared more than once",
1823                    peer.name
1824                )));
1825            }
1826            A2aEndpoint::parse(&peer.endpoint)?;
1827            // Peer client-auth (both legs) fails FAST at startup: header
1828            // templates must be secret-free + resolvable (bearer leg, same rule
1829            // as MCP servers), and mTLS material must come in a cert+key PAIR of
1830            // readable files (loaded at dial time, never inlined).
1831            for (name, value) in &peer.headers {
1832                if is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
1833                    return Err(usage(format!(
1834                        "a2a peer '{}' header '{name}' looks like a credential but has an inline value; use {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}}",
1835                        peer.name
1836                    )));
1837                }
1838            }
1839            crate::mcp::auth::headers_resolvable(&peer.headers)
1840                .map_err(|e| usage(format!("a2a peer '{}' header: {e}", peer.name)))?;
1841            match (&peer.client_cert, &peer.client_key) {
1842                (Some(_), None) | (None, Some(_)) => {
1843                    return Err(usage(format!(
1844                        "a2a peer '{}': client_cert and client_key must be set together",
1845                        peer.name
1846                    )));
1847                }
1848                (Some(cert), Some(key)) => {
1849                    for path in [cert, key] {
1850                        if let Err(e) = std::fs::metadata(path) {
1851                            return Err(usage(format!(
1852                                "a2a peer '{}': cannot read '{path}': {e}",
1853                                peer.name
1854                            )));
1855                        }
1856                    }
1857                }
1858                (None, None) => {}
1859            }
1860        }
1861        // Declared intelligence headers: reject an inline secret-shaped value,
1862        // and require every {{secret…}} ref to resolve. The `--validate-config`
1863        // path runs this same check through `collect_header_diags`, collecting
1864        // all of them, so the admission gate and startup never disagree.
1865        let mut header_diags = Vec::new();
1866        self.collect_header_diags(&mut header_diags);
1867        if let Some(first) = header_diags.into_iter().next() {
1868            return Err(usage(first));
1869        }
1870        // Rule of Two — the lethal-trifecta gate. It lives in `validate()` so
1871        // there is ONE validation authority: startup and `--validate-config`
1872        // share it and can never disagree. A grant co-locating all three legs
1873        // (untrusted input + sensitive data + egress) without `--allow-trifecta`
1874        // is refused as a config error (exit 2). The allowed-with-
1875        // `--allow-trifecta` case is NOT an error — it passes here, and the
1876        // supervisor (`main.rs`) emits the auditable `scope.trifecta_grant`
1877        // warning instead. A subagent's scope can only narrow, so the root
1878        // union bounds the whole subagent tree.
1879        if crate::sec::scope::check_trifecta(self.trifecta_grant_tags(), self.allow_trifecta)
1880            .is_refused()
1881        {
1882            return Err(usage(
1883                "refused — this grant gives one agent all three lethal-trifecta legs \
1884                 (untrusted input + sensitive data + egress). Split the capabilities across \
1885                 subagents, or relaunch with --allow-trifecta."
1886                    .into(),
1887            ));
1888        }
1889        Ok(())
1890    }
1891}
1892
1893// ──────────────────────────────  hot reload  ────────────────────────────────
1894//
1895// The reloadable-vs-restart-only partition plus the coherence check that both
1896// the reload path and `--validate-config` run. This block is pure data and
1897// pure-CPU checks — no side effect, no subsystem touched; the apply step lives
1898// in `triggers::mode`. It compiles in every feature combination: the SIGHUP
1899// trigger and the reactive apply are `hot-reload`-gated, while the partition
1900// itself is always available, so `--validate-config` reports restart-only
1901// warnings on any build.
1902
1903/// A reload diagnostic. `Warn` is advisory (a restart-only field
1904/// merely present in the file — it works, it just pins you to restart-to-change);
1905/// `Error` is fatal to the reload (it differs on a live reload, or the reloadable
1906/// subset is internally inconsistent). `--validate-config` reports both; the
1907/// reload path aborts on any `Error`.
1908#[derive(Debug, Clone, PartialEq, Eq)]
1909pub struct Diag {
1910    /// The config field/path the diagnostic is about (e.g. `mode`, `mcp_servers`).
1911    pub field: String,
1912    /// `warn` (advisory) or `error` (fatal to the reload).
1913    pub level: DiagLevel,
1914    /// The human-readable reason.
1915    pub msg: String,
1916}
1917
1918#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1919pub enum DiagLevel {
1920    Warn,
1921    Error,
1922}
1923
1924impl Diag {
1925    // The warn-vs-reject distinction is part of the coherence-check contract:
1926    // a restart-only field merely *present in the file* is a Warn — it works,
1927    // it just pins you to restart-to-change — which is a different thing from a
1928    // field that DIFFERS on a live reload, an Error. The file schema exposes no
1929    // restart-only key at all, so the Warn path has no live caller; the
1930    // constructor stays so widening the file schema needs no new API.
1931    #[allow(dead_code)]
1932    fn warn(field: &str, msg: impl Into<String>) -> Diag {
1933        Diag {
1934            field: field.to_string(),
1935            level: DiagLevel::Warn,
1936            msg: msg.into(),
1937        }
1938    }
1939    fn error(field: &str, msg: impl Into<String>) -> Diag {
1940        Diag {
1941            field: field.to_string(),
1942            level: DiagLevel::Error,
1943            msg: msg.into(),
1944        }
1945    }
1946    pub fn is_error(&self) -> bool {
1947        self.level == DiagLevel::Error
1948    }
1949    pub fn level_str(&self) -> &'static str {
1950        match self.level {
1951            DiagLevel::Warn => "warn",
1952            DiagLevel::Error => "error",
1953        }
1954    }
1955}
1956
1957/// The names of the **restart-only** fields. A live reload whose new-vs-running
1958/// diff touches ANY of these is rejected with `reason="restart_required"`;
1959/// whether that becomes a pod restart is agentctl's policy. They also drive the
1960/// "restart-only field set in the file" warning.
1961///
1962/// NB: `mcp_servers` is deliberately ABSENT because it is **reloadable**: a
1963/// validated reload re-handshakes the MCP server set at the quiesce boundary.
1964/// The name-keyed `servers`/`owner`/claim wiring in `triggers::mode` is what
1965/// makes that live re-handshake safe — a remove or add never shifts another
1966/// server's identity.
1967pub const RESTART_ONLY_FIELDS: &[&str] = &[
1968    "mode",
1969    // NB: `intelligence` (the endpoint list) and `model`/`model_swap` are
1970    // RELOADABLE through the runtime hot-swap primitive. A reload whose diff
1971    // repoints the endpoint list or changes the model is APPLIED at a turn
1972    // boundary — the supervisor fans `ctrl/swap_intel` to in-flight children —
1973    // rather than rejected, so they are deliberately absent from this list.
1974    // `mcp_servers` is likewise reloadable: re-handshaked, not rejected.
1975    "run_id",             // instance identity / idempotency key
1976    "serve_mcp",          // a live control socket must not rebind mid-flight
1977    "drain_timeout",      // validated against the pod grace at startup
1978    "continue_subscribe", // warm-session routing topology is restart-only
1979];
1980
1981impl Config {
1982    /// Re-resolve config for a hot reload: re-read ONLY the file and re-merge
1983    /// built-in<file<env<flag. `args`/`env` are the process's
1984    /// original, fixed inputs — only the FILE can change between loads, so this
1985    /// keeps precedence correct (a flag still overrides the new file). Pure-CPU,
1986    /// no side effect. The returned `Config` is the fully-validated candidate; an
1987    /// invalid file/value is the same `ConfigError::Usage` startup would raise.
1988    ///
1989    /// NB: `--validate-config`/`--config-schema`/`--capabilities` short-circuit
1990    /// inside `load`, but those flags never reach a running reactive daemon, so a
1991    /// reload's `args` never carries them — this is the ordinary load path.
1992    pub fn reload(args: &[String], env: &[(String, String)]) -> Result<Config, ConfigError> {
1993        Config::load(args, env)
1994    }
1995
1996    /// Advisory: a restart-only field set in the config FILE — "this field
1997    /// belongs in env/flag" — pushed as a `Warn`. The file schema exposes NO
1998    /// restart-only key: `mode`, `run_id` and `serve_mcp` are env/flag-only,
1999    /// and `mcp_servers`, the one structural field that could be mistaken for
2000    /// one, is RELOADABLE (a live re-handshake at the quiesce boundary). So
2001    /// there is nothing file-settable to warn about. The hook stays, consulting
2002    /// `file_present` — the gate a widened schema would use — so re-arming a
2003    /// warning needs no plumbing change.
2004    fn restart_only_file_warnings(&self, file_present: bool, _diags: &mut Vec<Diag>) {
2005        let _ = file_present; // the gate a widened file schema would use
2006    }
2007
2008    /// The reload-coherence check, run by BOTH `--validate-config` and the
2009    /// reload path. Pure-CPU, no side effect.
2010    ///
2011    /// 1. (advisory) a restart-only field set in the FILE → `Warn` (`file_present`).
2012    /// 2. (live reload only) any restart-only field that DIFFERS between `new` and
2013    ///    `running` → `Error` naming the field, which aborts the reload.
2014    /// 3. the reloadable subset is internally consistent: every subscription/claim
2015    ///    references a declared server where required, and server names are unique.
2016    ///
2017    /// `Ok(())` if no `Error` diagnostics (the `Warn`s are still surfaced by the
2018    /// caller); `Err(diags)` carries every diagnostic when at least one is an error.
2019    pub fn reload_coherence_check(
2020        new: &Config,
2021        running: Option<&Config>,
2022        file_present: bool,
2023    ) -> Result<(), Vec<Diag>> {
2024        let mut diags = Vec::new();
2025        // 1. restart-only-field-in-file advisory warnings.
2026        new.restart_only_file_warnings(file_present, &mut diags);
2027        // 2. on a live reload, a restart-only diff is a hard reject.
2028        if let Some(run) = running {
2029            for &f in RESTART_ONLY_FIELDS {
2030                if new.restart_only_field_differs(run, f) {
2031                    diags.push(Diag::error(
2032                        f,
2033                        format!(
2034                            "restart-only field '{f}' changed on a live reload; reload refused, \
2035                             a pod restart is required"
2036                        ),
2037                    ));
2038                }
2039            }
2040        }
2041        // 3. reloadable-subset internal consistency.
2042        check_unique_server_names(new, &mut diags);
2043        check_subscriptions_reference_declared_servers(new, &mut diags);
2044        if diags.iter().any(Diag::is_error) {
2045            Err(diags)
2046        } else {
2047            // Surface advisory warnings to the caller too (it logs them) — an
2048            // all-warn result is still `Ok` (the reload proceeds; the warnings
2049            // are informational). The caller that wants the warnings reads them
2050            // via the validate-collect path; the reload path only needs the
2051            // pass/fail, so an Ok here means "no restart-only diff, apply".
2052            Ok(())
2053        }
2054    }
2055
2056    /// Compare one restart-only field between `self` (new) and `running`. The
2057    /// match arms enumerate exactly [`RESTART_ONLY_FIELDS`] — a field added there
2058    /// without a comparison arm here defaults to `false` (no diff), which would
2059    /// silently let it reload, so the unit tests assert each named field is
2060    /// diff-detected. Pure.
2061    fn restart_only_field_differs(&self, running: &Config, field: &str) -> bool {
2062        match field {
2063            "mode" => self.mode != running.mode,
2064            "run_id" => self.run_id != running.run_id,
2065            "serve_mcp" => self.serve_mcp != running.serve_mcp,
2066            "drain_timeout" => self.drain_timeout != running.drain_timeout,
2067            "continue_subscribe" => self.continue_subscribe != running.continue_subscribe,
2068            _ => false,
2069        }
2070    }
2071
2072    /// The reloadable, **redacted** view of the running config for
2073    /// `agentd://config/effective`. Carries ONLY the reloadable structural
2074    /// fields — no token, no URL, no secret, and header NAMES rather than
2075    /// values. Management-readable, and held to the same no-secret discipline
2076    /// as the manifest: nothing here can embed a credential.
2077    pub fn effective_view(&self) -> serde_json::Value {
2078        serde_json::json!({
2079            "model": self.model,
2080            "swap_policy": self.model_swap.as_str(),
2081            "max_tokens": self.max_tokens,
2082            "limits": {
2083                "max_steps": self.max_steps,
2084                "max_depth": self.max_depth,
2085                "deadline_secs": self.deadline.map(|d| d.as_secs()),
2086                // The per-instance lifetime budget; omitted when unbounded (0).
2087                "lifetime_tokens": (self.budget_tokens_lifetime > 0)
2088                    .then_some(self.budget_tokens_lifetime),
2089            },
2090            // Structural name + tags only — never the endpoint (its host/path can
2091            // be sensitive) nor the auth headers, mirroring the manifest.
2092            "mcp_servers": self.mcp_servers.iter().map(|s| {
2093                serde_json::json!({"name": s.name, "tags": s.tags})
2094            }).collect::<Vec<_>>(),
2095            "subscribe": self.subscribe,
2096            "log_level": self.log_level.as_str(),
2097            // Header NAMES only: a value may be a {{secret:…}} ref, and the
2098            // resolved value is never exposed on a readable surface.
2099            "intelligence_headers": self.intelligence_headers.keys().collect::<Vec<_>>(),
2100        })
2101    }
2102}
2103
2104/// Check that declared MCP server names are unique. A duplicate would make the
2105/// name-keyed owner/claim map ambiguous, so it is an error.
2106fn check_unique_server_names(cfg: &Config, diags: &mut Vec<Diag>) {
2107    let mut seen = std::collections::HashSet::new();
2108    for s in &cfg.mcp_servers {
2109        if !seen.insert(s.name.as_str()) {
2110            diags.push(Diag::error(
2111                "mcp_servers",
2112                format!("duplicate MCP server name '{}'", s.name),
2113            ));
2114        }
2115    }
2116}
2117
2118/// Check that every route that names an MCP server references a declared one.
2119/// This is the reload-time mirror of the startup `validate()` check: on a
2120/// reload the candidate must be self-consistent before any subsystem is
2121/// touched. Plain `--subscribe` URIs need no declared server — they bind to
2122/// whichever connected server supports them — so they are not checked here,
2123/// exactly as in `validate()`.
2124fn check_subscriptions_reference_declared_servers(cfg: &Config, diags: &mut Vec<Diag>) {
2125    // Plain `--subscribe` URIs bind to whichever connected server supports
2126    // them, so there is no server reference here to resolve. The hook stays
2127    // because this check is about the reloadable subset being self-consistent,
2128    // and a field that DOES name a server would belong here.
2129    let _ = (cfg, diags);
2130}
2131
2132/// Heuristic: is this header name credential-shaped? A header so named must
2133/// carry a `{{secret:…}}` *reference*, never an inline literal, so a secret
2134/// cannot be smuggled into a config file under a plausible header name.
2135pub(crate) fn is_secret_shaped_key(name: &str) -> bool {
2136    let n = name.to_ascii_lowercase();
2137    n == "authorization"
2138        || n == "x-api-key"
2139        || n == "api-key"
2140        || n == "token"
2141        || n.ends_with("-token")
2142        || n.ends_with("_token")
2143        || n == "password"
2144        || n == "secret"
2145        || n.ends_with("-key")
2146        || n.ends_with("_key")
2147}
2148
2149/// The single-line `config.valid` verdict, to stderr, exit 0.
2150fn config_valid_line() -> String {
2151    serde_json::json!({"event": "config.valid"}).to_string()
2152}
2153
2154/// One machine-actionable `config.invalid` diagnostic line, to stderr, exit 2.
2155/// `msg` is the human-readable reason.
2156fn config_invalid_line(msg: &str) -> String {
2157    serde_json::json!({"event": "config.invalid", "msg": msg}).to_string()
2158}
2159
2160/// Validate the `--intelligence` value as an ORDERED, comma-separated endpoint
2161/// list. At least one non-empty element is required, and every element's scheme
2162/// is validated — exit 2 naming the bad element. Checking every element, not
2163/// just the first, is the point: a transport this build cannot dial would
2164/// otherwise only be discovered at the moment of failover.
2165pub(crate) fn validate_intelligence_uri(uri: &str) -> Result<(), ConfigError> {
2166    let elements: Vec<&str> = uri
2167        .split(',')
2168        .map(str::trim)
2169        .filter(|s| !s.is_empty())
2170        .collect();
2171    if elements.is_empty() {
2172        return Err(usage(
2173            "missing intelligence endpoint (AGENTD_INTELLIGENCE or --intelligence)".into(),
2174        ));
2175    }
2176    for el in elements {
2177        validate_one_intelligence_uri(el)?;
2178    }
2179    Ok(())
2180}
2181
2182/// Validate one endpoint URI's scheme. Intelligence is **HTTPS-only**:
2183/// `https://host[:port][/path]`, with plaintext `http://` admitted only for a
2184/// loopback host — the dev/test carve-out for the built-in mock LLM. Only the
2185/// *scheme shape* is the startup gate, and a bad scheme on any element is exit
2186/// 2. Whether this build can actually dial the transport (`https:` needs
2187/// `tls`) is left to the client, which reports `Unsupported` at dial time, so
2188/// a `--capabilities` or `--validate-config` probe of an https endpoint still
2189/// passes on a no-tls build.
2190fn validate_one_intelligence_uri(uri: &str) -> Result<(), ConfigError> {
2191    if uri.starts_with("https://") {
2192        return Ok(());
2193    }
2194    // `mock:<script>` — the offline dev endpoint (in-process mock LLM over
2195    // loopback). Admitted only where the client can actually serve it: debug
2196    // builds, or a release built `--features internal-mocks`.
2197    if uri.starts_with("mock:") {
2198        #[cfg(any(feature = "internal-mocks", debug_assertions))]
2199        return Ok(());
2200        #[cfg(not(any(feature = "internal-mocks", debug_assertions)))]
2201        return Err(usage(format!(
2202            "mock: intelligence needs a build with --features internal-mocks (got: {uri})"
2203        )));
2204    }
2205    if let Some(rest) = uri.strip_prefix("http://") {
2206        let authority = rest.split('/').next().unwrap_or(rest);
2207        // Split off the port: bracketed IPv6 keeps its brackets for the
2208        // loopback classifier; bare host:port loses the port.
2209        let host = if authority.starts_with('[') {
2210            authority.split(']').next().map_or(authority, |h| &h[1..])
2211        } else {
2212            authority.rsplit_once(':').map_or(authority, |(h, _)| h)
2213        };
2214        if crate::net::http::is_loopback_host(host) {
2215            return Ok(());
2216        }
2217        return Err(usage(format!(
2218            "plaintext http:// intelligence is allowed for loopback only (dev); use https:// (got: {uri})"
2219        )));
2220    }
2221    Err(usage(format!(
2222        "intelligence endpoint must be https://host[:port][/path] (got: {uri})"
2223    )))
2224}
2225
2226/// Probe each listed endpoint's per-endpoint token *file* env var: a
2227/// `AGENTD_INTELLIGENCE_TOKEN[_N]_FILE` that is set but unreadable is
2228/// exit 2 before any side effect — we fail fast rather than discover a missing
2229/// secret on failover. Endpoint 1 (index 0) uses the bare name; later endpoints
2230/// are 1-indexed (`_2`, `_3`, …). The inline env wins over the file (so a set
2231/// inline var means the file is not consulted), matching the resolver. The
2232/// resolved bytes are dropped immediately and never logged.
2233fn validate_endpoint_token_files(uri: &str) -> Result<(), ConfigError> {
2234    let count = uri
2235        .split(',')
2236        .map(str::trim)
2237        .filter(|s| !s.is_empty())
2238        .count();
2239    for idx in 0..count {
2240        let (inline_var, file_var) = if idx == 0 {
2241            (
2242                "AGENTD_INTELLIGENCE_TOKEN".to_string(),
2243                "AGENTD_INTELLIGENCE_TOKEN_FILE".to_string(),
2244            )
2245        } else {
2246            let n = idx + 1;
2247            (
2248                format!("AGENTD_INTELLIGENCE_TOKEN_{n}"),
2249                format!("AGENTD_INTELLIGENCE_TOKEN_{n}_FILE"),
2250            )
2251        };
2252        // An inline override means the file is never consulted — skip the probe.
2253        if std::env::var(&inline_var).is_ok() {
2254            continue;
2255        }
2256        if let Ok(path) = std::env::var(&file_var) {
2257            crate::sec::secret::read_token_file(&path).map_err(usage)?;
2258        }
2259    }
2260    Ok(())
2261}
2262
2263/// Parse `--mcp name=<endpoint>`. The value is a remote MCP endpoint
2264/// (`https://` / `http://`, Streamable HTTP) — the sole transport, since there
2265/// is no local process spawn. A non-endpoint value is rejected.
2266fn parse_mcp_spec(spec: &str) -> Result<McpServerSpec, ConfigError> {
2267    let (name, rhs) = spec
2268        .split_once('=')
2269        .ok_or_else(|| usage(format!("--mcp must be name=endpoint (got: {spec})")))?;
2270    let endpoint = rhs.trim();
2271    if name.is_empty() || endpoint.is_empty() {
2272        return Err(usage(format!("--mcp '{spec}' has empty name or endpoint")));
2273    }
2274    // `code` is RESERVED: workflow `tool` nodes address code-registered
2275    // (in-process, embedder-native) tools as server `code`, so a remote server
2276    // claiming the name would silently shadow them.
2277    if name == "code" {
2278        return Err(usage(
2279            "--mcp: the server name 'code' is reserved for code-registered tools".into(),
2280        ));
2281    }
2282    if !is_mcp_endpoint(endpoint) {
2283        return Err(usage(format!(
2284            "--mcp '{spec}': endpoint must be https://host[:port][/path] \
2285             (loopback http:// for dev)"
2286        )));
2287    }
2288    Ok(McpServerSpec {
2289        name: name.to_string(),
2290        endpoint: endpoint.to_string(),
2291        ..Default::default()
2292    })
2293}
2294
2295/// Parse `--a2a-peer name=endpoint` into an [`A2aPeerSpec`]. The endpoint is
2296/// the remainder after the FIRST `=`, so a URL containing `=` in a query string
2297/// survives intact; the scheme itself is validated later in
2298/// [`Config::validate`] via [`A2aEndpoint::parse`].
2299fn parse_a2a_peer_spec(spec: &str) -> Result<A2aPeerSpec, ConfigError> {
2300    let (name, endpoint) = spec
2301        .split_once('=')
2302        .ok_or_else(|| usage(format!("--a2a-peer must be name=endpoint (got: {spec})")))?;
2303    if name.is_empty() || endpoint.is_empty() {
2304        return Err(usage(format!(
2305            "--a2a-peer '{spec}' has an empty name or endpoint"
2306        )));
2307    }
2308    Ok(A2aPeerSpec {
2309        name: name.to_string(),
2310        endpoint: endpoint.to_string(),
2311        headers: Vec::new(),
2312        client_cert: None,
2313        client_key: None,
2314    })
2315}
2316
2317/// Parse `--mcp-tags name=tag,tag` into (server-name, tags). Tags are the
2318/// snake-case capability legs of the lethal trifecta.
2319pub(crate) fn parse_mcp_tags(spec: &str) -> Result<(String, Vec<TrifectaTag>), ConfigError> {
2320    let (name, list) = spec
2321        .split_once('=')
2322        .ok_or_else(|| usage(format!("--mcp-tags must be name=tag,tag (got: {spec})")))?;
2323    if name.is_empty() {
2324        return Err(usage(format!(
2325            "--mcp-tags '{spec}' has an empty server name"
2326        )));
2327    }
2328    let mut tags = Vec::new();
2329    for t in list.split(',').map(str::trim).filter(|t| !t.is_empty()) {
2330        let tag = TrifectaTag::parse(t).ok_or_else(|| {
2331            usage(format!(
2332                "unknown trifecta tag '{t}' (want: untrusted_input|sensitive|egress)"
2333            ))
2334        })?;
2335        tags.push(tag);
2336    }
2337    Ok((name.to_string(), tags))
2338}
2339
2340pub(crate) fn read_file(path: &str) -> Result<String, ConfigError> {
2341    std::fs::read_to_string(path)
2342        .map_err(|e| usage(format!("cannot read instruction file {path}: {e}")))
2343}
2344
2345/// How one argument spells the config-file flag. `--config` is the canonical
2346/// form; `-c` is the short alias, and either may attach its value with `=`
2347/// (`-c=a.yaml`, `--config=a.yaml`) as well as separate it with a space.
2348pub(crate) enum ConfigFlag<'a> {
2349    /// `--config a.yaml` / `-c a.yaml` — the value is the NEXT argument.
2350    Separate,
2351    /// `--config=a.yaml` / `-c=a.yaml` — the value is attached.
2352    Inline(&'a str),
2353    /// Not the config flag at all.
2354    No,
2355}
2356
2357/// Classify one argument as a spelling of the config-file flag.
2358pub(crate) fn config_flag(arg: &str) -> ConfigFlag<'_> {
2359    match arg {
2360        "--config" | "-c" => ConfigFlag::Separate,
2361        _ => match arg
2362            .strip_prefix("--config=")
2363            .or_else(|| arg.strip_prefix("-c="))
2364        {
2365            Some(v) => ConfigFlag::Inline(v),
2366            None => ConfigFlag::No,
2367        },
2368    }
2369}
2370
2371/// The config files an invocation will load, and **how they were chosen**.
2372///
2373/// The provenance is not a detail: a file the operator NAMED (`--config` /
2374/// `AGENTD_CONFIG`) is a decision they made, while a DISCOVERED `.agentd.yml`
2375/// is a file that happened to be in the working directory when they typed a
2376/// flags-only command. The two get different trust (see the discovered-config
2377/// containment in `config::v2::load`), so the loader must be able to tell them
2378/// apart rather than seeing one flat list of paths.
2379pub(crate) struct ConfigPaths {
2380    /// The files to load, in merge order (earlier is overridden by later).
2381    pub paths: Vec<String>,
2382    /// True when `paths` came from DISCOVERY — nothing named a config, so the
2383    /// chain was walked. Never true alongside a named path: discovery is a
2384    /// fallback for an empty list.
2385    pub discovered: bool,
2386    /// Set when one RUNG of the chain had two spellings at once. Carried as
2387    /// data rather than returned as an error so this stays pure and the file
2388    /// watcher can call it; the loader turns it into a usage error.
2389    pub ambiguous: Option<String>,
2390}
2391
2392/// The ordered config-file list over an already-debranded env map: the
2393/// `AGENTD_CONFIG` entries (`:`-separated, empty entries skipped) then each
2394/// `--config` value. Env first so a platform-injected base is overridden by an
2395/// operator's explicit `--config` overlay (later wins).
2396pub(crate) fn config_paths_from_map(args: &[String], envmap: &HashMap<&str, &str>) -> ConfigPaths {
2397    let mut paths: Vec<String> = envmap
2398        .get("AGENTD_CONFIG")
2399        .map(|v| {
2400            v.split(':')
2401                .map(str::trim)
2402                .filter(|p| !p.is_empty())
2403                .map(str::to_string)
2404                .collect()
2405        })
2406        .unwrap_or_default();
2407    let mut it = args.iter();
2408    while let Some(a) = it.next() {
2409        match config_flag(a) {
2410            ConfigFlag::Separate => {
2411                if let Some(v) = it.next() {
2412                    paths.push(v.clone());
2413                }
2414            }
2415            ConfigFlag::Inline(v) => paths.push(v.to_string()),
2416            ConfigFlag::No => {}
2417        }
2418    }
2419    // Nothing named a config, so walk the discovery chain — a user default, the
2420    // project's own file, a machine-local overlay — the way a linter or a
2421    // formatter picks up its dotfile. Only ever a fallback: an explicit
2422    // `--config` or `AGENTD_CONFIG` means the caller has already decided, and
2423    // silently merging a stray `agentd.local.yml` into a named production
2424    // config would be the worst kind of surprise.
2425    let mut discovered = false;
2426    let mut ambiguous = None;
2427    if paths.is_empty() && !is_informational(args) {
2428        match discovered_chain(Path::new("."), envmap) {
2429            Ok(found) => {
2430                discovered = !found.is_empty();
2431                paths.extend(found);
2432            }
2433            Err(e) => ambiguous = Some(e),
2434        }
2435    }
2436    ConfigPaths {
2437        paths,
2438        discovered,
2439        ambiguous,
2440    }
2441}
2442
2443/// One rung of the discovery chain: the spellings that name the SAME logical
2444/// file. Two spellings because `.yml` and `.yaml` are both idiomatic and
2445/// guessing wrong should not mean silence — a config file the tool ignores is
2446/// the worst outcome of the three.
2447///
2448/// The user rung, under `$XDG_CONFIG_HOME` (else `~/.config`): defaults that
2449/// follow the person, not the checkout.
2450pub const USER_CONFIG_NAMES: [&str; 2] = ["config.yml", "config.yaml"];
2451
2452/// The project rung, in the working directory. The dotted spellings are the
2453/// original discovery names and stay valid: they shipped, and silently
2454/// ignoring one would break the setups that adopted it.
2455pub const PROJECT_CONFIG_NAMES: [&str; 4] =
2456    ["agentd.yml", "agentd.yaml", ".agentd.yml", ".agentd.yaml"];
2457
2458/// The local rung: a machine-specific overlay that is expected to be
2459/// git-ignored, so a checkout can be pointed at a dev endpoint without the
2460/// change ever being committable by accident.
2461pub const LOCAL_CONFIG_NAMES: [&str; 2] = ["agentd.local.yml", "agentd.local.yaml"];
2462
2463/// The user rung's directory: `$XDG_CONFIG_HOME/agentd`, else `~/.config/agentd`.
2464/// `None` when neither variable is set — a daemon with no HOME (a scratch
2465/// container, a systemd unit without one) simply has no user rung rather than
2466/// resolving a path relative to nothing.
2467pub fn user_config_dir(envmap: &HashMap<&str, &str>) -> Option<PathBuf> {
2468    if let Some(x) = envmap.get("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
2469        return Some(Path::new(x).join("agentd"));
2470    }
2471    envmap
2472        .get("HOME")
2473        .filter(|v| !v.is_empty())
2474        .map(|h| Path::new(h).join(".config").join("agentd"))
2475}
2476
2477/// Which of `names` exist in `dir`, in order.
2478///
2479/// Returns **all** matches rather than the first, so that two spellings of one
2480/// rung present at once surfaces as an error rather than a silent pick between
2481/// them. Callers that get more than one refuse to start.
2482pub fn present_in(dir: &Path, names: &[&str]) -> Vec<String> {
2483    names
2484        .iter()
2485        .map(|n| dir.join(n))
2486        .filter(|p| p.is_file())
2487        .map(|p| p.to_string_lossy().into_owned())
2488        .collect()
2489}
2490
2491/// The whole discovery chain, LOWEST precedence first: the user rung, then the
2492/// project rung, then the local overlay. Every rung that has a file
2493/// contributes one, and they merge in that order — so a user default is
2494/// overridden by the project's config and that by a machine-local overlay,
2495/// with flags and environment still on top of all three.
2496///
2497/// `Err` names the rung that is ambiguous. Ambiguity is per RUNG, not across
2498/// the chain: `agentd.yml` beside `agentd.local.yml` is the design, while
2499/// `agentd.yml` beside `agentd.yaml` is a coin toss nobody should have to
2500/// debug.
2501pub fn discovered_chain(cwd: &Path, envmap: &HashMap<&str, &str>) -> Result<Vec<String>, String> {
2502    let mut out = Vec::new();
2503    let mut rungs: Vec<(&str, PathBuf, &[&str])> = Vec::new();
2504    if let Some(d) = user_config_dir(envmap) {
2505        rungs.push(("user", d, &USER_CONFIG_NAMES));
2506    }
2507    rungs.push(("project", cwd.to_path_buf(), &PROJECT_CONFIG_NAMES));
2508    rungs.push(("local", cwd.to_path_buf(), &LOCAL_CONFIG_NAMES));
2509    for (label, dir, names) in rungs {
2510        let found = present_in(&dir, names);
2511        if found.len() > 1 {
2512            return Err(format!(
2513                "the {label} config is ambiguous: {} are both present; keep one (or name the file with --config)",
2514                found.join(" and ")
2515            ));
2516        }
2517        out.extend(found);
2518    }
2519    Ok(out)
2520}
2521
2522/// Whether this invocation only wants to print something.
2523///
2524/// `--help` and `--version` must work in any directory. Discovering a config
2525/// for them would mean a stray `.agentd.yml` two levels of `cd` away could make
2526/// `agentd --help` fail, which is an unreasonable way to learn a file is
2527/// malformed.
2528fn is_informational(args: &[String]) -> bool {
2529    args.iter().any(|a| {
2530        matches!(
2531            a.as_str(),
2532            "-h" | "--help"
2533                | "-V"
2534                | "--version"
2535                | "--config-schema"
2536                | "--config-schema=2"
2537                | "--workflow-schema"
2538        )
2539    })
2540}
2541
2542/// Type a config DOCUMENT (a merged file set, the env-path layer, or one
2543/// `--<path>` flag) and overlay it onto `c`. `replace_lists` selects the
2544/// list/map semantics for the top-level keys the document actually carries:
2545/// `false` = **add** to what is there (the file layer, the named repeatable
2546/// flags' semantics; also a `--<map>.<key>` entry flag, which merges one key);
2547/// `true` = **set** — the document's value replaces the list/map (setting a
2548/// path from env or a `--<path>` flag). Keys absent from the document are never
2549/// touched either way.
2550fn apply_document(
2551    c: &mut Config,
2552    doc: serde_json::Value,
2553    source: &str,
2554    replace_lists: bool,
2555) -> Result<(), ConfigError> {
2556    let present: Vec<String> = doc
2557        .as_object()
2558        .map(|m| m.keys().cloned().collect())
2559        .unwrap_or_default();
2560    let cf = file::ConfigFile::from_document(doc, source).map_err(usage)?;
2561    if replace_lists {
2562        for key in &present {
2563            match key.as_str() {
2564                "mcp_servers" => c.mcp_servers.clear(),
2565                "subscribe" => c.subscribe.clear(),
2566                "a2a_peers" => c.a2a_peers.clear(),
2567                "intelligence_headers" => c.intelligence_headers.clear(),
2568                _ => {}
2569            }
2570        }
2571    }
2572    apply_config_file(c, cf, source)
2573}
2574
2575/// Overlay a typed [`file::ConfigFile`] onto `c` — the ONE overlay operation
2576/// every document layer uses: the config FILE (precedence layer 1), the
2577/// path-derived env layer, and each generic `--<path>` flag. Only keys
2578/// the document actually sets are written (field-wise); later layers override
2579/// them. List-valued keys (`mcp_servers`, `subscribe`, `a2a_peers`, the header
2580/// maps) **add to** the list — repeatable-flag semantics for every layer. Maps
2581/// the file's `endpoint`+`headers` into the runtime `McpServerSpec`, and
2582/// flattens the glob→tags map to the server's tag set. `source` names the layer
2583/// in error messages (`config file`, `env`, or the flag).
2584fn apply_config_file(
2585    c: &mut Config,
2586    cf: file::ConfigFile,
2587    source: &str,
2588) -> Result<(), ConfigError> {
2589    // The intelligence endpoint LIST is file-settable and reloadable, so a
2590    // ConfigMap repoint is a hot swap. The transport scheme is data; the
2591    // credential is NEVER inline here — env or `_FILE` only — and the validate
2592    // pass rejects a secret-shaped value just as it does for headers.
2593    if let Some(intelligence) = cf.intelligence {
2594        c.intelligence = Some(intelligence);
2595    }
2596    if let Some(policy) = cf.model_swap {
2597        c.model_swap = SwapPolicy::parse(&policy).ok_or_else(|| {
2598            usage(format!(
2599                "{source}: invalid model_swap: {policy} (want finish-on-old|restart-turn)"
2600            ))
2601        })?;
2602    }
2603    if let Some(model) = cf.model {
2604        c.model = Some(model);
2605    }
2606    if let Some(mt) = cf.max_tokens {
2607        c.max_tokens = mt;
2608    }
2609    if let Some(limits) = cf.limits {
2610        if let Some(s) = limits.max_steps {
2611            c.max_steps = s;
2612        }
2613        if let Some(d) = limits.max_depth {
2614            c.max_depth = d;
2615        }
2616        if let Some(secs) = limits.deadline_secs {
2617            c.deadline = Some(Duration::from_secs(secs));
2618        }
2619        if let Some(lt) = limits.lifetime_tokens {
2620            c.budget_tokens_lifetime = lt;
2621        }
2622    }
2623    if let Some(level) = cf.log_level {
2624        c.log_level = Level::parse(&level)
2625            .ok_or_else(|| usage(format!("{source}: invalid log_level: {level}")))?;
2626    }
2627    // mcp_servers: each file object → one McpServerSpec over the HTTP
2628    // transport: a remote `endpoint` plus secret-free header templates, with no
2629    // local process spawn. The glob→tags map flattens to the union of declared
2630    // tags. Seeds the list.
2631    for s in cf.mcp_servers {
2632        if s.name.is_empty() {
2633            return Err(usage(format!("{source}: an mcp server has an empty name")));
2634        }
2635        let endpoint = match s.endpoint {
2636            Some(ep) if !ep.trim().is_empty() => ep,
2637            _ => {
2638                return Err(usage(format!(
2639                    "{source}: mcp server '{}' has no endpoint \
2640                     (an MCP server is always a remote endpoint)",
2641                    s.name
2642                )));
2643            }
2644        };
2645        let headers = s.headers.into_iter().collect::<Vec<(String, String)>>();
2646        let mut tags: Vec<TrifectaTag> = Vec::new();
2647        for tag_list in s.tags.values() {
2648            for t in tag_list {
2649                let tag = TrifectaTag::parse(t).ok_or_else(|| {
2650                    usage(format!(
2651                        "{source}: mcp server '{}' has unknown trifecta tag '{t}' \
2652                         (want: untrusted_input|sensitive|egress)",
2653                        s.name
2654                    ))
2655                })?;
2656                if !tags.contains(&tag) {
2657                    tags.push(tag);
2658                }
2659            }
2660        }
2661        c.mcp_servers.push(McpServerSpec {
2662            name: s.name,
2663            endpoint,
2664            headers,
2665            tags,
2666            aauth: s.aauth,
2667            // OAuth client-credentials + the unified `auth:` block + the
2668            // service catalog are settings-document-only surfaces; this flat
2669            // config path does not carry them.
2670            oauth: None,
2671            auth: None,
2672            service: None,
2673            rate: None,
2674        });
2675    }
2676    c.subscribe.extend(cf.subscribe);
2677    for p in cf.a2a_peers {
2678        if p.name.is_empty() || p.endpoint.is_empty() {
2679            return Err(usage(format!(
2680                "{source}: a2a peer '{}' has an empty name or endpoint",
2681                p.name
2682            )));
2683        }
2684        c.a2a_peers.push(A2aPeerSpec {
2685            name: p.name,
2686            endpoint: p.endpoint,
2687            headers: p.headers.into_iter().collect(),
2688            client_cert: p.client_cert,
2689            client_key: p.client_key,
2690        });
2691    }
2692    // Declared intelligence headers (templates; secret-shaped values validated).
2693    c.intelligence_headers.extend(cf.intelligence_headers);
2694    Ok(())
2695}
2696
2697/// Parse `--workflow-resume <server>:<key>[@seq]`. The server
2698/// is the configured `--mcp` name of the checkpointer; the key identifies the
2699/// state lineage (`{run_id}` interpolates later); `@seq` pins a specific
2700/// envelope (fork/time-travel) — latest when absent.
2701#[cfg(feature = "workflow")]
2702fn parse_workflow_resume(
2703    spec: &str,
2704) -> Result<crate::subagent::protocol::WorkflowResumeRef, ConfigError> {
2705    let (server, rest) = spec.split_once(':').ok_or_else(|| {
2706        usage(format!(
2707            "--workflow-resume: want <server>:<key>[@seq] (got: {spec})"
2708        ))
2709    })?;
2710    let (key, seq) = match rest.rsplit_once('@') {
2711        Some((k, s)) => {
2712            let seq: u64 = s
2713                .parse()
2714                .map_err(|_| usage(format!("--workflow-resume: bad @seq {s:?} (want a number)")))?;
2715            (k, Some(seq))
2716        }
2717        None => (rest, None),
2718    };
2719    if server.trim().is_empty() || key.trim().is_empty() {
2720        return Err(usage(format!(
2721            "--workflow-resume: server and key must be non-empty (got: {spec})"
2722        )));
2723    }
2724    Ok(crate::subagent::protocol::WorkflowResumeRef {
2725        server: server.to_string(),
2726        key: key.to_string(),
2727        seq,
2728        force: false,
2729    })
2730}
2731
2732pub(crate) fn usage(msg: String) -> ConfigError {
2733    ConfigError::Usage(format!("agentd: {msg}"))
2734}
2735
2736pub(crate) fn truthy(v: &str) -> bool {
2737    matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")
2738}
2739
2740/// Parse `600s`, `5m`, `2h`, `30d`, `2w`, `500ms`, or a bare integer
2741/// (seconds). Days and weeks exist because retention, dunning, and cadence
2742/// windows are naturally written in them — `30d` reads, `720h` gets checked
2743/// with a calculator.
2744pub fn parse_duration(s: &str) -> Result<Duration, String> {
2745    let s = s.trim();
2746    if s.is_empty() {
2747        return Err("empty duration".into());
2748    }
2749    let (num, unit): (&str, &str) = match s.find(|c: char| c.is_ascii_alphabetic()) {
2750        Some(i) => (&s[..i], &s[i..]),
2751        None => (s, "s"),
2752    };
2753    let n: u64 = num.parse().map_err(|_| format!("invalid duration: {s}"))?;
2754    let d = match unit {
2755        "ms" => Duration::from_millis(n),
2756        "s" => Duration::from_secs(n),
2757        "m" => Duration::from_secs(n * 60),
2758        "h" => Duration::from_secs(n * 3600),
2759        "d" => Duration::from_secs(n * 86_400),
2760        "w" => Duration::from_secs(n * 604_800),
2761        other => return Err(format!("unknown duration unit '{other}' in {s}")),
2762    };
2763    Ok(d)
2764}
2765
2766/// A unique-enough run id for the default case (time + pid). The operator
2767/// overrides it with `--run-id` / `AGENTD_RUN_ID` when a retry must be
2768/// idempotent — the run id is the idempotency key, so a retry that wants to be
2769/// recognised as the same run must reuse it.
2770fn generate_run_id() -> String {
2771    let millis = SystemTime::now()
2772        .duration_since(UNIX_EPOCH)
2773        .unwrap_or_default()
2774        .as_millis();
2775    let pid = std::process::id();
2776    format!("{millis:011x}{pid:04x}")
2777}
2778
2779fn help_text() -> String {
2780    format!(
2781        "agentd {ver} — a minimal, MCP-native, reactive agent\n\
2782         \n\
2783         USAGE:\n\
2784         \x20 agentd --instruction <TEXT> --intelligence <URI> [--mcp name=endpoint ...] [options]\n\
2785         \n\
2786         REQUIRED:\n\
2787         \x20 --instruction <TEXT>        the task (or INSTRUCTION / AGENT_INSTRUCTION env)\n\
2788         \x20 --instruction-file <PATH>   read the instruction from a file\n\
2789         \x20 --intelligence <URI>        https://host[:port][/path] (comma-list = failover order; http:// loopback-only for dev; or INTELLIGENCE / AGENT_INTELLIGENCE env)\n\
2790         \n\
2791         INTELLIGENCE:\n\
2792         \x20 --intelligence-token <T>    bearer/key (or AGENT_INTELLIGENCE_TOKEN)\n\
2793         \x20 --intelligence-token-file <PATH>  read the token from a mounted file (rotation; or AGENT_INTELLIGENCE_TOKEN_FILE)\n\
2794         \x20 --model <NAME>              model id (or AGENT_MODEL)\n\
2795         \x20 --model-swap <finish-on-old|restart-turn>  in-flight model-change policy (default finish-on-old; or AGENT_MODEL_SWAP)\n\
2796         \n\
2797         TOOLS / MCP:\n\
2798         \x20 --mcp name=endpoint         declare a remote MCP server (repeatable; https://host[:port][/path])\n\
2799         \x20 --tls-ca <PATH>             extra PEM CA(s) trusted for outbound https (private/in-cluster PKI; added to the bundled roots)\n\
2800         \x20 --aauth-provider <URL>      [DRAFT] Agent Provider — sign every MCP request with an Ed25519 agent identity (needs --features aauth; or AGENT_AAUTH_PROVIDER)\n\
2801         \x20 --aauth-key-file <PATH>     durable Ed25519 key file (created 0600 if absent; default agent.key; or AGENT_AAUTH_KEY_FILE)\n\
2802         \x20 --aauth-enroll-token <T>    one-time enrollment token ({{secret:…}}; provider `token` mode; or AGENT_AAUTH_ENROLL_TOKEN)\n\
2803         \x20 --aauth-enroll-assertion-file <PATH>  enrollment assertion file — e.g. a projected K8s SA token (provider `federated` mode; re-read each enroll; or AGENT_AAUTH_ENROLL_ASSERTION_FILE)\n\
2804         \x20 --aauth-person-server <URL> [DRAFT] Person Server for user-scoped identity (Case C; or AGENT_AAUTH_PERSON_SERVER)\n\
2805         \x20 --serve-mcp <TARGET>        serve agentd's own MCP over HTTP(S): https://host:port (or loopback http:// for dev)\n\
2806         \x20 --a2a-peer name=<ENDPOINT>  declare a remote A2A delegation peer: https://host[:port] (repeatable; needs --features a2a)\n\
2807         \x20 --mcp-tags name=t,t         capability tags: untrusted_input|sensitive|egress\n\
2808         \x20 --allow-trifecta            permit all three capability legs in one agent\n\
2809         \n\
2810         MODE / TRIGGERS:\n\
2811         \x20 --mode once|loop|reactive|schedule|workflow   (default once)\n\
2812         \x20 --workflow <FILE>           pinned workflow JSON, driven by --mode workflow (needs --features workflow; or AGENT_WORKFLOW)\n\
2813         \x20 --workflow-resume <REF>     resume from a checkpoint: <server>:<key>[@seq] (needs --mode workflow; or AGENT_WORKFLOW_RESUME)\n\
2814         \x20 --workflow-resume-force     override the workflow-hash check (graph-edit-and-continue)\n\
2815         \x20 --subscribe <uri>           subscribe to an MCP resource (repeatable)\n\
2816         \x20 --continue <uri>            subscribe, routed to one warm session (repeatable)\n\
2817         \x20 --interval <dur>            loop/schedule interval (e.g. 5m)\n\
2818         \x20 --cron <5-field>           schedule on a UTC cron expr (needs --features cron)\n\
2819         \n\
2820         LIMITS:\n\
2821         \x20 --max-steps <N>             per-run step cap (default 50)\n\
2822         \x20 --max-tokens <N>            per-run token budget (default 200000)\n\
2823         \x20 --budget-tokens-lifetime <N>  per-INSTANCE cumulative token cap across all runs/reactions (0/unset = unbounded; or AGENT_BUDGET_TOKENS)\n\
2824         \x20 --deadline <dur>            wall-clock deadline (default 600s)\n\
2825         \x20 --max-depth <N>             subagent tree depth cap (default 4)\n\
2826         \n\
2827         RUNTIME:\n\
2828         \x20 --run-id <ID>               idempotency key (or AGENT_RUN_ID)\n\
2829         \x20 --log-level <L>             trace|debug|info|warn|error (default info)\n\
2830         \x20 --log-content               log tool args/results, not just lengths (opt-in)\n\
2831         \x20 --drain-timeout <dur>       graceful drain budget (default 25s; < pod grace)\n\
2832         \x20 --health-file <PATH>        liveness heartbeat file\n\
2833         \x20 --metrics-addr <host:port>  serve /metrics+/healthz+/readyz (`:port` = all IPv4 ifaces; needs --features metrics)\n\
2834         \x20 --cgroup <auto|PATH>        per-run cgroup for atomic cgroup.kill teardown (best-effort)\n\
2835         \x20 --cgroup-memory-max <SIZE>  per-run memory.max (max|512M|2G|bytes; needs --cgroup + delegation)\n\
2836         \x20 --cgroup-pids-max <N>       per-run pids.max (max|count of THREADS; needs --cgroup + delegation)\n\
2837         \x20 --traceparent <W3C>         continue an upstream trace (or AGENT_TRACEPARENT)\n\
2838         \x20 --report-file <PATH>        write the run-outcome report at terminal (atomic; inert for reactive)\n\
2839         \x20 --budget-exit-code <N>      remap the policy budget codes (3/7 only) to N at process exit (0..=255)\n\
2840         \x20 --events-ring <N>           agent://events ring size (default 1024; needs --serve-mcp + --features events)\n\
2841         \x20 --capabilities             print the capabilities manifest (JSON) and exit\n\
2842         \n\
2843         CONFIG FILE:\n\
2844         \x20 --config <PATH>             load a config file, YAML or JSON; repeatable — later files override earlier ones (or AGENT_CONFIG=a.yaml:b.yaml)\n\
2845         \x20 --validate-config          load+validate (file+env+flags), print the verdict, exit 0/2\n\
2846         \x20 --config-schema            print the config-file JSON Schema and exit\n\
2847         \x20 --watch-config             reload on config-file change via inotify (needs --config + --features config-watch; or AGENT_WATCH_CONFIG)\n\
2848         \x20 -h, --help / -V, --version\n\
2849         \n\
2850         {paths}",
2851        ver = crate::VERSION,
2852        paths = paths::help_section(),
2853    )
2854}
2855
2856#[cfg(test)]
2857mod tests {
2858    use super::*;
2859
2860    fn args(v: &[&str]) -> Vec<String> {
2861        v.iter().map(|s| s.to_string()).collect()
2862    }
2863
2864    #[test]
2865    fn config_flag_accepts_short_and_inline_spellings() {
2866        // `--config a.yaml`, `-c a.yaml`, and either with the value attached by
2867        // `=` all name the same file layer, in argument order.
2868        let env: Vec<(String, String)> = vec![];
2869        for spelling in [
2870            args(&["--config", "a.yaml"]),
2871            args(&["-c", "a.yaml"]),
2872            args(&["--config=a.yaml"]),
2873            args(&["-c=a.yaml"]),
2874        ] {
2875            assert_eq!(
2876                Config::config_paths_from(&spelling, &env),
2877                vec!["a.yaml".to_string()],
2878                "spelling {spelling:?}"
2879            );
2880        }
2881        // Mixed spellings merge in order (later wins downstream).
2882        assert_eq!(
2883            Config::config_paths_from(&args(&["-c", "base.yaml", "--config=over.yaml"]), &env),
2884            vec!["base.yaml".to_string(), "over.yaml".to_string()]
2885        );
2886        // The env layer comes first, then the flags.
2887        assert_eq!(
2888            Config::config_paths_from(
2889                &args(&["-c=flag.yaml"]),
2890                &[("AGENTD_CONFIG".into(), "env.yaml".into())]
2891            ),
2892            vec!["env.yaml".to_string(), "flag.yaml".to_string()]
2893        );
2894        // A bare `-c` with nothing after it contributes no path (and the arg
2895        // loop reports the usage error).
2896        assert!(Config::config_paths_from(&args(&["-c"]), &env).is_empty());
2897        // Not the config flag: neither a different flag nor a lookalike value.
2898        assert!(Config::config_paths_from(&args(&["--cluster-shard", "a"]), &env).is_empty());
2899    }
2900
2901    /// The chain: a user default, the project's file, a machine-local overlay —
2902    /// in that order, so each is overridden by the more specific one. Rungs
2903    /// compose; SPELLINGS within one rung do not.
2904    #[test]
2905    fn the_discovery_chain_layers_user_then_project_then_local() {
2906        let root = std::env::temp_dir().join(format!("agentd-chain-{}", std::process::id()));
2907        let _ = std::fs::remove_dir_all(&root);
2908        let (home, cwd) = (root.join("home"), root.join("work"));
2909        std::fs::create_dir_all(home.join(".config").join("agentd")).unwrap();
2910        std::fs::create_dir_all(&cwd).unwrap();
2911        let home_s = home.to_string_lossy().into_owned();
2912        let envmap: HashMap<&str, &str> = [("HOME", home_s.as_str())].into_iter().collect();
2913
2914        // An empty chain is not an error: agentd runs on its built-in defaults.
2915        assert!(discovered_chain(&cwd, &envmap).unwrap().is_empty());
2916
2917        std::fs::write(home.join(".config/agentd/config.yml"), "a: 1\n").unwrap();
2918        std::fs::write(cwd.join("agentd.yml"), "b: 2\n").unwrap();
2919        std::fs::write(cwd.join("agentd.local.yml"), "c: 3\n").unwrap();
2920        let chain = discovered_chain(&cwd, &envmap).unwrap();
2921        assert_eq!(chain.len(), 3, "{chain:?}");
2922        assert!(chain[0].ends_with("config.yml"), "{chain:?}");
2923        assert!(chain[1].ends_with("agentd.yml"), "{chain:?}");
2924        assert!(chain[2].ends_with("agentd.local.yml"), "{chain:?}");
2925
2926        // Two spellings of ONE rung is the coin toss nobody should debug.
2927        std::fs::write(cwd.join("agentd.yaml"), "b: 9\n").unwrap();
2928        let e = discovered_chain(&cwd, &envmap).unwrap_err();
2929        assert!(e.contains("project config is ambiguous"), "{e}");
2930        std::fs::remove_file(cwd.join("agentd.yaml")).unwrap();
2931
2932        // XDG wins over HOME when both are set.
2933        let xdg = root.join("xdg");
2934        std::fs::create_dir_all(xdg.join("agentd")).unwrap();
2935        std::fs::write(xdg.join("agentd/config.yml"), "a: 7\n").unwrap();
2936        let xdg_s = xdg.to_string_lossy().into_owned();
2937        let envmap2: HashMap<&str, &str> = [
2938            ("HOME", home_s.as_str()),
2939            ("XDG_CONFIG_HOME", xdg_s.as_str()),
2940        ]
2941        .into_iter()
2942        .collect();
2943        let chain = discovered_chain(&cwd, &envmap2).unwrap();
2944        assert!(chain[0].starts_with(&xdg_s), "{chain:?}");
2945
2946        // No HOME at all: no user rung, and no panic reaching for one.
2947        let bare: HashMap<&str, &str> = HashMap::new();
2948        assert_eq!(discovered_chain(&cwd, &bare).unwrap().len(), 2);
2949
2950        let _ = std::fs::remove_dir_all(&root);
2951    }
2952
2953    /// A project config in the working directory, picked up when the invocation
2954    /// named no config — and never when it did.
2955    #[test]
2956    fn a_dotfile_is_discovered_only_when_nothing_else_named_a_config() {
2957        let dir = std::env::temp_dir().join(format!("agentd-discover-{}", std::process::id()));
2958        let _ = std::fs::remove_dir_all(&dir);
2959        std::fs::create_dir_all(&dir).unwrap();
2960
2961        // Nothing there yet.
2962        assert!(present_in(&dir, &PROJECT_CONFIG_NAMES).is_empty());
2963
2964        std::fs::write(dir.join(".agentd.yml"), "config_version: \"1\"\n").unwrap();
2965        let found = present_in(&dir, &PROJECT_CONFIG_NAMES);
2966        assert_eq!(found.len(), 1);
2967        assert!(found[0].ends_with(".agentd.yml"), "{found:?}");
2968
2969        // Both spellings are reported, so the caller can refuse rather than
2970        // silently pick one: whichever it chose, somebody would be editing the
2971        // other and wondering why nothing changed.
2972        std::fs::write(dir.join(".agentd.yaml"), "config_version: \"1\"\n").unwrap();
2973        assert_eq!(present_in(&dir, &PROJECT_CONFIG_NAMES).len(), 2);
2974
2975        let _ = std::fs::remove_dir_all(&dir);
2976    }
2977
2978    /// The informational invocations must work in any directory — a stray
2979    /// dotfile must not be able to break `--help`.
2980    #[test]
2981    fn help_and_version_do_not_discover_a_config() {
2982        for a in [
2983            "--help",
2984            "-h",
2985            "--version",
2986            "-V",
2987            "--config-schema",
2988            "--workflow-schema",
2989        ] {
2990            assert!(is_informational(&args(&[a])), "{a} should be informational");
2991        }
2992        assert!(!is_informational(&args(&["--validate-config"])));
2993        assert!(!is_informational(&args(&[])));
2994    }
2995
2996    #[test]
2997    fn flags_override_env() {
2998        let env = vec![
2999            ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3000            ("INSTRUCTION".into(), "from-env".into()),
3001        ];
3002        let c = Config::load(&args(&["--instruction", "from-flag"]), &env).unwrap();
3003        assert_eq!(c.instruction.as_deref(), Some("from-flag"));
3004        assert_eq!(c.intelligence.as_deref(), Some("https://intel.example"));
3005    }
3006
3007    #[cfg(feature = "workflow")]
3008    #[test]
3009    fn workflow_mode_and_workflow_file_are_inseparable() {
3010        let intel_only = vec![(
3011            "AGENTD_INTELLIGENCE".to_string(),
3012            "https://intel.example".to_string(),
3013        )];
3014        // --mode workflow without --workflow → usage error.
3015        let e = Config::load(
3016            &args(&["--mode", "workflow", "--instruction", "x"]),
3017            &intel_only,
3018        )
3019        .unwrap_err();
3020        assert!(
3021            format!("{e}").contains("--mode workflow requires --workflow"),
3022            "{e}"
3023        );
3024        // --workflow without --mode workflow → usage error.
3025        let e = Config::load(&args(&["--workflow", "/tmp/g.json"]), &base_env()).unwrap_err();
3026        assert!(format!("{e}").contains("--workflow is only valid"), "{e}");
3027    }
3028
3029    #[cfg(feature = "workflow")]
3030    #[test]
3031    fn a_reactive_workflow_daemon_needs_no_subscribe_or_instruction() {
3032        // The workflow's Wait nodes ARE the subscriptions, and its nodes carry
3033        // the work — `--mode reactive --workflow <file>` stands alone.
3034        let c = Config::load(
3035            &args(&["--mode", "reactive", "--workflow", "/tmp/wf.json"]),
3036            &base_env(),
3037        )
3038        .unwrap();
3039        assert_eq!(c.mode, Mode::Reactive);
3040        assert_eq!(c.workflow_file.as_deref(), Some("/tmp/wf.json"));
3041        // A plain reactive daemon still requires a subscription.
3042        let e = Config::load(&args(&["--mode", "reactive"]), &base_env()).unwrap_err();
3043        assert!(matches!(e, ConfigError::Usage(_)));
3044        // And --workflow still refuses the modes it means nothing in.
3045        let e = Config::load(
3046            &args(&[
3047                "--mode",
3048                "loop",
3049                "--interval",
3050                "5m",
3051                "--workflow",
3052                "/tmp/wf.json",
3053                "--instruction",
3054                "x",
3055            ]),
3056            &base_env(),
3057        )
3058        .unwrap_err();
3059        assert!(format!("{e}").contains("--workflow is only valid"), "{e}");
3060    }
3061
3062    #[cfg(feature = "workflow")]
3063    #[test]
3064    fn a_reactive_workflow_with_subscriptions_still_requires_an_instruction() {
3065        // Subscription routes spawn instruction reactions — a blank task is a
3066        // wiring mistake even when a workflow also rides the daemon.
3067        let intel_only = vec![(
3068            "AGENTD_INTELLIGENCE".to_string(),
3069            "https://intel.example".to_string(),
3070        )];
3071        let e = Config::load(
3072            &args(&[
3073                "--mode",
3074                "reactive",
3075                "--workflow",
3076                "/tmp/wf.json",
3077                "--subscribe",
3078                "file:///inbox",
3079            ]),
3080            &intel_only,
3081        )
3082        .unwrap_err();
3083        assert!(format!("{e}").contains("missing instruction"), "{e}");
3084        // A PURE workflow daemon (no routes) still needs none.
3085        let c = Config::load(
3086            &args(&["--mode", "reactive", "--workflow", "/tmp/wf.json"]),
3087            &intel_only,
3088        )
3089        .unwrap();
3090        assert!(c.instruction.as_deref().unwrap_or("").is_empty());
3091        // With an instruction the combo is fine.
3092        let c = Config::load(
3093            &args(&[
3094                "--mode",
3095                "reactive",
3096                "--workflow",
3097                "/tmp/wf.json",
3098                "--subscribe",
3099                "file:///inbox",
3100                "--instruction",
3101                "triage it",
3102            ]),
3103            &base_env(),
3104        )
3105        .unwrap();
3106        assert_eq!(c.subscribe.len(), 1);
3107        assert!(c.workflow_file.is_some());
3108    }
3109
3110    #[cfg(feature = "workflow")]
3111    #[test]
3112    fn workflow_mode_does_not_require_an_instruction() {
3113        // The workflow carries its instructions, so `--mode workflow` needs no
3114        // `--instruction` (it still needs intelligence, for the Agent nodes).
3115        let intel_only = vec![(
3116            "AGENTD_INTELLIGENCE".to_string(),
3117            "https://intel.example".to_string(),
3118        )];
3119        let c = Config::load(
3120            &args(&["--mode", "workflow", "--workflow", "/tmp/g.json"]),
3121            &intel_only,
3122        )
3123        .unwrap();
3124        assert_eq!(c.mode, Mode::Workflow);
3125        assert_eq!(c.workflow_file.as_deref(), Some("/tmp/g.json"));
3126        assert!(c.instruction.as_deref().unwrap_or("").is_empty());
3127    }
3128
3129    #[cfg(feature = "workflow")]
3130    #[test]
3131    fn workflow_resume_parses_and_validates() {
3132        let intel_only = vec![(
3133            "AGENTD_INTELLIGENCE".to_string(),
3134            "https://intel.example".to_string(),
3135        )];
3136        // Full form, with a configured checkpointer server: server:key@seq.
3137        let c = Config::load(
3138            &args(&[
3139                "--mode",
3140                "workflow",
3141                "--workflow",
3142                "/tmp/g.json",
3143                "--mcp",
3144                "state=https://ckpt.internal/mcp",
3145                "--workflow-resume",
3146                "state:run/abc@17",
3147                "--workflow-resume-force",
3148            ]),
3149            &intel_only,
3150        )
3151        .unwrap();
3152        let r = c.workflow_resume.expect("parsed");
3153        assert_eq!(r.server, "state");
3154        assert_eq!(r.key, "run/abc");
3155        assert_eq!(r.seq, Some(17));
3156        assert!(r.force);
3157
3158        // Force is order-independent (force first, then the ref).
3159        let c = Config::load(
3160            &args(&[
3161                "--mode",
3162                "workflow",
3163                "--workflow",
3164                "/tmp/g.json",
3165                "--mcp",
3166                "state=https://ckpt.internal/mcp",
3167                "--workflow-resume-force",
3168                "--workflow-resume",
3169                "state:run/abc",
3170            ]),
3171            &intel_only,
3172        )
3173        .unwrap();
3174        assert!(c.workflow_resume.unwrap().force);
3175
3176        // Misconfigs are exit-2-shaped errors, pre-network: bad spec, force
3177        // without a ref, a non-workflow mode, an unconfigured server name.
3178        for bad in [
3179            vec![
3180                "--mode",
3181                "workflow",
3182                "--workflow",
3183                "/g",
3184                "--workflow-resume",
3185                "nocolon",
3186            ],
3187            vec![
3188                "--mode",
3189                "workflow",
3190                "--workflow",
3191                "/g",
3192                "--workflow-resume-force",
3193            ],
3194            vec!["--instruction", "x", "--workflow-resume", "s:k"],
3195            vec![
3196                "--mode",
3197                "workflow",
3198                "--workflow",
3199                "/g",
3200                "--workflow-resume",
3201                "ghost:k",
3202            ],
3203        ] {
3204            assert!(
3205                Config::load(&args(&bad), &base_env()).is_err(),
3206                "{bad:?} must be refused"
3207            );
3208        }
3209        // env spelling works too.
3210        let mut env = intel_only.clone();
3211        env.push(("AGENT_WORKFLOW_RESUME".into(), "state:run/xyz".into()));
3212        let c = Config::load(
3213            &args(&[
3214                "--mode",
3215                "workflow",
3216                "--workflow",
3217                "/g",
3218                "--mcp",
3219                "state=https://ckpt.internal/mcp",
3220            ]),
3221            &env,
3222        )
3223        .unwrap();
3224        assert_eq!(c.workflow_resume.unwrap().key, "run/xyz");
3225    }
3226
3227    fn base_env() -> Vec<(String, String)> {
3228        vec![
3229            ("INSTRUCTION".into(), "x".into()),
3230            ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3231        ]
3232    }
3233
3234    #[test]
3235    fn neutral_agent_env_prefix_is_accepted_as_an_alias() {
3236        // The neutral `AGENT_*` prefix is accepted on input wherever the
3237        // branded `AGENTD_*` one is, through one envmap normalization.
3238        let env = vec![
3239            ("INSTRUCTION".into(), "x".into()),
3240            (
3241                "AGENT_INTELLIGENCE".into(),
3242                "https://neutral.example".into(),
3243            ),
3244            ("AGENT_RUN_ID".into(), "run-neutral".into()),
3245            ("AGENT_MAX_STEPS".into(), "42".into()),
3246        ];
3247        let c = Config::load(&args(&[]), &env).unwrap();
3248        assert_eq!(c.intelligence.as_deref(), Some("https://neutral.example"));
3249        assert_eq!(c.run_id, "run-neutral");
3250        assert_eq!(c.max_steps, 42);
3251    }
3252
3253    #[test]
3254    fn branded_env_wins_over_neutral_on_conflict() {
3255        // Both spellings present ⇒ the branded `AGENTD_*` value wins (back-compat),
3256        // and the branded-only path still works (neutral merely also accepted).
3257        let env = vec![
3258            ("INSTRUCTION".into(), "x".into()),
3259            (
3260                "AGENTD_INTELLIGENCE".into(),
3261                "https://branded.example".into(),
3262            ),
3263            (
3264                "AGENT_INTELLIGENCE".into(),
3265                "https://neutral.example".into(),
3266            ),
3267        ];
3268        let c = Config::load(&args(&[]), &env).unwrap();
3269        assert_eq!(c.intelligence.as_deref(), Some("https://branded.example"));
3270    }
3271
3272    #[test]
3273    fn bare_env_spellings_work_for_the_two_required_inputs() {
3274        // The bare `INTELLIGENCE` mirrors the bare `INSTRUCTION`: the minimal
3275        // quickstart is `INSTRUCTION=… INTELLIGENCE=… agentd` with no prefix.
3276        let env = vec![
3277            ("INSTRUCTION".into(), "x".into()),
3278            ("INTELLIGENCE".into(), "https://bare.example".into()),
3279        ];
3280        let c = Config::load(&args(&[]), &env).unwrap();
3281        assert_eq!(c.intelligence.as_deref(), Some("https://bare.example"));
3282    }
3283
3284    #[test]
3285    fn prefixed_env_wins_over_the_bare_spelling() {
3286        // Specificity order within the env layer: branded > neutral > bare.
3287        // The neutral AGENT_* forms (debranded to AGENTD_*) beat the bare
3288        // aliases, for BOTH required inputs — AGENT_INSTRUCTION included.
3289        let env = vec![
3290            ("INSTRUCTION".into(), "bare-task".into()),
3291            ("AGENT_INSTRUCTION".into(), "neutral-task".into()),
3292            ("INTELLIGENCE".into(), "https://bare.example".into()),
3293            (
3294                "AGENT_INTELLIGENCE".into(),
3295                "https://neutral.example".into(),
3296            ),
3297        ];
3298        let c = Config::load(&args(&[]), &env).unwrap();
3299        assert_eq!(c.instruction.as_deref(), Some("neutral-task"));
3300        assert_eq!(c.intelligence.as_deref(), Some("https://neutral.example"));
3301    }
3302
3303    #[test]
3304    fn debrand_env_synthesizes_branded_only_when_absent() {
3305        // Unit-level: a neutral key without a branded counterpart gets a synthesized
3306        // branded entry; a present branded key is left untouched (branded wins).
3307        let env = vec![
3308            ("AGENT_MODE".into(), "loop".into()),
3309            ("AGENTD_RUN_ID".into(), "kept".into()),
3310            ("AGENT_RUN_ID".into(), "ignored".into()),
3311            ("INSTRUCTION".into(), "x".into()),
3312        ];
3313        let out = debrand_env(&env);
3314        let get = |k: &str| {
3315            out.iter()
3316                .filter(|(n, _)| n == k)
3317                .map(|(_, v)| v.as_str())
3318                .collect::<Vec<_>>()
3319        };
3320        // Neutral-only AGENT_MODE → synthesized AGENTD_MODE.
3321        assert_eq!(get("AGENTD_MODE"), vec!["loop"]);
3322        // Branded present → not overwritten by the neutral form.
3323        assert_eq!(get("AGENTD_RUN_ID"), vec!["kept"]);
3324        // Non-prefixed keys are passed through unchanged.
3325        assert_eq!(get("INSTRUCTION"), vec!["x"]);
3326    }
3327
3328    #[test]
3329    fn report_file_and_events_ring_parse_from_flag_and_env() {
3330        // Default: off, with the 1024-entry ring.
3331        let c = Config::load(&args(&[]), &base_env()).unwrap();
3332        assert_eq!(c.report_file, None);
3333        assert_eq!(c.events_ring, crate::obs::log::EVENTS_RING_DEFAULT);
3334
3335        // Flags set both.
3336        let c = Config::load(
3337            &args(&["--report-file", "/out/report.json", "--events-ring", "256"]),
3338            &base_env(),
3339        )
3340        .unwrap();
3341        assert_eq!(c.report_file.as_deref(), Some("/out/report.json"));
3342        assert_eq!(c.events_ring, 256);
3343
3344        // Env sets both; a flag overrides the ring (precedence: flag > env).
3345        let mut env = base_env();
3346        env.push(("AGENTD_REPORT_FILE".into(), "/env/report.json".into()));
3347        env.push(("AGENTD_EVENTS_RING".into(), "64".into()));
3348        let c = Config::load(&args(&["--events-ring", "512"]), &env).unwrap();
3349        assert_eq!(c.report_file.as_deref(), Some("/env/report.json"));
3350        assert_eq!(c.events_ring, 512);
3351    }
3352
3353    #[test]
3354    fn budget_exit_code_flag_parses_and_range_checks() {
3355        // Default: no remap (the canonical table applies).
3356        let c = Config::load(&args(&[]), &base_env()).unwrap();
3357        assert_eq!(c.budget_exit_code, None);
3358        // A valid POSIX exit byte is accepted.
3359        let c = Config::load(&args(&["--budget-exit-code", "0"]), &base_env()).unwrap();
3360        assert_eq!(c.budget_exit_code, Some(0));
3361        let c = Config::load(&args(&["--budget-exit-code", "42"]), &base_env()).unwrap();
3362        assert_eq!(c.budget_exit_code, Some(42));
3363        // Out of the 0..=255 byte range, or non-numeric ⇒ EXIT_USAGE (2).
3364        for bad in ["256", "-1", "nope"] {
3365            let e = Config::load(&args(&["--budget-exit-code", bad]), &base_env()).unwrap_err();
3366            assert!(
3367                matches!(e, ConfigError::Usage(_)),
3368                "{bad} must be a usage error"
3369            );
3370        }
3371    }
3372
3373    #[test]
3374    fn events_ring_zero_and_bad_value_are_usage_errors() {
3375        let zero = Config::load(&args(&["--events-ring", "0"]), &base_env()).unwrap_err();
3376        assert!(matches!(zero, ConfigError::Usage(_)));
3377        let bad = Config::load(&args(&["--events-ring", "lots"]), &base_env()).unwrap_err();
3378        assert!(matches!(bad, ConfigError::Usage(_)));
3379    }
3380
3381    #[test]
3382    fn mcp_tags_attach_to_their_server_order_independent() {
3383        // --mcp-tags before its --mcp still resolves.
3384        let c = Config::load(
3385            &args(&[
3386                "--mcp-tags",
3387                "fs=sensitive,egress",
3388                "--mcp",
3389                "fs=https://fs.example",
3390            ]),
3391            &base_env(),
3392        )
3393        .unwrap();
3394        assert_eq!(
3395            c.mcp_servers[0].tags,
3396            vec![TrifectaTag::Sensitive, TrifectaTag::Egress]
3397        );
3398    }
3399
3400    #[test]
3401    fn mcp_tags_unknown_server_or_tag_is_usage_error() {
3402        let bad_server = Config::load(
3403            &args(&[
3404                "--mcp",
3405                "fs=https://fs.example",
3406                "--mcp-tags",
3407                "ghost=egress",
3408            ]),
3409            &base_env(),
3410        )
3411        .unwrap_err();
3412        assert!(matches!(bad_server, ConfigError::Usage(_)));
3413        let bad_tag = Config::load(
3414            &args(&["--mcp", "fs=https://fs.example", "--mcp-tags", "fs=bogus"]),
3415            &base_env(),
3416        )
3417        .unwrap_err();
3418        assert!(matches!(bad_tag, ConfigError::Usage(_)));
3419    }
3420
3421    #[test]
3422    fn cgroup_limits_require_cgroup_and_reject_zero() {
3423        // A limit without --cgroup is a misconfiguration (silently unbounded run).
3424        let e = Config::load(&args(&["--cgroup-memory-max", "512M"]), &base_env()).unwrap_err();
3425        assert!(matches!(e, ConfigError::Usage(_)));
3426        let e2 = Config::load(&args(&["--cgroup-pids-max", "64"]), &base_env()).unwrap_err();
3427        assert!(matches!(e2, ConfigError::Usage(_)));
3428        // With --cgroup, the limits validate.
3429        let c = Config::load(
3430            &args(&[
3431                "--cgroup",
3432                "auto",
3433                "--cgroup-memory-max",
3434                "512M",
3435                "--cgroup-pids-max",
3436                "64",
3437            ]),
3438            &base_env(),
3439        )
3440        .unwrap();
3441        assert_eq!(c.cgroup_memory_max.as_deref(), Some("512M"));
3442        assert_eq!(c.cgroup_pids_max.as_deref(), Some("64"));
3443        // A zero limit can never let the agent run → rejected.
3444        let z = Config::load(
3445            &args(&["--cgroup", "auto", "--cgroup-pids-max", "0"]),
3446            &base_env(),
3447        )
3448        .unwrap_err();
3449        assert!(matches!(z, ConfigError::Usage(_)));
3450        let zm = Config::load(
3451            &args(&["--cgroup", "auto", "--cgroup-memory-max", "0"]),
3452            &base_env(),
3453        )
3454        .unwrap_err();
3455        assert!(matches!(zm, ConfigError::Usage(_)));
3456    }
3457
3458    #[test]
3459    fn cron_requires_schedule_mode() {
3460        // --cron with the wrong mode → usage error
3461        let e = Config::load(
3462            &args(&[
3463                "--mode",
3464                "reactive",
3465                "--subscribe",
3466                "x://y",
3467                "--cron",
3468                "* * * * *",
3469            ]),
3470            &base_env(),
3471        )
3472        .unwrap_err();
3473        assert!(matches!(e, ConfigError::Usage(_)));
3474        // --mode schedule --cron validates (the expr itself is parsed by the cron feature)
3475        let c = Config::load(
3476            &args(&["--mode", "schedule", "--cron", "0 9 * * 1-5"]),
3477            &base_env(),
3478        )
3479        .unwrap();
3480        assert_eq!(c.cron.as_deref(), Some("0 9 * * 1-5"));
3481        // schedule mode with neither interval nor cron → usage error
3482        let e2 = Config::load(&args(&["--mode", "schedule"]), &base_env()).unwrap_err();
3483        assert!(matches!(e2, ConfigError::Usage(_)));
3484    }
3485
3486    #[test]
3487    fn trifecta_grant_tags_defaults_untagged_to_untrusted() {
3488        let c = Config::load(&args(&["--mcp", "fs=https://fs.example"]), &base_env()).unwrap();
3489        let tags = c.trifecta_grant_tags();
3490        assert!(tags.contains(&TrifectaTag::UntrustedInput)); // untagged server
3491        assert!(!tags.contains(&TrifectaTag::Sensitive)); // one leg → not a trifecta
3492    }
3493
3494    #[test]
3495    fn missing_instruction_is_usage_error() {
3496        let env = vec![("AGENTD_INTELLIGENCE".into(), "https://intel.example".into())];
3497        let e = Config::load(&[], &env).unwrap_err();
3498        assert!(matches!(e, ConfigError::Usage(_)));
3499    }
3500
3501    #[test]
3502    fn help_short_circuits() {
3503        let e = Config::load(&args(&["--help"]), &[]).unwrap_err();
3504        assert!(matches!(e, ConfigError::Help(_)));
3505    }
3506
3507    #[test]
3508    fn reactive_requires_subscribe() {
3509        let env = vec![
3510            ("INSTRUCTION".into(), "x".into()),
3511            ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3512        ];
3513        let e = Config::load(&args(&["--mode", "reactive"]), &env).unwrap_err();
3514        assert!(matches!(e, ConfigError::Usage(_)));
3515        // with a subscription it validates
3516        let c = Config::load(
3517            &args(&["--mode", "reactive", "--subscribe", "file://a"]),
3518            &env,
3519        )
3520        .unwrap();
3521        assert_eq!(c.mode, Mode::Reactive);
3522    }
3523
3524    #[test]
3525    fn mcp_spec_parsing() {
3526        let env = vec![
3527            ("INSTRUCTION".into(), "x".into()),
3528            ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3529        ];
3530        // A `--mcp name=<endpoint>` is the Streamable HTTP transport (the only one).
3531        let c = Config::load(&args(&["--mcp", "fs=https://mcp.example.com/mcp"]), &env).unwrap();
3532        assert_eq!(c.mcp_servers.len(), 1);
3533        assert_eq!(c.mcp_servers[0].name, "fs");
3534        assert_eq!(c.mcp_servers[0].endpoint, "https://mcp.example.com/mcp");
3535    }
3536
3537    #[test]
3538    fn mcp_endpoint_spec_parsing() {
3539        // HTTPS-only: remote Streamable HTTP endpoints.
3540        assert!(is_mcp_endpoint("https://mcp.example.com/mcp"));
3541        assert!(is_mcp_endpoint("http://localhost:8080/mcp"));
3542        // Socket schemes and a stdio argv command are NOT endpoints.
3543        assert!(!is_mcp_endpoint("unix:/run/mcp.sock"));
3544        assert!(!is_mcp_endpoint("vsock:3:5000"));
3545        assert!(!is_mcp_endpoint("mcp-server-fs --root /data"));
3546        assert!(parse_mcp_spec("fs=mcp-server-fs --root /data").is_err());
3547        assert!(parse_mcp_spec("fs=unix:/run/mcp.sock").is_err());
3548
3549        for ep in ["https://mcp.example.com/mcp", "http://127.0.0.1:8080/mcp"] {
3550            let spec = parse_mcp_spec(&format!("fs={ep}")).unwrap();
3551            assert_eq!(spec.name, "fs");
3552            assert_eq!(spec.endpoint, ep);
3553        }
3554    }
3555
3556    #[test]
3557    fn mcp_endpoint_scheme_gate_is_https_only() {
3558        // The validation gate every server (CLI + config-file) flows through.
3559        assert!(mcp_endpoint_scheme_ok("https://mcp.example/mcp").is_ok());
3560        assert!(mcp_endpoint_scheme_ok("http://127.0.0.1:8080/mcp").is_ok());
3561        for bad in [
3562            "unix:/run/mcp.sock",
3563            "vsock:3:5000",
3564            "http://mcp.example:8080/mcp",
3565        ] {
3566            assert!(
3567                mcp_endpoint_scheme_ok(bad).is_err(),
3568                "{bad} must be rejected"
3569            );
3570        }
3571    }
3572
3573    #[test]
3574    fn mcp_endpoint_is_required_and_validated() {
3575        let env = vec![
3576            ("INSTRUCTION".into(), "x".into()),
3577            ("AGENTD_INTELLIGENCE".into(), "https://intel.example".into()),
3578        ];
3579        // A valid endpoint spec loads clean.
3580        let mut c =
3581            Config::load(&args(&["--mcp", "fs=https://mcp.example.com/mcp"]), &env).unwrap();
3582        assert!(c.validate().is_ok());
3583        // An empty endpoint is rejected.
3584        c.mcp_servers[0].endpoint.clear();
3585        assert!(c.validate().is_err(), "an empty endpoint must fail");
3586        // An unparseable endpoint is rejected.
3587        c.mcp_servers[0].endpoint = "ftp://nope/".into();
3588        assert!(
3589            c.validate().is_err(),
3590            "an unsupported endpoint scheme must fail"
3591        );
3592    }
3593
3594    #[test]
3595    fn duration_units() {
3596        assert_eq!(parse_duration("600s").unwrap(), Duration::from_secs(600));
3597        assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
3598        assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
3599        assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250));
3600        assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
3601        assert!(parse_duration("nope").is_err());
3602    }
3603
3604    #[test]
3605    fn invalid_intelligence_uri_rejected() {
3606        let env = vec![("INSTRUCTION".into(), "x".into())];
3607        let e = Config::load(&args(&["--intelligence", "ftp://x"]), &env).unwrap_err();
3608        assert!(matches!(e, ConfigError::Usage(_)));
3609    }
3610
3611    #[test]
3612    fn multi_endpoint_list_accepts_ordered_comma_list() {
3613        // --intelligence is an ORDERED comma-separated list.
3614        let env = vec![("INSTRUCTION".into(), "x".into())];
3615        let c = Config::load(
3616            &args(&[
3617                "--intelligence",
3618                "https://a.example,https://b.example,https://c.example",
3619            ]),
3620            &env,
3621        )
3622        .unwrap();
3623        // the raw scalar is preserved; the client parses it into N endpoints.
3624        assert_eq!(
3625            c.intelligence.as_deref(),
3626            Some("https://a.example,https://b.example,https://c.example")
3627        );
3628    }
3629
3630    #[test]
3631    fn multi_endpoint_bad_element_scheme_is_exit_2() {
3632        // A bad scheme on ANY element rejects the whole list.
3633        let env = vec![("INSTRUCTION".into(), "x".into())];
3634        let e = Config::load(
3635            &args(&[
3636                "--intelligence",
3637                "https://a.example,ftp://nope,https://c.example",
3638            ]),
3639            &env,
3640        )
3641        .unwrap_err();
3642        assert!(matches!(e, ConfigError::Usage(_)));
3643    }
3644
3645    #[test]
3646    fn empty_endpoint_list_is_exit_2() {
3647        // An all-empty/whitespace list is "missing endpoint".
3648        let env = vec![("INSTRUCTION".into(), "x".into())];
3649        let e = Config::load(&args(&["--intelligence", " , , "]), &env).unwrap_err();
3650        assert!(matches!(e, ConfigError::Usage(_)));
3651    }
3652
3653    #[test]
3654    fn serve_target_http_parses() {
3655        assert_eq!(
3656            ServeTarget::parse("https://0.0.0.0:8443").unwrap(),
3657            ServeTarget::Http {
3658                bind: "0.0.0.0:8443".into(),
3659                tls: true
3660            }
3661        );
3662        // loopback plaintext is allowed (dev); a bracketed IPv6 loopback too.
3663        assert_eq!(
3664            ServeTarget::parse("http://127.0.0.1:9000").unwrap(),
3665            ServeTarget::Http {
3666                bind: "127.0.0.1:9000".into(),
3667                tls: false
3668            }
3669        );
3670        assert!(matches!(
3671            ServeTarget::parse("http://[::1]:9000"),
3672            Ok(ServeTarget::Http { tls: false, .. })
3673        ));
3674        // non-loopback plaintext, a path, or a missing port → usage error
3675        for bad in [
3676            "http://10.0.0.5:9000",
3677            "https://host:8443/mcp",
3678            "https://host",
3679        ] {
3680            assert!(
3681                matches!(ServeTarget::parse(bad), Err(ConfigError::Usage(_))),
3682                "{bad} must be rejected"
3683            );
3684        }
3685    }
3686
3687    #[test]
3688    fn serve_auth_gates_the_control_plane() {
3689        let base = |extra: &[&str]| {
3690            let mut a = vec!["--instruction", "x", "--intelligence", "https://i.example"];
3691            a.extend_from_slice(extra);
3692            let args: Vec<String> = a.iter().map(|s| s.to_string()).collect();
3693            Config::load(&args, &[]).and_then(|c| c.validate().map(|_| c))
3694        };
3695        // A non-loopback https target with no auth is refused (open control plane).
3696        assert!(base(&["--serve-mcp", "https://0.0.0.0:8443"]).is_err());
3697        // https:// with no cert/key is refused even on loopback.
3698        assert!(base(&["--serve-mcp", "https://127.0.0.1:8443"]).is_err());
3699        // Loopback plaintext needs no auth (dev).
3700        assert!(base(&["--serve-mcp", "http://127.0.0.1:9000"]).is_ok());
3701        // TLS material on a plaintext target is rejected; a unix target skips
3702        // the TLS/auth material checks entirely (the kernel authenticates).
3703        assert!(base(&["--serve-mcp", "unix:/x.sock", "--serve-bearer", "t"]).is_ok());
3704        assert!(base(&["--serve-mcp", "http://127.0.0.1:9000", "--serve-cert", "/x"]).is_err());
3705        // Serve auth flags without --serve-mcp is a misconfig.
3706        assert!(base(&["--serve-bearer", "t"]).is_err());
3707    }
3708
3709    #[test]
3710    fn serve_target_rejects_unsupported_socket_schemes() {
3711        // `unix:` is accepted for co-located peers — the kernel authenticates
3712        // them by uid — while `vsock:` and `tcp:` are not served at all: exit 2.
3713        assert!(matches!(
3714            ServeTarget::parse("unix:/run/agentd.sock"),
3715            Ok(ServeTarget::Unix { ref path }) if path == "/run/agentd.sock"
3716        ));
3717        assert!(matches!(
3718            ServeTarget::parse("unix:///run/agentd.sock"),
3719            Ok(ServeTarget::Unix { ref path }) if path == "/run/agentd.sock"
3720        ));
3721        for bad in ["vsock:5005", "vsock:2:5005", "tcp:1234"] {
3722            assert!(
3723                matches!(ServeTarget::parse(bad), Err(ConfigError::Usage(_))),
3724                "{bad} must be a usage error"
3725            );
3726        }
3727    }
3728
3729    #[test]
3730    fn serve_mcp_validation_runs_at_load() {
3731        // a loopback http serve target parses through full load().
3732        let c = Config::load(
3733            &args(&["--serve-mcp", "http://127.0.0.1:9000"]),
3734            &base_env(),
3735        )
3736        .unwrap();
3737        assert_eq!(c.serve_mcp.as_deref(), Some("http://127.0.0.1:9000"));
3738        // A foreign scheme is rejected at load (exit 2) before any side effect.
3739        let e = Config::load(&args(&["--serve-mcp", "tcp:9000"]), &base_env()).unwrap_err();
3740        assert!(matches!(e, ConfigError::Usage(_)));
3741    }
3742
3743    #[test]
3744    fn a2a_peer_spec_parses_name_and_endpoint() {
3745        // The endpoint is the remainder after the first '=', so the unix:/vsock:
3746        // scheme passes through verbatim (no second '=' to confuse the split).
3747        let spec = parse_a2a_peer_spec("mesh=https://peer.example").unwrap();
3748        assert_eq!(spec.name, "mesh");
3749        assert_eq!(spec.endpoint, "https://peer.example");
3750        // Missing '=' / empty halves are usage errors.
3751        assert!(matches!(
3752            parse_a2a_peer_spec("noequals"),
3753            Err(ConfigError::Usage(_))
3754        ));
3755        assert!(matches!(
3756            parse_a2a_peer_spec("=https://x"),
3757            Err(ConfigError::Usage(_))
3758        ));
3759        assert!(matches!(
3760            parse_a2a_peer_spec("mesh="),
3761            Err(ConfigError::Usage(_))
3762        ));
3763    }
3764
3765    #[test]
3766    fn a2a_endpoint_https_parses_and_gates_plaintext() {
3767        assert_eq!(
3768            A2aEndpoint::parse("https://peer.example:8443/a2a").unwrap(),
3769            A2aEndpoint::Https("https://peer.example:8443/a2a".into())
3770        );
3771        // loopback plaintext is allowed (dev); non-loopback plaintext is exit 2.
3772        assert!(matches!(
3773            A2aEndpoint::parse("http://127.0.0.1:9000"),
3774            Ok(A2aEndpoint::Https(_))
3775        ));
3776        assert!(matches!(
3777            A2aEndpoint::parse("http://peer.example:9000"),
3778            Err(ConfigError::Usage(_))
3779        ));
3780    }
3781
3782    #[cfg(feature = "a2a")]
3783    #[test]
3784    fn a2a_peer_flag_parses_and_validates_on_a2a_build() {
3785        // A valid https peer loads through full validation.
3786        let c = Config::load(
3787            &args(&["--a2a-peer", "mesh=https://peer.example:8443/a2a"]),
3788            &base_env(),
3789        )
3790        .unwrap();
3791        assert_eq!(c.a2a_peers.len(), 1);
3792        assert_eq!(c.a2a_peers[0].name, "mesh");
3793        assert_eq!(c.a2a_peers[0].endpoint, "https://peer.example:8443/a2a");
3794
3795        // A unix peer endpoint parses (the co-located fast lane)…
3796        assert!(
3797            Config::load(
3798                &args(&["--a2a-peer", "mesh=unix:/run/peer.sock"]),
3799                &base_env()
3800            )
3801            .is_ok()
3802        );
3803        // …while vsock, non-loopback plaintext, and bare tcp stay rejected at
3804        // load (exit 2) before any side effect.
3805        for bad in [
3806            "mesh=vsock:2:5005",
3807            "mesh=http://peer.example:9000",
3808            "mesh=tcp:9000",
3809        ] {
3810            let e = Config::load(&args(&["--a2a-peer", bad]), &base_env()).unwrap_err();
3811            assert!(matches!(e, ConfigError::Usage(_)), "{bad} must be exit 2");
3812        }
3813
3814        // A duplicate peer name is rejected.
3815        let dup = Config::load(
3816            &args(&[
3817                "--a2a-peer",
3818                "mesh=https://a.example",
3819                "--a2a-peer",
3820                "mesh=https://b.example",
3821            ]),
3822            &base_env(),
3823        )
3824        .unwrap_err();
3825        assert!(matches!(dup, ConfigError::Usage(_)));
3826    }
3827
3828    #[cfg(feature = "a2a")]
3829    #[test]
3830    fn a2a_peer_client_auth_is_validated_at_startup() {
3831        // A secret-shaped INLINE header value on a peer is rejected —
3832        // templates only, the same rule as MCP servers.
3833        let file = write_tmp(
3834            r#"{ "a2a_peers": [{ "name": "mesh", "endpoint": "https://peer.example/a2a",
3835                 "headers": { "authorization": "Bearer sk-live-inline-oops" } }] }"#,
3836        );
3837        let e = Config::load(
3838            &args(&["--config", file.path().to_str().unwrap()]),
3839            &base_env(),
3840        )
3841        .unwrap_err();
3842        assert!(matches!(e, ConfigError::Usage(_)), "{e}");
3843        assert!(format!("{e}").contains("a2a peer 'mesh' header"), "{e}");
3844
3845        // A resolvable {{secret:…}} template passes (the PROCESS env carries the
3846        // secret — the resolver reads std::env, like the MCP header resolver).
3847        let file = write_tmp(
3848            r#"{ "a2a_peers": [{ "name": "mesh", "endpoint": "https://peer.example/a2a",
3849                 "headers": { "authorization": "Bearer {{secret:A2A_PEER_AUTH_TEST_TOKEN}}" } }] }"#,
3850        );
3851        // SAFETY: single-threaded test; unique var name avoids cross-test races.
3852        unsafe { std::env::set_var("A2A_PEER_AUTH_TEST_TOKEN", "tok") };
3853        let c = Config::load(
3854            &args(&["--config", file.path().to_str().unwrap()]),
3855            &base_env(),
3856        )
3857        .unwrap();
3858        unsafe { std::env::remove_var("A2A_PEER_AUTH_TEST_TOKEN") };
3859        assert_eq!(
3860            c.a2a_peers[0].headers.len(),
3861            1,
3862            "template stored, not resolved"
3863        );
3864        assert!(
3865            c.a2a_peers[0].headers[0].1.contains("{{secret:"),
3866            "the SPEC keeps the template, never the material"
3867        );
3868
3869        // client_cert without client_key (and vice versa) is a pairing error.
3870        let file = write_tmp(
3871            r#"{ "a2a_peers": [{ "name": "mesh", "endpoint": "https://peer.example/a2a",
3872                 "client_cert": "/tls/cert.pem" }] }"#,
3873        );
3874        let e = Config::load(
3875            &args(&["--config", file.path().to_str().unwrap()]),
3876            &base_env(),
3877        )
3878        .unwrap_err();
3879        assert!(
3880            format!("{e}").contains("client_cert and client_key must be set together"),
3881            "{e}"
3882        );
3883    }
3884
3885    #[cfg(not(feature = "a2a"))]
3886    #[test]
3887    fn a2a_peer_requires_the_a2a_feature() {
3888        // The flag parses, but validation rejects it without the build feature.
3889        let e = Config::load(
3890            &args(&["--a2a-peer", "mesh=https://peer.example"]),
3891            &base_env(),
3892        )
3893        .unwrap_err();
3894        match e {
3895            ConfigError::Usage(msg) => assert!(
3896                msg.contains("--a2a-peer requires the 'a2a' build feature"),
3897                "got: {msg}"
3898            ),
3899            other => panic!("expected a Usage error, got {other:?}"),
3900        }
3901    }
3902
3903    #[test]
3904    fn token_redacted_in_debug() {
3905        let env = vec![
3906            ("INSTRUCTION".into(), "x".into()),
3907            (
3908                "AGENTD_INTELLIGENCE".into(),
3909                "https://api.example/v1".into(),
3910            ),
3911            ("AGENTD_INTELLIGENCE_TOKEN".into(), "super-secret".into()),
3912        ];
3913        let c = Config::load(&[], &env).unwrap();
3914        let dbg = format!("{c:?}");
3915        assert!(!dbg.contains("super-secret"));
3916        assert!(dbg.contains("***"));
3917    }
3918
3919    #[test]
3920    fn debug_redacts_credential_bearing_intelligence_uri() {
3921        // The raw `--intelligence` URI can carry inline creds
3922        // (`https://user:pass@host`). The Debug impl must show the SCHEME only,
3923        // never the userinfo/host/path, mirroring effective_view.
3924        let env = vec![
3925            ("INSTRUCTION".into(), "x".into()),
3926            (
3927                "AGENTD_INTELLIGENCE".into(),
3928                "https://alice:hunter2@internal.example/v1".into(),
3929            ),
3930        ];
3931        let c = Config::load(&[], &env).unwrap();
3932        let dbg = format!("{c:?}");
3933        assert!(!dbg.contains("hunter2"), "creds leaked: {dbg}");
3934        assert!(!dbg.contains("internal.example"), "host leaked: {dbg}");
3935        assert!(dbg.contains("https:<redacted>"), "scheme missing: {dbg}");
3936    }
3937
3938    #[test]
3939    fn help_text_lists_model_swap() {
3940        // Fix 3: --model-swap is parsed+validated but was missing from --help.
3941        let h = match Config::load(&args(&["--help"]), &[]).unwrap_err() {
3942            ConfigError::Help(s) => s,
3943            other => panic!("expected Help, got {other:?}"),
3944        };
3945        assert!(h.contains("--model-swap"), "help omits --model-swap");
3946        assert!(h.contains("finish-on-old|restart-turn"));
3947    }
3948
3949    // ──────────────────────────── config file ────────────────────────────────
3950
3951    use std::io::Write as _;
3952
3953    fn write_tmp(contents: &str) -> tempfile::NamedTempFile {
3954        let mut f = tempfile::NamedTempFile::new().unwrap();
3955        f.write_all(contents.as_bytes()).unwrap();
3956        f.flush().unwrap();
3957        f
3958    }
3959
3960    #[test]
3961    fn config_file_loads_mcp_subscribe_a2a_and_limits() {
3962        let file = write_tmp(
3963            r#"{
3964                "model": "claude-from-file",
3965                "max_tokens": 1234567,
3966                "limits": { "max_steps": 77, "max_depth": 3, "deadline_secs": 120 },
3967                "mcp_servers": [
3968                    { "name": "web", "endpoint": "https://web.example.com/mcp",
3969                      "tags": { "*": ["untrusted_input"] } }
3970                ],
3971                "subscribe": ["fs:file:///watch/inbox"]
3972            }"#,
3973        );
3974        let c = Config::load(
3975            &args(&["--config", file.path().to_str().unwrap()]),
3976            &base_env(),
3977        )
3978        .unwrap();
3979        assert_eq!(c.model.as_deref(), Some("claude-from-file"));
3980        assert_eq!(c.max_tokens, 1_234_567);
3981        assert_eq!(c.max_steps, 77);
3982        assert_eq!(c.max_depth, 3);
3983        assert_eq!(c.deadline, Some(Duration::from_secs(120)));
3984        assert_eq!(c.mcp_servers.len(), 1);
3985        assert_eq!(c.mcp_servers[0].name, "web");
3986        assert_eq!(c.mcp_servers[0].endpoint, "https://web.example.com/mcp");
3987        assert_eq!(c.mcp_servers[0].tags, vec![TrifectaTag::UntrustedInput]);
3988        assert_eq!(c.subscribe, vec!["fs:file:///watch/inbox"]);
3989    }
3990
3991    #[test]
3992    fn budget_tokens_lifetime_parses_from_flag_env_and_file() {
3993        // The per-instance lifetime cap. Unbounded (0) by default.
3994        assert_eq!(
3995            Config::load(&args(&[]), &base_env())
3996                .unwrap()
3997                .budget_tokens_lifetime,
3998            0
3999        );
4000        // Flag.
4001        let c = Config::load(&args(&["--budget-tokens-lifetime", "2000000"]), &base_env()).unwrap();
4002        assert_eq!(c.budget_tokens_lifetime, 2_000_000);
4003        // Env (the neutral `AGENT_BUDGET_TOKENS` is aliased to `AGENTD_*`).
4004        let mut env = base_env();
4005        env.push(("AGENT_BUDGET_TOKENS".into(), "500000".into()));
4006        assert_eq!(
4007            Config::load(&args(&[]), &env)
4008                .unwrap()
4009                .budget_tokens_lifetime,
4010            500_000
4011        );
4012        // Config-file `limits.lifetime_tokens`, and flag > file precedence.
4013        let file = write_tmp(r#"{ "model": "m", "limits": { "lifetime_tokens": 111 } }"#);
4014        let path = file.path().to_str().unwrap().to_string();
4015        assert_eq!(
4016            Config::load(&args(&["--config", &path]), &base_env())
4017                .unwrap()
4018                .budget_tokens_lifetime,
4019            111
4020        );
4021        assert_eq!(
4022            Config::load(
4023                &args(&["--config", &path, "--budget-tokens-lifetime", "222"]),
4024                &base_env()
4025            )
4026            .unwrap()
4027            .budget_tokens_lifetime,
4028            222
4029        );
4030    }
4031
4032    #[test]
4033    fn env_and_flag_override_file_per_precedence() {
4034        // built-in < FILE < env < flag.
4035        let file = write_tmp(r#"{ "model": "from-file", "max_tokens": 100 }"#);
4036        let mut env = base_env();
4037        env.push(("AGENTD_MODEL".into(), "from-env".into()));
4038        // env beats file; a flag beats env.
4039        let c = Config::load(
4040            &args(&[
4041                "--config",
4042                file.path().to_str().unwrap(),
4043                "--max-tokens",
4044                "999",
4045            ]),
4046            &env,
4047        )
4048        .unwrap();
4049        assert_eq!(c.model.as_deref(), Some("from-env")); // env > file
4050        assert_eq!(c.max_tokens, 999); // flag > file
4051        // Without the env/flag, the file value stands.
4052        let c2 = Config::load(
4053            &args(&["--config", file.path().to_str().unwrap()]),
4054            &base_env(),
4055        )
4056        .unwrap();
4057        assert_eq!(c2.model.as_deref(), Some("from-file"));
4058        assert_eq!(c2.max_tokens, 100);
4059    }
4060
4061    /// A temp config file with a real extension (`Format::detect` reads it).
4062    fn write_tmp_ext(contents: &str, ext: &str) -> tempfile::NamedTempFile {
4063        let mut f = tempfile::Builder::new()
4064            .suffix(&format!(".{ext}"))
4065            .tempfile()
4066            .unwrap();
4067        f.write_all(contents.as_bytes()).unwrap();
4068        f.flush().unwrap();
4069        f
4070    }
4071
4072    #[test]
4073    fn config_file_may_be_yaml() {
4074        // The same structural config in YAML — nested limits, a server list
4075        // with tags, subscriptions in block style — loads exactly like JSON.
4076        let yaml = write_tmp_ext(
4077            r#"
4078# agentd config
4079model: claude-from-yaml
4080max_tokens: 1234567
4081limits:
4082  max_steps: 77
4083  max_depth: 3
4084  deadline_secs: 120
4085mcp_servers:
4086  - name: web
4087    endpoint: https://web.example.com/mcp
4088    tags:
4089      "*": [untrusted_input]
4090subscribe:
4091  - fs:file:///watch/inbox
4092log_level: warn
4093"#,
4094            "yaml",
4095        );
4096        let c = Config::load(
4097            &args(&["--config", yaml.path().to_str().unwrap()]),
4098            &base_env(),
4099        )
4100        .unwrap();
4101        assert_eq!(c.model.as_deref(), Some("claude-from-yaml"));
4102        assert_eq!(c.max_tokens, 1_234_567);
4103        assert_eq!(c.max_steps, 77);
4104        assert_eq!(c.max_depth, 3);
4105        assert_eq!(c.deadline, Some(Duration::from_secs(120)));
4106        assert_eq!(c.mcp_servers.len(), 1);
4107        assert_eq!(c.mcp_servers[0].name, "web");
4108        assert_eq!(c.mcp_servers[0].tags, vec![TrifectaTag::UntrustedInput]);
4109        assert_eq!(c.subscribe, vec!["fs:file:///watch/inbox"]);
4110        assert_eq!(c.log_level, Level::Warn);
4111
4112        // `.yml` too; and a YAML typo is still exit 2, naming the key.
4113        let bad = write_tmp_ext("model: m\nmax_token: 5\n", "yml");
4114        let e = Config::load(
4115            &args(&["--config", bad.path().to_str().unwrap()]),
4116            &base_env(),
4117        )
4118        .unwrap_err();
4119        assert!(matches!(e, ConfigError::Usage(_)), "{e}");
4120        assert!(format!("{e}").contains("max_token"), "{e}");
4121        // A YAML syntax error is exit 2 with the line named.
4122        let bad = write_tmp_ext("model: m\n\tlimits: {}\n", "yaml");
4123        let e = Config::load(
4124            &args(&["--config", bad.path().to_str().unwrap()]),
4125            &base_env(),
4126        )
4127        .unwrap_err();
4128        assert!(format!("{e}").contains("line 2"), "{e}");
4129    }
4130
4131    #[test]
4132    fn path_env_vars_set_config_paths() {
4133        // Every config-file path is an env var named after the path:
4134        // `limits.max_steps` ⇒ AGENTD_LIMITS_MAX_STEPS / AGENT_… / bare.
4135        let file = write_tmp_ext(
4136            "limits:\n  max_steps: 1\n  max_depth: 9\nmodel: f\n",
4137            "yaml",
4138        );
4139        let mut env = base_env();
4140        env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "5".into()));
4141        env.push(("AGENT_LIMITS_DEADLINE_SECS".into(), "30".into())); // neutral spelling
4142        env.push(("LIMITS_LIFETIME_TOKENS".into(), "4000".into())); // bare spelling
4143        env.push(("MODEL_SWAP".into(), "restart-turn".into())); // bare enum
4144        env.push(("AGENTD_SUBSCRIBE".into(), "a://1, a://2".into())); // list
4145        env.push((
4146            "AGENTD_INTELLIGENCE_HEADERS".into(),
4147            "{x-team: ops}".into(), // object literal
4148        ));
4149        let c = Config::load(&args(&["--config", file.path().to_str().unwrap()]), &env).unwrap();
4150        assert_eq!(c.max_steps, 5, "env path beats the file");
4151        assert_eq!(
4152            c.max_depth, 9,
4153            "untouched sibling path keeps the file value"
4154        );
4155        assert_eq!(c.deadline, Some(Duration::from_secs(30)));
4156        assert_eq!(c.budget_tokens_lifetime, 4000);
4157        assert_eq!(c.model_swap, SwapPolicy::RestartTurn);
4158        assert_eq!(c.subscribe, vec!["a://1", "a://2"]);
4159        assert_eq!(
4160            c.intelligence_headers.get("x-team").map(String::as_str),
4161            Some("ops")
4162        );
4163        assert_eq!(c.model.as_deref(), Some("f"));
4164
4165        // Precedence within env: branded > neutral > bare.
4166        let mut env = base_env();
4167        env.push(("LIMITS_MAX_STEPS".into(), "1".into()));
4168        env.push(("AGENT_LIMITS_MAX_STEPS".into(), "2".into()));
4169        assert_eq!(Config::load(&args(&[]), &env).unwrap().max_steps, 2);
4170        env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "3".into()));
4171        assert_eq!(Config::load(&args(&[]), &env).unwrap().max_steps, 3);
4172
4173        // A value that does not type per the schema is exit 2, naming the var.
4174        let mut env = base_env();
4175        env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "many".into()));
4176        let e = Config::load(&args(&[]), &env).unwrap_err();
4177        assert!(matches!(e, ConfigError::Usage(_)));
4178        assert!(format!("{e}").contains("AGENTD_LIMITS_MAX_STEPS"), "{e}");
4179        let mut env = base_env();
4180        env.push(("AGENTD_LOG_LEVEL".into(), "loud".into()));
4181        let e = Config::load(&args(&[]), &env).unwrap_err();
4182        assert!(format!("{e}").contains("AGENTD_LOG_LEVEL"), "{e}");
4183    }
4184
4185    #[test]
4186    fn generic_path_flags_override_env_and_file() {
4187        // Any config path is a flag: `--limits.max-steps` / `--limits-max-steps`
4188        // / `--limits.max_steps`; a flag beats env beats file; unknown flags are
4189        // still refused.
4190        let file = write_tmp_ext("limits:\n  max_steps: 1\n", "yaml");
4191        let mut env = base_env();
4192        env.push(("AGENTD_LIMITS_MAX_STEPS".into(), "2".into()));
4193        for spelling in [
4194            "--limits.max-steps",
4195            "--limits-max-steps",
4196            "--limits.max_steps",
4197        ] {
4198            let c = Config::load(
4199                &args(&["--config", file.path().to_str().unwrap(), spelling, "3"]),
4200                &env,
4201            )
4202            .unwrap();
4203            assert_eq!(c.max_steps, 3, "{spelling}");
4204        }
4205        // Flags apply in order: the last writer wins, whichever spelling.
4206        let c = Config::load(
4207            &args(&["--max-steps", "4", "--limits.max_steps", "5"]),
4208            &base_env(),
4209        )
4210        .unwrap();
4211        assert_eq!(c.max_steps, 5);
4212        let c = Config::load(
4213            &args(&["--limits.max_steps", "5", "--max-steps", "6"]),
4214            &base_env(),
4215        )
4216        .unwrap();
4217        assert_eq!(c.max_steps, 6);
4218        // A list path flag ADDS (repeatable-flag semantics), like --subscribe.
4219        let c = Config::load(
4220            &args(&["--subscribe", "a://1", "--subscribe", "[a://2, a://3]"]),
4221            &base_env(),
4222        )
4223        .unwrap();
4224        // (--subscribe is a named flag: its value is one URI, verbatim.)
4225        assert_eq!(c.subscribe, vec!["a://1", "[a://2, a://3]"]);
4226        let c = Config::load(
4227            &args(&[
4228                "--mcp-servers",
4229                "[{name: q, endpoint: https://q.example/mcp}]",
4230            ]),
4231            &base_env(),
4232        )
4233        .unwrap();
4234        assert_eq!(c.mcp_servers.len(), 1);
4235        assert_eq!(c.mcp_servers[0].name, "q");
4236        // Typed by the schema: a non-integer is refused, naming the flag.
4237        let e = Config::load(&args(&["--limits.max-steps", "lots"]), &base_env()).unwrap_err();
4238        assert!(format!("{e}").contains("--limits.max-steps"), "{e}");
4239        // An enum path is checked against its set.
4240        let e = Config::load(&args(&["--model-swap", "sideways"]), &base_env()).unwrap_err();
4241        assert!(matches!(e, ConfigError::Usage(_)), "{e}");
4242        // Not a config path → the usual unknown-argument refusal.
4243        let e = Config::load(&args(&["--no-such-thing", "1"]), &base_env()).unwrap_err();
4244        assert!(format!("{e}").contains("unknown argument"), "{e}");
4245        // A path flag with no value is refused.
4246        let e = Config::load(&args(&["--limits.max-steps"]), &base_env()).unwrap_err();
4247        assert!(format!("{e}").contains("requires a value"), "{e}");
4248    }
4249
4250    #[test]
4251    fn multiple_config_files_merge_in_order_later_wins() {
4252        // AGENTD_CONFIG (a `:` list) first, then each --config; a later file
4253        // overrides earlier ones: scalars replace, objects merge, lists replace,
4254        // `null` unsets. The merged document is ONE file layer — env and flags
4255        // still override it.
4256        let base = write_tmp_ext(
4257            "model: base
4258log_level: warn
4259limits:
4260  max_steps: 1
4261  max_depth: 2
4262subscribe: [a://1, a://2]
4263",
4264            "yaml",
4265        );
4266        let site = write_tmp_ext(
4267            r#"{ "model": "site", "limits": { "max_steps": 5 }, "subscribe": ["a://3"] }"#,
4268            "json",
4269        );
4270        let over = write_tmp_ext(
4271            "model: over
4272log_level: null
4273limits:
4274  max_depth: 7
4275",
4276            "yml",
4277        );
4278        let mut env = base_env();
4279        env.push((
4280            "AGENT_CONFIG".into(), // the neutral spelling of the list, base first
4281            format!("{}:{}", base.path().display(), site.path().display()),
4282        ));
4283        let c = Config::load(&args(&["--config", over.path().to_str().unwrap()]), &env).unwrap();
4284        assert_eq!(
4285            c.model.as_deref(),
4286            Some("over"),
4287            "last file wins on a scalar"
4288        );
4289        assert_eq!(
4290            c.max_steps, 5,
4291            "site's limits.max_steps survives (objects merge)"
4292        );
4293        assert_eq!(c.max_depth, 7, "over's limits.max_depth wins");
4294        assert_eq!(c.subscribe, vec!["a://3"], "a later file REPLACES a list");
4295        assert_eq!(
4296            c.log_level,
4297            Level::Info,
4298            "`null` unsets → back to the default"
4299        );
4300        assert_eq!(c.config_files.len(), 3);
4301        assert!(c.config_files[0].ends_with(".yaml") && c.config_files[2].ends_with(".yml"));
4302        // Order matters: the same overlay first, then base → base wins.
4303        let c = Config::load(
4304            &args(&[
4305                "--config",
4306                over.path().to_str().unwrap(),
4307                "--config",
4308                base.path().to_str().unwrap(),
4309            ]),
4310            &base_env(),
4311        )
4312        .unwrap();
4313        assert_eq!(c.model.as_deref(), Some("base"));
4314        assert_eq!(c.max_depth, 2);
4315        // Env and flags still beat every file.
4316        let mut env2 = base_env();
4317        env2.push(("AGENTD_MODEL".into(), "from-env".into()));
4318        let c = Config::load(
4319            &args(&[
4320                "--config",
4321                base.path().to_str().unwrap(),
4322                "--config",
4323                over.path().to_str().unwrap(),
4324                "--limits.max-depth",
4325                "9",
4326            ]),
4327            &env2,
4328        )
4329        .unwrap();
4330        assert_eq!(c.model.as_deref(), Some("from-env"));
4331        assert_eq!(c.max_depth, 9);
4332        // A broken file anywhere in the list is exit 2 naming that file.
4333        let e = Config::load(
4334            &args(&[
4335                "--config",
4336                base.path().to_str().unwrap(),
4337                "--config",
4338                "/no/such/overlay.yaml",
4339            ]),
4340            &base_env(),
4341        )
4342        .unwrap_err();
4343        assert!(format!("{e}").contains("/no/such/overlay.yaml"), "{e}");
4344        let paths = Config::config_paths_from(
4345            &args(&["--config", "b.yaml"]),
4346            &[("AGENT_CONFIG".into(), "x.yaml::y.json:".into())],
4347        );
4348        assert_eq!(paths, vec!["x.yaml", "y.json", "b.yaml"]);
4349    }
4350
4351    #[test]
4352    fn setting_a_path_replaces_the_value_while_named_flags_add() {
4353        // A path SET (env or `--<path>`) replaces the list/map at that path; the
4354        // named repeatable flags (`--mcp`, `--subscribe`) ADD; a `--<map>.<key>`
4355        // entry flag merges one key.
4356        let file = write_tmp_ext(
4357            "subscribe: [a://file]
4358mcp_servers:
4359  - name: web
4360    endpoint: https://web.example/mcp
4361intelligence_headers:
4362  keep: me
4363  x-team: file
4364",
4365            "yaml",
4366        );
4367        let path = file.path().to_str().unwrap().to_string();
4368        // env list path replaces the file's list.
4369        let mut env = base_env();
4370        env.push(("AGENTD_SUBSCRIBE".into(), "a://env".into()));
4371        let c = Config::load(&args(&["--config", &path]), &env).unwrap();
4372        assert_eq!(c.subscribe, vec!["a://env"]);
4373        // ...and the named flag adds to that.
4374        let c = Config::load(&args(&["--config", &path, "--subscribe", "a://flag"]), &env).unwrap();
4375        assert_eq!(c.subscribe, vec!["a://env", "a://flag"]);
4376        // `--mcp-servers '[…]'` sets the whole list; `--mcp` adds one.
4377        let c = Config::load(
4378            &args(&[
4379                "--config",
4380                &path,
4381                "--mcp-servers",
4382                "[{name: q, endpoint: https://q.example/mcp}]",
4383                "--mcp",
4384                "x=https://x.example/mcp",
4385            ]),
4386            &base_env(),
4387        )
4388        .unwrap();
4389        let names: Vec<&str> = c.mcp_servers.iter().map(|s| s.name.as_str()).collect();
4390        assert_eq!(names, vec!["q", "x"]);
4391        // A map ENTRY flag merges one key (exact spelling), keeping the others.
4392        let c = Config::load(
4393            &args(&["--config", &path, "--intelligence_headers.x-team", "ops"]),
4394            &base_env(),
4395        )
4396        .unwrap();
4397        assert_eq!(
4398            c.intelligence_headers.get("x-team").map(String::as_str),
4399            Some("ops")
4400        );
4401        assert_eq!(
4402            c.intelligence_headers.get("keep").map(String::as_str),
4403            Some("me")
4404        );
4405        // The whole-map form replaces the map.
4406        let c = Config::load(
4407            &args(&["--config", &path, "--intelligence-headers", "{only: this}"]),
4408            &base_env(),
4409        )
4410        .unwrap();
4411        assert_eq!(c.intelligence_headers.len(), 1);
4412        assert_eq!(
4413            c.intelligence_headers.get("only").map(String::as_str),
4414            Some("this")
4415        );
4416        // Reaching into a list by index is refused with a clear message.
4417        let e = Config::load(&args(&["--mcp-servers.0.aauth", "true"]), &base_env()).unwrap_err();
4418        assert!(format!("{e}").contains("array elements"), "{e}");
4419    }
4420
4421    #[test]
4422    fn help_lists_every_config_path_with_flag_and_env() {
4423        let h = match Config::load(&args(&["--help"]), &[]) {
4424            Err(ConfigError::Help(h)) => h,
4425            other => panic!("expected help, got {other:?}"),
4426        };
4427        assert!(h.contains("CONFIG PATHS"), "{h}");
4428        assert!(h.contains("limits.max_steps"), "{h}");
4429        assert!(h.contains("--limits-max-steps"), "{h}");
4430        assert!(h.contains("AGENTD_LIMITS_MAX_STEPS"), "{h}");
4431        assert!(h.contains("YAML or JSON"), "{h}");
4432    }
4433
4434    #[test]
4435    fn reload_re_reads_a_yaml_file() {
4436        // The reload path is `load` again over the ORIGINAL args/env; a rewritten
4437        // YAML file is picked up with flags still winning over it.
4438        let file = write_tmp_ext("model: v1\nlimits:\n  max_steps: 10\n", "yaml");
4439        let path = file.path().to_str().unwrap().to_string();
4440        let a = args(&["--config", &path, "--max-depth", "2"]);
4441        let env = base_env();
4442        let running = Config::load(&a, &env).unwrap();
4443        assert_eq!(running.model.as_deref(), Some("v1"));
4444        assert_eq!(running.max_steps, 10);
4445        std::fs::write(
4446            &path,
4447            "model: v2\nlimits:\n  max_steps: 20\n  max_depth: 7\n",
4448        )
4449        .unwrap();
4450        let reloaded = Config::reload(&a, &env).unwrap();
4451        assert_eq!(reloaded.model.as_deref(), Some("v2"));
4452        assert_eq!(reloaded.max_steps, 20);
4453        assert_eq!(
4454            reloaded.max_depth, 2,
4455            "the flag still overrides the new file"
4456        );
4457        // A now-broken YAML file is a rejected reload (Usage), not a crash.
4458        std::fs::write(&path, "model: [unterminated\n").unwrap();
4459        assert!(matches!(
4460            Config::reload(&a, &env),
4461            Err(ConfigError::Usage(_))
4462        ));
4463    }
4464
4465    #[test]
4466    fn flag_mcp_and_subscribe_add_to_the_file_list() {
4467        // Repeatable list flags ADD to the file's lists (the one documented
4468        // deviation from pure last-writer-wins).
4469        let file = write_tmp(
4470            r#"{ "mcp_servers": [{ "name": "web", "endpoint": "https://web.example.com/mcp" }],
4471                "subscribe": ["fs:file:///a"] }"#,
4472        );
4473        let c = Config::load(
4474            &args(&[
4475                "--config",
4476                file.path().to_str().unwrap(),
4477                "--mcp",
4478                "fs=https://fs.example",
4479                "--subscribe",
4480                "fs:file:///b",
4481            ]),
4482            &base_env(),
4483        )
4484        .unwrap();
4485        let names: Vec<&str> = c.mcp_servers.iter().map(|s| s.name.as_str()).collect();
4486        assert_eq!(names, vec!["web", "fs"]); // file seeds, flag adds
4487        assert_eq!(c.subscribe, vec!["fs:file:///a", "fs:file:///b"]);
4488    }
4489
4490    #[test]
4491    fn config_via_env_alias() {
4492        let file = write_tmp(r#"{ "model": "env-config" }"#);
4493        let mut env = base_env();
4494        env.push(("AGENTD_CONFIG".into(), file.path().to_str().unwrap().into()));
4495        let c = Config::load(&args(&[]), &env).unwrap();
4496        assert_eq!(c.model.as_deref(), Some("env-config"));
4497    }
4498
4499    #[test]
4500    fn malformed_config_file_is_usage_error() {
4501        let file = write_tmp("{ this is not json ");
4502        let e = Config::load(
4503            &args(&["--config", file.path().to_str().unwrap()]),
4504            &base_env(),
4505        )
4506        .unwrap_err();
4507        assert!(matches!(e, ConfigError::Usage(_)));
4508    }
4509
4510    #[test]
4511    fn unreadable_config_file_is_usage_error() {
4512        let e =
4513            Config::load(&args(&["--config", "/no/such/config.json"]), &base_env()).unwrap_err();
4514        assert!(matches!(e, ConfigError::Usage(_)));
4515    }
4516
4517    #[test]
4518    fn config_file_unknown_key_is_usage_error() {
4519        // deny_unknown_fields: a typo'd key fails at parse (exit 2).
4520        let file = write_tmp(r#"{ "max_token": 5 }"#);
4521        let e = Config::load(
4522            &args(&["--config", file.path().to_str().unwrap()]),
4523            &base_env(),
4524        )
4525        .unwrap_err();
4526        assert!(matches!(e, ConfigError::Usage(_)));
4527    }
4528
4529    // ─────────────────────────── --watch-config ──────────────────────────────
4530
4531    /// Without the `config-watch` build feature, `--watch-config` (even WITH a
4532    /// config file) is a usage error — never silently ignored (the operator would
4533    /// believe a ConfigMap swap reloads when only SIGHUP would).
4534    #[cfg(not(feature = "config-watch"))]
4535    #[test]
4536    fn watch_config_requires_config_watch_feature() {
4537        let file = write_tmp(r#"{ "model": "m" }"#);
4538        let e = Config::load(
4539            &args(&["--config", file.path().to_str().unwrap(), "--watch-config"]),
4540            &base_env(),
4541        )
4542        .unwrap_err();
4543        match e {
4544            ConfigError::Usage(msg) => assert!(
4545                msg.contains("--watch-config requires the 'config-watch' build feature"),
4546                "got: {msg}"
4547            ),
4548            other => panic!("expected Usage, got {other:?}"),
4549        }
4550    }
4551
4552    /// With the feature, `--watch-config` + a `--config` file parses and sets the
4553    /// always-compiled `watch_config` flag.
4554    #[cfg(feature = "config-watch")]
4555    #[test]
4556    fn watch_config_parses_with_a_config_file() {
4557        let file = write_tmp(r#"{ "model": "m" }"#);
4558        let c = Config::load(
4559            &args(&["--config", file.path().to_str().unwrap(), "--watch-config"]),
4560            &base_env(),
4561        )
4562        .unwrap();
4563        assert!(c.watch_config);
4564    }
4565
4566    /// `AGENTD_WATCH_CONFIG` env parses too (a flag would override it).
4567    #[cfg(feature = "config-watch")]
4568    #[test]
4569    fn watch_config_parses_from_env() {
4570        let file = write_tmp(r#"{ "model": "m" }"#);
4571        let mut env = base_env();
4572        env.push(("AGENTD_CONFIG".into(), file.path().to_str().unwrap().into()));
4573        env.push(("AGENTD_WATCH_CONFIG".into(), "true".into()));
4574        let c = Config::load(&args(&[]), &env).unwrap();
4575        assert!(c.watch_config);
4576    }
4577
4578    /// `--watch-config` with NO config file is a usage error — watching nothing is
4579    /// meaningless. (Only exercised on a `config-watch` build; off
4580    /// the feature the feature-gate error fires first.)
4581    #[cfg(feature = "config-watch")]
4582    #[test]
4583    fn watch_config_requires_a_config_file() {
4584        let e = Config::load(&args(&["--watch-config"]), &base_env()).unwrap_err();
4585        match e {
4586            ConfigError::Usage(msg) => assert!(
4587                msg.contains("--watch-config requires a config file"),
4588                "got: {msg}"
4589            ),
4590            other => panic!("expected Usage, got {other:?}"),
4591        }
4592    }
4593
4594    /// The admission gate (`--validate-config`) also rejects `--watch-config`
4595    /// without a config file — the same diagnostic, collected.
4596    #[cfg(feature = "config-watch")]
4597    #[test]
4598    fn validate_config_flags_watch_config_without_a_file() {
4599        let v = validate_verdict(&["--validate-config", "--watch-config"], &base_env());
4600        let lines = v.expect_err("watch-config without a file is invalid");
4601        assert!(
4602            lines.contains("--watch-config requires a config file"),
4603            "got: {lines}"
4604        );
4605    }
4606
4607    // ───────────────────────────  --validate-config  ─────────────────────────
4608
4609    fn validate_verdict(args_: &[&str], env: &[(String, String)]) -> Result<String, String> {
4610        match Config::load(&args(args_), env).unwrap_err() {
4611            ConfigError::Validate(v) => v,
4612            other => panic!("expected Validate, got {other:?}"),
4613        }
4614    }
4615
4616    #[test]
4617    fn validate_config_valid_returns_ok_with_no_instruction_needed() {
4618        // It validates whatever is given; a complete config returns the
4619        // config.valid verdict. (Here instruction+intelligence are present.)
4620        let v = validate_verdict(&["--validate-config"], &base_env());
4621        let line = v.expect("a complete config validates");
4622        assert!(line.contains("config.valid"));
4623        let _: serde_json::Value = serde_json::from_str(&line).unwrap();
4624    }
4625
4626    #[test]
4627    fn validate_config_invalid_returns_err_exit2_shape() {
4628        // reactive with no subscribe → invalid. Verdict is Err.
4629        let v = validate_verdict(&["--validate-config", "--mode", "reactive"], &base_env());
4630        let lines = v.unwrap_err();
4631        assert!(lines.contains("config.invalid"));
4632        // Each line is parseable NDJSON.
4633        for line in lines.lines() {
4634            let _: serde_json::Value = serde_json::from_str(line).unwrap();
4635        }
4636    }
4637
4638    #[test]
4639    fn validate_config_refuses_a_trifecta_only_config_exit2() {
4640        // The trifecta gate lives in `validate()`, the
4641        // ONE validation authority, so `--validate-config` must REFUSE a complete
4642        // trifecta exactly as startup does: a verdict that says "valid" for a
4643        // config the daemon would refuse is worse than no verdict at all. One
4644        // server tagged with all three legs, no override.
4645        let v = validate_verdict(
4646            &[
4647                "--validate-config",
4648                "--mcp",
4649                "s=https://s.example",
4650                "--mcp-tags",
4651                "s=untrusted_input,sensitive,egress",
4652            ],
4653            &base_env(),
4654        );
4655        let lines = v.expect_err("a trifecta-only config must be invalid");
4656        assert!(lines.contains("config.invalid"), "got: {lines}");
4657        assert!(lines.contains("lethal-trifecta"), "got: {lines}");
4658        for line in lines.lines() {
4659            let _: serde_json::Value = serde_json::from_str(line).unwrap();
4660        }
4661    }
4662
4663    #[test]
4664    fn validate_config_and_startup_agree_on_trifecta() {
4665        // The same trifecta config: startup `load()` errors (Usage, exit 2) and
4666        // `--validate-config` returns an invalid verdict — they can never disagree.
4667        let trifecta = [
4668            "--mcp",
4669            "s=https://s.example",
4670            "--mcp-tags",
4671            "s=untrusted_input,sensitive,egress",
4672        ];
4673        // Startup path (no --validate-config): a Usage error.
4674        let startup = Config::load(&args(&trifecta), &base_env()).unwrap_err();
4675        assert!(matches!(startup, ConfigError::Usage(_)));
4676        // --allow-trifecta makes BOTH paths pass.
4677        let mut allowed = vec!["--allow-trifecta"];
4678        allowed.extend_from_slice(&trifecta);
4679        assert!(Config::load(&args(&allowed), &base_env()).is_ok());
4680        let mut allowed_vc = vec!["--validate-config", "--allow-trifecta"];
4681        allowed_vc.extend_from_slice(&trifecta);
4682        assert!(validate_verdict(&allowed_vc, &base_env()).is_ok());
4683    }
4684
4685    #[test]
4686    fn validate_config_runs_without_an_instruction() {
4687        // No INSTRUCTION at all: --validate-config still produces a verdict (it
4688        // does not need an instruction to *run*); the missing-instruction shows
4689        // up as an invalid diagnostic, not a crash.
4690        let env = vec![("AGENTD_INTELLIGENCE".into(), "https://intel.example".into())];
4691        let v = match Config::load(&args(&["--validate-config"]), &env).unwrap_err() {
4692            ConfigError::Validate(v) => v,
4693            other => panic!("expected Validate, got {other:?}"),
4694        };
4695        let lines = v.unwrap_err();
4696        assert!(lines.contains("config.invalid"));
4697        assert!(lines.contains("instruction"));
4698    }
4699
4700    #[test]
4701    fn validate_config_rejects_bad_intelligence_scheme() {
4702        let mut env = base_env();
4703        env.retain(|(k, _)| k != "AGENTD_INTELLIGENCE");
4704        let v = validate_verdict(&["--validate-config", "--intelligence", "ftp://nope"], &env);
4705        assert!(v.unwrap_err().contains("config.invalid"));
4706    }
4707
4708    // ────────────────────────────  --config-schema  ──────────────────────────
4709
4710    #[test]
4711    fn config_schema_emits_parseable_json_schema() {
4712        let s = match Config::load(&args(&["--config-schema"]), &[]).unwrap_err() {
4713            ConfigError::Schema(s) => s,
4714            other => panic!("expected Schema, got {other:?}"),
4715        };
4716        let v: serde_json::Value = serde_json::from_str(&s).expect("schema is valid JSON");
4717        assert_eq!(
4718            v["$schema"],
4719            serde_json::json!("https://json-schema.org/draft/2020-12/schema")
4720        );
4721        assert!(v["properties"].is_object());
4722        // It short-circuits with NO instruction and NO config (static export).
4723    }
4724
4725    // ──────────────────────────────  secret refs  ────────────────────────────
4726
4727    #[test]
4728    fn intelligence_token_file_reads_and_trims() {
4729        let tok = write_tmp("file-token\n");
4730        let mut env = base_env();
4731        env.push((
4732            "AGENTD_INTELLIGENCE_TOKEN_FILE".into(),
4733            tok.path().to_str().unwrap().into(),
4734        ));
4735        let c = Config::load(&args(&[]), &env).unwrap();
4736        assert_eq!(c.intelligence_token.as_deref(), Some("file-token"));
4737        // The token never appears in the redacted Debug.
4738        let dbg = format!("{c:?}");
4739        assert!(!dbg.contains("file-token"));
4740        assert!(dbg.contains("***"));
4741    }
4742
4743    #[test]
4744    fn inline_token_wins_over_token_file() {
4745        let tok = write_tmp("from-file\n");
4746        let mut env = base_env();
4747        env.push(("AGENTD_INTELLIGENCE_TOKEN".into(), "from-inline".into()));
4748        env.push((
4749            "AGENTD_INTELLIGENCE_TOKEN_FILE".into(),
4750            tok.path().to_str().unwrap().into(),
4751        ));
4752        let c = Config::load(&args(&[]), &env).unwrap();
4753        assert_eq!(c.intelligence_token.as_deref(), Some("from-inline"));
4754    }
4755
4756    #[test]
4757    #[cfg(feature = "aauth")]
4758    fn aauth_flags_and_validation() {
4759        // Provider + all sub-flags parse into AAuthSettings (order-independent).
4760        let c = Config::load(
4761            &args(&[
4762                "--aauth-key-file",
4763                "/tmp/id.key",
4764                "--aauth-provider",
4765                "https://apd.example",
4766                "--aauth-enroll-token",
4767                "{{secret:ENROLL}}",
4768                "--aauth-enroll-assertion-file",
4769                "/var/run/secrets/aauth/token",
4770                "--aauth-person-server",
4771                "https://ps.example",
4772            ]),
4773            &base_env(),
4774        )
4775        .unwrap();
4776        let a = c.aauth.expect("aauth configured");
4777        assert_eq!(a.provider, "https://apd.example");
4778        assert_eq!(a.key_file, "/tmp/id.key");
4779        assert_eq!(a.enrollment_token.as_deref(), Some("{{secret:ENROLL}}"));
4780        assert_eq!(
4781            a.enroll_assertion_file.as_deref(),
4782            Some("/var/run/secrets/aauth/token")
4783        );
4784        assert_eq!(a.person_server.as_deref(), Some("https://ps.example"));
4785
4786        // The assertion file path also parses from its env spelling.
4787        let mut env = base_env();
4788        env.push(("AGENT_AAUTH_PROVIDER".into(), "https://apd.example".into()));
4789        env.push((
4790            "AGENT_AAUTH_ENROLL_ASSERTION_FILE".into(),
4791            "/var/run/secrets/aauth/token".into(),
4792        ));
4793        let a = Config::load(&args(&[]), &env).unwrap().aauth.unwrap();
4794        assert_eq!(
4795            a.enroll_assertion_file.as_deref(),
4796            Some("/var/run/secrets/aauth/token")
4797        );
4798
4799        // Key file defaults; env spelling; a bad provider URL is exit 2.
4800        let mut env = base_env();
4801        env.push(("AGENT_AAUTH_PROVIDER".into(), "https://apd.example".into()));
4802        let c = Config::load(&args(&[]), &env).unwrap();
4803        assert_eq!(c.aauth.unwrap().key_file, "agent.key");
4804        assert!(Config::load(&args(&["--aauth-provider", "not-a-url"]), &base_env()).is_err());
4805        assert!(
4806            Config::load(
4807                &args(&[
4808                    "--aauth-provider",
4809                    "https://apd.example",
4810                    "--aauth-person-server",
4811                    "nope"
4812                ]),
4813                &base_env()
4814            )
4815            .is_err()
4816        );
4817        // No provider ⇒ no aauth (the sub-flags alone are inert).
4818        assert!(
4819            Config::load(&args(&["--aauth-key-file", "/x"]), &base_env())
4820                .unwrap()
4821                .aauth
4822                .is_none()
4823        );
4824    }
4825
4826    #[test]
4827    #[cfg(feature = "tls")]
4828    fn tls_ca_flag_env_and_content_validation() {
4829        // A real CA PEM (the net crate's test fixture) parses + validates.
4830        let ca = write_tmp(include_str!("../../../net/tests/fixtures/ca.pem"));
4831        let ca_path = ca.path().to_str().unwrap().to_string();
4832
4833        // Flag form.
4834        let c = Config::load(&args(&["--tls-ca", &ca_path]), &base_env()).unwrap();
4835        assert_eq!(c.tls_ca.as_deref(), Some(ca_path.as_str()));
4836        // A file PATH is public material — visible in the redacted Debug.
4837        assert!(format!("{c:?}").contains(&ca_path));
4838
4839        // Env form, branded + neutral (debrand alias).
4840        for key in ["AGENTD_TLS_CA", "AGENT_TLS_CA"] {
4841            let mut env = base_env();
4842            env.push((key.into(), ca_path.clone()));
4843            let c = Config::load(&args(&[]), &env).unwrap();
4844            assert_eq!(c.tls_ca.as_deref(), Some(ca_path.as_str()), "via {key}");
4845        }
4846
4847        // A missing file is exit 2 at load, not a first-dial surprise.
4848        let err = Config::load(&args(&["--tls-ca", "/nonexistent/ca.pem"]), &base_env());
4849        assert!(matches!(err, Err(ConfigError::Usage(_))));
4850
4851        // Junk content (readable, but not a CA PEM) is exit 2 too.
4852        let junk = write_tmp("not a pem");
4853        let err = Config::load(
4854            &args(&["--tls-ca", junk.path().to_str().unwrap()]),
4855            &base_env(),
4856        );
4857        assert!(matches!(err, Err(ConfigError::Usage(_))));
4858    }
4859
4860    #[test]
4861    fn token_file_flag_reads_via_cli() {
4862        let tok = write_tmp("flag-token");
4863        let c = Config::load(
4864            &args(&["--intelligence-token-file", tok.path().to_str().unwrap()]),
4865            &base_env(),
4866        )
4867        .unwrap();
4868        assert_eq!(c.intelligence_token.as_deref(), Some("flag-token"));
4869    }
4870
4871    #[test]
4872    fn missing_token_file_is_usage_error() {
4873        let mut env = base_env();
4874        env.push((
4875            "AGENTD_INTELLIGENCE_TOKEN_FILE".into(),
4876            "/no/such/token".into(),
4877        ));
4878        let e = Config::load(&args(&[]), &env).unwrap_err();
4879        assert!(matches!(e, ConfigError::Usage(_)));
4880    }
4881
4882    #[test]
4883    fn secret_file_ref_resolves_and_does_not_leak() {
4884        // A declared header with a {{secret-file:PATH}} ref validates (the file
4885        // exists) and the resolved secret never enters the manifest or the
4886        // redacted Debug — only the structural ref/name does.
4887        let secret = write_tmp("RESOLVED-SECRET-VALUE\n");
4888        let path = secret.path().to_str().unwrap().to_string();
4889        let file = write_tmp(&format!(
4890            r#"{{ "intelligence_headers": {{
4891                "authorization": "Bearer {{{{secret-file:{path}}}}}" }} }}"#
4892        ));
4893        let c = Config::load(
4894            &args(&["--config", file.path().to_str().unwrap()]),
4895            &base_env(),
4896        )
4897        .unwrap();
4898        // The header TEMPLATE (the ref) is structural config and is stored…
4899        assert_eq!(
4900            c.intelligence_headers
4901                .get("authorization")
4902                .map(String::as_str),
4903            Some(format!("Bearer {{{{secret-file:{path}}}}}").as_str())
4904        );
4905        // …but the resolved secret value is NOT stored or logged.
4906        let dbg = format!("{c:?}");
4907        assert!(!dbg.contains("RESOLVED-SECRET-VALUE"));
4908        // The resolver materializes it only at the moment of use.
4909        let env = |_: &str| None;
4910        let resolved =
4911            crate::sec::secret::resolve(c.intelligence_headers.get("authorization").unwrap(), &env)
4912                .unwrap();
4913        assert_eq!(resolved, "Bearer RESOLVED-SECRET-VALUE");
4914    }
4915
4916    #[test]
4917    fn inline_secret_shaped_header_is_rejected() {
4918        // A credential-shaped header with an inline (non-ref) value is the
4919        // The "secret in the file" footgun — exit 2.
4920        let file = write_tmp(r#"{ "intelligence_headers": { "x-api-key": "sk-inline-literal" } }"#);
4921        let e = Config::load(
4922            &args(&["--config", file.path().to_str().unwrap()]),
4923            &base_env(),
4924        )
4925        .unwrap_err();
4926        assert!(matches!(e, ConfigError::Usage(_)));
4927        // A {{secret:NAME}} ref in the same header is fine (a reference, not a
4928        // value). The ref resolves against the PROCESS env at startup (the runtime
4929        // truth), so set the real var for this check.
4930        // SAFETY: single-threaded test; the var is unique to this test.
4931        unsafe {
4932            std::env::set_var("AGENTD_TEST_HDR_KEY_0017", "k");
4933        }
4934        let file_ok = write_tmp(
4935            r#"{ "intelligence_headers": { "x-api-key": "{{secret:AGENTD_TEST_HDR_KEY_0017}}" } }"#,
4936        );
4937        let c = Config::load(
4938            &args(&["--config", file_ok.path().to_str().unwrap()]),
4939            &base_env(),
4940        )
4941        .unwrap();
4942        assert!(c.intelligence_headers.contains_key("x-api-key"));
4943        unsafe {
4944            std::env::remove_var("AGENTD_TEST_HDR_KEY_0017");
4945        }
4946    }
4947
4948    #[test]
4949    fn unresolvable_secret_ref_in_header_is_rejected_at_validation() {
4950        // A {{secret:NAME}} whose env var is unset → exit 2 at startup.
4951        let file = write_tmp(
4952            r#"{ "intelligence_headers": { "x-api-key": "{{secret:DEFINITELY_UNSET_VAR_XYZ}}" } }"#,
4953        );
4954        let e = Config::load(
4955            &args(&["--config", file.path().to_str().unwrap()]),
4956            &base_env(),
4957        )
4958        .unwrap_err();
4959        assert!(matches!(e, ConfigError::Usage(_)));
4960    }
4961
4962    // ───────────────────────  hot-reload coherence  ──────────────────────────
4963
4964    /// A valid reactive baseline config to diff reloads against.
4965    fn reactive_base() -> Config {
4966        Config::load(
4967            &args(&["--mode", "reactive", "--subscribe", "file:///in.json"]),
4968            &base_env(),
4969        )
4970        .unwrap()
4971    }
4972
4973    #[test]
4974    fn coherence_rejects_a_differing_restart_only_field() {
4975        // A restart-only field that DIFFERS on a live
4976        // reload is a hard reject naming the field.
4977        let running = reactive_base();
4978        for mutate in [
4979            (|c: &mut Config| c.mode = Mode::Loop) as fn(&mut Config),
4980            |c: &mut Config| c.run_id = "different-run-id".into(),
4981            |c: &mut Config| c.serve_mcp = Some("https://a.example:8443".into()),
4982            |c: &mut Config| c.drain_timeout = Duration::from_secs(99),
4983        ] {
4984            let mut new = running.clone();
4985            mutate(&mut new);
4986            let diags = Config::reload_coherence_check(&new, Some(&running), true)
4987                .expect_err("a restart-only diff must be rejected");
4988            assert!(
4989                diags
4990                    .iter()
4991                    .any(|d| d.is_error() && d.msg.contains("restart-only")),
4992                "expected a restart-only error, got {diags:?}"
4993            );
4994        }
4995    }
4996
4997    #[test]
4998    fn coherence_accepts_a_reloadable_diff() {
4999        // log_level / model / subscribe / mcp_servers, the intelligence
5000        // endpoint list and the model-swap policy are all reloadable, so a diff
5001        // in any of them passes the coherence check untouched.
5002        let running = reactive_base();
5003        for mutate in [
5004            (|c: &mut Config| c.log_level = Level::Debug) as fn(&mut Config),
5005            |c: &mut Config| c.model = Some("claude-opus-4".into()),
5006            |c: &mut Config| c.max_tokens = 999_999,
5007            |c: &mut Config| c.max_steps = 123,
5008            |c: &mut Config| c.subscribe = vec!["file:///in.json".into(), "file:///b.json".into()],
5009            // The MCP server inventory is reloadable via a re-handshake.
5010            |c: &mut Config| {
5011                c.mcp_servers = vec![McpServerSpec {
5012                    name: "added".into(),
5013                    endpoint: "unix:/mcp-new.sock".into(),
5014                    ..Default::default()
5015                }]
5016            },
5017            // An endpoint repoint is a reloadable hot swap.
5018            |c: &mut Config| c.intelligence = Some("https://other.example".into()),
5019            |c: &mut Config| c.model_swap = SwapPolicy::RestartTurn,
5020        ] {
5021            let mut new = running.clone();
5022            mutate(&mut new);
5023            assert!(
5024                Config::reload_coherence_check(&new, Some(&running), true).is_ok(),
5025                "a reloadable diff must be accepted",
5026            );
5027        }
5028    }
5029
5030    #[test]
5031    fn mcp_servers_is_reloadable_not_restart_only() {
5032        // `mcp_servers` is not restart-only: `triggers::mode` performs a live
5033        // re-handshake, so adding, removing or editing a server is APPLIED at
5034        // the quiesce boundary rather than rejected.
5035        assert!(
5036            !RESTART_ONLY_FIELDS.contains(&"mcp_servers"),
5037            "mcp_servers must NOT be restart-only"
5038        );
5039        let running = reactive_base();
5040        // ADD a server.
5041        let mut added = running.clone();
5042        added.mcp_servers.push(McpServerSpec {
5043            name: "extra".into(),
5044            endpoint: "unix:/mcp-extra.sock".into(),
5045            ..Default::default()
5046        });
5047        assert!(
5048            Config::reload_coherence_check(&added, Some(&running), true).is_ok(),
5049            "adding an MCP server must pass the coherence check (it is reloadable)"
5050        );
5051        // EDIT a server's endpoint (a changed server = remove-then-add at apply).
5052        let mut with_server = running.clone();
5053        with_server.mcp_servers = vec![McpServerSpec {
5054            name: "s".into(),
5055            endpoint: "unix:/mcp-orig.sock".into(),
5056            ..Default::default()
5057        }];
5058        let mut edited = with_server.clone();
5059        edited.mcp_servers[0].endpoint = "https://mcp-edited.example".into();
5060        assert!(
5061            Config::reload_coherence_check(&edited, Some(&with_server), true).is_ok(),
5062            "editing an MCP server must pass the coherence check (it is reloadable)"
5063        );
5064    }
5065
5066    #[test]
5067    fn model_swap_flag_and_env_parse_and_default() {
5068        // `--model-swap` / `AGENTD_MODEL_SWAP` selects the policy; the default
5069        // is finish-on-old.
5070        let def = Config::load(&args(&[]), &base_env()).unwrap();
5071        assert_eq!(def.model_swap, SwapPolicy::FinishOnOld);
5072        let flag = Config::load(&args(&["--model-swap", "restart-turn"]), &base_env()).unwrap();
5073        assert_eq!(flag.model_swap, SwapPolicy::RestartTurn);
5074        let mut env = base_env();
5075        env.push(("AGENTD_MODEL_SWAP".into(), "restart-turn".into()));
5076        let e = Config::load(&args(&[]), &env).unwrap();
5077        assert_eq!(e.model_swap, SwapPolicy::RestartTurn);
5078        // A bad value is exit 2 (Usage), like any other invalid scalar.
5079        assert!(matches!(
5080            Config::load(&args(&["--model-swap", "nope"]), &base_env()),
5081            Err(ConfigError::Usage(_))
5082        ));
5083    }
5084
5085    #[test]
5086    fn intelligence_is_reloadable_not_restart_only() {
5087        // `intelligence` (the endpoint list) is not restart-only: a repoint is
5088        // APPLIED as a hot swap rather than rejected.
5089        assert!(
5090            !RESTART_ONLY_FIELDS.contains(&"intelligence"),
5091            "intelligence must NOT be restart-only"
5092        );
5093        let running = reactive_base();
5094        let mut new = running.clone();
5095        new.intelligence = Some("https://gw-b.example:1234".into());
5096        assert!(
5097            Config::reload_coherence_check(&new, Some(&running), true).is_ok(),
5098            "an endpoint repoint must pass the coherence check (it is reloadable)"
5099        );
5100    }
5101
5102    #[test]
5103    fn coherence_rejects_duplicate_server_names() {
5104        let mut cfg = reactive_base();
5105        cfg.mcp_servers = vec![
5106            McpServerSpec {
5107                name: "dup".into(),
5108                endpoint: "unix:/a.sock".into(),
5109                ..Default::default()
5110            },
5111            McpServerSpec {
5112                name: "dup".into(),
5113                endpoint: "unix:/b.sock".into(),
5114                ..Default::default()
5115            },
5116        ];
5117        let diags = Config::reload_coherence_check(&cfg, None, false)
5118            .expect_err("duplicate server names must be an error");
5119        assert!(
5120            diags
5121                .iter()
5122                .any(|d| d.is_error() && d.msg.contains("duplicate"))
5123        );
5124    }
5125
5126    #[test]
5127    fn restart_only_set_pins_the_immutable_fields() {
5128        // The partition pins mode / identity / transport, and each named field
5129        // is diff-detected
5130        // by `restart_only_field_differs` (a field listed but not compared would
5131        // silently reload — guard against that regression).
5132        for &f in RESTART_ONLY_FIELDS {
5133            let mut a = reactive_base();
5134            let b = a.clone();
5135            // Mutate the field on `a` and assert the diff is detected.
5136            match f {
5137                "mode" => a.mode = Mode::Loop,
5138                "run_id" => a.run_id = "x".into(),
5139                "serve_mcp" => a.serve_mcp = Some("https://s.example:8443".into()),
5140                "drain_timeout" => a.drain_timeout = Duration::from_secs(123),
5141                "continue_subscribe" => a.continue_subscribe = vec!["u".into()],
5142                other => panic!("RESTART_ONLY_FIELDS has an unmapped field '{other}'"),
5143            }
5144            assert!(
5145                a.restart_only_field_differs(&b, f),
5146                "restart-only field '{f}' must be diff-detected"
5147            );
5148        }
5149    }
5150
5151    #[test]
5152    fn effective_view_carries_no_secret_or_url() {
5153        // The effective view is reloadable and REDACTED — no token, no endpoint
5154        // URL, no resolved {{secret:…}} value, header NAMES only.
5155        const TOKEN: &str = "super-secret-effective-token";
5156        let mut env = base_env();
5157        env.push(("AGENTD_INTELLIGENCE_TOKEN".into(), TOKEN.into()));
5158        env.push((
5159            "AGENTD_INTELLIGENCE".into(),
5160            "https://user:embedded-cred@api.example/v1".into(),
5161        ));
5162        let mut cfg =
5163            Config::load(&args(&["--mcp", "vault=https://vault.example/mcp"]), &env).unwrap();
5164        cfg.intelligence_headers
5165            .insert("x-api-key".into(), "{{secret:SOME_NAME}}".into());
5166        let view = cfg.effective_view();
5167        let blob = serde_json::to_string(&view).unwrap();
5168        assert!(!blob.contains(TOKEN), "token leaked into effective view");
5169        assert!(!blob.contains("embedded-cred"), "URL creds leaked");
5170        assert!(!blob.contains("api.example"), "endpoint host leaked");
5171        assert!(!blob.contains("SOME_NAME"), "header ref value leaked");
5172        assert!(!blob.contains("vault-secret.sock"), "mcp endpoint leaked");
5173        // The structural reloadable fields ARE present (name + header KEY).
5174        assert_eq!(view["mcp_servers"][0]["name"], serde_json::json!("vault"));
5175        assert_eq!(
5176            view["intelligence_headers"],
5177            serde_json::json!(["x-api-key"])
5178        );
5179    }
5180}