Skip to main content

agentd/config/v2/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! **Configuration schema v2** (RFC 0030) — the agentd 2.0 settings document.
3//!
4//! One nested document (YAML or JSON; several files merge in order) whose
5//! every path is also `AGENTD_<PATH>` / `AGENT_<PATH>` / `<PATH>` and
6//! `--<path>`. This module holds the typed [`Settings`], its JSON Schema
7//! ([`schema::schema`]), the load pipeline (files → env → flags → typed →
8//! validated), the legacy **alias** table (`--instruction`, `--intelligence`,
9//! `--model`, `--mcp`, …), the `agentd --instruction X` **sugar**, v1/v2
10//! **detection**, and the reload partition (restart-only paths).
11//!
12//! Layering (RFC 0011 §2.1 / RFC 0017 §3.2, unchanged): `built-in < files <
13//! env < flags`. Files compose with JSON-Merge-Patch semantics; env sets a
14//! path (lists/maps replaced); flags apply in argument order — a generic
15//! `--<path>` SETS, a named repeatable alias (`--mcp`, `--a2a-peer`) ADDS.
16//!
17//! The 2.0 runtime consumes [`Settings`]; the v1 [`super::Config`] keeps
18//! serving the v1 runtime until the cut-over (plan §6 P5).
19
20pub mod schema;
21
22use super::file::{self, Format};
23use super::paths::{self, Binding};
24use super::{ConfigError, usage};
25use serde::{Deserialize, Serialize};
26use serde_json::{Map, Value, json};
27use std::collections::{BTreeMap, HashMap};
28use std::fmt;
29use std::time::Duration;
30
31// ---------------------------------------------------------------------------
32// Scalars
33// ---------------------------------------------------------------------------
34
35/// A duration deserialized from `"10m"` / `"500ms"` / bare seconds (string or
36/// integer). Displays in the same string form.
37#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
38pub struct Dur(pub Duration);
39
40impl fmt::Debug for Dur {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        write!(f, "{:?}", self.0)
43    }
44}
45
46impl<'de> Deserialize<'de> for Dur {
47    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
48        #[derive(Deserialize)]
49        #[serde(untagged)]
50        enum Raw {
51            Secs(u64),
52            Text(String),
53        }
54        match Raw::deserialize(d)? {
55            Raw::Secs(s) => Ok(Dur(Duration::from_secs(s))),
56            Raw::Text(t) => super::parse_duration(&t)
57                .map(Dur)
58                .map_err(serde::de::Error::custom),
59        }
60    }
61}
62
63/// A credential-bearing string: from a FILE it must be a `{{secret:…}}` /
64/// `{{secret-file:…}}` reference (§5 validation over the file document); from
65/// env/flags it may be inline. `Debug` never shows it.
66#[derive(Clone, PartialEq, Eq, Deserialize, Default)]
67pub struct Secret(pub String);
68
69impl fmt::Debug for Secret {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.write_str("***")
72    }
73}
74
75/// `all` | `none` | an explicit list.
76#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
77#[serde(untagged)]
78pub enum ToolSelect {
79    Keyword(SelectKeyword),
80    List(Vec<String>),
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum SelectKeyword {
86    All,
87    None,
88}
89
90impl Default for ToolSelect {
91    fn default() -> Self {
92        ToolSelect::Keyword(SelectKeyword::All)
93    }
94}
95
96impl ToolSelect {
97    pub fn allows(&self, name: &str) -> bool {
98        match self {
99            ToolSelect::Keyword(SelectKeyword::All) => true,
100            ToolSelect::Keyword(SelectKeyword::None) => false,
101            ToolSelect::List(l) => l.iter().any(|n| n == name),
102        }
103    }
104}
105
106fn string_or_list<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
107    #[derive(Deserialize)]
108    #[serde(untagged)]
109    enum Raw {
110        List(Vec<String>),
111        One(String),
112    }
113    Ok(match Raw::deserialize(d)? {
114        Raw::List(l) => l,
115        Raw::One(s) => s
116            .split(',')
117            .map(str::trim)
118            .filter(|s| !s.is_empty())
119            .map(str::to_string)
120            .collect(),
121    })
122}
123
124// ---------------------------------------------------------------------------
125// The document
126// ---------------------------------------------------------------------------
127
128/// The typed v2 settings document. Every object is `deny_unknown_fields`;
129/// every section defaults so a minimal document (`agent.instruction` alone)
130/// is complete.
131#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
132#[serde(deny_unknown_fields, default)]
133pub struct Settings {
134    pub config_version: Option<String>,
135    pub agent: Agent,
136    pub intelligence: Intelligence,
137    pub mcp: Mcp,
138    pub tools: Tools,
139    pub store: Store,
140    pub memory: Memory,
141    pub context: Context,
142    pub knowledge: Knowledge,
143    pub search: Search,
144    pub skills: Skills,
145    /// Inline dialect-3 definitions or `{name, file|uri}` references — kept as
146    /// raw documents here; the workflow engine (RFC 0027) types them.
147    pub workflows: Vec<Value>,
148    pub limits: Limits,
149    pub lifecycle: Lifecycle,
150    pub a2a: A2a,
151    /// The display-client surface (RFC 0032): opt-in TUI/web-UI methods on the
152    /// A2A listener (the global `SubscribeToEvents` feed + interface read ops).
153    pub interface: Interface,
154    /// The inbound webhook HTTP surface (RFC 0027): a dedicated listener for
155    /// `webhook` start nodes and `wait: {on: webhook}` callbacks.
156    pub webhooks: Webhooks,
157    /// The self-correcting goal watchdog (RFC 0026): a periodic check of whether
158    /// the configured goal is achieved (or the agent is stuck).
159    pub goal: Option<Goal>,
160    pub observability: Observability,
161    pub security: Security,
162}
163
164#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
165#[serde(deny_unknown_fields, default)]
166pub struct Agent {
167    pub name: Option<String>,
168    /// Static text, or a single-token URI a configured MCP server serves
169    /// (read + subscribed) — one field, parsed (RFC 0028 §3).
170    pub instruction: Option<String>,
171    /// A **one-shot task** (`--prompt`). With no workflows configured this is
172    /// what the generated run executes, while `instruction` stays the standing
173    /// policy (it becomes the run's system prompt). Given alone, the prompt is
174    /// the whole job — `agentd --prompt "…" --intelligence …` runs it once and
175    /// exits with the answer on stdout.
176    pub prompt: Option<String>,
177    pub preflight: Preflight,
178    pub wake_on: Option<Vec<WakeEvent>>,
179    pub on_workflow_finished: OnWorkflowFinished,
180    pub tools: AgentTools,
181    pub max_parallel_turns: Option<u32>,
182    pub conversation_budget: Option<Budget>,
183    /// What `ask_human` does when NO human channel can answer — the interface
184    /// is disabled — and, for `auto`, when a gate times out unanswered
185    /// (RFC 0032 §16): `fail` (default; the ask errors immediately), `wait`
186    /// (park until the ask timeout), or `auto` (an LLM judge answers on the
187    /// operator's behalf, conservatively, marked as auto).
188    pub ask_human_fallback: AskHumanFallback,
189}
190
191/// The `ask_human` fallback disposition (RFC 0032 §16).
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
193#[serde(rename_all = "lowercase")]
194pub enum AskHumanFallback {
195    /// Park the ask until its timeout (then it fails).
196    #[serde(alias = "pause", alias = "idle")]
197    Wait,
198    /// Error immediately — the caller (model / workflow policy) decides.
199    #[default]
200    #[serde(alias = "finish", alias = "stop")]
201    Fail,
202    /// An LLM judge answers on the operator's behalf (also fires when an
203    /// interface-served gate times out unanswered). `UNDECIDED` ⇒ fail.
204    Auto,
205}
206
207impl Agent {
208    /// The default wake set (RFC 0026 §3.1).
209    pub fn wake_on(&self) -> Vec<WakeEvent> {
210        self.wake_on.clone().unwrap_or_else(|| {
211            vec![
212                WakeEvent::A2aMessage,
213                WakeEvent::HumanReply,
214                WakeEvent::SubagentResult,
215                WakeEvent::WorkflowFailed,
216            ]
217        })
218    }
219    pub fn max_parallel_turns(&self) -> u32 {
220        self.max_parallel_turns.unwrap_or(4)
221    }
222    /// Whether the instruction is a resource reference (a single-token URI).
223    pub fn instruction_is_uri(&self) -> bool {
224        self.instruction
225            .as_deref()
226            .is_some_and(looks_like_resource_uri)
227    }
228}
229
230/// `scheme://…` with no whitespace, and a scheme that is not a bare `http(s)`
231/// URL to a web page… — any `<alpha><alnum+.->://` single token counts; the
232/// registry decides which server serves it (RFC 0028 §3).
233pub fn looks_like_resource_uri(s: &str) -> bool {
234    let t = s.trim();
235    if t.contains(char::is_whitespace) {
236        return false;
237    }
238    let Some((scheme, rest)) = t.split_once("://") else {
239        return false;
240    };
241    !scheme.is_empty()
242        && scheme
243            .chars()
244            .next()
245            .is_some_and(|c| c.is_ascii_alphabetic())
246        && scheme
247            .chars()
248            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
249        && !rest.is_empty()
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
253#[serde(rename_all = "lowercase")]
254pub enum Preflight {
255    Never,
256    #[default]
257    Auto,
258    Always,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum WakeEvent {
264    A2aMessage,
265    HumanReply,
266    SubagentResult,
267    WorkflowFinished,
268    WorkflowFailed,
269    InstructionUpdated,
270    BudgetResumed,
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
274#[serde(rename_all = "lowercase")]
275pub enum OnWorkflowFinished {
276    Ignore,
277    #[default]
278    Note,
279    Think,
280}
281
282#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
283#[serde(deny_unknown_fields, default)]
284pub struct AgentTools {
285    pub internal: ToolSelect,
286    pub mcp: ToolSelect,
287    pub code: ToolSelect,
288}
289
290#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
291#[serde(deny_unknown_fields, default)]
292pub struct Intelligence {
293    #[serde(deserialize_with = "string_or_list")]
294    pub endpoints: Vec<String>,
295    pub model: Option<String>,
296    /// The wire dialect (RFC 0031 §8): `openai` (default), `anthropic`, or
297    /// `bedrock` (native Amazon Bedrock Converse — pair with `auth: {kind: aws,
298    /// service: bedrock}`). Unset ⇒ OpenAI-compatible.
299    pub dialect: Option<String>,
300    pub token: Option<Secret>,
301    pub token_file: Option<String>,
302    pub headers: BTreeMap<String, String>,
303    /// A unified credential provider (RFC 0031 §5) for the LLM endpoint — e.g.
304    /// `oauth2` device-login for an enterprise gateway. Obtained via
305    /// `agentd login intelligence`; the resolved bearer overrides `token`.
306    pub auth: Option<Auth>,
307    pub swap_policy: Option<String>,
308    pub structured_output: StructuredOutput,
309    pub budget: Budget,
310    pub pricing: BTreeMap<String, Pricing>,
311    pub timeout: Option<Dur>,
312}
313
314impl Intelligence {
315    pub fn timeout(&self) -> Duration {
316        self.timeout.map(|d| d.0).unwrap_or(Duration::from_secs(60))
317    }
318    /// The comma-joined endpoint list URI the v1 intelligence client speaks.
319    pub fn endpoint_list(&self) -> Option<String> {
320        if self.endpoints.is_empty() {
321            None
322        } else {
323            Some(self.endpoints.join(","))
324        }
325    }
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
329#[serde(rename_all = "snake_case")]
330pub enum StructuredOutput {
331    #[default]
332    Auto,
333    JsonSchema,
334    Tool,
335    Prompt,
336}
337
338#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
339#[serde(deny_unknown_fields, default)]
340pub struct Budget {
341    pub windows: Vec<BudgetWindow>,
342    pub lifetime_tokens: Option<u64>,
343    pub scope: Option<Vec<BudgetScope>>,
344    pub on_exhausted: BudgetTactic,
345    pub slow: Slow,
346    pub degrade: Degrade,
347    pub reserve: Reserve,
348}
349
350#[derive(Debug, Clone, Deserialize, PartialEq)]
351#[serde(deny_unknown_fields)]
352pub struct BudgetWindow {
353    pub per: WindowUnit,
354    #[serde(default)]
355    pub tokens: Option<u64>,
356    #[serde(default)]
357    pub requests: Option<u64>,
358    #[serde(default)]
359    pub reset: Option<String>,
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
363#[serde(rename_all = "lowercase")]
364pub enum WindowUnit {
365    Second,
366    Minute,
367    Hour,
368    Day,
369    Week,
370}
371
372impl WindowUnit {
373    pub fn duration(self) -> Duration {
374        match self {
375            WindowUnit::Second => Duration::from_secs(1),
376            WindowUnit::Minute => Duration::from_secs(60),
377            WindowUnit::Hour => Duration::from_secs(3600),
378            WindowUnit::Day => Duration::from_secs(86_400),
379            WindowUnit::Week => Duration::from_secs(7 * 86_400),
380        }
381    }
382    /// Calendar windows reset at a wall-clock time; rolling windows are buckets.
383    pub fn is_calendar(self) -> bool {
384        matches!(self, WindowUnit::Day | WindowUnit::Week)
385    }
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
389#[serde(rename_all = "lowercase")]
390pub enum BudgetScope {
391    Instance,
392    Run,
393    Conversation,
394    Principal,
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
398#[serde(rename_all = "lowercase")]
399pub enum BudgetTactic {
400    #[default]
401    Wait,
402    Slow,
403    Degrade,
404    Refuse,
405    Fail,
406}
407
408#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
409#[serde(deny_unknown_fields, default)]
410pub struct Slow {
411    pub factor: Option<f64>,
412}
413
414#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
415#[serde(deny_unknown_fields, default)]
416pub struct Degrade {
417    pub model: Option<String>,
418}
419
420#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
421#[serde(deny_unknown_fields, default)]
422pub struct Reserve {
423    pub estimate: ReserveEstimate,
424    pub fixed: Option<u64>,
425}
426
427#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
428#[serde(rename_all = "lowercase")]
429pub enum ReserveEstimate {
430    #[default]
431    Context,
432    Fixed,
433    None,
434}
435
436#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
437#[serde(deny_unknown_fields, default)]
438pub struct Pricing {
439    pub input_per_1k: Option<f64>,
440    pub output_per_1k: Option<f64>,
441    pub currency: Option<String>,
442}
443
444#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
445#[serde(deny_unknown_fields, default)]
446pub struct Mcp {
447    pub servers: Vec<McpServer>,
448    pub default_timeout: Option<Dur>,
449}
450
451#[derive(Debug, Clone, Deserialize, PartialEq)]
452#[serde(deny_unknown_fields)]
453pub struct McpServer {
454    pub name: String,
455    pub endpoint: String,
456    #[serde(default)]
457    pub ns: Option<String>,
458    #[serde(default)]
459    pub headers: BTreeMap<String, String>,
460    #[serde(default)]
461    pub tags: BTreeMap<String, Vec<String>>,
462    #[serde(default)]
463    pub aauth: Option<bool>,
464    #[serde(default)]
465    pub oauth: Option<McpOauth>,
466    /// A unified credential provider (RFC 0031 §5) — `static` / `oauth2` (device
467    /// login, refresh). Interactive providers obtain their token via
468    /// `agentd login mcp:<name>`; the daemon reads the cached token. Coexists
469    /// with the legacy `oauth` shortcut (client-credentials).
470    #[serde(default)]
471    pub auth: Option<Auth>,
472    #[serde(default)]
473    pub timeout: Option<Dur>,
474}
475
476impl McpServer {
477    /// The flattened, deduplicated trifecta tag set (RFC 0012 §3.1).
478    pub fn tag_set(&self) -> Result<Vec<crate::sec::scope::TrifectaTag>, String> {
479        let mut out = Vec::new();
480        for list in self.tags.values() {
481            for t in list {
482                let tag = crate::sec::scope::TrifectaTag::parse(t).ok_or_else(|| {
483                    format!("mcp server '{}' has unknown trifecta tag '{t}'", self.name)
484                })?;
485                if !out.contains(&tag) {
486                    out.push(tag);
487                }
488            }
489        }
490        Ok(out)
491    }
492
493    /// The v1 runtime spec (the MCP client / spawn payload shape).
494    pub fn to_spec(&self) -> Result<super::McpServerSpec, String> {
495        Ok(super::McpServerSpec {
496            name: self.name.clone(),
497            endpoint: self.endpoint.clone(),
498            headers: self
499                .headers
500                .iter()
501                .map(|(k, v)| (k.clone(), v.clone()))
502                .collect(),
503            tags: self.tag_set()?,
504            aauth: self.aauth,
505            // RFC 0031: carry the OAuth client-credentials config to the runtime
506            // spec (previously dropped here, leaving `mcp.servers[].oauth` inert).
507            oauth: self.oauth.as_ref().map(|o| super::McpOauthSpec {
508                token_url: o.token_url.clone(),
509                client_id: o.client_id.clone(),
510                client_secret: o.client_secret.0.clone(),
511                scope: o.scope.clone(),
512            }),
513            auth: self.auth.as_ref().map(|a| a.to_spec()),
514        })
515    }
516}
517
518#[derive(Debug, Clone, Deserialize, PartialEq)]
519#[serde(deny_unknown_fields)]
520pub struct McpOauth {
521    pub token_url: String,
522    pub client_id: String,
523    pub client_secret: Secret,
524    #[serde(default)]
525    pub scope: Option<String>,
526}
527
528/// A unified per-endpoint authentication provider (RFC 0031 §5). A flat,
529/// `kind`-discriminated record: only the fields relevant to the chosen `kind`
530/// are set; semantic validation (§14) enforces which are required. The provider
531/// kinds land incrementally — `static`/`oauth2` first, then `aws`/`spiffe`.
532#[derive(Debug, Clone, Deserialize, PartialEq)]
533#[serde(deny_unknown_fields)]
534pub struct Auth {
535    pub kind: AuthKind,
536    // --- oauth2 / oidc (RFC 0031 §7) ---
537    /// Issuer base URL for `.well-known` metadata discovery (RFC 8414 / OIDC),
538    /// used to fill the token / device-authorization endpoints when unset.
539    #[serde(default)]
540    pub issuer: Option<String>,
541    #[serde(default)]
542    pub token_url: Option<String>,
543    #[serde(default)]
544    pub device_authorization_url: Option<String>,
545    #[serde(default)]
546    pub authorization_url: Option<String>,
547    #[serde(default)]
548    pub client_id: Option<String>,
549    /// A confidential client's secret (`{{secret:…}}`); omit for a public client
550    /// (the device grant needs no secret).
551    #[serde(default)]
552    pub client_secret: Option<Secret>,
553    /// `device` (default, interactive), `authorization_code`, or
554    /// `client_credentials` (headless M2M).
555    #[serde(default)]
556    pub grant: Option<OAuthGrant>,
557    #[serde(default)]
558    pub scopes: Vec<String>,
559    #[serde(default)]
560    pub audience: Option<String>,
561    // --- static (RFC 0031 §6) ---
562    /// A static bearer (`{{secret:…}}`) → `Authorization: Bearer …`.
563    #[serde(default)]
564    pub token: Option<Secret>,
565    /// A static credential under an arbitrary header name (paired with `value`).
566    #[serde(default)]
567    pub header: Option<String>,
568    #[serde(default)]
569    pub value: Option<Secret>,
570    // --- aws (SigV4, RFC 0031 §8) ---
571    #[serde(default)]
572    pub region: Option<String>,
573    /// The AWS service to sign for (e.g. `bedrock`, `execute-api`).
574    #[serde(default)]
575    pub service: Option<String>,
576    /// The credential source: `env` / `static` / `sso` (IAM Identity Center
577    /// interactive login → temporary credentials). (`imds`/`irsa` are follow-ups.)
578    #[serde(default)]
579    pub source: Option<String>,
580    /// aws `source: sso` — the IAM Identity Center portal start URL, the account,
581    /// and the permission-set role to assume (via `agentd login`).
582    #[serde(default)]
583    pub sso_start_url: Option<String>,
584    #[serde(default)]
585    pub account_id: Option<String>,
586    #[serde(default)]
587    pub role_name: Option<String>,
588    // --- spiffe (workload identity, RFC 0031 §9) ---
589    /// The SVID type: `jwt` (a rotating JWT-SVID bearer, the file-SVID MVP) or
590    /// `x509` (mTLS — a follow-up).
591    #[serde(default)]
592    pub svid: Option<String>,
593    /// Path to the SPIRE-written JWT-SVID token file (re-read per request, so a
594    /// rotation is picked up).
595    #[serde(default)]
596    pub jwt_svid_file: Option<String>,
597    /// Paths to the X.509-SVID cert + key (for `svid: x509`).
598    #[serde(default)]
599    pub svid_file: Option<String>,
600    #[serde(default)]
601    pub key_file: Option<String>,
602}
603
604impl Auth {
605    /// Lower to the secret-free runtime [`AuthSpec`](super::AuthSpec) (spawn
606    /// payload). Secrets stay as `{{secret:…}}` templates.
607    pub fn to_spec(&self) -> super::AuthSpec {
608        super::AuthSpec {
609            kind: match self.kind {
610                AuthKind::Static => "static",
611                AuthKind::Oauth2 => "oauth2",
612                AuthKind::Aws => "aws",
613                AuthKind::Spiffe => "spiffe",
614            }
615            .to_string(),
616            grant: self.grant.map(|g| {
617                match g {
618                    OAuthGrant::Device => "device",
619                    OAuthGrant::AuthorizationCode => "authorization_code",
620                    OAuthGrant::ClientCredentials => "client_credentials",
621                }
622                .to_string()
623            }),
624            issuer: self.issuer.clone(),
625            token_url: self.token_url.clone(),
626            device_authorization_url: self.device_authorization_url.clone(),
627            authorization_url: self.authorization_url.clone(),
628            client_id: self.client_id.clone(),
629            client_secret: self.client_secret.as_ref().map(|s| s.0.clone()),
630            scopes: self.scopes.clone(),
631            audience: self.audience.clone(),
632            token: self.token.as_ref().map(|s| s.0.clone()),
633            header: self.header.clone(),
634            value: self.value.as_ref().map(|s| s.0.clone()),
635            region: self.region.clone(),
636            service: self.service.clone(),
637            source: self.source.clone(),
638            sso_start_url: self.sso_start_url.clone(),
639            account_id: self.account_id.clone(),
640            role_name: self.role_name.clone(),
641            svid: self.svid.clone(),
642            jwt_svid_file: self.jwt_svid_file.clone(),
643            svid_file: self.svid_file.clone(),
644            key_file: self.key_file.clone(),
645        }
646    }
647}
648
649/// The authentication provider family (RFC 0031 §5).
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
651#[serde(rename_all = "snake_case")]
652pub enum AuthKind {
653    /// A static bearer/header credential (today's behavior, made explicit).
654    Static,
655    /// OAuth 2.1 / OIDC — device grant, authorization-code, or client-credentials.
656    Oauth2,
657    /// AWS Signature Version 4 (RFC 0031 §8) — SigV4-signed requests.
658    Aws,
659    /// SPIFFE/SPIRE workload identity (RFC 0031 §9) — a JWT-SVID bearer (or
660    /// X.509-SVID mTLS).
661    Spiffe,
662}
663
664/// The OAuth 2.1 grant type (RFC 0031 §7).
665#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
666#[serde(rename_all = "snake_case")]
667pub enum OAuthGrant {
668    /// RFC 8628 device authorization — the interactive default.
669    Device,
670    /// RFC 7636 authorization-code + PKCE (browser loopback).
671    AuthorizationCode,
672    /// The headless machine-to-machine grant.
673    ClientCredentials,
674}
675
676#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
677#[serde(deny_unknown_fields, default)]
678pub struct Tools {
679    pub disabled: Vec<String>,
680    pub overrides: BTreeMap<String, ToolOverride>,
681}
682
683#[derive(Debug, Clone, Deserialize, PartialEq)]
684#[serde(deny_unknown_fields)]
685pub struct ToolOverride {
686    pub server: String,
687    pub tool: String,
688    #[serde(default)]
689    pub args: Option<String>,
690    #[serde(default)]
691    pub result: Option<String>,
692}
693
694#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
695#[serde(deny_unknown_fields, default)]
696pub struct Store {
697    pub kind: StoreKind,
698    pub prefix: Option<String>,
699    pub mcp: Option<StoreMcp>,
700    pub http: Option<StoreHttp>,
701    pub file: Option<StoreFile>,
702    pub checkpoint: Checkpoint,
703    pub durability: Durability,
704    pub retention: Retention,
705    pub on_error: StoreOnError,
706    pub audit: bool,
707    pub timeout: Option<Dur>,
708}
709
710impl Store {
711    pub fn prefix(&self) -> &str {
712        self.prefix.as_deref().unwrap_or("agentd")
713    }
714}
715
716#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
717#[serde(rename_all = "lowercase")]
718pub enum StoreKind {
719    Mcp,
720    Http,
721    /// The local filesystem (RFC 0033): one file per key under a root
722    /// directory, single-writer, durable to whatever the filesystem is.
723    File,
724    Memory,
725    #[default]
726    None,
727}
728
729/// `store.file` (RFC 0033 §4). The only setting is where the state lives; the
730/// adapter needs nothing else, so the block itself is optional — `kind: file`
731/// with no block resolves the root from the environment ([`file_store_root`]).
732#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
733#[serde(deny_unknown_fields)]
734pub struct StoreFile {
735    #[serde(default)]
736    pub path: Option<String>,
737}
738
739/// The `file` store's root directory (RFC 0033 §4), first that applies:
740/// `store.file.path`, `$AGENTD_STATE_DIR`, `$XDG_STATE_HOME/agentd/state`,
741/// `$HOME/.local/state/agentd/state`, else the OS temp dir.
742///
743/// This is deliberately the same chain — and the same order — that
744/// [`crate::auth::cache::default_dir`] uses for the credential cache, one
745/// sibling over (`state` beside `creds`): an operator who has learned where
746/// agentd keeps its tokens already knows where it keeps its state, and one
747/// `XDG_STATE_HOME` moves both. Resolution lives here, next to the schema, so
748/// the startup log, `--capabilities` and [`crate::store::open`] all name the
749/// one directory instead of each re-deriving it.
750///
751/// The last resort is the OS temp dir: a store that is *there* survives a
752/// process restart but not a reboot, which is why the runtime logs the
753/// resolved path and whether it was defaulted (RFC 0033 §5.1) rather than
754/// letting a user believe more than the filesystem delivers.
755pub fn file_store_root(store: &Store) -> std::path::PathBuf {
756    file_store_root_in(store, &|k| std::env::var_os(k))
757}
758
759/// [`file_store_root`] with the environment injected. The chain is the part
760/// worth testing and the process env is shared by every test in this binary,
761/// so the lookup is a parameter — the same shape `unresolved_secret_ref` uses.
762fn file_store_root_in(
763    store: &Store,
764    env: &dyn Fn(&str) -> Option<std::ffi::OsString>,
765) -> std::path::PathBuf {
766    use std::path::PathBuf;
767    if let Some(p) = store.file.as_ref().and_then(|f| f.path.as_deref()) {
768        return PathBuf::from(p);
769    }
770    if let Some(d) = env("AGENTD_STATE_DIR") {
771        return PathBuf::from(d);
772    }
773    if let Some(d) = env("XDG_STATE_HOME") {
774        return PathBuf::from(d).join("agentd").join("state");
775    }
776    if let Some(h) = env("HOME") {
777        return PathBuf::from(h)
778            .join(".local")
779            .join("state")
780            .join("agentd")
781            .join("state");
782    }
783    std::env::temp_dir().join("agentd").join("state")
784}
785
786#[derive(Debug, Clone, Deserialize, PartialEq)]
787#[serde(deny_unknown_fields)]
788pub struct StoreMcp {
789    pub server: String,
790    #[serde(default)]
791    pub put: Option<StoreOp>,
792    #[serde(default)]
793    pub get: Option<StoreOp>,
794    #[serde(default)]
795    pub list: Option<StoreOp>,
796    #[serde(default)]
797    pub delete: Option<StoreOp>,
798}
799
800#[derive(Debug, Clone, Deserialize, PartialEq)]
801#[serde(deny_unknown_fields)]
802pub struct StoreOp {
803    pub tool: String,
804    #[serde(default)]
805    pub args: Option<String>,
806    #[serde(default)]
807    pub ok: Option<String>,
808    #[serde(default)]
809    pub conflict: Option<String>,
810    #[serde(default)]
811    pub value: Option<String>,
812    #[serde(default)]
813    pub keys: Option<String>,
814}
815
816#[derive(Debug, Clone, Deserialize, PartialEq)]
817#[serde(deny_unknown_fields)]
818pub struct StoreHttp {
819    pub base_url: String,
820    #[serde(default)]
821    pub headers: BTreeMap<String, String>,
822    #[serde(default)]
823    pub get: Option<HttpOp>,
824    #[serde(default)]
825    pub put: Option<HttpOp>,
826    #[serde(default)]
827    pub list: Option<HttpOp>,
828    #[serde(default)]
829    pub delete: Option<HttpOp>,
830}
831
832#[derive(Debug, Clone, Deserialize, PartialEq)]
833#[serde(deny_unknown_fields)]
834pub struct HttpOp {
835    #[serde(default)]
836    pub method: Option<String>,
837    pub url: String,
838    #[serde(default)]
839    pub body: Option<String>,
840    #[serde(default)]
841    pub value: Option<String>,
842    #[serde(default)]
843    pub keys: Option<String>,
844    #[serde(default)]
845    pub conflict_status: Option<u16>,
846}
847
848#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
849#[serde(deny_unknown_fields, default)]
850pub struct Checkpoint {
851    pub debounce_ms: Option<u64>,
852}
853
854/// What to keep once a run is over.
855///
856/// A long-lived instance accumulates one durable record per run forever. On a
857/// laptop that is the difference between an agent that runs for a month and one
858/// that fills a disk — and the store had no eviction at all, so "forever" was
859/// literal.
860#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
861#[serde(deny_unknown_fields, default)]
862pub struct Retention {
863    pub runs: RunRetention,
864}
865
866#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
867#[serde(deny_unknown_fields, default)]
868pub struct RunRetention {
869    /// Keep at most this many terminal runs (newest first).
870    pub keep_last: Option<u32>,
871    /// Drop a terminal run older than this.
872    pub ttl: Option<Dur>,
873}
874
875#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
876#[serde(deny_unknown_fields, default)]
877pub struct Durability {
878    pub a2a: Option<DurabilityLevel>,
879    pub steps: Option<DurabilityLevel>,
880}
881
882#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
883#[serde(rename_all = "lowercase")]
884pub enum DurabilityLevel {
885    Strict,
886    Eventual,
887}
888
889#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
890#[serde(rename_all = "lowercase")]
891pub enum StoreOnError {
892    #[default]
893    Halt,
894    Degrade,
895}
896
897#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
898#[serde(deny_unknown_fields, default)]
899pub struct Memory {
900    pub max_value_bytes: Option<u64>,
901    pub list_default_limit: Option<u64>,
902}
903
904#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
905#[serde(deny_unknown_fields, default)]
906pub struct Context {
907    pub compact_at: Option<f64>,
908    pub keep_last: Option<u32>,
909    /// The model's context window in tokens (overrides the value inferred
910    /// from `intelligence.model`) — the base of the compaction threshold.
911    pub model_window: Option<u64>,
912    pub plan: Plan,
913}
914
915#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
916#[serde(deny_unknown_fields, default)]
917pub struct Plan {
918    pub max_items: Option<u32>,
919}
920
921#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
922#[serde(deny_unknown_fields, default)]
923pub struct Knowledge {
924    pub server: Option<String>,
925    pub auto_context: AutoContext,
926}
927
928#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
929#[serde(deny_unknown_fields, default)]
930pub struct AutoContext {
931    pub on: AutoContextOn,
932    pub top_k: Option<u32>,
933    pub max_bytes: Option<u64>,
934}
935
936#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
937#[serde(rename_all = "lowercase")]
938pub enum AutoContextOn {
939    Turn,
940    #[default]
941    Never,
942}
943
944#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
945#[serde(deny_unknown_fields, default)]
946pub struct Search {
947    pub server: Option<String>,
948}
949
950#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
951#[serde(deny_unknown_fields, default)]
952pub struct Skills {
953    pub sources: Vec<SkillSource>,
954    pub reference_prefix: Option<String>,
955    pub max_loaded: Option<u32>,
956    pub max_bytes: Option<u64>,
957}
958
959#[derive(Debug, Clone, Deserialize, PartialEq)]
960#[serde(deny_unknown_fields)]
961pub struct SkillSource {
962    pub server: String,
963    #[serde(default)]
964    pub discover: Discover,
965    #[serde(default)]
966    pub filter: Option<String>,
967}
968
969#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
970#[serde(rename_all = "lowercase")]
971pub enum Discover {
972    Prompts,
973    Resources,
974    #[default]
975    Auto,
976}
977
978#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
979#[serde(deny_unknown_fields, default)]
980pub struct Limits {
981    pub max_runs: Option<u32>,
982    pub run: RunLimits,
983    pub subagents: SubagentLimits,
984    pub inline_max_bytes: Option<u64>,
985    pub step_timeout: Option<Dur>,
986    pub workflow: WorkflowLimits,
987}
988
989/// Ceilings a workflow definition is checked against at load time.
990#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
991#[serde(deny_unknown_fields, default)]
992pub struct WorkflowLimits {
993    /// The most concurrent lanes a `foreach`/`batch` body may use. A definition
994    /// asking for more is REFUSED at load rather than quietly clamped: silent
995    /// clamping is how a workflow ends up running eight-wide while its author
996    /// believes it runs fifty, and the whole point of the field whitelist is
997    /// that a knob either does what it says or fails loudly.
998    pub fan_out: Option<u32>,
999}
1000
1001#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1002#[serde(deny_unknown_fields, default)]
1003pub struct RunLimits {
1004    pub steps: Option<u32>,
1005    pub tokens: Option<u64>,
1006    pub deadline: Option<Dur>,
1007}
1008
1009impl RunLimits {
1010    pub fn steps(&self) -> u32 {
1011        self.steps.unwrap_or(500)
1012    }
1013    pub fn tokens(&self) -> u64 {
1014        self.tokens.unwrap_or(2_000_000)
1015    }
1016    pub fn deadline(&self) -> Duration {
1017        self.deadline
1018            .map(|d| d.0)
1019            .unwrap_or(Duration::from_secs(3600))
1020    }
1021}
1022
1023#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1024#[serde(deny_unknown_fields, default)]
1025pub struct SubagentLimits {
1026    pub depth: Option<u32>,
1027    pub breadth: Option<u32>,
1028    pub total: Option<u32>,
1029    pub rate: Option<String>,
1030}
1031
1032#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1033#[serde(deny_unknown_fields, default)]
1034pub struct Lifecycle {
1035    pub run_until: RunUntil,
1036    pub idle_grace: Option<Dur>,
1037    pub drain_timeout: Option<Dur>,
1038    pub run_id: Option<String>,
1039    pub exit_code_map: BTreeMap<String, i32>,
1040    pub watch_config: bool,
1041}
1042
1043impl Lifecycle {
1044    pub fn drain_timeout(&self) -> Duration {
1045        self.drain_timeout
1046            .map(|d| d.0)
1047            .unwrap_or(Duration::from_secs(25))
1048    }
1049    pub fn idle_grace(&self) -> Duration {
1050        self.idle_grace
1051            .map(|d| d.0)
1052            .unwrap_or(Duration::from_secs(5))
1053    }
1054}
1055
1056#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1057#[serde(rename_all = "lowercase")]
1058pub enum RunUntil {
1059    #[default]
1060    Auto,
1061    Idle,
1062    Drained,
1063}
1064
1065#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1066#[serde(deny_unknown_fields, default)]
1067pub struct A2a {
1068    pub listen: Option<String>,
1069    pub tls: A2aTls,
1070    pub bearer: Option<Secret>,
1071    pub principals: Vec<Principal>,
1072    pub peers: Vec<A2aPeer>,
1073    pub conversation_ttl: Option<Dur>,
1074    pub push: A2aPush,
1075}
1076
1077/// **Push notifications**: a caller registers a webhook and agentd POSTs its
1078/// task's updates there instead of holding a stream open.
1079///
1080/// Default-OFF, because the URL comes from the caller: every delivery is an
1081/// outbound request to an address a *peer* chose, which is the shape of an SSRF.
1082/// Enabling it says you are willing to make that request; `allow_private` says
1083/// you are willing to make it to a private or loopback address, which is a
1084/// separate and larger decision (a peer could otherwise reach agentd's own
1085/// surfaces, or a cloud metadata endpoint).
1086#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1087#[serde(deny_unknown_fields, default)]
1088pub struct A2aPush {
1089    /// Accept `CreateTaskPushNotificationConfig` and deliver on transitions.
1090    pub enabled: bool,
1091    /// Permit webhook targets on private / loopback addresses.
1092    pub allow_private: bool,
1093}
1094
1095#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1096#[serde(deny_unknown_fields, default)]
1097pub struct A2aTls {
1098    pub cert: Option<String>,
1099    pub key: Option<String>,
1100    pub client_ca: Option<String>,
1101}
1102
1103/// The **display-client interface** (RFC 0032): the opt-in surface a thin
1104/// TUI/web-UI client rides — the global `SubscribeToEvents` feed and the
1105/// `interface.*`/debug read ops, served on the existing A2A listener (no new
1106/// socket). Default-OFF: with `enabled: false` those methods answer
1107/// UNSUPPORTED_OPERATION and the core A2A surface is byte-identical. `debug`
1108/// additionally exposes internals (conversation transcripts, per-step run
1109/// detail, the live log ring, audit records on the feed) — operator-grade
1110/// information; leave it off in production unless you need it. `origins` lets a
1111/// hosted web UI (a non-loopback browser origin) through the DNS-rebind guard
1112/// with CORS; loopback origins are always accepted.
1113#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1114#[serde(deny_unknown_fields, default)]
1115pub struct Interface {
1116    /// Serve the interface methods (`SubscribeToEvents`, `interface.info`, …).
1117    pub enabled: bool,
1118    /// Expose extra debug information (transcripts, run step detail, the log
1119    /// ring, audit feed events). Clients render their debug panes only when
1120    /// this is on. Runtime-togglable over the wire via `config.set` (operator).
1121    pub debug: bool,
1122    /// Extra allowed browser origins (`scheme://host[:port]`, exact match) for
1123    /// a hosted web UI. Loopback origins never need listing.
1124    pub origins: Vec<String>,
1125    /// What the display clients render in their chrome (RFC 0032 §12) — the
1126    /// daemon decides; every attached client renders the same layout.
1127    pub display: Display,
1128    /// Pairing-code login (RFC 0032 §13): a rotating short code shown to the
1129    /// operator that a client exchanges for a session token — the low-friction
1130    /// alternative to copying a bearer.
1131    pub pairing: Pairing,
1132}
1133
1134/// The client-chrome layout (RFC 0032 §12): ordered item lists for the top
1135/// (header) and bottom (status bar) edges. `None` ⇒ the built-in default;
1136/// unknown items are skipped by clients (forward compatibility). The item
1137/// vocabulary lives in [`DISPLAY_ITEMS`].
1138#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1139#[serde(deny_unknown_fields, default)]
1140pub struct Display {
1141    pub top: Option<Vec<String>>,
1142    pub bottom: Option<Vec<String>>,
1143}
1144
1145/// The display items a client knows how to render (RFC 0032 §12).
1146pub const DISPLAY_ITEMS: &[&str] = &[
1147    "name",     // agent name (card)
1148    "version",  // agentd version
1149    "instance", // instance identity
1150    "model",    // intelligence.model
1151    "endpoint", // the endpoint the client dialed
1152    "conn",     // connection state (live/polling/error)
1153    "debug",    // the debug badge
1154    "draining", // the DRAINING notice
1155    "active",   // active task count
1156    "turns",    // counter
1157    "tokens",   // tokens in/out
1158    "tool_calls",
1159    "runs",          // run count
1160    "subagents",     // subagent count
1161    "conversations", // conversation count
1162    "screen",        // current screen name (tui)
1163    "keys",          // key hints (tui)
1164    "clock",         // local time
1165];
1166
1167/// Pairing-code login (RFC 0032 §13). The code is a 6-digit value derived
1168/// from a per-process random seed and the current 60-second window — shown
1169/// only to operators (`pairing.code`), verified with the previous window's
1170/// grace, rate-limited, and exchanged (`Pair`) for a high-entropy session
1171/// token that lives in memory until `ttl` (or restart).
1172#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1173#[serde(deny_unknown_fields, default)]
1174pub struct Pairing {
1175    pub enabled: bool,
1176    /// The role a paired session gets: `operator` (default — whoever can read
1177    /// the code can already see the operator console) or `user`.
1178    pub role: Option<Role>,
1179    /// Session-token lifetime (default 12h).
1180    pub ttl: Option<Dur>,
1181}
1182
1183/// The webhook inbound HTTP surface (RFC 0027): a dedicated listener serving the
1184/// `webhook` start nodes and `wait: {on: webhook}` callbacks. Auth is **per
1185/// node** (each `webhook` declares its own verification); a listener-wide default
1186/// may be set here and is used by nodes that declare no `auth`.
1187#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1188#[serde(deny_unknown_fields, default)]
1189pub struct Webhooks {
1190    /// `https://host:port` (loopback `http://` for dev). Required when any
1191    /// `webhook` start node or `wait: {on: webhook}` is used.
1192    pub listen: Option<String>,
1193    pub tls: A2aTls,
1194    /// A default auth applied to `webhook` nodes that declare none.
1195    pub default_auth: Option<WebhookAuth>,
1196}
1197
1198/// A webhook's inbound authentication. Best practice (and the default guidance)
1199/// is HMAC over the raw body; a required-header or bearer match are alternatives;
1200/// `none: true` is an explicit loopback-only dev opt-out.
1201#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1202#[serde(deny_unknown_fields, default)]
1203pub struct WebhookAuth {
1204    /// HMAC signature verification over the raw request body (GitHub/Stripe-style).
1205    pub hmac: Option<Hmac>,
1206    /// A shared bearer token (`Authorization: Bearer …`), constant-time matched.
1207    pub bearer: Option<Secret>,
1208    /// A required header exact-match (`{name, equals}`).
1209    pub header: Option<HeaderMatch>,
1210    /// Loopback-only, no auth (dev). Explicit opt-in.
1211    pub none: bool,
1212}
1213
1214#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1215#[serde(deny_unknown_fields, default)]
1216pub struct Hmac {
1217    pub secret: Option<Secret>,
1218    /// The header carrying the signature (default `X-Signature`).
1219    pub header: Option<String>,
1220    /// Digest algorithm — `sha256` (default; the only supported algorithm).
1221    pub algo: Option<String>,
1222    /// A prefix stripped before the constant-time hex compare (e.g. `sha256=`).
1223    pub prefix: Option<String>,
1224}
1225
1226#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1227#[serde(deny_unknown_fields, default)]
1228pub struct HeaderMatch {
1229    pub name: Option<String>,
1230    pub equals: Option<Secret>,
1231}
1232
1233/// The self-correcting goal watchdog (RFC 0026). A supervisor-level periodic
1234/// check of whether the configured `statement` is achieved (or the agent is
1235/// stuck), with a configurable disposition. It never blocks the agent loop.
1236#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1237#[serde(deny_unknown_fields, default)]
1238pub struct Goal {
1239    /// The goal in natural language (the LLM judge reads it).
1240    pub statement: Option<String>,
1241    pub check: GoalCheck,
1242    /// N consecutive no-progress checks ⇒ self-correct (default 3).
1243    pub stuck_after: Option<u32>,
1244    /// What to do when the goal is achieved (default: `finish`).
1245    pub on_achieved: Option<GoalAction>,
1246    /// What to do when stuck (default: `replan`).
1247    pub on_stuck: Option<GoalAction>,
1248}
1249
1250#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1251#[serde(deny_unknown_fields, default)]
1252pub struct GoalCheck {
1253    /// The check cadence (default `5m`).
1254    pub every: Option<Dur>,
1255    /// An optional cheap CEL predicate over durable state, evaluated first.
1256    pub condition: Option<String>,
1257    /// `both` (default: CEL then LLM), `condition` (CEL only), or `agent` (LLM only).
1258    pub via: Option<String>,
1259}
1260
1261/// A goal disposition. Deserialized from a bare string
1262/// (`finish`/`idle`/`replan`/`escalate`) or `{ workflow: <name> }`.
1263#[derive(Debug, Clone, PartialEq)]
1264pub enum GoalAction {
1265    Finish,
1266    Idle,
1267    Replan,
1268    Escalate,
1269    Workflow(String),
1270}
1271
1272impl<'de> Deserialize<'de> for GoalAction {
1273    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1274        use serde::de::Error;
1275        match Value::deserialize(d)? {
1276            Value::String(s) => match s.as_str() {
1277                "finish" => Ok(GoalAction::Finish),
1278                "idle" => Ok(GoalAction::Idle),
1279                "replan" => Ok(GoalAction::Replan),
1280                "escalate" => Ok(GoalAction::Escalate),
1281                other => Err(D::Error::custom(format!(
1282                    "unknown goal action '{other}' (want finish|idle|replan|escalate|{{workflow: <name>}})"
1283                ))),
1284            },
1285            Value::Object(m) => match m.get("workflow").and_then(Value::as_str) {
1286                Some(w) => Ok(GoalAction::Workflow(w.to_string())),
1287                None => Err(D::Error::custom(
1288                    "a goal action object must be { workflow: <name> }",
1289                )),
1290            },
1291            _ => Err(D::Error::custom(
1292                "a goal action must be a string or { workflow: <name> }",
1293            )),
1294        }
1295    }
1296}
1297
1298#[derive(Debug, Clone, Deserialize, PartialEq)]
1299#[serde(deny_unknown_fields)]
1300pub struct Principal {
1301    #[serde(rename = "match")]
1302    pub matcher: PrincipalMatch,
1303    pub role: Role,
1304    #[serde(default)]
1305    pub grants: Vec<String>,
1306    #[serde(default)]
1307    pub quotas: Option<Quotas>,
1308}
1309
1310#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1311#[serde(deny_unknown_fields, default)]
1312pub struct PrincipalMatch {
1313    pub san: Option<String>,
1314    pub sub: Option<String>,
1315    pub bearer_ref: Option<String>,
1316    pub aauth_agent: Option<String>,
1317    pub any: bool,
1318}
1319
1320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1321#[serde(rename_all = "lowercase")]
1322pub enum Role {
1323    Operator,
1324    User,
1325    Agent,
1326    Anonymous,
1327}
1328
1329#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1330#[serde(deny_unknown_fields, default)]
1331pub struct Quotas {
1332    pub rate: Option<String>,
1333    pub budget: Option<Budget>,
1334}
1335
1336#[derive(Debug, Clone, Deserialize, PartialEq)]
1337#[serde(deny_unknown_fields)]
1338pub struct A2aPeer {
1339    pub name: String,
1340    pub endpoint: String,
1341    #[serde(default)]
1342    pub headers: BTreeMap<String, String>,
1343    #[serde(default)]
1344    pub client_cert: Option<String>,
1345    #[serde(default)]
1346    pub client_key: Option<String>,
1347    /// A unified credential provider (RFC 0031 §5) for the peer — `static` /
1348    /// `oauth2` (device-login) / `spiffe` (jwt). Resolved to a bearer at dial
1349    /// time. (`aws` SigV4 for A2A is a follow-up — it needs per-request signing.)
1350    #[serde(default)]
1351    pub auth: Option<Auth>,
1352}
1353
1354#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1355#[serde(deny_unknown_fields, default)]
1356pub struct Observability {
1357    pub log_level: Option<String>,
1358    pub log_content: bool,
1359    pub otel: Otel,
1360    pub metrics_addr: Option<String>,
1361    pub health_file: Option<String>,
1362    pub report_file: Option<String>,
1363    pub events_ring: Option<u32>,
1364    pub audit: Audit,
1365    pub traceparent: Option<String>,
1366}
1367
1368#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1369#[serde(deny_unknown_fields, default)]
1370pub struct Otel {
1371    pub endpoint: Option<String>,
1372    pub traces: Option<bool>,
1373    pub metrics: Option<bool>,
1374    pub logs: Option<bool>,
1375}
1376
1377#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1378#[serde(deny_unknown_fields, default)]
1379pub struct Audit {
1380    pub sink: Option<Vec<AuditSink>>,
1381}
1382
1383#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1384#[serde(rename_all = "lowercase")]
1385pub enum AuditSink {
1386    Log,
1387    Store,
1388}
1389
1390#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1391#[serde(deny_unknown_fields, default)]
1392pub struct Security {
1393    pub allow_trifecta: bool,
1394    pub tls_ca: Option<String>,
1395    pub aauth: Option<AAuth>,
1396    pub cgroup: Cgroup,
1397    pub exec: Exec,
1398}
1399
1400/// The local command-runner controls (RFC 0028 §exec). agentd's default posture
1401/// is **no local execution** (RFC 0012); this is off unless an operator both
1402/// builds with `--features exec` AND sets `enabled: true` — and even then runs
1403/// only allow-listed commands, in a confined directory, with a minimal env. The
1404/// `exec` tool is otherwise **mapping-only** (delegate off-box via
1405/// `tools.overrides`). It carries the `sensitive` + `egress` trifecta tags.
1406#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1407#[serde(deny_unknown_fields, default)]
1408pub struct Exec {
1409    /// Enable a LOCAL runner. Requires the `exec` build feature too; default OFF.
1410    pub enabled: bool,
1411    /// Allow-listed command names (`argv[0]`); anything else is refused. Empty =
1412    /// deny all (so `enabled` alone runs nothing).
1413    pub allow: Vec<String>,
1414    /// The directory commands run in; a requested `cwd` must resolve inside it.
1415    pub workdir: Option<String>,
1416    /// Max wall-clock per command (a longer requested `timeout` is clamped). 30s.
1417    pub timeout: Option<Dur>,
1418    /// Cap on captured stdout+stderr bytes (default 1 MiB).
1419    pub max_output: Option<u64>,
1420    /// Environment variable NAMES passed through to the child (default none — a
1421    /// minimal env; the agent's own env/secrets are never inherited).
1422    pub env: Vec<String>,
1423}
1424
1425#[derive(Debug, Clone, Deserialize, PartialEq)]
1426#[serde(deny_unknown_fields)]
1427pub struct AAuth {
1428    pub provider: String,
1429    #[serde(default)]
1430    pub key_file: Option<String>,
1431    #[serde(default)]
1432    pub enroll_token: Option<Secret>,
1433    #[serde(default)]
1434    pub enroll_assertion_file: Option<String>,
1435    #[serde(default)]
1436    pub person_server: Option<String>,
1437}
1438
1439#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1440#[serde(deny_unknown_fields, default)]
1441pub struct Cgroup {
1442    pub spec: Option<String>,
1443    pub memory_max: Option<String>,
1444    pub pids_max: Option<String>,
1445}
1446
1447impl Settings {
1448    /// Type a settings document. `source` names it in errors.
1449    pub fn from_document(doc: Value, source: &str) -> Result<Settings, String> {
1450        serde_json::from_value(doc).map_err(|e| format!("{source} parse error: {e}"))
1451    }
1452
1453    /// The `agent.name` fallback chain: config › downward-API instance ›
1454    /// hostname › `agentd`.
1455    pub fn instance_name(&self) -> String {
1456        if let Some(n) = &self.agent.name {
1457            return n.clone();
1458        }
1459        let id =
1460            crate::identity::Identity::from_env(self.lifecycle.run_id.as_deref().unwrap_or(""));
1461        if let Some(inst) = id.instance.filter(|i| !i.trim().is_empty()) {
1462            return inst;
1463        }
1464        std::env::var("HOSTNAME")
1465            .ok()
1466            .filter(|h| !h.trim().is_empty())
1467            .unwrap_or_else(|| "agentd".to_string())
1468    }
1469
1470    /// Whether this instance OUTLIVES a single run — it serves A2A or webhooks,
1471    /// watches a goal, or owns a workflow with a long-lived start node
1472    /// (`loop`/`schedule`/`subscribe`/`signal`/`event`/`a2a`/`webhook`).
1473    ///
1474    /// This is the durability predicate (RFC 0025 §durability, RFC 0033 §5): a
1475    /// job-shaped run can lose its state and simply be re-run, an instance that
1476    /// keeps running cannot. Two callers need the same answer — [`load`], which
1477    /// defaults such an instance to the file store, and [`validate`], which
1478    /// refuses an EXPLICIT `store.kind: none` here — so the predicate lives in
1479    /// one place rather than being spelled twice and drifting.
1480    pub fn is_long_lived(&self) -> bool {
1481        self.a2a.listen.is_some()
1482            || self.webhooks.listen.is_some()
1483            || self.goal.is_some()
1484            || self.workflows.iter().any(workflow_is_long_lived)
1485    }
1486}
1487
1488// ---------------------------------------------------------------------------
1489// Detection
1490// ---------------------------------------------------------------------------
1491
1492/// v2-only top-level keys (RFC 0030 §2). `limits` exists in both schemas
1493/// (neutral); `intelligence` is a v1 STRING (the endpoint list) but a v2
1494/// OBJECT — decided by shape in [`detect`].
1495pub const V2_KEYS: &[&str] = &[
1496    "agent",
1497    "store",
1498    "workflows",
1499    "tools",
1500    "a2a",
1501    "lifecycle",
1502    "observability",
1503    "security",
1504    "knowledge",
1505    "search",
1506    "skills",
1507    "memory",
1508    "context",
1509];
1510
1511/// v1 (flat) top-level keys.
1512pub const V1_KEYS: &[&str] = &[
1513    "intelligence_headers",
1514    "model_swap",
1515    "model",
1516    "max_tokens",
1517    "mcp_servers",
1518    "subscribe",
1519    "a2a_peers",
1520    "log_level",
1521];
1522
1523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1524pub enum Detected {
1525    /// No document at all (no config files).
1526    Empty,
1527    /// The v1 flat schema.
1528    V1,
1529    /// The v2 nested schema.
1530    V2,
1531    /// Both key families present — refused.
1532    Mixed,
1533}
1534
1535/// Decide which schema a merged document speaks.
1536pub fn detect(doc: &Value) -> Detected {
1537    let Some(obj) = doc.as_object() else {
1538        return Detected::Empty;
1539    };
1540    if obj.is_empty() {
1541        return Detected::Empty;
1542    }
1543    let version = obj.get("config_version").and_then(Value::as_str);
1544    let intel_is_object = obj.get("intelligence").is_some_and(Value::is_object);
1545    let intel_is_string = obj.get("intelligence").is_some_and(Value::is_string);
1546    let has_v2 = version == Some(schema::CONFIG_VERSION)
1547        || intel_is_object
1548        || obj.keys().any(|k| V2_KEYS.contains(&k.as_str()));
1549    let has_v1 = intel_is_string
1550        || obj.keys().any(|k| V1_KEYS.contains(&k.as_str()))
1551        || matches!(version, Some(v) if v != schema::CONFIG_VERSION);
1552    match (has_v1, has_v2) {
1553        (true, true) => Detected::Mixed,
1554        (false, true) => Detected::V2,
1555        (true, false) => Detected::V1,
1556        // Only `config_version` absent + neither family (e.g. `{}` with a
1557        // comment) or `intelligence`… every key was matched above; anything
1558        // else is a v1 document for the v1 loader to judge.
1559        (false, false) => Detected::V1,
1560    }
1561}
1562
1563// ---------------------------------------------------------------------------
1564// Aliases (RFC 0030 §3 alias column + §7)
1565// ---------------------------------------------------------------------------
1566
1567/// How a legacy flag maps onto the document.
1568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1569pub enum AliasKind {
1570    /// `--flag <value>` sets `path` (typed by the schema binding of `path`).
1571    Set,
1572    /// `--flag` (no value) sets `path` to `true`.
1573    SetTrue,
1574    /// `--flag <value>` appends a parsed element to the array at `path`.
1575    Append,
1576    /// `--flag <value>` reads the FILE at `<value>` and sets `path` to its text.
1577    SetFromFile,
1578    /// Handled by dedicated code (`--mcp-tags`, `--budget-exit-code`).
1579    Special,
1580}
1581
1582/// A legacy flag → v2 path alias.
1583#[derive(Debug, Clone, Copy)]
1584pub struct Alias {
1585    pub flag: &'static str,
1586    pub path: &'static str,
1587    pub kind: AliasKind,
1588}
1589
1590/// The alias table (RFC 0030 §3). Order irrelevant; flags apply in argument
1591/// order.
1592pub const ALIASES: &[Alias] = &[
1593    Alias {
1594        flag: "--instruction",
1595        path: "agent.instruction",
1596        kind: AliasKind::Set,
1597    },
1598    Alias {
1599        flag: "--instruction-file",
1600        path: "agent.instruction",
1601        kind: AliasKind::SetFromFile,
1602    },
1603    Alias {
1604        flag: "--prompt",
1605        path: "agent.prompt",
1606        kind: AliasKind::Set,
1607    },
1608    Alias {
1609        flag: "--prompt-file",
1610        path: "agent.prompt",
1611        kind: AliasKind::SetFromFile,
1612    },
1613    Alias {
1614        flag: "--intelligence",
1615        path: "intelligence.endpoints",
1616        kind: AliasKind::Set,
1617    },
1618    Alias {
1619        flag: "--intelligence-token",
1620        path: "intelligence.token",
1621        kind: AliasKind::Set,
1622    },
1623    Alias {
1624        flag: "--intelligence-token-file",
1625        path: "intelligence.token_file",
1626        kind: AliasKind::Set,
1627    },
1628    Alias {
1629        flag: "--model",
1630        path: "intelligence.model",
1631        kind: AliasKind::Set,
1632    },
1633    Alias {
1634        flag: "--model-swap",
1635        path: "intelligence.swap_policy",
1636        kind: AliasKind::Set,
1637    },
1638    Alias {
1639        flag: "--budget-tokens-lifetime",
1640        path: "intelligence.budget.lifetime_tokens",
1641        kind: AliasKind::Set,
1642    },
1643    Alias {
1644        flag: "--mcp",
1645        path: "mcp.servers",
1646        kind: AliasKind::Append,
1647    },
1648    Alias {
1649        flag: "--mcp-tags",
1650        path: "mcp.servers",
1651        kind: AliasKind::Special,
1652    },
1653    Alias {
1654        flag: "--a2a-peer",
1655        path: "a2a.peers",
1656        kind: AliasKind::Append,
1657    },
1658    Alias {
1659        flag: "--workflow",
1660        path: "workflows",
1661        kind: AliasKind::Append,
1662    },
1663    Alias {
1664        flag: "--max-steps",
1665        path: "limits.run.steps",
1666        kind: AliasKind::Set,
1667    },
1668    Alias {
1669        flag: "--max-tokens",
1670        path: "limits.run.tokens",
1671        kind: AliasKind::Set,
1672    },
1673    Alias {
1674        flag: "--deadline",
1675        path: "limits.run.deadline",
1676        kind: AliasKind::Set,
1677    },
1678    Alias {
1679        flag: "--max-depth",
1680        path: "limits.subagents.depth",
1681        kind: AliasKind::Set,
1682    },
1683    Alias {
1684        flag: "--run-id",
1685        path: "lifecycle.run_id",
1686        kind: AliasKind::Set,
1687    },
1688    Alias {
1689        flag: "--drain-timeout",
1690        path: "lifecycle.drain_timeout",
1691        kind: AliasKind::Set,
1692    },
1693    Alias {
1694        flag: "--watch-config",
1695        path: "lifecycle.watch_config",
1696        kind: AliasKind::SetTrue,
1697    },
1698    Alias {
1699        flag: "--budget-exit-code",
1700        path: "lifecycle.exit_code_map",
1701        kind: AliasKind::Special,
1702    },
1703    Alias {
1704        flag: "--listen",
1705        path: "a2a.listen",
1706        kind: AliasKind::Set,
1707    },
1708    Alias {
1709        flag: "--serve-mcp",
1710        path: "a2a.listen",
1711        kind: AliasKind::Set,
1712    },
1713    Alias {
1714        flag: "--serve-cert",
1715        path: "a2a.tls.cert",
1716        kind: AliasKind::Set,
1717    },
1718    Alias {
1719        flag: "--serve-key",
1720        path: "a2a.tls.key",
1721        kind: AliasKind::Set,
1722    },
1723    Alias {
1724        flag: "--serve-client-ca",
1725        path: "a2a.tls.client_ca",
1726        kind: AliasKind::Set,
1727    },
1728    Alias {
1729        flag: "--serve-bearer",
1730        path: "a2a.bearer",
1731        kind: AliasKind::Set,
1732    },
1733    Alias {
1734        flag: "--log-level",
1735        path: "observability.log_level",
1736        kind: AliasKind::Set,
1737    },
1738    Alias {
1739        flag: "--log-content",
1740        path: "observability.log_content",
1741        kind: AliasKind::SetTrue,
1742    },
1743    Alias {
1744        flag: "--metrics-addr",
1745        path: "observability.metrics_addr",
1746        kind: AliasKind::Set,
1747    },
1748    Alias {
1749        flag: "--health-file",
1750        path: "observability.health_file",
1751        kind: AliasKind::Set,
1752    },
1753    Alias {
1754        flag: "--report-file",
1755        path: "observability.report_file",
1756        kind: AliasKind::Set,
1757    },
1758    Alias {
1759        flag: "--events-ring",
1760        path: "observability.events_ring",
1761        kind: AliasKind::Set,
1762    },
1763    Alias {
1764        flag: "--traceparent",
1765        path: "observability.traceparent",
1766        kind: AliasKind::Set,
1767    },
1768    Alias {
1769        flag: "--allow-trifecta",
1770        path: "security.allow_trifecta",
1771        kind: AliasKind::SetTrue,
1772    },
1773    Alias {
1774        flag: "--tls-ca",
1775        path: "security.tls_ca",
1776        kind: AliasKind::Set,
1777    },
1778    Alias {
1779        flag: "--aauth-provider",
1780        path: "security.aauth.provider",
1781        kind: AliasKind::Set,
1782    },
1783    Alias {
1784        flag: "--aauth-key-file",
1785        path: "security.aauth.key_file",
1786        kind: AliasKind::Set,
1787    },
1788    Alias {
1789        flag: "--aauth-enroll-token",
1790        path: "security.aauth.enroll_token",
1791        kind: AliasKind::Set,
1792    },
1793    Alias {
1794        flag: "--aauth-enroll-assertion-file",
1795        path: "security.aauth.enroll_assertion_file",
1796        kind: AliasKind::Set,
1797    },
1798    Alias {
1799        flag: "--aauth-person-server",
1800        path: "security.aauth.person_server",
1801        kind: AliasKind::Set,
1802    },
1803    Alias {
1804        flag: "--cgroup",
1805        path: "security.cgroup.spec",
1806        kind: AliasKind::Set,
1807    },
1808    Alias {
1809        flag: "--cgroup-memory-max",
1810        path: "security.cgroup.memory_max",
1811        kind: AliasKind::Set,
1812    },
1813    Alias {
1814        flag: "--cgroup-pids-max",
1815        path: "security.cgroup.pids_max",
1816        kind: AliasKind::Set,
1817    },
1818];
1819
1820/// Legacy env names → v2 paths (the derived `AGENTD_<PATH>` names are the
1821/// primary surface; these keep the quickstart and the 1.x k8s manifests
1822/// working). Branded (`AGENTD_`) and neutral (`AGENT_`) prefixes both apply.
1823pub const ENV_ALIASES: &[(&str, &str)] = &[
1824    ("INSTRUCTION", "agent.instruction"),
1825    ("PROMPT", "agent.prompt"),
1826    ("INTELLIGENCE", "intelligence.endpoints"),
1827    ("INTELLIGENCE_TOKEN", "intelligence.token"),
1828    ("INTELLIGENCE_TOKEN_FILE", "intelligence.token_file"),
1829    ("MODEL", "intelligence.model"),
1830    ("MODEL_SWAP", "intelligence.swap_policy"),
1831    ("BUDGET_TOKENS", "intelligence.budget.lifetime_tokens"),
1832    ("MAX_STEPS", "limits.run.steps"),
1833    ("MAX_TOKENS", "limits.run.tokens"),
1834    ("DEADLINE", "limits.run.deadline"),
1835    ("RUN_ID", "lifecycle.run_id"),
1836    ("DRAIN_TIMEOUT", "lifecycle.drain_timeout"),
1837    ("LOG_LEVEL", "observability.log_level"),
1838    ("LOG_CONTENT", "observability.log_content"),
1839    ("METRICS_ADDR", "observability.metrics_addr"),
1840    ("TRACEPARENT", "observability.traceparent"),
1841    ("SERVE_MCP", "a2a.listen"),
1842    ("SERVE_BEARER", "a2a.bearer"),
1843    ("TLS_CA", "security.tls_ca"),
1844    ("ALLOW_TRIFECTA", "security.allow_trifecta"),
1845    ("WATCH_CONFIG", "lifecycle.watch_config"),
1846];
1847
1848/// Flags removed in 2.0 with the migration hint (RFC 0030 §7).
1849pub const REMOVED_FLAGS: &[(&str, &str)] = &[
1850    (
1851        "--mode",
1852        "modes are gone: give the workflow a start node (`once` | `loop` | `schedule` | `subscribe` | `signal` | `event` | `a2a` | `manual`) and set `lifecycle.run_until` if needed",
1853    ),
1854    (
1855        "--subscribe",
1856        "use a `subscribe` start node: `{kind: subscribe, server: <name>, uri: <uri>}`",
1857    ),
1858    (
1859        "--continue",
1860        "use a `subscribe` start node with `deliver: wait` (or a warm subagent)",
1861    ),
1862    (
1863        "--interval",
1864        "use a `loop` start node with `interval`, or a `schedule` start node with `every`",
1865    ),
1866    ("--cron", "use a `schedule` start node with `cron`"),
1867    // Clustering was removed, not migrated: agentd has no coordination protocol
1868    // of its own. A fleet partitions upstream — one subscription per replica, or
1869    // the queue's own lease semantics from a workflow step (docs/scaling.md).
1870    (
1871        "--shard",
1872        "agentd does not partition work; give each replica its own subscription (docs/scaling.md)",
1873    ),
1874    (
1875        "--claim",
1876        "call the queue's own claim/lease tools from a workflow step (docs/scaling.md §2c)",
1877    ),
1878    ("--claim-ttl", "it went with --claim"),
1879    ("--claim-renew-fraction", "it went with --claim"),
1880    (
1881        "--standby",
1882        "there is no standby pool; a worker replica is an ordinary instance with its own subscription",
1883    ),
1884    ("--assign-from", "it went with --standby"),
1885    (
1886        "--workflow-resume",
1887        "automatic: runs resume from the store on restart (`resume_policy` per workflow)",
1888    ),
1889    (
1890        "--workflow-resume-force",
1891        "set `resume_policy: force` on the workflow",
1892    ),
1893];
1894
1895// ---------------------------------------------------------------------------
1896// Load pipeline
1897// ---------------------------------------------------------------------------
1898
1899/// The result of a v2 load: the typed settings plus the documents they came
1900/// from (the merged FILE document is kept for secret-provenance validation and
1901/// for the reload diff).
1902#[derive(Debug, Clone)]
1903pub struct Loaded {
1904    pub settings: Settings,
1905    /// The effective document (files ← env ← flags), what `Settings` typed.
1906    pub doc: Value,
1907    /// The merged FILE layer alone (before env/flags).
1908    pub file_doc: Value,
1909    pub files: Vec<(String, Format)>,
1910    /// Every non-fatal advisory collected during load (surfaced by
1911    /// `--validate-config` and logged at startup).
1912    pub warnings: Vec<String>,
1913}
1914
1915/// What the loader was asked to do besides loading (short-circuits the CLI
1916/// handles).
1917#[derive(Debug, Clone, PartialEq, Eq)]
1918pub enum Ask {
1919    Run,
1920    Help,
1921    Version,
1922    Schema,
1923    WorkflowSchema,
1924    Validate,
1925    Capabilities,
1926    /// `--login <target>` (RFC 0031 §12): complete the interactive OAuth device
1927    /// flow for a configured endpoint and cache the token.
1928    Login(String),
1929    /// `--logout <target>`: evict a cached credential.
1930    Logout(String),
1931}
1932
1933/// Probe the invocation without side effects: which schema the config files
1934/// speak (`Detected`), so `main` can route to the v2 runtime.
1935pub fn probe(args: &[String], env: &[(String, String)]) -> Result<Detected, ConfigError> {
1936    let env = super::debrand_env(env);
1937    let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
1938    // A flag/env `config_version: "2"` selects the 2.0 runtime for a flag-only
1939    // invocation (`agentd --config-version 2 --instruction …`).
1940    let flag_v2 = args
1941        .windows(2)
1942        .any(|w| matches!(w[0].as_str(), "--config-version" | "--config_version") && w[1] == "2")
1943        || args
1944            .iter()
1945            .any(|a| a == "--config-version=2" || a == "--config_version=2")
1946        || envmap
1947            .get("AGENTD_CONFIG_VERSION")
1948            .or_else(|| envmap.get("CONFIG_VERSION"))
1949            .is_some_and(|v| *v == "2");
1950    let paths = super::config_paths_from_map(args, &envmap).paths;
1951    if paths.is_empty() {
1952        return Ok(if flag_v2 {
1953            Detected::V2
1954        } else {
1955            Detected::Empty
1956        });
1957    }
1958    let (doc, _) = file::read_documents_checked(&paths, &|_, _| Ok(())).map_err(usage)?;
1959    let d = detect(&doc);
1960    Ok(match (d, flag_v2) {
1961        (Detected::Empty, true) => Detected::V2,
1962        (Detected::V1, true) => Detected::Mixed,
1963        (d, _) => d,
1964    })
1965}
1966
1967/// Load, layer and validate a v2 document from `args` (excluding the program
1968/// name) and `env`. Returns `(Loaded, Ask)`; `Ask` tells the caller what the
1969/// invocation wants (`--help`, `--config-schema`, `--validate-config`, …).
1970/// Errors are `ConfigError::Usage` (exit 2), before any side effect.
1971pub fn load(args: &[String], env: &[(String, String)]) -> Result<(Loaded, Ask), ConfigError> {
1972    let env = super::debrand_env(env);
1973    let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
1974    let schema = schema::schema();
1975    let bindings = paths::bindings_of(&schema);
1976    let mut warnings = Vec::new();
1977
1978    // --- FILE layer: several files, later wins (JSON Merge Patch) ---
1979    let super::ConfigPaths {
1980        paths: config_paths,
1981        discovered,
1982    } = super::config_paths_from_map(args, &envmap);
1983    // Two discovered spellings at once: refuse rather than pick. Whichever one
1984    // agentd chose, somebody would be editing the other and wondering why
1985    // nothing changed. Only DISCOVERY is ambiguous this way — naming two files
1986    // that happen to be spelled `.agentd.yml` and `.agentd.yaml` (`--config a/.agentd.yml
1987    // --config b/.agentd.yaml`) states an order, so layering them is legal.
1988    if discovered && config_paths.len() > 1 {
1989        return Err(usage(format!(
1990            "both {} and {} are present; keep one (or name the file with --config)",
1991            super::DISCOVERED_CONFIG_NAMES[0],
1992            super::DISCOVERED_CONFIG_NAMES[1]
1993        )));
1994    }
1995    let (file_doc, files) = if config_paths.is_empty() {
1996        (Value::Object(Map::new()), Vec::new())
1997    } else {
1998        file::read_documents_checked(&config_paths, &|doc, source| {
1999            // A v1/mixed file is judged after the merge (a clear migration
2000            // message); a v2 file is typed here so an unknown key names ITS file.
2001            match detect(doc) {
2002                Detected::V2 | Detected::Empty => {
2003                    Settings::from_document(doc.clone(), source).map(|_| ())
2004                }
2005                _ => Ok(()),
2006            }
2007        })
2008        .map_err(usage)?
2009    };
2010    match detect(&file_doc) {
2011        Detected::Mixed => {
2012            return Err(usage(
2013                "config file mixes v1 keys (model/subscribe/mcp_servers/…) with v2 sections (agent/intelligence/…); \
2014                 migrate the v1 keys (docs/configuration.md §migration)"
2015                    .into(),
2016            ));
2017        }
2018        Detected::V1 => {
2019            return Err(usage(
2020                "config file speaks the v1 schema; the 2.0 loader needs `config_version: \"2\"` or v2 sections".into(),
2021            ));
2022        }
2023        _ => {}
2024    }
2025    // A DISCOVERED config governs an invocation that never named it: `cd` into a
2026    // repo you cloned, type `agentd --prompt …`, and that repo's `.agentd.yml`
2027    // decides where your credentials go. Convenience is worth that only while the
2028    // file cannot RELAX a security control, so an unnamed file setting one is
2029    // exit 2 with the file and the setting named. An explicit `--config` keeps
2030    // its full power — naming the file IS the deliberate act, and that is the
2031    // whole distinction being drawn here.
2032    if discovered {
2033        let file = config_paths.first().map_or("", String::as_str);
2034        if let Some((_, label)) = DISCOVERY_FORBIDDEN_RELAXATIONS
2035            .iter()
2036            .find(|(ptr, _)| file_doc.pointer(ptr).and_then(Value::as_bool) == Some(true))
2037        {
2038            return Err(usage(format!(
2039                "{file} was discovered, not named, and it sets {label}: a config found in the \
2040                 working directory may not relax a security control. Pass `--config {file}` if \
2041                 you meant to run under that file's grant."
2042            )));
2043        }
2044        // …and whatever else it wired that bears on security is named at startup
2045        // (option (c) of the containment): an adopted dotfile is never silent
2046        // about the endpoints, peers and powers it just chose for this process.
2047        let touched = discovered_security_settings(&file_doc);
2048        if !touched.is_empty() {
2049            warnings.push(format!(
2050                "adopted the discovered config {file} (no --config given); it sets {}",
2051                touched.join(", ")
2052            ));
2053        }
2054    }
2055    let mut doc = file_doc.clone();
2056
2057    // --- ENV layer: derived path names, then legacy aliases (path names win) ---
2058    let mut env_doc = Value::Object(Map::new());
2059    for (name, path) in ENV_ALIASES {
2060        let candidates = [
2061            format!("AGENTD_{name}"),
2062            format!("AGENT_{name}"),
2063            (*name).to_string(),
2064        ];
2065        if let Some(raw) = candidates.iter().find_map(|k| envmap.get(k.as_str())) {
2066            let binding = binding_for(&bindings, path)
2067                .ok_or_else(|| usage(format!("internal: alias path {path} not in schema")))?;
2068            let v = binding
2069                .coerce(raw)
2070                .map_err(|e| usage(format!("invalid {}: {e}", candidates[0])))?;
2071            paths::set_path(&mut env_doc, path, v);
2072        }
2073    }
2074    let (derived, _applied) = paths::env_document_in(&bindings, &envmap).map_err(usage)?;
2075    file::merge_into(&mut env_doc, derived);
2076    file::merge_into(&mut doc, env_doc);
2077
2078    // --- FLAG layer: aliases + generic path flags, in argument order ---
2079    let mut ask = Ask::Run;
2080    let mut mcp_tags: Vec<(String, Vec<String>)> = Vec::new();
2081    let mut it = args.iter().peekable();
2082    while let Some(arg) = it.next() {
2083        let a = arg.as_str();
2084        match a {
2085            "-h" | "--help" => ask = Ask::Help,
2086            "-V" | "--version" => ask = Ask::Version,
2087            "--config-schema" | "--config-schema=2" => ask = Ask::Schema,
2088            "--workflow-schema" => ask = Ask::WorkflowSchema,
2089            "--validate-config" => ask = Ask::Validate,
2090            "--capabilities" => ask = Ask::Capabilities,
2091            "--login" => {
2092                let t = it
2093                    .next()
2094                    .cloned()
2095                    .ok_or_else(|| usage("--login requires a target (e.g. mcp:<name>)".into()))?;
2096                ask = Ask::Login(t);
2097            }
2098            "--logout" => {
2099                let t = it
2100                    .next()
2101                    .cloned()
2102                    .ok_or_else(|| usage("--logout requires a target (e.g. mcp:<name>)".into()))?;
2103                ask = Ask::Logout(t);
2104            }
2105            "--config" | "-c" => {
2106                it.next(); // consumed by the FILE layer
2107            }
2108            // `--config=a.yaml` / `-c=a.yaml`: the FILE layer already took it.
2109            _ if matches!(
2110                crate::config::config_flag(a),
2111                crate::config::ConfigFlag::Inline(_)
2112            ) => {}
2113            _ => {
2114                if let Some((flag, hint)) = REMOVED_FLAGS.iter().find(|(f, _)| *f == a) {
2115                    return Err(usage(format!("{flag} was removed in agentd 2.0: {hint}")));
2116                }
2117                if let Some(alias) = ALIASES.iter().find(|al| al.flag == a) {
2118                    apply_alias(&mut doc, &bindings, alias, &mut it, &mut mcp_tags)?;
2119                    continue;
2120                }
2121                match paths::resolve_flag_in(&bindings, a).map_err(usage)? {
2122                    Some(target) => {
2123                        let raw = if matches!(target.value_kind(), paths::Kind::Boolean)
2124                            && !it.peek().is_some_and(|n| !n.starts_with("--"))
2125                        {
2126                            "true".to_string()
2127                        } else {
2128                            it.next()
2129                                .cloned()
2130                                .ok_or_else(|| usage(format!("{a} requires a value")))?
2131                        };
2132                        let value = paths::coerce(target.value_kind(), &raw)
2133                            .map_err(|e| usage(format!("invalid {a}: {e}")))?;
2134                        file::merge_into(&mut doc, target.document(value));
2135                    }
2136                    None => return Err(usage(format!("unknown argument: {a}"))),
2137                }
2138            }
2139        }
2140    }
2141    // `--mcp-tags name=tags` after every `--mcp` is known.
2142    for (name, tags) in mcp_tags {
2143        let Some(servers) = doc
2144            .pointer_mut("/mcp/servers")
2145            .and_then(Value::as_array_mut)
2146        else {
2147            return Err(usage(format!(
2148                "--mcp-tags references unknown server '{name}'"
2149            )));
2150        };
2151        match servers
2152            .iter_mut()
2153            .find(|s| s.get("name").and_then(Value::as_str) == Some(name.as_str()))
2154        {
2155            Some(s) => {
2156                s["tags"] = json!({ "*": tags });
2157            }
2158            None => {
2159                return Err(usage(format!(
2160                    "--mcp-tags references unknown server '{name}'"
2161                )));
2162            }
2163        }
2164    }
2165
2166    // --- sugar: `agentd --instruction X` with no workflows ---
2167    if ask == Ask::Run || ask == Ask::Validate {
2168        apply_instruction_sugar(&mut doc);
2169    }
2170
2171    // --- env substitution: `${VAR}` / `${VAR:-default}` in any string value of
2172    //     the merged document (config + workflows), from the process env. Distinct
2173    //     from `{{secret:…}}` (which resolves a redacted credential). ---
2174    if let Err(e) = substitute_env(&mut doc, &envmap) {
2175        return Err(usage(e));
2176    }
2177
2178    // --- type + validate ---
2179    let mut settings = Settings::from_document(doc.clone(), "config").map_err(usage)?;
2180    // --- RFC 0033 §5: durability a laptop already satisfies ---
2181    //
2182    // A long-lived instance that names no store used to exit 2 and ask for a
2183    // coordination backend before the operator had run anything. It now gets the
2184    // FILE adapter: durable to whatever filesystem it lands on, and the runtime
2185    // says so at startup (`store.file`) rather than implying more (§5.1).
2186    //
2187    // "Absent" is read off the effective DOCUMENT, not off `settings.store.kind`
2188    // — `StoreKind` derives `Default = None`, so the typed value cannot tell a
2189    // config that said nothing from one that said `none`. `doc` is the merged
2190    // file ← env ← flag layers, so `--store-kind none` / `AGENTD_STORE_KIND=none`
2191    // count as explicit exactly like the YAML key does. That distinction is the
2192    // whole point: an operator who WROTE `none` on a long-lived instance still
2193    // gets the diagnostic (validate, below), because silently overriding a
2194    // stated choice is worse than refusing to start.
2195    //
2196    // A one-shot instance is deliberately untouched and keeps `none`: a job that
2197    // suddenly began writing state to disk would surprise every existing user of
2198    // it, and re-running it is already the recovery story.
2199    if doc.pointer("/store/kind").is_none() && settings.is_long_lived() {
2200        settings.store.kind = StoreKind::File;
2201    }
2202    let mut loaded = Loaded {
2203        settings,
2204        doc,
2205        file_doc,
2206        files,
2207        warnings: Vec::new(),
2208    };
2209    let diags = validate(&loaded);
2210    warnings.extend(diags.warnings);
2211    loaded.warnings = warnings;
2212    if ask != Ask::Validate
2213        && ask != Ask::Help
2214        && ask != Ask::Version
2215        && ask != Ask::Schema
2216        && ask != Ask::WorkflowSchema
2217        && !matches!(ask, Ask::Login(_) | Ask::Logout(_))
2218        && let Some(first) = diags.errors.first()
2219    {
2220        return Err(usage(first.clone()));
2221    }
2222    if ask == Ask::Validate && !diags.errors.is_empty() {
2223        return Err(ConfigError::Validate(Err(diags
2224            .errors
2225            .iter()
2226            .map(|d| super::config_invalid_line(d))
2227            .collect::<Vec<_>>()
2228            .join("\n"))));
2229    }
2230    Ok((loaded, ask))
2231}
2232
2233/// The security controls a config file can **relax** — the two booleans that
2234/// widen what this process may do (lift the lethal-trifecta refusal, RFC 0012
2235/// §3.2; turn on the local command runner, RFC 0028 §exec). A file the operator
2236/// NAMED may set them; a file merely discovered in the working directory may
2237/// not. Narrowing settings are deliberately absent: a dotfile that takes power
2238/// away needs no ceremony.
2239const DISCOVERY_FORBIDDEN_RELAXATIONS: [(&str, &str); 2] = [
2240    ("/security/allow_trifecta", "security.allow_trifecta"),
2241    ("/security/exec/enabled", "security.exec.enabled"),
2242];
2243
2244/// The settings that decide where this agent's credentials go, who may reach
2245/// it, and what it may call. A DISCOVERED config that sets any of them has them
2246/// named in a startup `config.warning`, so adopting a dotfile is visible in the
2247/// log rather than inferred from behaviour. Pointer + the dotted label to print:
2248/// NAMES only, never values (a value may be a `{{secret:…}}` template — RFC 0012
2249/// §3.7).
2250const DISCOVERY_SECURITY_SETTINGS: [(&str, &str); 12] = [
2251    ("/intelligence/endpoints", "intelligence.endpoints"),
2252    ("/intelligence/token", "intelligence.token"),
2253    ("/intelligence/token_file", "intelligence.token_file"),
2254    ("/intelligence/headers", "intelligence.headers"),
2255    ("/intelligence/auth", "intelligence.auth"),
2256    ("/mcp/servers", "mcp.servers"),
2257    ("/tools/overrides", "tools.overrides"),
2258    ("/store", "store"),
2259    ("/a2a/listen", "a2a.listen"),
2260    ("/a2a/peers", "a2a.peers"),
2261    ("/webhooks/listen", "webhooks.listen"),
2262    ("/security", "security"),
2263];
2264
2265/// Which of [`DISCOVERY_SECURITY_SETTINGS`] the file layer actually set, in
2266/// declaration order. `null` counts as unset (RFC 7396 unsets with `null`).
2267fn discovered_security_settings(file_doc: &Value) -> Vec<&'static str> {
2268    DISCOVERY_SECURITY_SETTINGS
2269        .iter()
2270        .filter(|(ptr, _)| file_doc.pointer(ptr).is_some_and(|v| !v.is_null()))
2271        .map(|(_, label)| *label)
2272        .collect()
2273}
2274
2275fn binding_for<'a>(bindings: &'a [Binding], path: &str) -> Option<&'a Binding> {
2276    bindings.iter().find(|b| b.path == path)
2277}
2278
2279fn apply_alias(
2280    doc: &mut Value,
2281    bindings: &[Binding],
2282    alias: &Alias,
2283    it: &mut std::iter::Peekable<std::slice::Iter<'_, String>>,
2284    mcp_tags: &mut Vec<(String, Vec<String>)>,
2285) -> Result<(), ConfigError> {
2286    let mut take = || -> Result<String, ConfigError> {
2287        it.next()
2288            .cloned()
2289            .ok_or_else(|| usage(format!("{} requires a value", alias.flag)))
2290    };
2291    match alias.kind {
2292        AliasKind::Set => {
2293            let raw = take()?;
2294            let b = binding_for(bindings, alias.path).ok_or_else(|| {
2295                usage(format!("internal: alias path {} not in schema", alias.path))
2296            })?;
2297            let v = b
2298                .coerce(&raw)
2299                .map_err(|e| usage(format!("invalid {}: {e}", alias.flag)))?;
2300            let mut patch = Value::Object(Map::new());
2301            paths::set_path(&mut patch, alias.path, v);
2302            file::merge_into(doc, patch);
2303        }
2304        AliasKind::SetTrue => {
2305            let mut patch = Value::Object(Map::new());
2306            paths::set_path(&mut patch, alias.path, Value::Bool(true));
2307            file::merge_into(doc, patch);
2308        }
2309        AliasKind::SetFromFile => {
2310            let path = take()?;
2311            let text = super::read_file(&path)?;
2312            let mut patch = Value::Object(Map::new());
2313            paths::set_path(&mut patch, alias.path, Value::String(text));
2314            file::merge_into(doc, patch);
2315        }
2316        AliasKind::Append => {
2317            let raw = take()?;
2318            let element = match alias.flag {
2319                "--mcp" => {
2320                    let (name, endpoint) = raw
2321                        .split_once('=')
2322                        .ok_or_else(|| usage(format!("--mcp: want name=endpoint (got: {raw})")))?;
2323                    json!({ "name": name.trim(), "endpoint": endpoint.trim() })
2324                }
2325                "--a2a-peer" => {
2326                    let (name, endpoint) = raw.split_once('=').ok_or_else(|| {
2327                        usage(format!("--a2a-peer: want name=endpoint (got: {raw})"))
2328                    })?;
2329                    json!({ "name": name.trim(), "endpoint": endpoint.trim() })
2330                }
2331                "--workflow" => {
2332                    let name = std::path::Path::new(&raw)
2333                        .file_stem()
2334                        .and_then(|s| s.to_str())
2335                        .unwrap_or("workflow")
2336                        .to_string();
2337                    json!({ "name": name, "file": raw })
2338                }
2339                other => return Err(usage(format!("internal: no append rule for {other}"))),
2340            };
2341            append_at(doc, alias.path, element);
2342        }
2343        AliasKind::Special => match alias.flag {
2344            "--mcp-tags" => {
2345                let raw = take()?;
2346                let (name, tags) = raw
2347                    .split_once('=')
2348                    .ok_or_else(|| usage(format!("--mcp-tags: want name=tag,tag (got: {raw})")))?;
2349                mcp_tags.push((
2350                    name.trim().to_string(),
2351                    tags.split(',')
2352                        .map(str::trim)
2353                        .filter(|t| !t.is_empty())
2354                        .map(str::to_string)
2355                        .collect(),
2356                ));
2357            }
2358            "--budget-exit-code" => {
2359                let raw = take()?;
2360                let n: i64 = raw
2361                    .trim()
2362                    .parse()
2363                    .ok()
2364                    .filter(|n| (0..=255).contains(n))
2365                    .ok_or_else(|| {
2366                        usage(format!("invalid --budget-exit-code: {raw} (want 0..=255)"))
2367                    })?;
2368                let mut patch = Value::Object(Map::new());
2369                paths::set_path(
2370                    &mut patch,
2371                    "lifecycle.exit_code_map",
2372                    json!({ "3": n, "7": n }),
2373                );
2374                file::merge_into(doc, patch);
2375            }
2376            other => return Err(usage(format!("internal: no special rule for {other}"))),
2377        },
2378    }
2379    Ok(())
2380}
2381
2382/// Push `element` onto the array at dotted `path` (creating it).
2383fn append_at(doc: &mut Value, path: &str, element: Value) {
2384    let pointer = format!("/{}", path.replace('.', "/"));
2385    if doc.pointer(&pointer).is_none() {
2386        let mut patch = Value::Object(Map::new());
2387        paths::set_path(&mut patch, path, Value::Array(Vec::new()));
2388        file::merge_into(doc, patch);
2389    }
2390    if let Some(arr) = doc.pointer_mut(&pointer) {
2391        if !arr.is_array() {
2392            *arr = Value::Array(Vec::new());
2393        }
2394        arr.as_array_mut().expect("array").push(element);
2395    }
2396}
2397
2398/// `agentd --instruction X` (or `agent.instruction` alone) with no workflows ⇒
2399/// the one-node workflow `once → agent → finish` (RFC 0030 §7).
2400///
2401/// A `--prompt` deliberately does NOT come here: a prompt is a **message to
2402/// the agent**, delivered into its root context at startup, not a canned
2403/// workflow step. That is what lets it set itself up — workflow-authoring
2404/// tools are root-scoped, so a prompt running as a step could never define the
2405/// loop/schedule it was asked for (`Caller::Workflow` vs `Caller::Root` in
2406/// the registry).
2407fn apply_instruction_sugar(doc: &mut Value) {
2408    let has_workflows = doc
2409        .pointer("/workflows")
2410        .and_then(Value::as_array)
2411        .is_some_and(|w| !w.is_empty());
2412    let nonblank = |p: &str| {
2413        doc.pointer(p)
2414            .and_then(Value::as_str)
2415            .is_some_and(|s| !s.trim().is_empty())
2416    };
2417    let has_instruction = nonblank("/agent/instruction");
2418    // A prompt runs as a root turn, so an instruction+prompt pair needs no
2419    // sugar workflow at all — the prompt IS the job.
2420    if has_workflows || !has_instruction || nonblank("/agent/prompt") {
2421        return;
2422    }
2423    let work = json!({
2424        "kind": "agent",
2425        "depends_on": ["start"],
2426        "instruction": "{{env.instruction}}",
2427    });
2428    let mut patch = Value::Object(Map::new());
2429    paths::set_path(
2430        &mut patch,
2431        "workflows",
2432        json!([{
2433            "name": "main",
2434            "version": 3,
2435            "steps": {
2436                "start": { "kind": "once" },
2437                "work":  work,
2438                "done":  { "kind": "finish", "depends_on": ["work"], "status": "completed", "output": "{{steps.work.output}}" }
2439            }
2440        }]),
2441    );
2442    file::merge_into(doc, patch);
2443}
2444
2445/// Substitute `${VAR}` / `${VAR:-default}` references in **every string value**
2446/// of the merged document (config sections *and* inline workflows) from the
2447/// process environment. Braces are required — a bare `$VAR` (or a `$` not
2448/// followed by `{`) is left verbatim, and `$${` yields a literal `${` — so
2449/// shell snippets and prices survive untouched. An unset variable with no
2450/// default is a hard error (fail-closed). This is intentionally distinct from
2451/// `{{secret:NAME}}` / `{{secret-file:PATH}}` (which resolve a *redacted*
2452/// credential and are never echoed): `${VAR}` is for plain, loggable values
2453/// like hostnames, ports, and paths that differ per environment.
2454fn substitute_env(v: &mut Value, env: &HashMap<&str, &str>) -> Result<(), String> {
2455    match v {
2456        Value::String(s) => {
2457            if s.as_bytes().contains(&b'$') {
2458                *s = expand_env_str(s, env)?;
2459            }
2460            Ok(())
2461        }
2462        Value::Array(a) => a.iter_mut().try_for_each(|item| substitute_env(item, env)),
2463        Value::Object(m) => m.values_mut().try_for_each(|val| substitute_env(val, env)),
2464        _ => Ok(()),
2465    }
2466}
2467
2468/// Expand a single string's `${…}` references (see [`substitute_env`]).
2469fn expand_env_str(s: &str, env: &HashMap<&str, &str>) -> Result<String, String> {
2470    let mut out = String::with_capacity(s.len());
2471    let b = s.as_bytes();
2472    let mut i = 0;
2473    while i < b.len() {
2474        // `$` is ASCII and can never appear inside a multi-byte UTF-8 sequence,
2475        // so scanning for it byte-wise is safe; the fallthrough advances by whole
2476        // chars to keep every slice on a boundary.
2477        if b[i] == b'$' {
2478            if b.get(i + 1) == Some(&b'$') {
2479                out.push('$'); // `$$` -> literal `$`
2480                i += 2;
2481                continue;
2482            }
2483            if b.get(i + 1) == Some(&b'{') {
2484                let start = i + 2;
2485                let Some(rel) = s[start..].find('}') else {
2486                    return Err(format!("unterminated `${{` in config value {s:?}"));
2487                };
2488                let end = start + rel;
2489                let expr = &s[start..end];
2490                let (name, default) = match expr.split_once(":-") {
2491                    Some((n, d)) => (n.trim(), Some(d)),
2492                    None => (expr.trim(), None),
2493                };
2494                if name.is_empty() {
2495                    return Err(format!("empty `${{}}` reference in config value {s:?}"));
2496                }
2497                if !name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') {
2498                    return Err(format!(
2499                        "invalid environment variable name {name:?} in `${{{expr}}}`"
2500                    ));
2501                }
2502                match env.get(name) {
2503                    Some(val) => out.push_str(val),
2504                    None => match default {
2505                        Some(d) => out.push_str(d),
2506                        None => {
2507                            return Err(format!(
2508                                "environment variable ${{{name}}} is not set (referenced in config); \
2509                                 set it or write ${{{name}:-default}}"
2510                            ));
2511                        }
2512                    },
2513                }
2514                i = end + 1;
2515                continue;
2516            }
2517        }
2518        let ch = s[i..].chars().next().unwrap();
2519        out.push(ch);
2520        i += ch.len_utf8();
2521    }
2522    Ok(out)
2523}
2524
2525// ---------------------------------------------------------------------------
2526// Validation (RFC 0030 §5)
2527// ---------------------------------------------------------------------------
2528
2529/// Collected diagnostics: `errors` fail the load (exit 2); `warnings` are
2530/// advisory (logged, printed by `--validate-config`).
2531#[derive(Debug, Default, Clone)]
2532pub struct Diagnostics {
2533    pub errors: Vec<String>,
2534    pub warnings: Vec<String>,
2535}
2536
2537/// Every check, collected (never fast-fails) so `--validate-config` reports
2538/// all problems at once. Pure.
2539/// Validate a unified `auth:` block (RFC 0031 §5) — the required fields per
2540/// `kind`/`grant`, and secret-freedom for credential fields. Returns error
2541/// strings prefixed with `ctx` (e.g. `mcp server 'github'`).
2542fn validate_auth_block(auth: &Auth, ctx: &str) -> Vec<String> {
2543    let mut out = Vec::new();
2544    // Credential fields must be `{{secret:…}}` references, never inline.
2545    for (field, s) in [
2546        ("client_secret", &auth.client_secret),
2547        ("token", &auth.token),
2548        ("value", &auth.value),
2549    ] {
2550        if let Some(sec) = s
2551            && !sec.0.trim().is_empty()
2552            && !crate::sec::secret::has_secret_ref(&sec.0)
2553        {
2554            out.push(format!(
2555                "{ctx}: auth.{field} carries an inline credential; use a {{{{secret:…}}}} reference"
2556            ));
2557        }
2558    }
2559    match auth.kind {
2560        AuthKind::Static => {
2561            let has_bearer = auth.token.is_some();
2562            let has_header = auth.header.is_some() && auth.value.is_some();
2563            if !has_bearer && !has_header {
2564                out.push(format!(
2565                    "{ctx}: auth.kind static needs `token` (a bearer) or `header` + `value`"
2566                ));
2567            }
2568        }
2569        AuthKind::Aws => {
2570            if auth.region.is_none() {
2571                out.push(format!("{ctx}: auth.kind aws needs `region`"));
2572            }
2573            if auth.service.is_none() {
2574                out.push(format!(
2575                    "{ctx}: auth.kind aws needs `service` (e.g. bedrock, execute-api)"
2576                ));
2577            }
2578            match auth.source.as_deref() {
2579                Some("sso") => {
2580                    if auth.sso_start_url.is_none()
2581                        || auth.account_id.is_none()
2582                        || auth.role_name.is_none()
2583                    {
2584                        out.push(format!(
2585                            "{ctx}: aws source sso needs `sso_start_url` + `account_id` + `role_name`"
2586                        ));
2587                    }
2588                }
2589                Some(src) if !matches!(src, "env" | "static" | "imds" | "irsa") => {
2590                    out.push(format!(
2591                        "{ctx}: auth.source '{src}' is not a known AWS source (env|static|imds|irsa|sso)"
2592                    ));
2593                }
2594                _ => {}
2595            }
2596        }
2597        AuthKind::Spiffe => match auth.svid.as_deref().unwrap_or("jwt") {
2598            "jwt" => {
2599                if auth.jwt_svid_file.is_none() {
2600                    out.push(format!(
2601                        "{ctx}: auth.kind spiffe (svid jwt) needs `jwt_svid_file`"
2602                    ));
2603                }
2604            }
2605            "x509" => {
2606                if auth.svid_file.is_none() || auth.key_file.is_none() {
2607                    out.push(format!(
2608                        "{ctx}: auth.kind spiffe (svid x509) needs `svid_file` + `key_file`"
2609                    ));
2610                }
2611            }
2612            other => out.push(format!("{ctx}: auth.svid '{other}' (want jwt|x509)")),
2613        },
2614        AuthKind::Oauth2 => {
2615            if auth.client_id.is_none() {
2616                out.push(format!("{ctx}: auth.kind oauth2 needs `client_id`"));
2617            }
2618            if auth.token_url.is_none() && auth.issuer.is_none() {
2619                out.push(format!(
2620                    "{ctx}: auth oauth2 needs `token_url` or `issuer` (for discovery)"
2621                ));
2622            }
2623            match auth.grant.unwrap_or(OAuthGrant::Device) {
2624                OAuthGrant::Device => {
2625                    if auth.device_authorization_url.is_none() && auth.issuer.is_none() {
2626                        out.push(format!(
2627                            "{ctx}: the device grant needs `device_authorization_url` or `issuer`"
2628                        ));
2629                    }
2630                }
2631                OAuthGrant::ClientCredentials => {
2632                    if auth.client_secret.is_none() {
2633                        out.push(format!(
2634                            "{ctx}: the client_credentials grant needs `client_secret`"
2635                        ));
2636                    }
2637                }
2638                OAuthGrant::AuthorizationCode => {
2639                    if auth.authorization_url.is_none() && auth.issuer.is_none() {
2640                        out.push(format!(
2641                            "{ctx}: the authorization_code grant needs `authorization_url` or `issuer`"
2642                        ));
2643                    }
2644                }
2645            }
2646        }
2647    }
2648    out
2649}
2650
2651/// Why a declared header value's `{{secret:NAME}}` / `{{secret-file:PATH}}` ref
2652/// does not resolve, or `None` when it does (or when the value carries no ref).
2653///
2654/// This is the security half of header validation, and it is not cosmetic: a
2655/// header whose ref does not resolve is a header that is **not sent**, so
2656/// without this check the process starts and dials the endpoint with no
2657/// credential at all. Validate-before-side-effect (RFC 0011 §2) means that is
2658/// exit 2 at startup, naming the ref — the same rule, and the same resolver, the
2659/// runtime uses at the moment of use. The message names the ref, never the
2660/// resolved value (RFC 0012 §3.7).
2661fn unresolved_secret_ref(value: &str) -> Option<String> {
2662    if !crate::sec::secret::has_secret_ref(value) {
2663        return None;
2664    }
2665    crate::sec::secret::refs_resolvable(value, &|k| std::env::var(k).ok()).err()
2666}
2667
2668pub fn validate(loaded: &Loaded) -> Diagnostics {
2669    let s = &loaded.settings;
2670    let mut d = Diagnostics::default();
2671    let err = |d: &mut Diagnostics, m: String| d.errors.push(m);
2672
2673    // config_version
2674    if let Some(v) = &s.config_version
2675        && v != schema::CONFIG_VERSION
2676    {
2677        err(
2678            &mut d,
2679            format!(
2680                "config_version must be \"{}\" (got {v:?})",
2681                schema::CONFIG_VERSION
2682            ),
2683        );
2684    }
2685
2686    // intelligence
2687    for e in &s.intelligence.endpoints {
2688        if let Err(e) = super::validate_intelligence_uri(e) {
2689            err(&mut d, e.to_string());
2690        }
2691    }
2692    if let Some(p) = &s.intelligence.swap_policy
2693        && super::SwapPolicy::parse(p).is_none()
2694    {
2695        err(
2696            &mut d,
2697            format!("intelligence.swap_policy: {p:?} (want finish-on-old|restart-turn)"),
2698        );
2699    }
2700    if s.intelligence.token.is_some() && s.intelligence.token_file.is_some() {
2701        d.warnings.push(
2702            "intelligence.token and intelligence.token_file are both set; the inline token wins"
2703                .into(),
2704        );
2705    }
2706    if let Some(auth) = &s.intelligence.auth {
2707        for e in validate_auth_block(auth, "intelligence") {
2708            err(&mut d, e);
2709        }
2710    }
2711    if let Some(dialect) = &s.intelligence.dialect {
2712        if crate::intel::client::Provider::from_dialect(Some(dialect)).is_none() {
2713            err(
2714                &mut d,
2715                format!("intelligence.dialect: {dialect:?} (want openai|anthropic|bedrock)"),
2716            );
2717        }
2718        // Native Bedrock authenticates by SigV4 — an `auth: {kind: aws}` is
2719        // required (creds come from env/imds/irsa/sso at dial time).
2720        if dialect == "bedrock"
2721            && !matches!(
2722                s.intelligence.auth.as_ref().map(|a| a.kind),
2723                Some(AuthKind::Aws)
2724            )
2725        {
2726            err(
2727                &mut d,
2728                "intelligence.dialect: bedrock requires intelligence.auth.kind = aws (SigV4)"
2729                    .into(),
2730            );
2731        }
2732    }
2733    validate_budget(&s.intelligence.budget, "intelligence.budget", &mut d);
2734    if let Some(b) = &s.agent.conversation_budget {
2735        validate_budget(b, "agent.conversation_budget", &mut d);
2736    }
2737    for (name, value) in &s.intelligence.headers {
2738        if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
2739            err(
2740                &mut d,
2741                format!(
2742                    "intelligence.headers['{name}'] looks like a credential but has an inline value; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}}"
2743                ),
2744            );
2745        } else if let Some(e) = unresolved_secret_ref(value) {
2746            err(&mut d, format!("intelligence.headers['{name}']: {e}"));
2747        }
2748    }
2749
2750    // mcp servers
2751    let mut names = std::collections::HashSet::new();
2752    for srv in &s.mcp.servers {
2753        if srv.name.trim().is_empty() {
2754            err(&mut d, "mcp.servers[]: a server has an empty name".into());
2755        }
2756        if !names.insert(srv.name.as_str()) {
2757            err(
2758                &mut d,
2759                format!("mcp.servers[]: duplicate server name '{}'", srv.name),
2760            );
2761        }
2762        if srv.name == "code" {
2763            err(
2764                &mut d,
2765                "mcp.servers[]: the server name 'code' is reserved for code-registered tools"
2766                    .into(),
2767            );
2768        }
2769        if let Err(e) = super::mcp_endpoint_scheme_ok(&srv.endpoint) {
2770            err(&mut d, format!("mcp server '{}': {e}", srv.name));
2771        }
2772        if let Err(e) = srv.tag_set() {
2773            err(&mut d, e);
2774        }
2775        for (h, v) in &srv.headers {
2776            if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
2777                err(
2778                    &mut d,
2779                    format!(
2780                        "mcp server '{}' header '{h}' looks like a credential but has an inline value; use a {{{{secret:…}}}} reference",
2781                        srv.name
2782                    ),
2783                );
2784            } else if let Some(e) = unresolved_secret_ref(v) {
2785                err(
2786                    &mut d,
2787                    format!("mcp server '{}' header '{h}': {e}", srv.name),
2788                );
2789            }
2790        }
2791        if let Some(auth) = &srv.auth {
2792            for e in validate_auth_block(auth, &format!("mcp server '{}'", srv.name)) {
2793                err(&mut d, e);
2794            }
2795        }
2796    }
2797    let server_known = |n: &str| s.mcp.servers.iter().any(|x| x.name == n);
2798
2799    // tools
2800    for (name, ov) in &s.tools.overrides {
2801        if !server_known(&ov.server) {
2802            err(
2803                &mut d,
2804                format!(
2805                    "tools.overrides['{name}'] references undeclared MCP server '{}'",
2806                    ov.server
2807                ),
2808            );
2809        }
2810        if s.tools.disabled.iter().any(|x| x == name) {
2811            err(
2812                &mut d,
2813                format!("tool '{name}' is both disabled and overridden"),
2814            );
2815        }
2816        for (label, tpl) in [("args", &ov.args), ("result", &ov.result)] {
2817            if let Some(t) = tpl
2818                && let Some(expr) = t.strip_prefix("CEL:")
2819                && let Err(e) = crate::cel::compile_check(expr.trim())
2820            {
2821                err(&mut d, format!("tools.overrides['{name}'].{label}: {e}"));
2822            }
2823        }
2824    }
2825
2826    // store
2827    match s.store.kind {
2828        StoreKind::Mcp => match &s.store.mcp {
2829            None => err(&mut d, "store.kind is mcp but store.mcp is not set".into()),
2830            Some(m) => {
2831                if !server_known(&m.server) {
2832                    err(
2833                        &mut d,
2834                        format!(
2835                            "store.mcp.server '{}' is not a declared MCP server",
2836                            m.server
2837                        ),
2838                    );
2839                }
2840                for (label, op) in [
2841                    ("put", &m.put),
2842                    ("get", &m.get),
2843                    ("list", &m.list),
2844                    ("delete", &m.delete),
2845                ] {
2846                    if let Some(op) = op {
2847                        for (f, t) in [
2848                            ("args", &op.args),
2849                            ("ok", &op.ok),
2850                            ("conflict", &op.conflict),
2851                            ("value", &op.value),
2852                            ("keys", &op.keys),
2853                        ] {
2854                            if let Some(t) = t
2855                                && let Some(expr) = t.strip_prefix("CEL:")
2856                                && let Err(e) = crate::cel::compile_check(expr.trim())
2857                            {
2858                                err(&mut d, format!("store.mcp.{label}.{f}: {e}"));
2859                            }
2860                        }
2861                    }
2862                }
2863            }
2864        },
2865        StoreKind::Http => match &s.store.http {
2866            None => err(
2867                &mut d,
2868                "store.kind is http but store.http is not set".into(),
2869            ),
2870            Some(h) => {
2871                if !(h.base_url.starts_with("https://") || h.base_url.starts_with("http://")) {
2872                    err(
2873                        &mut d,
2874                        format!(
2875                            "store.http.base_url must be an http(s) URL (got {})",
2876                            h.base_url
2877                        ),
2878                    );
2879                }
2880                if h.get.is_none() || h.put.is_none() {
2881                    err(
2882                        &mut d,
2883                        "store.http needs at least `get` and `put` operations".into(),
2884                    );
2885                }
2886                for (name, v) in &h.headers {
2887                    if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(v) {
2888                        err(
2889                            &mut d,
2890                            format!(
2891                                "store.http.headers['{name}'] looks like a credential but has an inline value"
2892                            ),
2893                        );
2894                    } else if let Some(e) = unresolved_secret_ref(v) {
2895                        err(&mut d, format!("store.http.headers['{name}']: {e}"));
2896                    }
2897                }
2898            }
2899        },
2900        StoreKind::File => {
2901            // No block is required: `kind: file` alone resolves a root from the
2902            // environment (RFC 0033 §4). The one thing that cannot work is an
2903            // explicit empty path — it would resolve to the process's working
2904            // directory, so it is refused here rather than discovered as a
2905            // state directory nobody meant to create.
2906            if let Some(f) = &s.store.file
2907                && f.path.as_deref().is_some_and(|p| p.trim().is_empty())
2908            {
2909                err(
2910                    &mut d,
2911                    "store.file.path is empty — set a directory, or omit the field to use $AGENTD_STATE_DIR / $XDG_STATE_HOME/agentd/state".into(),
2912                );
2913            }
2914        }
2915        StoreKind::Memory => {
2916            d.warnings.push(
2917                "store.kind is memory: state does not survive the process (dev/test only)".into(),
2918            );
2919        }
2920        StoreKind::None => {
2921            // A job-shaped instance (one-shot workflows, no listener) may run
2922            // without a store — a crash re-runs it. Anything long-lived MUST
2923            // be durable (RFC 0025): an A2A listener or a long-lived start node.
2924            //
2925            // Reaching here with a long-lived instance now means the operator
2926            // WROTE `none` (RFC 0033 §5): the absent case was defaulted to the
2927            // file store back in `load`. So the message says how to take the
2928            // default back, not just which backends exist.
2929            if s.is_long_lived() {
2930                err(&mut d, "store.kind is none but the instance is long-lived (serves A2A / webhooks / a goal watchdog / has a loop|schedule|subscribe|signal|event|a2a|webhook start node) — configure a durable store (store.kind: file | mcp | http), or drop store.kind to get the local file store by default".into());
2931            } else if !s.workflows.is_empty() {
2932                d.warnings.push("store.kind is none: this one-shot run is not durable (a crash re-runs it from scratch); set store.kind for durability".into());
2933            }
2934        }
2935    }
2936    // The mirror of the checks above: each adapter validates the block it needs,
2937    // so a block that belongs to an adapter that is not selected is dead config.
2938    // Silence would be the wrong answer — `store.file.path` set beside
2939    // `kind: mcp` reads like state on disk and is not — but so would refusing to
2940    // start, since the block does no harm; the operator is told it is ignored.
2941    if s.store.file.is_some() && s.store.kind != StoreKind::File {
2942        d.warnings.push(format!(
2943            "store.file is set but store.kind is {} — the file adapter is not in use and the block is ignored",
2944            // The Debug name lowercased is exactly the YAML spelling of the
2945            // variant (`serde(rename_all = "lowercase")`), so the warning
2946            // quotes back what the operator wrote.
2947            format!("{:?}", s.store.kind).to_lowercase()
2948        ));
2949    }
2950    if let Some(ms) = s.store.checkpoint.debounce_ms
2951        && ms > 60_000
2952    {
2953        d.warnings.push(format!(
2954            "store.checkpoint.debounce_ms is {ms} (> 60s): progress may lag far behind reality"
2955        ));
2956    }
2957
2958    // knowledge / search / skills servers
2959    if let Some(k) = &s.knowledge.server
2960        && !server_known(k)
2961    {
2962        err(
2963            &mut d,
2964            format!("knowledge.server '{k}' is not a declared MCP server"),
2965        );
2966    }
2967    if let Some(k) = &s.search.server
2968        && !server_known(k)
2969    {
2970        err(
2971            &mut d,
2972            format!("search.server '{k}' is not a declared MCP server"),
2973        );
2974    }
2975    for src in &s.skills.sources {
2976        if !server_known(&src.server) {
2977            err(
2978                &mut d,
2979                format!(
2980                    "skills.sources[] references undeclared MCP server '{}'",
2981                    src.server
2982                ),
2983            );
2984        }
2985    }
2986    if let Some(c) = s.context.compact_at
2987        && !(c > 0.0 && c <= 1.0)
2988    {
2989        err(
2990            &mut d,
2991            format!("context.compact_at must be in (0, 1] (got {c})"),
2992        );
2993    }
2994
2995    // workflows (structural minimum here; RFC 0027 validation lives in the engine)
2996    let mut wf_names = std::collections::HashSet::new();
2997    for (i, w) in s.workflows.iter().enumerate() {
2998        let Some(obj) = w.as_object() else {
2999            err(&mut d, format!("workflows[{i}] must be an object"));
3000            continue;
3001        };
3002        let name = obj.get("name").and_then(Value::as_str).unwrap_or("");
3003        if name.trim().is_empty() {
3004            err(&mut d, format!("workflows[{i}] has no name"));
3005        } else if !wf_names.insert(name.to_string()) {
3006            err(
3007                &mut d,
3008                format!("workflows[]: duplicate workflow name '{name}'"),
3009            );
3010        }
3011        let has_file = obj.contains_key("file");
3012        let has_uri = obj.contains_key("uri");
3013        let has_steps = obj.contains_key("steps");
3014        if (has_file as u8 + has_uri as u8 + has_steps as u8) != 1 {
3015            err(
3016                &mut d,
3017                format!("workflows['{name}'] must have exactly one of file | uri | steps"),
3018            );
3019        }
3020        if let Some(f) = obj.get("file").and_then(Value::as_str)
3021            && !std::path::Path::new(f).exists()
3022        {
3023            err(
3024                &mut d,
3025                format!("workflows['{name}'].file {f:?} does not exist"),
3026            );
3027        }
3028    }
3029
3030    // lifecycle
3031    for (k, v) in &s.lifecycle.exit_code_map {
3032        if k != "3" && k != "7" {
3033            err(
3034                &mut d,
3035                format!(
3036                    "lifecycle.exit_code_map: only the policy codes 3 and 7 are remappable (got key {k:?})"
3037                ),
3038            );
3039        }
3040        if !(0..=255).contains(v) {
3041            err(
3042                &mut d,
3043                format!("lifecycle.exit_code_map[{k}] must be 0..=255 (got {v})"),
3044            );
3045        }
3046    }
3047    if s.lifecycle.watch_config && loaded.files.is_empty() {
3048        err(
3049            &mut d,
3050            "lifecycle.watch_config requires a config file (--config / AGENTD_CONFIG)".into(),
3051        );
3052    }
3053
3054    // a2a
3055    if let Some(l) = &s.a2a.listen {
3056        match super::ServeTarget::parse(l) {
3057            Ok(super::ServeTarget::Http { bind, tls }) => {
3058                let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
3059                if tls && (s.a2a.tls.cert.is_none() || s.a2a.tls.key.is_none()) {
3060                    err(
3061                        &mut d,
3062                        "a2a.listen is https:// but a2a.tls.cert / a2a.tls.key are not set".into(),
3063                    );
3064                }
3065                if !loopback
3066                    && s.a2a.tls.client_ca.is_none()
3067                    && s.a2a.bearer.is_none()
3068                    && !s.interface.pairing.enabled
3069                {
3070                    err(&mut d, "a2a.listen on a non-loopback address needs client auth: a2a.tls.client_ca, a2a.bearer, and/or interface.pairing".into());
3071                }
3072                if !tls && !loopback {
3073                    err(
3074                        &mut d,
3075                        "a2a.listen plaintext http:// is allowed for loopback only; use https://"
3076                            .into(),
3077                    );
3078                }
3079            }
3080            Err(e) => err(&mut d, format!("a2a.listen: {e}")),
3081        }
3082    }
3083
3084    // interface (RFC 0032 display-client surface — rides the A2A listener)
3085    if s.interface.enabled && s.a2a.listen.is_none() {
3086        err(
3087            &mut d,
3088            "interface.enabled requires a2a.listen (the interface is served on the A2A listener)"
3089                .into(),
3090        );
3091    }
3092    if s.interface.debug && !s.interface.enabled {
3093        d.warnings
3094            .push("interface.debug has no effect while interface.enabled is false".into());
3095    }
3096    for o in &s.interface.origins {
3097        // An origin is `scheme://host[:port]` — no path, no trailing slash.
3098        let ok = o
3099            .split_once("://")
3100            .map(|(scheme, rest)| {
3101                matches!(scheme, "http" | "https") && !rest.is_empty() && !rest.contains('/')
3102            })
3103            .unwrap_or(false);
3104        if !ok {
3105            err(
3106                &mut d,
3107                format!(
3108                    "interface.origins: {o:?} is not an origin (want scheme://host[:port], no path)"
3109                ),
3110            );
3111        }
3112    }
3113    // Display items: unknown names are skipped by clients — warn, don't refuse
3114    // (forward compatibility across client versions).
3115    for (edge, items) in [
3116        ("top", &s.interface.display.top),
3117        ("bottom", &s.interface.display.bottom),
3118    ] {
3119        for item in items.iter().flatten() {
3120            if !DISPLAY_ITEMS.contains(&item.as_str()) {
3121                d.warnings.push(format!(
3122                    "interface.display.{edge}: unknown item {item:?} (clients skip it); known: {}",
3123                    DISPLAY_ITEMS.join(", ")
3124                ));
3125            }
3126        }
3127    }
3128    // Pairing (RFC 0032 §13).
3129    if s.interface.pairing.enabled {
3130        if !s.interface.enabled {
3131            err(
3132                &mut d,
3133                "interface.pairing.enabled requires interface.enabled (pairing rides the interface surface)".into(),
3134            );
3135        }
3136        if let Some(role) = s.interface.pairing.role
3137            && !matches!(role, Role::Operator | Role::User)
3138        {
3139            err(
3140                &mut d,
3141                "interface.pairing.role must be operator or user".into(),
3142            );
3143        }
3144    }
3145
3146    // webhooks (RFC 0027 inbound HTTP surface)
3147    let uses_webhook = s.workflows.iter().any(workflow_uses_webhook);
3148    if uses_webhook && s.webhooks.listen.is_none() {
3149        err(&mut d, "a `webhook` node (start or wait) is used but webhooks.listen is not set — configure webhooks.listen (https://host:port)".into());
3150    }
3151    if let Some(l) = &s.webhooks.listen {
3152        match super::ServeTarget::parse(l) {
3153            Ok(super::ServeTarget::Http { bind, tls }) => {
3154                let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
3155                if tls && (s.webhooks.tls.cert.is_none() || s.webhooks.tls.key.is_none()) {
3156                    err(
3157                        &mut d,
3158                        "webhooks.listen is https:// but webhooks.tls.cert / webhooks.tls.key are not set"
3159                            .into(),
3160                    );
3161                }
3162                if !tls && !loopback {
3163                    err(
3164                        &mut d,
3165                        "webhooks.listen plaintext http:// is allowed for loopback only; use https://"
3166                            .into(),
3167                    );
3168                }
3169                // Symmetric with the `a2a.listen` refusal above: both are inbound
3170                // listeners that TRIGGER work, so a reachable one must authenticate
3171                // its callers — an open webhook route hands the agent's workflows to
3172                // anyone who can reach the port. Auth is resolved per route
3173                // (`runtime::webhooks::build_verify`: the node's own `auth`, else the
3174                // listener `default_auth`), so refuse only when a route would really
3175                // end up unverified — a listener whose every node signs is fine.
3176                // `none: true` is the documented loopback-only dev opt-out, not
3177                // authentication, so it does not buy an open public bind; the schema
3178                // offers no other way to ask for one, and this deliberately does not
3179                // invent one.
3180                if !loopback && !webhook_default_verifies(s.webhooks.default_auth.as_ref()) {
3181                    let mut open: Vec<String> = Vec::new();
3182                    let mut nodes = 0usize;
3183                    for w in &s.workflows {
3184                        let wf = w.get("name").and_then(Value::as_str).unwrap_or("?");
3185                        for (node, auth) in webhook_nodes(w) {
3186                            nodes += 1;
3187                            if !webhook_auth_verifies(auth) {
3188                                open.push(format!("{wf}/{node}"));
3189                            }
3190                        }
3191                    }
3192                    if !open.is_empty() {
3193                        err(
3194                            &mut d,
3195                            format!(
3196                                "webhooks.listen on a non-loopback address needs auth: set webhooks.default_auth (hmac, bearer or header), or give every `webhook` node its own `auth` (HMAC recommended) — unauthenticated: {}",
3197                                open.join(", ")
3198                            ),
3199                        );
3200                    } else if nodes == 0 {
3201                        // Nothing is reachable yet (every path answers 404), so this
3202                        // is not a live hole — but the next node added would be one.
3203                        d.warnings.push("webhooks.listen is non-loopback with no webhooks.default_auth — every webhook node must declare its own `auth` (HMAC recommended)".into());
3204                    }
3205                }
3206            }
3207            Err(e) => err(&mut d, format!("webhooks.listen: {e}")),
3208        }
3209    }
3210
3211    // goal watchdog (RFC 0026)
3212    if let Some(g) = &s.goal {
3213        let via = g.check.via.as_deref().unwrap_or("both");
3214        if via == "condition" && g.check.condition.is_none() {
3215            err(
3216                &mut d,
3217                "goal.check.via is 'condition' but goal.check.condition is not set".into(),
3218            );
3219        }
3220        for (label, act) in [("on_achieved", &g.on_achieved), ("on_stuck", &g.on_stuck)] {
3221            if let Some(GoalAction::Workflow(name)) = act
3222                && !s
3223                    .workflows
3224                    .iter()
3225                    .any(|w| w.get("name").and_then(Value::as_str) == Some(name.as_str()))
3226            {
3227                err(
3228                    &mut d,
3229                    format!(
3230                        "goal.{label} references workflow '{name}', which is not defined in workflows"
3231                    ),
3232                );
3233            }
3234        }
3235    }
3236
3237    let mut peer_names = std::collections::HashSet::new();
3238    for p in &s.a2a.peers {
3239        if !peer_names.insert(p.name.as_str()) {
3240            err(
3241                &mut d,
3242                format!("a2a.peers[]: duplicate peer name '{}'", p.name),
3243            );
3244        }
3245        if !p.endpoint.starts_with("https://") && !p.endpoint.starts_with("http://") {
3246            err(
3247                &mut d,
3248                format!("a2a peer '{}': endpoint must be http(s)://", p.name),
3249            );
3250        }
3251        if p.client_cert.is_some() != p.client_key.is_some() {
3252            err(
3253                &mut d,
3254                format!(
3255                    "a2a peer '{}': client_cert and client_key must be set together",
3256                    p.name
3257                ),
3258            );
3259        }
3260        if let Some(auth) = &p.auth {
3261            for e in validate_auth_block(auth, &format!("a2a peer '{}'", p.name)) {
3262                err(&mut d, e);
3263            }
3264            if auth.kind == AuthKind::Aws {
3265                err(
3266                    &mut d,
3267                    format!(
3268                        "a2a peer '{}': SigV4 (auth kind aws) is a follow-up",
3269                        p.name
3270                    ),
3271                );
3272            }
3273        }
3274        for (h, v) in &p.headers {
3275            if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
3276                err(
3277                    &mut d,
3278                    format!(
3279                        "a2a peer '{}' header '{h}' looks like a credential but has an inline value",
3280                        p.name
3281                    ),
3282                );
3283            } else if let Some(e) = unresolved_secret_ref(v) {
3284                err(&mut d, format!("a2a peer '{}' header '{h}': {e}", p.name));
3285            }
3286        }
3287    }
3288    for (i, pr) in s.a2a.principals.iter().enumerate() {
3289        let m = &pr.matcher;
3290        if m.san.is_none()
3291            && m.sub.is_none()
3292            && m.bearer_ref.is_none()
3293            && m.aauth_agent.is_none()
3294            && !m.any
3295        {
3296            err(
3297                &mut d,
3298                format!(
3299                    "a2a.principals[{i}]: match needs one of san | sub | bearer_ref | aauth_agent | any"
3300                ),
3301            );
3302        }
3303        if m.any && pr.role == Role::Operator {
3304            err(
3305                &mut d,
3306                format!("a2a.principals[{i}]: `any` cannot grant the operator role"),
3307            );
3308        }
3309    }
3310
3311    // observability
3312    if let Some(l) = &s.observability.log_level
3313        && crate::obs::log::Level::parse(l).is_none()
3314    {
3315        err(
3316            &mut d,
3317            format!("observability.log_level: {l:?} (want trace|debug|info|warn|error)"),
3318        );
3319    }
3320
3321    // secrets provenance: the FILE layer must not carry inline secrets
3322    for m in secret_violations(&loaded.file_doc) {
3323        err(&mut d, m);
3324    }
3325    for f in &s.observability.audit.sink.clone().unwrap_or_default() {
3326        if *f == AuditSink::Store && s.store.kind == StoreKind::None {
3327            err(
3328                &mut d,
3329                "observability.audit.sink includes `store` but store.kind is none".into(),
3330            );
3331        }
3332    }
3333
3334    // trifecta over the root grant (RFC 0012 §3.2)
3335    let mut tags = Vec::new();
3336    for srv in &s.mcp.servers {
3337        match srv.tag_set() {
3338            Ok(t) if t.is_empty() => tags.push(crate::sec::scope::TrifectaTag::UntrustedInput),
3339            Ok(t) => tags.extend(t),
3340            Err(_) => {}
3341        }
3342    }
3343    // The local command runner is a capability like any other, and it carries
3344    // the two heaviest legs: it can touch anything inside `workdir`
3345    // (`sensitive`) and it can talk to the network if the allow-list lets it
3346    // (`egress`). The registry tags it that way, but the registry is built
3347    // after validation — so the tags have to be contributed here, or enabling
3348    // `exec` next to an untrusted-input server assembles the whole trifecta
3349    // and starts anyway. Gated on the feature: without it `exec` is
3350    // mapping-only, and whichever MCP server provides it carries its own tags.
3351    #[cfg(feature = "exec")]
3352    if s.security.exec.enabled {
3353        tags.push(crate::sec::scope::TrifectaTag::Sensitive);
3354        tags.push(crate::sec::scope::TrifectaTag::Egress);
3355    }
3356    use crate::sec::scope::{TrifectaVerdict, check_trifecta};
3357    if check_trifecta(tags, s.security.allow_trifecta) == TrifectaVerdict::RefusedTrifecta {
3358        err(&mut d, "lethal-trifecta refused: the root grant wires untrusted_input + sensitive + egress into one agent; narrow the tags or set security.allow_trifecta (audited)".into());
3359    }
3360
3361    // Workflow definitions — the SAME strict parse the runtime runs at startup
3362    // (`load_workflows`). Without it, `--validate-config` passes a config that
3363    // then exits 2 on the first real start: a typo'd step field (`prompt:` on
3364    // an `agent` node) validated clean and failed in production, which is what
3365    // the pre-flight check exists to prevent. Reported after the structural
3366    // checks above so the more basic error still leads. `file:`/`uri:` refs
3367    // resolve at startup, so only inline definitions are checkable here.
3368    // `store.durability.{a2a,steps}` is parsed, published in the schema, and
3369    // surfaced in the manifest — and read by no writer. `eventual` therefore
3370    // promised a weaker-but-faster durability that was never implemented, on the
3371    // one guarantee agentd exists to make. Refusing the value is better than
3372    // honouring it: a durability dial nobody wired is a lie, and implementing it
3373    // would trade away the property the product is for. `strict` (the default)
3374    // is what the engine already does, so only a config that asked for the
3375    // unimplemented setting fails, and it fails saying so.
3376    for (path, level) in [
3377        ("store.durability.a2a", s.store.durability.a2a),
3378        ("store.durability.steps", s.store.durability.steps),
3379    ] {
3380        if level == Some(DurabilityLevel::Eventual) {
3381            d.errors.push(format!(
3382                "{path}: `eventual` is not implemented — every durable write is strict \
3383                 (checkpoint-before-effect). Remove the key; `strict` is the default and \
3384                 the only behaviour."
3385            ));
3386        }
3387    }
3388    for w in &s.workflows {
3389        if w.get("steps").is_none() {
3390            continue;
3391        }
3392        if let Err(errs) = crate::engine::model::parse_workflow(w) {
3393            // The parser's messages already name the workflow and the step.
3394            d.errors.extend(errs);
3395        }
3396        // Fan-out is checked HERE rather than in the parser because the ceiling
3397        // is a config value the parser cannot see.
3398        let cap = s
3399            .limits
3400            .workflow
3401            .fan_out
3402            .unwrap_or(crate::engine::model::MAX_BATCH_PARALLEL as u32);
3403        let wname = w.get("name").and_then(Value::as_str).unwrap_or("?");
3404        if let Some(steps) = w.get("steps").and_then(Value::as_object) {
3405            for (sid, step) in steps {
3406                let want = step.get("parallel").and_then(Value::as_u64).or_else(|| {
3407                    step.get("batch")
3408                        .and_then(|b| b.get("parallel"))
3409                        .and_then(Value::as_u64)
3410                });
3411                if let Some(want) = want
3412                    && want > cap as u64
3413                {
3414                    d.errors.push(format!(
3415                        "workflow {wname:?} step {sid:?}: parallel {want} exceeds \
3416                         limits.workflow.fan_out ({cap}) — raise the limit or lower the step"
3417                    ));
3418                }
3419            }
3420        }
3421    }
3422    d
3423}
3424
3425/// Long-lived start-node kinds (RFC 0027 §4) — an instance running one needs a
3426/// durable store (RFC 0026 §8 lifecycle: `run_until: drained`).
3427pub const LONG_LIVED_STARTS: &[&str] = &[
3428    "loop",
3429    "schedule",
3430    "subscribe",
3431    "signal",
3432    "event",
3433    "a2a",
3434    "webhook",
3435];
3436
3437/// Whether a raw workflow document has a long-lived start node.
3438pub fn workflow_is_long_lived(w: &Value) -> bool {
3439    w.get("steps")
3440        .and_then(Value::as_object)
3441        .is_some_and(|steps| {
3442            steps.values().any(|st| {
3443                st.get("kind")
3444                    .and_then(Value::as_str)
3445                    .is_some_and(|k| LONG_LIVED_STARTS.contains(&k))
3446            })
3447        })
3448}
3449
3450/// Whether a raw workflow document uses the inbound webhook surface — a
3451/// `webhook` start node, or a `wait: {on: webhook}` callback (either needs
3452/// `webhooks.listen`).
3453pub fn workflow_uses_webhook(w: &Value) -> bool {
3454    w.get("steps")
3455        .and_then(Value::as_object)
3456        .is_some_and(|steps| {
3457            steps.values().any(|st| {
3458                let kind = st.get("kind").and_then(Value::as_str);
3459                kind == Some("webhook")
3460                    || (matches!(kind, Some("wait") | Some("await"))
3461                        && st.get("on").and_then(Value::as_str) == Some("webhook"))
3462            })
3463        })
3464}
3465
3466/// The inbound-webhook routes a raw workflow document arms, as
3467/// `(node id, declared auth)`. Two shapes, matching what the listener reads: a
3468/// `webhook` start node carries its `auth` at the top level, while a
3469/// `wait: {on: webhook}` callback carries it under `webhook.auth`
3470/// (`runtime::webhooks::webhook_wait`).
3471fn webhook_nodes(w: &Value) -> Vec<(&str, Option<&Value>)> {
3472    let Some(steps) = w.get("steps").and_then(Value::as_object) else {
3473        return Vec::new();
3474    };
3475    steps
3476        .iter()
3477        .filter_map(|(id, st)| {
3478            let kind = st.get("kind").and_then(Value::as_str);
3479            if kind == Some("webhook") {
3480                Some((id.as_str(), st.get("auth")))
3481            } else if matches!(kind, Some("wait") | Some("await"))
3482                && st.get("on").and_then(Value::as_str) == Some("webhook")
3483            {
3484                Some((id.as_str(), st.get("webhook").and_then(|c| c.get("auth"))))
3485            } else {
3486                None
3487            }
3488        })
3489        .collect()
3490}
3491
3492/// Whether a node's declared `auth` actually verifies the caller. This mirrors
3493/// `runtime::webhooks::build_verify` INCLUDING its type tests — there a
3494/// non-object `hmac`/`header` or a non-string `bearer` is not a verifier and
3495/// falls through, so counting it as auth here would bless a route the listener
3496/// serves open. `none: true` short-circuits to `Verify::None`, so it is the
3497/// opposite of authentication.
3498fn webhook_auth_verifies(auth: Option<&Value>) -> bool {
3499    let Some(a) = auth else { return false };
3500    if a.get("none").and_then(Value::as_bool) == Some(true) {
3501        return false;
3502    }
3503    a.get("hmac").and_then(Value::as_object).is_some()
3504        || a.get("header").and_then(Value::as_object).is_some()
3505        || a.get("bearer").and_then(Value::as_str).is_some()
3506}
3507
3508/// The same question for the listener-wide `default_auth` (the typed twin,
3509/// `runtime::webhooks::build_verify_typed`): `none` wins over everything, and a
3510/// declared-but-incomplete verifier still counts — the listener refuses to spawn
3511/// on it, which fails closed.
3512fn webhook_default_verifies(d: Option<&WebhookAuth>) -> bool {
3513    d.is_some_and(|d| !d.none && (d.hmac.is_some() || d.bearer.is_some() || d.header.is_some()))
3514}
3515
3516fn validate_budget(b: &Budget, at: &str, d: &mut Diagnostics) {
3517    for (i, w) in b.windows.iter().enumerate() {
3518        if w.tokens.is_none() && w.requests.is_none() {
3519            d.errors
3520                .push(format!("{at}.windows[{i}]: set tokens and/or requests"));
3521        }
3522        if let Some(r) = &w.reset {
3523            // `HH:MMZ` is ASCII by construction, and the ASCII test must come
3524            // BEFORE the byte slices: `r.len()` is bytes, so a multi-byte char
3525            // (`0é:0Z` is six bytes) would otherwise make `r[..2]` land inside a
3526            // character and panic. A config error is exit 2, never a panic.
3527            let ok = r.len() == 6
3528                && r.is_ascii()
3529                && r.ends_with('Z')
3530                && r[..2].parse::<u32>().is_ok_and(|h| h < 24)
3531                && &r[2..3] == ":"
3532                && r[3..5].parse::<u32>().is_ok_and(|m| m < 60);
3533            if !ok {
3534                d.errors.push(format!(
3535                    "{at}.windows[{i}].reset must be HH:MMZ (got {r:?})"
3536                ));
3537            }
3538            if !w.per.is_calendar() {
3539                d.warnings.push(format!(
3540                    "{at}.windows[{i}].reset is only meaningful for day/week windows"
3541                ));
3542            }
3543        }
3544    }
3545    if let Some(f) = b.slow.factor
3546        && !(f > 0.0 && f <= 1.0)
3547    {
3548        d.errors
3549            .push(format!("{at}.slow.factor must be in (0, 1] (got {f})"));
3550    }
3551    if b.on_exhausted == BudgetTactic::Degrade && b.degrade.model.is_none() {
3552        d.errors.push(format!(
3553            "{at}.on_exhausted is degrade but {at}.degrade.model is not set"
3554        ));
3555    }
3556    if b.reserve.estimate == ReserveEstimate::Fixed && b.reserve.fixed.is_none() {
3557        d.errors.push(format!(
3558            "{at}.reserve.estimate is fixed but {at}.reserve.fixed is not set"
3559        ));
3560    }
3561}
3562
3563/// Secret-bearing paths that must be REFERENCES when they come from a file.
3564const FILE_SECRET_PATHS: &[&str] = &[
3565    "/intelligence/token",
3566    "/a2a/bearer",
3567    "/security/aauth/enroll_token",
3568];
3569
3570/// Inline (non-reference) credentials in the FILE document (RFC 0030 §5).
3571fn secret_violations(file_doc: &Value) -> Vec<String> {
3572    let mut out = Vec::new();
3573    for p in FILE_SECRET_PATHS {
3574        if let Some(Value::String(v)) = file_doc.pointer(p)
3575            && !crate::sec::secret::has_secret_ref(v)
3576        {
3577            out.push(format!(
3578                "config file: {} carries an inline credential; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}} (or set it from env/flag)",
3579                p.trim_start_matches('/').replace('/', ".")
3580            ));
3581        }
3582    }
3583    if let Some(servers) = file_doc.pointer("/mcp/servers").and_then(Value::as_array) {
3584        for s in servers {
3585            if let Some(Value::String(v)) = s.pointer("/oauth/client_secret")
3586                && !crate::sec::secret::has_secret_ref(v)
3587            {
3588                out.push(format!(
3589                    "config file: mcp server '{}' oauth.client_secret carries an inline credential; use a {{{{secret:…}}}} reference",
3590                    s.get("name").and_then(Value::as_str).unwrap_or("?")
3591                ));
3592            }
3593        }
3594    }
3595    out
3596}
3597
3598// ---------------------------------------------------------------------------
3599// Reload partition (RFC 0030 §6)
3600// ---------------------------------------------------------------------------
3601
3602/// Restart-only path prefixes: a live reload whose effective document differs
3603/// under any of these is refused (`restart_required`).
3604pub const RESTART_ONLY_PATHS: &[&str] = &[
3605    "config_version",
3606    "agent.name",
3607    "store.kind",
3608    "store.prefix",
3609    "store.mcp",
3610    "store.http",
3611    // Moving the state directory under a running instance would strand every
3612    // key it has written, so it joins the other store paths as restart-only.
3613    "store.file",
3614    "lifecycle.run_until",
3615    "lifecycle.drain_timeout",
3616    "lifecycle.run_id",
3617    "lifecycle.exit_code_map",
3618    "lifecycle.watch_config",
3619    "a2a.listen",
3620    "a2a.tls",
3621    "a2a.bearer",
3622    "observability.otel",
3623    "observability.metrics_addr",
3624    "observability.health_file",
3625    "observability.events_ring",
3626    "observability.traceparent",
3627    "security",
3628];
3629
3630/// The restart-only paths whose values differ between two effective documents.
3631pub fn restart_only_diff(running: &Value, candidate: &Value) -> Vec<String> {
3632    RESTART_ONLY_PATHS
3633        .iter()
3634        .filter(|p| {
3635            let ptr = format!("/{}", p.replace('.', "/"));
3636            running.pointer(&ptr) != candidate.pointer(&ptr)
3637        })
3638        .map(|p| (*p).to_string())
3639        .collect()
3640}
3641
3642/// The `--help` section for the v2 paths.
3643pub fn help_section() -> String {
3644    paths::help_section_in(&paths::bindings_of(&schema::schema()))
3645}
3646
3647/// The v2 `--help` text: usage, the alias flags, the removed flags, and every
3648/// config path (flag · env).
3649pub fn help_text() -> String {
3650    let mut out = format!(
3651        "agentd {ver} — a durable, workflow-driven agent (config schema v2)\n\
3652         \n\
3653         USAGE:\n\
3654         \x20 agentd --config <settings.yaml> [--config <overlay.yaml> …] [--<path> <value> …]\n\
3655         \x20 agentd --prompt <TEXT> --intelligence <URL>                    # one-shot: ask, answer, exit\n\
3656         \x20 agentd --instruction <TEXT> --intelligence <URL> [--mcp name=endpoint …]   # one-shot sugar\n\
3657         \x20 agentd tui|ui --config <settings.yaml> [--<path> <value> …]   # + a display client\n\
3658         \n\
3659         Every setting is a document path (YAML/JSON file, AGENTD_<PATH> env, --<path> flag);\n\
3660         several files merge in order (later wins). Precedence: built-in < files < env < flags.\n\
3661         \n\
3662         ALIASES (legacy spellings of paths):\n",
3663        ver = crate::VERSION
3664    );
3665    for a in ALIASES {
3666        let shape = match a.kind {
3667            AliasKind::Set | AliasKind::SetFromFile => "<value>",
3668            AliasKind::SetTrue => "",
3669            AliasKind::Append => "<value>  (adds one)",
3670            AliasKind::Special => "<value>",
3671        };
3672        out.push_str(&format!("  {:<32} {} → {}\n", a.flag, shape, a.path));
3673    }
3674    out.push_str(
3675        "\nSUBCOMMANDS (run the daemon with a display client attached; RFC 0032):\n\
3676         \x20 tui                        + the terminal UI (fullscreen; --inline for in-place)\n\
3677         \x20 ui                         + the web UI, opened in a browser\n\
3678         \x20                            both need `interface.enabled: true`, which the\n\
3679         \x20                            subcommand sets for you; the client exits with the daemon.\n\
3680         \x20                            Detached instead: run `agentd -c …`, then `agentd-tui\n\
3681         \x20                            --endpoint <url>` (npm i -g @agentd-dev/cli).\n\
3682         \nCONTROL:\n\
3683         \x20 -c, --config <PATH>        a settings file (repeatable; `=` form too; or AGENT_CONFIG=a.yaml:b.yaml)\n\
3684         \x20 --validate-config          load+validate everything, print the verdict, exit 0/2\n\
3685         \x20 --config-schema=2          print the settings JSON Schema (v2) and exit\n\
3686         \x20 --workflow-schema          print the workflow (dialect 3) JSON Schema + node registry and exit\n\
3687         \x20 --capabilities             print the capabilities manifest and exit\n\
3688         \x20 --login <target>           complete an OAuth device-login for an endpoint (e.g. mcp:<name>) and cache the token\n\
3689         \x20 --logout <target>          evict a cached credential\n\
3690         \x20 -h, --help / -V, --version\n\
3691         \nREMOVED IN 2.0:\n",
3692    );
3693    for (flag, hint) in REMOVED_FLAGS {
3694        out.push_str(&format!("  {flag:<32} {hint}\n"));
3695    }
3696    out.push('\n');
3697    out.push_str(&help_section());
3698    out
3699}
3700
3701#[cfg(test)]
3702mod tests {
3703    use super::*;
3704    use std::io::Write;
3705
3706    fn args(v: &[&str]) -> Vec<String> {
3707        v.iter().map(|s| s.to_string()).collect()
3708    }
3709
3710    fn write_tmp(contents: &str, ext: &str) -> tempfile::NamedTempFile {
3711        let mut f = tempfile::Builder::new()
3712            .suffix(&format!(".{ext}"))
3713            .tempfile()
3714            .unwrap();
3715        f.write_all(contents.as_bytes()).unwrap();
3716        f.flush().unwrap();
3717        f
3718    }
3719
3720    fn base_env() -> Vec<(String, String)> {
3721        vec![(
3722            "AGENTD_INTELLIGENCE_ENDPOINTS".into(),
3723            "https://intel.example/v1".into(),
3724        )]
3725    }
3726
3727    // ---- schema ↔ struct drift ---------------------------------------------
3728
3729    /// serde's `deny_unknown_fields` error names the expected fields; that
3730    /// list IS the struct's field set — compare it with the schema properties
3731    /// at every object, so neither can drift from the other.
3732    fn struct_fields_at(doc_path: &str) -> Vec<String> {
3733        // Build a document that is empty except for a probe key at `doc_path`.
3734        let mut probe = Value::Object(Map::new());
3735        let path = if doc_path.is_empty() {
3736            "__probe__".to_string()
3737        } else {
3738            format!("{doc_path}.__probe__")
3739        };
3740        paths::set_path(&mut probe, &path, json!(1));
3741        let err = Settings::from_document(probe, "t").expect_err("probe must be rejected");
3742        // "… unknown field `__probe__`, expected one of `a`, `b`, `c` …" (or
3743        // "expected `a` or `b`" for two, "expected `a`" for one).
3744        let after = err.split("expected").nth(1).unwrap_or("");
3745        let mut out: Vec<String> = after
3746            .split('`')
3747            .skip(1)
3748            .step_by(2)
3749            .map(str::to_string)
3750            .collect();
3751        out.sort();
3752        out
3753    }
3754
3755    fn schema_props_at(schema: &Value, doc_path: &str) -> Vec<String> {
3756        let mut node = schema.clone();
3757        let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
3758        for seg in doc_path.split('.').filter(|s| !s.is_empty()) {
3759            let props = node.get("properties").cloned().unwrap_or(Value::Null);
3760            node = props.get(seg).cloned().unwrap_or(Value::Null);
3761            if let Some(r) = node.get("$ref").and_then(Value::as_str)
3762                && let Some(name) = r.strip_prefix("#/$defs/")
3763            {
3764                node = defs.get(name).cloned().unwrap_or(Value::Null);
3765            }
3766        }
3767        let mut out: Vec<String> = node
3768            .get("properties")
3769            .and_then(Value::as_object)
3770            .map(|m| m.keys().cloned().collect())
3771            .unwrap_or_default();
3772        out.sort();
3773        out
3774    }
3775
3776    #[test]
3777    fn schema_matches_struct_at_every_object() {
3778        let schema = schema::schema();
3779        for path in [
3780            "",
3781            "agent",
3782            "agent.tools",
3783            "intelligence",
3784            "intelligence.auth",
3785            "intelligence.budget",
3786            "intelligence.budget.slow",
3787            "intelligence.budget.degrade",
3788            "intelligence.budget.reserve",
3789            "mcp",
3790            "tools",
3791            "store",
3792            "store.checkpoint",
3793            "store.durability",
3794            "memory",
3795            "context",
3796            "context.plan",
3797            "knowledge",
3798            "knowledge.auto_context",
3799            "search",
3800            "skills",
3801            "limits",
3802            "limits.run",
3803            "limits.subagents",
3804            "lifecycle",
3805            "a2a",
3806            "a2a.tls",
3807            "observability",
3808            "observability.otel",
3809            "observability.audit",
3810            "security",
3811            "security.cgroup",
3812            "security.exec",
3813        ] {
3814            let s = schema_props_at(&schema, path);
3815            let f = struct_fields_at(path);
3816            assert_eq!(s, f, "schema/struct drift at `{path}`");
3817        }
3818    }
3819
3820    #[test]
3821    fn every_schema_path_deserializes_a_sample() {
3822        // Every binding, given a kind-appropriate sample, must be accepted by
3823        // the typed Settings (proves the schema names real fields with the
3824        // right shapes — the paths mechanism depends on it).
3825        for b in paths::bindings_of(&schema::schema()) {
3826            let sample = match &b.kind {
3827                paths::Kind::String => match b.path.as_str() {
3828                    "config_version" => json!("2"),
3829                    _ => json!("x"),
3830                },
3831                paths::Kind::Integer => json!(1),
3832                paths::Kind::Number => json!(0.5),
3833                paths::Kind::Boolean => json!(true),
3834                paths::Kind::Enum(vs) => json!(vs[0]),
3835                paths::Kind::Array(item) => match (**item).clone() {
3836                    paths::Kind::Object => match b.path.as_str() {
3837                        "mcp.servers" => {
3838                            json!([{"name": "a", "endpoint": "https://a.example/mcp"}])
3839                        }
3840                        "workflows" => json!([{"name": "w", "steps": {}}]),
3841                        "a2a.principals" => json!([{"match": {"any": true}, "role": "user"}]),
3842                        "a2a.peers" => json!([{"name": "p", "endpoint": "https://p.example"}]),
3843                        "skills.sources" => json!([{"server": "s"}]),
3844                        "intelligence.budget.windows" | "agent.conversation_budget.windows" => {
3845                            json!([{"per": "hour", "tokens": 1}])
3846                        }
3847                        other => panic!("no sample for object list {other}"),
3848                    },
3849                    paths::Kind::Enum(vs) => json!([vs[0]]),
3850                    _ => json!(["s"]),
3851                },
3852                paths::Kind::Object => match b.path.as_str() {
3853                    "intelligence.pricing" => json!({"m": {"input_per_1k": 1.0}}),
3854                    "tools.overrides" => json!({"memory.get": {"server": "s", "tool": "t"}}),
3855                    "store.mcp" => json!({"server": "s"}),
3856                    "store.http" => json!({"base_url": "https://s"}),
3857                    "security.aauth" => json!({"provider": "https://apd"}),
3858                    "lifecycle.exit_code_map" => json!({"3": 0}),
3859                    _ => json!({"k": "v"}),
3860                },
3861                paths::Kind::Any => match b.path.as_str() {
3862                    "intelligence.endpoints" => json!("https://a,https://b"),
3863                    "goal.on_achieved" | "goal.on_stuck" => json!("finish"),
3864                    p if p.ends_with("timeout")
3865                        || p.ends_with("deadline")
3866                        || p.ends_with("_grace")
3867                        || p.ends_with("ttl")
3868                        || p.ends_with("every") =>
3869                    {
3870                        json!("10s")
3871                    }
3872                    p if p.starts_with("agent.tools.") => json!("all"),
3873                    _ => json!("x"),
3874                },
3875            };
3876            let mut doc = Value::Object(Map::new());
3877            paths::set_path(&mut doc, &b.path, sample);
3878            fill_required(&mut doc, &schema::schema(), &b.path);
3879            Settings::from_document(doc, "t")
3880                .unwrap_or_else(|e| panic!("path {} does not deserialize: {e}", b.path));
3881        }
3882    }
3883
3884    /// Along `path`, every schema object with `required` gets its required
3885    /// properties filled with a sample (so a lone leaf under `store.mcp` still
3886    /// types — the runtime validation reports the missing siblings instead).
3887    fn fill_required(doc: &mut Value, schema: &Value, path: &str) {
3888        let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
3889        let resolve = |v: &Value| -> Value {
3890            match v
3891                .get("$ref")
3892                .and_then(Value::as_str)
3893                .and_then(|r| r.strip_prefix("#/$defs/"))
3894            {
3895                Some(name) => defs.get(name).cloned().unwrap_or(Value::Null),
3896                None => v.clone(),
3897            }
3898        };
3899        let mut node = schema.clone();
3900        let mut prefix = String::new();
3901        let segs: Vec<&str> = path.split('.').collect();
3902        for (i, seg) in segs.iter().enumerate() {
3903            let props = node.get("properties").cloned().unwrap_or(Value::Null);
3904            node = resolve(&props.get(*seg).cloned().unwrap_or(Value::Null));
3905            prefix = if prefix.is_empty() {
3906                (*seg).to_string()
3907            } else {
3908                format!("{prefix}.{seg}")
3909            };
3910            if i + 1 == segs.len() {
3911                break;
3912            }
3913            if let Some(req) = node.get("required").and_then(Value::as_array) {
3914                let props = node.get("properties").cloned().unwrap_or(Value::Null);
3915                for r in req.iter().filter_map(Value::as_str) {
3916                    let p = format!("{prefix}.{r}");
3917                    if doc.pointer(&format!("/{}", p.replace('.', "/"))).is_none() {
3918                        // Honor an enum-typed required field (e.g. `auth.kind`) so
3919                        // the filled sample is a valid variant, not `"x"`.
3920                        let sample = match props
3921                            .get(r)
3922                            .and_then(|f| f.get("enum"))
3923                            .and_then(Value::as_array)
3924                            .filter(|a| !a.is_empty())
3925                        {
3926                            Some(vs) => vs[0].clone(),
3927                            None => match r {
3928                                "provider" | "base_url" | "url" => json!("https://x.example"),
3929                                _ => json!("x"),
3930                            },
3931                        };
3932                        paths::set_path(doc, &p, sample);
3933                    }
3934                }
3935            }
3936        }
3937    }
3938
3939    #[test]
3940    fn env_and_flag_names_derive_from_the_v2_paths() {
3941        let bs = paths::bindings_of(&schema::schema());
3942        let model = bs.iter().find(|b| b.path == "intelligence.model").unwrap();
3943        assert_eq!(model.env_names()[0], "AGENTD_INTELLIGENCE_MODEL");
3944        assert_eq!(model.env_names()[2], "INTELLIGENCE_MODEL");
3945        assert_eq!(model.flag(), "--intelligence-model");
3946        let steps = bs.iter().find(|b| b.path == "limits.run.steps").unwrap();
3947        assert_eq!(steps.env_names()[0], "AGENTD_LIMITS_RUN_STEPS");
3948        // Uniqueness of the derived names across the whole v2 schema.
3949        let mut seen = std::collections::HashSet::new();
3950        for b in &bs {
3951            assert!(seen.insert(b.flag()), "duplicate flag {}", b.flag());
3952        }
3953    }
3954
3955    // ---- detection ------------------------------------------------------------
3956
3957    #[test]
3958    fn detects_v1_v2_mixed_and_empty() {
3959        assert_eq!(detect(&json!({})), Detected::Empty);
3960        assert_eq!(detect(&json!({"model": "m"})), Detected::V1);
3961        assert_eq!(detect(&json!({"config_version": "2"})), Detected::V2);
3962        assert_eq!(
3963            detect(&json!({"agent": {"instruction": "x"}})),
3964            Detected::V2
3965        );
3966        assert_eq!(detect(&json!({"agent": {}, "model": "m"})), Detected::Mixed);
3967        assert_eq!(
3968            detect(&json!({"config_version": "1.0", "model": "m"})),
3969            Detected::V1
3970        );
3971        // `limits` is neutral; `intelligence` decides by shape.
3972        assert_eq!(
3973            detect(&json!({"model": "m", "limits": {"max_steps": 1}})),
3974            Detected::V1
3975        );
3976        assert_eq!(
3977            detect(&json!({"intelligence": "https://x", "limits": {}})),
3978            Detected::V1
3979        );
3980        assert_eq!(
3981            detect(&json!({"intelligence": {"model": "m"}, "limits": {}})),
3982            Detected::V2
3983        );
3984        assert_eq!(detect(&json!({"limits": {"max_steps": 1}})), Detected::V1);
3985    }
3986
3987    // ---- load: layering, aliases, sugar --------------------------------------
3988
3989    #[cfg(feature = "exec")]
3990    #[test]
3991    fn enabling_exec_next_to_untrusted_input_assembles_the_trifecta() {
3992        // `exec` is tagged sensitive+egress in the tool registry — but the
3993        // registry is built AFTER validation, so for a long time those tags
3994        // never reached the check and this config started happily. It is the
3995        // whole lethal trifecta: untrusted input, sensitive powers, an egress
3996        // path.
3997        let cfg = "config_version: \"2\"\nstore: {kind: memory}\n\
3998                   mcp:\n  servers:\n    - name: web\n      endpoint: https://mcp-web.internal/mcp\n      tags: {\"*\": [untrusted_input]}\n\
3999                   security:\n  exec: {enabled: true, workdir: /tmp, allow: [git]}\n";
4000        let f = write_tmp(cfg, "yaml");
4001        let e = load(
4002            &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
4003            &base_env(),
4004        )
4005        .unwrap_err();
4006        assert!(format!("{e}").contains("lethal-trifecta refused"), "{e}");
4007
4008        // The documented override still lets an operator take the risk.
4009        load(
4010            &args(&[
4011                "--config",
4012                f.path().to_str().unwrap(),
4013                "--validate-config",
4014                "--allow-trifecta",
4015            ]),
4016            &base_env(),
4017        )
4018        .expect("--allow-trifecta is the escape hatch");
4019
4020        // exec WITHOUT an untrusted-input source is only two legs: still fine.
4021        let alone = write_tmp(
4022            "config_version: \"2\"\nstore: {kind: memory}\n\
4023             security:\n  exec: {enabled: true, workdir: /tmp, allow: [git]}\n",
4024            "yaml",
4025        );
4026        load(
4027            &args(&[
4028                "--config",
4029                alone.path().to_str().unwrap(),
4030                "--validate-config",
4031            ]),
4032            &base_env(),
4033        )
4034        .expect("two legs are not the trifecta");
4035    }
4036
4037    #[test]
4038    fn validate_config_catches_workflow_body_errors_the_runtime_would_refuse() {
4039        // The pre-flight check must not pass a config that then exits 2 on the
4040        // first real start. A typo'd step field used to validate clean and be
4041        // refused by `load_workflows` at startup — the worst possible split.
4042        let f = write_tmp(
4043            "config_version: \"2\"\nstore: {kind: memory}\nworkflows:\n  - name: w\n    version: 3\n    steps:\n      s: {kind: once}\n      a: {kind: agent, depends_on: [s], prompt: \"typo — agent steps take `instruction`\"}\n      f: {kind: finish, depends_on: [a], status: completed}\n",
4044            "yaml",
4045        );
4046        let e = load(
4047            &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
4048            &base_env(),
4049        )
4050        .unwrap_err();
4051        let msg = format!("{e}");
4052        assert!(msg.contains("unknown field"), "{msg}");
4053        assert!(msg.contains("prompt"), "{msg}");
4054        assert!(
4055            msg.contains("instruction"),
4056            "names the allowed fields: {msg}"
4057        );
4058
4059        // The same workflow, spelled correctly, still validates.
4060        let ok = write_tmp(
4061            "config_version: \"2\"\nstore: {kind: memory}\nworkflows:\n  - name: w\n    version: 3\n    steps:\n      s: {kind: once}\n      a: {kind: agent, depends_on: [s], instruction: \"do it\"}\n      f: {kind: finish, depends_on: [a], status: completed}\n",
4062            "yaml",
4063        );
4064        load(
4065            &args(&["--config", ok.path().to_str().unwrap(), "--validate-config"]),
4066            &base_env(),
4067        )
4068        .expect("a correct workflow validates");
4069    }
4070
4071    #[test]
4072    fn a_prompt_is_a_message_not_a_sugar_workflow() {
4073        // A prompt is delivered into the agent's ROOT context at startup, so
4074        // it authors no workflow — that is what gives it root-scoped tools and
4075        // lets it set the instance up (workflow.create) rather than only
4076        // answering a canned step.
4077        let (l, ask) = load(&args(&["--prompt", "do the thing"]), &base_env()).unwrap();
4078        assert_eq!(ask, Ask::Run);
4079        assert_eq!(l.settings.agent.prompt.as_deref(), Some("do the thing"));
4080        assert!(
4081            l.settings.workflows.is_empty(),
4082            "a prompt needs no workflow: {:?}",
4083            l.settings.workflows
4084        );
4085
4086        // An instruction alone still gets the one-shot sugar workflow…
4087        let (only_instr, _) = load(&args(&["--instruction", "be terse"]), &base_env()).unwrap();
4088        assert_eq!(only_instr.settings.workflows.len(), 1);
4089
4090        // …but a prompt alongside it means the prompt is the job: the
4091        // instruction stays standing policy, and no step is synthesized.
4092        let (both, _) = load(
4093            &args(&["--prompt", "do the thing", "--instruction", "be terse"]),
4094            &base_env(),
4095        )
4096        .unwrap();
4097        assert!(both.settings.workflows.is_empty());
4098        assert_eq!(both.settings.agent.instruction.as_deref(), Some("be terse"));
4099
4100        // The env spelling works too (12-factor).
4101        let mut env = base_env();
4102        env.push(("AGENTD_AGENT_PROMPT".into(), "from env".into()));
4103        let (from_env, _) = load(&args(&[]), &env).unwrap();
4104        assert_eq!(from_env.settings.agent.prompt.as_deref(), Some("from env"));
4105    }
4106
4107    #[test]
4108    fn minimal_instruction_run_gets_the_sugar_workflow() {
4109        let (l, ask) = load(&args(&["--instruction", "do it"]), &base_env()).unwrap();
4110        assert_eq!(ask, Ask::Run);
4111        assert_eq!(l.settings.agent.instruction.as_deref(), Some("do it"));
4112        assert_eq!(
4113            l.settings.intelligence.endpoints,
4114            vec!["https://intel.example/v1"]
4115        );
4116        assert_eq!(l.settings.workflows.len(), 1, "sugar workflow synthesized");
4117        assert_eq!(l.settings.workflows[0]["name"], json!("main"));
4118        assert_eq!(
4119            l.settings.workflows[0]["steps"]["start"]["kind"],
4120            json!("once")
4121        );
4122        // A one-shot job may run without a store — with a warning.
4123        assert!(
4124            l.warnings.iter().any(|w| w.contains("not durable")),
4125            "{:?}",
4126            l.warnings
4127        );
4128    }
4129
4130    #[test]
4131    fn a_long_lived_instance_defaults_to_the_file_store_but_an_explicit_none_is_refused() {
4132        // An A2A listener is long-lived ⇒ the file store, not the old exit 2.
4133        let (l, _) = load(
4134            &args(&[
4135                "--instruction",
4136                "x",
4137                "--a2a.listen",
4138                "http://127.0.0.1:8443",
4139            ]),
4140            &base_env(),
4141        )
4142        .unwrap();
4143        assert_eq!(l.settings.store.kind, StoreKind::File);
4144        // A long-lived start node ⇒ the same default.
4145        let f = write_tmp(
4146            "config_version: \"2\"\nworkflows:\n  - name: w\n    steps:\n      s: {kind: schedule, cron: \"* * * * *\"}\n      f: {kind: finish, depends_on: [s], status: completed}\n",
4147            "yaml",
4148        );
4149        let (l, _) = load(
4150            &args(&["--config", f.path().to_str().unwrap()]),
4151            &base_env(),
4152        )
4153        .unwrap();
4154        assert_eq!(l.settings.store.kind, StoreKind::File);
4155        // …but a STATED `none` is still refused: the default fills a silence, it
4156        // does not overrule an operator (RFC 0033 §5).
4157        let e = load(
4158            &args(&[
4159                "--config",
4160                f.path().to_str().unwrap(),
4161                "--store.kind",
4162                "none",
4163            ]),
4164            &base_env(),
4165        )
4166        .unwrap_err();
4167        assert!(format!("{e}").contains("long-lived"), "{e}");
4168        // A one-shot job keeps `none` — the default deliberately does not move
4169        // for the shape that can simply be re-run.
4170        let (l, _) = load(&args(&["--instruction", "x"]), &base_env()).unwrap();
4171        assert_eq!(l.settings.store.kind, StoreKind::None);
4172        // memory is accepted (with a warning).
4173        let (l, _) = load(
4174            &args(&["--instruction", "x", "--store.kind", "memory"]),
4175            &base_env(),
4176        )
4177        .unwrap();
4178        assert!(
4179            l.warnings.iter().any(|w| w.contains("memory")),
4180            "{:?}",
4181            l.warnings
4182        );
4183    }
4184
4185    // ---- env substitution: `${VAR}` / `${VAR:-default}` --------------------
4186
4187    #[test]
4188    fn expand_env_str_covers_the_forms() {
4189        let env: HashMap<&str, &str> = [("HOST", "db.internal"), ("PORT", "5432")]
4190            .into_iter()
4191            .collect();
4192        // A plain reference; multiple in one string.
4193        assert_eq!(
4194            expand_env_str("${HOST}:${PORT}", &env).unwrap(),
4195            "db.internal:5432"
4196        );
4197        // A default applies only when the variable is unset.
4198        assert_eq!(
4199            expand_env_str("${MISSING:-fallback}", &env).unwrap(),
4200            "fallback"
4201        );
4202        assert_eq!(
4203            expand_env_str("${HOST:-fallback}", &env).unwrap(),
4204            "db.internal"
4205        );
4206        // Braces are required: a bare `$VAR` and a lone `$` pass through.
4207        assert_eq!(
4208            expand_env_str("$HOST costs $5", &env).unwrap(),
4209            "$HOST costs $5"
4210        );
4211        // `$$` escapes to a literal `$` and does not open a reference.
4212        assert_eq!(expand_env_str("$${HOST}", &env).unwrap(), "${HOST}");
4213        // An unset variable with no default is a hard error (fail-closed).
4214        assert!(
4215            expand_env_str("${NOPE}", &env)
4216                .unwrap_err()
4217                .contains("NOPE")
4218        );
4219        // A malformed reference is rejected, not silently passed through.
4220        assert!(expand_env_str("${HOST", &env).is_err());
4221        assert!(expand_env_str("${bad-name}", &env).is_err());
4222    }
4223
4224    #[test]
4225    fn env_substitution_reaches_config_values_and_workflows() {
4226        let file = write_tmp(
4227            "config_version: \"2\"\n\
4228             agent:\n  name: ${SVC_NAME}\n  instruction: serve\n  preflight: never\n\
4229             intelligence:\n  endpoints: [https://x/v1]\n  model: m\n\
4230             store:\n  kind: memory\n\
4231             workflows:\n  - name: w\n    steps:\n\
4232             \x20     s: {kind: once}\n\
4233             \x20     c: {kind: http, depends_on: [s], url: \"https://api.${REGION:-us}.example/${SVC_NAME}\"}\n\
4234             \x20     f: {kind: finish, depends_on: [c]}\n",
4235            "yaml",
4236        );
4237        let mut env = base_env();
4238        env.push(("SVC_NAME".into(), "billing".into()));
4239        // REGION is deliberately unset -> the `:-us` default applies.
4240        let (l, _) = load(&args(&["--config", file.path().to_str().unwrap()]), &env).unwrap();
4241        // A plain config value is substituted.
4242        assert_eq!(
4243            l.settings.agent.name.as_deref(),
4244            Some("billing"),
4245            "the `${{SVC_NAME}}` in a config value was substituted"
4246        );
4247        // A value nested inside an inline workflow is substituted too, honouring
4248        // the `:-default` for the unset REGION and the set SVC_NAME.
4249        let url = l.settings.workflows[0]
4250            .pointer("/steps/c/url")
4251            .and_then(Value::as_str)
4252            .unwrap_or_default();
4253        assert_eq!(
4254            url, "https://api.us.example/billing",
4255            "the workflow value was substituted (default + set var)"
4256        );
4257    }
4258
4259    #[test]
4260    fn mcp_server_oauth_is_carried_to_the_runtime_spec() {
4261        // RFC 0031: `mcp.servers[].oauth` was silently dropped by `to_spec()`,
4262        // leaving OAuth client-credentials inert. It must reach the runtime spec
4263        // (as a secret-free template) so the connect path can build the signer.
4264        let s = McpServer {
4265            name: "gh".into(),
4266            endpoint: "https://mcp.example".into(),
4267            ns: None,
4268            headers: BTreeMap::new(),
4269            tags: BTreeMap::new(),
4270            aauth: None,
4271            oauth: Some(McpOauth {
4272                token_url: "https://auth.example/token".into(),
4273                client_id: "cid".into(),
4274                client_secret: Secret("{{secret:CS}}".into()),
4275                scope: Some("mcp:read".into()),
4276            }),
4277            auth: None,
4278            timeout: None,
4279        };
4280        let spec = s.to_spec().unwrap();
4281        let o = spec.oauth.expect("oauth reaches the runtime spec");
4282        assert_eq!(o.token_url, "https://auth.example/token");
4283        assert_eq!(o.client_id, "cid");
4284        // The secret stays a template — never resolved into the spec/payload.
4285        assert_eq!(o.client_secret, "{{secret:CS}}");
4286        assert_eq!(o.scope.as_deref(), Some("mcp:read"));
4287    }
4288
4289    #[test]
4290    fn files_env_flags_layer_in_order_with_aliases() {
4291        let base = write_tmp(
4292            "config_version: \"2\"\nagent:\n  instruction: from-file\nintelligence:\n  endpoints: [https://file.example/v1]\n  model: file-model\nlimits:\n  run:\n    steps: 10\nstore: { kind: memory }\n",
4293            "yaml",
4294        );
4295        let over = write_tmp("intelligence:\n  model: over-model\n", "yml");
4296        let mut env = base_env();
4297        env.clear();
4298        env.push(("AGENTD_LIMITS_RUN_STEPS".into(), "20".into())); // derived path name
4299        env.push(("AGENT_MODEL".into(), "env-model".into())); // legacy alias
4300        env.push(("INSTRUCTION".into(), "env-instruction".into())); // bare legacy alias
4301        let (l, _) = load(
4302            &args(&[
4303                "--config",
4304                base.path().to_str().unwrap(),
4305                "--config",
4306                over.path().to_str().unwrap(),
4307                "--max-steps",
4308                "30",
4309                "--mcp",
4310                "fs=https://fs.example/mcp",
4311                "--mcp-tags",
4312                "fs=sensitive",
4313                "--intelligence.headers.x-team",
4314                "ops",
4315            ]),
4316            &env,
4317        )
4318        .unwrap();
4319        let s = &l.settings;
4320        assert_eq!(
4321            s.agent.instruction.as_deref(),
4322            Some("env-instruction"),
4323            "env > file"
4324        );
4325        assert_eq!(
4326            s.intelligence.model.as_deref(),
4327            Some("env-model"),
4328            "env alias > later file"
4329        );
4330        assert_eq!(s.limits.run.steps(), 30, "flag alias > env");
4331        assert_eq!(s.mcp.servers.len(), 1);
4332        assert_eq!(s.mcp.servers[0].name, "fs");
4333        assert_eq!(s.mcp.servers[0].tags["*"], vec!["sensitive"]);
4334        assert_eq!(
4335            s.intelligence.headers.get("x-team").map(String::as_str),
4336            Some("ops")
4337        );
4338        assert_eq!(l.files.len(), 2);
4339        // Path env beats the legacy alias for the same field.
4340        let env2: Vec<(String, String)> = vec![
4341            ("AGENT_MODEL".into(), "legacy".into()),
4342            ("AGENTD_INTELLIGENCE_MODEL".into(), "path".into()),
4343            ("AGENTD_INTELLIGENCE_ENDPOINTS".into(), "https://i".into()),
4344        ];
4345        let (l2, _) = load(
4346            &args(&["--instruction", "x", "--store.kind", "memory"]),
4347            &env2,
4348        )
4349        .unwrap();
4350        assert_eq!(l2.settings.intelligence.model.as_deref(), Some("path"));
4351    }
4352
4353    #[test]
4354    fn removed_flags_name_their_replacement() {
4355        for (flag, _) in REMOVED_FLAGS {
4356            let e = load(&args(&[flag, "x"]), &base_env()).unwrap_err();
4357            assert!(
4358                format!("{e}").contains("removed in agentd 2.0"),
4359                "{flag}: {e}"
4360            );
4361        }
4362        let e = load(&args(&["--mode", "reactive"]), &base_env()).unwrap_err();
4363        assert!(format!("{e}").contains("start node"), "{e}");
4364    }
4365
4366    #[test]
4367    fn mixed_and_v1_files_are_refused_by_the_v2_loader() {
4368        let mixed = write_tmp("agent: {instruction: x}\nmodel: m\n", "yaml");
4369        let e = load(
4370            &args(&["--config", mixed.path().to_str().unwrap()]),
4371            &base_env(),
4372        )
4373        .unwrap_err();
4374        assert!(format!("{e}").contains("mixes v1"), "{e}");
4375        let v1 = write_tmp("model: m\n", "yaml");
4376        let e = load(
4377            &args(&["--config", v1.path().to_str().unwrap()]),
4378            &base_env(),
4379        )
4380        .unwrap_err();
4381        assert!(format!("{e}").contains("v1 schema"), "{e}");
4382    }
4383
4384    #[test]
4385    fn budget_exit_code_and_instruction_file_aliases() {
4386        let f = write_tmp("read me from a file", "txt");
4387        let (l, _) = load(
4388            &args(&[
4389                "--instruction-file",
4390                f.path().to_str().unwrap(),
4391                "--budget-exit-code",
4392                "9",
4393                "--store.kind",
4394                "memory",
4395            ]),
4396            &base_env(),
4397        )
4398        .unwrap();
4399        assert_eq!(
4400            l.settings.agent.instruction.as_deref(),
4401            Some("read me from a file")
4402        );
4403        assert_eq!(l.settings.lifecycle.exit_code_map.get("3"), Some(&9));
4404        assert_eq!(l.settings.lifecycle.exit_code_map.get("7"), Some(&9));
4405    }
4406
4407    // ---- validation -----------------------------------------------------------
4408
4409    fn load_doc(yaml: &str) -> Result<Loaded, ConfigError> {
4410        let f = write_tmp(yaml, "yaml");
4411        load(&args(&["--config", f.path().to_str().unwrap()]), &[]).map(|(l, _)| l)
4412    }
4413
4414    #[test]
4415    fn validation_collects_the_rfc_0030_rules() {
4416        // A file with an inline credential is refused; the same value from env is fine.
4417        let e = load_doc(
4418            "config_version: \"2\"\nintelligence:\n  endpoints: [https://i]\n  token: sk-inline\n",
4419        )
4420        .unwrap_err();
4421        assert!(format!("{e}").contains("inline credential"), "{e}");
4422        let (l, _) = load(
4423            &args(&[
4424                "--intelligence",
4425                "https://i",
4426                "--intelligence-token",
4427                "sk-inline",
4428            ]),
4429            &[],
4430        )
4431        .unwrap();
4432        assert_eq!(
4433            l.settings.intelligence.token.as_ref().map(|s| s.0.as_str()),
4434            Some("sk-inline")
4435        );
4436        assert!(
4437            !format!("{:?}", l.settings).contains("sk-inline"),
4438            "Debug redacts"
4439        );
4440
4441        // Undeclared servers referenced by tools/store/knowledge/skills: the
4442        // startup path fast-fails on the first problem (exit 2)…
4443        let e = load_doc(
4444            "config_version: \"2\"\nstore: {kind: mcp, mcp: {server: nope}}\nknowledge: {server: kb}\nskills: {sources: [{server: sk}]}\ntools: {overrides: {memory.get: {server: mem, tool: t}}, disabled: [memory.get]}\n",
4445        )
4446        .unwrap_err();
4447        assert!(matches!(e, ConfigError::Usage(_)), "{e}");
4448
4449        // --validate-config collects EVERYTHING.
4450        let f = write_tmp(
4451            "config_version: \"2\"\nstore: {kind: mcp, mcp: {server: nope}}\nknowledge: {server: kb}\nskills: {sources: [{server: sk}]}\ntools: {overrides: {memory.get: {server: mem, tool: t}}, disabled: [memory.get]}\nlifecycle: {exit_code_map: {\"4\": 300}}\n",
4452            "yaml",
4453        );
4454        let e = load(
4455            &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
4456            &[],
4457        )
4458        .unwrap_err();
4459        let ConfigError::Validate(Err(lines)) = e else {
4460            panic!("expected a validate verdict, got {e:?}")
4461        };
4462        for needle in [
4463            "store.mcp.server 'nope'",
4464            "knowledge.server 'kb'",
4465            "skills.sources[]",
4466            "tools.overrides['memory.get']",
4467            "both disabled and overridden",
4468            "only the policy codes 3 and 7",
4469            "0..=255",
4470        ] {
4471            assert!(lines.contains(needle), "missing {needle} in:\n{lines}");
4472        }
4473
4474        // A2A listener rules.
4475        let e = load_doc("config_version: \"2\"\nstore: {kind: memory}\na2a: {listen: \"https://0.0.0.0:8443\"}\n").unwrap_err();
4476        assert!(format!("{e}").contains("a2a.tls.cert"), "{e}");
4477        let e = load_doc("config_version: \"2\"\nstore: {kind: memory}\na2a: {listen: \"http://0.0.0.0:8080\"}\n").unwrap_err();
4478        assert!(format!("{e}").contains("loopback"), "{e}");
4479        // Principals: `any` cannot be operator.
4480        let e = load_doc(
4481            "config_version: \"2\"\na2a: {principals: [{match: {any: true}, role: operator}]}\n",
4482        )
4483        .unwrap_err();
4484        assert!(format!("{e}").contains("operator role"), "{e}");
4485        // Budget rules.
4486        let e = load_doc("config_version: \"2\"\nintelligence: {budget: {windows: [{per: hour}], on_exhausted: degrade}}\n").unwrap_err();
4487        assert!(format!("{e}").contains("tokens and/or requests"), "{e}");
4488        // Trifecta over the root grant.
4489        let e = load_doc(
4490            "config_version: \"2\"\nmcp:\n  servers:\n    - {name: fs, endpoint: https://fs/mcp, tags: {\"*\": [untrusted_input, sensitive, egress]}}\n",
4491        )
4492        .unwrap_err();
4493        assert!(format!("{e}").contains("lethal-trifecta"), "{e}");
4494    }
4495
4496    #[test]
4497    fn restart_only_diff_names_changed_paths() {
4498        let a = json!({"agent": {"name": "x", "instruction": "i"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
4499        let b = json!({"agent": {"name": "y", "instruction": "j"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
4500        assert_eq!(restart_only_diff(&a, &b), vec!["agent.name".to_string()]);
4501        let c = json!({"agent": {"name": "x", "instruction": "changed"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
4502        assert!(
4503            restart_only_diff(&a, &c).is_empty(),
4504            "instruction is reloadable"
4505        );
4506    }
4507
4508    #[test]
4509    fn duration_and_tool_select_scalars() {
4510        let s = Settings::from_document(
4511            json!({"limits": {"run": {"deadline": "90s"}, "step_timeout": 5}, "agent": {"tools": {"mcp": "none", "internal": ["memory.get"]}}}),
4512            "t",
4513        )
4514        .unwrap();
4515        assert_eq!(s.limits.run.deadline(), Duration::from_secs(90));
4516        assert_eq!(s.limits.step_timeout, Some(Dur(Duration::from_secs(5))));
4517        assert!(!s.agent.tools.mcp.allows("fs.read"));
4518        assert!(s.agent.tools.internal.allows("memory.get"));
4519        assert!(!s.agent.tools.internal.allows("finish"));
4520        assert!(s.agent.tools.code.allows("anything"));
4521        assert!(
4522            Settings::from_document(json!({"limits": {"run": {"deadline": "soon"}}}), "t").is_err()
4523        );
4524    }
4525
4526    // ---- the file store (RFC 0033) --------------------------------------------
4527
4528    #[test]
4529    fn file_store_root_walks_the_chain_in_order() {
4530        use std::ffi::OsString;
4531        use std::path::PathBuf;
4532        let env = |pairs: Vec<(&'static str, &'static str)>| {
4533            move |k: &str| -> Option<OsString> {
4534                pairs
4535                    .iter()
4536                    .find(|(n, _)| *n == k)
4537                    .map(|(_, v)| OsString::from(*v))
4538            }
4539        };
4540        let all = vec![
4541            ("AGENTD_STATE_DIR", "/state-dir"),
4542            ("XDG_STATE_HOME", "/xdg"),
4543            ("HOME", "/home/a"),
4544        ];
4545        let with_file = |path: Option<&str>| Store {
4546            file: Some(StoreFile {
4547                path: path.map(str::to_string),
4548            }),
4549            ..Store::default()
4550        };
4551
4552        // 1. store.file.path wins over every environment variable.
4553        assert_eq!(
4554            file_store_root_in(&with_file(Some("/var/lib/agentd")), &env(all.clone())),
4555            PathBuf::from("/var/lib/agentd")
4556        );
4557        // 2. $AGENTD_STATE_DIR is taken verbatim — an operator naming the
4558        //    directory does not get `agentd/state` appended to it.
4559        assert_eq!(
4560            file_store_root_in(&with_file(None), &env(all.clone())),
4561            PathBuf::from("/state-dir")
4562        );
4563        // 3. $XDG_STATE_HOME, with the agentd/state suffix (`creds` sibling).
4564        assert_eq!(
4565            file_store_root_in(&Store::default(), &env(all[1..].to_vec())),
4566            PathBuf::from("/xdg/agentd/state")
4567        );
4568        // 4. $HOME/.local/state/… — the XDG default spelled out.
4569        assert_eq!(
4570            file_store_root_in(&Store::default(), &env(all[2..].to_vec())),
4571            PathBuf::from("/home/a/.local/state/agentd/state")
4572        );
4573        // 5. Last resort: the OS temp dir (non-durable; the runtime says so).
4574        assert_eq!(
4575            file_store_root_in(&Store::default(), &env(vec![])),
4576            std::env::temp_dir().join("agentd").join("state")
4577        );
4578        // The chain is the credential cache's, one sibling over: same order,
4579        // same suffix shape, `state` where `creds` is.
4580        assert!(
4581            file_store_root_in(&Store::default(), &env(all[1..].to_vec()))
4582                .ends_with("agentd/state")
4583        );
4584    }
4585
4586    #[test]
4587    fn file_store_validation_diagnostics() {
4588        // `kind: file` needs no block at all.
4589        let l = load_doc("config_version: \"2\"\nstore: {kind: file}\n").unwrap();
4590        assert_eq!(l.settings.store.kind, StoreKind::File);
4591        assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
4592        // …and a long-lived instance is satisfied by it (no `store.kind is none`).
4593        let l = load_doc(
4594            "config_version: \"2\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\na2a: {listen: \"http://127.0.0.1:8080\"}\n",
4595        )
4596        .unwrap();
4597        assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
4598        assert_eq!(
4599            file_store_root(&l.settings.store),
4600            std::path::PathBuf::from("/var/lib/agentd")
4601        );
4602
4603        // An explicitly empty path would resolve to the working directory.
4604        let e = load_doc("config_version: \"2\"\nstore: {kind: file, file: {path: \"\"}}\n")
4605            .unwrap_err();
4606        assert!(format!("{e}").contains("store.file.path is empty"), "{e}");
4607
4608        // A block belonging to an adapter that is not selected is dead config:
4609        // a warning (it is ignored), not a refusal (it does no harm).
4610        let l = load_doc(
4611            "config_version: \"2\"\nstore: {kind: memory, file: {path: /var/lib/agentd}}\n",
4612        )
4613        .unwrap();
4614        let d = validate(&l);
4615        assert!(d.errors.is_empty(), "{:?}", d.errors);
4616        assert!(
4617            d.warnings
4618                .iter()
4619                .any(|w| w.contains("store.file is set but store.kind is memory")),
4620            "{:?}",
4621            d.warnings
4622        );
4623        // No warning when the file adapter IS the selected one.
4624        let l =
4625            load_doc("config_version: \"2\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\n")
4626                .unwrap();
4627        assert!(
4628            !validate(&l)
4629                .warnings
4630                .iter()
4631                .any(|w| w.contains("store.file")),
4632            "{:?}",
4633            validate(&l).warnings
4634        );
4635        // Changing the state directory under a running instance is restart-only.
4636        assert_eq!(
4637            restart_only_diff(
4638                &json!({"store": {"kind": "file", "file": {"path": "/a"}}}),
4639                &json!({"store": {"kind": "file", "file": {"path": "/b"}}})
4640            ),
4641            vec!["store.file".to_string()]
4642        );
4643    }
4644
4645    #[test]
4646    fn instruction_uri_detection() {
4647        assert!(looks_like_resource_uri("mcp://docs/agent-instruction"));
4648        assert!(looks_like_resource_uri("docs://agent"));
4649        assert!(!looks_like_resource_uri("You are a helpful agent."));
4650        assert!(!looks_like_resource_uri(
4651            "see https://x.example for details"
4652        ));
4653        assert!(!looks_like_resource_uri("://nope"));
4654    }
4655
4656    #[test]
4657    fn help_and_schema_asks_short_circuit_validation() {
4658        let (_, ask) = load(&args(&["--help"]), &[]).unwrap();
4659        assert_eq!(ask, Ask::Help);
4660        let (_, ask) = load(&args(&["--config-schema=2"]), &[]).unwrap();
4661        assert_eq!(ask, Ask::Schema);
4662        // `--workflow-schema` is a static, side-effect-free dump: it must resolve
4663        // even with no config file present (no intelligence endpoint, etc.).
4664        let (_, ask) = load(&args(&["--workflow-schema"]), &[]).unwrap();
4665        assert_eq!(ask, Ask::WorkflowSchema);
4666        assert!(help_section().contains("intelligence.model"));
4667    }
4668}