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.tls.client_ca, a2a.bearer, and/or interface.pairing".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    "observability.otel",
5757    "observability.metrics_addr",
5758    "observability.health_file",
5759    "observability.events_ring",
5760    "observability.traceparent",
5761    "security",
5762];
5763
5764/// The restart-only paths whose values differ between two effective documents.
5765pub fn restart_only_diff(running: &Value, candidate: &Value) -> Vec<String> {
5766    RESTART_ONLY_PATHS
5767        .iter()
5768        .filter(|p| {
5769            let ptr = format!("/{}", p.replace('.', "/"));
5770            running.pointer(&ptr) != candidate.pointer(&ptr)
5771        })
5772        .map(|p| (*p).to_string())
5773        .collect()
5774}
5775
5776/// The `--help` section for the v2 paths.
5777pub fn help_section() -> String {
5778    paths::help_section_in(&paths::bindings_of(&schema::schema()))
5779}
5780
5781/// The v2 `--help` text: usage, the alias flags, the removed flags, and every
5782/// config path (flag · env).
5783pub fn help_text() -> String {
5784    let mut out = format!(
5785        "agentd {ver} — a durable, workflow-driven agent (config schema v2)\n\
5786         \n\
5787         USAGE:\n\
5788         \x20 agentd --config <settings.yaml> [--config <overlay.yaml> …] [--<path> <value> …]\n\
5789         \x20 agentd --prompt <TEXT> --intelligence <URL>                    # one-shot: ask, answer, exit\n\
5790         \x20 agentd --instruction <TEXT> --intelligence <URL> [--mcp name=endpoint …]   # one-shot sugar\n\
5791         \x20 agentd tui|ui --config <settings.yaml> [--<path> <value> …]   # + a display client\n\
5792         \n\
5793         Every setting is a document path (YAML/JSON file, AGENTD_<PATH> env, --<path> flag);\n\
5794         several files merge in order (later wins). Precedence: built-in < files < env < flags.\n\
5795         \n\
5796         ALIASES (short spellings of paths):\n",
5797        ver = crate::VERSION
5798    );
5799    for a in ALIASES {
5800        let shape = match a.kind {
5801            AliasKind::Set | AliasKind::SetFromFile => "<value>",
5802            AliasKind::SetTrue => "",
5803            AliasKind::Append => "<value>  (adds one)",
5804            AliasKind::Special => "<value>",
5805        };
5806        out.push_str(&format!("  {:<32} {} → {}\n", a.flag, shape, a.path));
5807    }
5808    out.push_str(
5809        "\nSUBCOMMANDS (run the daemon with a display client attached):\n\
5810         \x20 tui                        + the terminal UI (fullscreen; --inline for in-place)\n\
5811         \x20 ui                         + the web UI, opened in a browser\n\
5812         \x20                            both need `interface.enabled: true`, which the\n\
5813         \x20                            subcommand sets for you; the client exits with the daemon.\n\
5814         \x20                            Detached instead: run `agentd -c …`, then `agentd-tui\n\
5815         \x20                            --endpoint <url>` (npm i -g @agentd-dev/cli).\n\
5816         \nCONTROL:\n\
5817         \x20 -c, --config <PATH>        a settings file (repeatable; `=` form too; or AGENT_CONFIG=a.yaml:b.yaml)\n\
5818         \x20 --validate-config          load+validate everything, print the verdict, exit 0/2\n\
5819         \x20 --config-schema            print the settings JSON Schema and exit\n\
5820         \x20 --context-template        print the built-in system-prompt template and exit\n\
5821     \x20 --workflow-schema          print the workflow JSON Schema + node registry and exit\n\
5822         \x20 --capabilities             print the capabilities manifest and exit\n\
5823         \x20 --login <target>           complete an OAuth device-login for an endpoint (e.g. mcp:<name>) and cache the token\n\
5824         \x20 --logout <target>          evict a cached credential\n\
5825         \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\
5826         \x20 --env <FILE>               load a dotenv file into this process's environment (repeatable; real env wins, later files win)\n\
5827         \x20 -h, --help / -V, --version\n\
5828         \nREMOVED FLAGS:\n",
5829    );
5830    for (flag, hint) in REMOVED_FLAGS {
5831        out.push_str(&format!("  {flag:<32} {hint}\n"));
5832    }
5833    out.push('\n');
5834    out.push_str(&help_section());
5835    out
5836}
5837
5838#[cfg(test)]
5839mod tests {
5840    use super::*;
5841    use std::io::Write;
5842
5843    fn args(v: &[&str]) -> Vec<String> {
5844        v.iter().map(|s| s.to_string()).collect()
5845    }
5846
5847    fn write_tmp(contents: &str, ext: &str) -> tempfile::NamedTempFile {
5848        let mut f = tempfile::Builder::new()
5849            .suffix(&format!(".{ext}"))
5850            .tempfile()
5851            .unwrap();
5852        f.write_all(contents.as_bytes()).unwrap();
5853        f.flush().unwrap();
5854        f
5855    }
5856
5857    /// The environment a test config loads under.
5858    ///
5859    /// Also seeds the credential the shared `CATALOG` fixture references.
5860    /// Validation now runs the SAME whole-document reference scan startup runs,
5861    /// so a fixture naming `{{secret:BILLING}}` is refused unless that secret
5862    /// resolves — which is the point of the change, and makes these fixtures
5863    /// behave like a real deployment. Seeded through the prompted-values store
5864    /// rather than `std::env::set_var`: it is additive and never removes, so
5865    /// tests running in parallel cannot unset each other's secrets.
5866    fn base_env() -> Vec<(String, String)> {
5867        // Only the two the loaded fixtures reference. `SUB_WINDOW_TEST_UNSET`
5868        // is deliberately NOT seeded — a test asserts the missing case, and
5869        // seeding it would quietly delete that coverage.
5870        for name in ["BILLING", "PEER"] {
5871            crate::sec::secret::set_prompted(name, "test-value".into());
5872        }
5873        vec![(
5874            "AGENTD_INTELLIGENCE_ENDPOINTS".into(),
5875            "https://intel.example/v1".into(),
5876        )]
5877    }
5878
5879    // ---- schema ↔ struct agreement -----------------------------------------
5880
5881    /// serde's `deny_unknown_fields` error names the expected fields; that
5882    /// list IS the struct's field set — compare it with the schema properties
5883    /// at every object, so neither can diverge from the other unnoticed.
5884    fn struct_fields_at(doc_path: &str) -> Vec<String> {
5885        // Build a document that is empty except for a probe key at `doc_path`.
5886        let mut probe = Value::Object(Map::new());
5887        let path = if doc_path.is_empty() {
5888            "__probe__".to_string()
5889        } else {
5890            format!("{doc_path}.__probe__")
5891        };
5892        paths::set_path(&mut probe, &path, json!(1));
5893        let err = Settings::from_document(probe, "t").expect_err("probe must be rejected");
5894        // "… unknown field `__probe__`, expected one of `a`, `b`, `c` …" (or
5895        // "expected `a` or `b`" for two, "expected `a`" for one).
5896        let after = err.split("expected").nth(1).unwrap_or("");
5897        let mut out: Vec<String> = after
5898            .split('`')
5899            .skip(1)
5900            .step_by(2)
5901            .map(str::to_string)
5902            .collect();
5903        out.sort();
5904        out
5905    }
5906
5907    fn schema_props_at(schema: &Value, doc_path: &str) -> Vec<String> {
5908        let mut node = schema.clone();
5909        let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
5910        for seg in doc_path.split('.').filter(|s| !s.is_empty()) {
5911            let props = node.get("properties").cloned().unwrap_or(Value::Null);
5912            node = props.get(seg).cloned().unwrap_or(Value::Null);
5913            if let Some(r) = node.get("$ref").and_then(Value::as_str)
5914                && let Some(name) = r.strip_prefix("#/$defs/")
5915            {
5916                node = defs.get(name).cloned().unwrap_or(Value::Null);
5917            }
5918        }
5919        let mut out: Vec<String> = node
5920            .get("properties")
5921            .and_then(Value::as_object)
5922            .map(|m| m.keys().cloned().collect())
5923            .unwrap_or_default();
5924        out.sort();
5925        out
5926    }
5927
5928    #[test]
5929    fn schema_matches_struct_at_every_object() {
5930        let schema = schema::schema();
5931        for path in [
5932            "",
5933            "agent",
5934            "agent.tools",
5935            "intelligence",
5936            "intelligence.auth",
5937            "intelligence.budget",
5938            "intelligence.budget.slow",
5939            "intelligence.budget.degrade",
5940            "intelligence.budget.reserve",
5941            "mcp",
5942            "tools",
5943            "store",
5944            "store.checkpoint",
5945            "store.durability",
5946            "memory",
5947            "context",
5948            "context.plan",
5949            "context.summarize",
5950            "knowledge",
5951            "knowledge.auto_context",
5952            "search",
5953            "skills",
5954            "limits",
5955            "limits.run",
5956            "limits.subagents",
5957            "limits.subagents.instances",
5958            "subagents",
5959            "subagents.defaults",
5960            "lifecycle",
5961            "a2a",
5962            "a2a.tls",
5963            "observability",
5964            "observability.otel",
5965            "observability.audit",
5966            "security",
5967            "security.cgroup",
5968            "security.exec",
5969        ] {
5970            let s = schema_props_at(&schema, path);
5971            let f = struct_fields_at(path);
5972            assert_eq!(s, f, "schema/struct drift at `{path}`");
5973        }
5974    }
5975
5976    #[test]
5977    fn every_schema_path_deserializes_a_sample() {
5978        // Every binding, given a kind-appropriate sample, must be accepted by
5979        // the typed Settings (proves the schema names real fields with the
5980        // right shapes — the paths mechanism depends on it).
5981        for b in paths::bindings_of(&schema::schema()) {
5982            let sample = match &b.kind {
5983                paths::Kind::String => match b.path.as_str() {
5984                    "config_version" => json!("2"),
5985                    _ => json!("x"),
5986                },
5987                paths::Kind::Integer => json!(1),
5988                paths::Kind::Number => json!(0.5),
5989                paths::Kind::Boolean => json!(true),
5990                paths::Kind::Enum(vs) => json!(vs[0]),
5991                paths::Kind::Array(item) => match (**item).clone() {
5992                    paths::Kind::Object => match b.path.as_str() {
5993                        "mcp.servers" => {
5994                            json!([{"name": "a", "endpoint": "https://a.example/mcp"}])
5995                        }
5996                        "workflows" => json!([{"name": "w", "steps": {}}]),
5997                        "a2a.principals" => json!([{"match": {"any": true}, "role": "user"}]),
5998                        "a2a.peers" => json!([{"name": "p", "endpoint": "https://p.example"}]),
5999                        "skills.sources" => json!([{"server": "s"}]),
6000                        "security.policies" => {
6001                            json!([{"match": {"tool": "fs.*"}, "action": "deny"}])
6002                        }
6003                        "intelligence.budget.windows" | "agent.conversation_budget.windows" => {
6004                            json!([{"per": "hour", "tokens": 1}])
6005                        }
6006                        other => panic!("no sample for object list {other}"),
6007                    },
6008                    paths::Kind::Enum(vs) => json!([vs[0]]),
6009                    _ => json!(["s"]),
6010                },
6011                paths::Kind::Object => match b.path.as_str() {
6012                    "intelligence.pricing" => json!({"m": {"input_per_1k": 1.0}}),
6013                    "intelligence.models" => json!({"small": {"model": "m-1"}}),
6014                    "tools.overrides" => json!({"memory.get": {"server": "s", "tool": "t"}}),
6015                    "store.mcp" => json!({"server": "s"}),
6016                    "streams" => json!({"orders": {"retention": {"max_events": 1}}}),
6017                    "services" => json!({"billing": {"endpoint": "https://b.example/mcp"}}),
6018                    "subagents.templates" => json!({"t": {"instruction": "do the thing"}}),
6019                    "subagents.defaults.limits" => json!({"max_tokens": 1000}),
6020                    "store.http" => json!({"base_url": "https://s"}),
6021                    "security.aauth" => json!({"provider": "https://apd"}),
6022                    "lifecycle.exit_code_map" => json!({"3": 0}),
6023                    _ => json!({"k": "v"}),
6024                },
6025                paths::Kind::Any => match b.path.as_str() {
6026                    "intelligence.endpoints" => json!("https://a,https://b"),
6027                    "goal.on_achieved" | "goal.on_stuck" => json!("finish"),
6028                    p if p.ends_with("timeout")
6029                        || p.ends_with("deadline")
6030                        || p.ends_with("_grace")
6031                        || p.ends_with("ttl")
6032                        || p.ends_with("every") =>
6033                    {
6034                        json!("10s")
6035                    }
6036                    p if p.starts_with("agent.tools.") => json!("all"),
6037                    _ => json!("x"),
6038                },
6039            };
6040            let mut doc = Value::Object(Map::new());
6041            paths::set_path(&mut doc, &b.path, sample);
6042            fill_required(&mut doc, &schema::schema(), &b.path);
6043            Settings::from_document(doc, "t")
6044                .unwrap_or_else(|e| panic!("path {} does not deserialize: {e}", b.path));
6045        }
6046    }
6047
6048    /// Along `path`, every schema object with `required` gets its required
6049    /// properties filled with a sample (so a lone leaf under `store.mcp` still
6050    /// types — the runtime validation reports the missing siblings instead).
6051    fn fill_required(doc: &mut Value, schema: &Value, path: &str) {
6052        let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
6053        let resolve = |v: &Value| -> Value {
6054            match v
6055                .get("$ref")
6056                .and_then(Value::as_str)
6057                .and_then(|r| r.strip_prefix("#/$defs/"))
6058            {
6059                Some(name) => defs.get(name).cloned().unwrap_or(Value::Null),
6060                None => v.clone(),
6061            }
6062        };
6063        let mut node = schema.clone();
6064        let mut prefix = String::new();
6065        let segs: Vec<&str> = path.split('.').collect();
6066        for (i, seg) in segs.iter().enumerate() {
6067            let props = node.get("properties").cloned().unwrap_or(Value::Null);
6068            node = resolve(&props.get(*seg).cloned().unwrap_or(Value::Null));
6069            prefix = if prefix.is_empty() {
6070                (*seg).to_string()
6071            } else {
6072                format!("{prefix}.{seg}")
6073            };
6074            if i + 1 == segs.len() {
6075                break;
6076            }
6077            if let Some(req) = node.get("required").and_then(Value::as_array) {
6078                let props = node.get("properties").cloned().unwrap_or(Value::Null);
6079                for r in req.iter().filter_map(Value::as_str) {
6080                    let p = format!("{prefix}.{r}");
6081                    if doc.pointer(&format!("/{}", p.replace('.', "/"))).is_none() {
6082                        // Honor an enum-typed required field (e.g. `auth.kind`) so
6083                        // the filled sample is a valid variant, not `"x"`.
6084                        let sample = match props
6085                            .get(r)
6086                            .and_then(|f| f.get("enum"))
6087                            .and_then(Value::as_array)
6088                            .filter(|a| !a.is_empty())
6089                        {
6090                            Some(vs) => vs[0].clone(),
6091                            None => match r {
6092                                "provider" | "base_url" | "url" => json!("https://x.example"),
6093                                _ => json!("x"),
6094                            },
6095                        };
6096                        paths::set_path(doc, &p, sample);
6097                    }
6098                }
6099            }
6100        }
6101    }
6102
6103    #[test]
6104    fn env_and_flag_names_derive_from_the_v2_paths() {
6105        let bs = paths::bindings_of(&schema::schema());
6106        let model = bs.iter().find(|b| b.path == "intelligence.model").unwrap();
6107        assert_eq!(model.env_names()[0], "AGENTD_INTELLIGENCE_MODEL");
6108        assert_eq!(model.env_names()[2], "INTELLIGENCE_MODEL");
6109        assert_eq!(model.flag(), "--intelligence-model");
6110        let steps = bs.iter().find(|b| b.path == "limits.run.steps").unwrap();
6111        assert_eq!(steps.env_names()[0], "AGENTD_LIMITS_RUN_STEPS");
6112        // Uniqueness of the derived names across the whole v2 schema.
6113        let mut seen = std::collections::HashSet::new();
6114        for b in &bs {
6115            assert!(seen.insert(b.flag()), "duplicate flag {}", b.flag());
6116        }
6117    }
6118
6119    // ---- detection ------------------------------------------------------------
6120
6121    #[test]
6122    fn detects_v1_v2_mixed_and_empty() {
6123        assert_eq!(detect(&json!({})), Detected::Empty);
6124        assert_eq!(detect(&json!({"model": "m"})), Detected::V1);
6125        assert_eq!(detect(&json!({"config_version": "1"})), Detected::V2);
6126        assert_eq!(
6127            detect(&json!({"agent": {"instruction": "x"}})),
6128            Detected::V2
6129        );
6130        assert_eq!(detect(&json!({"agent": {}, "model": "m"})), Detected::Mixed);
6131        assert_eq!(
6132            detect(&json!({"config_version": "1.0", "model": "m"})),
6133            Detected::V1
6134        );
6135        // `limits` is neutral; `intelligence` decides by shape.
6136        assert_eq!(
6137            detect(&json!({"model": "m", "limits": {"max_steps": 1}})),
6138            Detected::V1
6139        );
6140        assert_eq!(
6141            detect(&json!({"intelligence": "https://x", "limits": {}})),
6142            Detected::V1
6143        );
6144        assert_eq!(
6145            detect(&json!({"intelligence": {"model": "m"}, "limits": {}})),
6146            Detected::V2
6147        );
6148        assert_eq!(detect(&json!({"limits": {"max_steps": 1}})), Detected::V1);
6149    }
6150
6151    // ---- load: layering, aliases, sugar --------------------------------------
6152
6153    #[cfg(feature = "exec")]
6154    #[test]
6155    fn enabling_exec_next_to_untrusted_input_assembles_the_trifecta() {
6156        // `exec` is tagged sensitive+egress in the tool registry — but the
6157        // registry is built AFTER validation, so for a long time those tags
6158        // never reached the check and this config started happily. It is the
6159        // whole lethal trifecta: untrusted input, sensitive powers, an egress
6160        // path.
6161        let cfg = "config_version: \"1\"\nstore: {kind: memory}\n\
6162                   mcp:\n  servers:\n    - name: web\n      endpoint: https://mcp-web.internal/mcp\n      tags: {\"*\": [untrusted_input]}\n\
6163                   security:\n  exec: {enabled: true, workdir: /tmp, allow: [git]}\n";
6164        let f = write_tmp(cfg, "yaml");
6165        let e = load(
6166            &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
6167            &base_env(),
6168        )
6169        .unwrap_err();
6170        assert!(format!("{e}").contains("lethal-trifecta refused"), "{e}");
6171
6172        // The documented override still lets an operator take the risk.
6173        load(
6174            &args(&[
6175                "--config",
6176                f.path().to_str().unwrap(),
6177                "--validate-config",
6178                "--allow-trifecta",
6179            ]),
6180            &base_env(),
6181        )
6182        .expect("--allow-trifecta is the escape hatch");
6183
6184        // exec WITHOUT an untrusted-input source is only two legs: still fine.
6185        let alone = write_tmp(
6186            "config_version: \"1\"\nstore: {kind: memory}\n\
6187             security:\n  exec: {enabled: true, workdir: /tmp, allow: [git]}\n",
6188            "yaml",
6189        );
6190        load(
6191            &args(&[
6192                "--config",
6193                alone.path().to_str().unwrap(),
6194                "--validate-config",
6195            ]),
6196            &base_env(),
6197        )
6198        .expect("two legs are not the trifecta");
6199    }
6200
6201    #[test]
6202    fn validate_config_catches_workflow_body_errors_the_runtime_would_refuse() {
6203        // The pre-flight check must not pass a config that then exits 2 on
6204        // the first real start: a validator that accepts what startup refuses
6205        // is worse than no validator, because it certifies the broken config.
6206        let f = write_tmp(
6207            "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",
6208            "yaml",
6209        );
6210        let e = load(
6211            &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
6212            &base_env(),
6213        )
6214        .unwrap_err();
6215        let msg = format!("{e}");
6216        assert!(msg.contains("unknown field"), "{msg}");
6217        assert!(msg.contains("prompt"), "{msg}");
6218        assert!(
6219            msg.contains("instruction"),
6220            "names the allowed fields: {msg}"
6221        );
6222
6223        // The same workflow, spelled correctly, still validates.
6224        let ok = write_tmp(
6225            "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",
6226            "yaml",
6227        );
6228        load(
6229            &args(&["--config", ok.path().to_str().unwrap(), "--validate-config"]),
6230            &base_env(),
6231        )
6232        .expect("a correct workflow validates");
6233    }
6234
6235    #[test]
6236    fn a_prompt_is_a_message_not_a_sugar_workflow() {
6237        // A prompt is delivered into the agent's ROOT context at startup, so
6238        // it authors no workflow — that is what gives it root-scoped tools and
6239        // lets it set the instance up (workflow.create) rather than only
6240        // answering a canned step.
6241        let (l, ask) = load(&args(&["--prompt", "do the thing"]), &base_env()).unwrap();
6242        assert_eq!(ask, Ask::Run);
6243        assert_eq!(l.settings.agent.prompt.as_deref(), Some("do the thing"));
6244        assert!(
6245            l.settings.workflows.is_empty(),
6246            "a prompt needs no workflow: {:?}",
6247            l.settings.workflows
6248        );
6249
6250        // An instruction alone still gets the one-shot sugar workflow…
6251        let (only_instr, _) = load(&args(&["--instruction", "be terse"]), &base_env()).unwrap();
6252        assert_eq!(only_instr.settings.workflows.len(), 1);
6253
6254        // …but a prompt alongside it means the prompt is the job: the
6255        // instruction stays standing policy, and no step is synthesized.
6256        let (both, _) = load(
6257            &args(&["--prompt", "do the thing", "--instruction", "be terse"]),
6258            &base_env(),
6259        )
6260        .unwrap();
6261        assert!(both.settings.workflows.is_empty());
6262        assert_eq!(both.settings.agent.instruction.as_deref(), Some("be terse"));
6263
6264        // The env spelling works too (12-factor).
6265        let mut env = base_env();
6266        env.push(("AGENTD_AGENT_PROMPT".into(), "from env".into()));
6267        let (from_env, _) = load(&args(&[]), &env).unwrap();
6268        assert_eq!(from_env.settings.agent.prompt.as_deref(), Some("from env"));
6269    }
6270
6271    #[test]
6272    fn minimal_instruction_run_gets_the_sugar_workflow() {
6273        let (l, ask) = load(&args(&["--instruction", "do it"]), &base_env()).unwrap();
6274        assert_eq!(ask, Ask::Run);
6275        assert_eq!(l.settings.agent.instruction.as_deref(), Some("do it"));
6276        assert_eq!(
6277            l.settings.intelligence.endpoints,
6278            vec!["https://intel.example/v1"]
6279        );
6280        assert_eq!(l.settings.workflows.len(), 1, "sugar workflow synthesized");
6281        assert_eq!(l.settings.workflows[0]["name"], json!("main"));
6282        assert_eq!(
6283            l.settings.workflows[0]["steps"]["start"]["kind"],
6284            json!("once")
6285        );
6286        // A one-shot job may run without a store — with a warning.
6287        assert!(
6288            l.warnings.iter().any(|w| w.contains("not durable")),
6289            "{:?}",
6290            l.warnings
6291        );
6292    }
6293
6294    #[test]
6295    fn a_long_lived_instance_defaults_to_the_file_store_but_an_explicit_none_is_refused() {
6296        // An A2A listener makes the instance long-lived ⇒ the file store.
6297        let (l, _) = load(
6298            &args(&[
6299                "--instruction",
6300                "x",
6301                "--a2a.listen",
6302                "http://127.0.0.1:8443",
6303            ]),
6304            &base_env(),
6305        )
6306        .unwrap();
6307        assert_eq!(l.settings.store.kind, StoreKind::File);
6308        // A long-lived start node ⇒ the same default.
6309        let f = write_tmp(
6310            "config_version: \"1\"\nworkflows:\n  - name: w\n    steps:\n      s: {kind: schedule, cron: \"* * * * *\"}\n      f: {kind: finish, depends_on: [s], status: completed}\n",
6311            "yaml",
6312        );
6313        let (l, _) = load(
6314            &args(&["--config", f.path().to_str().unwrap()]),
6315            &base_env(),
6316        )
6317        .unwrap();
6318        assert_eq!(l.settings.store.kind, StoreKind::File);
6319        // …but a STATED `none` is still refused: the default fills a silence,
6320        // it does not overrule an operator.
6321        let e = load(
6322            &args(&[
6323                "--config",
6324                f.path().to_str().unwrap(),
6325                "--store.kind",
6326                "none",
6327            ]),
6328            &base_env(),
6329        )
6330        .unwrap_err();
6331        assert!(format!("{e}").contains("long-lived"), "{e}");
6332        // A one-shot job keeps `none` — the default deliberately does not move
6333        // for the shape that can simply be re-run.
6334        let (l, _) = load(&args(&["--instruction", "x"]), &base_env()).unwrap();
6335        assert_eq!(l.settings.store.kind, StoreKind::None);
6336        // memory is accepted (with a warning).
6337        let (l, _) = load(
6338            &args(&["--instruction", "x", "--store.kind", "memory"]),
6339            &base_env(),
6340        )
6341        .unwrap();
6342        assert!(
6343            l.warnings.iter().any(|w| w.contains("memory")),
6344            "{:?}",
6345            l.warnings
6346        );
6347    }
6348
6349    // ---- env substitution: `${VAR}` / `${VAR:-default}` --------------------
6350
6351    #[test]
6352    fn expand_env_str_covers_the_forms() {
6353        let env: HashMap<&str, &str> = [("HOST", "db.internal"), ("PORT", "5432")]
6354            .into_iter()
6355            .collect();
6356        // A plain reference; multiple in one string.
6357        assert_eq!(
6358            expand_env_str("${HOST}:${PORT}", &env).unwrap(),
6359            "db.internal:5432"
6360        );
6361        // A default applies only when the variable is unset.
6362        assert_eq!(
6363            expand_env_str("${MISSING:-fallback}", &env).unwrap(),
6364            "fallback"
6365        );
6366        assert_eq!(
6367            expand_env_str("${HOST:-fallback}", &env).unwrap(),
6368            "db.internal"
6369        );
6370        // Braces are required: a bare `$VAR` and a lone `$` pass through.
6371        assert_eq!(
6372            expand_env_str("$HOST costs $5", &env).unwrap(),
6373            "$HOST costs $5"
6374        );
6375        // `$$` escapes to a literal `$` and does not open a reference.
6376        assert_eq!(expand_env_str("$${HOST}", &env).unwrap(), "${HOST}");
6377        // An unset variable with no default is a hard error (fail-closed).
6378        assert!(
6379            expand_env_str("${NOPE}", &env)
6380                .unwrap_err()
6381                .contains("NOPE")
6382        );
6383        // A malformed reference is rejected, not silently passed through.
6384        assert!(expand_env_str("${HOST", &env).is_err());
6385        assert!(expand_env_str("${bad-name}", &env).is_err());
6386    }
6387
6388    #[test]
6389    fn config_vars_fold_typed_values_and_collect_every_miss() {
6390        let file = write_tmp(
6391            "config_version: \"1\"\n\
6392             vars:\n  region: eu-1\n  port: 8443\n  team:\n    name: platform\n\
6393             agent:\n  name: \"svc-{{config.region}}\"\n  instruction: serve\n  preflight: never\n\
6394             intelligence:\n  endpoints: [https://x/v1]\n  model: m\n\
6395             store:\n  kind: memory\n\
6396             limits:\n  step_timeout: \"{{config.port}}s\"\n\
6397             workflows:\n  - name: w\n    steps:\n\
6398             \x20     s: {kind: once}\n\
6399             \x20     c: {kind: http, depends_on: [s], url: \"https://api.{{config.region}}.example\", headers: {x-team: \"{{config.team.name}}\"}}\n\
6400             \x20     f: {kind: finish, depends_on: [c]}\n",
6401            "yaml",
6402        );
6403        let (l, _) = load(
6404            &args(&["--config", file.path().to_str().unwrap()]),
6405            &base_env(),
6406        )
6407        .unwrap();
6408        // Embedded token: stringified into place, in config values…
6409        assert_eq!(l.settings.agent.name.as_deref(), Some("svc-eu-1"));
6410        // A folded scalar keeps its place in typed config too.
6411        assert_eq!(
6412            l.settings
6413                .limits
6414                .step_timeout
6415                .as_ref()
6416                .map(|d| d.0.as_secs()),
6417            Some(8443)
6418        );
6419        // Workflow docs are deliberately NOT folded here: `load_workflows`
6420        // folds them (so URL/file/dir sources get the identical treatment and
6421        // the definition hash pins the RESOLVED doc) — the e2e proves that leg.
6422        let wf = &l.settings.workflows[0];
6423        assert_eq!(
6424            wf.pointer("/steps/c/url").and_then(Value::as_str),
6425            Some("https://api.{{config.region}}.example")
6426        );
6427
6428        // Every unresolved reference is reported, in ONE refusal.
6429        let bad = write_tmp(
6430            "config_version: \"1\"\n\
6431             vars:\n  set: yes\n\
6432             agent:\n  name: \"{{config.gone}}\"\n  instruction: serve\n  preflight: never\n\
6433             intelligence:\n  endpoints: [\"https://{{config.also_gone}}/v1\"]\n  model: m\n\
6434             store:\n  kind: memory\n",
6435            "yaml",
6436        );
6437        let err = load(
6438            &args(&["--config", bad.path().to_str().unwrap()]),
6439            &base_env(),
6440        )
6441        .err()
6442        .map(|e| e.to_string())
6443        .unwrap_or_default();
6444        assert!(err.contains("config.gone"), "{err}");
6445        assert!(err.contains("config.also_gone"), "{err}");
6446        assert!(
6447            err.contains("2 unresolved config var reference"),
6448            "all misses in one report: {err}"
6449        );
6450    }
6451
6452    #[test]
6453    fn missing_references_name_every_gap_with_its_locations() {
6454        let doc = serde_json::json!({
6455            "a": "{{secret:SUB_WINDOW_TEST_UNSET}}",
6456            "b": {"c": ["{{secret-file:/definitely/not/here}}", "{{config.gone}}"]},
6457            "d": "{{secret:SUB_WINDOW_TEST_UNSET}} again",
6458        });
6459        let vars: std::collections::BTreeMap<String, Value> =
6460            [("present".to_string(), serde_json::json!(1))].into();
6461        let missing = missing_references(&doc, "cfg", &vars);
6462        assert_eq!(missing.len(), 3, "{missing:?}");
6463        let all = missing.join("\n");
6464        assert!(
6465            all.contains("{{secret:SUB_WINDOW_TEST_UNSET}} is not set"),
6466            "{all}"
6467        );
6468        assert!(
6469            all.contains("cfg.a") && all.contains("cfg.d"),
6470            "both locations: {all}"
6471        );
6472        assert!(
6473            all.contains("{{secret-file:/definitely/not/here}} is not readable"),
6474            "{all}"
6475        );
6476        assert!(all.contains("config.gone is not defined in vars"), "{all}");
6477        // A resolvable reference is not noise.
6478        let ok = serde_json::json!({"x": "{{config.present}}"});
6479        assert!(missing_references(&ok, "cfg", &vars).is_empty());
6480    }
6481
6482    #[test]
6483    fn env_substitution_reaches_config_values_and_workflows() {
6484        let file = write_tmp(
6485            "config_version: \"1\"\n\
6486             agent:\n  name: ${SVC_NAME}\n  instruction: serve\n  preflight: never\n\
6487             intelligence:\n  endpoints: [https://x/v1]\n  model: m\n\
6488             store:\n  kind: memory\n\
6489             workflows:\n  - name: w\n    steps:\n\
6490             \x20     s: {kind: once}\n\
6491             \x20     c: {kind: http, depends_on: [s], url: \"https://api.${REGION:-us}.example/${SVC_NAME}\"}\n\
6492             \x20     f: {kind: finish, depends_on: [c]}\n",
6493            "yaml",
6494        );
6495        let mut env = base_env();
6496        env.push(("SVC_NAME".into(), "billing".into()));
6497        // REGION is deliberately unset -> the `:-us` default applies.
6498        let (l, _) = load(&args(&["--config", file.path().to_str().unwrap()]), &env).unwrap();
6499        // A plain config value is substituted.
6500        assert_eq!(
6501            l.settings.agent.name.as_deref(),
6502            Some("billing"),
6503            "the `${{SVC_NAME}}` in a config value was substituted"
6504        );
6505        // A value nested inside an inline workflow is substituted too, honouring
6506        // the `:-default` for the unset REGION and the set SVC_NAME.
6507        let url = l.settings.workflows[0]
6508            .pointer("/steps/c/url")
6509            .and_then(Value::as_str)
6510            .unwrap_or_default();
6511        assert_eq!(
6512            url, "https://api.us.example/billing",
6513            "the workflow value was substituted (default + set var)"
6514        );
6515    }
6516
6517    #[test]
6518    fn mcp_server_oauth_is_carried_to_the_runtime_spec() {
6519        // `mcp.servers[].oauth` must reach the runtime spec as a secret-free
6520        // template, or the connect path has nothing to build a signer from and
6521        // the configured client-credentials grant is inert.
6522        let s = McpServer {
6523            name: "gh".into(),
6524            endpoint: "https://mcp.example".into(),
6525            service: None,
6526            service_rate: None,
6527            ns: None,
6528            headers: BTreeMap::new(),
6529            tags: BTreeMap::new(),
6530            allow: None,
6531            exclude: Vec::new(),
6532            aauth: None,
6533            oauth: Some(McpOauth {
6534                token_url: "https://auth.example/token".into(),
6535                client_id: "cid".into(),
6536                client_secret: Secret("{{secret:CS}}".into()),
6537                scope: Some("mcp:read".into()),
6538            }),
6539            auth: None,
6540            timeout: None,
6541        };
6542        let spec = s.to_spec().unwrap();
6543        let o = spec.oauth.expect("oauth reaches the runtime spec");
6544        assert_eq!(o.token_url, "https://auth.example/token");
6545        assert_eq!(o.client_id, "cid");
6546        // The secret stays a template — never resolved into the spec/payload.
6547        assert_eq!(o.client_secret, "{{secret:CS}}");
6548        assert_eq!(o.scope.as_deref(), Some("mcp:read"));
6549    }
6550
6551    #[test]
6552    fn files_env_flags_layer_in_order_with_aliases() {
6553        let base = write_tmp(
6554            "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",
6555            "yaml",
6556        );
6557        let over = write_tmp("intelligence:\n  model: over-model\n", "yml");
6558        let mut env = base_env();
6559        env.clear();
6560        env.push(("AGENTD_LIMITS_RUN_STEPS".into(), "20".into())); // derived path name
6561        env.push(("AGENT_MODEL".into(), "env-model".into())); // short alias
6562        env.push(("INSTRUCTION".into(), "env-instruction".into())); // bare alias
6563        let (l, _) = load(
6564            &args(&[
6565                "--config",
6566                base.path().to_str().unwrap(),
6567                "--config",
6568                over.path().to_str().unwrap(),
6569                "--max-steps",
6570                "30",
6571                "--mcp",
6572                "fs=https://fs.example/mcp",
6573                "--mcp-tags",
6574                "fs=sensitive",
6575                "--intelligence.headers.x-team",
6576                "ops",
6577            ]),
6578            &env,
6579        )
6580        .unwrap();
6581        let s = &l.settings;
6582        assert_eq!(
6583            s.agent.instruction.as_deref(),
6584            Some("env-instruction"),
6585            "env > file"
6586        );
6587        assert_eq!(
6588            s.intelligence.model.as_deref(),
6589            Some("env-model"),
6590            "env alias > later file"
6591        );
6592        assert_eq!(s.limits.run.steps(), 30, "flag alias > env");
6593        assert_eq!(s.mcp.servers.len(), 1);
6594        assert_eq!(s.mcp.servers[0].name, "fs");
6595        assert_eq!(s.mcp.servers[0].tags["*"], vec!["sensitive"]);
6596        assert_eq!(
6597            s.intelligence.headers.get("x-team").map(String::as_str),
6598            Some("ops")
6599        );
6600        assert_eq!(l.files.len(), 2);
6601        // A derived path name beats the short alias for the same field.
6602        let env2: Vec<(String, String)> = vec![
6603            ("AGENT_MODEL".into(), "legacy".into()),
6604            ("AGENTD_INTELLIGENCE_MODEL".into(), "path".into()),
6605            ("AGENTD_INTELLIGENCE_ENDPOINTS".into(), "https://i".into()),
6606        ];
6607        let (l2, _) = load(
6608            &args(&["--instruction", "x", "--store.kind", "memory"]),
6609            &env2,
6610        )
6611        .unwrap();
6612        assert_eq!(l2.settings.intelligence.model.as_deref(), Some("path"));
6613    }
6614
6615    #[test]
6616    fn removed_flags_name_their_replacement() {
6617        for (flag, _) in REMOVED_FLAGS {
6618            let e = load(&args(&[flag, "x"]), &base_env()).unwrap_err();
6619            assert!(format!("{e}").contains("removed in agentd"), "{flag}: {e}");
6620        }
6621        let e = load(&args(&["--mode", "reactive"]), &base_env()).unwrap_err();
6622        assert!(format!("{e}").contains("start node"), "{e}");
6623    }
6624
6625    #[test]
6626    fn mixed_and_v1_files_are_refused_by_the_v2_loader() {
6627        let mixed = write_tmp("agent: {instruction: x}\nmodel: m\n", "yaml");
6628        let e = load(
6629            &args(&["--config", mixed.path().to_str().unwrap()]),
6630            &base_env(),
6631        )
6632        .unwrap_err();
6633        assert!(format!("{e}").contains("mixes legacy flat keys"), "{e}");
6634        let v1 = write_tmp("model: m\n", "yaml");
6635        let e = load(
6636            &args(&["--config", v1.path().to_str().unwrap()]),
6637            &base_env(),
6638        )
6639        .unwrap_err();
6640        assert!(format!("{e}").contains("retired flat schema"), "{e}");
6641    }
6642
6643    #[test]
6644    fn budget_exit_code_and_instruction_file_aliases() {
6645        let f = write_tmp("read me from a file", "txt");
6646        let (l, _) = load(
6647            &args(&[
6648                "--instruction-file",
6649                f.path().to_str().unwrap(),
6650                "--budget-exit-code",
6651                "9",
6652                "--store.kind",
6653                "memory",
6654            ]),
6655            &base_env(),
6656        )
6657        .unwrap();
6658        assert_eq!(
6659            l.settings.agent.instruction.as_deref(),
6660            Some("read me from a file")
6661        );
6662        assert_eq!(l.settings.lifecycle.exit_code_map.get("3"), Some(&9));
6663        assert_eq!(l.settings.lifecycle.exit_code_map.get("7"), Some(&9));
6664    }
6665
6666    // ---- validation -----------------------------------------------------------
6667
6668    fn load_doc(yaml: &str) -> Result<Loaded, ConfigError> {
6669        let f = write_tmp(yaml, "yaml");
6670        load(&args(&["--config", f.path().to_str().unwrap()]), &[]).map(|(l, _)| l)
6671    }
6672
6673    #[test]
6674    fn validation_collects_the_document_rules() {
6675        // A file with an inline credential is refused; the same value from env is fine.
6676        let e = load_doc(
6677            "config_version: \"1\"\nintelligence:\n  endpoints: [https://i]\n  token: sk-inline\n",
6678        )
6679        .unwrap_err();
6680        assert!(format!("{e}").contains("inline credential"), "{e}");
6681        let (l, _) = load(
6682            &args(&[
6683                "--intelligence",
6684                "https://i",
6685                "--intelligence-token",
6686                "sk-inline",
6687            ]),
6688            &[],
6689        )
6690        .unwrap();
6691        assert_eq!(
6692            l.settings.intelligence.token.as_ref().map(|s| s.0.as_str()),
6693            Some("sk-inline")
6694        );
6695        assert!(
6696            !format!("{:?}", l.settings).contains("sk-inline"),
6697            "Debug redacts"
6698        );
6699
6700        // Undeclared servers referenced by tools/store/knowledge/skills: the
6701        // startup path fast-fails on the first problem (exit 2)…
6702        let e = load_doc(
6703            "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",
6704        )
6705        .unwrap_err();
6706        assert!(matches!(e, ConfigError::Usage(_)), "{e}");
6707
6708        // --validate-config collects EVERYTHING.
6709        let f = write_tmp(
6710            "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",
6711            "yaml",
6712        );
6713        let e = load(
6714            &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
6715            &[],
6716        )
6717        .unwrap_err();
6718        let ConfigError::Validate(Err(lines)) = e else {
6719            panic!("expected a validate verdict, got {e:?}")
6720        };
6721        for needle in [
6722            "store.mcp.server 'nope'",
6723            "knowledge.server 'kb'",
6724            "skills.sources[]",
6725            "tools.overrides['memory.get']",
6726            "both disabled and overridden",
6727            "only the policy codes 3 and 7",
6728            "0..=255",
6729        ] {
6730            assert!(lines.contains(needle), "missing {needle} in:\n{lines}");
6731        }
6732
6733        // A2A listener rules.
6734        let e = load_doc("config_version: \"1\"\nstore: {kind: memory}\na2a: {listen: \"https://0.0.0.0:8443\"}\n").unwrap_err();
6735        assert!(format!("{e}").contains("a2a.tls.cert"), "{e}");
6736        let e = load_doc("config_version: \"1\"\nstore: {kind: memory}\na2a: {listen: \"http://0.0.0.0:8080\"}\n").unwrap_err();
6737        assert!(format!("{e}").contains("loopback"), "{e}");
6738        // Principals: `any` cannot be operator.
6739        let e = load_doc(
6740            "config_version: \"1\"\na2a: {principals: [{match: {any: true}, role: operator}]}\n",
6741        )
6742        .unwrap_err();
6743        assert!(format!("{e}").contains("operator role"), "{e}");
6744        // Budget rules.
6745        let e = load_doc("config_version: \"1\"\nintelligence: {budget: {windows: [{per: hour}], on_exhausted: degrade}}\n").unwrap_err();
6746        assert!(format!("{e}").contains("tokens and/or requests"), "{e}");
6747        // Trifecta over the root grant.
6748        let e = load_doc(
6749            "config_version: \"1\"\nmcp:\n  servers:\n    - {name: fs, endpoint: https://fs/mcp, tags: {\"*\": [untrusted_input, sensitive, egress]}}\n",
6750        )
6751        .unwrap_err();
6752        assert!(format!("{e}").contains("lethal-trifecta"), "{e}");
6753    }
6754
6755    #[test]
6756    fn restart_only_diff_names_changed_paths() {
6757        let a = json!({"agent": {"name": "x", "instruction": "i"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
6758        let b = json!({"agent": {"name": "y", "instruction": "j"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
6759        assert_eq!(restart_only_diff(&a, &b), vec!["agent.name".to_string()]);
6760        let c = json!({"agent": {"name": "x", "instruction": "changed"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
6761        assert!(
6762            restart_only_diff(&a, &c).is_empty(),
6763            "instruction is reloadable"
6764        );
6765    }
6766
6767    #[test]
6768    fn duration_and_tool_select_scalars() {
6769        let s = Settings::from_document(
6770            json!({"limits": {"run": {"deadline": "90s"}, "step_timeout": 5}, "agent": {"tools": {"mcp": "none", "internal": ["memory.get"]}}}),
6771            "t",
6772        )
6773        .unwrap();
6774        assert_eq!(s.limits.run.deadline(), Duration::from_secs(90));
6775        assert_eq!(s.limits.step_timeout, Some(Dur(Duration::from_secs(5))));
6776        assert!(!s.agent.tools.mcp.allows("fs.read"));
6777        assert!(s.agent.tools.internal.allows("memory.get"));
6778        assert!(!s.agent.tools.internal.allows("finish"));
6779        assert!(s.agent.tools.code.allows("anything"));
6780        assert!(
6781            Settings::from_document(json!({"limits": {"run": {"deadline": "soon"}}}), "t").is_err()
6782        );
6783    }
6784
6785    #[test]
6786    fn instruction_config_directives_define_the_agent_and_explicit_keys_win() {
6787        let instr = ":::config\nlimits: {max_runs: 9}\n:::\n\
6788                     :::stream{name=orders}\nretention: {max_events: 50}\n:::\n\
6789                     :::mcp{name=fs}\nendpoint: \"https://fs.internal/mcp\"\nexclude: [\"delete_*\"]\n:::\n\
6790                     Do the work.";
6791        let s = Settings::from_document(
6792            json!({"agent": {"instruction": instr}, "limits": {"max_runs": 3}}),
6793            "t",
6794        )
6795        .unwrap();
6796        assert_eq!(
6797            s.limits.max_runs,
6798            Some(3),
6799            "an explicit key beats the fragment"
6800        );
6801        assert_eq!(
6802            s.streams.get("orders").map(|c| c.max_events()),
6803            Some(50),
6804            "the fragment fills what the config left unsaid"
6805        );
6806        let srv = s
6807            .mcp
6808            .servers
6809            .iter()
6810            .find(|m| m.name == "fs")
6811            .expect("declared");
6812        assert_eq!(srv.endpoint, "https://fs.internal/mcp");
6813        assert_eq!(srv.exclude, vec!["delete_*"]);
6814        let cleaned = s.agent.instruction.as_deref().unwrap();
6815        assert!(cleaned.contains("Do the work."));
6816        assert!(
6817            !cleaned.contains("endpoint"),
6818            "machinery never reaches the model"
6819        );
6820        // A fragment with a bogus section is refused by the SAME deserializer
6821        // that guards the config file — no parallel, laxer path.
6822        assert!(
6823            Settings::from_document(
6824                json!({"agent": {"instruction": ":::config\nnot_a_section: 1\n:::\nx"}}),
6825                "t"
6826            )
6827            .is_err()
6828        );
6829    }
6830
6831    // ---- the file store ------------------------------------------------------
6832
6833    #[test]
6834    fn file_store_root_walks_the_chain_in_order() {
6835        use std::ffi::OsString;
6836        use std::path::PathBuf;
6837        let env = |pairs: Vec<(&'static str, &'static str)>| {
6838            move |k: &str| -> Option<OsString> {
6839                pairs
6840                    .iter()
6841                    .find(|(n, _)| *n == k)
6842                    .map(|(_, v)| OsString::from(*v))
6843            }
6844        };
6845        let all = vec![
6846            ("AGENTD_STATE_DIR", "/state-dir"),
6847            ("XDG_STATE_HOME", "/xdg"),
6848            ("HOME", "/home/a"),
6849        ];
6850        let with_file = |path: Option<&str>| Store {
6851            file: Some(StoreFile {
6852                path: path.map(str::to_string),
6853                min_free: None,
6854            }),
6855            ..Store::default()
6856        };
6857
6858        // 1. store.file.path wins over every environment variable.
6859        assert_eq!(
6860            file_store_root_in(&with_file(Some("/var/lib/agentd")), &env(all.clone())),
6861            PathBuf::from("/var/lib/agentd")
6862        );
6863        // 2. $AGENTD_STATE_DIR is taken verbatim — an operator naming the
6864        //    directory does not get `agentd/state` appended to it.
6865        assert_eq!(
6866            file_store_root_in(&with_file(None), &env(all.clone())),
6867            PathBuf::from("/state-dir")
6868        );
6869        // 3. $XDG_STATE_HOME, with the agentd/state suffix (`creds` sibling).
6870        assert_eq!(
6871            file_store_root_in(&Store::default(), &env(all[1..].to_vec())),
6872            PathBuf::from("/xdg/agentd/state")
6873        );
6874        // 4. $HOME/.local/state/… — the XDG default spelled out.
6875        assert_eq!(
6876            file_store_root_in(&Store::default(), &env(all[2..].to_vec())),
6877            PathBuf::from("/home/a/.local/state/agentd/state")
6878        );
6879        // 5. Last resort: the OS temp dir (non-durable; the runtime says so).
6880        assert_eq!(
6881            file_store_root_in(&Store::default(), &env(vec![])),
6882            std::env::temp_dir().join("agentd").join("state")
6883        );
6884        // The chain is the credential cache's, one sibling over: same order,
6885        // same suffix shape, `state` where `creds` is.
6886        assert!(
6887            file_store_root_in(&Store::default(), &env(all[1..].to_vec()))
6888                .ends_with("agentd/state")
6889        );
6890    }
6891
6892    #[test]
6893    fn file_store_validation_diagnostics() {
6894        // `kind: file` needs no block at all.
6895        let l = load_doc("config_version: \"1\"\nstore: {kind: file}\n").unwrap();
6896        assert_eq!(l.settings.store.kind, StoreKind::File);
6897        assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
6898        // …and a long-lived instance is satisfied by it (no `store.kind is none`).
6899        let l = load_doc(
6900            "config_version: \"1\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\na2a: {listen: \"http://127.0.0.1:8080\"}\n",
6901        )
6902        .unwrap();
6903        assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
6904        assert_eq!(
6905            file_store_root(&l.settings.store),
6906            std::path::PathBuf::from("/var/lib/agentd")
6907        );
6908
6909        // An explicitly empty path would resolve to the working directory.
6910        let e = load_doc("config_version: \"1\"\nstore: {kind: file, file: {path: \"\"}}\n")
6911            .unwrap_err();
6912        assert!(format!("{e}").contains("store.file.path is empty"), "{e}");
6913
6914        // A block belonging to an adapter that is not selected is dead config:
6915        // a warning (it is ignored), not a refusal (it does no harm).
6916        let l = load_doc(
6917            "config_version: \"1\"\nstore: {kind: memory, file: {path: /var/lib/agentd}}\n",
6918        )
6919        .unwrap();
6920        let d = validate(&l);
6921        assert!(d.errors.is_empty(), "{:?}", d.errors);
6922        assert!(
6923            d.warnings
6924                .iter()
6925                .any(|w| w.contains("store.file is set but store.kind is memory")),
6926            "{:?}",
6927            d.warnings
6928        );
6929        // No warning when the file adapter IS the selected one.
6930        let l =
6931            load_doc("config_version: \"1\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\n")
6932                .unwrap();
6933        assert!(
6934            !validate(&l)
6935                .warnings
6936                .iter()
6937                .any(|w| w.contains("store.file")),
6938            "{:?}",
6939            validate(&l).warnings
6940        );
6941        // Changing the state directory under a running instance is restart-only.
6942        assert_eq!(
6943            restart_only_diff(
6944                &json!({"store": {"kind": "file", "file": {"path": "/a"}}}),
6945                &json!({"store": {"kind": "file", "file": {"path": "/b"}}})
6946            ),
6947            vec!["store.file".to_string()]
6948        );
6949    }
6950
6951    #[test]
6952    fn instruction_uri_detection() {
6953        assert!(looks_like_resource_uri("mcp://docs/agent-instruction"));
6954        assert!(looks_like_resource_uri("docs://agent"));
6955        assert!(!looks_like_resource_uri("You are a helpful agent."));
6956        assert!(!looks_like_resource_uri(
6957            "see https://x.example for details"
6958        ));
6959        assert!(!looks_like_resource_uri("://nope"));
6960    }
6961
6962    #[test]
6963    fn help_and_schema_asks_short_circuit_validation() {
6964        let (_, ask) = load(&args(&["--help"]), &[]).unwrap();
6965        assert_eq!(ask, Ask::Help);
6966        let (_, ask) = load(&args(&["--config-schema=1"]), &[]).unwrap();
6967        assert_eq!(ask, Ask::Schema);
6968        // `--workflow-schema` is a static, side-effect-free dump: it must resolve
6969        // even with no config file present (no intelligence endpoint, etc.).
6970        let (_, ask) = load(&args(&["--workflow-schema"]), &[]).unwrap();
6971        assert_eq!(ask, Ask::WorkflowSchema);
6972        assert!(help_section().contains("intelligence.model"));
6973    }
6974
6975    // ---- service catalog & egress policy -----------------------------------
6976
6977    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";
6978
6979    #[test]
6980    fn service_reference_inherits_and_narrows() {
6981        let f = write_tmp(
6982            &format!(
6983                "{CATALOG}mcp:\n  servers:\n    - {{name: money, service: billing, allow: [charge_lookup], ns: fin}}\n"
6984            ),
6985            "yaml",
6986        );
6987        let (loaded, _) = load(
6988            &args(&["--config", f.path().to_str().unwrap()]),
6989            &base_env(),
6990        )
6991        .unwrap();
6992        let s = &loaded.settings.mcp.servers[0];
6993        assert_eq!(s.endpoint, "https://billing.example/mcp", "inherited");
6994        assert!(s.auth.is_some(), "inherited auth");
6995        assert_eq!(s.headers["X-Env"], "prod", "inherited headers");
6996        assert_eq!(s.allow.as_deref(), Some(&["charge_lookup".to_string()][..]));
6997        assert_eq!(
6998            s.exclude,
6999            vec!["invoice_purge".to_string()],
7000            "exclude unions"
7001        );
7002        assert_eq!(s.tags["*"], vec!["sensitive"], "tag floor applied");
7003        assert_eq!(s.ns.as_deref(), Some("fin"), "consumer-local ns kept");
7004    }
7005
7006    #[test]
7007    fn service_reference_without_allow_inherits_the_ceiling() {
7008        let f = write_tmp(
7009            &format!("{CATALOG}mcp:\n  servers:\n    - {{name: money, service: billing}}\n"),
7010            "yaml",
7011        );
7012        let (loaded, _) = load(
7013            &args(&["--config", f.path().to_str().unwrap()]),
7014            &base_env(),
7015        )
7016        .unwrap();
7017        let s = &loaded.settings.mcp.servers[0];
7018        assert_eq!(
7019            s.allow.as_deref(),
7020            Some(&["charge_lookup".to_string(), "invoice_*".to_string()][..]),
7021            "absent consumer allow inherits the catalog ceiling"
7022        );
7023    }
7024
7025    #[test]
7026    fn service_reference_refuses_restated_connection_settings() {
7027        let f = write_tmp(
7028            &format!(
7029                "{CATALOG}mcp:\n  servers:\n    - {{name: money, service: billing, endpoint: \"https://other.example\"}}\n"
7030            ),
7031            "yaml",
7032        );
7033        let e = load(
7034            &args(&["--config", f.path().to_str().unwrap()]),
7035            &base_env(),
7036        )
7037        .unwrap_err();
7038        let msg = format!("{e}");
7039        assert!(msg.contains("restates `endpoint`"), "{msg}");
7040    }
7041
7042    #[test]
7043    fn service_allow_widening_is_refused() {
7044        let f = write_tmp(
7045            &format!(
7046                "{CATALOG}mcp:\n  servers:\n    - {{name: money, service: billing, allow: [refund_all]}}\n"
7047            ),
7048            "yaml",
7049        );
7050        let e = load(
7051            &args(&["--config", f.path().to_str().unwrap()]),
7052            &base_env(),
7053        )
7054        .unwrap_err();
7055        let msg = format!("{e}");
7056        assert!(msg.contains("widens the ceiling"), "{msg}");
7057    }
7058
7059    #[test]
7060    fn unknown_service_reference_is_refused() {
7061        let f = write_tmp(
7062            "config_version: \"1\"\nstore: {kind: memory}\nmcp:\n  servers:\n    - {name: x, service: nope}\n",
7063            "yaml",
7064        );
7065        let e = load(
7066            &args(&["--config", f.path().to_str().unwrap()]),
7067            &base_env(),
7068        )
7069        .unwrap_err();
7070        assert!(format!("{e}").contains("unknown service 'nope'"), "{e}");
7071    }
7072
7073    #[test]
7074    fn tag_floor_applies_to_inline_matching_servers() {
7075        // An INLINE server pointing under a catalogued endpoint gets the
7076        // entry's tags unioned in — under-tagging cannot launder a sensitive
7077        // endpoint past the trifecta gate.
7078        let f = write_tmp(
7079            &format!(
7080                "{CATALOG}mcp:\n  servers:\n    - {{name: sneaky, endpoint: \"https://billing.example/mcp/sub\"}}\n"
7081            ),
7082            "yaml",
7083        );
7084        let (loaded, _) = load(
7085            &args(&["--config", f.path().to_str().unwrap()]),
7086            &base_env(),
7087        )
7088        .unwrap();
7089        assert_eq!(
7090            loaded.settings.mcp.servers[0].tags["*"],
7091            vec!["sensitive"],
7092            "the catalog's tags are a floor for any matching endpoint"
7093        );
7094    }
7095
7096    #[test]
7097    fn egress_closed_refuses_uncatalogued_and_admits_catalogued() {
7098        let f = write_tmp(
7099            &format!(
7100                "{CATALOG}security: {{egress: closed}}\nmcp:\n  servers:\n    - {{name: rogue, endpoint: \"https://rogue.example/mcp\"}}\n"
7101            ),
7102            "yaml",
7103        );
7104        let e = load(
7105            &args(&["--config", f.path().to_str().unwrap()]),
7106            &base_env(),
7107        )
7108        .unwrap_err();
7109        let msg = format!("{e}");
7110        assert!(
7111            msg.contains("matches no `kind: mcp` services: catalog entry"),
7112            "{msg}"
7113        );
7114
7115        let ok = write_tmp(
7116            &format!(
7117                "{CATALOG}security: {{egress: closed}}\nmcp:\n  servers:\n    - {{name: money, service: billing}}\n"
7118            ),
7119            "yaml",
7120        );
7121        load(
7122            &args(&["--config", ok.path().to_str().unwrap()]),
7123            &base_env(),
7124        )
7125        .expect("a catalogued reference passes closed egress");
7126    }
7127
7128    #[test]
7129    fn ambiguous_catalog_endpoints_are_refused() {
7130        let f = write_tmp(
7131            "config_version: \"1\"\nstore: {kind: memory}\nservices:\n  a: {endpoint: \"https://s.example/mcp\"}\n  b: {endpoint: \"https://s.example/mcp/deeper\"}\n",
7132            "yaml",
7133        );
7134        let e = load(
7135            &args(&["--config", f.path().to_str().unwrap()]),
7136            &base_env(),
7137        )
7138        .unwrap_err();
7139        assert!(format!("{e}").contains("prefix-comparable"), "{e}");
7140    }
7141
7142    #[test]
7143    fn service_match_respects_segment_boundaries() {
7144        let mut services = BTreeMap::new();
7145        services.insert(
7146            "a".to_string(),
7147            Service {
7148                kind: ServiceKind::Mcp,
7149                endpoint: "https://s.example/api".into(),
7150                headers: BTreeMap::new(),
7151                tags: BTreeMap::new(),
7152                allow: None,
7153                exclude: Vec::new(),
7154                auth: None,
7155                rate: None,
7156                timeout: None,
7157                methods: None,
7158                breaker: None,
7159            },
7160        );
7161        let m = ServiceKind::Mcp;
7162        assert!(service_match(&services, m, "https://s.example/api").is_some());
7163        assert!(service_match(&services, m, "https://s.example/api/v2").is_some());
7164        assert!(
7165            service_match(&services, m, "https://s.example/apiary").is_none(),
7166            "prefix match is on segment boundaries, not string prefixes"
7167        );
7168        assert!(service_match(&services, m, "https://other.example/api").is_none());
7169        assert!(
7170            service_match(&services, m, "http://s.example/api").is_none(),
7171            "scheme must match"
7172        );
7173        assert!(
7174            service_match(&services, ServiceKind::Http, "https://s.example/api").is_none(),
7175            "matching is kind-filtered"
7176        );
7177    }
7178
7179    #[test]
7180    fn peer_references_resolve_and_all_four_kinds_gate_closed_egress() {
7181        // A `kind: peer` entry feeds a2a.peers[].service, and closed mode
7182        // covers intelligence endpoints, peers and http-step literals too.
7183        let f = write_tmp(
7184            "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",
7185            "yaml",
7186        );
7187        let (loaded, _) = load(
7188            &args(&["--config", f.path().to_str().unwrap()]),
7189            &base_env(),
7190        )
7191        .expect("all surfaces catalogued ⇒ closed mode admits the config");
7192        let p = &loaded.settings.a2a.peers[0];
7193        assert_eq!(p.endpoint, "https://peer.example", "peer inherited");
7194        assert!(p.auth.is_some(), "peer inherited auth");
7195
7196        let bad = write_tmp(
7197            "config_version: \"1\"\nstore: {kind: memory}\nsecurity: {egress: closed}\na2a:\n  peers:\n    - {name: rogue, endpoint: \"https://rogue.example\"}\n",
7198            "yaml",
7199        );
7200        let e = load(
7201            &args(&["--config", bad.path().to_str().unwrap()]),
7202            &base_env(),
7203        )
7204        .unwrap_err();
7205        assert!(
7206            format!("{e}").contains("kind: peer"),
7207            "an uncatalogued peer is refused naming the kind: {e}"
7208        );
7209
7210        let badi = write_tmp(
7211            "config_version: \"1\"\nstore: {kind: memory}\nsecurity: {egress: closed}\nintelligence: {endpoints: \"https://rogue-intel.example/v1\"}\n",
7212            "yaml",
7213        );
7214        let e = load(&args(&["--config", badi.path().to_str().unwrap()]), &[]).unwrap_err();
7215        assert!(
7216            format!("{e}").contains("kind: intelligence"),
7217            "an uncatalogued intelligence endpoint is refused: {e}"
7218        );
7219    }
7220
7221    #[test]
7222    fn kind_specific_entry_fields_are_validated() {
7223        let f = write_tmp(
7224            "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",
7225            "yaml",
7226        );
7227        let e = load(
7228            &args(&["--config", f.path().to_str().unwrap()]),
7229            &base_env(),
7230        )
7231        .unwrap_err();
7232        let msg = format!("{e}");
7233        assert!(msg.contains("`tags` applies to `kind: mcp`"), "{msg}");
7234        assert!(msg.contains("`methods` applies to `kind: http`"), "{msg}");
7235    }
7236
7237    /// The message hop cap is a fail-closed gate, so it has to bind on a
7238    /// config that never mentions it. An operator who has not heard of
7239    /// `max_message_depth` is exactly the one a runaway chain would surprise.
7240    #[test]
7241    fn the_message_hop_cap_binds_without_being_configured() {
7242        let l = Limits::default();
7243        assert_eq!(l.max_message_depth, None, "unset by default");
7244        assert_eq!(l.message_depth(), DEFAULT_MESSAGE_DEPTH);
7245        assert!(
7246            l.message_depth() > 0,
7247            "a cap of 0 would refuse every message"
7248        );
7249        // An explicit setting still wins.
7250        let tuned = Limits {
7251            max_message_depth: Some(2),
7252            ..Default::default()
7253        };
7254        assert_eq!(tuned.message_depth(), 2);
7255    }
7256
7257    #[test]
7258    fn pattern_subsumption_covers_the_glob_grammar() {
7259        assert!(pattern_subsumes("charge_lookup", "charge_lookup"));
7260        assert!(pattern_subsumes("charge_lookup", "charge_*"));
7261        assert!(pattern_subsumes("charge_*", "charge_*"));
7262        assert!(pattern_subsumes("charge_x_*", "charge_*"));
7263        assert!(!pattern_subsumes("charge_*", "charge_lookup"));
7264        assert!(!pattern_subsumes("refund_all", "charge_*"));
7265        assert!(pattern_subsumes("anything", "*"));
7266    }
7267}