Skip to main content

agentd/config/v2/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **agentd settings document** — one nested document (YAML or JSON;
3//! several files merge in order) whose every path is also `AGENTD_<PATH>` /
4//! `AGENT_<PATH>` / `<PATH>` and `--<path>`. This module holds the typed
5//! [`Settings`], its JSON Schema ([`schema::schema`]), the load pipeline
6//! (files → env → flags → typed → validated), the flat **alias** table
7//! (`--instruction`, `--intelligence`, `--model`, `--mcp`, …), the
8//! `agentd --instruction X` **sugar**, schema **detection**, and the reload
9//! partition (which paths only a restart can change).
10//!
11//! Layering: `built-in < files < env < flags`. Files compose with
12//! JSON-Merge-Patch semantics; env sets a path (lists and maps are replaced,
13//! never merged); flags apply in argument order — a generic `--<path>` SETS,
14//! while a named repeatable alias (`--mcp`, `--a2a-peer`) ADDS to its list.
15
16pub mod schema;
17
18use super::file::{self, Format};
19use super::paths::{self, Binding};
20use super::{ConfigError, usage};
21use serde::{Deserialize, Serialize};
22use serde_json::{Map, Value, json};
23use std::collections::{BTreeMap, HashMap};
24use std::fmt;
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28// ---------------------------------------------------------------------------
29// Scalars
30// ---------------------------------------------------------------------------
31
32/// A duration deserialized from `"10m"` / `"500ms"` / bare seconds (string or
33/// integer). Displays in the same string form.
34#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
35pub struct Dur(pub Duration);
36
37impl fmt::Debug for Dur {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        write!(f, "{:?}", self.0)
40    }
41}
42
43impl<'de> Deserialize<'de> for Dur {
44    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
45        #[derive(Deserialize)]
46        #[serde(untagged)]
47        enum Raw {
48            Secs(u64),
49            Text(String),
50        }
51        match Raw::deserialize(d)? {
52            Raw::Secs(s) => Ok(Dur(Duration::from_secs(s))),
53            Raw::Text(t) => super::parse_duration(&t)
54                .map(Dur)
55                .map_err(serde::de::Error::custom),
56        }
57    }
58}
59
60/// A credential-bearing string. From a FILE it must be a `{{secret:…}}` /
61/// `{{secret-file:…}}` reference — validation over the file document enforces
62/// that, so a config document never carries a live credential — while an
63/// env/flag value may be inline. `Debug` never shows the contents.
64#[derive(Clone, PartialEq, Eq, Deserialize, Default)]
65pub struct Secret(pub String);
66
67impl fmt::Debug for Secret {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.write_str("***")
70    }
71}
72
73/// `all` | `none` | an explicit list.
74#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
75#[serde(untagged)]
76pub enum ToolSelect {
77    Keyword(SelectKeyword),
78    List(Vec<String>),
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum SelectKeyword {
84    All,
85    None,
86}
87
88impl Default for ToolSelect {
89    fn default() -> Self {
90        ToolSelect::Keyword(SelectKeyword::All)
91    }
92}
93
94impl ToolSelect {
95    pub fn allows(&self, name: &str) -> bool {
96        match self {
97            ToolSelect::Keyword(SelectKeyword::All) => true,
98            ToolSelect::Keyword(SelectKeyword::None) => false,
99            ToolSelect::List(l) => l.iter().any(|n| n == name),
100        }
101    }
102}
103
104fn string_or_list<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
105    #[derive(Deserialize)]
106    #[serde(untagged)]
107    enum Raw {
108        List(Vec<String>),
109        One(String),
110    }
111    Ok(match Raw::deserialize(d)? {
112        Raw::List(l) => l,
113        Raw::One(s) => s
114            .split(',')
115            .map(str::trim)
116            .filter(|s| !s.is_empty())
117            .map(str::to_string)
118            .collect(),
119    })
120}
121
122// ---------------------------------------------------------------------------
123// The document
124// ---------------------------------------------------------------------------
125
126/// The typed v2 settings document. Every object is `deny_unknown_fields`;
127/// every section defaults so a minimal document (`agent.instruction` alone)
128/// is complete.
129#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
130#[serde(deny_unknown_fields, default)]
131pub struct Settings {
132    pub config_version: Option<String>,
133    /// Named durable event streams: `{name: {retention: {max_events,
134    /// max_age}}}`. A stream must be declared before an `emit` or `stream` node
135    /// may reference it — an undeclared name is refused at startup rather than
136    /// silently creating a stream nothing retains.
137    #[serde(default)]
138    pub streams: BTreeMap<String, StreamCfg>,
139    /// The **service catalog**: the named external services this deployment may
140    /// use. Entries carry connection settings, one shared credential,
141    /// authoritative trifecta tags and a tool-surface ceiling; `mcp.servers`
142    /// entries reference them via `service:`. An absent catalog imposes no
143    /// constraints at all.
144    #[serde(default)]
145    pub services: BTreeMap<String, Service>,
146    /// Operator-defined constants, referenced anywhere in this document and in
147    /// workflow definitions as `{{config.NAME}}` (dotted paths reach into
148    /// nested values). The template prefix is `config.` and NOT `vars.`
149    /// because `vars.*` already names a RUN's own variables — two things
150    /// called `vars` in one template language would be a permanent trap.
151    ///
152    /// Substitution is fail-closed: a reference to an undefined name refuses
153    /// startup naming every unresolved reference at once. In workflows the
154    /// values fold in at LOAD time, so they participate in the definition hash
155    /// — a var change is a definition change, and in-flight runs stay pinned
156    /// to the definition they started with.
157    pub vars: BTreeMap<String, Value>,
158    pub agent: Agent,
159    pub intelligence: Intelligence,
160    pub mcp: Mcp,
161    pub tools: Tools,
162    pub store: Store,
163    pub memory: Memory,
164    pub context: Context,
165    pub knowledge: Knowledge,
166    pub search: Search,
167    pub skills: Skills,
168    /// Inline workflow definitions or `{name, file|uri}` references — kept as
169    /// raw documents here and typed by the workflow engine, so config loading
170    /// never has to know the node registry.
171    pub workflows: Vec<Value>,
172    pub limits: Limits,
173    pub lifecycle: Lifecycle,
174    /// Subagent templates + spawn policy: operator-declared definitions the
175    /// model may instantiate (filling declared `params` only), section-wide
176    /// defaults, and the freeform-spawn switch.
177    pub subagents: Subagents,
178    pub a2a: A2a,
179    /// The display-client surface: opt-in TUI/web-UI methods on the A2A
180    /// listener (the global `SubscribeToEvents` feed + interface read ops).
181    pub interface: Interface,
182    /// The inbound webhook HTTP surface: a dedicated listener for `webhook`
183    /// start nodes and `wait: {on: webhook}` callbacks.
184    pub webhooks: Webhooks,
185    /// The self-correcting goal watchdog: a periodic check of whether the
186    /// configured goal is achieved (or the agent is stuck).
187    pub goal: Option<Goal>,
188    pub observability: Observability,
189    pub security: Security,
190    /// Who work is done ON BEHALF OF, and what travels with it.
191    pub identity: Identity,
192}
193
194#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
195#[serde(deny_unknown_fields, default)]
196pub struct Agent {
197    pub name: Option<String>,
198    /// Static text, or a single-token URI a configured MCP server serves
199    /// (read + subscribed) — one field, with the shape deciding which.
200    pub instruction: Option<String>,
201    /// A **one-shot task** (`--prompt`). With no workflows configured this is
202    /// what the generated run executes, while `instruction` stays the standing
203    /// policy (it becomes the run's system prompt). Given alone, the prompt is
204    /// the whole job — `agentd --prompt "…" --intelligence …` runs it once and
205    /// exits with the answer on stdout.
206    pub prompt: Option<String>,
207    /// Skills defined by `:::skill` directives in the instruction — DERIVED
208    /// (never a config key): `Settings::from_document` extracts them, the
209    /// runtime feeds them to the catalogue. In the struct so a reload diff
210    /// sees an edited inline skill as an agent change.
211    #[serde(skip)]
212    pub inline_skills: Vec<crate::config::directives::InlineSkill>,
213    pub preflight: Preflight,
214    pub wake_on: Option<Vec<WakeEvent>>,
215    pub on_workflow_finished: OnWorkflowFinished,
216    pub tools: AgentTools,
217    pub max_parallel_turns: Option<u32>,
218    pub conversation_budget: Option<Budget>,
219    /// What `ask_human` does when NO human channel can answer — the interface
220    /// is disabled — and, for `auto`, when a gate times out unanswered
221    /// `fail` (default; the ask errors immediately), `wait` (park until the
222    /// ask timeout), or `auto` (an LLM judge answers on the operator's behalf,
223    /// conservatively, marked as auto).
224    pub ask_human_fallback: AskHumanFallback,
225    /// What a gate does when a human COULD answer.
226    ///
227    /// `ask_human_fallback` governs the case where nobody can answer;
228    /// this governs whether to ask at all. They are separate because they are
229    /// separate questions: "there is no channel" is a fact about deployment,
230    /// "do not interrupt me" is a policy about attention.
231    pub approval: Approval,
232}
233
234/// How much a person wants to be asked.
235///
236/// Runtime-settable, because the right answer changes with what the agent is
237/// doing: you supervise closely while it is somewhere unfamiliar and stop
238/// wanting to be asked once it is doing something you have watched it do
239/// twenty times.
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
241#[serde(rename_all = "lowercase")]
242pub enum Approval {
243    /// Ask a person and wait. The default: a gate exists because someone
244    /// wanted a decision, so the decision is theirs unless told otherwise.
245    #[default]
246    #[serde(alias = "await", alias = "human")]
247    Ask,
248    /// An LLM judge decides whether it is safe to proceed, conservatively, and
249    /// the answer is marked `via: auto` so nobody mistakes it for a person's.
250    Auto,
251    /// Take the recommendation without asking.
252    ///
253    /// Only usable when the ask CARRIES one — a `recommend` argument or a
254    /// schema `default`. With neither there is nothing to accept, and inventing
255    /// an answer would be worse than the interruption, so it degrades to
256    /// `auto` rather than guessing.
257    #[serde(alias = "accept_all", alias = "yes")]
258    Accept,
259}
260
261/// The `ask_human` fallback disposition.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
263#[serde(rename_all = "lowercase")]
264pub enum AskHumanFallback {
265    /// Park the ask until its timeout (then it fails).
266    #[serde(alias = "pause", alias = "idle")]
267    Wait,
268    /// Error immediately — the caller (model / workflow policy) decides.
269    #[default]
270    #[serde(alias = "finish", alias = "stop")]
271    Fail,
272    /// An LLM judge answers on the operator's behalf (also fires when an
273    /// interface-served gate times out unanswered). `UNDECIDED` ⇒ fail.
274    Auto,
275}
276
277impl Agent {
278    /// The wake set used when the operator declares none: the events that
279    /// carry information the agent cannot get any other way. A finished
280    /// workflow is deliberately absent — success needs no attention.
281    pub fn wake_on(&self) -> Vec<WakeEvent> {
282        self.wake_on.clone().unwrap_or_else(|| {
283            vec![
284                WakeEvent::A2aMessage,
285                WakeEvent::HumanReply,
286                WakeEvent::SubagentResult,
287                WakeEvent::WorkflowFailed,
288            ]
289        })
290    }
291    pub fn max_parallel_turns(&self) -> u32 {
292        self.max_parallel_turns.unwrap_or(4)
293    }
294    /// Whether the instruction is a resource reference (a single-token URI).
295    pub fn instruction_is_uri(&self) -> bool {
296        self.instruction
297            .as_deref()
298            .is_some_and(looks_like_resource_uri)
299    }
300}
301
302/// `scheme://…` with no whitespace, and a scheme that is not a bare `http(s)`
303/// URL to a web page… — any `<alpha><alnum+.->://` single token counts; the
304/// registry decides which server serves it.
305pub fn looks_like_resource_uri(s: &str) -> bool {
306    let t = s.trim();
307    if t.contains(char::is_whitespace) {
308        return false;
309    }
310    let Some((scheme, rest)) = t.split_once("://") else {
311        return false;
312    };
313    !scheme.is_empty()
314        && scheme
315            .chars()
316            .next()
317            .is_some_and(|c| c.is_ascii_alphabetic())
318        && scheme
319            .chars()
320            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
321        && !rest.is_empty()
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
325#[serde(rename_all = "lowercase")]
326pub enum Preflight {
327    Never,
328    #[default]
329    Auto,
330    Always,
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
334#[serde(rename_all = "snake_case")]
335pub enum WakeEvent {
336    A2aMessage,
337    HumanReply,
338    SubagentResult,
339    WorkflowFinished,
340    WorkflowFailed,
341    InstructionUpdated,
342    BudgetResumed,
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
346#[serde(rename_all = "lowercase")]
347pub enum OnWorkflowFinished {
348    Ignore,
349    #[default]
350    Note,
351    Think,
352}
353
354#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
355#[serde(deny_unknown_fields, default)]
356pub struct AgentTools {
357    pub internal: ToolSelect,
358    pub mcp: ToolSelect,
359    pub code: ToolSelect,
360}
361
362#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
363#[serde(deny_unknown_fields, default)]
364pub struct Intelligence {
365    #[serde(deserialize_with = "string_or_list")]
366    pub endpoints: Vec<String>,
367    pub model: Option<String>,
368    /// The wire dialect: `openai` (default), `anthropic`, or
369    /// `bedrock` (native Amazon Bedrock Converse — pair with `auth: {kind: aws,
370    /// service: bedrock}`). Unset ⇒ OpenAI-compatible.
371    pub dialect: Option<String>,
372    pub token: Option<Secret>,
373    pub token_file: Option<String>,
374    pub headers: BTreeMap<String, String>,
375    /// A unified credential provider for the LLM endpoint — e.g. `oauth2`
376    /// device-login for an enterprise gateway. Obtained via
377    /// `agentd login intelligence`; the resolved bearer overrides `token`.
378    pub auth: Option<Auth>,
379    pub swap_policy: Option<String>,
380    pub structured_output: StructuredOutput,
381    pub budget: Budget,
382    pub pricing: BTreeMap<String, Pricing>,
383    pub timeout: Option<Dur>,
384    /// Named model TIERS. The model was one instance-global string, so
385    /// choosing a cheap model for a classify step and a frontier one for a
386    /// judgement call meant forking a subagent process just to change it —
387    /// and the breaker was per ENDPOINT, so a frontier and a cheap model
388    /// behind one gateway shared one breaker and one spend pool.
389    ///
390    /// A tier is NOT a second service catalog: `services:` already names
391    /// endpoints, auth, tags, rate and breaker, and restating those here would
392    /// be a parallel mechanism. A tier points AT a service and may only
393    /// narrow — it inherits that service's trifecta tags and can never declare
394    /// its own floor, so "make it cheaper" cannot quietly become a different
395    /// security decision.
396    pub models: BTreeMap<String, ModelTier>,
397    /// Which tier is used when nothing names one. Falls back to `model`.
398    pub default: Option<String>,
399    /// The tier preflight runs on. Preflight is a recurring fixed cost on
400    /// every inbound message, like compaction — it does not need the model
401    /// that answers.
402    pub preflight_model: Option<String>,
403}
404
405/// One named model tier.
406#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
407#[serde(deny_unknown_fields, default)]
408pub struct ModelTier {
409    /// The wire model name sent to the provider. Required.
410    pub model: Option<String>,
411    /// A `services:` entry of `kind: intelligence` supplying the endpoint,
412    /// auth and tags. Absent ⇒ the top-level `intelligence` endpoint.
413    pub service: Option<String>,
414    /// This model's context window, so compaction stops guessing from the
415    /// model NAME (a substring match that is wrong for every provider whose
416    /// naming does not happen to match).
417    pub window: Option<u64>,
418    /// The tier to fall back to when this one is unavailable or the budget is
419    /// squeezed — a degradation ladder that walks DOWN instead of failing.
420    pub fallback: Option<String>,
421    pub pricing: Option<Pricing>,
422}
423
424impl Intelligence {
425    /// Resolve a model reference to the wire model name.
426    ///
427    /// A reference is either a declared TIER name or a literal model string,
428    /// with the tier winning. That ordering is what lets `models:` be adopted
429    /// without rewriting every place a model is already named — an existing
430    /// literal keeps working, and a tier name takes over the moment one is
431    /// declared under that name.
432    pub fn wire_model(&self, reference: &str) -> String {
433        match self.models.get(reference).and_then(|t| t.model.clone()) {
434            Some(m) => m,
435            None => reference.to_string(),
436        }
437    }
438
439    /// The tier a reference names, if it names one.
440    pub fn tier(&self, reference: &str) -> Option<&ModelTier> {
441        self.models.get(reference)
442    }
443
444    /// The model reference used when nothing names one: `default` (a tier),
445    /// else `model` (a literal or a tier name).
446    pub fn default_reference(&self) -> Option<String> {
447        self.default.clone().or_else(|| self.model.clone())
448    }
449
450    /// Walk the fallback chain from `reference`, stopping at the first tier
451    /// with no fallback. Cycles are impossible because validation refuses
452    /// them; the bound here is belt-and-braces for a config that reached the
453    /// runtime some other way.
454    pub fn fallback_chain(&self, reference: &str) -> Vec<String> {
455        let mut out = Vec::new();
456        let mut cur = reference.to_string();
457        for _ in 0..8 {
458            let Some(next) = self.models.get(&cur).and_then(|t| t.fallback.clone()) else {
459                break;
460            };
461            if out.contains(&next) || next == reference {
462                break;
463            }
464            out.push(next.clone());
465            cur = next;
466        }
467        out
468    }
469
470    pub fn timeout(&self) -> Duration {
471        self.timeout.map(|d| d.0).unwrap_or(Duration::from_secs(60))
472    }
473    /// The comma-joined endpoint list URI the v1 intelligence client speaks.
474    pub fn endpoint_list(&self) -> Option<String> {
475        if self.endpoints.is_empty() {
476            None
477        } else {
478            Some(self.endpoints.join(","))
479        }
480    }
481}
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
484#[serde(rename_all = "snake_case")]
485pub enum StructuredOutput {
486    #[default]
487    Auto,
488    JsonSchema,
489    Tool,
490    Prompt,
491}
492
493#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
494#[serde(deny_unknown_fields, default)]
495pub struct Budget {
496    pub windows: Vec<BudgetWindow>,
497    pub lifetime_tokens: Option<u64>,
498    pub scope: Option<Vec<BudgetScope>>,
499    pub on_exhausted: BudgetTactic,
500    pub slow: Slow,
501    pub degrade: Degrade,
502    pub reserve: Reserve,
503}
504
505#[derive(Debug, Clone, Deserialize, PartialEq)]
506#[serde(deny_unknown_fields)]
507pub struct BudgetWindow {
508    pub per: WindowUnit,
509    #[serde(default)]
510    pub tokens: Option<u64>,
511    #[serde(default)]
512    pub requests: Option<u64>,
513    #[serde(default)]
514    pub reset: Option<String>,
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
518#[serde(rename_all = "lowercase")]
519pub enum WindowUnit {
520    Second,
521    Minute,
522    Hour,
523    Day,
524    Week,
525}
526
527impl WindowUnit {
528    pub fn duration(self) -> Duration {
529        match self {
530            WindowUnit::Second => Duration::from_secs(1),
531            WindowUnit::Minute => Duration::from_secs(60),
532            WindowUnit::Hour => Duration::from_secs(3600),
533            WindowUnit::Day => Duration::from_secs(86_400),
534            WindowUnit::Week => Duration::from_secs(7 * 86_400),
535        }
536    }
537    /// Calendar windows reset at a wall-clock time; rolling windows are buckets.
538    pub fn is_calendar(self) -> bool {
539        matches!(self, WindowUnit::Day | WindowUnit::Week)
540    }
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
544#[serde(rename_all = "lowercase")]
545pub enum BudgetScope {
546    Instance,
547    Run,
548    Conversation,
549    Principal,
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
553#[serde(rename_all = "lowercase")]
554pub enum BudgetTactic {
555    #[default]
556    Wait,
557    Slow,
558    Degrade,
559    Refuse,
560    Fail,
561}
562
563#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
564#[serde(deny_unknown_fields, default)]
565pub struct Slow {
566    pub factor: Option<f64>,
567}
568
569#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
570#[serde(deny_unknown_fields, default)]
571pub struct Degrade {
572    pub model: Option<String>,
573}
574
575#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
576#[serde(deny_unknown_fields, default)]
577pub struct Reserve {
578    pub estimate: ReserveEstimate,
579    pub fixed: Option<u64>,
580}
581
582#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
583#[serde(rename_all = "lowercase")]
584pub enum ReserveEstimate {
585    #[default]
586    Context,
587    Fixed,
588    None,
589}
590
591#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
592#[serde(deny_unknown_fields, default)]
593pub struct Pricing {
594    pub input_per_1k: Option<f64>,
595    pub output_per_1k: Option<f64>,
596    pub currency: Option<String>,
597}
598
599#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
600#[serde(deny_unknown_fields, default)]
601pub struct Mcp {
602    pub servers: Vec<McpServer>,
603    pub default_timeout: Option<Dur>,
604}
605
606/// A **service catalog entry**: a named external service this deployment may
607/// use — connection settings, one shared credential, authoritative trifecta
608/// tags (a floor for any matching endpoint, not just referencing ones), and a
609/// tool-surface ceiling consumers can only narrow. The catalog itself dials
610/// nothing; `mcp.servers` entries reference it.
611#[derive(Debug, Clone, Deserialize, PartialEq)]
612#[serde(deny_unknown_fields)]
613pub struct Service {
614    #[serde(default)]
615    pub kind: ServiceKind,
616    pub endpoint: String,
617    #[serde(default)]
618    pub headers: BTreeMap<String, String>,
619    /// Authoritative trifecta tags: unioned into any consumer whose endpoint
620    /// matches this entry — referencing or inline, `open` or `closed` mode.
621    #[serde(default)]
622    pub tags: BTreeMap<String, Vec<String>>,
623    /// The CEILING: the widest advertised-tool surface any consumer may get.
624    /// A consumer `allow` pattern not subsumed by this list is refused.
625    #[serde(default)]
626    pub allow: Option<Vec<String>>,
627    #[serde(default)]
628    pub exclude: Vec<String>,
629    #[serde(default)]
630    pub auth: Option<Auth>,
631    /// Per-instance pacing toward the service (`<burst>/<per>`, e.g. `60/1m`),
632    /// shared by every consumer of the entry in this process.
633    #[serde(default)]
634    pub rate: Option<String>,
635    #[serde(default)]
636    pub timeout: Option<Dur>,
637    /// `kind: http` only — the METHOD ceiling for `http` steps against this
638    /// entry (`[GET, POST]`); absent = any method.
639    #[serde(default)]
640    pub methods: Option<Vec<String>>,
641    /// `kind: mcp` only — a default `breaker:` policy for `mcp.tool` steps
642    /// against this entry (same shape as the step field); a step's own
643    /// `breaker:` wins.
644    #[serde(default)]
645    pub breaker: Option<Value>,
646}
647
648/// Which outbound surface a catalog entry describes. Matching is
649/// KIND-FILTERED: an MCP dial only matches `mcp` entries, an `http` step only
650/// `http` entries, and so on, because one host may legitimately serve several
651/// kinds under different trust budgets.
652#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
653#[serde(rename_all = "lowercase")]
654pub enum ServiceKind {
655    #[default]
656    Mcp,
657    Intelligence,
658    Peer,
659    Http,
660}
661
662impl ServiceKind {
663    pub fn as_str(self) -> &'static str {
664        match self {
665            ServiceKind::Mcp => "mcp",
666            ServiceKind::Intelligence => "intelligence",
667            ServiceKind::Peer => "peer",
668            ServiceKind::Http => "http",
669        }
670    }
671}
672
673/// Resolve `service:` references against the catalog and apply the
674/// unconditional tag floor. Mutates `mcp.servers` in place — after this, every
675/// server carries its effective endpoint, auth, headers, admission lists and
676/// tag set, so validation, the trifecta gate and the runtime all judge the
677/// *outcome* rather than each re-deriving it. Must therefore run BEFORE
678/// validation. Returns the resolution errors, for aggregation with
679/// validation's.
680pub fn resolve_services(s: &mut Settings) -> Vec<String> {
681    let services = s.services.clone();
682    let mut errs = Vec::new();
683    for srv in &mut s.mcp.servers {
684        let Some(name) = srv.service.clone() else {
685            continue;
686        };
687        let Some(entry) = services.get(&name) else {
688            errs.push(format!(
689                "mcp server '{}' references unknown service '{name}' (services.{name} is not declared)",
690                srv.name
691            ));
692            continue;
693        };
694        // Consumers reference, never restate: connection settings live in the
695        // catalog only, so there is exactly one place to rotate a credential
696        // or repoint a host.
697        for (restated, what) in [
698            (!srv.endpoint.is_empty(), "endpoint"),
699            (srv.auth.is_some(), "auth"),
700            (srv.oauth.is_some(), "oauth"),
701            (!srv.headers.is_empty(), "headers"),
702        ] {
703            if restated {
704                errs.push(format!(
705                    "mcp server '{}' references service '{name}' and restates `{what}` — a referencing consumer inherits connection settings from the catalog",
706                    srv.name
707                ));
708            }
709        }
710        srv.endpoint = entry.endpoint.clone();
711        srv.auth = entry.auth.clone();
712        srv.headers = entry.headers.clone();
713        if srv.timeout.is_none() {
714            srv.timeout = entry.timeout;
715        }
716        // The ceiling: consumer `allow` may only narrow (every consumer
717        // pattern must be subsumed by some catalog pattern); absent consumer
718        // `allow` inherits the ceiling itself. `exclude` unions.
719        match (&entry.allow, &mut srv.allow) {
720            (Some(ceil), Some(mine)) => {
721                for p in mine.iter() {
722                    if !ceil.iter().any(|c| pattern_subsumes(p, c)) {
723                        errs.push(format!(
724                            "mcp server '{}': allow pattern '{p}' widens the ceiling of service '{name}' (catalog allow: {ceil:?})",
725                            srv.name
726                        ));
727                    }
728                }
729            }
730            (Some(ceil), mine @ None) => *mine = Some(ceil.clone()),
731            _ => {}
732        }
733        for e in &entry.exclude {
734            if !srv.exclude.contains(e) {
735                srv.exclude.push(e.clone());
736            }
737        }
738        union_tags(&mut srv.tags, &entry.tags);
739        srv.service_rate = entry.rate.clone();
740    }
741    // The unconditional tag floor: ANY server whose endpoint matches a catalog
742    // entry gets that entry's tags unioned in — whether it referenced the entry
743    // or spelled the URL out inline, and in either egress mode. Without this,
744    // restating a catalogued endpoint inline with weaker tags would launder
745    // away the trifecta tags the catalog declares. (The entry's pacing applies
746    // to matched inline consumers too, for the same reason.)
747    for srv in &mut s.mcp.servers {
748        if srv.endpoint.is_empty() {
749            continue;
750        }
751        if let Some((name, entry)) = service_match(&services, ServiceKind::Mcp, &srv.endpoint) {
752            union_tags(&mut srv.tags, &entry.tags);
753            if srv.service.is_none() {
754                srv.service = Some(name.clone());
755                srv.service_rate = entry.rate.clone();
756            }
757        }
758    }
759    // `a2a.peers[].service` references resolve the same way against
760    // `kind: peer` entries — inherit, never restate.
761    for peer in &mut s.a2a.peers {
762        let Some(name) = peer.service.clone() else {
763            continue;
764        };
765        let entry = match services.get(&name) {
766            Some(e) if e.kind == ServiceKind::Peer => e,
767            Some(e) => {
768                errs.push(format!(
769                    "a2a peer '{}' references service '{name}', which is `kind: {}` (a peer reference needs `kind: peer`)",
770                    peer.name,
771                    e.kind.as_str()
772                ));
773                continue;
774            }
775            None => {
776                errs.push(format!(
777                    "a2a peer '{}' references unknown service '{name}' (services.{name} is not declared)",
778                    peer.name
779                ));
780                continue;
781            }
782        };
783        for (restated, what) in [
784            (!peer.endpoint.is_empty(), "endpoint"),
785            (peer.auth.is_some(), "auth"),
786            (!peer.headers.is_empty(), "headers"),
787        ] {
788            if restated {
789                errs.push(format!(
790                    "a2a peer '{}' references service '{name}' and restates `{what}` — a referencing consumer inherits connection settings from the catalog",
791                    peer.name
792                ));
793            }
794        }
795        peer.endpoint = entry.endpoint.clone();
796        peer.auth = entry.auth.clone();
797        peer.headers = entry.headers.clone();
798    }
799    errs
800}
801
802/// Does catalog pattern `ceiling` cover consumer pattern `p`? Patterns are the
803/// registry's trailing-`*` globs. A literal ceiling covers only itself; a
804/// glob ceiling covers any pattern whose fixed prefix extends the ceiling's.
805fn pattern_subsumes(p: &str, ceiling: &str) -> bool {
806    match ceiling.strip_suffix('*') {
807        Some(prefix) => p.strip_suffix('*').unwrap_or(p).starts_with(prefix),
808        None => p == ceiling,
809    }
810}
811
812/// Union `from` into `into` (per tool-pattern key; tag lists dedup).
813fn union_tags(into: &mut BTreeMap<String, Vec<String>>, from: &BTreeMap<String, Vec<String>>) {
814    for (k, list) in from {
815        let slot = into.entry(k.clone()).or_default();
816        for t in list {
817            if !slot.contains(t) {
818                slot.push(t.clone());
819            }
820        }
821    }
822}
823
824/// Match a URL against the catalog's entries OF ONE KIND: scheme and authority
825/// must be equal (host case-insensitively), and the URL's path must extend the
826/// entry's path on a segment boundary — so `/v1` never matches `/v1betaX`.
827/// Returns the matching entry and refuses nothing; the caller decides what a
828/// non-match means (`Egress::Closed` refuses it).
829pub fn service_match<'a>(
830    services: &'a BTreeMap<String, Service>,
831    kind: ServiceKind,
832    url: &str,
833) -> Option<(&'a String, &'a Service)> {
834    let (scheme, authority, path) = split_url(url)?;
835    services.iter().find(|(_, e)| {
836        if e.kind != kind {
837            return false;
838        }
839        let Some((es, ea, ep)) = split_url(&e.endpoint) else {
840            return false;
841        };
842        scheme == es
843            && authority.eq_ignore_ascii_case(&ea)
844            && (ep.is_empty()
845                || ep == "/"
846                || path == ep
847                || (path.starts_with(&ep)
848                    && (ep.ends_with('/') || path.as_bytes().get(ep.len()) == Some(&b'/'))))
849    })
850}
851
852/// `scheme://authority/path` → (scheme, authority, path). `unix:` sockets
853/// have no authority; the socket path is the authority for matching purposes.
854fn split_url(url: &str) -> Option<(String, String, String)> {
855    if let Some(rest) = url
856        .strip_prefix("unix://")
857        .or_else(|| url.strip_prefix("unix:"))
858    {
859        return Some(("unix".into(), rest.to_string(), String::new()));
860    }
861    let (scheme, rest) = url.split_once("://")?;
862    let (authority, path) = match rest.split_once('/') {
863        Some((a, p)) => (a, format!("/{p}")),
864        None => (rest, String::new()),
865    };
866    Some((scheme.to_string(), authority.to_string(), path))
867}
868
869/// The dial-time egress check. `Open` always passes; `Closed` requires the URL
870/// to match a catalog entry of the surface's kind, so an uncatalogued host
871/// cannot be reached even if some other config path names it.
872pub fn egress_allows(
873    services: &BTreeMap<String, Service>,
874    egress: Egress,
875    kind: ServiceKind,
876    url: &str,
877) -> Result<(), String> {
878    if egress == Egress::Open || service_match(services, kind, url).is_some() {
879        return Ok(());
880    }
881    Err(format!(
882        "security.egress is `closed` and {url} matches no `kind: {}` services: catalog entry — catalog the endpoint to allow it",
883        kind.as_str()
884    ))
885}
886
887#[derive(Debug, Clone, Deserialize, PartialEq)]
888#[serde(deny_unknown_fields)]
889pub struct McpServer {
890    pub name: String,
891    /// Either a literal URL, or empty when `service:` references a catalog
892    /// entry — resolution fills it in before anything dials.
893    #[serde(default)]
894    pub endpoint: String,
895    /// Reference a `services:` catalog entry: inherit its connection settings
896    /// (restating `endpoint`/`auth`/`headers` here is refused) and narrow its
897    /// tool ceiling.
898    #[serde(default)]
899    pub service: Option<String>,
900    /// NOT a config key: the referenced entry's `rate:`, stamped by
901    /// resolution so `to_spec` can carry it to every process's pace registry.
902    #[serde(skip)]
903    pub service_rate: Option<String>,
904    #[serde(default)]
905    pub ns: Option<String>,
906    #[serde(default)]
907    pub headers: BTreeMap<String, String>,
908    #[serde(default)]
909    pub tags: BTreeMap<String, Vec<String>>,
910    /// Tool admission control, on the server's ADVERTISED names (before any
911    /// `ns` prefixing): with `allow`, only matching tools register; anything
912    /// matching `exclude` never registers, and exclude beats allow. Globs are
913    /// the registry's `pattern_matches` (trailing `*`).
914    #[serde(default)]
915    pub allow: Option<Vec<String>>,
916    #[serde(default)]
917    pub exclude: Vec<String>,
918    #[serde(default)]
919    pub aauth: Option<bool>,
920    #[serde(default)]
921    pub oauth: Option<McpOauth>,
922    /// A unified credential provider — `static` / `oauth2` (device login,
923    /// refresh) / `aws` / `spiffe`. Interactive providers obtain their token
924    /// via `agentd login mcp:<name>` and the daemon only reads the cached
925    /// token, so an unattended process never has to run a browser flow.
926    /// Coexists with the narrower `oauth` client-credentials shortcut.
927    #[serde(default)]
928    pub auth: Option<Auth>,
929    #[serde(default)]
930    pub timeout: Option<Dur>,
931}
932
933impl McpServer {
934    /// The flattened, deduplicated trifecta tag set across every tool-pattern
935    /// key. An unknown tag name is an error rather than a silent drop, since
936    /// a typo'd tag would otherwise read as "this server is untagged".
937    pub fn tag_set(&self) -> Result<Vec<crate::sec::scope::TrifectaTag>, String> {
938        let mut out = Vec::new();
939        for list in self.tags.values() {
940            for t in list {
941                let tag = crate::sec::scope::TrifectaTag::parse(t).ok_or_else(|| {
942                    format!("mcp server '{}' has unknown trifecta tag '{t}'", self.name)
943                })?;
944                if !out.contains(&tag) {
945                    out.push(tag);
946                }
947            }
948        }
949        Ok(out)
950    }
951
952    /// Lower to the runtime spec the MCP client and the spawn payload carry.
953    pub fn to_spec(&self) -> Result<super::McpServerSpec, String> {
954        Ok(super::McpServerSpec {
955            name: self.name.clone(),
956            endpoint: self.endpoint.clone(),
957            headers: self
958                .headers
959                .iter()
960                .map(|(k, v)| (k.clone(), v.clone()))
961                .collect(),
962            tags: self.tag_set()?,
963            aauth: self.aauth,
964            // The OAuth client-credentials config must be carried into the
965            // runtime spec: dropping it here would leave `mcp.servers[].oauth`
966            // configured but inert, and the dial would go out unauthenticated.
967            oauth: self.oauth.as_ref().map(|o| super::McpOauthSpec {
968                token_url: o.token_url.clone(),
969                client_id: o.client_id.clone(),
970                client_secret: o.client_secret.0.clone(),
971                scope: o.scope.clone(),
972            }),
973            auth: self.auth.as_ref().map(|a| a.to_spec()),
974            service: self.service.clone(),
975            rate: self.service_rate.clone(),
976        })
977    }
978}
979
980#[derive(Debug, Clone, Deserialize, PartialEq)]
981#[serde(deny_unknown_fields)]
982pub struct McpOauth {
983    pub token_url: String,
984    pub client_id: String,
985    pub client_secret: Secret,
986    #[serde(default)]
987    pub scope: Option<String>,
988}
989
990/// A unified per-endpoint authentication provider. A flat, `kind`-discriminated
991/// record: only the fields relevant to the chosen `kind` are set, and semantic
992/// validation is what enforces which of them are required — the type itself
993/// cannot, because every field is optional for some other kind.
994#[derive(Debug, Clone, Deserialize, PartialEq)]
995#[serde(deny_unknown_fields)]
996pub struct Auth {
997    pub kind: AuthKind,
998    // --- oauth2 / oidc ---
999    /// Issuer base URL for `.well-known` metadata discovery (RFC 8414 / OIDC).
1000    /// When set, it fills in whichever of the token / device-authorization /
1001    /// authorization endpoints the document leaves unset.
1002    #[serde(default)]
1003    pub issuer: Option<String>,
1004    #[serde(default)]
1005    pub token_url: Option<String>,
1006    #[serde(default)]
1007    pub device_authorization_url: Option<String>,
1008    #[serde(default)]
1009    pub authorization_url: Option<String>,
1010    #[serde(default)]
1011    pub client_id: Option<String>,
1012    /// A confidential client's secret (`{{secret:…}}`); omit for a public client
1013    /// (the device grant needs no secret).
1014    #[serde(default)]
1015    pub client_secret: Option<Secret>,
1016    /// `device` (default, interactive), `authorization_code`, or
1017    /// `client_credentials` (headless M2M).
1018    #[serde(default)]
1019    pub grant: Option<OAuthGrant>,
1020    #[serde(default)]
1021    pub scopes: Vec<String>,
1022    #[serde(default)]
1023    pub audience: Option<String>,
1024    // --- static ---
1025    /// A static bearer (`{{secret:…}}`) → `Authorization: Bearer …`.
1026    #[serde(default)]
1027    pub token: Option<Secret>,
1028    /// A static credential under an arbitrary header name (paired with `value`).
1029    #[serde(default)]
1030    pub header: Option<String>,
1031    #[serde(default)]
1032    pub value: Option<Secret>,
1033    // --- aws (SigV4) ---
1034    #[serde(default)]
1035    pub region: Option<String>,
1036    /// The AWS service to sign for (e.g. `bedrock`, `execute-api`).
1037    #[serde(default)]
1038    pub service: Option<String>,
1039    /// The credential source: `env` / `static`, `sso` (IAM Identity Center
1040    /// interactive login → temporary credentials), `imds` (the EC2 instance
1041    /// role) or `irsa` (the Kubernetes projected service-account token).
1042    /// Unset behaves as `env`.
1043    #[serde(default)]
1044    pub source: Option<String>,
1045    /// aws `source: sso` — the IAM Identity Center portal start URL, the account,
1046    /// and the permission-set role to assume (via `agentd login`).
1047    #[serde(default)]
1048    pub sso_start_url: Option<String>,
1049    #[serde(default)]
1050    pub account_id: Option<String>,
1051    #[serde(default)]
1052    pub role_name: Option<String>,
1053    // --- spiffe (workload identity) ---
1054    /// The SVID type: `jwt` (a rotating JWT-SVID bearer read from a file) or
1055    /// `x509` (an mTLS client identity rather than a request signer).
1056    #[serde(default)]
1057    pub svid: Option<String>,
1058    /// Path to the SPIRE-written JWT-SVID token file (re-read per request, so a
1059    /// rotation is picked up).
1060    #[serde(default)]
1061    pub jwt_svid_file: Option<String>,
1062    /// Paths to the X.509-SVID cert + key (for `svid: x509`).
1063    #[serde(default)]
1064    pub svid_file: Option<String>,
1065    #[serde(default)]
1066    pub key_file: Option<String>,
1067}
1068
1069impl Auth {
1070    /// Lower to the secret-free runtime [`AuthSpec`](super::AuthSpec) (spawn
1071    /// payload). Secrets stay as `{{secret:…}}` templates.
1072    pub fn to_spec(&self) -> super::AuthSpec {
1073        super::AuthSpec {
1074            kind: match self.kind {
1075                AuthKind::Static => "static",
1076                AuthKind::Oauth2 => "oauth2",
1077                AuthKind::Aws => "aws",
1078                AuthKind::Spiffe => "spiffe",
1079            }
1080            .to_string(),
1081            grant: self.grant.map(|g| {
1082                match g {
1083                    OAuthGrant::Device => "device",
1084                    OAuthGrant::AuthorizationCode => "authorization_code",
1085                    OAuthGrant::ClientCredentials => "client_credentials",
1086                }
1087                .to_string()
1088            }),
1089            issuer: self.issuer.clone(),
1090            token_url: self.token_url.clone(),
1091            device_authorization_url: self.device_authorization_url.clone(),
1092            authorization_url: self.authorization_url.clone(),
1093            client_id: self.client_id.clone(),
1094            client_secret: self.client_secret.as_ref().map(|s| s.0.clone()),
1095            scopes: self.scopes.clone(),
1096            audience: self.audience.clone(),
1097            token: self.token.as_ref().map(|s| s.0.clone()),
1098            header: self.header.clone(),
1099            value: self.value.as_ref().map(|s| s.0.clone()),
1100            region: self.region.clone(),
1101            service: self.service.clone(),
1102            source: self.source.clone(),
1103            sso_start_url: self.sso_start_url.clone(),
1104            account_id: self.account_id.clone(),
1105            role_name: self.role_name.clone(),
1106            svid: self.svid.clone(),
1107            jwt_svid_file: self.jwt_svid_file.clone(),
1108            svid_file: self.svid_file.clone(),
1109            key_file: self.key_file.clone(),
1110        }
1111    }
1112}
1113
1114/// The authentication provider family.
1115#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1116#[serde(rename_all = "snake_case")]
1117pub enum AuthKind {
1118    /// A static bearer or named-header credential.
1119    Static,
1120    /// OAuth 2.1 / OIDC — device grant, authorization-code, or client-credentials.
1121    Oauth2,
1122    /// AWS Signature Version 4 — every request is SigV4-signed.
1123    Aws,
1124    /// SPIFFE/SPIRE workload identity — a JWT-SVID bearer, or X.509-SVID mTLS.
1125    Spiffe,
1126}
1127
1128/// The OAuth 2.1 grant type.
1129#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1130#[serde(rename_all = "snake_case")]
1131pub enum OAuthGrant {
1132    /// RFC 8628 device authorization — the interactive default.
1133    Device,
1134    /// RFC 7636 authorization-code + PKCE (browser loopback).
1135    AuthorizationCode,
1136    /// The headless machine-to-machine grant.
1137    ClientCredentials,
1138}
1139
1140#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1141#[serde(deny_unknown_fields, default)]
1142pub struct Tools {
1143    pub disabled: Vec<String>,
1144    pub overrides: BTreeMap<String, ToolOverride>,
1145}
1146
1147#[derive(Debug, Clone, Deserialize, PartialEq)]
1148#[serde(deny_unknown_fields)]
1149pub struct ToolOverride {
1150    pub server: String,
1151    pub tool: String,
1152    #[serde(default)]
1153    pub args: Option<String>,
1154    #[serde(default)]
1155    pub result: Option<String>,
1156}
1157
1158#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1159#[serde(deny_unknown_fields, default)]
1160pub struct Store {
1161    pub kind: StoreKind,
1162    pub prefix: Option<String>,
1163    pub mcp: Option<StoreMcp>,
1164    pub http: Option<StoreHttp>,
1165    pub file: Option<StoreFile>,
1166    pub checkpoint: Checkpoint,
1167    pub durability: Durability,
1168    pub retention: Retention,
1169    pub on_error: StoreOnError,
1170    pub audit: bool,
1171    pub timeout: Option<Dur>,
1172}
1173
1174impl Store {
1175    pub fn prefix(&self) -> &str {
1176        self.prefix.as_deref().unwrap_or("agentd")
1177    }
1178}
1179
1180#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1181#[serde(rename_all = "lowercase")]
1182pub enum StoreKind {
1183    Mcp,
1184    Http,
1185    /// The local filesystem: one file per key under a root directory,
1186    /// single-writer, and durable to whatever the filesystem is.
1187    File,
1188    Memory,
1189    #[default]
1190    None,
1191}
1192
1193/// `store.file`. The only setting is where the state lives; the adapter needs
1194/// nothing else, so the block itself is optional — `kind: file` with no block
1195/// resolves the root from the environment ([`file_store_root`]).
1196#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1197#[serde(deny_unknown_fields)]
1198pub struct StoreFile {
1199    #[serde(default)]
1200    pub path: Option<String>,
1201    /// Shed new work when the store's filesystem has less than this free
1202    /// (`256MB`, `1.5GiB`, plain bytes; `"0"` disables). Warn at twice it.
1203    /// Default 256MB: a checkpoint failure at ENOSPC HALTS the daemon, so with
1204    /// under a quarter-gig free, refusing new runs while draining the current
1205    /// ones is almost certainly what the operator would have chosen — and the
1206    /// alternative was choosing nothing and dying mid-write.
1207    #[serde(default)]
1208    pub min_free: Option<String>,
1209}
1210
1211/// One declared event stream.
1212#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1213#[serde(deny_unknown_fields, default)]
1214pub struct StreamCfg {
1215    pub retention: StreamRetention,
1216}
1217
1218/// Retention: whichever bound trims first. Neither set = the 10k default —
1219/// an unbounded stream on a disk the pressure system guards would be a
1220/// self-inflicted shed.
1221#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1222#[serde(deny_unknown_fields, default)]
1223pub struct StreamRetention {
1224    pub max_events: Option<u64>,
1225    pub max_age: Option<Dur>,
1226}
1227
1228impl StreamCfg {
1229    pub fn max_events(&self) -> u64 {
1230        self.retention.max_events.unwrap_or(10_000)
1231    }
1232    pub fn max_age_ms(&self) -> Option<u64> {
1233        self.retention.max_age.map(|d| d.0.as_millis() as u64)
1234    }
1235}
1236
1237/// The `file` store's root directory, first that applies:
1238/// `store.file.path`, `$AGENTD_STATE_DIR`, `$XDG_STATE_HOME/agentd/state`,
1239/// `$HOME/.local/state/agentd/state`, else the OS temp dir.
1240///
1241/// This is deliberately the same chain — and the same order — that
1242/// [`crate::auth::cache::default_dir`] uses for the credential cache, one
1243/// sibling over (`state` beside `creds`): an operator who has learned where
1244/// agentd keeps its tokens already knows where it keeps its state, and one
1245/// `XDG_STATE_HOME` moves both. Resolution lives here, next to the schema, so
1246/// the startup log, `--capabilities` and [`crate::store::open`] all name the
1247/// one directory instead of each re-deriving it.
1248///
1249/// The last resort is the OS temp dir: a store that is *there* survives a
1250/// process restart but not a reboot, which is why the runtime logs the
1251/// resolved path and whether it was defaulted, rather than letting a user
1252/// believe more durability than the filesystem actually delivers.
1253pub fn file_store_root(store: &Store) -> std::path::PathBuf {
1254    file_store_root_in(store, &|k| std::env::var_os(k))
1255}
1256
1257/// [`file_store_root`] with the environment injected. The chain is the part
1258/// worth testing and the process env is shared by every test in this binary,
1259/// so the lookup is a parameter — the same shape `unresolved_secret_ref` uses.
1260fn file_store_root_in(
1261    store: &Store,
1262    env: &dyn Fn(&str) -> Option<std::ffi::OsString>,
1263) -> std::path::PathBuf {
1264    use std::path::PathBuf;
1265    if let Some(p) = store.file.as_ref().and_then(|f| f.path.as_deref()) {
1266        return PathBuf::from(p);
1267    }
1268    if let Some(d) = env("AGENTD_STATE_DIR") {
1269        return PathBuf::from(d);
1270    }
1271    if let Some(d) = env("XDG_STATE_HOME") {
1272        return PathBuf::from(d).join("agentd").join("state");
1273    }
1274    if let Some(h) = env("HOME") {
1275        return PathBuf::from(h)
1276            .join(".local")
1277            .join("state")
1278            .join("agentd")
1279            .join("state");
1280    }
1281    std::env::temp_dir().join("agentd").join("state")
1282}
1283
1284#[derive(Debug, Clone, Deserialize, PartialEq)]
1285#[serde(deny_unknown_fields)]
1286pub struct StoreMcp {
1287    pub server: String,
1288    #[serde(default)]
1289    pub put: Option<StoreOp>,
1290    #[serde(default)]
1291    pub get: Option<StoreOp>,
1292    #[serde(default)]
1293    pub list: Option<StoreOp>,
1294    #[serde(default)]
1295    pub delete: Option<StoreOp>,
1296}
1297
1298#[derive(Debug, Clone, Deserialize, PartialEq)]
1299#[serde(deny_unknown_fields)]
1300pub struct StoreOp {
1301    pub tool: String,
1302    #[serde(default)]
1303    pub args: Option<String>,
1304    #[serde(default)]
1305    pub ok: Option<String>,
1306    #[serde(default)]
1307    pub conflict: Option<String>,
1308    #[serde(default)]
1309    pub value: Option<String>,
1310    #[serde(default)]
1311    pub keys: Option<String>,
1312}
1313
1314#[derive(Debug, Clone, Deserialize, PartialEq)]
1315#[serde(deny_unknown_fields)]
1316pub struct StoreHttp {
1317    pub base_url: String,
1318    #[serde(default)]
1319    pub headers: BTreeMap<String, String>,
1320    #[serde(default)]
1321    pub get: Option<HttpOp>,
1322    #[serde(default)]
1323    pub put: Option<HttpOp>,
1324    #[serde(default)]
1325    pub list: Option<HttpOp>,
1326    #[serde(default)]
1327    pub delete: Option<HttpOp>,
1328}
1329
1330#[derive(Debug, Clone, Deserialize, PartialEq)]
1331#[serde(deny_unknown_fields)]
1332pub struct HttpOp {
1333    #[serde(default)]
1334    pub method: Option<String>,
1335    pub url: String,
1336    #[serde(default)]
1337    pub body: Option<String>,
1338    #[serde(default)]
1339    pub value: Option<String>,
1340    #[serde(default)]
1341    pub keys: Option<String>,
1342    #[serde(default)]
1343    pub conflict_status: Option<u16>,
1344}
1345
1346#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1347#[serde(deny_unknown_fields, default)]
1348pub struct Checkpoint {
1349    pub debounce_ms: Option<u64>,
1350}
1351
1352/// What to keep once a run is over.
1353///
1354/// A long-lived instance accumulates one durable record per run forever. On a
1355/// laptop that is the difference between an agent that runs for a month and one
1356/// that fills a disk — and the store had no eviction at all, so "forever" was
1357/// literal.
1358#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1359#[serde(deny_unknown_fields, default)]
1360pub struct Retention {
1361    pub runs: RunRetention,
1362}
1363
1364#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1365#[serde(deny_unknown_fields, default)]
1366pub struct RunRetention {
1367    /// Keep at most this many terminal runs (newest first).
1368    pub keep_last: Option<u32>,
1369    /// Drop a terminal run older than this.
1370    pub ttl: Option<Dur>,
1371}
1372
1373#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1374#[serde(deny_unknown_fields, default)]
1375pub struct Durability {
1376    pub a2a: Option<DurabilityLevel>,
1377    pub steps: Option<DurabilityLevel>,
1378    /// The default durability CLASS for work (runs + subagent records):
1379    /// `durable` (the default — everything checkpoints and survives a
1380    /// restart) or `ephemeral` (nothing persists unless a workflow says
1381    /// `durable: true` / a spawn passes `durable: true` — the fast path for
1382    /// deployments that treat work as recomputable). The inbox, tasks,
1383    /// memory and credentials stay durable regardless.
1384    pub work: Option<WorkDurability>,
1385}
1386
1387#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1388#[serde(rename_all = "lowercase")]
1389pub enum WorkDurability {
1390    Durable,
1391    Ephemeral,
1392}
1393
1394#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1395#[serde(rename_all = "lowercase")]
1396pub enum DurabilityLevel {
1397    Strict,
1398    Eventual,
1399}
1400
1401#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1402#[serde(rename_all = "lowercase")]
1403pub enum StoreOnError {
1404    #[default]
1405    Halt,
1406    Degrade,
1407}
1408
1409#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1410#[serde(deny_unknown_fields, default)]
1411pub struct Memory {
1412    pub max_value_bytes: Option<u64>,
1413    pub list_default_limit: Option<u64>,
1414}
1415
1416#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1417#[serde(deny_unknown_fields, default)]
1418pub struct Context {
1419    pub compact_at: Option<f64>,
1420    pub keep_last: Option<u32>,
1421    /// The model's context window in tokens (overrides the value inferred
1422    /// from `intelligence.model`) — the base of the compaction threshold.
1423    pub model_window: Option<u64>,
1424    pub plan: Plan,
1425    /// The system-prompt template. Unset = the built-in default,
1426    /// which `agentd --context-template` prints. Written in the small
1427    /// `{{#if}}` / `{{#each}}` language over the environment data; expressions
1428    /// are a path first and CEL second.
1429    pub template: Option<String>,
1430    /// Named alternates a node selects with `context: {template: <name>}` —
1431    /// e.g. a `minimal` template for extraction steps that need no
1432    /// environment.
1433    pub templates: BTreeMap<String, String>,
1434    /// Compaction's model-facing half: the summarizer prompt and (optionally)
1435    /// a cheaper model to run it on.
1436    pub summarize: Summarize,
1437}
1438
1439#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1440#[serde(deny_unknown_fields, default)]
1441pub struct Summarize {
1442    /// Override the summarizer's guidance. The JSON schema it must satisfy
1443    /// (`goals`/`decisions`/`open`/`facts`/`narrative`) is NOT yours to
1444    /// change — the summary is parsed back into the context, so a prompt that
1445    /// asks for another shape produces a refusal, not a nicer summary.
1446    pub prompt: Option<String>,
1447    /// Summarize on this model instead of the instance's. Compaction is a
1448    /// recurring fixed cost that rarely needs the frontier model.
1449    pub model: Option<String>,
1450}
1451
1452#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1453#[serde(deny_unknown_fields, default)]
1454pub struct Plan {
1455    pub max_items: Option<u32>,
1456}
1457
1458#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1459#[serde(deny_unknown_fields, default)]
1460pub struct Knowledge {
1461    pub server: Option<String>,
1462    pub auto_context: AutoContext,
1463}
1464
1465#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1466#[serde(deny_unknown_fields, default)]
1467pub struct AutoContext {
1468    pub on: AutoContextOn,
1469    pub top_k: Option<u32>,
1470    pub max_bytes: Option<u64>,
1471}
1472
1473#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1474#[serde(rename_all = "lowercase")]
1475pub enum AutoContextOn {
1476    Turn,
1477    #[default]
1478    Never,
1479}
1480
1481#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1482#[serde(deny_unknown_fields, default)]
1483pub struct Search {
1484    pub server: Option<String>,
1485}
1486
1487#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1488#[serde(deny_unknown_fields, default)]
1489pub struct Skills {
1490    pub sources: Vec<SkillSource>,
1491    /// A LOCAL folder of skill files, beside the config rather than behind an
1492    /// MCP server. Skills are documents; requiring a server to serve a
1493    /// markdown file was the one place the "capability comes from a server"
1494    /// rule bought nothing — a skill grants no tool, it is prose the model
1495    /// reads. `skills/` beside the config is adopted automatically when this
1496    /// is unset and the folder has files in it.
1497    pub dir: Option<String>,
1498    pub reference_prefix: Option<String>,
1499    pub max_loaded: Option<u32>,
1500    pub max_bytes: Option<u64>,
1501}
1502
1503#[derive(Debug, Clone, Deserialize, PartialEq)]
1504#[serde(deny_unknown_fields)]
1505pub struct SkillSource {
1506    pub server: String,
1507    #[serde(default)]
1508    pub discover: Discover,
1509    #[serde(default)]
1510    pub filter: Option<String>,
1511}
1512
1513#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1514#[serde(rename_all = "lowercase")]
1515pub enum Discover {
1516    Prompts,
1517    Resources,
1518    #[default]
1519    Auto,
1520}
1521
1522#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1523#[serde(deny_unknown_fields, default)]
1524pub struct Limits {
1525    pub max_runs: Option<u32>,
1526    pub run: RunLimits,
1527    pub subagents: SubagentLimits,
1528    pub inline_max_bytes: Option<u64>,
1529    pub step_timeout: Option<Dur>,
1530    pub workflow: WorkflowLimits,
1531    /// How many `message` hops may chain before a delivery is refused
1532    /// (default [`DEFAULT_MESSAGE_DEPTH`]). This is the fail-closed gate on
1533    /// message → turn → run → message, not a tuning knob: the loop it stops
1534    /// re-arms itself faster than pressure shedding can hold it, because
1535    /// shedding queues new turns while the chain keeps adding more.
1536    pub max_message_depth: Option<u32>,
1537}
1538
1539/// The default ceiling on chained `message` deliveries. Deep enough for real
1540/// delegation — a schedule waking the agent, which runs a workflow, which asks
1541/// a question back — and shallow enough that a runaway is caught in seconds.
1542pub const DEFAULT_MESSAGE_DEPTH: u32 = 8;
1543
1544impl Limits {
1545    pub fn message_depth(&self) -> u32 {
1546        self.max_message_depth.unwrap_or(DEFAULT_MESSAGE_DEPTH)
1547    }
1548}
1549
1550/// Ceilings a workflow definition is checked against at load time.
1551#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1552#[serde(deny_unknown_fields, default)]
1553pub struct WorkflowLimits {
1554    /// The most concurrent lanes a `foreach`/`batch` body may use. A definition
1555    /// asking for more is REFUSED at load rather than quietly clamped: silent
1556    /// clamping is how a workflow ends up running eight-wide while its author
1557    /// believes it runs fifty, and the whole point of the field whitelist is
1558    /// that a knob either does what it says or fails loudly.
1559    pub fan_out: Option<u32>,
1560}
1561
1562#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1563#[serde(deny_unknown_fields, default)]
1564pub struct RunLimits {
1565    pub steps: Option<u32>,
1566    pub tokens: Option<u64>,
1567    pub deadline: Option<Dur>,
1568}
1569
1570impl RunLimits {
1571    pub fn steps(&self) -> u32 {
1572        self.steps.unwrap_or(500)
1573    }
1574    pub fn tokens(&self) -> u64 {
1575        self.tokens.unwrap_or(2_000_000)
1576    }
1577    pub fn deadline(&self) -> Duration {
1578        self.deadline
1579            .map(|d| d.0)
1580            .unwrap_or(Duration::from_secs(3600))
1581    }
1582}
1583
1584#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1585#[serde(deny_unknown_fields, default)]
1586pub struct SubagentLimits {
1587    pub depth: Option<u32>,
1588    pub breadth: Option<u32>,
1589    pub total: Option<u32>,
1590    pub rate: Option<String>,
1591    /// Instance-tier children are far heavier than flat workers, so they carry
1592    /// their own, much tighter caps rather than sharing the ones above.
1593    /// Defaults: 2 live, 8 over the parent's lifetime, `4/1h`.
1594    pub instances: InstanceLimits,
1595}
1596
1597#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1598#[serde(deny_unknown_fields, default)]
1599pub struct InstanceLimits {
1600    pub breadth: Option<u32>,
1601    pub total: Option<u32>,
1602    pub rate: Option<String>,
1603}
1604
1605/// The `subagents:` section: what the model may spawn, and how.
1606#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1607#[serde(deny_unknown_fields, default)]
1608pub struct Subagents {
1609    /// `false` makes templates the ONLY spawn path — every child the model can
1610    /// create is a definition the operator reviewed. Default `true` (freeform
1611    /// flat-tier spawns keep working; there is no freeform INSTANCE spawn
1612    /// regardless).
1613    pub allow_freeform: Option<bool>,
1614    /// Applied to every spawn (flat and templated) unless overridden at the
1615    /// template or call site.
1616    pub defaults: SubagentDefaults,
1617    /// Named, operator-authored definitions. A template whose `instruction`
1618    /// carries no config-defining directives spawns a flat worker; one that
1619    /// defines machinery (`:::workflow`/`:::mcp`/`:::stream`/`:::config`/
1620    /// `:::tools`) spawns an instance-tier child. The tier follows from the
1621    /// text, so it is never declared twice and cannot disagree with itself.
1622    pub templates: BTreeMap<String, SubagentTemplate>,
1623}
1624
1625#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1626#[serde(deny_unknown_fields, default)]
1627pub struct SubagentDefaults {
1628    pub model: Option<String>,
1629    pub priority: Option<String>,
1630    pub mode: Option<String>,
1631    /// Default durability class for spawns (see `SubagentTemplate::durable`).
1632    pub durable: Option<bool>,
1633    /// The per-spawn limits object the `subagent` step takes (`max_tokens`,
1634    /// `deadline`, `memory`, `cpu`, `max_steps`) — kept raw; the spawn path
1635    /// parses it exactly like call-site limits.
1636    pub limits: Option<Value>,
1637}
1638
1639#[derive(Debug, Clone, Deserialize, PartialEq)]
1640#[serde(deny_unknown_fields)]
1641pub struct SubagentTemplate {
1642    /// A full instruction document. Directive extraction runs ONCE, at boot,
1643    /// on this operator-authored text; `params` fold in later at spawn as data
1644    /// and are never re-parsed for directives, so a param value cannot
1645    /// introduce machinery the operator never wrote.
1646    pub instruction: String,
1647    /// The ONLY holes the model may fill, schema-validated at spawn.
1648    #[serde(default)]
1649    pub params: BTreeMap<String, ParamSpec>,
1650    /// Flat tier only: narrowing grants from the parent's server/tool set.
1651    #[serde(default)]
1652    pub servers: Option<Vec<String>>,
1653    #[serde(default)]
1654    pub tools: Option<Vec<String>>,
1655    /// Flat tier: the full per-spawn limits object. Instance tier: OS caps
1656    /// only (`memory`, `cpu`) — token ceilings live in `budget`.
1657    #[serde(default)]
1658    pub limits: Option<Value>,
1659    #[serde(default)]
1660    pub mode: Option<String>,
1661    #[serde(default)]
1662    pub model: Option<String>,
1663    #[serde(default)]
1664    pub priority: Option<String>,
1665    #[serde(default)]
1666    pub skills: Option<Value>,
1667    #[serde(default)]
1668    pub context: Option<Value>,
1669    #[serde(default)]
1670    pub output_contract: Option<String>,
1671    #[serde(default)]
1672    pub output_schema: Option<Value>,
1673    /// Instance tier only: the child's own budget (a `Budget` document),
1674    /// enforced in-child with `on_exhausted: refuse`.
1675    #[serde(default)]
1676    pub budget: Option<Value>,
1677    /// Instance tier only: a lifetime after which the child retires
1678    /// gracefully rather than being killed.
1679    #[serde(default)]
1680    pub ttl: Option<Dur>,
1681    /// A signal name (templated over params) whose delivery IN THE CHILD
1682    /// retires it.
1683    #[serde(default)]
1684    pub until: Option<String>,
1685    /// One live child at a time; its A2A peer alias is the template name.
1686    #[serde(default)]
1687    pub singleton: bool,
1688    /// Durability class: `false` ⇒ the spawn's record is memory-only (and an
1689    /// instance child runs on a memory store — no restore-respawn). Absent ⇒
1690    /// the deployment default (`store.durability.work`).
1691    #[serde(default)]
1692    pub durable: Option<bool>,
1693    /// Instance tier, `mode: sync`: `{workflow: <name>}` — the spawn
1694    /// resolves when the CHILD's named workflow first completes,
1695    /// returning that run's output. Composed as a reporter workflow in the
1696    /// child; requires the parent to have an A2A listener.
1697    #[serde(default)]
1698    pub result: Option<Value>,
1699    /// Instance tier: child streams mirrored into the PARENT's same-named
1700    /// streams — each event forwarded over the socket and
1701    /// appended with source `instance:<handle>`. Requires the stream declared
1702    /// on BOTH sides and a parent A2A listener.
1703    #[serde(default)]
1704    pub mirror_streams: Option<Vec<String>>,
1705}
1706
1707/// One declared template parameter — the only hole a spawn may fill.
1708#[derive(Debug, Clone, Deserialize, PartialEq)]
1709#[serde(deny_unknown_fields)]
1710pub struct ParamSpec {
1711    /// `string` (default) | `number` | `integer` | `boolean`.
1712    #[serde(rename = "type", default)]
1713    pub kind: Option<String>,
1714    #[serde(default)]
1715    pub required: bool,
1716    #[serde(default)]
1717    pub default: Option<Value>,
1718    #[serde(rename = "enum", default)]
1719    pub one_of: Option<Vec<Value>>,
1720    #[serde(default)]
1721    pub description: Option<String>,
1722}
1723
1724#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1725#[serde(deny_unknown_fields, default)]
1726pub struct Lifecycle {
1727    pub run_until: RunUntil,
1728    pub idle_grace: Option<Dur>,
1729    pub drain_timeout: Option<Dur>,
1730    pub run_id: Option<String>,
1731    pub exit_code_map: BTreeMap<String, i32>,
1732    pub watch_config: bool,
1733    /// Delivery of this signal begins graceful shutdown — the retirement
1734    /// trigger a parent composes into an instance-tier child
1735    /// (`until:` on the template), and available to any daemon that should
1736    /// drain when a named signal arrives.
1737    pub until_signal: Option<String>,
1738}
1739
1740impl Lifecycle {
1741    pub fn drain_timeout(&self) -> Duration {
1742        self.drain_timeout
1743            .map(|d| d.0)
1744            .unwrap_or(Duration::from_secs(25))
1745    }
1746    pub fn idle_grace(&self) -> Duration {
1747        self.idle_grace
1748            .map(|d| d.0)
1749            .unwrap_or(Duration::from_secs(5))
1750    }
1751}
1752
1753#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1754#[serde(rename_all = "lowercase")]
1755pub enum RunUntil {
1756    #[default]
1757    Auto,
1758    Idle,
1759    Drained,
1760}
1761
1762#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1763#[serde(deny_unknown_fields, default)]
1764pub struct Identity {
1765    /// Who autonomous work is done as. A schedule, webhook, stream or timer
1766    /// firing carries no principal, so "every effect names the human or the
1767    /// schedule that caused it" was false by construction — the attribution
1768    /// chain was dropped at the very first hop. Default `system`.
1769    pub autonomous_as: Option<String>,
1770    /// Labels stamped on autonomous work, the same way a principal's are.
1771    #[serde(default)]
1772    pub labels: BTreeMap<String, String>,
1773}
1774
1775impl Identity {
1776    pub fn autonomous_id(&self) -> &str {
1777        self.autonomous_as.as_deref().unwrap_or("system")
1778    }
1779}
1780
1781#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1782#[serde(deny_unknown_fields, default)]
1783pub struct A2a {
1784    pub listen: Option<String>,
1785    pub tls: A2aTls,
1786    pub bearer: Option<Secret>,
1787    pub principals: Vec<Principal>,
1788    pub peers: Vec<A2aPeer>,
1789    pub conversation_ttl: Option<Dur>,
1790    pub push: A2aPush,
1791}
1792
1793/// **Push notifications**: a caller registers a webhook and agentd POSTs its
1794/// task's updates there instead of holding a stream open.
1795///
1796/// Default-OFF, because the URL comes from the caller: every delivery is an
1797/// outbound request to an address a *peer* chose, which is the shape of an SSRF.
1798/// Enabling it says you are willing to make that request; `allow_private` says
1799/// you are willing to make it to a private or loopback address, which is a
1800/// separate and larger decision (a peer could otherwise reach agentd's own
1801/// surfaces, or a cloud metadata endpoint).
1802#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1803#[serde(deny_unknown_fields, default)]
1804pub struct A2aPush {
1805    /// Accept `CreateTaskPushNotificationConfig` and deliver on transitions.
1806    pub enabled: bool,
1807    /// Permit webhook targets on private / loopback addresses.
1808    pub allow_private: bool,
1809}
1810
1811#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1812#[serde(deny_unknown_fields, default)]
1813pub struct A2aTls {
1814    pub cert: Option<String>,
1815    pub key: Option<String>,
1816    pub client_ca: Option<String>,
1817}
1818
1819/// The **display-client interface**: the opt-in surface a thin TUI/web-UI
1820/// client rides — the global `SubscribeToEvents` feed and the
1821/// `interface.*`/debug read ops, served on the existing A2A listener (no new
1822/// socket). Default-OFF: with `enabled: false` those methods answer
1823/// UNSUPPORTED_OPERATION and the core A2A surface is byte-identical. `debug`
1824/// additionally exposes internals (conversation transcripts, per-step run
1825/// detail, the live log ring, audit records on the feed) — operator-grade
1826/// information; leave it off in production unless you need it. `origins` lets a
1827/// hosted web UI (a non-loopback browser origin) through the DNS-rebind guard
1828/// with CORS; loopback origins are always accepted.
1829#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1830#[serde(deny_unknown_fields, default)]
1831pub struct Interface {
1832    /// Serve the interface methods (`SubscribeToEvents`, `interface.info`, …).
1833    pub enabled: bool,
1834    /// Expose extra debug information (transcripts, run step detail, the log
1835    /// ring, audit feed events). Clients render their debug panes only when
1836    /// this is on. Runtime-togglable over the wire via `config.set` (operator).
1837    pub debug: bool,
1838    /// Extra allowed browser origins (`scheme://host[:port]`, exact match) for
1839    /// a hosted web UI. Loopback origins never need listing.
1840    pub origins: Vec<String>,
1841    /// What the display clients render in their chrome. The daemon decides,
1842    /// so every attached client renders the same layout.
1843    pub display: Display,
1844    /// Pairing-code login: a rotating short code shown to the operator that a
1845    /// client exchanges for a session token — the low-friction alternative to
1846    /// copying a bearer around.
1847    pub pairing: Pairing,
1848}
1849
1850/// The client-chrome layout: ordered item lists for the top (header) and
1851/// bottom (status bar) edges. `None` ⇒ the built-in default. Clients skip an
1852/// item they do not recognise instead of erroring, so a newer daemon can name
1853/// items an older client has never heard of. The vocabulary is
1854/// [`DISPLAY_ITEMS`].
1855#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1856#[serde(deny_unknown_fields, default)]
1857pub struct Display {
1858    pub top: Option<Vec<String>>,
1859    pub bottom: Option<Vec<String>>,
1860}
1861
1862/// The display items a client knows how to render.
1863pub const DISPLAY_ITEMS: &[&str] = &[
1864    "name",     // agent name (card)
1865    "version",  // agentd version
1866    "instance", // instance identity
1867    "model",    // intelligence.model
1868    "endpoint", // the endpoint the client dialed
1869    "conn",     // connection state (live/polling/error)
1870    "debug",    // the debug badge
1871    "draining", // the DRAINING notice
1872    "active",   // active task count
1873    "turns",    // counter
1874    "tokens",   // tokens in/out
1875    "tool_calls",
1876    "runs",          // run count
1877    "subagents",     // subagent count
1878    "conversations", // conversation count
1879    "screen",        // current screen name (tui)
1880    "keys",          // key hints (tui)
1881    "clock",         // local time
1882];
1883
1884/// Pairing-code login. The code is a 6-digit value derived
1885/// from a per-process random seed and the current 60-second window — shown
1886/// only to operators (`pairing.code`), verified with the previous window's
1887/// grace, rate-limited, and exchanged (`Pair`) for a high-entropy session
1888/// token that lives in memory until `ttl` (or restart).
1889#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1890#[serde(deny_unknown_fields, default)]
1891pub struct Pairing {
1892    pub enabled: bool,
1893    /// The role a paired session gets: `operator` (default — whoever can read
1894    /// the code can already see the operator console) or `user`.
1895    pub role: Option<Role>,
1896    /// Session-token lifetime (default 12h).
1897    pub ttl: Option<Dur>,
1898}
1899
1900/// The webhook inbound HTTP surface: a dedicated listener serving the
1901/// `webhook` start nodes and `wait: {on: webhook}` callbacks. Auth is **per
1902/// node** — each `webhook` declares its own verification — so one permissive
1903/// route cannot weaken the others; the listener-wide default set here applies
1904/// only to nodes that declare no `auth` of their own.
1905#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1906#[serde(deny_unknown_fields, default)]
1907pub struct Webhooks {
1908    /// `https://host:port` (loopback `http://` for dev). Required when any
1909    /// `webhook` start node or `wait: {on: webhook}` is used.
1910    pub listen: Option<String>,
1911    pub tls: A2aTls,
1912    /// A default auth applied to `webhook` nodes that declare none.
1913    pub default_auth: Option<WebhookAuth>,
1914}
1915
1916/// A webhook's inbound authentication. Best practice (and the default guidance)
1917/// is HMAC over the raw body; a required-header or bearer match are alternatives;
1918/// `none: true` is an explicit loopback-only dev opt-out.
1919#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1920#[serde(deny_unknown_fields, default)]
1921pub struct WebhookAuth {
1922    /// HMAC signature verification over the raw request body (GitHub/Stripe-style).
1923    pub hmac: Option<Hmac>,
1924    /// A shared bearer token (`Authorization: Bearer …`), constant-time matched.
1925    pub bearer: Option<Secret>,
1926    /// A required header exact-match (`{name, equals}`).
1927    pub header: Option<HeaderMatch>,
1928    /// Loopback-only, no auth (dev). Explicit opt-in.
1929    pub none: bool,
1930}
1931
1932#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1933#[serde(deny_unknown_fields, default)]
1934pub struct Hmac {
1935    pub secret: Option<Secret>,
1936    /// The header carrying the signature (default `X-Signature`).
1937    pub header: Option<String>,
1938    /// Digest algorithm — `sha256` (default; the only supported algorithm).
1939    pub algo: Option<String>,
1940    /// A prefix stripped before the constant-time hex compare (e.g. `sha256=`).
1941    pub prefix: Option<String>,
1942}
1943
1944#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1945#[serde(deny_unknown_fields, default)]
1946pub struct HeaderMatch {
1947    pub name: Option<String>,
1948    pub equals: Option<Secret>,
1949}
1950
1951/// The self-correcting goal watchdog. A supervisor-level periodic check of
1952/// whether the configured `statement` is achieved (or the agent is stuck),
1953/// with a configurable disposition. It runs beside the agent loop and never
1954/// blocks it, so a slow judge cannot stall real work.
1955#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1956#[serde(deny_unknown_fields, default)]
1957pub struct Goal {
1958    /// The goal in natural language (the LLM judge reads it).
1959    pub statement: Option<String>,
1960    pub check: GoalCheck,
1961    /// N consecutive no-progress checks ⇒ self-correct (default 3).
1962    pub stuck_after: Option<u32>,
1963    /// What to do when the goal is achieved (default: `finish`).
1964    pub on_achieved: Option<GoalAction>,
1965    /// What to do when stuck (default: `replan`).
1966    pub on_stuck: Option<GoalAction>,
1967}
1968
1969#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1970#[serde(deny_unknown_fields, default)]
1971pub struct GoalCheck {
1972    /// The check cadence (default `5m`).
1973    pub every: Option<Dur>,
1974    /// An optional cheap CEL predicate over durable state, evaluated first.
1975    pub condition: Option<String>,
1976    /// `both` (default: CEL then LLM), `condition` (CEL only), or `agent` (LLM only).
1977    pub via: Option<String>,
1978}
1979
1980/// A goal disposition. Deserialized from a bare string
1981/// (`finish`/`idle`/`replan`/`escalate`) or `{ workflow: <name> }`.
1982#[derive(Debug, Clone, PartialEq)]
1983pub enum GoalAction {
1984    Finish,
1985    Idle,
1986    Replan,
1987    Escalate,
1988    Workflow(String),
1989}
1990
1991impl<'de> Deserialize<'de> for GoalAction {
1992    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1993        use serde::de::Error;
1994        match Value::deserialize(d)? {
1995            Value::String(s) => match s.as_str() {
1996                "finish" => Ok(GoalAction::Finish),
1997                "idle" => Ok(GoalAction::Idle),
1998                "replan" => Ok(GoalAction::Replan),
1999                "escalate" => Ok(GoalAction::Escalate),
2000                other => Err(D::Error::custom(format!(
2001                    "unknown goal action '{other}' (want finish|idle|replan|escalate|{{workflow: <name>}})"
2002                ))),
2003            },
2004            Value::Object(m) => match m.get("workflow").and_then(Value::as_str) {
2005                Some(w) => Ok(GoalAction::Workflow(w.to_string())),
2006                None => Err(D::Error::custom(
2007                    "a goal action object must be { workflow: <name> }",
2008                )),
2009            },
2010            _ => Err(D::Error::custom(
2011                "a goal action must be a string or { workflow: <name> }",
2012            )),
2013        }
2014    }
2015}
2016
2017#[derive(Debug, Clone, Deserialize, PartialEq)]
2018#[serde(deny_unknown_fields)]
2019pub struct Principal {
2020    #[serde(rename = "match")]
2021    pub matcher: PrincipalMatch,
2022    pub role: Role,
2023    #[serde(default)]
2024    pub grants: Vec<String>,
2025    #[serde(default)]
2026    pub quotas: Option<Quotas>,
2027    /// Operator-declared attributes carried with everything this principal
2028    /// causes — into the run, the MCP `_meta` and the audit line.
2029    ///
2030    /// A CLOSED domain on purpose: these become durable governor scope keys
2031    /// and audit fields, and minting them from values arriving off the box is
2032    /// the same unbounded-cardinality hazard the metrics layer already bans
2033    /// for labels, relocated into the manifest.
2034    #[serde(default)]
2035    pub labels: BTreeMap<String, String>,
2036}
2037
2038#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2039#[serde(deny_unknown_fields, default)]
2040pub struct PrincipalMatch {
2041    pub san: Option<String>,
2042    pub sub: Option<String>,
2043    pub bearer_ref: Option<String>,
2044    pub aauth_agent: Option<String>,
2045    pub any: bool,
2046}
2047
2048#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2049#[serde(rename_all = "lowercase")]
2050pub enum Role {
2051    Operator,
2052    User,
2053    Agent,
2054    Anonymous,
2055}
2056
2057#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2058#[serde(deny_unknown_fields, default)]
2059pub struct Quotas {
2060    pub rate: Option<String>,
2061    pub budget: Option<Budget>,
2062}
2063
2064#[derive(Debug, Clone, Deserialize, PartialEq)]
2065#[serde(deny_unknown_fields)]
2066pub struct A2aPeer {
2067    pub name: String,
2068    /// Either a literal URL, or empty when `service:` references a
2069    /// `kind: peer` catalog entry — resolution fills it in before any dial.
2070    #[serde(default)]
2071    pub endpoint: String,
2072    /// Reference a `services:` entry of `kind: peer`: inherit its connection
2073    /// settings (restating `endpoint`/`auth`/`headers` here is refused).
2074    #[serde(default)]
2075    pub service: Option<String>,
2076    #[serde(default)]
2077    pub headers: BTreeMap<String, String>,
2078    #[serde(default)]
2079    pub client_cert: Option<String>,
2080    #[serde(default)]
2081    pub client_key: Option<String>,
2082    /// A unified credential provider for the peer — `static` / `oauth2`
2083    /// (device-login) / `spiffe` (jwt), each resolved to a bearer at dial
2084    /// time, or `aws`, which instead signs every request body individually.
2085    #[serde(default)]
2086    pub auth: Option<Auth>,
2087}
2088
2089#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2090#[serde(deny_unknown_fields, default)]
2091pub struct Observability {
2092    pub log_level: Option<String>,
2093    pub log_content: bool,
2094    pub otel: Otel,
2095    pub metrics_addr: Option<String>,
2096    pub health_file: Option<String>,
2097    pub report_file: Option<String>,
2098    pub events_ring: Option<u32>,
2099    pub audit: Audit,
2100    pub traceparent: Option<String>,
2101    /// Mirror a selected subset of the daemon's own event vocabulary onto a
2102    /// declared stream, so the runtime can react to itself: a tripped breaker,
2103    /// a shed admission or an unhealthy child becomes an ordinary start node.
2104    pub runtime_events: Option<RuntimeEvents>,
2105}
2106
2107/// Which of the daemon's own events reach a stream, and at what rate.
2108#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2109#[serde(deny_unknown_fields, default)]
2110pub struct RuntimeEvents {
2111    /// The declared stream events land on. Required.
2112    pub stream: Option<String>,
2113    /// Families taken in full (the segment before the first dot in an event
2114    /// name). Validated against the closed vocabulary at startup.
2115    pub include: Vec<String>,
2116    /// Families taken at a sampled rate. A high-rate family cannot share a
2117    /// list with a once-a-week one: `pressure.shed` arrives in storms exactly
2118    /// when the disk it would be written to is the thing under pressure.
2119    pub sampled: Vec<String>,
2120    /// How many events may queue between ticks before the tap starts dropping
2121    /// (and counting). Default [`DEFAULT_TAP_QUEUE`].
2122    pub queue: Option<u32>,
2123}
2124
2125/// The default bound on the runtime-event queue.
2126pub const DEFAULT_TAP_QUEUE: u32 = 512;
2127
2128impl RuntimeEvents {
2129    pub fn queue_cap(&self) -> usize {
2130        self.queue.unwrap_or(DEFAULT_TAP_QUEUE) as usize
2131    }
2132}
2133
2134#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2135#[serde(deny_unknown_fields, default)]
2136pub struct Otel {
2137    pub endpoint: Option<String>,
2138    pub traces: Option<bool>,
2139    pub metrics: Option<bool>,
2140    pub logs: Option<bool>,
2141}
2142
2143#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2144#[serde(deny_unknown_fields, default)]
2145pub struct Audit {
2146    pub sink: Option<Vec<AuditSink>>,
2147    /// The declared stream `sink: [stream]` appends to. Required with that
2148    /// sink; audit records are otherwise written and then unreadable, since
2149    /// `Kind::Audit` is deliberately not manifest-indexed.
2150    pub stream: Option<String>,
2151}
2152
2153#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
2154#[serde(rename_all = "lowercase")]
2155pub enum AuditSink {
2156    Log,
2157    Store,
2158    /// Append to a declared stream — the supported path off the box, and the
2159    /// one sink a workflow can consume: compliance evidence becomes a
2160    /// scheduled run that reads a window and ships it, with no evidence
2161    /// subsystem in the binary.
2162    Stream,
2163}
2164
2165#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2166#[serde(deny_unknown_fields, default)]
2167pub struct Security {
2168    pub allow_trifecta: bool,
2169    pub tls_ca: Option<String>,
2170    pub aauth: Option<AAuth>,
2171    pub cgroup: Cgroup,
2172    pub exec: Exec,
2173    pub workflows: WorkflowSecurity,
2174    /// `closed` ⇒ an outbound MCP dial whose URL matches no `services:`
2175    /// catalog entry is refused: at boot for configured servers, and at dial
2176    /// time for everything else, so a URL assembled at runtime is caught too.
2177    /// Default `open`.
2178    pub egress: Egress,
2179    /// Ordered verdicts on the tool CALL — by name, tag, caller, principal or
2180    /// arguments. First match wins; no match is allow.
2181    ///
2182    /// This is the only place an argument can be judged: grants are name
2183    /// patterns, so "delete anything outside /tmp" has no expression in them,
2184    /// and `agent.approval` only decides whether to honour a gate the MODEL
2185    /// asked for. It is also where the trifecta tags finally do work at
2186    /// runtime rather than only folding at startup.
2187    pub policies: Vec<Policy>,
2188}
2189
2190/// One rule: what it matches, and what happens.
2191#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2192#[serde(deny_unknown_fields, default)]
2193pub struct Policy {
2194    #[serde(rename = "match")]
2195    pub matcher: PolicyMatch,
2196    pub action: PolicyAction,
2197    /// The question put to a person for `action: ask`. Templated with
2198    /// `{{tool}}`, `{{caller}}` and `{{args}}`.
2199    pub question: Option<String>,
2200    /// What an unanswered `ask` becomes. Default `deny` — a gate nobody
2201    /// answered has not been approved.
2202    pub on_timeout: Option<PolicyAction>,
2203    pub timeout: Option<Dur>,
2204}
2205
2206#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2207#[serde(deny_unknown_fields, default)]
2208pub struct PolicyMatch {
2209    /// Tool-name glob (`fs.*`). Absent matches every tool.
2210    pub tool: Option<String>,
2211    /// Every listed trifecta tag must be present on the tool.
2212    pub tags: Vec<String>,
2213    /// Which callers this applies to: `root`, `workflow`, `subagent`.
2214    pub caller: Vec<PolicyCaller>,
2215    /// Principal-id glob, for calls carrying one.
2216    pub principal: Option<String>,
2217    /// A CEL predicate over `args`, `tool` and `caller`. Needs the `cel`
2218    /// feature; a build without it refuses the config rather than evaluating
2219    /// an argument guard to "no match".
2220    pub args: Option<String>,
2221}
2222
2223#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
2224#[serde(rename_all = "lowercase")]
2225pub enum PolicyCaller {
2226    Root,
2227    Workflow,
2228    Subagent,
2229}
2230
2231#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
2232#[serde(rename_all = "lowercase")]
2233pub enum PolicyAction {
2234    #[default]
2235    Allow,
2236    Deny,
2237    /// Suspend on a human gate; the answer decides. Rides the same deferred
2238    /// path `ask_human` and the `human` node already use, so a policy gate
2239    /// renders as an answerable row in every attached client and survives a
2240    /// restart.
2241    Ask,
2242    /// Refuse, but say plainly that the call was held rather than run. NOT a
2243    /// synthetic success: a schema-conformant fake result is reasoned over as
2244    /// real, and every later decision is then built on a fabricated
2245    /// observation — a strange thing for a fail-closed runtime to ship.
2246    Shadow,
2247}
2248
2249/// Whether outbound dials are confined to the service catalog.
2250#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
2251#[serde(rename_all = "lowercase")]
2252pub enum Egress {
2253    #[default]
2254    Open,
2255    Closed,
2256}
2257
2258/// Whether the agent may rewrite its own workflows.
2259#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2260#[serde(deny_unknown_fields, default)]
2261pub struct WorkflowSecurity {
2262    /// Refuse `workflow.create` / `.update` / `.delete` at runtime.
2263    ///
2264    /// Workflows are the agent's *standing instructions* — what it does when a
2265    /// schedule fires or a webhook lands, unattended. An agent that can rewrite
2266    /// them can quietly change what happens next time, and the change survives
2267    /// the conversation that caused it. Anywhere a definition is reviewed
2268    /// before it ships — a file in git, a config a deploy applies — self-update
2269    /// is not a feature, it is a hole in that review.
2270    ///
2271    /// Off by default, because the runtime-created workflow is a real workflow
2272    /// (`docs/workflows.md`); turn it on and definitions become read-only, from
2273    /// the config and the store, for everyone: the model, a subagent, and an
2274    /// operator over A2A alike. Loading is unaffected — the daemon still reads
2275    /// files, URLs and directories at startup.
2276    pub immutable: bool,
2277}
2278
2279/// The local command-runner controls. agentd's default posture is **no local
2280/// execution**, so this stays off unless an operator both builds with
2281/// `--features exec` AND sets `enabled: true` — two independent switches, so
2282/// neither a config mistake nor a stock binary can turn it on alone. Even then
2283/// it runs only allow-listed commands, in a confined directory, with a minimal
2284/// env. Without the local runner the `exec` tool is **mapping-only**: it can
2285/// be delegated off-box via `tools.overrides`. It carries the `sensitive` +
2286/// `egress` trifecta tags, so enabling it narrows what else the agent may
2287/// compose with.
2288#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2289#[serde(deny_unknown_fields, default)]
2290pub struct Exec {
2291    /// Enable a LOCAL runner. Requires the `exec` build feature too; default OFF.
2292    pub enabled: bool,
2293    /// Allow-listed command names (`argv[0]`); anything else is refused. Empty =
2294    /// deny all (so `enabled` alone runs nothing).
2295    pub allow: Vec<String>,
2296    /// The directory commands run in; a requested `cwd` must resolve inside it.
2297    pub workdir: Option<String>,
2298    /// Max wall-clock per command (a longer requested `timeout` is clamped). 30s.
2299    pub timeout: Option<Dur>,
2300    /// Cap on captured stdout+stderr bytes (default 1 MiB).
2301    pub max_output: Option<u64>,
2302    /// Environment variable NAMES passed through to the child (default none — a
2303    /// minimal env; the agent's own env/secrets are never inherited).
2304    pub env: Vec<String>,
2305}
2306
2307#[derive(Debug, Clone, Deserialize, PartialEq)]
2308#[serde(deny_unknown_fields)]
2309pub struct AAuth {
2310    pub provider: String,
2311    #[serde(default)]
2312    pub key_file: Option<String>,
2313    #[serde(default)]
2314    pub enroll_token: Option<Secret>,
2315    #[serde(default)]
2316    pub enroll_assertion_file: Option<String>,
2317    #[serde(default)]
2318    pub person_server: Option<String>,
2319}
2320
2321#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2322#[serde(deny_unknown_fields, default)]
2323pub struct Cgroup {
2324    pub spec: Option<String>,
2325    pub memory_max: Option<String>,
2326    pub pids_max: Option<String>,
2327}
2328
2329/// One `{{…}}` reference found by [`scan_references`].
2330#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2331pub struct FoundRef {
2332    /// `secret` | `secret-file` | `config`.
2333    pub kind: &'static str,
2334    pub name: String,
2335    /// Where it sat (a dotted path into the document).
2336    pub at: String,
2337}
2338
2339/// Collect every `{{secret:…}}`, `{{secret-file:…}}` and `{{config.…}}`
2340/// reference in `value`, with where each sits.
2341///
2342/// This exists so a deployment can be checked in ONE pass: the alternative —
2343/// failing on whichever reference happens to be evaluated first — turns
2344/// configuring a new instance into a guessing game played one restart at a
2345/// time, entering values one failure per attempt.
2346pub fn scan_references(value: &Value, at: &str, out: &mut Vec<FoundRef>) {
2347    match value {
2348        Value::String(s) => {
2349            let mut rest = s.as_str();
2350            while let Some(open) = rest.find("{{") {
2351                let after = &rest[open + 2..];
2352                let Some(close) = after.find("}}") else { break };
2353                let token = after[..close].trim();
2354                if let Some(n) = token.strip_prefix("secret:") {
2355                    out.push(FoundRef {
2356                        kind: "secret",
2357                        name: n.trim().into(),
2358                        at: at.into(),
2359                    });
2360                } else if let Some(p) = token.strip_prefix("secret-file:") {
2361                    out.push(FoundRef {
2362                        kind: "secret-file",
2363                        name: p.trim().into(),
2364                        at: at.into(),
2365                    });
2366                } else if let Some(c) = token.strip_prefix("config.") {
2367                    out.push(FoundRef {
2368                        kind: "config",
2369                        name: c.trim().into(),
2370                        at: at.into(),
2371                    });
2372                }
2373                rest = &after[close + 2..];
2374            }
2375        }
2376        Value::Array(a) => {
2377            for (i, v) in a.iter().enumerate() {
2378                scan_references(v, &format!("{at}[{i}]"), out);
2379            }
2380        }
2381        Value::Object(o) => {
2382            for (k, v) in o {
2383                scan_references(v, &format!("{at}.{k}"), out);
2384            }
2385        }
2386        _ => {}
2387    }
2388}
2389
2390/// Every `hmac.algo` in a document, with where it sits.
2391///
2392/// A whole-document walk rather than a typed lookup, because the field appears
2393/// in two unrelated places — `webhooks.default_auth.hmac` and each webhook
2394/// node's own `auth.hmac` — and a workflow's steps are untyped `Value`s at this
2395/// point. A definition arriving from `file:`/`url:` is not in the document and
2396/// is caught at listener build instead.
2397pub fn hmac_algos(value: &Value, at: &str) -> Vec<(String, String)> {
2398    let mut out = Vec::new();
2399    fn walk(v: &Value, at: &str, out: &mut Vec<(String, String)>) {
2400        match v {
2401            Value::Object(o) => {
2402                for (k, child) in o {
2403                    if k == "hmac"
2404                        && let Some(a) = child.get("algo").and_then(Value::as_str)
2405                    {
2406                        out.push((format!("{at}.hmac.algo"), a.to_string()));
2407                    }
2408                    walk(child, &format!("{at}.{k}"), out);
2409                }
2410            }
2411            Value::Array(a) => {
2412                for (i, child) in a.iter().enumerate() {
2413                    walk(child, &format!("{at}[{i}]"), out);
2414                }
2415            }
2416            _ => {}
2417        }
2418    }
2419    walk(value, at, &mut out);
2420    out
2421}
2422
2423/// The references in `value` that would NOT resolve right now — secrets against
2424/// the environment (and any interactively-entered values), secret-files against
2425/// the filesystem, `config.*` against `vars`. One message per missing
2426/// reference, deduplicated, every location listed.
2427pub fn missing_references(value: &Value, at: &str, vars: &BTreeMap<String, Value>) -> Vec<String> {
2428    let mut found = Vec::new();
2429    scan_references(value, at, &mut found);
2430    let mut by_ref: BTreeMap<(&'static str, String), Vec<String>> = BTreeMap::new();
2431    for r in found {
2432        let missing = match r.kind {
2433            "secret" => !crate::sec::secret::secret_available(&r.name),
2434            "secret-file" => std::fs::metadata(&r.name).is_err(),
2435            "config" => {
2436                let mut parts = r.name.split('.');
2437                let mut cur = parts.next().and_then(|p| vars.get(p));
2438                for p in parts {
2439                    cur = cur.and_then(|v| v.get(p));
2440                }
2441                cur.is_none()
2442            }
2443            _ => false,
2444        };
2445        if missing {
2446            by_ref.entry((r.kind, r.name)).or_default().push(r.at);
2447        }
2448    }
2449    by_ref
2450        .into_iter()
2451        .map(|((kind, name), ats)| {
2452            let what = match kind {
2453                "secret" => format!("{{{{secret:{name}}}}} is not set in the environment"),
2454                "secret-file" => format!("{{{{secret-file:{name}}}}} is not readable"),
2455                _ => format!("config.{name} is not defined in vars"),
2456            };
2457            format!("{what} (referenced at {})", ats.join(", "))
2458        })
2459        .collect()
2460}
2461
2462/// Substitute `{{config.NAME}}` tokens in `value` from `vars`, appending every
2463/// unresolved reference to `errs` (with `at` naming where it sat).
2464///
2465/// A string that IS exactly one token takes the variable's typed value — a
2466/// number stays a number. A token embedded in a longer string is stringified
2467/// into place. There is no escape syntax: an unresolved reference is an error
2468/// rather than a literal, because a URL that still contains `{{config.region}}`
2469/// at runtime is a bug wherever it was headed.
2470pub fn substitute_config_vars(
2471    value: &mut Value,
2472    vars: &BTreeMap<String, Value>,
2473    at: &str,
2474    errs: &mut Vec<String>,
2475) {
2476    fn lookup<'a>(vars: &'a BTreeMap<String, Value>, path: &str) -> Option<&'a Value> {
2477        let mut parts = path.split('.');
2478        let mut cur = vars.get(parts.next()?)?;
2479        for p in parts {
2480            cur = cur.get(p)?;
2481        }
2482        Some(cur)
2483    }
2484    fn token_at(s: &str, from: usize) -> Option<(usize, usize, String)> {
2485        let start = s[from..].find("{{config.")? + from;
2486        let end = s[start..].find("}}")? + start + 2;
2487        let name = s[start + 9..end - 2].trim().to_string();
2488        Some((start, end, name))
2489    }
2490    match value {
2491        Value::String(s) => {
2492            // The whole string is one token: keep the value's TYPE.
2493            if let Some((0, end, name)) = token_at(s, 0)
2494                && end == s.len()
2495            {
2496                match lookup(vars, &name) {
2497                    Some(v) => *value = v.clone(),
2498                    None => errs.push(format!("{at}: config.{name} is not defined in vars")),
2499                }
2500                return;
2501            }
2502            let mut out = String::new();
2503            let mut pos = 0;
2504            while let Some((start, end, name)) = token_at(s, pos) {
2505                out.push_str(&s[pos..start]);
2506                match lookup(vars, &name) {
2507                    Some(Value::String(v)) => out.push_str(v),
2508                    Some(v) => out.push_str(&v.to_string()),
2509                    None => {
2510                        errs.push(format!("{at}: config.{name} is not defined in vars"));
2511                        out.push_str(&s[start..end]);
2512                    }
2513                }
2514                pos = end;
2515            }
2516            if pos > 0 {
2517                out.push_str(&s[pos..]);
2518                *s = out;
2519            }
2520        }
2521        Value::Array(a) => {
2522            for (i, v) in a.iter_mut().enumerate() {
2523                substitute_config_vars(v, vars, &format!("{at}[{i}]"), errs);
2524            }
2525        }
2526        Value::Object(o) => {
2527            for (k, v) in o.iter_mut() {
2528                substitute_config_vars(v, vars, &format!("{at}.{k}"), errs);
2529            }
2530        }
2531        _ => {}
2532    }
2533}
2534
2535impl Settings {
2536    /// Type a settings document. `source` names it in errors.
2537    ///
2538    /// `{{config.*}}` substitution happens here, before typing — so a var can
2539    /// sit anywhere a string can: an endpoint, a path, a header. The
2540    /// `workflows` array is deliberately left alone; workflow documents are
2541    /// substituted at LOAD time instead (`load_workflows`), where the ones
2542    /// arriving from files, URLs and directories can be treated identically to
2543    /// inline ones.
2544    pub fn from_document(mut doc: Value, source: &str) -> Result<Settings, String> {
2545        let vars: BTreeMap<String, Value> = doc
2546            .get("vars")
2547            .and_then(Value::as_object)
2548            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
2549            .unwrap_or_default();
2550        let workflows = doc.as_object_mut().and_then(|o| o.remove("workflows"));
2551        let mut errs = Vec::new();
2552        substitute_config_vars(&mut doc, &vars, source, &mut errs);
2553        if let (Some(o), Some(w)) = (doc.as_object_mut(), workflows) {
2554            o.insert("workflows".into(), w);
2555        }
2556        if !errs.is_empty() {
2557            return Err(format!(
2558                "{} unresolved config var reference(s):\n  {}",
2559                errs.len(),
2560                errs.join("\n  ")
2561            ));
2562        }
2563        // Colon-fence directives in the instruction (operator-authored text —
2564        // this is the ONLY surface extraction runs on; conversation text is
2565        // never parsed). `:::workflow` bodies join `workflows:` exactly as
2566        // inline entries — same folding, validation, hashing, retirement —
2567        // and the model reads the CLEANED text, where each block became a
2568        // one-line note instead of machinery it might paraphrase. Extraction
2569        // runs BEFORE deserialization because the config-defining blocks
2570        // (`:::config`/`:::mcp`/`:::stream`/`:::tools`) contribute a fragment
2571        // that merges UNDER the explicit document — an instruction file alone
2572        // can define the whole agent, and an explicit key still wins.
2573        let mut extraction = None;
2574        if let Some(instr) = doc
2575            .get("agent")
2576            .and_then(|a| a.get("instruction"))
2577            .and_then(Value::as_str)
2578            .map(str::to_string)
2579            && !looks_like_resource_uri(&instr)
2580            && instr.lines().any(|l| l.starts_with(":::"))
2581        {
2582            match crate::config::directives::extract(&instr) {
2583                Ok(ex) => {
2584                    // (`{{config.*}}` inside a block already resolved: the doc
2585                    // passed substitute_config_vars with the instruction in it.
2586                    // A nameless block is refused by parse_workflow, the one
2587                    // authority on that message.)
2588                    if let Some(a) = doc.get_mut("agent").and_then(Value::as_object_mut) {
2589                        a.insert("instruction".into(), Value::String(ex.cleaned.clone()));
2590                    }
2591                    if let (Some(o), Value::Object(fragment)) =
2592                        (doc.as_object_mut(), ex.config.clone())
2593                    {
2594                        crate::config::directives::merge_missing(o, fragment, false);
2595                    }
2596                    extraction = Some(ex);
2597                }
2598                Err(errs) => {
2599                    return Err(format!(
2600                        "{source}: agent.instruction directives:
2601  {}",
2602                        errs.join(
2603                            "
2604  "
2605                        )
2606                    ));
2607                }
2608            }
2609        }
2610        let mut settings: Settings =
2611            serde_json::from_value(doc).map_err(|e| format!("{source} parse error: {e}"))?;
2612        if let Some(ex) = extraction {
2613            settings.agent.inline_skills = ex.skills;
2614            settings.workflows.extend(ex.workflows);
2615        }
2616        Ok(settings)
2617    }
2618
2619    /// The `agent.name` fallback chain: config › downward-API instance ›
2620    /// hostname › `agentd`.
2621    pub fn instance_name(&self) -> String {
2622        if let Some(n) = &self.agent.name {
2623            return n.clone();
2624        }
2625        let id =
2626            crate::identity::Identity::from_env(self.lifecycle.run_id.as_deref().unwrap_or(""));
2627        if let Some(inst) = id.instance.filter(|i| !i.trim().is_empty()) {
2628            return inst;
2629        }
2630        std::env::var("HOSTNAME")
2631            .ok()
2632            .filter(|h| !h.trim().is_empty())
2633            .unwrap_or_else(|| "agentd".to_string())
2634    }
2635
2636    /// Whether this instance OUTLIVES a single run — it serves A2A or webhooks,
2637    /// watches a goal, or owns a workflow with a long-lived start node
2638    /// (`loop`/`schedule`/`subscribe`/`signal`/`event`/`a2a`/`webhook`).
2639    ///
2640    /// This is the durability predicate: a job-shaped run can lose its state
2641    /// and simply be re-run, while an instance that keeps running cannot. Two
2642    /// callers need exactly the same answer — [`load`], which defaults such an
2643    /// instance to the file store, and [`validate`], which refuses an EXPLICIT
2644    /// `store.kind: none` here — so the predicate lives in one place instead of
2645    /// being spelled out twice and diverging.
2646    pub fn is_long_lived(&self) -> bool {
2647        self.a2a.listen.is_some()
2648            || self.webhooks.listen.is_some()
2649            || self.goal.is_some()
2650            || self.workflows.iter().any(workflow_is_long_lived)
2651    }
2652}
2653
2654// ---------------------------------------------------------------------------
2655// Detection
2656// ---------------------------------------------------------------------------
2657
2658/// Top-level keys only the settings document has. `limits` is deliberately
2659/// absent: it exists in both schemas, so it decides nothing. `intelligence`
2660/// is absent for a different reason — it is a STRING (the endpoint list) in
2661/// the flat schema but an OBJECT here, so [`detect`] judges it by shape.
2662pub const V2_KEYS: &[&str] = &[
2663    "agent",
2664    "store",
2665    "workflows",
2666    "tools",
2667    "a2a",
2668    "lifecycle",
2669    "observability",
2670    "security",
2671    "knowledge",
2672    "search",
2673    "skills",
2674    "memory",
2675    "context",
2676    "vars",
2677    "streams",
2678];
2679
2680/// v1 (flat) top-level keys.
2681pub const V1_KEYS: &[&str] = &[
2682    "intelligence_headers",
2683    "model_swap",
2684    "model",
2685    "max_tokens",
2686    "mcp_servers",
2687    "subscribe",
2688    "a2a_peers",
2689    "log_level",
2690];
2691
2692#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2693pub enum Detected {
2694    /// No document at all (no config files).
2695    Empty,
2696    /// The v1 flat schema.
2697    V1,
2698    /// The v2 nested schema.
2699    V2,
2700    /// Both key families present — refused.
2701    Mixed,
2702}
2703
2704/// Decide which schema a merged document speaks.
2705pub fn detect(doc: &Value) -> Detected {
2706    let Some(obj) = doc.as_object() else {
2707        return Detected::Empty;
2708    };
2709    if obj.is_empty() {
2710        return Detected::Empty;
2711    }
2712    let version = obj.get("config_version").and_then(Value::as_str);
2713    let intel_is_object = obj.get("intelligence").is_some_and(Value::is_object);
2714    let intel_is_string = obj.get("intelligence").is_some_and(Value::is_string);
2715    let has_v2 = version == Some(schema::CONFIG_VERSION)
2716        || intel_is_object
2717        || obj.keys().any(|k| V2_KEYS.contains(&k.as_str()));
2718    let has_v1 = intel_is_string
2719        || obj.keys().any(|k| V1_KEYS.contains(&k.as_str()))
2720        || matches!(version, Some(v) if v != schema::CONFIG_VERSION);
2721    match (has_v1, has_v2) {
2722        (true, true) => Detected::Mixed,
2723        (false, true) => Detected::V2,
2724        (true, false) => Detected::V1,
2725        // Only `config_version` absent + neither family (e.g. `{}` with a
2726        // comment) or `intelligence`… every key was matched above; anything
2727        // else is a v1 document for the v1 loader to judge.
2728        (false, false) => Detected::V1,
2729    }
2730}
2731
2732// ---------------------------------------------------------------------------
2733// Aliases
2734// ---------------------------------------------------------------------------
2735
2736/// How a named flag maps onto the document.
2737#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2738pub enum AliasKind {
2739    /// `--flag <value>` sets `path` (typed by the schema binding of `path`).
2740    Set,
2741    /// `--flag` (no value) sets `path` to `true`.
2742    SetTrue,
2743    /// `--flag <value>` appends a parsed element to the array at `path`.
2744    Append,
2745    /// `--flag <value>` reads the FILE at `<value>` and sets `path` to its text.
2746    SetFromFile,
2747    /// Handled by dedicated code (`--mcp-tags`, `--budget-exit-code`).
2748    Special,
2749}
2750
2751/// A named flag → document-path alias.
2752#[derive(Debug, Clone, Copy)]
2753pub struct Alias {
2754    pub flag: &'static str,
2755    pub path: &'static str,
2756    pub kind: AliasKind,
2757}
2758
2759/// The alias table. The order of entries is irrelevant — flags take effect in
2760/// the order they appear on the command line, not the order they are listed
2761/// here.
2762pub const ALIASES: &[Alias] = &[
2763    Alias {
2764        flag: "--instruction",
2765        path: "agent.instruction",
2766        kind: AliasKind::Set,
2767    },
2768    Alias {
2769        flag: "--instruction-file",
2770        path: "agent.instruction",
2771        kind: AliasKind::SetFromFile,
2772    },
2773    Alias {
2774        flag: "--prompt",
2775        path: "agent.prompt",
2776        kind: AliasKind::Set,
2777    },
2778    Alias {
2779        flag: "--prompt-file",
2780        path: "agent.prompt",
2781        kind: AliasKind::SetFromFile,
2782    },
2783    Alias {
2784        flag: "--intelligence",
2785        path: "intelligence.endpoints",
2786        kind: AliasKind::Set,
2787    },
2788    Alias {
2789        flag: "--intelligence-token",
2790        path: "intelligence.token",
2791        kind: AliasKind::Set,
2792    },
2793    Alias {
2794        flag: "--intelligence-token-file",
2795        path: "intelligence.token_file",
2796        kind: AliasKind::Set,
2797    },
2798    Alias {
2799        flag: "--model",
2800        path: "intelligence.model",
2801        kind: AliasKind::Set,
2802    },
2803    Alias {
2804        flag: "--model-swap",
2805        path: "intelligence.swap_policy",
2806        kind: AliasKind::Set,
2807    },
2808    Alias {
2809        flag: "--budget-tokens-lifetime",
2810        path: "intelligence.budget.lifetime_tokens",
2811        kind: AliasKind::Set,
2812    },
2813    Alias {
2814        flag: "--mcp",
2815        path: "mcp.servers",
2816        kind: AliasKind::Append,
2817    },
2818    Alias {
2819        flag: "--mcp-tags",
2820        path: "mcp.servers",
2821        kind: AliasKind::Special,
2822    },
2823    Alias {
2824        flag: "--a2a-peer",
2825        path: "a2a.peers",
2826        kind: AliasKind::Append,
2827    },
2828    Alias {
2829        flag: "--workflow",
2830        path: "workflows",
2831        kind: AliasKind::Append,
2832    },
2833    Alias {
2834        flag: "--max-steps",
2835        path: "limits.run.steps",
2836        kind: AliasKind::Set,
2837    },
2838    Alias {
2839        flag: "--max-tokens",
2840        path: "limits.run.tokens",
2841        kind: AliasKind::Set,
2842    },
2843    Alias {
2844        flag: "--deadline",
2845        path: "limits.run.deadline",
2846        kind: AliasKind::Set,
2847    },
2848    Alias {
2849        flag: "--max-depth",
2850        path: "limits.subagents.depth",
2851        kind: AliasKind::Set,
2852    },
2853    Alias {
2854        flag: "--run-id",
2855        path: "lifecycle.run_id",
2856        kind: AliasKind::Set,
2857    },
2858    Alias {
2859        flag: "--drain-timeout",
2860        path: "lifecycle.drain_timeout",
2861        kind: AliasKind::Set,
2862    },
2863    Alias {
2864        flag: "--watch-config",
2865        path: "lifecycle.watch_config",
2866        kind: AliasKind::SetTrue,
2867    },
2868    Alias {
2869        flag: "--budget-exit-code",
2870        path: "lifecycle.exit_code_map",
2871        kind: AliasKind::Special,
2872    },
2873    Alias {
2874        flag: "--listen",
2875        path: "a2a.listen",
2876        kind: AliasKind::Set,
2877    },
2878    Alias {
2879        flag: "--serve-mcp",
2880        path: "a2a.listen",
2881        kind: AliasKind::Set,
2882    },
2883    Alias {
2884        flag: "--serve-cert",
2885        path: "a2a.tls.cert",
2886        kind: AliasKind::Set,
2887    },
2888    Alias {
2889        flag: "--serve-key",
2890        path: "a2a.tls.key",
2891        kind: AliasKind::Set,
2892    },
2893    Alias {
2894        flag: "--serve-client-ca",
2895        path: "a2a.tls.client_ca",
2896        kind: AliasKind::Set,
2897    },
2898    Alias {
2899        flag: "--serve-bearer",
2900        path: "a2a.bearer",
2901        kind: AliasKind::Set,
2902    },
2903    Alias {
2904        flag: "--log-level",
2905        path: "observability.log_level",
2906        kind: AliasKind::Set,
2907    },
2908    Alias {
2909        flag: "--log-content",
2910        path: "observability.log_content",
2911        kind: AliasKind::SetTrue,
2912    },
2913    Alias {
2914        flag: "--metrics-addr",
2915        path: "observability.metrics_addr",
2916        kind: AliasKind::Set,
2917    },
2918    Alias {
2919        flag: "--health-file",
2920        path: "observability.health_file",
2921        kind: AliasKind::Set,
2922    },
2923    Alias {
2924        flag: "--report-file",
2925        path: "observability.report_file",
2926        kind: AliasKind::Set,
2927    },
2928    Alias {
2929        flag: "--events-ring",
2930        path: "observability.events_ring",
2931        kind: AliasKind::Set,
2932    },
2933    Alias {
2934        flag: "--traceparent",
2935        path: "observability.traceparent",
2936        kind: AliasKind::Set,
2937    },
2938    Alias {
2939        flag: "--allow-trifecta",
2940        path: "security.allow_trifecta",
2941        kind: AliasKind::SetTrue,
2942    },
2943    Alias {
2944        flag: "--tls-ca",
2945        path: "security.tls_ca",
2946        kind: AliasKind::Set,
2947    },
2948    Alias {
2949        flag: "--aauth-provider",
2950        path: "security.aauth.provider",
2951        kind: AliasKind::Set,
2952    },
2953    Alias {
2954        flag: "--aauth-key-file",
2955        path: "security.aauth.key_file",
2956        kind: AliasKind::Set,
2957    },
2958    Alias {
2959        flag: "--aauth-enroll-token",
2960        path: "security.aauth.enroll_token",
2961        kind: AliasKind::Set,
2962    },
2963    Alias {
2964        flag: "--aauth-enroll-assertion-file",
2965        path: "security.aauth.enroll_assertion_file",
2966        kind: AliasKind::Set,
2967    },
2968    Alias {
2969        flag: "--aauth-person-server",
2970        path: "security.aauth.person_server",
2971        kind: AliasKind::Set,
2972    },
2973    Alias {
2974        flag: "--cgroup",
2975        path: "security.cgroup.spec",
2976        kind: AliasKind::Set,
2977    },
2978    Alias {
2979        flag: "--cgroup-memory-max",
2980        path: "security.cgroup.memory_max",
2981        kind: AliasKind::Set,
2982    },
2983    Alias {
2984        flag: "--cgroup-pids-max",
2985        path: "security.cgroup.pids_max",
2986        kind: AliasKind::Set,
2987    },
2988];
2989
2990/// Short env names → document paths. The derived `AGENTD_<PATH>` names are the
2991/// primary surface; these are the shorter spellings a quickstart or a k8s
2992/// manifest can use instead. Branded (`AGENTD_`) and neutral (`AGENT_`)
2993/// prefixes both apply, as does the bare name.
2994pub const ENV_ALIASES: &[(&str, &str)] = &[
2995    ("INSTRUCTION", "agent.instruction"),
2996    ("PROMPT", "agent.prompt"),
2997    ("INTELLIGENCE", "intelligence.endpoints"),
2998    ("INTELLIGENCE_TOKEN", "intelligence.token"),
2999    ("INTELLIGENCE_TOKEN_FILE", "intelligence.token_file"),
3000    ("MODEL", "intelligence.model"),
3001    ("MODEL_SWAP", "intelligence.swap_policy"),
3002    ("BUDGET_TOKENS", "intelligence.budget.lifetime_tokens"),
3003    ("MAX_STEPS", "limits.run.steps"),
3004    ("MAX_TOKENS", "limits.run.tokens"),
3005    ("DEADLINE", "limits.run.deadline"),
3006    ("RUN_ID", "lifecycle.run_id"),
3007    ("DRAIN_TIMEOUT", "lifecycle.drain_timeout"),
3008    ("LOG_LEVEL", "observability.log_level"),
3009    ("LOG_CONTENT", "observability.log_content"),
3010    ("METRICS_ADDR", "observability.metrics_addr"),
3011    ("TRACEPARENT", "observability.traceparent"),
3012    ("SERVE_MCP", "a2a.listen"),
3013    ("SERVE_BEARER", "a2a.bearer"),
3014    ("TLS_CA", "security.tls_ca"),
3015    ("ALLOW_TRIFECTA", "security.allow_trifecta"),
3016    ("WATCH_CONFIG", "lifecycle.watch_config"),
3017];
3018
3019/// Flags agentd does not accept, each paired with the hint that replaces it.
3020/// Naming one fails the load with its hint, so a stale command line is a loud
3021/// error rather than a flag that is silently ignored.
3022pub const REMOVED_FLAGS: &[(&str, &str)] = &[
3023    (
3024        "--mode",
3025        "modes are gone: give the workflow a start node (`once` | `loop` | `schedule` | `subscribe` | `signal` | `event` | `a2a` | `manual`) and set `lifecycle.run_until` if needed",
3026    ),
3027    (
3028        "--subscribe",
3029        "use a `subscribe` start node: `{kind: subscribe, server: <name>, uri: <uri>}`",
3030    ),
3031    (
3032        "--continue",
3033        "use a `subscribe` start node with `deliver: wait` (or a warm subagent)",
3034    ),
3035    (
3036        "--interval",
3037        "use a `loop` start node with `interval`, or a `schedule` start node with `every`",
3038    ),
3039    ("--cron", "use a `schedule` start node with `cron`"),
3040    // Clustering has no replacement flag, deliberately: agentd owns no
3041    // coordination protocol. A fleet partitions upstream instead — one
3042    // subscription per replica, or the queue's own lease semantics called from
3043    // a workflow step (docs/scaling.md).
3044    (
3045        "--shard",
3046        "agentd does not partition work; give each replica its own subscription (docs/scaling.md)",
3047    ),
3048    (
3049        "--claim",
3050        "call the queue's own claim/lease tools from a workflow step (docs/scaling.md)",
3051    ),
3052    ("--claim-ttl", "it went with --claim"),
3053    ("--claim-renew-fraction", "it went with --claim"),
3054    (
3055        "--standby",
3056        "there is no standby pool; a worker replica is an ordinary instance with its own subscription",
3057    ),
3058    ("--assign-from", "it went with --standby"),
3059    (
3060        "--workflow-resume",
3061        "automatic: runs resume from the store on restart (`resume_policy` per workflow)",
3062    ),
3063    (
3064        "--workflow-resume-force",
3065        "set `resume_policy: force` on the workflow",
3066    ),
3067];
3068
3069// ---------------------------------------------------------------------------
3070// Load pipeline
3071// ---------------------------------------------------------------------------
3072
3073/// The result of a v2 load: the typed settings plus the documents they came
3074/// from (the merged FILE document is kept for secret-provenance validation and
3075/// for the reload diff).
3076#[derive(Debug, Clone)]
3077pub struct Loaded {
3078    pub settings: Settings,
3079    /// The effective document (files ← env ← flags), what `Settings` typed.
3080    pub doc: Value,
3081    /// The merged FILE layer alone (before env/flags).
3082    pub file_doc: Value,
3083    pub files: Vec<(String, Format)>,
3084    /// Every non-fatal advisory collected during load (surfaced by
3085    /// `--validate-config` and logged at startup).
3086    pub warnings: Vec<String>,
3087}
3088
3089/// What the loader was asked to do besides loading (short-circuits the CLI
3090/// handles).
3091#[derive(Debug, Clone, PartialEq, Eq)]
3092pub enum Ask {
3093    Run,
3094    Help,
3095    Version,
3096    Schema,
3097    WorkflowSchema,
3098    /// `--context-template`: print the built-in system-prompt template, so an
3099    /// override starts from a copy rather than a guess.
3100    ContextTemplate,
3101    Validate,
3102    Capabilities,
3103    /// `--login <target>`: complete the interactive OAuth device flow for a
3104    /// configured endpoint and cache the token.
3105    Login(String),
3106    /// `--logout <target>`: evict a cached credential.
3107    Logout(String),
3108}
3109
3110/// Probe the invocation without side effects: which schema the config files
3111/// speak (`Detected`), so `main` can route to the v2 runtime.
3112pub fn probe(args: &[String], env: &[(String, String)]) -> Result<Detected, ConfigError> {
3113    let env = super::debrand_env(env);
3114    let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
3115    // A flag/env `config_version: "1"` selects the full runtime for a flag-only
3116    // invocation (`agentd --config-version 1 --instruction …`).
3117    let flag_v2 = args
3118        .windows(2)
3119        .any(|w| matches!(w[0].as_str(), "--config-version" | "--config_version") && w[1] == "1")
3120        || args
3121            .iter()
3122            .any(|a| a == "--config-version=1" || a == "--config_version=1")
3123        || envmap
3124            .get("AGENTD_CONFIG_VERSION")
3125            .or_else(|| envmap.get("CONFIG_VERSION"))
3126            .is_some_and(|v| *v == "1");
3127    let paths = super::config_paths_from_map(args, &envmap).paths;
3128    if paths.is_empty() {
3129        return Ok(if flag_v2 {
3130            Detected::V2
3131        } else {
3132            Detected::Empty
3133        });
3134    }
3135    let (doc, _) = file::read_documents_checked(&paths, &|_, _| Ok(())).map_err(usage)?;
3136    let d = detect(&doc);
3137    Ok(match (d, flag_v2) {
3138        (Detected::Empty, true) => Detected::V2,
3139        (Detected::V1, true) => Detected::Mixed,
3140        (d, _) => d,
3141    })
3142}
3143
3144/// Load, layer and validate a v2 document from `args` (excluding the program
3145/// name) and `env`. Returns `(Loaded, Ask)`; `Ask` tells the caller what the
3146/// invocation wants (`--help`, `--config-schema`, `--validate-config`, …).
3147/// Errors are `ConfigError::Usage` (exit 2), before any side effect.
3148pub fn load(args: &[String], env: &[(String, String)]) -> Result<(Loaded, Ask), ConfigError> {
3149    let env = super::debrand_env(env);
3150    let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
3151    let schema = schema::schema();
3152    let bindings = paths::bindings_of(&schema);
3153    let mut warnings = Vec::new();
3154
3155    // --- FILE layer: several files, later wins (JSON Merge Patch) ---
3156    let super::ConfigPaths {
3157        paths: config_paths,
3158        discovered,
3159        ambiguous,
3160    } = super::config_paths_from_map(args, &envmap);
3161    // Two spellings of ONE rung at once: refuse rather than pick. Whichever
3162    // agentd chose, somebody would be editing the other and wondering why
3163    // nothing changed. Only DISCOVERY is ambiguous this way — naming two files
3164    // that happen to be spelled `agentd.yml` and `agentd.yaml` (`--config
3165    // a/agentd.yml --config b/agentd.yaml`) states an order, so layering them
3166    // is legal. Ambiguity is per rung: `agentd.yml` beside `agentd.local.yml`
3167    // is the chain working as designed.
3168    if let Some(e) = ambiguous {
3169        return Err(usage(e));
3170    }
3171    let (file_doc, files) = if config_paths.is_empty() {
3172        (Value::Object(Map::new()), Vec::new())
3173    } else {
3174        file::read_documents_checked(&config_paths, &|doc, source| {
3175            // A v1/mixed file is judged after the merge (a clear migration
3176            // message); a v2 file is typed here so an unknown key names ITS file.
3177            match detect(doc) {
3178                Detected::V2 | Detected::Empty => {
3179                    Settings::from_document(doc.clone(), source).map(|_| ())
3180                }
3181                _ => Ok(()),
3182            }
3183        })
3184        .map_err(usage)?
3185    };
3186    match detect(&file_doc) {
3187        Detected::Mixed => {
3188            return Err(usage(
3189                "config file mixes legacy flat keys (model/subscribe/mcp_servers/…) with settings sections (agent/intelligence/…); \
3190                 migrate the legacy keys (docs/configuration.md §migration)"
3191                    .into(),
3192            ));
3193        }
3194        Detected::V1 => {
3195            return Err(usage(
3196                "config file speaks the retired flat schema; the loader needs `config_version: \"1\"` or settings sections (agent/intelligence/…)".into(),
3197            ));
3198        }
3199        _ => {}
3200    }
3201    // A DISCOVERED config governs an invocation that never named it: `cd` into a
3202    // repo you cloned, type `agentd --prompt …`, and that repo's `agentd.yml`
3203    // decides where your credentials go. Convenience is worth that only while the
3204    // file cannot RELAX a security control, so an unnamed file setting one is
3205    // exit 2 with the file and the setting named. An explicit `--config` keeps
3206    // its full power — naming the file IS the deliberate act, and that is the
3207    // whole distinction being drawn here.
3208    //
3209    // The rule covers the WHOLE chain, not its first rung: every file discovery
3210    // adopted was unnamed, so a machine-local overlay must not be able to relax
3211    // what the project file could not. The check reads the merged document for
3212    // exactly that reason — wherever the relaxation entered, it is refused.
3213    if discovered {
3214        let file = config_paths.join(", ");
3215        let file = file.as_str();
3216        if let Some((_, label)) = DISCOVERY_FORBIDDEN_RELAXATIONS
3217            .iter()
3218            .find(|(ptr, _)| file_doc.pointer(ptr).and_then(Value::as_bool) == Some(true))
3219        {
3220            return Err(usage(format!(
3221                "{file} was discovered, not named, and it sets {label}: a config found in the \
3222                 working directory may not relax a security control. Pass `--config {file}` if \
3223                 you meant to run under that file's grant."
3224            )));
3225        }
3226        // …and whatever else it wired that bears on security is named at startup
3227        // (option (c) of the containment): an adopted dotfile is never silent
3228        // about the endpoints, peers and powers it just chose for this process.
3229        let touched = discovered_security_settings(&file_doc);
3230        if !touched.is_empty() {
3231            warnings.push(format!(
3232                "adopted the discovered config {file} (no --config given); it sets {}",
3233                touched.join(", ")
3234            ));
3235        }
3236    }
3237    let mut doc = file_doc.clone();
3238
3239    // --- ENV layer: derived path names, then the short aliases. A path name
3240    // wins over an alias for the same field, since it names the field exactly
3241    // and cannot be a coincidence. ---
3242    let mut env_doc = Value::Object(Map::new());
3243    for (name, path) in ENV_ALIASES {
3244        let candidates = [
3245            format!("AGENTD_{name}"),
3246            format!("AGENT_{name}"),
3247            (*name).to_string(),
3248        ];
3249        if let Some(raw) = candidates.iter().find_map(|k| envmap.get(k.as_str())) {
3250            let binding = binding_for(&bindings, path)
3251                .ok_or_else(|| usage(format!("internal: alias path {path} not in schema")))?;
3252            let v = binding
3253                .coerce(raw)
3254                .map_err(|e| usage(format!("invalid {}: {e}", candidates[0])))?;
3255            paths::set_path(&mut env_doc, path, v);
3256        }
3257    }
3258    let (derived, _applied) = paths::env_document_in(&bindings, &envmap).map_err(usage)?;
3259    file::merge_into(&mut env_doc, derived);
3260    file::merge_into(&mut doc, env_doc);
3261
3262    // --- FLAG layer: aliases + generic path flags, in argument order ---
3263    let mut ask = Ask::Run;
3264    let mut mcp_tags: Vec<(String, Vec<String>)> = Vec::new();
3265    let mut it = args.iter().peekable();
3266    while let Some(arg) = it.next() {
3267        let a = arg.as_str();
3268        match a {
3269            "-h" | "--help" => ask = Ask::Help,
3270            "-V" | "--version" => ask = Ask::Version,
3271            "--config-schema" | "--config-schema=1" => ask = Ask::Schema,
3272            "--workflow-schema" => ask = Ask::WorkflowSchema,
3273            "--context-template" => ask = Ask::ContextTemplate,
3274            "--validate-config" => ask = Ask::Validate,
3275            "--capabilities" => ask = Ask::Capabilities,
3276            "--login" => {
3277                let t = it
3278                    .next()
3279                    .cloned()
3280                    .ok_or_else(|| usage("--login requires a target (e.g. mcp:<name>)".into()))?;
3281                ask = Ask::Login(t);
3282            }
3283            "--logout" => {
3284                let t = it
3285                    .next()
3286                    .cloned()
3287                    .ok_or_else(|| usage("--logout requires a target (e.g. mcp:<name>)".into()))?;
3288                ask = Ask::Logout(t);
3289            }
3290            "--config" | "-c" => {
3291                it.next(); // consumed by the FILE layer
3292            }
3293            // `--config=a.yaml` / `-c=a.yaml`: the FILE layer already took it.
3294            _ if matches!(
3295                crate::config::config_flag(a),
3296                crate::config::ConfigFlag::Inline(_)
3297            ) => {}
3298            _ => {
3299                if let Some((flag, hint)) = REMOVED_FLAGS.iter().find(|(f, _)| *f == a) {
3300                    return Err(usage(format!("{flag} was removed in agentd: {hint}")));
3301                }
3302                if let Some(alias) = ALIASES.iter().find(|al| al.flag == a) {
3303                    apply_alias(&mut doc, &bindings, alias, &mut it, &mut mcp_tags)?;
3304                    continue;
3305                }
3306                match paths::resolve_flag_in(&bindings, a).map_err(usage)? {
3307                    Some(target) => {
3308                        let raw = if matches!(target.value_kind(), paths::Kind::Boolean)
3309                            && !it.peek().is_some_and(|n| !n.starts_with("--"))
3310                        {
3311                            "true".to_string()
3312                        } else {
3313                            it.next()
3314                                .cloned()
3315                                .ok_or_else(|| usage(format!("{a} requires a value")))?
3316                        };
3317                        let value = paths::coerce(target.value_kind(), &raw)
3318                            .map_err(|e| usage(format!("invalid {a}: {e}")))?;
3319                        file::merge_into(&mut doc, target.document(value));
3320                    }
3321                    None => return Err(usage(format!("unknown argument: {a}"))),
3322                }
3323            }
3324        }
3325    }
3326    // `--mcp-tags name=tags` after every `--mcp` is known.
3327    for (name, tags) in mcp_tags {
3328        let Some(servers) = doc
3329            .pointer_mut("/mcp/servers")
3330            .and_then(Value::as_array_mut)
3331        else {
3332            return Err(usage(format!(
3333                "--mcp-tags references unknown server '{name}'"
3334            )));
3335        };
3336        match servers
3337            .iter_mut()
3338            .find(|s| s.get("name").and_then(Value::as_str) == Some(name.as_str()))
3339        {
3340            Some(s) => {
3341                s["tags"] = json!({ "*": tags });
3342            }
3343            None => {
3344                return Err(usage(format!(
3345                    "--mcp-tags references unknown server '{name}'"
3346                )));
3347            }
3348        }
3349    }
3350
3351    // --- conventional folders beside the config ---
3352    //
3353    // Runs BEFORE the instruction sugar on purpose: a project with a
3354    // `workflows/` folder has declared its machinery, and the sugar `main` loop
3355    // is for the case where nothing did.
3356    apply_default_folders(&mut doc, &config_dirs(&config_paths), &mut warnings);
3357
3358    // --- sugar: `agentd --instruction X` with no workflows ---
3359    if ask == Ask::Run || ask == Ask::Validate {
3360        apply_instruction_sugar(&mut doc);
3361    }
3362
3363    // --- env substitution: `${VAR}` / `${VAR:-default}` in any string value of
3364    //     the merged document (config + workflows), from the process env. Distinct
3365    //     from `{{secret:…}}` (which resolves a redacted credential). ---
3366    if let Err(e) = substitute_env(&mut doc, &envmap) {
3367        return Err(usage(e));
3368    }
3369
3370    // --- type + validate ---
3371    let mut settings = Settings::from_document(doc.clone(), "config").map_err(usage)?;
3372    // --- durability a laptop already satisfies ---
3373    //
3374    // A long-lived instance that names no store gets the FILE adapter: durable
3375    // to whatever filesystem it lands on, with the runtime logging exactly that
3376    // at startup (`store.file`) rather than implying more. Demanding a
3377    // coordination backend before the operator has run anything would make the
3378    // first honest deployment the hardest one.
3379    //
3380    // "Absent" is read off the effective DOCUMENT, not off `settings.store.kind`
3381    // — `StoreKind` derives `Default = None`, so the typed value cannot tell a
3382    // config that said nothing from one that said `none`. `doc` is the merged
3383    // file ← env ← flag layers, so `--store-kind none` / `AGENTD_STORE_KIND=none`
3384    // count as explicit exactly like the YAML key does. That distinction is the
3385    // whole point: an operator who WROTE `none` on a long-lived instance still
3386    // gets the diagnostic (validate, below), because silently overriding a
3387    // stated choice is worse than refusing to start.
3388    //
3389    // A one-shot instance is deliberately untouched and keeps `none`: a job that
3390    // suddenly began writing state to disk would surprise every existing user of
3391    // it, and re-running it is already the recovery story.
3392    //
3393    // "Explicit" has to mean any layer INCLUDING an instruction directive. A
3394    // `:::config` fragment declaring `store: {kind: memory}` is folded into
3395    // `settings` during typing and never written back into `doc`, so reading
3396    // the document alone judged it unstated and overrode it. That stayed hidden
3397    // only because `stream` was missing from the long-lived list this check
3398    // depends on: a stream-only agent was misclassified short-lived, so the
3399    // branch never ran. Fixing the classification exposed it — an agent defined
3400    // entirely in one markdown file, stating a memory store, silently got a
3401    // file store instead and then collided on the shared state directory.
3402    let store_stated =
3403        doc.pointer("/store/kind").is_some() || settings.store.kind != StoreKind::default();
3404    if !store_stated && settings.is_long_lived() {
3405        settings.store.kind = StoreKind::File;
3406    }
3407    // Resolve `service:` references and apply the tag floor BEFORE validation
3408    // and the trifecta gate, so both judge the effective servers rather than
3409    // the pre-resolution shorthand.
3410    let service_errors = resolve_services(&mut settings);
3411    let mut loaded = Loaded {
3412        settings,
3413        doc,
3414        file_doc,
3415        files,
3416        warnings: Vec::new(),
3417    };
3418    // `--prompt-missing`, before validation: the person is standing at a
3419    // terminal ready to supply what is missing, so ask FIRST and let the
3420    // validation that follows see the values — otherwise the aggregate error
3421    // below exits before a prompt could ever appear. Only in run mode: a
3422    // `--validate-config` must stay side-effect-free and report, not converse.
3423    if ask == Ask::Run && crate::config::prompt::prompt_missing_requested() {
3424        let mut found = Vec::new();
3425        scan_references(&loaded.doc, "config", &mut found);
3426        let mut names: Vec<String> = found
3427            .into_iter()
3428            .filter(|r| r.kind == "secret" && !crate::sec::secret::secret_available(&r.name))
3429            .map(|r| r.name)
3430            .collect();
3431        names.sort();
3432        names.dedup();
3433        for name in names {
3434            match crate::config::prompt::read_secret_from_tty(&format!("{name} (secret)")) {
3435                Ok(v) => crate::sec::secret::set_prompted(&name, v),
3436                // A failed prompt (no terminal, EOF) falls through to the
3437                // normal aggregate refusal below, which names what is missing.
3438                Err(_) => break,
3439            }
3440        }
3441    }
3442    let mut diags = validate(&loaded);
3443    diags.errors.splice(0..0, service_errors);
3444    warnings.extend(diags.warnings);
3445    loaded.warnings = warnings;
3446    if ask != Ask::Validate
3447        && ask != Ask::Help
3448        && ask != Ask::Version
3449        && ask != Ask::Schema
3450        && ask != Ask::WorkflowSchema
3451        && ask != Ask::ContextTemplate
3452        && !matches!(ask, Ask::Login(_) | Ask::Logout(_))
3453        && let Some(first) = diags.errors.first()
3454    {
3455        // ALL of them, not the first. Failing on whichever error happens to
3456        // sort first turns fixing a config into a loop of restart, read one
3457        // line, fix one thing — the aggregate report is the whole point of
3458        // validating everything up front.
3459        let msg = if diags.errors.len() == 1 {
3460            first.clone()
3461        } else {
3462            format!(
3463                "{} configuration errors:\n  - {}",
3464                diags.errors.len(),
3465                diags.errors.join("\n  - ")
3466            )
3467        };
3468        return Err(usage(msg));
3469    }
3470    if ask == Ask::Validate && !diags.errors.is_empty() {
3471        return Err(ConfigError::Validate(Err(diags
3472            .errors
3473            .iter()
3474            .map(|d| super::config_invalid_line(d))
3475            .collect::<Vec<_>>()
3476            .join("\n"))));
3477    }
3478    Ok((loaded, ask))
3479}
3480
3481/// The security controls a config file can **relax** — the two booleans that
3482/// widen what this process may do: lifting the lethal-trifecta refusal, and
3483/// turning on the local command runner. A file the operator NAMED may set
3484/// them; a file merely discovered in the working directory may not, so
3485/// stepping into a repository cannot silently grant its dotfile more power
3486/// than the operator asked for. Narrowing settings are deliberately absent:
3487/// a dotfile that takes power away needs no ceremony.
3488const DISCOVERY_FORBIDDEN_RELAXATIONS: [(&str, &str); 2] = [
3489    ("/security/allow_trifecta", "security.allow_trifecta"),
3490    ("/security/exec/enabled", "security.exec.enabled"),
3491];
3492
3493/// The settings that decide where this agent's credentials go, who may reach
3494/// it, and what it may call. A DISCOVERED config that sets any of them has them
3495/// named in a startup `config.warning`, so adopting a dotfile is visible in the
3496/// log rather than inferred from behaviour. Pointer + the dotted label to print:
3497/// NAMES only, never values — a value may be a `{{secret:…}}` template, and
3498/// the log is not the place to widen a credential's blast radius.
3499const DISCOVERY_SECURITY_SETTINGS: [(&str, &str); 12] = [
3500    ("/intelligence/endpoints", "intelligence.endpoints"),
3501    ("/intelligence/token", "intelligence.token"),
3502    ("/intelligence/token_file", "intelligence.token_file"),
3503    ("/intelligence/headers", "intelligence.headers"),
3504    ("/intelligence/auth", "intelligence.auth"),
3505    ("/mcp/servers", "mcp.servers"),
3506    ("/tools/overrides", "tools.overrides"),
3507    ("/store", "store"),
3508    ("/a2a/listen", "a2a.listen"),
3509    ("/a2a/peers", "a2a.peers"),
3510    ("/webhooks/listen", "webhooks.listen"),
3511    ("/security", "security"),
3512];
3513
3514/// Which of [`DISCOVERY_SECURITY_SETTINGS`] the file layer actually set, in
3515/// declaration order. An explicit `null` counts as unset, matching the merge
3516/// semantics where `null` removes a key.
3517fn discovered_security_settings(file_doc: &Value) -> Vec<&'static str> {
3518    DISCOVERY_SECURITY_SETTINGS
3519        .iter()
3520        .filter(|(ptr, _)| file_doc.pointer(ptr).is_some_and(|v| !v.is_null()))
3521        .map(|(_, label)| *label)
3522        .collect()
3523}
3524
3525fn binding_for<'a>(bindings: &'a [Binding], path: &str) -> Option<&'a Binding> {
3526    bindings.iter().find(|b| b.path == path)
3527}
3528
3529fn apply_alias(
3530    doc: &mut Value,
3531    bindings: &[Binding],
3532    alias: &Alias,
3533    it: &mut std::iter::Peekable<std::slice::Iter<'_, String>>,
3534    mcp_tags: &mut Vec<(String, Vec<String>)>,
3535) -> Result<(), ConfigError> {
3536    let mut take = || -> Result<String, ConfigError> {
3537        it.next()
3538            .cloned()
3539            .ok_or_else(|| usage(format!("{} requires a value", alias.flag)))
3540    };
3541    match alias.kind {
3542        AliasKind::Set => {
3543            let raw = take()?;
3544            let b = binding_for(bindings, alias.path).ok_or_else(|| {
3545                usage(format!("internal: alias path {} not in schema", alias.path))
3546            })?;
3547            let v = b
3548                .coerce(&raw)
3549                .map_err(|e| usage(format!("invalid {}: {e}", alias.flag)))?;
3550            let mut patch = Value::Object(Map::new());
3551            paths::set_path(&mut patch, alias.path, v);
3552            file::merge_into(doc, patch);
3553        }
3554        AliasKind::SetTrue => {
3555            let mut patch = Value::Object(Map::new());
3556            paths::set_path(&mut patch, alias.path, Value::Bool(true));
3557            file::merge_into(doc, patch);
3558        }
3559        AliasKind::SetFromFile => {
3560            let path = take()?;
3561            let text = super::read_file(&path)?;
3562            let mut patch = Value::Object(Map::new());
3563            paths::set_path(&mut patch, alias.path, Value::String(text));
3564            file::merge_into(doc, patch);
3565        }
3566        AliasKind::Append => {
3567            let raw = take()?;
3568            let element = match alias.flag {
3569                "--mcp" => {
3570                    let (name, endpoint) = raw
3571                        .split_once('=')
3572                        .ok_or_else(|| usage(format!("--mcp: want name=endpoint (got: {raw})")))?;
3573                    json!({ "name": name.trim(), "endpoint": endpoint.trim() })
3574                }
3575                "--a2a-peer" => {
3576                    let (name, endpoint) = raw.split_once('=').ok_or_else(|| {
3577                        usage(format!("--a2a-peer: want name=endpoint (got: {raw})"))
3578                    })?;
3579                    json!({ "name": name.trim(), "endpoint": endpoint.trim() })
3580                }
3581                "--workflow" => {
3582                    let name = std::path::Path::new(&raw)
3583                        .file_stem()
3584                        .and_then(|s| s.to_str())
3585                        .unwrap_or("workflow")
3586                        .to_string();
3587                    json!({ "name": name, "file": raw })
3588                }
3589                other => return Err(usage(format!("internal: no append rule for {other}"))),
3590            };
3591            append_at(doc, alias.path, element);
3592        }
3593        AliasKind::Special => match alias.flag {
3594            "--mcp-tags" => {
3595                let raw = take()?;
3596                let (name, tags) = raw
3597                    .split_once('=')
3598                    .ok_or_else(|| usage(format!("--mcp-tags: want name=tag,tag (got: {raw})")))?;
3599                mcp_tags.push((
3600                    name.trim().to_string(),
3601                    tags.split(',')
3602                        .map(str::trim)
3603                        .filter(|t| !t.is_empty())
3604                        .map(str::to_string)
3605                        .collect(),
3606                ));
3607            }
3608            "--budget-exit-code" => {
3609                let raw = take()?;
3610                let n: i64 = raw
3611                    .trim()
3612                    .parse()
3613                    .ok()
3614                    .filter(|n| (0..=255).contains(n))
3615                    .ok_or_else(|| {
3616                        usage(format!("invalid --budget-exit-code: {raw} (want 0..=255)"))
3617                    })?;
3618                let mut patch = Value::Object(Map::new());
3619                paths::set_path(
3620                    &mut patch,
3621                    "lifecycle.exit_code_map",
3622                    json!({ "3": n, "7": n }),
3623                );
3624                file::merge_into(doc, patch);
3625            }
3626            other => return Err(usage(format!("internal: no special rule for {other}"))),
3627        },
3628    }
3629    Ok(())
3630}
3631
3632/// Push `element` onto the array at dotted `path` (creating it).
3633fn append_at(doc: &mut Value, path: &str, element: Value) {
3634    let pointer = format!("/{}", path.replace('.', "/"));
3635    if doc.pointer(&pointer).is_none() {
3636        let mut patch = Value::Object(Map::new());
3637        paths::set_path(&mut patch, path, Value::Array(Vec::new()));
3638        file::merge_into(doc, patch);
3639    }
3640    if let Some(arr) = doc.pointer_mut(&pointer) {
3641        if !arr.is_array() {
3642            *arr = Value::Array(Vec::new());
3643        }
3644        arr.as_array_mut().expect("array").push(element);
3645    }
3646}
3647
3648/// Where conventional folders are looked for: beside each config file, MOST
3649/// SPECIFIC FIRST.
3650///
3651/// A single directory is not enough. `agentd -c ./agentd.yml -c /tmp/over.yml`
3652/// is an ordinary shape — a thin overlay that lives nowhere near the project —
3653/// and keying on the last file alone finds no folders and falls back to the
3654/// sugar `main` loop in silence, which is a worse outcome than any ordering
3655/// question. Keying on the FIRST is wrong too: the chain's first rung is
3656/// `~/.config/agentd`, where nobody keeps a project's workflows.
3657///
3658/// The working directory is a candidate ONLY when no config file was loaded at
3659/// all. Adding it unconditionally leaks: `agentd -c examples/voice/ears.yaml`
3660/// run from a repository root adopted that repository's own `./skills`, which
3661/// is exactly the "a stray file modified a run you spelled out" surprise the
3662/// whole discovery design refuses. Naming a config means the caller decided,
3663/// and that has to hold for the folders beside it too. Discovery does not lose
3664/// anything here — a discovered `./agentd.yml` has `.` as its parent, so the
3665/// working directory is already in the list when it should be.
3666fn config_dirs(paths: &[String]) -> Vec<PathBuf> {
3667    if paths.is_empty() {
3668        return vec![PathBuf::from(".")];
3669    }
3670    let mut out: Vec<PathBuf> = Vec::new();
3671    for p in paths.iter().rev() {
3672        // A path with no directory component — `-c agentd.yml` — names a file
3673        // in the working directory, so its directory IS the working directory.
3674        // Filtering the empty parent out instead dropped the project entirely
3675        // whenever the config was named relatively, which is the common way to
3676        // name it.
3677        let d = match Path::new(p).parent() {
3678            Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
3679            _ => PathBuf::from("."),
3680        };
3681        if !out.contains(&d) {
3682            out.push(d);
3683        }
3684    }
3685    out
3686}
3687
3688/// Files in `dir` with any of `exts`, sorted, empty when the directory is not
3689/// there. Sorted so a folder's load order is its filename order — the only
3690/// ordering an operator can see without reading the loader.
3691fn folder_files(dir: &Path, exts: &[&str]) -> Vec<PathBuf> {
3692    let Ok(rd) = std::fs::read_dir(dir) else {
3693        return Vec::new();
3694    };
3695    let mut out: Vec<PathBuf> = rd
3696        .flatten()
3697        .map(|e| e.path())
3698        .filter(|p| p.is_file())
3699        .filter(|p| {
3700            p.extension()
3701                .and_then(|e| e.to_str())
3702                .is_some_and(|e| exts.contains(&e))
3703        })
3704        .collect();
3705    out.sort();
3706    out
3707}
3708
3709/// Adopt the conventional folders beside the config — `workflows/`,
3710/// `subagents/`, `context/` — for the settings the operator did not write.
3711///
3712/// Two rules make these CONVENTIONS rather than declarations, and both matter:
3713///
3714/// 1. **Only when the setting is absent.** Someone who wrote `workflows:` has
3715///    decided, including writing an empty list to mean "none". A default that
3716///    appended to an explicit list would make the explicit one unreadable.
3717/// 2. **Only when the folder yields something.** A named `dir:` with no match
3718///    is exit 2, and rightly so — you asked for it by name. A default that did
3719///    the same would make agentd unrunnable in any directory that happens to
3720///    lack a `subagents/`, which is nearly all of them.
3721///
3722/// Injection happens on the merged DOCUMENT, so everything downstream —
3723/// validation, `{{config.*}}` folding, the definition hash, hot reload — sees
3724/// an ordinary explicit entry and needs no case for "came from a folder".
3725fn apply_default_folders(doc: &mut Value, dirs: &[PathBuf], warnings: &mut Vec<String>) {
3726    let Some(obj) = doc.as_object_mut() else {
3727        return;
3728    };
3729
3730    /// The first candidate directory whose `<dir>/<name>` satisfies `has`.
3731    fn find(dirs: &[PathBuf], name: &str, has: impl Fn(&Path) -> bool) -> Option<PathBuf> {
3732        dirs.iter().map(|d| d.join(name)).find(|p| has(p))
3733    }
3734
3735    // workflows/ — reuses the `dir:` entry the loader already expands, so the
3736    // glob, the sort and the naming stay one implementation rather than two.
3737    if !obj.contains_key("workflows")
3738        && let Some(d) = find(dirs, "workflows", |p| {
3739            !folder_files(p, &["yaml", "yml", "json"]).is_empty()
3740        })
3741    {
3742        obj.insert(
3743            "workflows".into(),
3744            json!([{"dir": d.to_string_lossy(), "glob": "*.yaml,*.yml,*.json"}]),
3745        );
3746    }
3747
3748    // skills/ — prose the model reads, so it needs no server. Either
3749    // `<name>.md` or the Agent Skill directory form `<name>/SKILL.md`.
3750    if obj.get("skills").and_then(|s| s.get("dir")).is_none()
3751        && let Some(d) = find(dirs, "skills", |p| {
3752            !folder_files(p, &["md"]).is_empty()
3753                || std::fs::read_dir(p)
3754                    .is_ok_and(|rd| rd.flatten().any(|e| e.path().join("SKILL.md").is_file()))
3755        })
3756        && let Some(sk) = obj
3757            .entry("skills")
3758            .or_insert_with(|| json!({}))
3759            .as_object_mut()
3760    {
3761        sk.insert("dir".into(), json!(d.to_string_lossy()));
3762    }
3763
3764    // subagents/ — one reviewed template per file, named by stem.
3765    if obj
3766        .get("subagents")
3767        .and_then(|s| s.get("templates"))
3768        .is_none()
3769        && let Some(d) = find(dirs, "subagents", |p| {
3770            !folder_files(p, &["yaml", "yml", "json"]).is_empty()
3771        })
3772    {
3773        let mut templates = Map::new();
3774        for path in folder_files(&d, &["yaml", "yml", "json"]) {
3775            let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
3776                continue;
3777            };
3778            match file::read_document(&path.to_string_lossy()) {
3779                Ok((v, _)) => {
3780                    templates.insert(name.to_string(), v);
3781                }
3782                Err(e) => warnings.push(format!("subagents template {}: {e}", path.display())),
3783            }
3784        }
3785        if !templates.is_empty()
3786            && let Some(sub) = obj
3787                .entry("subagents")
3788                .or_insert_with(|| json!({}))
3789                .as_object_mut()
3790        {
3791            sub.insert("templates".into(), Value::Object(templates));
3792        }
3793    }
3794
3795    // context/ — a prompt template is prose, so it is a file whose whole body
3796    // is the template and whose stem is the name a node selects with
3797    // `context: {template: <name>}`.
3798    if obj
3799        .get("context")
3800        .and_then(|c| c.get("templates"))
3801        .is_none()
3802        && let Some(d) = find(dirs, "context", |p| {
3803            !folder_files(p, &["md", "txt", "hbs"]).is_empty()
3804        })
3805    {
3806        let mut templates = Map::new();
3807        for path in folder_files(&d, &["md", "txt", "hbs"]) {
3808            let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
3809                continue;
3810            };
3811            match std::fs::read_to_string(&path) {
3812                Ok(text) => {
3813                    templates.insert(name.to_string(), Value::String(text));
3814                }
3815                Err(e) => warnings.push(format!("context template {}: {e}", path.display())),
3816            }
3817        }
3818        if !templates.is_empty()
3819            && let Some(ctx) = obj
3820                .entry("context")
3821                .or_insert_with(|| json!({}))
3822                .as_object_mut()
3823        {
3824            ctx.insert("templates".into(), Value::Object(templates));
3825        }
3826    }
3827}
3828
3829/// `agentd --instruction X` (or `agent.instruction` alone) with no workflows ⇒
3830/// the one-node workflow `once → agent → finish`.
3831///
3832/// A `--prompt` deliberately does NOT come here: a prompt is a **message to
3833/// the agent**, delivered into its root context at startup, not a canned
3834/// workflow step. That is what lets it set itself up — workflow-authoring
3835/// tools are root-scoped, so a prompt running as a step could never define the
3836/// loop/schedule it was asked for (`Caller::Workflow` vs `Caller::Root` in
3837/// the registry).
3838fn apply_instruction_sugar(doc: &mut Value) {
3839    let has_workflows = doc
3840        .pointer("/workflows")
3841        .and_then(Value::as_array)
3842        .is_some_and(|w| !w.is_empty());
3843    let nonblank = |p: &str| {
3844        doc.pointer(p)
3845            .and_then(Value::as_str)
3846            .is_some_and(|s| !s.trim().is_empty())
3847    };
3848    let has_instruction = nonblank("/agent/instruction");
3849    // An instruction that CARRIES a `:::workflow` directive has authored its
3850    // machinery explicitly — extraction (from_document) will add it to
3851    // `workflows:`, so generating a sugar `main` here would bolt a model loop
3852    // onto a config that declared none.
3853    let carries_workflow = doc
3854        .pointer("/agent/instruction")
3855        .and_then(Value::as_str)
3856        .is_some_and(|t| t.lines().any(|l| l.starts_with(":::workflow")));
3857    // A prompt runs as a root turn, so an instruction+prompt pair needs no
3858    // sugar workflow at all — the prompt IS the job.
3859    if has_workflows || carries_workflow || !has_instruction || nonblank("/agent/prompt") {
3860        return;
3861    }
3862    let work = json!({
3863        "kind": "agent",
3864        "depends_on": ["start"],
3865        "instruction": "{{env.instruction}}",
3866    });
3867    let mut patch = Value::Object(Map::new());
3868    paths::set_path(
3869        &mut patch,
3870        "workflows",
3871        json!([{
3872            "name": "main",
3873            "version": 3,
3874            "steps": {
3875                "start": { "kind": "once" },
3876                "work":  work,
3877                "done":  { "kind": "finish", "depends_on": ["work"], "status": "completed", "output": "{{steps.work.output}}" }
3878            }
3879        }]),
3880    );
3881    file::merge_into(doc, patch);
3882}
3883
3884/// Substitute `${VAR}` / `${VAR:-default}` references in **every string value**
3885/// of the merged document (config sections *and* inline workflows) from the
3886/// process environment. Braces are required — a bare `$VAR` (or a `$` not
3887/// followed by `{`) is left verbatim, and `$${` yields a literal `${` — so
3888/// shell snippets and prices survive untouched. An unset variable with no
3889/// default is a hard error (fail-closed). This is intentionally distinct from
3890/// `{{secret:NAME}}` / `{{secret-file:PATH}}` (which resolve a *redacted*
3891/// credential and are never echoed): `${VAR}` is for plain, loggable values
3892/// like hostnames, ports, and paths that differ per environment.
3893fn substitute_env(v: &mut Value, env: &HashMap<&str, &str>) -> Result<(), String> {
3894    match v {
3895        Value::String(s) => {
3896            if s.as_bytes().contains(&b'$') {
3897                *s = expand_env_str(s, env)?;
3898            }
3899            Ok(())
3900        }
3901        Value::Array(a) => a.iter_mut().try_for_each(|item| substitute_env(item, env)),
3902        Value::Object(m) => m.values_mut().try_for_each(|val| substitute_env(val, env)),
3903        _ => Ok(()),
3904    }
3905}
3906
3907/// Expand a single string's `${…}` references (see [`substitute_env`]).
3908fn expand_env_str(s: &str, env: &HashMap<&str, &str>) -> Result<String, String> {
3909    let mut out = String::with_capacity(s.len());
3910    let b = s.as_bytes();
3911    let mut i = 0;
3912    while i < b.len() {
3913        // `$` is ASCII and can never appear inside a multi-byte UTF-8 sequence,
3914        // so scanning for it byte-wise is safe; the fallthrough advances by whole
3915        // chars to keep every slice on a boundary.
3916        if b[i] == b'$' {
3917            if b.get(i + 1) == Some(&b'$') {
3918                out.push('$'); // `$$` -> literal `$`
3919                i += 2;
3920                continue;
3921            }
3922            if b.get(i + 1) == Some(&b'{') {
3923                let start = i + 2;
3924                let Some(rel) = s[start..].find('}') else {
3925                    return Err(format!("unterminated `${{` in config value {s:?}"));
3926                };
3927                let end = start + rel;
3928                let expr = &s[start..end];
3929                let (name, default) = match expr.split_once(":-") {
3930                    Some((n, d)) => (n.trim(), Some(d)),
3931                    None => (expr.trim(), None),
3932                };
3933                if name.is_empty() {
3934                    return Err(format!("empty `${{}}` reference in config value {s:?}"));
3935                }
3936                if !name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') {
3937                    return Err(format!(
3938                        "invalid environment variable name {name:?} in `${{{expr}}}`"
3939                    ));
3940                }
3941                match env.get(name) {
3942                    Some(val) => out.push_str(val),
3943                    None => match default {
3944                        Some(d) => out.push_str(d),
3945                        None => {
3946                            return Err(format!(
3947                                "environment variable ${{{name}}} is not set (referenced in config); \
3948                                 set it or write ${{{name}:-default}}"
3949                            ));
3950                        }
3951                    },
3952                }
3953                i = end + 1;
3954                continue;
3955            }
3956        }
3957        let ch = s[i..].chars().next().unwrap();
3958        out.push(ch);
3959        i += ch.len_utf8();
3960    }
3961    Ok(out)
3962}
3963
3964// ---------------------------------------------------------------------------
3965// Validation
3966// ---------------------------------------------------------------------------
3967
3968/// Collected diagnostics: `errors` fail the load (exit 2); `warnings` are
3969/// advisory (logged, printed by `--validate-config`).
3970#[derive(Debug, Default, Clone)]
3971pub struct Diagnostics {
3972    pub errors: Vec<String>,
3973    pub warnings: Vec<String>,
3974}
3975
3976/// Every check, collected (never fast-fails) so `--validate-config` reports
3977/// all problems at once. Pure.
3978/// Validate a unified `auth:` block — the fields each `kind`/`grant` requires,
3979/// and secret-freedom for credential fields. Returns error strings prefixed
3980/// with `ctx` (e.g. `mcp server 'github'`).
3981fn validate_auth_block(auth: &Auth, ctx: &str) -> Vec<String> {
3982    let mut out = Vec::new();
3983    // Credential fields must be `{{secret:…}}` references, never inline.
3984    for (field, s) in [
3985        ("client_secret", &auth.client_secret),
3986        ("token", &auth.token),
3987        ("value", &auth.value),
3988    ] {
3989        if let Some(sec) = s
3990            && !sec.0.trim().is_empty()
3991            && !crate::sec::secret::has_secret_ref(&sec.0)
3992        {
3993            out.push(format!(
3994                "{ctx}: auth.{field} carries an inline credential; use a {{{{secret:…}}}} reference"
3995            ));
3996        }
3997    }
3998    match auth.kind {
3999        AuthKind::Static => {
4000            let has_bearer = auth.token.is_some();
4001            let has_header = auth.header.is_some() && auth.value.is_some();
4002            if !has_bearer && !has_header {
4003                out.push(format!(
4004                    "{ctx}: auth.kind static needs `token` (a bearer) or `header` + `value`"
4005                ));
4006            }
4007        }
4008        AuthKind::Aws => {
4009            if auth.region.is_none() {
4010                out.push(format!("{ctx}: auth.kind aws needs `region`"));
4011            }
4012            if auth.service.is_none() {
4013                out.push(format!(
4014                    "{ctx}: auth.kind aws needs `service` (e.g. bedrock, execute-api)"
4015                ));
4016            }
4017            match auth.source.as_deref() {
4018                Some("sso") => {
4019                    if auth.sso_start_url.is_none()
4020                        || auth.account_id.is_none()
4021                        || auth.role_name.is_none()
4022                    {
4023                        out.push(format!(
4024                            "{ctx}: aws source sso needs `sso_start_url` + `account_id` + `role_name`"
4025                        ));
4026                    }
4027                }
4028                Some(src) if !matches!(src, "env" | "static" | "imds" | "irsa") => {
4029                    out.push(format!(
4030                        "{ctx}: auth.source '{src}' is not a known AWS source (env|static|imds|irsa|sso)"
4031                    ));
4032                }
4033                _ => {}
4034            }
4035        }
4036        AuthKind::Spiffe => match auth.svid.as_deref().unwrap_or("jwt") {
4037            "jwt" => {
4038                if auth.jwt_svid_file.is_none() {
4039                    out.push(format!(
4040                        "{ctx}: auth.kind spiffe (svid jwt) needs `jwt_svid_file`"
4041                    ));
4042                }
4043            }
4044            "x509" => {
4045                if auth.svid_file.is_none() || auth.key_file.is_none() {
4046                    out.push(format!(
4047                        "{ctx}: auth.kind spiffe (svid x509) needs `svid_file` + `key_file`"
4048                    ));
4049                }
4050            }
4051            other => out.push(format!("{ctx}: auth.svid '{other}' (want jwt|x509)")),
4052        },
4053        AuthKind::Oauth2 => {
4054            if auth.client_id.is_none() {
4055                out.push(format!("{ctx}: auth.kind oauth2 needs `client_id`"));
4056            }
4057            if auth.token_url.is_none() && auth.issuer.is_none() {
4058                out.push(format!(
4059                    "{ctx}: auth oauth2 needs `token_url` or `issuer` (for discovery)"
4060                ));
4061            }
4062            match auth.grant.unwrap_or(OAuthGrant::Device) {
4063                OAuthGrant::Device => {
4064                    if auth.device_authorization_url.is_none() && auth.issuer.is_none() {
4065                        out.push(format!(
4066                            "{ctx}: the device grant needs `device_authorization_url` or `issuer`"
4067                        ));
4068                    }
4069                }
4070                OAuthGrant::ClientCredentials => {
4071                    if auth.client_secret.is_none() {
4072                        out.push(format!(
4073                            "{ctx}: the client_credentials grant needs `client_secret`"
4074                        ));
4075                    }
4076                }
4077                OAuthGrant::AuthorizationCode => {
4078                    if auth.authorization_url.is_none() && auth.issuer.is_none() {
4079                        out.push(format!(
4080                            "{ctx}: the authorization_code grant needs `authorization_url` or `issuer`"
4081                        ));
4082                    }
4083                }
4084            }
4085        }
4086    }
4087    out
4088}
4089
4090/// Why a declared header value's `{{secret:NAME}}` / `{{secret-file:PATH}}` ref
4091/// does not resolve, or `None` when it does (or when the value carries no ref).
4092///
4093/// This is the security half of header validation, and it is not cosmetic: a
4094/// header whose ref does not resolve is a header that is **not sent**, so
4095/// without this check the process starts and dials the endpoint with no
4096/// credential at all. Validating before any side effect makes that exit 2 at
4097/// startup, naming the ref — the same rule, and the same resolver, the runtime
4098/// applies at the moment of use. The message names the ref and never the
4099/// resolved value, so a diagnostic cannot leak the credential.
4100fn unresolved_secret_ref(value: &str) -> Option<String> {
4101    if !crate::sec::secret::has_secret_ref(value) {
4102        return None;
4103    }
4104    // Interactively-entered values (`--prompt-missing`) count as resolvable:
4105    // by the time the runtime dereferences the ref, the prompted store answers.
4106    crate::sec::secret::refs_resolvable(value, &|k| {
4107        crate::sec::secret::prompted_of(k).or_else(|| std::env::var(k).ok())
4108    })
4109    .err()
4110}
4111
4112pub fn validate(loaded: &Loaded) -> Diagnostics {
4113    let s = &loaded.settings;
4114    let mut d = Diagnostics::default();
4115    let err = |d: &mut Diagnostics, m: String| d.errors.push(m);
4116
4117    // Every `{{secret:…}}` / `{{secret-file:…}}` / `{{config.…}}` in the whole
4118    // document, checked the way STARTUP checks it — the identical scan, so the
4119    // two cannot disagree.
4120    //
4121    // They did disagree, and that is the bug this closes. The scan already
4122    // existed and ran only at startup; validation checked credentials in
4123    // exactly one place, `intelligence.headers`, because the check rode along
4124    // with a lint that only header maps have. So `intelligence.token`,
4125    // `mcp.servers[].auth.token` and `a2a.principals[].match.bearer_ref` — the
4126    // idiomatic spellings — passed `--validate-config` and then exited 2 at
4127    // startup. A validator that green-lights a config the daemon refuses is
4128    // worse than no validator: it is the one tool whose whole purpose is to
4129    // move that failure before any side effect.
4130    for m in missing_references(&loaded.doc, "config", &s.vars) {
4131        err(&mut d, m);
4132    }
4133
4134    // An `auth.hmac.algo` the verifier does not implement. Refused at listener
4135    // build too, but catching it HERE is the point: this is the same
4136    // validate/startup divergence the reference scan above closes, and adding a
4137    // startup-only refusal would have re-opened it in a security field.
4138    for (at, algo) in hmac_algos(&loaded.doc, "config") {
4139        if !algo.eq_ignore_ascii_case("sha256") {
4140            err(
4141                &mut d,
4142                format!(
4143                    "{at} {algo:?} is not implemented — agentd computes HMAC-SHA256 only;                      use `algo: sha256` (or omit it) and have senders sign SHA-256"
4144                ),
4145            );
4146        }
4147    }
4148
4149    // config_version
4150    if let Some(v) = &s.config_version
4151        && v != schema::CONFIG_VERSION
4152    {
4153        err(
4154            &mut d,
4155            format!(
4156                "config_version must be \"{}\" (got {v:?})",
4157                schema::CONFIG_VERSION
4158            ),
4159        );
4160    }
4161
4162    // observability.runtime_events / audit.stream — both name a stream, and a
4163    // stream that is not declared can never be appended to. Refusing here
4164    // rather than at the first append keeps the failure at boot, where an
4165    // operator is looking, instead of inside a storm.
4166    if let Some(re) = &s.observability.runtime_events {
4167        match re.stream.as_deref() {
4168            None => err(
4169                &mut d,
4170                "observability.runtime_events: `stream` is required".into(),
4171            ),
4172            Some(name) if !s.streams.contains_key(name) => err(
4173                &mut d,
4174                format!(
4175                    "observability.runtime_events.stream: {name:?} is not declared (add it under `streams:`)"
4176                ),
4177            ),
4178            Some(_) => {}
4179        }
4180        if re.include.is_empty() && re.sampled.is_empty() {
4181            err(
4182                &mut d,
4183                "observability.runtime_events: name at least one family in `include` or `sampled`"
4184                    .into(),
4185            );
4186        }
4187        for f in re.include.iter().chain(re.sampled.iter()) {
4188            if !crate::obs::log::EVENT_FAMILIES.contains(&f.as_str()) {
4189                err(
4190                    &mut d,
4191                    format!(
4192                        "observability.runtime_events: unknown event family {f:?} (known: {})",
4193                        crate::obs::log::EVENT_FAMILIES.join(", ")
4194                    ),
4195                );
4196            }
4197        }
4198        for f in &re.sampled {
4199            if re.include.contains(f) {
4200                err(
4201                    &mut d,
4202                    format!(
4203                        "observability.runtime_events: family {f:?} is in both `include` and `sampled` — pick one"
4204                    ),
4205                );
4206            }
4207        }
4208    }
4209    if let Some(sinks) = &s.observability.audit.sink
4210        && sinks.iter().any(|x| matches!(x, AuditSink::Stream))
4211    {
4212        match s.observability.audit.stream.as_deref() {
4213            None => err(
4214                &mut d,
4215                "observability.audit: `sink: [stream]` needs `stream: <name>`".into(),
4216            ),
4217            Some(name) if !s.streams.contains_key(name) => err(
4218                &mut d,
4219                format!(
4220                    "observability.audit.stream: {name:?} is not declared (add it under `streams:`)"
4221                ),
4222            ),
4223            Some(_) => {}
4224        }
4225    }
4226
4227    // intelligence.models — a tier catalogue whose names are referenced from
4228    // several places, so a typo has to be a startup error rather than a
4229    // silent fall-through to "a literal model called `smal`".
4230    for (name, t) in &s.intelligence.models {
4231        let at = format!("intelligence.models.{name}");
4232        if t.model.as_deref().unwrap_or("").trim().is_empty() {
4233            err(&mut d, format!("{at}: `model` is required"));
4234        }
4235        if let Some(svc) = &t.service {
4236            match s.services.get(svc) {
4237                None => err(
4238                    &mut d,
4239                    format!("{at}.service: {svc:?} is not declared (add it under `services:`)"),
4240                ),
4241                Some(entry) if entry.kind != ServiceKind::Intelligence => err(
4242                    &mut d,
4243                    format!(
4244                        "{at}.service: {svc:?} is `kind: {}` — a model tier needs `kind: intelligence`",
4245                        entry.kind.as_str()
4246                    ),
4247                ),
4248                Some(_) => {}
4249            }
4250        }
4251        if let Some(f) = &t.fallback {
4252            if !s.intelligence.models.contains_key(f) {
4253                err(&mut d, format!("{at}.fallback: no model tier named {f:?}"));
4254            } else if f == name {
4255                err(
4256                    &mut d,
4257                    format!("{at}.fallback: a tier cannot fall back to itself"),
4258                );
4259            }
4260        }
4261    }
4262    // A degradation ladder that loops is a hang under exactly the conditions
4263    // it exists to survive.
4264    for name in s.intelligence.models.keys() {
4265        let mut seen = vec![name.clone()];
4266        let mut cur = name.clone();
4267        while let Some(next) = s
4268            .intelligence
4269            .models
4270            .get(&cur)
4271            .and_then(|t| t.fallback.clone())
4272        {
4273            if seen.contains(&next) {
4274                err(
4275                    &mut d,
4276                    format!(
4277                        "intelligence.models: fallback cycle {} -> {next}",
4278                        seen.join(" -> ")
4279                    ),
4280                );
4281                break;
4282            }
4283            seen.push(next.clone());
4284            cur = next;
4285        }
4286    }
4287    for (field, reference) in [
4288        ("intelligence.default", s.intelligence.default.as_ref()),
4289        (
4290            "intelligence.preflight_model",
4291            s.intelligence.preflight_model.as_ref(),
4292        ),
4293        (
4294            "context.summarize.model",
4295            s.context.summarize.model.as_ref(),
4296        ),
4297    ] {
4298        // These name a TIER. A literal here would work by accident today and
4299        // break the moment a tier of that name is declared, so require the
4300        // tier when a catalogue exists at all.
4301        if let Some(r) = reference
4302            && !s.intelligence.models.is_empty()
4303            && !s.intelligence.models.contains_key(r)
4304        {
4305            err(
4306                &mut d,
4307                format!(
4308                    "{field}: {r:?} is not a declared model tier (known: {})",
4309                    s.intelligence
4310                        .models
4311                        .keys()
4312                        .cloned()
4313                        .collect::<Vec<_>>()
4314                        .join(", ")
4315                ),
4316            );
4317        }
4318    }
4319
4320    // a2a.principals[].quotas — a limit that parses and is never checked for
4321    // shape is the same failure as one that is never enforced: the operator
4322    // believes they set a ceiling.
4323    for (i, p) in s.a2a.principals.iter().enumerate() {
4324        let Some(q) = &p.quotas else { continue };
4325        if let Some(r) = &q.rate
4326            && let Err(e) = crate::supervisor::tree::parse_rate(r)
4327        {
4328            err(&mut d, format!("a2a.principals[{i}].quotas.rate: {e}"));
4329        }
4330        if let Some(b) = &q.budget {
4331            validate_budget(b, &format!("a2a.principals[{i}].quotas.budget"), &mut d);
4332        }
4333    }
4334
4335    // security.policies — a security control has to fail loudly when it cannot
4336    // do what it says.
4337    for (i, p) in s.security.policies.iter().enumerate() {
4338        let at = format!("security.policies[{i}]");
4339        if let Some(expr) = &p.matcher.args {
4340            if !cfg!(feature = "cel") {
4341                err(
4342                    &mut d,
4343                    format!(
4344                        "{at}: `match.args` needs the `cel` feature; this build cannot evaluate an \
4345                         argument guard, and silently treating it as no-match would turn a deny \
4346                         into an allow"
4347                    ),
4348                );
4349            } else if let Err(e) =
4350                crate::cel::compile_check(expr.trim().trim_start_matches("CEL:").trim())
4351            {
4352                err(&mut d, format!("{at}: match.args: {e}"));
4353            }
4354        }
4355        for t in &p.matcher.tags {
4356            if !["untrusted_input", "sensitive", "egress"].contains(&t.as_str()) {
4357                err(
4358                    &mut d,
4359                    format!("{at}: unknown tag {t:?} (want untrusted_input|sensitive|egress)"),
4360                );
4361            }
4362        }
4363        if p.action != PolicyAction::Ask && (p.question.is_some() || p.on_timeout.is_some()) {
4364            err(
4365                &mut d,
4366                format!("{at}: `question`/`on_timeout` apply to `action: ask`"),
4367            );
4368        }
4369        if p.on_timeout == Some(PolicyAction::Ask) {
4370            err(
4371                &mut d,
4372                format!("{at}: `on_timeout: ask` would ask again forever"),
4373            );
4374        }
4375    }
4376
4377    // intelligence
4378    for e in &s.intelligence.endpoints {
4379        if let Err(e) = super::validate_intelligence_uri(e) {
4380            err(&mut d, e.to_string());
4381        }
4382    }
4383    if let Some(p) = &s.intelligence.swap_policy
4384        && super::SwapPolicy::parse(p).is_none()
4385    {
4386        err(
4387            &mut d,
4388            format!("intelligence.swap_policy: {p:?} (want finish-on-old|restart-turn)"),
4389        );
4390    }
4391    if s.intelligence.token.is_some() && s.intelligence.token_file.is_some() {
4392        d.warnings.push(
4393            "intelligence.token and intelligence.token_file are both set; the inline token wins"
4394                .into(),
4395        );
4396    }
4397    if let Some(auth) = &s.intelligence.auth {
4398        for e in validate_auth_block(auth, "intelligence") {
4399            err(&mut d, e);
4400        }
4401    }
4402    if let Some(dialect) = &s.intelligence.dialect {
4403        if crate::intel::client::Provider::from_dialect(Some(dialect)).is_none() {
4404            err(
4405                &mut d,
4406                format!("intelligence.dialect: {dialect:?} (want openai|anthropic|bedrock)"),
4407            );
4408        }
4409        // Native Bedrock authenticates by SigV4 — an `auth: {kind: aws}` is
4410        // required (creds come from env/imds/irsa/sso at dial time).
4411        if dialect == "bedrock"
4412            && !matches!(
4413                s.intelligence.auth.as_ref().map(|a| a.kind),
4414                Some(AuthKind::Aws)
4415            )
4416        {
4417            err(
4418                &mut d,
4419                "intelligence.dialect: bedrock requires intelligence.auth.kind = aws (SigV4)"
4420                    .into(),
4421            );
4422        }
4423    }
4424    validate_budget(&s.intelligence.budget, "intelligence.budget", &mut d);
4425    if let Some(b) = &s.agent.conversation_budget {
4426        validate_budget(b, "agent.conversation_budget", &mut d);
4427    }
4428    for (name, value) in &s.intelligence.headers {
4429        if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
4430            err(
4431                &mut d,
4432                format!(
4433                    "intelligence.headers['{name}'] looks like a credential but has an inline value; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}}"
4434                ),
4435            );
4436        } else if let Some(e) = unresolved_secret_ref(value) {
4437            err(&mut d, format!("intelligence.headers['{name}']: {e}"));
4438        }
4439    }
4440
4441    // mcp servers
4442    let mut names = std::collections::HashSet::new();
4443    for srv in &s.mcp.servers {
4444        if srv.name.trim().is_empty() {
4445            err(&mut d, "mcp.servers[]: a server has an empty name".into());
4446        }
4447        if !names.insert(srv.name.as_str()) {
4448            err(
4449                &mut d,
4450                format!("mcp.servers[]: duplicate server name '{}'", srv.name),
4451            );
4452        }
4453        if srv.name == "code" {
4454            err(
4455                &mut d,
4456                "mcp.servers[]: the server name 'code' is reserved for code-registered tools"
4457                    .into(),
4458            );
4459        }
4460        if srv.endpoint.is_empty() {
4461            // A `service:` reference is filled by resolution (an unknown name
4462            // already errored there); a server with NEITHER is malformed.
4463            if srv.service.is_none() {
4464                err(
4465                    &mut d,
4466                    format!(
4467                        "mcp server '{}' needs an `endpoint` or a `service:` catalog reference",
4468                        srv.name
4469                    ),
4470                );
4471            }
4472        } else if let Err(e) = super::mcp_endpoint_scheme_ok(&srv.endpoint) {
4473            err(&mut d, format!("mcp server '{}': {e}", srv.name));
4474        }
4475        if let Err(e) = srv.tag_set() {
4476            err(&mut d, e);
4477        }
4478        for (h, v) in &srv.headers {
4479            if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
4480                err(
4481                    &mut d,
4482                    format!(
4483                        "mcp server '{}' header '{h}' looks like a credential but has an inline value; use a {{{{secret:…}}}} reference",
4484                        srv.name
4485                    ),
4486                );
4487            } else if let Some(e) = unresolved_secret_ref(v) {
4488                err(
4489                    &mut d,
4490                    format!("mcp server '{}' header '{h}': {e}", srv.name),
4491                );
4492            }
4493        }
4494        if let Some(auth) = &srv.auth {
4495            for e in validate_auth_block(auth, &format!("mcp server '{}'", srv.name)) {
4496                err(&mut d, e);
4497            }
4498        }
4499    }
4500    // services catalog
4501    for (name, svc) in &s.services {
4502        if name.is_empty()
4503            || !name
4504                .chars()
4505                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
4506        {
4507            err(
4508                &mut d,
4509                format!("services: entry name '{name}' must be [a-zA-Z0-9_-]+"),
4510            );
4511        }
4512        // The endpoint scheme is judged by the entry's KIND: a peer speaks
4513        // A2A (https / loopback http / unix), everything else HTTPS-or-loopback.
4514        match svc.kind {
4515            ServiceKind::Peer => {
4516                if let Err(e) = crate::config::A2aEndpoint::parse(&svc.endpoint) {
4517                    err(&mut d, format!("services.{name}: {e}"));
4518                }
4519            }
4520            _ => {
4521                if let Err(e) = super::mcp_endpoint_scheme_ok(&svc.endpoint) {
4522                    err(&mut d, format!("services.{name}: {e}"));
4523                }
4524            }
4525        }
4526        // Kind-specific fields: the tool surface (allow/exclude/tags/breaker)
4527        // is `kind: mcp` vocabulary; `methods` is `kind: http` vocabulary.
4528        if svc.kind != ServiceKind::Mcp {
4529            for (set, what) in [
4530                (svc.allow.is_some(), "allow"),
4531                (!svc.exclude.is_empty(), "exclude"),
4532                (!svc.tags.is_empty(), "tags"),
4533                (svc.breaker.is_some(), "breaker"),
4534            ] {
4535                if set {
4536                    err(
4537                        &mut d,
4538                        format!(
4539                            "services.{name}: `{what}` applies to `kind: mcp` entries only (this entry is `kind: {}`)",
4540                            svc.kind.as_str()
4541                        ),
4542                    );
4543                }
4544            }
4545        }
4546        if svc.methods.is_some() && svc.kind != ServiceKind::Http {
4547            err(
4548                &mut d,
4549                format!(
4550                    "services.{name}: `methods` applies to `kind: http` entries only (this entry is `kind: {}`)",
4551                    svc.kind.as_str()
4552                ),
4553            );
4554        }
4555        if let Some(ms) = &svc.methods {
4556            for m in ms {
4557                if !matches!(
4558                    m.as_str(),
4559                    "GET" | "PUT" | "POST" | "DELETE" | "PATCH" | "HEAD"
4560                ) {
4561                    err(
4562                        &mut d,
4563                        format!(
4564                            "services.{name}.methods: unknown method '{m}' (want GET|PUT|POST|DELETE|PATCH|HEAD, uppercase)"
4565                        ),
4566                    );
4567                }
4568            }
4569        }
4570        if let Some(b) = &svc.breaker
4571            && crate::runtime::breaker::Config::of(Some(b)).is_none()
4572        {
4573            err(
4574                &mut d,
4575                format!(
4576                    "services.{name}.breaker: want {{failures: N>=1, cooldown: \"60s\"}} — both fields required"
4577                ),
4578            );
4579        }
4580        for list in svc.tags.values() {
4581            for t in list {
4582                if crate::sec::scope::TrifectaTag::parse(t).is_none() {
4583                    err(
4584                        &mut d,
4585                        format!("services.{name} has unknown trifecta tag '{t}'"),
4586                    );
4587                }
4588            }
4589        }
4590        for (h, v) in &svc.headers {
4591            if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
4592                err(
4593                    &mut d,
4594                    format!(
4595                        "services.{name} header '{h}' looks like a credential but has an inline value; use a {{{{secret:…}}}} reference"
4596                    ),
4597                );
4598            } else if let Some(e) = unresolved_secret_ref(v) {
4599                err(&mut d, format!("services.{name} header '{h}': {e}"));
4600            }
4601        }
4602        if let Some(auth) = &svc.auth {
4603            for e in validate_auth_block(auth, &format!("services.{name}")) {
4604                err(&mut d, e);
4605            }
4606        }
4607        if let Some(r) = &svc.rate
4608            && let Err(e) = crate::supervisor::tree::parse_rate(r)
4609        {
4610            err(&mut d, format!("services.{name}.rate: {e}"));
4611        }
4612    }
4613    // Matching must be unambiguous PER KIND: no entry's endpoint may itself
4614    // match another entry of the same kind (identical or prefix-comparable).
4615    // Different kinds on one host are legal — matching is kind-filtered.
4616    {
4617        let entries: Vec<(&String, &Service)> = s.services.iter().collect();
4618        for i in 0..entries.len() {
4619            for j in (i + 1)..entries.len() {
4620                if entries[i].1.kind != entries[j].1.kind {
4621                    continue;
4622                }
4623                let kind = entries[i].1.kind;
4624                let one = BTreeMap::from([(entries[i].0.clone(), entries[i].1.clone())]);
4625                let other = BTreeMap::from([(entries[j].0.clone(), entries[j].1.clone())]);
4626                if service_match(&one, kind, &entries[j].1.endpoint).is_some()
4627                    || service_match(&other, kind, &entries[i].1.endpoint).is_some()
4628                {
4629                    err(
4630                        &mut d,
4631                        format!(
4632                            "services.{} and services.{} have prefix-comparable endpoints of the same kind — URL matching must be unambiguous",
4633                            entries[i].0, entries[j].0
4634                        ),
4635                    );
4636                }
4637            }
4638        }
4639    }
4640    // Egress policy, over all four outbound surfaces: closed ⇒ every
4641    // configured dial must match a catalog entry of its own kind.
4642    if s.security.egress == Egress::Closed {
4643        let closed = |d: &mut Diagnostics, kind: ServiceKind, what: &str, url: &str| {
4644            if service_match(&s.services, kind, url).is_none() {
4645                d.errors.push(format!(
4646                    "security.egress is closed and {what} ({url}) matches no `kind: {}` services: catalog entry — catalog the endpoint to allow it",
4647                    kind.as_str()
4648                ));
4649            }
4650        };
4651        for srv in &s.mcp.servers {
4652            if !srv.endpoint.is_empty() {
4653                closed(
4654                    &mut d,
4655                    ServiceKind::Mcp,
4656                    &format!("mcp server '{}'", srv.name),
4657                    &srv.endpoint,
4658                );
4659            }
4660        }
4661        for e in &s.intelligence.endpoints {
4662            // `mock:` is the in-process test endpoint — no socket, no egress.
4663            if !e.starts_with("mock:") {
4664                closed(
4665                    &mut d,
4666                    ServiceKind::Intelligence,
4667                    "intelligence endpoint",
4668                    e,
4669                );
4670            }
4671        }
4672        for p in &s.a2a.peers {
4673            if !p.endpoint.is_empty() {
4674                closed(
4675                    &mut d,
4676                    ServiceKind::Peer,
4677                    &format!("a2a peer '{}'", p.name),
4678                    &p.endpoint,
4679                );
4680            }
4681        }
4682        if s.store.kind == StoreKind::Http
4683            && let Some(h) = &s.store.http
4684        {
4685            closed(
4686                &mut d,
4687                ServiceKind::Http,
4688                "store.http.base_url",
4689                &h.base_url,
4690            );
4691            // Ops that don't build on {base_url} dial their own literal hosts.
4692            for (opname, op) in [
4693                ("get", &h.get),
4694                ("put", &h.put),
4695                ("list", &h.list),
4696                ("delete", &h.delete),
4697            ] {
4698                if let Some(op) = op
4699                    && !op.url.starts_with("{base_url}")
4700                    && !op.url.contains("{{")
4701                {
4702                    closed(
4703                        &mut d,
4704                        ServiceKind::Http,
4705                        &format!("store.http.{opname}.url"),
4706                        &op.url,
4707                    );
4708                }
4709            }
4710        }
4711        for w in &s.workflows {
4712            if let Some(u) = w.get("url").and_then(Value::as_str) {
4713                closed(&mut d, ServiceKind::Http, "workflow reference url", u);
4714            }
4715            // Literal `http` step URLs are judged here; templated ones are
4716            // checked at execution (`step_http`'s runtime gate).
4717            if let Some(steps) = w.get("steps").and_then(Value::as_object) {
4718                for (sid, st) in steps {
4719                    if st.get("kind").and_then(Value::as_str) == Some("http")
4720                        && let Some(u) = st.get("url").and_then(Value::as_str)
4721                        && !u.contains("{{")
4722                    {
4723                        closed(&mut d, ServiceKind::Http, &format!("http step '{sid}'"), u);
4724                    }
4725                }
4726            }
4727        }
4728        // The one surface deliberately outside the policy: telemetry export.
4729        if s.observability.otel.endpoint.is_some() {
4730            d.warnings.push(
4731                "security.egress: closed does not cover observability.otel.endpoint (telemetry export is operator plumbing, not agent egress)".into(),
4732            );
4733        }
4734    }
4735    // Context templates are parsed at LOAD, so a malformed block or a typo'd
4736    // reference is a startup error rather than a prompt that renders wrong
4737    // once the agent is already running unattended.
4738    {
4739        let known: &[&str] = &[
4740            "instance",
4741            "instruction",
4742            "extra",
4743            "tools",
4744            "workflows",
4745            "services",
4746            "egress_closed",
4747            "streams",
4748            "templates",
4749            "skills",
4750            "peers",
4751            "signals",
4752            "memory",
4753        ];
4754        let mut check = |what: String, src: &str, is_default_slot: bool| {
4755            match crate::context::prompt::Template::parse(src) {
4756                Err(e) => err(&mut d, format!("{what}: {e}")),
4757                Ok(t) => {
4758                    for r in &t.roots {
4759                        if !known.contains(&r.as_str()) {
4760                            err(
4761                                &mut d,
4762                                format!(
4763                                    "{what}: unknown reference {{{{{r}}}}} (available: {})",
4764                                    known.join(", ")
4765                                ),
4766                            );
4767                        }
4768                    }
4769                    if t.needs_cel && !cfg!(feature = "cel") {
4770                        err(
4771                            &mut d,
4772                            format!(
4773                                "{what}: uses an expression, which needs the 'cel' build feature (bare paths work without it)"
4774                            ),
4775                        );
4776                    }
4777                    // Losing the standing policy is the failure that still
4778                    // looks like a working agent — say so, loudly.
4779                    if is_default_slot && !t.reads("instruction") {
4780                        d.warnings.push(format!(
4781                            "{what} never references {{{{instruction}}}} — this agent's standing policy will not reach the model"
4782                        ));
4783                    }
4784                }
4785            }
4786        };
4787        if let Some(src) = &s.context.template {
4788            check("context.template".into(), src, true);
4789        }
4790        for (name, src) in &s.context.templates {
4791            check(format!("context.templates.{name}"), src, false);
4792        }
4793    }
4794    // Subagent templates: extraction, tier resolution and the
4795    // instance-machinery checks all run here, so a bad template refuses the
4796    // PARENT's startup — naming the template — instead of failing at the first
4797    // spawn, long after the deploy.
4798    if let Err(errs) = crate::config::templates::compile_templates(s) {
4799        for e in errs {
4800            err(&mut d, e);
4801        }
4802    }
4803    if !s.subagents.templates.is_empty() && s.a2a.listen.is_none() {
4804        d.warnings.push(
4805            "subagents.templates are declared but a2a.listen is unset — instance-tier children get no `parent` peer (they cannot call home)".into(),
4806        );
4807    }
4808    let server_known = |n: &str| s.mcp.servers.iter().any(|x| x.name == n);
4809
4810    // tools
4811    for (name, ov) in &s.tools.overrides {
4812        if !server_known(&ov.server) {
4813            err(
4814                &mut d,
4815                format!(
4816                    "tools.overrides['{name}'] references undeclared MCP server '{}'",
4817                    ov.server
4818                ),
4819            );
4820        }
4821        if s.tools.disabled.iter().any(|x| x == name) {
4822            err(
4823                &mut d,
4824                format!("tool '{name}' is both disabled and overridden"),
4825            );
4826        }
4827        for (label, tpl) in [("args", &ov.args), ("result", &ov.result)] {
4828            if let Some(t) = tpl
4829                && let Some(expr) = t.strip_prefix("CEL:")
4830                && let Err(e) = crate::cel::compile_check(expr.trim())
4831            {
4832                err(&mut d, format!("tools.overrides['{name}'].{label}: {e}"));
4833            }
4834        }
4835    }
4836
4837    // store
4838    match s.store.kind {
4839        StoreKind::Mcp => match &s.store.mcp {
4840            None => err(&mut d, "store.kind is mcp but store.mcp is not set".into()),
4841            Some(m) => {
4842                if !server_known(&m.server) {
4843                    err(
4844                        &mut d,
4845                        format!(
4846                            "store.mcp.server '{}' is not a declared MCP server",
4847                            m.server
4848                        ),
4849                    );
4850                }
4851                for (label, op) in [
4852                    ("put", &m.put),
4853                    ("get", &m.get),
4854                    ("list", &m.list),
4855                    ("delete", &m.delete),
4856                ] {
4857                    if let Some(op) = op {
4858                        for (f, t) in [
4859                            ("args", &op.args),
4860                            ("ok", &op.ok),
4861                            ("conflict", &op.conflict),
4862                            ("value", &op.value),
4863                            ("keys", &op.keys),
4864                        ] {
4865                            if let Some(t) = t
4866                                && let Some(expr) = t.strip_prefix("CEL:")
4867                                && let Err(e) = crate::cel::compile_check(expr.trim())
4868                            {
4869                                err(&mut d, format!("store.mcp.{label}.{f}: {e}"));
4870                            }
4871                        }
4872                    }
4873                }
4874            }
4875        },
4876        StoreKind::Http => match &s.store.http {
4877            None => err(
4878                &mut d,
4879                "store.kind is http but store.http is not set".into(),
4880            ),
4881            Some(h) => {
4882                if !(h.base_url.starts_with("https://") || h.base_url.starts_with("http://")) {
4883                    err(
4884                        &mut d,
4885                        format!(
4886                            "store.http.base_url must be an http(s) URL (got {})",
4887                            h.base_url
4888                        ),
4889                    );
4890                }
4891                if h.get.is_none() || h.put.is_none() {
4892                    err(
4893                        &mut d,
4894                        "store.http needs at least `get` and `put` operations".into(),
4895                    );
4896                }
4897                for (name, v) in &h.headers {
4898                    if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(v) {
4899                        err(
4900                            &mut d,
4901                            format!(
4902                                "store.http.headers['{name}'] looks like a credential but has an inline value"
4903                            ),
4904                        );
4905                    } else if let Some(e) = unresolved_secret_ref(v) {
4906                        err(&mut d, format!("store.http.headers['{name}']: {e}"));
4907                    }
4908                }
4909            }
4910        },
4911        StoreKind::File => {
4912            // No block is required: `kind: file` alone resolves a root from
4913            // the environment. The one thing that cannot work is an
4914            // explicit empty path — it would resolve to the process's working
4915            // directory, so it is refused here rather than discovered as a
4916            // state directory nobody meant to create.
4917            if let Some(f) = &s.store.file
4918                && f.path.as_deref().is_some_and(|p| p.trim().is_empty())
4919            {
4920                err(
4921                    &mut d,
4922                    "store.file.path is empty — set a directory, or omit the field to use $AGENTD_STATE_DIR / $XDG_STATE_HOME/agentd/state".into(),
4923                );
4924            }
4925        }
4926        StoreKind::Memory => {
4927            d.warnings.push(
4928                "store.kind is memory: state does not survive the process (dev/test only)".into(),
4929            );
4930        }
4931        StoreKind::None => {
4932            // A job-shaped instance (one-shot workflows, no listener) may run
4933            // without a store — a crash simply re-runs it. Anything long-lived
4934            // MUST be durable: an A2A listener or a long-lived start node.
4935            //
4936            // Reaching here with a long-lived instance means the operator
4937            // WROTE `none`, because `load` defaults the absent case to the file
4938            // store. So the message says how to take that default back, not
4939            // just which backends exist.
4940            if s.is_long_lived() {
4941                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());
4942            } else if !s.workflows.is_empty() {
4943                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());
4944            }
4945        }
4946    }
4947    // The mirror of the checks above: each adapter validates the block it needs,
4948    // so a block that belongs to an adapter that is not selected is dead config.
4949    // Silence would be the wrong answer — `store.file.path` set beside
4950    // `kind: mcp` reads like state on disk and is not — but so would refusing to
4951    // start, since the block does no harm; the operator is told it is ignored.
4952    if s.store.file.is_some() && s.store.kind != StoreKind::File {
4953        d.warnings.push(format!(
4954            "store.file is set but store.kind is {} — the file adapter is not in use and the block is ignored",
4955            // The Debug name lowercased is exactly the YAML spelling of the
4956            // variant (`serde(rename_all = "lowercase")`), so the warning
4957            // quotes back what the operator wrote.
4958            format!("{:?}", s.store.kind).to_lowercase()
4959        ));
4960    }
4961    if let Some(ms) = s.store.checkpoint.debounce_ms
4962        && ms > 60_000
4963    {
4964        d.warnings.push(format!(
4965            "store.checkpoint.debounce_ms is {ms} (> 60s): progress may lag far behind reality"
4966        ));
4967    }
4968
4969    // knowledge / search / skills servers
4970    if let Some(k) = &s.knowledge.server
4971        && !server_known(k)
4972    {
4973        err(
4974            &mut d,
4975            format!("knowledge.server '{k}' is not a declared MCP server"),
4976        );
4977    }
4978    if let Some(k) = &s.search.server
4979        && !server_known(k)
4980    {
4981        err(
4982            &mut d,
4983            format!("search.server '{k}' is not a declared MCP server"),
4984        );
4985    }
4986    for src in &s.skills.sources {
4987        if !server_known(&src.server) {
4988            err(
4989                &mut d,
4990                format!(
4991                    "skills.sources[] references undeclared MCP server '{}'",
4992                    src.server
4993                ),
4994            );
4995        }
4996    }
4997    if let Some(c) = s.context.compact_at
4998        && !(c > 0.0 && c <= 1.0)
4999    {
5000        err(
5001            &mut d,
5002            format!("context.compact_at must be in (0, 1] (got {c})"),
5003        );
5004    }
5005
5006    // workflows (the structural minimum only; full validation is the engine's)
5007    let mut wf_names = std::collections::HashSet::new();
5008    for (i, w) in s.workflows.iter().enumerate() {
5009        let Some(obj) = w.as_object() else {
5010            err(&mut d, format!("workflows[{i}] must be an object"));
5011            continue;
5012        };
5013        // A `{dir}` entry names no workflow: it expands into one per matching
5014        // file, and each file carries its own name. Requiring one here would
5015        // mean inventing a name for a set.
5016        if obj.contains_key("dir") {
5017            continue;
5018        }
5019        let name = obj.get("name").and_then(Value::as_str).unwrap_or("");
5020        if name.trim().is_empty() {
5021            err(&mut d, format!("workflows[{i}] has no name"));
5022        } else if !wf_names.insert(name.to_string()) {
5023            err(
5024                &mut d,
5025                format!("workflows[]: duplicate workflow name '{name}'"),
5026            );
5027        }
5028        // A `model:` on a node names a TIER once a catalogue exists. Catching
5029        // the typo here keeps it a startup error instead of a run that
5030        // silently asks the provider for a model called `smal`.
5031        if !s.intelligence.models.is_empty()
5032            && let Some(steps) = obj.get("steps").and_then(Value::as_object)
5033        {
5034            for (sid, st) in steps {
5035                let Some(m) = st.get("model").and_then(Value::as_str) else {
5036                    continue;
5037                };
5038                if !s.intelligence.models.contains_key(m) {
5039                    err(
5040                        &mut d,
5041                        format!(
5042                            "workflow '{name}' step '{sid}': model {m:?} is not a declared tier (known: {})",
5043                            s.intelligence
5044                                .models
5045                                .keys()
5046                                .cloned()
5047                                .collect::<Vec<_>>()
5048                                .join(", ")
5049                        ),
5050                    );
5051                }
5052            }
5053        }
5054        // One entry, one source. `dir` is not in this list because a dir entry
5055        // returned above — it names a SET, and each file it expands to gets
5056        // checked as its own entry.
5057        let sources = ["file", "uri", "url", "steps"]
5058            .iter()
5059            .filter(|k| obj.contains_key(**k))
5060            .count();
5061        if sources != 1 {
5062            err(
5063                &mut d,
5064                format!(
5065                    "workflows['{name}'] must have exactly one of file | uri | url | steps (dir is a separate entry shape)"
5066                ),
5067            );
5068        }
5069        // The reference is folded against `vars` before the check, because a
5070        // workflow entry's `{{config.*}}` is resolved at LOAD time (workflow
5071        // documents are deliberately excluded from the settings-wide
5072        // substitution so that inline, file, url and dir entries are all
5073        // treated alike). Checking the raw token here rejected a perfectly
5074        // good `file: "{{config.wf_file}}"` as "does not exist". An
5075        // UNRESOLVABLE var is not reported here — the load-time fold names it
5076        // once, and two messages for one typo is worse than one.
5077        if let Some(f) = obj.get("file").and_then(Value::as_str) {
5078            let mut folded = Value::String(f.to_string());
5079            let mut ignored = Vec::new();
5080            substitute_config_vars(&mut folded, &s.vars, "workflow entry", &mut ignored);
5081            if ignored.is_empty()
5082                && let Some(path) = folded.as_str()
5083                && !std::path::Path::new(path).exists()
5084            {
5085                err(
5086                    &mut d,
5087                    format!("workflows['{name}'].file {path:?} does not exist"),
5088                );
5089            }
5090        }
5091    }
5092
5093    // lifecycle
5094    for (k, v) in &s.lifecycle.exit_code_map {
5095        if k != "3" && k != "7" {
5096            err(
5097                &mut d,
5098                format!(
5099                    "lifecycle.exit_code_map: only the policy codes 3 and 7 are remappable (got key {k:?})"
5100                ),
5101            );
5102        }
5103        if !(0..=255).contains(v) {
5104            err(
5105                &mut d,
5106                format!("lifecycle.exit_code_map[{k}] must be 0..=255 (got {v})"),
5107            );
5108        }
5109    }
5110    if s.lifecycle.watch_config && loaded.files.is_empty() {
5111        err(
5112            &mut d,
5113            "lifecycle.watch_config requires a config file (--config / AGENTD_CONFIG)".into(),
5114        );
5115    }
5116
5117    // a2a
5118    if let Some(l) = &s.a2a.listen {
5119        match super::ServeTarget::parse(l) {
5120            Ok(super::ServeTarget::Http { bind, tls }) => {
5121                let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
5122                if tls && (s.a2a.tls.cert.is_none() || s.a2a.tls.key.is_none()) {
5123                    err(
5124                        &mut d,
5125                        "a2a.listen is https:// but a2a.tls.cert / a2a.tls.key are not set".into(),
5126                    );
5127                }
5128                if !loopback
5129                    && s.a2a.tls.client_ca.is_none()
5130                    && s.a2a.bearer.is_none()
5131                    && !s.interface.pairing.enabled
5132                {
5133                    err(&mut d, "a2a.listen on a non-loopback address needs client auth: a2a.bearer, interface.pairing, or a2a.tls.client_ca (mTLS — then EVERY caller needs a client certificate, bearer-only and paired included)".into());
5134                }
5135                if !tls && !loopback {
5136                    err(
5137                        &mut d,
5138                        "a2a.listen plaintext http:// is allowed for loopback only; use https://"
5139                            .into(),
5140                    );
5141                }
5142            }
5143            Ok(super::ServeTarget::Unix { .. }) => {
5144                // The kernel is the authenticator: only same-uid (or root)
5145                // peers may connect, so TLS material is meaningless here and
5146                // configuring it is a sign of a misunderstood posture.
5147                if s.a2a.tls.cert.is_some()
5148                    || s.a2a.tls.key.is_some()
5149                    || s.a2a.tls.client_ca.is_some()
5150                {
5151                    err(
5152                        &mut d,
5153                        "a2a.listen is unix:// — the kernel authenticates peers (same-uid); a2a.tls does not apply and must be unset".into(),
5154                    );
5155                }
5156            }
5157            Err(e) => err(&mut d, format!("a2a.listen: {e}")),
5158        }
5159    }
5160
5161    // interface (the display-client surface — it rides the A2A listener)
5162    if s.interface.enabled && s.a2a.listen.is_none() {
5163        err(
5164            &mut d,
5165            "interface.enabled requires a2a.listen (the interface is served on the A2A listener)"
5166                .into(),
5167        );
5168    }
5169    if s.interface.debug && !s.interface.enabled {
5170        d.warnings
5171            .push("interface.debug has no effect while interface.enabled is false".into());
5172    }
5173    for o in &s.interface.origins {
5174        // An origin is `scheme://host[:port]` — no path, no trailing slash.
5175        let ok = o
5176            .split_once("://")
5177            .map(|(scheme, rest)| {
5178                matches!(scheme, "http" | "https") && !rest.is_empty() && !rest.contains('/')
5179            })
5180            .unwrap_or(false);
5181        if !ok {
5182            err(
5183                &mut d,
5184                format!(
5185                    "interface.origins: {o:?} is not an origin (want scheme://host[:port], no path)"
5186                ),
5187            );
5188        }
5189    }
5190    // Display items: unknown names are skipped by clients — warn, don't refuse
5191    // (forward compatibility across client versions).
5192    for (edge, items) in [
5193        ("top", &s.interface.display.top),
5194        ("bottom", &s.interface.display.bottom),
5195    ] {
5196        for item in items.iter().flatten() {
5197            // `memory:<key>` renders whatever a WORKFLOW wrote to that key —
5198            // the extension point that lets the status line show a branch, a PR
5199            // number or a deploy state without the daemon learning to compute
5200            // any of them. The key still has to be a legal memory key, so a
5201            // typo is caught here rather than silently never rendering.
5202            if let Some(key) = item.strip_prefix("memory:") {
5203                if key.is_empty() {
5204                    d.errors.push(format!(
5205                        "interface.display.{edge}: {item:?} names no memory key"
5206                    ));
5207                } else if let Err(e) = crate::context::memory::Memory::check_key(key) {
5208                    d.errors
5209                        .push(format!("interface.display.{edge}: {item:?}: {e}"));
5210                }
5211                continue;
5212            }
5213            if !DISPLAY_ITEMS.contains(&item.as_str()) {
5214                d.warnings.push(format!(
5215                    "interface.display.{edge}: unknown item {item:?} (clients skip it); known: {}, \
5216                     or memory:<key> for a value a workflow maintains",
5217                    DISPLAY_ITEMS.join(", ")
5218                ));
5219            }
5220        }
5221    }
5222    // Pairing-code login.
5223    if s.interface.pairing.enabled {
5224        if !s.interface.enabled {
5225            err(
5226                &mut d,
5227                "interface.pairing.enabled requires interface.enabled (pairing rides the interface surface)".into(),
5228            );
5229        }
5230        if let Some(role) = s.interface.pairing.role
5231            && !matches!(role, Role::Operator | Role::User)
5232        {
5233            err(
5234                &mut d,
5235                "interface.pairing.role must be operator or user".into(),
5236            );
5237        }
5238    }
5239
5240    // webhooks (the inbound HTTP surface)
5241    let uses_webhook = s.workflows.iter().any(workflow_uses_webhook);
5242    if uses_webhook && s.webhooks.listen.is_none() {
5243        err(&mut d, "a `webhook` node (start or wait) is used but webhooks.listen is not set — configure webhooks.listen (https://host:port)".into());
5244    }
5245    if let Some(l) = &s.webhooks.listen {
5246        match super::ServeTarget::parse(l) {
5247            Ok(super::ServeTarget::Unix { .. }) => {
5248                err(
5249                    &mut d,
5250                    "webhooks.listen does not support unix:// (webhooks are an external surface); use https://".into(),
5251                );
5252            }
5253            Ok(super::ServeTarget::Http { bind, tls }) => {
5254                let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
5255                if tls && (s.webhooks.tls.cert.is_none() || s.webhooks.tls.key.is_none()) {
5256                    err(
5257                        &mut d,
5258                        "webhooks.listen is https:// but webhooks.tls.cert / webhooks.tls.key are not set"
5259                            .into(),
5260                    );
5261                }
5262                if !tls && !loopback {
5263                    err(
5264                        &mut d,
5265                        "webhooks.listen plaintext http:// is allowed for loopback only; use https://"
5266                            .into(),
5267                    );
5268                }
5269                // Symmetric with the `a2a.listen` refusal above: both are inbound
5270                // listeners that TRIGGER work, so a reachable one must authenticate
5271                // its callers — an open webhook route hands the agent's workflows to
5272                // anyone who can reach the port. Auth is resolved per route
5273                // (`runtime::webhooks::build_verify`: the node's own `auth`, else the
5274                // listener `default_auth`), so refuse only when a route would really
5275                // end up unverified — a listener whose every node signs is fine.
5276                // `none: true` is the documented loopback-only dev opt-out, not
5277                // authentication, so it does not buy an open public bind; the schema
5278                // offers no other way to ask for one, and this deliberately does not
5279                // invent one.
5280                if !loopback && !webhook_default_verifies(s.webhooks.default_auth.as_ref()) {
5281                    let mut open: Vec<String> = Vec::new();
5282                    let mut nodes = 0usize;
5283                    for w in &s.workflows {
5284                        let wf = w.get("name").and_then(Value::as_str).unwrap_or("?");
5285                        for (node, auth) in webhook_nodes(w) {
5286                            nodes += 1;
5287                            if !webhook_auth_verifies(auth) {
5288                                open.push(format!("{wf}/{node}"));
5289                            }
5290                        }
5291                    }
5292                    if !open.is_empty() {
5293                        err(
5294                            &mut d,
5295                            format!(
5296                                "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: {}",
5297                                open.join(", ")
5298                            ),
5299                        );
5300                    } else if nodes == 0 {
5301                        // Nothing is reachable yet (every path answers 404), so this
5302                        // is not a live hole — but the next node added would be one.
5303                        d.warnings.push("webhooks.listen is non-loopback with no webhooks.default_auth — every webhook node must declare its own `auth` (HMAC recommended)".into());
5304                    }
5305                }
5306            }
5307            Err(e) => err(&mut d, format!("webhooks.listen: {e}")),
5308        }
5309    }
5310
5311    // goal watchdog
5312    if let Some(g) = &s.goal {
5313        let via = g.check.via.as_deref().unwrap_or("both");
5314        if via == "condition" && g.check.condition.is_none() {
5315            err(
5316                &mut d,
5317                "goal.check.via is 'condition' but goal.check.condition is not set".into(),
5318            );
5319        }
5320        for (label, act) in [("on_achieved", &g.on_achieved), ("on_stuck", &g.on_stuck)] {
5321            if let Some(GoalAction::Workflow(name)) = act
5322                && !s
5323                    .workflows
5324                    .iter()
5325                    .any(|w| w.get("name").and_then(Value::as_str) == Some(name.as_str()))
5326            {
5327                err(
5328                    &mut d,
5329                    format!(
5330                        "goal.{label} references workflow '{name}', which is not defined in workflows"
5331                    ),
5332                );
5333            }
5334        }
5335    }
5336
5337    let mut peer_names = std::collections::HashSet::new();
5338    for p in &s.a2a.peers {
5339        if !peer_names.insert(p.name.as_str()) {
5340            err(
5341                &mut d,
5342                format!("a2a.peers[]: duplicate peer name '{}'", p.name),
5343            );
5344        }
5345        let unix_peer = p.endpoint.starts_with("unix://") || p.endpoint.starts_with("unix:");
5346        if unix_peer && !cfg!(unix) {
5347            err(
5348                &mut d,
5349                format!("a2a peer '{}': unix:// endpoints are unix-only", p.name),
5350            );
5351        }
5352        if !unix_peer && !p.endpoint.starts_with("https://") && !p.endpoint.starts_with("http://") {
5353            err(
5354                &mut d,
5355                format!(
5356                    "a2a peer '{}': endpoint must be http(s):// (or unix:///path for a co-located peer)",
5357                    p.name
5358                ),
5359            );
5360        }
5361        if p.client_cert.is_some() != p.client_key.is_some() {
5362            err(
5363                &mut d,
5364                format!(
5365                    "a2a peer '{}': client_cert and client_key must be set together",
5366                    p.name
5367                ),
5368            );
5369        }
5370        if let Some(auth) = &p.auth {
5371            for e in validate_auth_block(auth, &format!("a2a peer '{}'", p.name)) {
5372                err(&mut d, e);
5373            }
5374            if auth.kind == AuthKind::Aws {
5375                err(
5376                    &mut d,
5377                    format!(
5378                        "a2a peer '{}': auth kind `aws` is not accepted for peers — use static, oauth2 or spiffe",
5379                        p.name
5380                    ),
5381                );
5382            }
5383        }
5384        for (h, v) in &p.headers {
5385            if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
5386                err(
5387                    &mut d,
5388                    format!(
5389                        "a2a peer '{}' header '{h}' looks like a credential but has an inline value",
5390                        p.name
5391                    ),
5392                );
5393            } else if let Some(e) = unresolved_secret_ref(v) {
5394                err(&mut d, format!("a2a peer '{}' header '{h}': {e}", p.name));
5395            }
5396        }
5397    }
5398    for (i, pr) in s.a2a.principals.iter().enumerate() {
5399        let m = &pr.matcher;
5400        if m.san.is_none()
5401            && m.sub.is_none()
5402            && m.bearer_ref.is_none()
5403            && m.aauth_agent.is_none()
5404            && !m.any
5405        {
5406            err(
5407                &mut d,
5408                format!(
5409                    "a2a.principals[{i}]: match needs one of san | sub | bearer_ref | aauth_agent | any"
5410                ),
5411            );
5412        }
5413        if m.any && pr.role == Role::Operator {
5414            err(
5415                &mut d,
5416                format!("a2a.principals[{i}]: `any` cannot grant the operator role"),
5417            );
5418        }
5419    }
5420
5421    // observability
5422    if let Some(l) = &s.observability.log_level
5423        && crate::obs::log::Level::parse(l).is_none()
5424    {
5425        err(
5426            &mut d,
5427            format!("observability.log_level: {l:?} (want trace|debug|info|warn|error)"),
5428        );
5429    }
5430
5431    // secrets provenance: the FILE layer must not carry inline secrets
5432    for m in secret_violations(&loaded.file_doc) {
5433        err(&mut d, m);
5434    }
5435    for f in &s.observability.audit.sink.clone().unwrap_or_default() {
5436        if *f == AuditSink::Store && s.store.kind == StoreKind::None {
5437            err(
5438                &mut d,
5439                "observability.audit.sink includes `store` but store.kind is none".into(),
5440            );
5441        }
5442    }
5443
5444    // trifecta over the root grant
5445    let mut tags = Vec::new();
5446    for srv in &s.mcp.servers {
5447        match srv.tag_set() {
5448            Ok(t) if t.is_empty() => tags.push(crate::sec::scope::TrifectaTag::UntrustedInput),
5449            Ok(t) => tags.extend(t),
5450            Err(_) => {}
5451        }
5452    }
5453    // The local command runner is a capability like any other, and it carries
5454    // the two heaviest legs: it can touch anything inside `workdir`
5455    // (`sensitive`) and it can talk to the network if the allow-list lets it
5456    // (`egress`). The registry tags it that way, but the registry is built
5457    // after validation — so the tags have to be contributed here, or enabling
5458    // `exec` next to an untrusted-input server assembles the whole trifecta
5459    // and starts anyway. Gated on the feature: without it `exec` is
5460    // mapping-only, and whichever MCP server provides it carries its own tags.
5461    #[cfg(feature = "exec")]
5462    if s.security.exec.enabled {
5463        tags.push(crate::sec::scope::TrifectaTag::Sensitive);
5464        tags.push(crate::sec::scope::TrifectaTag::Egress);
5465    }
5466    use crate::sec::scope::{TrifectaVerdict, check_trifecta};
5467    if check_trifecta(tags, s.security.allow_trifecta) == TrifectaVerdict::RefusedTrifecta {
5468        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());
5469    }
5470
5471    // Workflow definitions — the SAME strict parse the runtime runs at startup
5472    // (`load_workflows`). Without it, `--validate-config` passes a config that
5473    // then exits 2 on the first real start: a typo'd step field (`prompt:` on
5474    // an `agent` node) validated clean and failed in production, which is what
5475    // the pre-flight check exists to prevent. Reported after the structural
5476    // checks above so the more basic error still leads. `file:`/`uri:` refs
5477    // resolve at startup, so only inline definitions are checkable here.
5478    // `store.durability.{a2a,steps}` is parsed, published in the schema, and
5479    // surfaced in the manifest — and read by no writer. `eventual` therefore
5480    // promised a weaker-but-faster durability that was never implemented, on the
5481    // one guarantee agentd exists to make. Refusing the value is better than
5482    // honouring it: a durability dial nobody wired is a lie, and implementing it
5483    // would trade away the property the product is for. `strict` (the default)
5484    // is what the engine already does, so only a config that asked for the
5485    // unimplemented setting fails, and it fails saying so.
5486    for (path, level) in [
5487        ("store.durability.a2a", s.store.durability.a2a),
5488        ("store.durability.steps", s.store.durability.steps),
5489    ] {
5490        if level == Some(DurabilityLevel::Eventual) {
5491            d.errors.push(format!(
5492                "{path}: `eventual` is not implemented — every durable write is strict \
5493                 (checkpoint-before-effect). Remove the key; `strict` is the default and \
5494                 the only behaviour."
5495            ));
5496        }
5497    }
5498    // The reference preflight, aggregated: every secret, secret-file and config
5499    // var an INLINE workflow mentions, checked now and reported TOGETHER.
5500    // (Definitions arriving from files, URLs and directories get the same check
5501    // at startup, after they are fetched.) Failing on whichever reference
5502    // happens to be evaluated first turns configuring a deployment into a
5503    // guessing game played one restart at a time.
5504    for (i, w) in s.workflows.iter().enumerate() {
5505        if w.get("steps").is_some() {
5506            for msg in missing_references(w, &format!("workflows[{i}]"), &s.vars) {
5507                err(&mut d, msg);
5508            }
5509        }
5510    }
5511    for w in &s.workflows {
5512        // A reference — file, uri, url or dir — resolves at startup, so there
5513        // is nothing to parse here. Only inline definitions are checkable.
5514        if w.get("steps").is_none() {
5515            // A credential in a `headers` value must be a reference, the same
5516            // rule every other header in this config follows.
5517            if let Some(h) = w.get("headers").and_then(Value::as_object) {
5518                for (k, v) in h {
5519                    if let Some(val) = v.as_str()
5520                        && super::is_secret_shaped_key(k)
5521                        && !crate::sec::secret::has_secret_ref(val)
5522                    {
5523                        d.errors.push(format!(
5524                            "workflows: headers[{k:?}] looks like a credential — use {{{{secret:NAME}}}} rather than a literal"
5525                        ));
5526                    }
5527                }
5528            }
5529            continue;
5530        }
5531        if let Err(errs) = crate::engine::model::parse_workflow(w) {
5532            // The parser's messages already name the workflow and the step.
5533            d.errors.extend(errs);
5534        }
5535        // Fan-out is checked HERE rather than in the parser because the ceiling
5536        // is a config value the parser cannot see.
5537        let cap = s
5538            .limits
5539            .workflow
5540            .fan_out
5541            .unwrap_or(crate::engine::model::MAX_BATCH_PARALLEL as u32);
5542        let wname = w.get("name").and_then(Value::as_str).unwrap_or("?");
5543        if let Some(steps) = w.get("steps").and_then(Value::as_object) {
5544            for (sid, step) in steps {
5545                let want = step.get("parallel").and_then(Value::as_u64).or_else(|| {
5546                    step.get("batch")
5547                        .and_then(|b| b.get("parallel"))
5548                        .and_then(Value::as_u64)
5549                });
5550                if let Some(want) = want
5551                    && want > cap as u64
5552                {
5553                    d.errors.push(format!(
5554                        "workflow {wname:?} step {sid:?}: parallel {want} exceeds \
5555                         limits.workflow.fan_out ({cap}) — raise the limit or lower the step"
5556                    ));
5557                }
5558            }
5559        }
5560    }
5561    d
5562}
5563
5564/// Start-node kinds that keep an instance alive indefinitely. An instance
5565/// running one drains rather than finishing, so it needs a durable store: its
5566/// state has no re-run to fall back on.
5567/// Re-exported so existing callers keep a name to use; the judgement itself
5568/// lives in `engine::model` with the kind table, so it cannot drift from it.
5569pub use crate::engine::model::is_long_lived_start;
5570
5571/// Whether a raw workflow document has a long-lived start node.
5572pub fn workflow_is_long_lived(w: &Value) -> bool {
5573    w.get("steps")
5574        .and_then(Value::as_object)
5575        .is_some_and(|steps| {
5576            steps.values().any(|st| {
5577                st.get("kind")
5578                    .and_then(Value::as_str)
5579                    .is_some_and(is_long_lived_start)
5580            })
5581        })
5582}
5583
5584/// Whether a raw workflow document uses the inbound webhook surface — a
5585/// `webhook` start node, or a `wait: {on: webhook}` callback (either needs
5586/// `webhooks.listen`).
5587pub fn workflow_uses_webhook(w: &Value) -> bool {
5588    w.get("steps")
5589        .and_then(Value::as_object)
5590        .is_some_and(|steps| {
5591            steps.values().any(|st| {
5592                let kind = st.get("kind").and_then(Value::as_str);
5593                kind == Some("webhook")
5594                    || (matches!(kind, Some("wait") | Some("await"))
5595                        && st.get("on").and_then(Value::as_str) == Some("webhook"))
5596            })
5597        })
5598}
5599
5600/// The inbound-webhook routes a raw workflow document arms, as
5601/// `(node id, declared auth)`. Two shapes, matching what the listener reads: a
5602/// `webhook` start node carries its `auth` at the top level, while a
5603/// `wait: {on: webhook}` callback carries it under `webhook.auth`
5604/// (`runtime::webhooks::webhook_wait`).
5605fn webhook_nodes(w: &Value) -> Vec<(&str, Option<&Value>)> {
5606    let Some(steps) = w.get("steps").and_then(Value::as_object) else {
5607        return Vec::new();
5608    };
5609    steps
5610        .iter()
5611        .filter_map(|(id, st)| {
5612            let kind = st.get("kind").and_then(Value::as_str);
5613            if kind == Some("webhook") {
5614                Some((id.as_str(), st.get("auth")))
5615            } else if matches!(kind, Some("wait") | Some("await"))
5616                && st.get("on").and_then(Value::as_str) == Some("webhook")
5617            {
5618                Some((id.as_str(), st.get("webhook").and_then(|c| c.get("auth"))))
5619            } else {
5620                None
5621            }
5622        })
5623        .collect()
5624}
5625
5626/// Whether a node's declared `auth` actually verifies the caller. This mirrors
5627/// `runtime::webhooks::build_verify` INCLUDING its type tests — there a
5628/// non-object `hmac`/`header` or a non-string `bearer` is not a verifier and
5629/// falls through, so counting it as auth here would bless a route the listener
5630/// serves open. `none: true` short-circuits to `Verify::None`, so it is the
5631/// opposite of authentication.
5632fn webhook_auth_verifies(auth: Option<&Value>) -> bool {
5633    let Some(a) = auth else { return false };
5634    if a.get("none").and_then(Value::as_bool) == Some(true) {
5635        return false;
5636    }
5637    a.get("hmac").and_then(Value::as_object).is_some()
5638        || a.get("header").and_then(Value::as_object).is_some()
5639        || a.get("bearer").and_then(Value::as_str).is_some()
5640}
5641
5642/// The same question for the listener-wide `default_auth` (the typed twin,
5643/// `runtime::webhooks::build_verify_typed`): `none` wins over everything, and a
5644/// declared-but-incomplete verifier still counts — the listener refuses to spawn
5645/// on it, which fails closed.
5646fn webhook_default_verifies(d: Option<&WebhookAuth>) -> bool {
5647    d.is_some_and(|d| !d.none && (d.hmac.is_some() || d.bearer.is_some() || d.header.is_some()))
5648}
5649
5650fn validate_budget(b: &Budget, at: &str, d: &mut Diagnostics) {
5651    for (i, w) in b.windows.iter().enumerate() {
5652        if w.tokens.is_none() && w.requests.is_none() {
5653            d.errors
5654                .push(format!("{at}.windows[{i}]: set tokens and/or requests"));
5655        }
5656        if let Some(r) = &w.reset {
5657            // `HH:MMZ` is ASCII by construction, and the ASCII test must come
5658            // BEFORE the byte slices: `r.len()` is bytes, so a multi-byte char
5659            // (`0é:0Z` is six bytes) would otherwise make `r[..2]` land inside a
5660            // character and panic. A config error is exit 2, never a panic.
5661            let ok = r.len() == 6
5662                && r.is_ascii()
5663                && r.ends_with('Z')
5664                && r[..2].parse::<u32>().is_ok_and(|h| h < 24)
5665                && &r[2..3] == ":"
5666                && r[3..5].parse::<u32>().is_ok_and(|m| m < 60);
5667            if !ok {
5668                d.errors.push(format!(
5669                    "{at}.windows[{i}].reset must be HH:MMZ (got {r:?})"
5670                ));
5671            }
5672            if !w.per.is_calendar() {
5673                d.warnings.push(format!(
5674                    "{at}.windows[{i}].reset is only meaningful for day/week windows"
5675                ));
5676            }
5677        }
5678    }
5679    if let Some(f) = b.slow.factor
5680        && !(f > 0.0 && f <= 1.0)
5681    {
5682        d.errors
5683            .push(format!("{at}.slow.factor must be in (0, 1] (got {f})"));
5684    }
5685    if b.on_exhausted == BudgetTactic::Degrade && b.degrade.model.is_none() {
5686        d.errors.push(format!(
5687            "{at}.on_exhausted is degrade but {at}.degrade.model is not set"
5688        ));
5689    }
5690    if b.reserve.estimate == ReserveEstimate::Fixed && b.reserve.fixed.is_none() {
5691        d.errors.push(format!(
5692            "{at}.reserve.estimate is fixed but {at}.reserve.fixed is not set"
5693        ));
5694    }
5695}
5696
5697/// Secret-bearing paths that must be REFERENCES when they come from a file.
5698const FILE_SECRET_PATHS: &[&str] = &[
5699    "/intelligence/token",
5700    "/a2a/bearer",
5701    "/security/aauth/enroll_token",
5702];
5703
5704/// Inline (non-reference) credentials found in the FILE document.
5705fn secret_violations(file_doc: &Value) -> Vec<String> {
5706    let mut out = Vec::new();
5707    for p in FILE_SECRET_PATHS {
5708        if let Some(Value::String(v)) = file_doc.pointer(p)
5709            && !crate::sec::secret::has_secret_ref(v)
5710        {
5711            out.push(format!(
5712                "config file: {} carries an inline credential; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}} (or set it from env/flag)",
5713                p.trim_start_matches('/').replace('/', ".")
5714            ));
5715        }
5716    }
5717    if let Some(servers) = file_doc.pointer("/mcp/servers").and_then(Value::as_array) {
5718        for s in servers {
5719            if let Some(Value::String(v)) = s.pointer("/oauth/client_secret")
5720                && !crate::sec::secret::has_secret_ref(v)
5721            {
5722                out.push(format!(
5723                    "config file: mcp server '{}' oauth.client_secret carries an inline credential; use a {{{{secret:…}}}} reference",
5724                    s.get("name").and_then(Value::as_str).unwrap_or("?")
5725                ));
5726            }
5727        }
5728    }
5729    out
5730}
5731
5732// ---------------------------------------------------------------------------
5733// Reload partition
5734// ---------------------------------------------------------------------------
5735
5736/// Restart-only path prefixes: a live reload whose effective document differs
5737/// under any of these is refused (`restart_required`).
5738pub const RESTART_ONLY_PATHS: &[&str] = &[
5739    "config_version",
5740    "agent.name",
5741    "store.kind",
5742    "store.prefix",
5743    "store.mcp",
5744    "store.http",
5745    // Moving the state directory under a running instance would strand every
5746    // key it has written, so it joins the other store paths as restart-only.
5747    "store.file",
5748    "lifecycle.run_until",
5749    "lifecycle.drain_timeout",
5750    "lifecycle.run_id",
5751    "lifecycle.exit_code_map",
5752    "lifecycle.watch_config",
5753    "a2a.listen",
5754    "a2a.tls",
5755    "a2a.bearer",
5756    // Principals are compiled into the `Resolver` at startup (runtime/mod.rs)
5757    // and the reload never rebuilds it, so a changed principal — a new
5758    // subject, an edited label, a rotated `bearer_ref` — took effect only on
5759    // restart. It was NOT listed here either, which is the part that made it
5760    // dangerous: the reload was APPLIED, `config.reloaded` reported success,
5761    // and the resolver kept its boot snapshot. Refusing is the honest posture
5762    // until the resolver is rebuilt on reload; a refusal an operator can see
5763    // beats an apply that lied.
5764    "a2a.principals",
5765    // Same shape: the webhook routes and their `auth` are compiled into the
5766    // listener at startup and never rebuilt. Rotating a route's HMAC secret
5767    // through a reload reported success and kept verifying against the OLD
5768    // secret — a silent security regression for anyone who relaxed their
5769    // restart policy on the strength of what agentd refuses.
5770    "webhooks",
5771    "observability.otel",
5772    "observability.metrics_addr",
5773    "observability.health_file",
5774    "observability.events_ring",
5775    "observability.traceparent",
5776    "security",
5777];
5778
5779/// The restart-only paths whose values differ between two effective documents.
5780pub fn restart_only_diff(running: &Value, candidate: &Value) -> Vec<String> {
5781    RESTART_ONLY_PATHS
5782        .iter()
5783        .filter(|p| {
5784            let ptr = format!("/{}", p.replace('.', "/"));
5785            running.pointer(&ptr) != candidate.pointer(&ptr)
5786        })
5787        .map(|p| (*p).to_string())
5788        .collect()
5789}
5790
5791/// The `--help` section for the v2 paths.
5792pub fn help_section() -> String {
5793    paths::help_section_in(&paths::bindings_of(&schema::schema()))
5794}
5795
5796/// The v2 `--help` text: usage, the alias flags, the removed flags, and every
5797/// config path (flag · env).
5798pub fn help_text() -> String {
5799    let mut out = format!(
5800        "agentd {ver} — a durable, workflow-driven agent (config schema v2)\n\
5801         \n\
5802         USAGE:\n\
5803         \x20 agentd --config <settings.yaml> [--config <overlay.yaml> …] [--<path> <value> …]\n\
5804         \x20 agentd --prompt <TEXT> --intelligence <URL>                    # one-shot: ask, answer, exit\n\
5805         \x20 agentd --instruction <TEXT> --intelligence <URL> [--mcp name=endpoint …]   # one-shot sugar\n\
5806         \x20 agentd tui|ui --config <settings.yaml> [--<path> <value> …]   # + a display client\n\
5807         \n\
5808         Every setting is a document path (YAML/JSON file, AGENTD_<PATH> env, --<path> flag);\n\
5809         several files merge in order (later wins). Precedence: built-in < files < env < flags.\n\
5810         \n\
5811         ALIASES (short spellings of paths):\n",
5812        ver = crate::VERSION
5813    );
5814    for a in ALIASES {
5815        let shape = match a.kind {
5816            AliasKind::Set | AliasKind::SetFromFile => "<value>",
5817            AliasKind::SetTrue => "",
5818            AliasKind::Append => "<value>  (adds one)",
5819            AliasKind::Special => "<value>",
5820        };
5821        out.push_str(&format!("  {:<32} {} → {}\n", a.flag, shape, a.path));
5822    }
5823    out.push_str(
5824        "\nSUBCOMMANDS (run the daemon with a display client attached):\n\
5825         \x20 tui                        + the terminal UI (fullscreen; --inline for in-place)\n\
5826         \x20 ui                         + the web UI, opened in a browser\n\
5827         \x20                            both need `interface.enabled: true`, which the\n\
5828         \x20                            subcommand sets for you; the client exits with the daemon.\n\
5829         \x20                            Detached instead: run `agentd -c …`, then `agentd-tui\n\
5830         \x20                            --endpoint <url>` (npm i -g @agentd-dev/cli).\n\
5831         \nCONTROL:\n\
5832         \x20 -c, --config <PATH>        a settings file (repeatable; `=` form too; or AGENT_CONFIG=a.yaml:b.yaml)\n\
5833         \x20 --validate-config          load+validate everything, print the verdict, exit 0/2\n\
5834         \x20 --config-schema            print the settings JSON Schema and exit\n\
5835         \x20 --context-template        print the built-in system-prompt template and exit\n\
5836     \x20 --workflow-schema          print the workflow JSON Schema + node registry and exit\n\
5837         \x20 --capabilities             print the capabilities manifest and exit\n\
5838         \x20 --login <target>           complete an OAuth device-login for an endpoint (e.g. mcp:<name>) and cache the token\n\
5839         \x20 --logout <target>          evict a cached credential\n\
5840         \x20 --prompt-missing           ask interactively (echo off, on /dev/tty) for each {{secret:NAME}} the startup preflight finds missing; refused without a controlling terminal\n\
5841         \x20 --env <FILE>               load a dotenv file into this process's environment (repeatable; real env wins, later files win)\n\
5842         \x20 -h, --help / -V, --version\n\
5843         \nREMOVED FLAGS:\n",
5844    );
5845    for (flag, hint) in REMOVED_FLAGS {
5846        out.push_str(&format!("  {flag:<32} {hint}\n"));
5847    }
5848    out.push('\n');
5849    out.push_str(&help_section());
5850    out
5851}
5852
5853#[cfg(test)]
5854mod tests {
5855    use super::*;
5856    use std::io::Write;
5857
5858    fn args(v: &[&str]) -> Vec<String> {
5859        v.iter().map(|s| s.to_string()).collect()
5860    }
5861
5862    fn write_tmp(contents: &str, ext: &str) -> tempfile::NamedTempFile {
5863        let mut f = tempfile::Builder::new()
5864            .suffix(&format!(".{ext}"))
5865            .tempfile()
5866            .unwrap();
5867        f.write_all(contents.as_bytes()).unwrap();
5868        f.flush().unwrap();
5869        f
5870    }
5871
5872    /// The environment a test config loads under.
5873    ///
5874    /// Also seeds the credential the shared `CATALOG` fixture references.
5875    /// Validation now runs the SAME whole-document reference scan startup runs,
5876    /// so a fixture naming `{{secret:BILLING}}` is refused unless that secret
5877    /// resolves — which is the point of the change, and makes these fixtures
5878    /// behave like a real deployment. Seeded through the prompted-values store
5879    /// rather than `std::env::set_var`: it is additive and never removes, so
5880    /// tests running in parallel cannot unset each other's secrets.
5881    fn base_env() -> Vec<(String, String)> {
5882        // Only the two the loaded fixtures reference. `SUB_WINDOW_TEST_UNSET`
5883        // is deliberately NOT seeded — a test asserts the missing case, and
5884        // seeding it would quietly delete that coverage.
5885        for name in ["BILLING", "PEER"] {
5886            crate::sec::secret::set_prompted(name, "test-value".into());
5887        }
5888        vec![(
5889            "AGENTD_INTELLIGENCE_ENDPOINTS".into(),
5890            "https://intel.example/v1".into(),
5891        )]
5892    }
5893
5894    // ---- schema ↔ struct agreement -----------------------------------------
5895
5896    /// serde's `deny_unknown_fields` error names the expected fields; that
5897    /// list IS the struct's field set — compare it with the schema properties
5898    /// at every object, so neither can diverge from the other unnoticed.
5899    fn struct_fields_at(doc_path: &str) -> Vec<String> {
5900        // Build a document that is empty except for a probe key at `doc_path`.
5901        let mut probe = Value::Object(Map::new());
5902        let path = if doc_path.is_empty() {
5903            "__probe__".to_string()
5904        } else {
5905            format!("{doc_path}.__probe__")
5906        };
5907        paths::set_path(&mut probe, &path, json!(1));
5908        let err = Settings::from_document(probe, "t").expect_err("probe must be rejected");
5909        // "… unknown field `__probe__`, expected one of `a`, `b`, `c` …" (or
5910        // "expected `a` or `b`" for two, "expected `a`" for one).
5911        let after = err.split("expected").nth(1).unwrap_or("");
5912        let mut out: Vec<String> = after
5913            .split('`')
5914            .skip(1)
5915            .step_by(2)
5916            .map(str::to_string)
5917            .collect();
5918        out.sort();
5919        out
5920    }
5921
5922    fn schema_props_at(schema: &Value, doc_path: &str) -> Vec<String> {
5923        let mut node = schema.clone();
5924        let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
5925        for seg in doc_path.split('.').filter(|s| !s.is_empty()) {
5926            let props = node.get("properties").cloned().unwrap_or(Value::Null);
5927            node = props.get(seg).cloned().unwrap_or(Value::Null);
5928            if let Some(r) = node.get("$ref").and_then(Value::as_str)
5929                && let Some(name) = r.strip_prefix("#/$defs/")
5930            {
5931                node = defs.get(name).cloned().unwrap_or(Value::Null);
5932            }
5933        }
5934        let mut out: Vec<String> = node
5935            .get("properties")
5936            .and_then(Value::as_object)
5937            .map(|m| m.keys().cloned().collect())
5938            .unwrap_or_default();
5939        out.sort();
5940        out
5941    }
5942
5943    /// The same agreement check for types that live INSIDE a collection —
5944    /// a map value (`services.<name>` → `Service`) and an array item
5945    /// (`a2a.peers[]` → `A2aPeer`).
5946    ///
5947    /// `schema_matches_struct_at_every_object` walks `properties` segment by
5948    /// segment, so it cannot reach through `additionalProperties` or `items`
5949    /// and never covered these two. That gap is exactly where the published
5950    /// schema drifted: `Service.kind` still said mcp-only against four kinds in
5951    /// the loader, `Service.methods` and `A2aPeer.service` were absent, and
5952    /// with `additionalProperties: false` that made our OWN shipped
5953    /// examples/voice/hands.yaml red in any editor honouring the schema.
5954    ///
5955    /// Note what this could NOT have caught: CI regenerates the published files
5956    /// from the binary and diffs, which proves the file matches the generator
5957    /// and says nothing about whether the generator matches the loader.
5958    #[test]
5959    fn schema_matches_struct_for_collection_item_types() {
5960        /// The struct's field set, via serde's `deny_unknown_fields` error on a
5961        /// probe document that reaches into the collection.
5962        fn fields_of(probe: Value) -> Vec<String> {
5963            let err = Settings::from_document(probe, "t").expect_err("probe must be rejected");
5964            let after = err.split("expected").nth(1).unwrap_or("");
5965            let mut out: Vec<String> = after
5966                .split('`')
5967                .skip(1)
5968                .step_by(2)
5969                .map(str::to_string)
5970                .collect();
5971            out.sort();
5972            out.dedup();
5973            out
5974        }
5975        fn def_props(schema: &Value, name: &str) -> Vec<String> {
5976            let mut out: Vec<String> = schema["$defs"][name]["properties"]
5977                .as_object()
5978                .map(|m| m.keys().cloned().collect())
5979                .unwrap_or_default();
5980            out.sort();
5981            out
5982        }
5983
5984        let schema = schema::schema();
5985        for (def, probe) in [
5986            (
5987                "Service",
5988                json!({"services": {"p": {"endpoint": "https://x", "__probe__": 1}}}),
5989            ),
5990            (
5991                "A2aPeer",
5992                json!({"a2a": {"peers": [{"name": "p", "endpoint": "https://x", "__probe__": 1}]}}),
5993            ),
5994        ] {
5995            assert_eq!(
5996                def_props(&schema, def),
5997                fields_of(probe),
5998                "schema/struct drift in $defs/{def}"
5999            );
6000        }
6001
6002        // And the enum, which a field-name comparison cannot see: every
6003        // ServiceKind variant must be offered, or a valid config reads as
6004        // invalid in an editor.
6005        assert_eq!(
6006            schema["$defs"]["Service"]["properties"]["kind"]["enum"],
6007            json!(["mcp", "intelligence", "peer", "http"]),
6008        );
6009    }
6010
6011    #[test]
6012    fn schema_matches_struct_at_every_object() {
6013        let schema = schema::schema();
6014        for path in [
6015            "",
6016            "agent",
6017            "agent.tools",
6018            "intelligence",
6019            "intelligence.auth",
6020            "intelligence.budget",
6021            "intelligence.budget.slow",
6022            "intelligence.budget.degrade",
6023            "intelligence.budget.reserve",
6024            "mcp",
6025            "tools",
6026            "store",
6027            "store.checkpoint",
6028            "store.durability",
6029            "memory",
6030            "context",
6031            "context.plan",
6032            "context.summarize",
6033            "knowledge",
6034            "knowledge.auto_context",
6035            "search",
6036            "skills",
6037            "limits",
6038            "limits.run",
6039            "limits.subagents",
6040            "limits.subagents.instances",
6041            "subagents",
6042            "subagents.defaults",
6043            "lifecycle",
6044            "a2a",
6045            "a2a.tls",
6046            "observability",
6047            "observability.otel",
6048            "observability.audit",
6049            "security",
6050            "security.cgroup",
6051            "security.exec",
6052        ] {
6053            let s = schema_props_at(&schema, path);
6054            let f = struct_fields_at(path);
6055            assert_eq!(s, f, "schema/struct drift at `{path}`");
6056        }
6057    }
6058
6059    #[test]
6060    fn every_schema_path_deserializes_a_sample() {
6061        // Every binding, given a kind-appropriate sample, must be accepted by
6062        // the typed Settings (proves the schema names real fields with the
6063        // right shapes — the paths mechanism depends on it).
6064        for b in paths::bindings_of(&schema::schema()) {
6065            let sample = match &b.kind {
6066                paths::Kind::String => match b.path.as_str() {
6067                    "config_version" => json!("2"),
6068                    _ => json!("x"),
6069                },
6070                paths::Kind::Integer => json!(1),
6071                paths::Kind::Number => json!(0.5),
6072                paths::Kind::Boolean => json!(true),
6073                paths::Kind::Enum(vs) => json!(vs[0]),
6074                paths::Kind::Array(item) => match (**item).clone() {
6075                    paths::Kind::Object => match b.path.as_str() {
6076                        "mcp.servers" => {
6077                            json!([{"name": "a", "endpoint": "https://a.example/mcp"}])
6078                        }
6079                        "workflows" => json!([{"name": "w", "steps": {}}]),
6080                        "a2a.principals" => json!([{"match": {"any": true}, "role": "user"}]),
6081                        "a2a.peers" => json!([{"name": "p", "endpoint": "https://p.example"}]),
6082                        "skills.sources" => json!([{"server": "s"}]),
6083                        "security.policies" => {
6084                            json!([{"match": {"tool": "fs.*"}, "action": "deny"}])
6085                        }
6086                        "intelligence.budget.windows" | "agent.conversation_budget.windows" => {
6087                            json!([{"per": "hour", "tokens": 1}])
6088                        }
6089                        other => panic!("no sample for object list {other}"),
6090                    },
6091                    paths::Kind::Enum(vs) => json!([vs[0]]),
6092                    _ => json!(["s"]),
6093                },
6094                paths::Kind::Object => match b.path.as_str() {
6095                    "intelligence.pricing" => json!({"m": {"input_per_1k": 1.0}}),
6096                    "intelligence.models" => json!({"small": {"model": "m-1"}}),
6097                    "tools.overrides" => json!({"memory.get": {"server": "s", "tool": "t"}}),
6098                    "store.mcp" => json!({"server": "s"}),
6099                    "streams" => json!({"orders": {"retention": {"max_events": 1}}}),
6100                    "services" => json!({"billing": {"endpoint": "https://b.example/mcp"}}),
6101                    "subagents.templates" => json!({"t": {"instruction": "do the thing"}}),
6102                    "subagents.defaults.limits" => json!({"max_tokens": 1000}),
6103                    "store.http" => json!({"base_url": "https://s"}),
6104                    "security.aauth" => json!({"provider": "https://apd"}),
6105                    "lifecycle.exit_code_map" => json!({"3": 0}),
6106                    _ => json!({"k": "v"}),
6107                },
6108                paths::Kind::Any => match b.path.as_str() {
6109                    "intelligence.endpoints" => json!("https://a,https://b"),
6110                    "goal.on_achieved" | "goal.on_stuck" => json!("finish"),
6111                    p if p.ends_with("timeout")
6112                        || p.ends_with("deadline")
6113                        || p.ends_with("_grace")
6114                        || p.ends_with("ttl")
6115                        || p.ends_with("every") =>
6116                    {
6117                        json!("10s")
6118                    }
6119                    p if p.starts_with("agent.tools.") => json!("all"),
6120                    _ => json!("x"),
6121                },
6122            };
6123            let mut doc = Value::Object(Map::new());
6124            paths::set_path(&mut doc, &b.path, sample);
6125            fill_required(&mut doc, &schema::schema(), &b.path);
6126            Settings::from_document(doc, "t")
6127                .unwrap_or_else(|e| panic!("path {} does not deserialize: {e}", b.path));
6128        }
6129    }
6130
6131    /// Along `path`, every schema object with `required` gets its required
6132    /// properties filled with a sample (so a lone leaf under `store.mcp` still
6133    /// types — the runtime validation reports the missing siblings instead).
6134    fn fill_required(doc: &mut Value, schema: &Value, path: &str) {
6135        let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
6136        let resolve = |v: &Value| -> Value {
6137            match v
6138                .get("$ref")
6139                .and_then(Value::as_str)
6140                .and_then(|r| r.strip_prefix("#/$defs/"))
6141            {
6142                Some(name) => defs.get(name).cloned().unwrap_or(Value::Null),
6143                None => v.clone(),
6144            }
6145        };
6146        let mut node = schema.clone();
6147        let mut prefix = String::new();
6148        let segs: Vec<&str> = path.split('.').collect();
6149        for (i, seg) in segs.iter().enumerate() {
6150            let props = node.get("properties").cloned().unwrap_or(Value::Null);
6151            node = resolve(&props.get(*seg).cloned().unwrap_or(Value::Null));
6152            prefix = if prefix.is_empty() {
6153                (*seg).to_string()
6154            } else {
6155                format!("{prefix}.{seg}")
6156            };
6157            if i + 1 == segs.len() {
6158                break;
6159            }
6160            if let Some(req) = node.get("required").and_then(Value::as_array) {
6161                let props = node.get("properties").cloned().unwrap_or(Value::Null);
6162                for r in req.iter().filter_map(Value::as_str) {
6163                    let p = format!("{prefix}.{r}");
6164                    if doc.pointer(&format!("/{}", p.replace('.', "/"))).is_none() {
6165                        // Honor an enum-typed required field (e.g. `auth.kind`) so
6166                        // the filled sample is a valid variant, not `"x"`.
6167                        let sample = match props
6168                            .get(r)
6169                            .and_then(|f| f.get("enum"))
6170                            .and_then(Value::as_array)
6171                            .filter(|a| !a.is_empty())
6172                        {
6173                            Some(vs) => vs[0].clone(),
6174                            None => match r {
6175                                "provider" | "base_url" | "url" => json!("https://x.example"),
6176                                _ => json!("x"),
6177                            },
6178                        };
6179                        paths::set_path(doc, &p, sample);
6180                    }
6181                }
6182            }
6183        }
6184    }
6185
6186    #[test]
6187    fn env_and_flag_names_derive_from_the_v2_paths() {
6188        let bs = paths::bindings_of(&schema::schema());
6189        let model = bs.iter().find(|b| b.path == "intelligence.model").unwrap();
6190        assert_eq!(model.env_names()[0], "AGENTD_INTELLIGENCE_MODEL");
6191        assert_eq!(model.env_names()[2], "INTELLIGENCE_MODEL");
6192        assert_eq!(model.flag(), "--intelligence-model");
6193        let steps = bs.iter().find(|b| b.path == "limits.run.steps").unwrap();
6194        assert_eq!(steps.env_names()[0], "AGENTD_LIMITS_RUN_STEPS");
6195        // Uniqueness of the derived names across the whole v2 schema.
6196        let mut seen = std::collections::HashSet::new();
6197        for b in &bs {
6198            assert!(seen.insert(b.flag()), "duplicate flag {}", b.flag());
6199        }
6200    }
6201
6202    // ---- detection ------------------------------------------------------------
6203
6204    #[test]
6205    fn detects_v1_v2_mixed_and_empty() {
6206        assert_eq!(detect(&json!({})), Detected::Empty);
6207        assert_eq!(detect(&json!({"model": "m"})), Detected::V1);
6208        assert_eq!(detect(&json!({"config_version": "1"})), Detected::V2);
6209        assert_eq!(
6210            detect(&json!({"agent": {"instruction": "x"}})),
6211            Detected::V2
6212        );
6213        assert_eq!(detect(&json!({"agent": {}, "model": "m"})), Detected::Mixed);
6214        assert_eq!(
6215            detect(&json!({"config_version": "1.0", "model": "m"})),
6216            Detected::V1
6217        );
6218        // `limits` is neutral; `intelligence` decides by shape.
6219        assert_eq!(
6220            detect(&json!({"model": "m", "limits": {"max_steps": 1}})),
6221            Detected::V1
6222        );
6223        assert_eq!(
6224            detect(&json!({"intelligence": "https://x", "limits": {}})),
6225            Detected::V1
6226        );
6227        assert_eq!(
6228            detect(&json!({"intelligence": {"model": "m"}, "limits": {}})),
6229            Detected::V2
6230        );
6231        assert_eq!(detect(&json!({"limits": {"max_steps": 1}})), Detected::V1);
6232    }
6233
6234    // ---- load: layering, aliases, sugar --------------------------------------
6235
6236    #[cfg(feature = "exec")]
6237    #[test]
6238    fn enabling_exec_next_to_untrusted_input_assembles_the_trifecta() {
6239        // `exec` is tagged sensitive+egress in the tool registry — but the
6240        // registry is built AFTER validation, so for a long time those tags
6241        // never reached the check and this config started happily. It is the
6242        // whole lethal trifecta: untrusted input, sensitive powers, an egress
6243        // path.
6244        let cfg = "config_version: \"1\"\nstore: {kind: memory}\n\
6245                   mcp:\n  servers:\n    - name: web\n      endpoint: https://mcp-web.internal/mcp\n      tags: {\"*\": [untrusted_input]}\n\
6246                   security:\n  exec: {enabled: true, workdir: /tmp, allow: [git]}\n";
6247        let f = write_tmp(cfg, "yaml");
6248        let e = load(
6249            &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
6250            &base_env(),
6251        )
6252        .unwrap_err();
6253        assert!(format!("{e}").contains("lethal-trifecta refused"), "{e}");
6254
6255        // The documented override still lets an operator take the risk.
6256        load(
6257            &args(&[
6258                "--config",
6259                f.path().to_str().unwrap(),
6260                "--validate-config",
6261                "--allow-trifecta",
6262            ]),
6263            &base_env(),
6264        )
6265        .expect("--allow-trifecta is the escape hatch");
6266
6267        // exec WITHOUT an untrusted-input source is only two legs: still fine.
6268        let alone = write_tmp(
6269            "config_version: \"1\"\nstore: {kind: memory}\n\
6270             security:\n  exec: {enabled: true, workdir: /tmp, allow: [git]}\n",
6271            "yaml",
6272        );
6273        load(
6274            &args(&[
6275                "--config",
6276                alone.path().to_str().unwrap(),
6277                "--validate-config",
6278            ]),
6279            &base_env(),
6280        )
6281        .expect("two legs are not the trifecta");
6282    }
6283
6284    #[test]
6285    fn validate_config_catches_workflow_body_errors_the_runtime_would_refuse() {
6286        // The pre-flight check must not pass a config that then exits 2 on
6287        // the first real start: a validator that accepts what startup refuses
6288        // is worse than no validator, because it certifies the broken config.
6289        let f = write_tmp(
6290            "config_version: \"1\"\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",
6291            "yaml",
6292        );
6293        let e = load(
6294            &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
6295            &base_env(),
6296        )
6297        .unwrap_err();
6298        let msg = format!("{e}");
6299        assert!(msg.contains("unknown field"), "{msg}");
6300        assert!(msg.contains("prompt"), "{msg}");
6301        assert!(
6302            msg.contains("instruction"),
6303            "names the allowed fields: {msg}"
6304        );
6305
6306        // The same workflow, spelled correctly, still validates.
6307        let ok = write_tmp(
6308            "config_version: \"1\"\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",
6309            "yaml",
6310        );
6311        load(
6312            &args(&["--config", ok.path().to_str().unwrap(), "--validate-config"]),
6313            &base_env(),
6314        )
6315        .expect("a correct workflow validates");
6316    }
6317
6318    #[test]
6319    fn a_prompt_is_a_message_not_a_sugar_workflow() {
6320        // A prompt is delivered into the agent's ROOT context at startup, so
6321        // it authors no workflow — that is what gives it root-scoped tools and
6322        // lets it set the instance up (workflow.create) rather than only
6323        // answering a canned step.
6324        let (l, ask) = load(&args(&["--prompt", "do the thing"]), &base_env()).unwrap();
6325        assert_eq!(ask, Ask::Run);
6326        assert_eq!(l.settings.agent.prompt.as_deref(), Some("do the thing"));
6327        assert!(
6328            l.settings.workflows.is_empty(),
6329            "a prompt needs no workflow: {:?}",
6330            l.settings.workflows
6331        );
6332
6333        // An instruction alone still gets the one-shot sugar workflow…
6334        let (only_instr, _) = load(&args(&["--instruction", "be terse"]), &base_env()).unwrap();
6335        assert_eq!(only_instr.settings.workflows.len(), 1);
6336
6337        // …but a prompt alongside it means the prompt is the job: the
6338        // instruction stays standing policy, and no step is synthesized.
6339        let (both, _) = load(
6340            &args(&["--prompt", "do the thing", "--instruction", "be terse"]),
6341            &base_env(),
6342        )
6343        .unwrap();
6344        assert!(both.settings.workflows.is_empty());
6345        assert_eq!(both.settings.agent.instruction.as_deref(), Some("be terse"));
6346
6347        // The env spelling works too (12-factor).
6348        let mut env = base_env();
6349        env.push(("AGENTD_AGENT_PROMPT".into(), "from env".into()));
6350        let (from_env, _) = load(&args(&[]), &env).unwrap();
6351        assert_eq!(from_env.settings.agent.prompt.as_deref(), Some("from env"));
6352    }
6353
6354    #[test]
6355    fn minimal_instruction_run_gets_the_sugar_workflow() {
6356        let (l, ask) = load(&args(&["--instruction", "do it"]), &base_env()).unwrap();
6357        assert_eq!(ask, Ask::Run);
6358        assert_eq!(l.settings.agent.instruction.as_deref(), Some("do it"));
6359        assert_eq!(
6360            l.settings.intelligence.endpoints,
6361            vec!["https://intel.example/v1"]
6362        );
6363        assert_eq!(l.settings.workflows.len(), 1, "sugar workflow synthesized");
6364        assert_eq!(l.settings.workflows[0]["name"], json!("main"));
6365        assert_eq!(
6366            l.settings.workflows[0]["steps"]["start"]["kind"],
6367            json!("once")
6368        );
6369        // A one-shot job may run without a store — with a warning.
6370        assert!(
6371            l.warnings.iter().any(|w| w.contains("not durable")),
6372            "{:?}",
6373            l.warnings
6374        );
6375    }
6376
6377    #[test]
6378    fn a_long_lived_instance_defaults_to_the_file_store_but_an_explicit_none_is_refused() {
6379        // An A2A listener makes the instance long-lived ⇒ the file store.
6380        let (l, _) = load(
6381            &args(&[
6382                "--instruction",
6383                "x",
6384                "--a2a.listen",
6385                "http://127.0.0.1:8443",
6386            ]),
6387            &base_env(),
6388        )
6389        .unwrap();
6390        assert_eq!(l.settings.store.kind, StoreKind::File);
6391        // A long-lived start node ⇒ the same default.
6392        let f = write_tmp(
6393            "config_version: \"1\"\nworkflows:\n  - name: w\n    steps:\n      s: {kind: schedule, cron: \"* * * * *\"}\n      f: {kind: finish, depends_on: [s], status: completed}\n",
6394            "yaml",
6395        );
6396        let (l, _) = load(
6397            &args(&["--config", f.path().to_str().unwrap()]),
6398            &base_env(),
6399        )
6400        .unwrap();
6401        assert_eq!(l.settings.store.kind, StoreKind::File);
6402        // …but a STATED `none` is still refused: the default fills a silence,
6403        // it does not overrule an operator.
6404        let e = load(
6405            &args(&[
6406                "--config",
6407                f.path().to_str().unwrap(),
6408                "--store.kind",
6409                "none",
6410            ]),
6411            &base_env(),
6412        )
6413        .unwrap_err();
6414        assert!(format!("{e}").contains("long-lived"), "{e}");
6415        // A one-shot job keeps `none` — the default deliberately does not move
6416        // for the shape that can simply be re-run.
6417        let (l, _) = load(&args(&["--instruction", "x"]), &base_env()).unwrap();
6418        assert_eq!(l.settings.store.kind, StoreKind::None);
6419        // memory is accepted (with a warning).
6420        let (l, _) = load(
6421            &args(&["--instruction", "x", "--store.kind", "memory"]),
6422            &base_env(),
6423        )
6424        .unwrap();
6425        assert!(
6426            l.warnings.iter().any(|w| w.contains("memory")),
6427            "{:?}",
6428            l.warnings
6429        );
6430    }
6431
6432    // ---- env substitution: `${VAR}` / `${VAR:-default}` --------------------
6433
6434    #[test]
6435    fn expand_env_str_covers_the_forms() {
6436        let env: HashMap<&str, &str> = [("HOST", "db.internal"), ("PORT", "5432")]
6437            .into_iter()
6438            .collect();
6439        // A plain reference; multiple in one string.
6440        assert_eq!(
6441            expand_env_str("${HOST}:${PORT}", &env).unwrap(),
6442            "db.internal:5432"
6443        );
6444        // A default applies only when the variable is unset.
6445        assert_eq!(
6446            expand_env_str("${MISSING:-fallback}", &env).unwrap(),
6447            "fallback"
6448        );
6449        assert_eq!(
6450            expand_env_str("${HOST:-fallback}", &env).unwrap(),
6451            "db.internal"
6452        );
6453        // Braces are required: a bare `$VAR` and a lone `$` pass through.
6454        assert_eq!(
6455            expand_env_str("$HOST costs $5", &env).unwrap(),
6456            "$HOST costs $5"
6457        );
6458        // `$$` escapes to a literal `$` and does not open a reference.
6459        assert_eq!(expand_env_str("$${HOST}", &env).unwrap(), "${HOST}");
6460        // An unset variable with no default is a hard error (fail-closed).
6461        assert!(
6462            expand_env_str("${NOPE}", &env)
6463                .unwrap_err()
6464                .contains("NOPE")
6465        );
6466        // A malformed reference is rejected, not silently passed through.
6467        assert!(expand_env_str("${HOST", &env).is_err());
6468        assert!(expand_env_str("${bad-name}", &env).is_err());
6469    }
6470
6471    /// The two paths that used to report a SUCCESSFUL reload and do nothing.
6472    ///
6473    /// Neither is rebuilt by `reload.rs` — principals are compiled into the
6474    /// `Resolver` at startup, webhook routes and their auth into the listener —
6475    /// and neither was listed restart-only, so a change was applied in name
6476    /// only. Rotating a route's HMAC secret through a reload kept verifying
6477    /// against the OLD secret while `config.reloaded` said success.
6478    #[test]
6479    fn principals_and_webhooks_refuse_a_reload_rather_than_no_op() {
6480        let base = json!({
6481            "a2a": {"listen": "http://127.0.0.1:1", "principals": [
6482                {"match": {"any": true}, "role": "user", "labels": {"team": "alpha"}}]},
6483            "webhooks": {"listen": "http://127.0.0.1:2",
6484                         "default_auth": {"hmac": {"secret": "{{secret:S}}"}}},
6485            "agent": {"instruction": "before"},
6486        });
6487
6488        // A principal label edit: refused, and the refusal NAMES the path.
6489        let mut changed = base.clone();
6490        changed["a2a"]["principals"][0]["labels"]["team"] = json!("bravo");
6491        assert_eq!(restart_only_diff(&base, &changed), ["a2a.principals"]);
6492
6493        // A rotated webhook secret: likewise.
6494        let mut rotated = base.clone();
6495        rotated["webhooks"]["default_auth"]["hmac"]["secret"] = json!("{{secret:S2}}");
6496        assert_eq!(restart_only_diff(&base, &rotated), ["webhooks"]);
6497
6498        // And the reloadable half still reloads — this must not become a
6499        // blanket "restart on anything", which would defeat hot reload.
6500        let mut instr = base.clone();
6501        instr["agent"]["instruction"] = json!("after");
6502        assert!(restart_only_diff(&base, &instr).is_empty());
6503    }
6504
6505    #[test]
6506    fn config_vars_fold_typed_values_and_collect_every_miss() {
6507        let file = write_tmp(
6508            "config_version: \"1\"\n\
6509             vars:\n  region: eu-1\n  port: 8443\n  team:\n    name: platform\n\
6510             agent:\n  name: \"svc-{{config.region}}\"\n  instruction: serve\n  preflight: never\n\
6511             intelligence:\n  endpoints: [https://x/v1]\n  model: m\n\
6512             store:\n  kind: memory\n\
6513             limits:\n  step_timeout: \"{{config.port}}s\"\n\
6514             workflows:\n  - name: w\n    steps:\n\
6515             \x20     s: {kind: once}\n\
6516             \x20     c: {kind: http, depends_on: [s], url: \"https://api.{{config.region}}.example\", headers: {x-team: \"{{config.team.name}}\"}}\n\
6517             \x20     f: {kind: finish, depends_on: [c]}\n",
6518            "yaml",
6519        );
6520        let (l, _) = load(
6521            &args(&["--config", file.path().to_str().unwrap()]),
6522            &base_env(),
6523        )
6524        .unwrap();
6525        // Embedded token: stringified into place, in config values…
6526        assert_eq!(l.settings.agent.name.as_deref(), Some("svc-eu-1"));
6527        // A folded scalar keeps its place in typed config too.
6528        assert_eq!(
6529            l.settings
6530                .limits
6531                .step_timeout
6532                .as_ref()
6533                .map(|d| d.0.as_secs()),
6534            Some(8443)
6535        );
6536        // Workflow docs are deliberately NOT folded here: `load_workflows`
6537        // folds them (so URL/file/dir sources get the identical treatment and
6538        // the definition hash pins the RESOLVED doc) — the e2e proves that leg.
6539        let wf = &l.settings.workflows[0];
6540        assert_eq!(
6541            wf.pointer("/steps/c/url").and_then(Value::as_str),
6542            Some("https://api.{{config.region}}.example")
6543        );
6544
6545        // Every unresolved reference is reported, in ONE refusal.
6546        let bad = write_tmp(
6547            "config_version: \"1\"\n\
6548             vars:\n  set: yes\n\
6549             agent:\n  name: \"{{config.gone}}\"\n  instruction: serve\n  preflight: never\n\
6550             intelligence:\n  endpoints: [\"https://{{config.also_gone}}/v1\"]\n  model: m\n\
6551             store:\n  kind: memory\n",
6552            "yaml",
6553        );
6554        let err = load(
6555            &args(&["--config", bad.path().to_str().unwrap()]),
6556            &base_env(),
6557        )
6558        .err()
6559        .map(|e| e.to_string())
6560        .unwrap_or_default();
6561        assert!(err.contains("config.gone"), "{err}");
6562        assert!(err.contains("config.also_gone"), "{err}");
6563        assert!(
6564            err.contains("2 unresolved config var reference"),
6565            "all misses in one report: {err}"
6566        );
6567    }
6568
6569    #[test]
6570    fn missing_references_name_every_gap_with_its_locations() {
6571        let doc = serde_json::json!({
6572            "a": "{{secret:SUB_WINDOW_TEST_UNSET}}",
6573            "b": {"c": ["{{secret-file:/definitely/not/here}}", "{{config.gone}}"]},
6574            "d": "{{secret:SUB_WINDOW_TEST_UNSET}} again",
6575        });
6576        let vars: std::collections::BTreeMap<String, Value> =
6577            [("present".to_string(), serde_json::json!(1))].into();
6578        let missing = missing_references(&doc, "cfg", &vars);
6579        assert_eq!(missing.len(), 3, "{missing:?}");
6580        let all = missing.join("\n");
6581        assert!(
6582            all.contains("{{secret:SUB_WINDOW_TEST_UNSET}} is not set"),
6583            "{all}"
6584        );
6585        assert!(
6586            all.contains("cfg.a") && all.contains("cfg.d"),
6587            "both locations: {all}"
6588        );
6589        assert!(
6590            all.contains("{{secret-file:/definitely/not/here}} is not readable"),
6591            "{all}"
6592        );
6593        assert!(all.contains("config.gone is not defined in vars"), "{all}");
6594        // A resolvable reference is not noise.
6595        let ok = serde_json::json!({"x": "{{config.present}}"});
6596        assert!(missing_references(&ok, "cfg", &vars).is_empty());
6597    }
6598
6599    #[test]
6600    fn env_substitution_reaches_config_values_and_workflows() {
6601        let file = write_tmp(
6602            "config_version: \"1\"\n\
6603             agent:\n  name: ${SVC_NAME}\n  instruction: serve\n  preflight: never\n\
6604             intelligence:\n  endpoints: [https://x/v1]\n  model: m\n\
6605             store:\n  kind: memory\n\
6606             workflows:\n  - name: w\n    steps:\n\
6607             \x20     s: {kind: once}\n\
6608             \x20     c: {kind: http, depends_on: [s], url: \"https://api.${REGION:-us}.example/${SVC_NAME}\"}\n\
6609             \x20     f: {kind: finish, depends_on: [c]}\n",
6610            "yaml",
6611        );
6612        let mut env = base_env();
6613        env.push(("SVC_NAME".into(), "billing".into()));
6614        // REGION is deliberately unset -> the `:-us` default applies.
6615        let (l, _) = load(&args(&["--config", file.path().to_str().unwrap()]), &env).unwrap();
6616        // A plain config value is substituted.
6617        assert_eq!(
6618            l.settings.agent.name.as_deref(),
6619            Some("billing"),
6620            "the `${{SVC_NAME}}` in a config value was substituted"
6621        );
6622        // A value nested inside an inline workflow is substituted too, honouring
6623        // the `:-default` for the unset REGION and the set SVC_NAME.
6624        let url = l.settings.workflows[0]
6625            .pointer("/steps/c/url")
6626            .and_then(Value::as_str)
6627            .unwrap_or_default();
6628        assert_eq!(
6629            url, "https://api.us.example/billing",
6630            "the workflow value was substituted (default + set var)"
6631        );
6632    }
6633
6634    #[test]
6635    fn mcp_server_oauth_is_carried_to_the_runtime_spec() {
6636        // `mcp.servers[].oauth` must reach the runtime spec as a secret-free
6637        // template, or the connect path has nothing to build a signer from and
6638        // the configured client-credentials grant is inert.
6639        let s = McpServer {
6640            name: "gh".into(),
6641            endpoint: "https://mcp.example".into(),
6642            service: None,
6643            service_rate: None,
6644            ns: None,
6645            headers: BTreeMap::new(),
6646            tags: BTreeMap::new(),
6647            allow: None,
6648            exclude: Vec::new(),
6649            aauth: None,
6650            oauth: Some(McpOauth {
6651                token_url: "https://auth.example/token".into(),
6652                client_id: "cid".into(),
6653                client_secret: Secret("{{secret:CS}}".into()),
6654                scope: Some("mcp:read".into()),
6655            }),
6656            auth: None,
6657            timeout: None,
6658        };
6659        let spec = s.to_spec().unwrap();
6660        let o = spec.oauth.expect("oauth reaches the runtime spec");
6661        assert_eq!(o.token_url, "https://auth.example/token");
6662        assert_eq!(o.client_id, "cid");
6663        // The secret stays a template — never resolved into the spec/payload.
6664        assert_eq!(o.client_secret, "{{secret:CS}}");
6665        assert_eq!(o.scope.as_deref(), Some("mcp:read"));
6666    }
6667
6668    #[test]
6669    fn files_env_flags_layer_in_order_with_aliases() {
6670        let base = write_tmp(
6671            "config_version: \"1\"\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",
6672            "yaml",
6673        );
6674        let over = write_tmp("intelligence:\n  model: over-model\n", "yml");
6675        let mut env = base_env();
6676        env.clear();
6677        env.push(("AGENTD_LIMITS_RUN_STEPS".into(), "20".into())); // derived path name
6678        env.push(("AGENT_MODEL".into(), "env-model".into())); // short alias
6679        env.push(("INSTRUCTION".into(), "env-instruction".into())); // bare alias
6680        let (l, _) = load(
6681            &args(&[
6682                "--config",
6683                base.path().to_str().unwrap(),
6684                "--config",
6685                over.path().to_str().unwrap(),
6686                "--max-steps",
6687                "30",
6688                "--mcp",
6689                "fs=https://fs.example/mcp",
6690                "--mcp-tags",
6691                "fs=sensitive",
6692                "--intelligence.headers.x-team",
6693                "ops",
6694            ]),
6695            &env,
6696        )
6697        .unwrap();
6698        let s = &l.settings;
6699        assert_eq!(
6700            s.agent.instruction.as_deref(),
6701            Some("env-instruction"),
6702            "env > file"
6703        );
6704        assert_eq!(
6705            s.intelligence.model.as_deref(),
6706            Some("env-model"),
6707            "env alias > later file"
6708        );
6709        assert_eq!(s.limits.run.steps(), 30, "flag alias > env");
6710        assert_eq!(s.mcp.servers.len(), 1);
6711        assert_eq!(s.mcp.servers[0].name, "fs");
6712        assert_eq!(s.mcp.servers[0].tags["*"], vec!["sensitive"]);
6713        assert_eq!(
6714            s.intelligence.headers.get("x-team").map(String::as_str),
6715            Some("ops")
6716        );
6717        assert_eq!(l.files.len(), 2);
6718        // A derived path name beats the short alias for the same field.
6719        let env2: Vec<(String, String)> = vec![
6720            ("AGENT_MODEL".into(), "legacy".into()),
6721            ("AGENTD_INTELLIGENCE_MODEL".into(), "path".into()),
6722            ("AGENTD_INTELLIGENCE_ENDPOINTS".into(), "https://i".into()),
6723        ];
6724        let (l2, _) = load(
6725            &args(&["--instruction", "x", "--store.kind", "memory"]),
6726            &env2,
6727        )
6728        .unwrap();
6729        assert_eq!(l2.settings.intelligence.model.as_deref(), Some("path"));
6730    }
6731
6732    #[test]
6733    fn removed_flags_name_their_replacement() {
6734        for (flag, _) in REMOVED_FLAGS {
6735            let e = load(&args(&[flag, "x"]), &base_env()).unwrap_err();
6736            assert!(format!("{e}").contains("removed in agentd"), "{flag}: {e}");
6737        }
6738        let e = load(&args(&["--mode", "reactive"]), &base_env()).unwrap_err();
6739        assert!(format!("{e}").contains("start node"), "{e}");
6740    }
6741
6742    #[test]
6743    fn mixed_and_v1_files_are_refused_by_the_v2_loader() {
6744        let mixed = write_tmp("agent: {instruction: x}\nmodel: m\n", "yaml");
6745        let e = load(
6746            &args(&["--config", mixed.path().to_str().unwrap()]),
6747            &base_env(),
6748        )
6749        .unwrap_err();
6750        assert!(format!("{e}").contains("mixes legacy flat keys"), "{e}");
6751        let v1 = write_tmp("model: m\n", "yaml");
6752        let e = load(
6753            &args(&["--config", v1.path().to_str().unwrap()]),
6754            &base_env(),
6755        )
6756        .unwrap_err();
6757        assert!(format!("{e}").contains("retired flat schema"), "{e}");
6758    }
6759
6760    #[test]
6761    fn budget_exit_code_and_instruction_file_aliases() {
6762        let f = write_tmp("read me from a file", "txt");
6763        let (l, _) = load(
6764            &args(&[
6765                "--instruction-file",
6766                f.path().to_str().unwrap(),
6767                "--budget-exit-code",
6768                "9",
6769                "--store.kind",
6770                "memory",
6771            ]),
6772            &base_env(),
6773        )
6774        .unwrap();
6775        assert_eq!(
6776            l.settings.agent.instruction.as_deref(),
6777            Some("read me from a file")
6778        );
6779        assert_eq!(l.settings.lifecycle.exit_code_map.get("3"), Some(&9));
6780        assert_eq!(l.settings.lifecycle.exit_code_map.get("7"), Some(&9));
6781    }
6782
6783    // ---- validation -----------------------------------------------------------
6784
6785    fn load_doc(yaml: &str) -> Result<Loaded, ConfigError> {
6786        let f = write_tmp(yaml, "yaml");
6787        load(&args(&["--config", f.path().to_str().unwrap()]), &[]).map(|(l, _)| l)
6788    }
6789
6790    #[test]
6791    fn validation_collects_the_document_rules() {
6792        // A file with an inline credential is refused; the same value from env is fine.
6793        let e = load_doc(
6794            "config_version: \"1\"\nintelligence:\n  endpoints: [https://i]\n  token: sk-inline\n",
6795        )
6796        .unwrap_err();
6797        assert!(format!("{e}").contains("inline credential"), "{e}");
6798        let (l, _) = load(
6799            &args(&[
6800                "--intelligence",
6801                "https://i",
6802                "--intelligence-token",
6803                "sk-inline",
6804            ]),
6805            &[],
6806        )
6807        .unwrap();
6808        assert_eq!(
6809            l.settings.intelligence.token.as_ref().map(|s| s.0.as_str()),
6810            Some("sk-inline")
6811        );
6812        assert!(
6813            !format!("{:?}", l.settings).contains("sk-inline"),
6814            "Debug redacts"
6815        );
6816
6817        // Undeclared servers referenced by tools/store/knowledge/skills: the
6818        // startup path fast-fails on the first problem (exit 2)…
6819        let e = load_doc(
6820            "config_version: \"1\"\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",
6821        )
6822        .unwrap_err();
6823        assert!(matches!(e, ConfigError::Usage(_)), "{e}");
6824
6825        // --validate-config collects EVERYTHING.
6826        let f = write_tmp(
6827            "config_version: \"1\"\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",
6828            "yaml",
6829        );
6830        let e = load(
6831            &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
6832            &[],
6833        )
6834        .unwrap_err();
6835        let ConfigError::Validate(Err(lines)) = e else {
6836            panic!("expected a validate verdict, got {e:?}")
6837        };
6838        for needle in [
6839            "store.mcp.server 'nope'",
6840            "knowledge.server 'kb'",
6841            "skills.sources[]",
6842            "tools.overrides['memory.get']",
6843            "both disabled and overridden",
6844            "only the policy codes 3 and 7",
6845            "0..=255",
6846        ] {
6847            assert!(lines.contains(needle), "missing {needle} in:\n{lines}");
6848        }
6849
6850        // A2A listener rules.
6851        let e = load_doc("config_version: \"1\"\nstore: {kind: memory}\na2a: {listen: \"https://0.0.0.0:8443\"}\n").unwrap_err();
6852        assert!(format!("{e}").contains("a2a.tls.cert"), "{e}");
6853        let e = load_doc("config_version: \"1\"\nstore: {kind: memory}\na2a: {listen: \"http://0.0.0.0:8080\"}\n").unwrap_err();
6854        assert!(format!("{e}").contains("loopback"), "{e}");
6855        // Principals: `any` cannot be operator.
6856        let e = load_doc(
6857            "config_version: \"1\"\na2a: {principals: [{match: {any: true}, role: operator}]}\n",
6858        )
6859        .unwrap_err();
6860        assert!(format!("{e}").contains("operator role"), "{e}");
6861        // Budget rules.
6862        let e = load_doc("config_version: \"1\"\nintelligence: {budget: {windows: [{per: hour}], on_exhausted: degrade}}\n").unwrap_err();
6863        assert!(format!("{e}").contains("tokens and/or requests"), "{e}");
6864        // Trifecta over the root grant.
6865        let e = load_doc(
6866            "config_version: \"1\"\nmcp:\n  servers:\n    - {name: fs, endpoint: https://fs/mcp, tags: {\"*\": [untrusted_input, sensitive, egress]}}\n",
6867        )
6868        .unwrap_err();
6869        assert!(format!("{e}").contains("lethal-trifecta"), "{e}");
6870    }
6871
6872    #[test]
6873    fn restart_only_diff_names_changed_paths() {
6874        let a = json!({"agent": {"name": "x", "instruction": "i"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
6875        let b = json!({"agent": {"name": "y", "instruction": "j"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
6876        assert_eq!(restart_only_diff(&a, &b), vec!["agent.name".to_string()]);
6877        let c = json!({"agent": {"name": "x", "instruction": "changed"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
6878        assert!(
6879            restart_only_diff(&a, &c).is_empty(),
6880            "instruction is reloadable"
6881        );
6882    }
6883
6884    #[test]
6885    fn duration_and_tool_select_scalars() {
6886        let s = Settings::from_document(
6887            json!({"limits": {"run": {"deadline": "90s"}, "step_timeout": 5}, "agent": {"tools": {"mcp": "none", "internal": ["memory.get"]}}}),
6888            "t",
6889        )
6890        .unwrap();
6891        assert_eq!(s.limits.run.deadline(), Duration::from_secs(90));
6892        assert_eq!(s.limits.step_timeout, Some(Dur(Duration::from_secs(5))));
6893        assert!(!s.agent.tools.mcp.allows("fs.read"));
6894        assert!(s.agent.tools.internal.allows("memory.get"));
6895        assert!(!s.agent.tools.internal.allows("finish"));
6896        assert!(s.agent.tools.code.allows("anything"));
6897        assert!(
6898            Settings::from_document(json!({"limits": {"run": {"deadline": "soon"}}}), "t").is_err()
6899        );
6900    }
6901
6902    #[test]
6903    fn instruction_config_directives_define_the_agent_and_explicit_keys_win() {
6904        let instr = ":::config\nlimits: {max_runs: 9}\n:::\n\
6905                     :::stream{name=orders}\nretention: {max_events: 50}\n:::\n\
6906                     :::mcp{name=fs}\nendpoint: \"https://fs.internal/mcp\"\nexclude: [\"delete_*\"]\n:::\n\
6907                     Do the work.";
6908        let s = Settings::from_document(
6909            json!({"agent": {"instruction": instr}, "limits": {"max_runs": 3}}),
6910            "t",
6911        )
6912        .unwrap();
6913        assert_eq!(
6914            s.limits.max_runs,
6915            Some(3),
6916            "an explicit key beats the fragment"
6917        );
6918        assert_eq!(
6919            s.streams.get("orders").map(|c| c.max_events()),
6920            Some(50),
6921            "the fragment fills what the config left unsaid"
6922        );
6923        let srv = s
6924            .mcp
6925            .servers
6926            .iter()
6927            .find(|m| m.name == "fs")
6928            .expect("declared");
6929        assert_eq!(srv.endpoint, "https://fs.internal/mcp");
6930        assert_eq!(srv.exclude, vec!["delete_*"]);
6931        let cleaned = s.agent.instruction.as_deref().unwrap();
6932        assert!(cleaned.contains("Do the work."));
6933        assert!(
6934            !cleaned.contains("endpoint"),
6935            "machinery never reaches the model"
6936        );
6937        // A fragment with a bogus section is refused by the SAME deserializer
6938        // that guards the config file — no parallel, laxer path.
6939        assert!(
6940            Settings::from_document(
6941                json!({"agent": {"instruction": ":::config\nnot_a_section: 1\n:::\nx"}}),
6942                "t"
6943            )
6944            .is_err()
6945        );
6946    }
6947
6948    // ---- the file store ------------------------------------------------------
6949
6950    #[test]
6951    fn file_store_root_walks_the_chain_in_order() {
6952        use std::ffi::OsString;
6953        use std::path::PathBuf;
6954        let env = |pairs: Vec<(&'static str, &'static str)>| {
6955            move |k: &str| -> Option<OsString> {
6956                pairs
6957                    .iter()
6958                    .find(|(n, _)| *n == k)
6959                    .map(|(_, v)| OsString::from(*v))
6960            }
6961        };
6962        let all = vec![
6963            ("AGENTD_STATE_DIR", "/state-dir"),
6964            ("XDG_STATE_HOME", "/xdg"),
6965            ("HOME", "/home/a"),
6966        ];
6967        let with_file = |path: Option<&str>| Store {
6968            file: Some(StoreFile {
6969                path: path.map(str::to_string),
6970                min_free: None,
6971            }),
6972            ..Store::default()
6973        };
6974
6975        // 1. store.file.path wins over every environment variable.
6976        assert_eq!(
6977            file_store_root_in(&with_file(Some("/var/lib/agentd")), &env(all.clone())),
6978            PathBuf::from("/var/lib/agentd")
6979        );
6980        // 2. $AGENTD_STATE_DIR is taken verbatim — an operator naming the
6981        //    directory does not get `agentd/state` appended to it.
6982        assert_eq!(
6983            file_store_root_in(&with_file(None), &env(all.clone())),
6984            PathBuf::from("/state-dir")
6985        );
6986        // 3. $XDG_STATE_HOME, with the agentd/state suffix (`creds` sibling).
6987        assert_eq!(
6988            file_store_root_in(&Store::default(), &env(all[1..].to_vec())),
6989            PathBuf::from("/xdg/agentd/state")
6990        );
6991        // 4. $HOME/.local/state/… — the XDG default spelled out.
6992        assert_eq!(
6993            file_store_root_in(&Store::default(), &env(all[2..].to_vec())),
6994            PathBuf::from("/home/a/.local/state/agentd/state")
6995        );
6996        // 5. Last resort: the OS temp dir (non-durable; the runtime says so).
6997        assert_eq!(
6998            file_store_root_in(&Store::default(), &env(vec![])),
6999            std::env::temp_dir().join("agentd").join("state")
7000        );
7001        // The chain is the credential cache's, one sibling over: same order,
7002        // same suffix shape, `state` where `creds` is.
7003        assert!(
7004            file_store_root_in(&Store::default(), &env(all[1..].to_vec()))
7005                .ends_with("agentd/state")
7006        );
7007    }
7008
7009    #[test]
7010    fn file_store_validation_diagnostics() {
7011        // `kind: file` needs no block at all.
7012        let l = load_doc("config_version: \"1\"\nstore: {kind: file}\n").unwrap();
7013        assert_eq!(l.settings.store.kind, StoreKind::File);
7014        assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
7015        // …and a long-lived instance is satisfied by it (no `store.kind is none`).
7016        let l = load_doc(
7017            "config_version: \"1\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\na2a: {listen: \"http://127.0.0.1:8080\"}\n",
7018        )
7019        .unwrap();
7020        assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
7021        assert_eq!(
7022            file_store_root(&l.settings.store),
7023            std::path::PathBuf::from("/var/lib/agentd")
7024        );
7025
7026        // An explicitly empty path would resolve to the working directory.
7027        let e = load_doc("config_version: \"1\"\nstore: {kind: file, file: {path: \"\"}}\n")
7028            .unwrap_err();
7029        assert!(format!("{e}").contains("store.file.path is empty"), "{e}");
7030
7031        // A block belonging to an adapter that is not selected is dead config:
7032        // a warning (it is ignored), not a refusal (it does no harm).
7033        let l = load_doc(
7034            "config_version: \"1\"\nstore: {kind: memory, file: {path: /var/lib/agentd}}\n",
7035        )
7036        .unwrap();
7037        let d = validate(&l);
7038        assert!(d.errors.is_empty(), "{:?}", d.errors);
7039        assert!(
7040            d.warnings
7041                .iter()
7042                .any(|w| w.contains("store.file is set but store.kind is memory")),
7043            "{:?}",
7044            d.warnings
7045        );
7046        // No warning when the file adapter IS the selected one.
7047        let l =
7048            load_doc("config_version: \"1\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\n")
7049                .unwrap();
7050        assert!(
7051            !validate(&l)
7052                .warnings
7053                .iter()
7054                .any(|w| w.contains("store.file")),
7055            "{:?}",
7056            validate(&l).warnings
7057        );
7058        // Changing the state directory under a running instance is restart-only.
7059        assert_eq!(
7060            restart_only_diff(
7061                &json!({"store": {"kind": "file", "file": {"path": "/a"}}}),
7062                &json!({"store": {"kind": "file", "file": {"path": "/b"}}})
7063            ),
7064            vec!["store.file".to_string()]
7065        );
7066    }
7067
7068    #[test]
7069    fn instruction_uri_detection() {
7070        assert!(looks_like_resource_uri("mcp://docs/agent-instruction"));
7071        assert!(looks_like_resource_uri("docs://agent"));
7072        assert!(!looks_like_resource_uri("You are a helpful agent."));
7073        assert!(!looks_like_resource_uri(
7074            "see https://x.example for details"
7075        ));
7076        assert!(!looks_like_resource_uri("://nope"));
7077    }
7078
7079    #[test]
7080    fn help_and_schema_asks_short_circuit_validation() {
7081        let (_, ask) = load(&args(&["--help"]), &[]).unwrap();
7082        assert_eq!(ask, Ask::Help);
7083        let (_, ask) = load(&args(&["--config-schema=1"]), &[]).unwrap();
7084        assert_eq!(ask, Ask::Schema);
7085        // `--workflow-schema` is a static, side-effect-free dump: it must resolve
7086        // even with no config file present (no intelligence endpoint, etc.).
7087        let (_, ask) = load(&args(&["--workflow-schema"]), &[]).unwrap();
7088        assert_eq!(ask, Ask::WorkflowSchema);
7089        assert!(help_section().contains("intelligence.model"));
7090    }
7091
7092    // ---- service catalog & egress policy -----------------------------------
7093
7094    const CATALOG: &str = "config_version: \"1\"\nstore: {kind: memory}\nservices:\n  billing:\n    endpoint: https://billing.example/mcp\n    auth: {kind: static, token: \"{{secret:BILLING}}\"}\n    headers: {X-Env: prod}\n    tags: {\"*\": [sensitive]}\n    allow: [charge_lookup, invoice_*]\n    exclude: [invoice_purge]\n  brain:\n    kind: intelligence\n    endpoint: https://intel.example/v1\n";
7095
7096    #[test]
7097    fn service_reference_inherits_and_narrows() {
7098        let f = write_tmp(
7099            &format!(
7100                "{CATALOG}mcp:\n  servers:\n    - {{name: money, service: billing, allow: [charge_lookup], ns: fin}}\n"
7101            ),
7102            "yaml",
7103        );
7104        let (loaded, _) = load(
7105            &args(&["--config", f.path().to_str().unwrap()]),
7106            &base_env(),
7107        )
7108        .unwrap();
7109        let s = &loaded.settings.mcp.servers[0];
7110        assert_eq!(s.endpoint, "https://billing.example/mcp", "inherited");
7111        assert!(s.auth.is_some(), "inherited auth");
7112        assert_eq!(s.headers["X-Env"], "prod", "inherited headers");
7113        assert_eq!(s.allow.as_deref(), Some(&["charge_lookup".to_string()][..]));
7114        assert_eq!(
7115            s.exclude,
7116            vec!["invoice_purge".to_string()],
7117            "exclude unions"
7118        );
7119        assert_eq!(s.tags["*"], vec!["sensitive"], "tag floor applied");
7120        assert_eq!(s.ns.as_deref(), Some("fin"), "consumer-local ns kept");
7121    }
7122
7123    #[test]
7124    fn service_reference_without_allow_inherits_the_ceiling() {
7125        let f = write_tmp(
7126            &format!("{CATALOG}mcp:\n  servers:\n    - {{name: money, service: billing}}\n"),
7127            "yaml",
7128        );
7129        let (loaded, _) = load(
7130            &args(&["--config", f.path().to_str().unwrap()]),
7131            &base_env(),
7132        )
7133        .unwrap();
7134        let s = &loaded.settings.mcp.servers[0];
7135        assert_eq!(
7136            s.allow.as_deref(),
7137            Some(&["charge_lookup".to_string(), "invoice_*".to_string()][..]),
7138            "absent consumer allow inherits the catalog ceiling"
7139        );
7140    }
7141
7142    #[test]
7143    fn service_reference_refuses_restated_connection_settings() {
7144        let f = write_tmp(
7145            &format!(
7146                "{CATALOG}mcp:\n  servers:\n    - {{name: money, service: billing, endpoint: \"https://other.example\"}}\n"
7147            ),
7148            "yaml",
7149        );
7150        let e = load(
7151            &args(&["--config", f.path().to_str().unwrap()]),
7152            &base_env(),
7153        )
7154        .unwrap_err();
7155        let msg = format!("{e}");
7156        assert!(msg.contains("restates `endpoint`"), "{msg}");
7157    }
7158
7159    #[test]
7160    fn service_allow_widening_is_refused() {
7161        let f = write_tmp(
7162            &format!(
7163                "{CATALOG}mcp:\n  servers:\n    - {{name: money, service: billing, allow: [refund_all]}}\n"
7164            ),
7165            "yaml",
7166        );
7167        let e = load(
7168            &args(&["--config", f.path().to_str().unwrap()]),
7169            &base_env(),
7170        )
7171        .unwrap_err();
7172        let msg = format!("{e}");
7173        assert!(msg.contains("widens the ceiling"), "{msg}");
7174    }
7175
7176    #[test]
7177    fn unknown_service_reference_is_refused() {
7178        let f = write_tmp(
7179            "config_version: \"1\"\nstore: {kind: memory}\nmcp:\n  servers:\n    - {name: x, service: nope}\n",
7180            "yaml",
7181        );
7182        let e = load(
7183            &args(&["--config", f.path().to_str().unwrap()]),
7184            &base_env(),
7185        )
7186        .unwrap_err();
7187        assert!(format!("{e}").contains("unknown service 'nope'"), "{e}");
7188    }
7189
7190    #[test]
7191    fn tag_floor_applies_to_inline_matching_servers() {
7192        // An INLINE server pointing under a catalogued endpoint gets the
7193        // entry's tags unioned in — under-tagging cannot launder a sensitive
7194        // endpoint past the trifecta gate.
7195        let f = write_tmp(
7196            &format!(
7197                "{CATALOG}mcp:\n  servers:\n    - {{name: sneaky, endpoint: \"https://billing.example/mcp/sub\"}}\n"
7198            ),
7199            "yaml",
7200        );
7201        let (loaded, _) = load(
7202            &args(&["--config", f.path().to_str().unwrap()]),
7203            &base_env(),
7204        )
7205        .unwrap();
7206        assert_eq!(
7207            loaded.settings.mcp.servers[0].tags["*"],
7208            vec!["sensitive"],
7209            "the catalog's tags are a floor for any matching endpoint"
7210        );
7211    }
7212
7213    #[test]
7214    fn egress_closed_refuses_uncatalogued_and_admits_catalogued() {
7215        let f = write_tmp(
7216            &format!(
7217                "{CATALOG}security: {{egress: closed}}\nmcp:\n  servers:\n    - {{name: rogue, endpoint: \"https://rogue.example/mcp\"}}\n"
7218            ),
7219            "yaml",
7220        );
7221        let e = load(
7222            &args(&["--config", f.path().to_str().unwrap()]),
7223            &base_env(),
7224        )
7225        .unwrap_err();
7226        let msg = format!("{e}");
7227        assert!(
7228            msg.contains("matches no `kind: mcp` services: catalog entry"),
7229            "{msg}"
7230        );
7231
7232        let ok = write_tmp(
7233            &format!(
7234                "{CATALOG}security: {{egress: closed}}\nmcp:\n  servers:\n    - {{name: money, service: billing}}\n"
7235            ),
7236            "yaml",
7237        );
7238        load(
7239            &args(&["--config", ok.path().to_str().unwrap()]),
7240            &base_env(),
7241        )
7242        .expect("a catalogued reference passes closed egress");
7243    }
7244
7245    #[test]
7246    fn ambiguous_catalog_endpoints_are_refused() {
7247        let f = write_tmp(
7248            "config_version: \"1\"\nstore: {kind: memory}\nservices:\n  a: {endpoint: \"https://s.example/mcp\"}\n  b: {endpoint: \"https://s.example/mcp/deeper\"}\n",
7249            "yaml",
7250        );
7251        let e = load(
7252            &args(&["--config", f.path().to_str().unwrap()]),
7253            &base_env(),
7254        )
7255        .unwrap_err();
7256        assert!(format!("{e}").contains("prefix-comparable"), "{e}");
7257    }
7258
7259    #[test]
7260    fn service_match_respects_segment_boundaries() {
7261        let mut services = BTreeMap::new();
7262        services.insert(
7263            "a".to_string(),
7264            Service {
7265                kind: ServiceKind::Mcp,
7266                endpoint: "https://s.example/api".into(),
7267                headers: BTreeMap::new(),
7268                tags: BTreeMap::new(),
7269                allow: None,
7270                exclude: Vec::new(),
7271                auth: None,
7272                rate: None,
7273                timeout: None,
7274                methods: None,
7275                breaker: None,
7276            },
7277        );
7278        let m = ServiceKind::Mcp;
7279        assert!(service_match(&services, m, "https://s.example/api").is_some());
7280        assert!(service_match(&services, m, "https://s.example/api/v2").is_some());
7281        assert!(
7282            service_match(&services, m, "https://s.example/apiary").is_none(),
7283            "prefix match is on segment boundaries, not string prefixes"
7284        );
7285        assert!(service_match(&services, m, "https://other.example/api").is_none());
7286        assert!(
7287            service_match(&services, m, "http://s.example/api").is_none(),
7288            "scheme must match"
7289        );
7290        assert!(
7291            service_match(&services, ServiceKind::Http, "https://s.example/api").is_none(),
7292            "matching is kind-filtered"
7293        );
7294    }
7295
7296    #[test]
7297    fn peer_references_resolve_and_all_four_kinds_gate_closed_egress() {
7298        // A `kind: peer` entry feeds a2a.peers[].service, and closed mode
7299        // covers intelligence endpoints, peers and http-step literals too.
7300        let f = write_tmp(
7301            "config_version: \"1\"\nstore: {kind: memory}\nsecurity: {egress: closed}\nservices:\n  brain: {kind: intelligence, endpoint: \"https://intel.example/v1\"}\n  buddy: {kind: peer, endpoint: \"https://peer.example\", auth: {kind: static, token: \"{{secret:PEER}}\"}}\n  hooks: {kind: http, endpoint: \"https://hooks.example\", methods: [POST]}\na2a:\n  peers:\n    - {name: pal, service: buddy}\nworkflows:\n  - name: w\n    steps:\n      s: {kind: once}\n      h: {kind: http, depends_on: [s], method: POST, url: \"https://hooks.example/x\"}\n      f: {kind: finish, depends_on: [h], status: completed}\n",
7302            "yaml",
7303        );
7304        let (loaded, _) = load(
7305            &args(&["--config", f.path().to_str().unwrap()]),
7306            &base_env(),
7307        )
7308        .expect("all surfaces catalogued ⇒ closed mode admits the config");
7309        let p = &loaded.settings.a2a.peers[0];
7310        assert_eq!(p.endpoint, "https://peer.example", "peer inherited");
7311        assert!(p.auth.is_some(), "peer inherited auth");
7312
7313        let bad = write_tmp(
7314            "config_version: \"1\"\nstore: {kind: memory}\nsecurity: {egress: closed}\na2a:\n  peers:\n    - {name: rogue, endpoint: \"https://rogue.example\"}\n",
7315            "yaml",
7316        );
7317        let e = load(
7318            &args(&["--config", bad.path().to_str().unwrap()]),
7319            &base_env(),
7320        )
7321        .unwrap_err();
7322        assert!(
7323            format!("{e}").contains("kind: peer"),
7324            "an uncatalogued peer is refused naming the kind: {e}"
7325        );
7326
7327        let badi = write_tmp(
7328            "config_version: \"1\"\nstore: {kind: memory}\nsecurity: {egress: closed}\nintelligence: {endpoints: \"https://rogue-intel.example/v1\"}\n",
7329            "yaml",
7330        );
7331        let e = load(&args(&["--config", badi.path().to_str().unwrap()]), &[]).unwrap_err();
7332        assert!(
7333            format!("{e}").contains("kind: intelligence"),
7334            "an uncatalogued intelligence endpoint is refused: {e}"
7335        );
7336    }
7337
7338    #[test]
7339    fn kind_specific_entry_fields_are_validated() {
7340        let f = write_tmp(
7341            "config_version: \"1\"\nstore: {kind: memory}\nservices:\n  x: {kind: http, endpoint: \"https://x.example\", tags: {\"*\": [egress]}}\n  y: {kind: mcp, endpoint: \"https://y.example\", methods: [GET]}\n",
7342            "yaml",
7343        );
7344        let e = load(
7345            &args(&["--config", f.path().to_str().unwrap()]),
7346            &base_env(),
7347        )
7348        .unwrap_err();
7349        let msg = format!("{e}");
7350        assert!(msg.contains("`tags` applies to `kind: mcp`"), "{msg}");
7351        assert!(msg.contains("`methods` applies to `kind: http`"), "{msg}");
7352    }
7353
7354    /// The message hop cap is a fail-closed gate, so it has to bind on a
7355    /// config that never mentions it. An operator who has not heard of
7356    /// `max_message_depth` is exactly the one a runaway chain would surprise.
7357    #[test]
7358    fn the_message_hop_cap_binds_without_being_configured() {
7359        let l = Limits::default();
7360        assert_eq!(l.max_message_depth, None, "unset by default");
7361        assert_eq!(l.message_depth(), DEFAULT_MESSAGE_DEPTH);
7362        assert!(
7363            l.message_depth() > 0,
7364            "a cap of 0 would refuse every message"
7365        );
7366        // An explicit setting still wins.
7367        let tuned = Limits {
7368            max_message_depth: Some(2),
7369            ..Default::default()
7370        };
7371        assert_eq!(tuned.message_depth(), 2);
7372    }
7373
7374    #[test]
7375    fn pattern_subsumption_covers_the_glob_grammar() {
7376        assert!(pattern_subsumes("charge_lookup", "charge_lookup"));
7377        assert!(pattern_subsumes("charge_lookup", "charge_*"));
7378        assert!(pattern_subsumes("charge_*", "charge_*"));
7379        assert!(pattern_subsumes("charge_x_*", "charge_*"));
7380        assert!(!pattern_subsumes("charge_*", "charge_lookup"));
7381        assert!(!pattern_subsumes("refund_all", "charge_*"));
7382        assert!(pattern_subsumes("anything", "*"));
7383    }
7384}