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