Skip to main content

agentd/config/
mod.rs

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