Skip to main content

agentd/config/
file.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The declarative config **file** + its JSON Schema.
3//!
4//! One document, two syntaxes: **YAML** (`.yaml`/`.yml`, read by the
5//! hand-rolled [`super::yaml`] subset reader — no `serde_yaml`, the minimalism
6//! moat) or **JSON** with comments (`.json`/`.jsonc`); an unknown extension is
7//! sniffed (`{`/`[` ⇒ JSON, else YAML). Both parse to the same
8//! `serde_json::Value` document ([`read_document`]) and then to the typed
9//! [`ConfigFile`] ([`ConfigFile::from_document`]) — so validation, the schema,
10//! the env/flag path bindings ([`super::paths`]) and hot reload are all
11//! format-agnostic.
12//!
13//! The file carries **only verbose structural config**: the MCP-server
14//! inventory, declared subscriptions, A2A peers, limits, and the model/log
15//! knobs. It **never** carries secrets or per-environment scalars (those stay
16//! env/flag).
17//!
18//! Precedence: `built-in default < FILE < env < flag`. The file is loaded
19//! first, then `Config::load` applies env and flags over it; a flag/env for the
20//! same key wins. List-valued keys (`mcp_servers`, `subscribe`, `a2a_peers`)
21//! *seed* the list — repeatable `--mcp`/`--subscribe`/`--a2a-peer` flags **add
22//! to** the file's list rather than replacing it, matching the repeatable-flag
23//! semantics operators already expect.
24//!
25//! `deny_unknown_fields` makes a typo'd key (`max_token` vs `max_tokens`) a hard
26//! config error (exit 2) instead of a silently-ignored value — the single most
27//! common config footgun, closed at parse time.
28//!
29//! The schema is **hand-written** (no `schemars` — a forbidden dependency) and
30//! kept faithful to this struct by a unit test asserting the schema's top-level
31//! properties match the struct's fields, so the two cannot diverge unnoticed.
32
33use serde::Deserialize;
34use serde_json::{Value, json};
35use std::collections::BTreeMap;
36use std::path::Path;
37
38/// The two config-file syntaxes.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Format {
41    /// JSON, with `//` and `/* */` comments tolerated (jsonc).
42    Json,
43    /// The YAML subset [`super::yaml`] reads.
44    Yaml,
45}
46
47impl Format {
48    pub fn as_str(self) -> &'static str {
49        match self {
50            Format::Json => "json",
51            Format::Yaml => "yaml",
52        }
53    }
54
55    /// Decide the format of a config document: the file extension when it is a
56    /// known one (`.yaml`/`.yml` ⇒ YAML; `.json`/`.jsonc` ⇒ JSON), else by
57    /// sniffing the text — a document whose first significant character (after
58    /// whitespace and `//`/`/* */` comments) is `{` or `[` is JSON, anything
59    /// else is YAML.
60    pub fn detect(path: Option<&Path>, text: &str) -> Format {
61        if let Some(ext) = path.and_then(|p| p.extension()).and_then(|e| e.to_str()) {
62            match ext.to_ascii_lowercase().as_str() {
63                "yaml" | "yml" => return Format::Yaml,
64                "json" | "jsonc" => return Format::Json,
65                _ => {}
66            }
67        }
68        Format::sniff(text)
69    }
70
71    fn sniff(text: &str) -> Format {
72        let t = text.strip_prefix('\u{feff}').unwrap_or(text);
73        let bytes = t.as_bytes();
74        let mut i = 0;
75        loop {
76            while i < bytes.len() && bytes[i].is_ascii_whitespace() {
77                i += 1;
78            }
79            if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
80                while i < bytes.len() && bytes[i] != b'\n' {
81                    i += 1;
82                }
83                continue;
84            }
85            if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
86                i += 2;
87                while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
88                    i += 1;
89                }
90                i += 2;
91                continue;
92            }
93            break;
94        }
95        match bytes.get(i) {
96            Some(b'{') | Some(b'[') => Format::Json,
97            _ => Format::Yaml,
98        }
99    }
100}
101
102/// Parse config text of the given format into its document (a JSON value). A
103/// syntax error names the line/column; the document must be a mapping (object)
104/// at the top level.
105pub fn parse_document(text: &str, format: Format) -> Result<Value, String> {
106    let doc = match format {
107        Format::Json => {
108            let stripped = strip_jsonc(text);
109            serde_json::from_str::<Value>(&stripped)
110                .map_err(|e| format!("config file parse error (json): {e}"))?
111        }
112        Format::Yaml => {
113            super::yaml::parse(text).map_err(|e| format!("config file parse error (yaml): {e}"))?
114        }
115    };
116    match doc {
117        Value::Object(_) => Ok(doc),
118        Value::Null if format == Format::Yaml => Ok(Value::Object(serde_json::Map::new())),
119        other => Err(format!(
120            "config file must be a mapping (an object) at the top level, got {}",
121            kind_name(&other)
122        )),
123    }
124}
125
126/// Read + parse a config file from a local path into its document, deciding the
127/// format from the extension (else by sniffing the text). Errors name the path.
128pub fn read_document(path: &str) -> Result<(Value, Format), String> {
129    let text = std::fs::read_to_string(path)
130        .map_err(|e| format!("cannot read config file {path}: {e}"))?;
131    let format = Format::detect(Some(Path::new(path)), &text);
132    let doc = parse_document(&text, format).map_err(|e| format!("{path}: {e}"))?;
133    Ok((doc, format))
134}
135
136/// Read several config files, in order, into ONE effective document: each later
137/// file is merged over the previous ones with **JSON Merge Patch** semantics
138/// (RFC 7396) — objects merge recursively, scalars and lists are REPLACED by the
139/// later file, and a `null` value UNSETS the key. Every file is type-checked on
140/// its own first (so an unknown key is reported against the file that carries
141/// it), then the merged document is returned with the `(path, format)` list.
142pub fn read_documents(paths: &[String]) -> Result<(Value, Vec<(String, Format)>), String> {
143    read_documents_checked(paths, &|doc, source| {
144        ConfigFile::from_document(doc.clone(), source).map(|_| ())
145    })
146}
147
148/// [`read_documents`] with a caller-supplied per-file check (the v2 settings
149/// typing, or none) — `check(doc, "config file <path>")` runs before the merge
150/// so an unknown key is attributed to its file.
151pub fn read_documents_checked(
152    paths: &[String],
153    check: &dyn Fn(&Value, &str) -> Result<(), String>,
154) -> Result<(Value, Vec<(String, Format)>), String> {
155    let mut merged = Value::Object(serde_json::Map::new());
156    let mut loaded = Vec::with_capacity(paths.len());
157    for path in paths {
158        let (doc, format) = read_document(path)?;
159        check(&doc, &format!("config file {path}"))?;
160        merge_into(&mut merged, doc);
161        loaded.push((path.clone(), format));
162    }
163    Ok((merged, loaded))
164}
165
166/// JSON Merge Patch (RFC 7396): `overlay` onto `base`. Objects merge key by key
167/// (recursively); any other value — a scalar or a list — replaces what was
168/// there; an explicit `null` removes the key. A non-object overlay replaces the
169/// base wholesale.
170pub fn merge_into(base: &mut Value, overlay: Value) {
171    match overlay {
172        Value::Object(over) => {
173            if !base.is_object() {
174                *base = Value::Object(serde_json::Map::new());
175            }
176            let map = base.as_object_mut().expect("just ensured an object");
177            for (k, v) in over {
178                match v {
179                    Value::Null => {
180                        map.remove(&k);
181                    }
182                    Value::Object(_) => {
183                        let slot = map
184                            .entry(k)
185                            .or_insert(Value::Object(serde_json::Map::new()));
186                        merge_into(slot, v);
187                    }
188                    other => {
189                        map.insert(k, other);
190                    }
191                }
192            }
193        }
194        other => *base = other,
195    }
196}
197
198fn kind_name(v: &Value) -> &'static str {
199    match v {
200        Value::Null => "null",
201        Value::Bool(_) => "a boolean",
202        Value::Number(_) => "a number",
203        Value::String(_) => "a string",
204        Value::Array(_) => "a list",
205        Value::Object(_) => "an object",
206    }
207}
208
209/// The `x-agentd-contract-version` the schema carries. It is the same value as
210/// the capabilities manifest's `contract_version` — a tool that validated a
211/// document against this schema knows exactly which runtime contract it targets
212/// — and `tests::schema_contract_version_matches_manifest` holds the two equal.
213pub const SCHEMA_CONTRACT_VERSION: &str = "1.0";
214
215/// The deserialized config-file shape — one source of truth for the loader, the
216/// validator, and the `--config-schema` generator. `serde` only.
217///
218/// `deny_unknown_fields` rejects a typo'd key at parse time (exit 2). A flattened
219/// catch-all is INTENTIONALLY ABSENT — `deny_unknown_fields` is the guard.
220#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
221#[serde(deny_unknown_fields)]
222pub struct ConfigFile {
223    /// Optional; pins the file to a schema major agentctl validated against.
224    pub config_version: Option<String>,
225    /// `--intelligence` / `AGENTD_INTELLIGENCE` — the ordered intelligence
226    /// endpoint *list* URI. File-settable and **reloadable** so a ConfigMap
227    /// update can repoint the endpoint list as a hot swap: the reload fans
228    /// `ctrl/swap_intel` to in-flight work and re-points new spawns. The
229    /// transport SCHEME is data, not a secret; the per-endpoint credential is
230    /// NEVER inline here — it comes from env or a `_FILE` path, so a config
231    /// document can be committed and mounted without carrying a credential.
232    pub intelligence: Option<String>,
233    /// `--model-swap` / `AGENTD_MODEL_SWAP` — the model hot-swap policy
234    /// (`finish-on-old` | `restart-turn`), deciding what an in-flight turn does
235    /// when the model changes under it. Reloadable. Validated against
236    /// [`crate::config::SwapPolicy`].
237    pub model_swap: Option<String>,
238    /// `--model` / `AGENTD_MODEL` (reloadable param, never the transport).
239    pub model: Option<String>,
240    /// `--max-tokens` / `AGENTD_MAX_TOKENS`.
241    pub max_tokens: Option<u64>,
242    /// Bounds on the model loop (`--max-steps` / `--max-depth` / `--deadline`).
243    pub limits: Option<LimitsFile>,
244    /// The MCP server inventory — one object per `--mcp name=cmd … --mcp-tags …`.
245    #[serde(default)]
246    pub mcp_servers: Vec<McpServerFile>,
247    /// Declared subscriptions (reactive mode) — each string == one `--subscribe URI`.
248    #[serde(default)]
249    pub subscribe: Vec<String>,
250    /// Declared remote-A2A delegation peers — each == one `--a2a-peer name=endpoint`.
251    #[serde(default)]
252    pub a2a_peers: Vec<A2aPeerFile>,
253    /// `--log-level` / `AGENTD_LOG_LEVEL` (a string; validated against `Level`).
254    pub log_level: Option<String>,
255    /// Declared intelligence HTTP headers. Values MAY interpolate
256    /// `{{secret:NAME}}` / `{{secret-file:PATH}}`; the resolved secret never
257    /// lands in this struct or in a log — only the reference does. A value that
258    /// looks like an inline secret is rejected outright, so a credential cannot
259    /// be committed to a config file by accident.
260    #[serde(default)]
261    pub intelligence_headers: BTreeMap<String, String>,
262}
263
264/// The `limits` sub-object — maps to the per-run limit flags.
265#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
266#[serde(deny_unknown_fields)]
267pub struct LimitsFile {
268    /// `--max-steps`.
269    pub max_steps: Option<u32>,
270    /// `--max-depth`.
271    pub max_depth: Option<u32>,
272    /// `--deadline` in whole seconds.
273    pub deadline_secs: Option<u64>,
274    /// `--budget-tokens-lifetime` — the per-instance cumulative token cap
275    /// across all runs and reactions, not per run (the CRD's
276    /// `limits.lifetimeTokens`). `0` or absent = unbounded.
277    pub lifetime_tokens: Option<u64>,
278}
279
280/// One MCP server, reached over the Streamable HTTP transport: a remote
281/// `endpoint` (`https://host[:port][/path]`, loopback `http://` for dev) with
282/// optional secret-free auth `headers`. There is no local process spawn — every
283/// server is a network peer, so config can never turn into command execution.
284/// `tags` is the glob→tags wire (the loader flattens a `{"*": ["sensitive"]}`
285/// map to the server's tag set).
286#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
287#[serde(deny_unknown_fields)]
288pub struct McpServerFile {
289    pub name: String,
290    /// Remote MCP endpoint.
291    pub endpoint: Option<String>,
292    /// Auth/framing header templates — values MAY interpolate `{{secret:NAME}}` /
293    /// `{{secret-file:PATH}}`, never inline secrets.
294    #[serde(default)]
295    pub headers: BTreeMap<String, String>,
296    /// Glob→trifecta-tags. A server with no tags is treated as
297    /// `untrusted_input`, so forgetting to tag one narrows the trust budget
298    /// rather than widening it.
299    #[serde(default)]
300    pub tags: BTreeMap<String, Vec<String>>,
301    /// Sign requests to this server with the AAuth agent identity.
302    /// `None` inherits the global default (sign all when an identity is
303    /// configured); `false` opts out; `true` opts in. Needs `--features aauth`.
304    #[serde(default)]
305    pub aauth: Option<bool>,
306}
307
308/// One A2A peer — maps to `--a2a-peer name=endpoint`.
309#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
310#[serde(deny_unknown_fields)]
311pub struct A2aPeerFile {
312    pub name: String,
313    pub endpoint: String,
314    /// Secret-free auth header templates presented TO the peer (bearer leg),
315    /// e.g. `"authorization": "Bearer {{secret:PEER_TOKEN}}"`.
316    #[serde(default)]
317    pub headers: BTreeMap<String, String>,
318    /// Client-certificate PEM file paths for mutual TLS to the peer (both or
319    /// neither).
320    #[serde(default)]
321    pub client_cert: Option<String>,
322    #[serde(default)]
323    pub client_key: Option<String>,
324}
325
326/// The list of `ConfigFile` field names, in declaration order — the single
327/// source both the schema generator and its unit test read, so the schema's
328/// `properties` can never silently diverge from the struct.
329pub const CONFIG_FILE_FIELDS: &[&str] = &[
330    "config_version",
331    "intelligence",
332    "model_swap",
333    "model",
334    "max_tokens",
335    "limits",
336    "mcp_servers",
337    "subscribe",
338    "a2a_peers",
339    "log_level",
340    "intelligence_headers",
341];
342
343impl ConfigFile {
344    /// Parse config text (YAML or JSON — sniffed, since there is no path). A
345    /// malformed document is an `Err` with a message the caller maps to exit 2
346    /// — before any side effect. JSON-with-comments is tolerated (`//` and
347    /// `/* */` are stripped first, matching the jsonc shown in the RFC set).
348    pub fn parse(text: &str) -> Result<ConfigFile, String> {
349        let doc = parse_document(text, Format::detect(None, text))?;
350        Self::from_document(doc, "config file")
351    }
352
353    /// Type a config DOCUMENT (from a file, or the env/flag path layers —
354    /// `source` names it in errors). Unknown keys are rejected
355    /// (`deny_unknown_fields`); the error names the offending key.
356    pub fn from_document(doc: Value, source: &str) -> Result<ConfigFile, String> {
357        serde_json::from_value(doc).map_err(|e| format!("{source} parse error: {e}"))
358    }
359
360    /// Load + parse a config file from a local path (no network) — YAML or JSON
361    /// by extension, sniffed otherwise.
362    pub fn load(path: &str) -> Result<ConfigFile, String> {
363        let (doc, _format) = read_document(path)?;
364        Self::from_document(doc, "config file")
365    }
366}
367
368/// Strip line (`//`) and block (`/* */`) comments from JSON-with-comments,
369/// preserving string literals (a `//` inside a `"…"` is data, not a comment).
370/// Byte-oriented and minimal — the moat forbids a jsonc *crate*.
371///
372/// **Everything kept is copied as a SLICE of `src`, never as `byte as char`.**
373/// That distinction is the whole UTF-8 story: `0xE2 as char` is U+00E2 ('â'), so
374/// a byte-wise copy silently mojibake's an em-dash, an accented name or any CJK
375/// text into its Latin-1 shadow — and the result is still valid JSON, so nothing
376/// ever reports an error and the agent runs on a subtly wrong instruction.
377/// Slicing carries the whole multibyte sequence through untouched.
378///
379/// Scanning stays byte-wise, which is safe because every byte this function
380/// *matches on* (`"`, `\`, `/`, `*`, `\n`) is ASCII, and an ASCII byte can never
381/// occur inside a multibyte UTF-8 sequence (continuation bytes are all ≥ 0x80).
382/// So a comment boundary is always a char boundary and the slices below can
383/// never split a character.
384fn strip_jsonc(src: &str) -> String {
385    let bytes = src.as_bytes();
386    let mut out = String::with_capacity(src.len());
387    let mut i = 0;
388    let mut in_str = false;
389    // Start of the run of bytes not yet copied out. A run is broken only by a
390    // comment; everything else is emitted verbatim by slicing `src[run..i]`.
391    let mut run = 0;
392    while i < bytes.len() {
393        let b = bytes[i];
394        if in_str {
395            if b == b'\\' && i + 1 < bytes.len() {
396                // Skip the escape AND the escaped byte without inspecting it, so
397                // a \" cannot end the string. Both stay in the current run.
398                i += 2;
399                continue;
400            }
401            if b == b'"' {
402                in_str = false;
403            }
404            i += 1;
405            continue;
406        }
407        if b == b'"' {
408            in_str = true;
409            i += 1;
410            continue;
411        }
412        if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
413            // line comment → skip to end of line (keep the newline for line counts).
414            out.push_str(&src[run..i]);
415            while i < bytes.len() && bytes[i] != b'\n' {
416                i += 1;
417            }
418            run = i;
419            continue;
420        }
421        if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
422            // block comment → skip to the closing */.
423            out.push_str(&src[run..i]);
424            i += 2;
425            while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
426                i += 1;
427            }
428            // Step past the `*/`. On an UNTERMINATED comment `i` is left mid-text,
429            // so clamp to the end — `bytes.len()` is always a char boundary, and
430            // the malformed document is serde_json's error to report, not ours.
431            i = (i + 2).min(bytes.len());
432            run = i;
433            continue;
434        }
435        i += 1;
436    }
437    out.push_str(&src[run..]);
438    out
439}
440
441/// Emit the hand-written **JSON Schema (Draft 2020-12)** of the config file.
442/// No `schemars` — a schema *library* is binary weight the moat forbids. Kept
443/// faithful to [`ConfigFile`] by `tests::schema_properties_match_struct_fields`.
444///
445/// `additionalProperties:false` mirrors `deny_unknown_fields`; `$id` pins the
446/// major; `x-agentd-contract-version` ties it to the manifest. agentctl
447/// validates a CR against this before applying it to a pod.
448pub fn config_schema() -> Value {
449    json!({
450        "$schema": "https://json-schema.org/draft/2020-12/schema",
451        // A DIFFERENT document from the settings schema `--config-schema`
452        // prints, so it gets its own `$id`: two schemas sharing one identity
453        // is a real hazard for any tool that caches by `$id`. This one is
454        // internal — it derives the env/flag path bindings — and is not
455        // served.
456        "$id": format!("https://agentd.dev/schema/internal/config-file-{SCHEMA_CONTRACT_VERSION}.json"),
457        "x-agentd-contract-version": SCHEMA_CONTRACT_VERSION,
458        "title": "agentd config file",
459        "type": "object",
460        "additionalProperties": false,
461        "properties": {
462            "config_version": { "type": "string" },
463            "intelligence": { "type": "string" },
464            "model_swap": { "enum": ["finish-on-old", "restart-turn"] },
465            "model": { "type": "string" },
466            "max_tokens": { "type": "integer", "minimum": 1 },
467            "limits": { "$ref": "#/$defs/Limits" },
468            "mcp_servers": { "type": "array", "items": { "$ref": "#/$defs/McpServer" } },
469            "subscribe": { "type": "array", "items": { "type": "string" } },
470            "a2a_peers": { "type": "array", "items": { "$ref": "#/$defs/A2aPeer" } },
471            "log_level": { "enum": ["trace", "debug", "info", "warn", "error"] },
472            "intelligence_headers": {
473                "type": "object",
474                "additionalProperties": { "type": "string" }
475            }
476        },
477        "$defs": {
478            "Limits": {
479                "type": "object",
480                "additionalProperties": false,
481                "properties": {
482                    "max_steps": { "type": "integer", "minimum": 1 },
483                    "max_depth": { "type": "integer", "minimum": 0 },
484                    "deadline_secs": { "type": "integer", "minimum": 0 },
485                    "lifetime_tokens": { "type": "integer", "minimum": 0 }
486                }
487            },
488            "McpServer": {
489                "type": "object",
490                "additionalProperties": false,
491                "required": ["name", "endpoint"],
492                "properties": {
493                    "name": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" },
494                    "endpoint": { "type": "string" },
495                    "headers": {
496                        "type": "object",
497                        "additionalProperties": { "type": "string" }
498                    },
499                    "tags": {
500                        "type": "object",
501                        "additionalProperties": {
502                            "type": "array",
503                            "items": { "enum": ["untrusted_input", "sensitive", "egress"] }
504                        }
505                    },
506                    "aauth": {
507                        "type": "boolean",
508                        "description": "sign requests to this server with the AAuth agent identity; omit to inherit the global default"
509                    }
510                }
511            },
512            "A2aPeer": {
513                "type": "object",
514                "additionalProperties": false,
515                "required": ["name", "endpoint"],
516                "properties": {
517                    "name": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" },
518                    "endpoint": { "type": "string" },
519                    "headers": {
520                        "type": "object",
521                        "additionalProperties": { "type": "string" },
522                        "description": "secret-free auth header templates presented to the peer ({{secret:NAME}} references)"
523                    },
524                    "client_cert": { "type": "string", "description": "client certificate PEM file path (mutual TLS to the peer; requires client_key)" },
525                    "client_key": { "type": "string", "description": "client private-key PEM file path" }
526                }
527            }
528        }
529    })
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    #[test]
537    fn parses_a_full_file() {
538        let src = r#"{
539            "config_version": "1.0",
540            "model": "claude-opus-4",
541            "max_tokens": 2000000,
542            "limits": { "max_steps": 200, "max_depth": 4, "deadline_secs": 600 },
543            "mcp_servers": [
544                { "name": "web", "endpoint": "https://web.example.com/mcp",
545                  "headers": { "Authorization": "Bearer {{secret:WEB_TOKEN}}" },
546                  "tags": { "*": ["untrusted_input"] } }
547            ],
548            "subscribe": ["fs:file:///watch/inbox"],
549            "a2a_peers": [{ "name": "mesh", "endpoint": "unix:/run/peer.sock" }],
550            "log_level": "info",
551            "intelligence_headers": { "anthropic-version": "2023-06-01" }
552        }"#;
553        let cf = ConfigFile::parse(src).unwrap();
554        assert_eq!(cf.model.as_deref(), Some("claude-opus-4"));
555        assert_eq!(cf.max_tokens, Some(2_000_000));
556        assert_eq!(cf.limits.unwrap().max_steps, Some(200));
557        assert_eq!(cf.mcp_servers.len(), 1);
558        assert_eq!(
559            cf.mcp_servers[0].endpoint.as_deref(),
560            Some("https://web.example.com/mcp")
561        );
562        assert_eq!(cf.subscribe, vec!["fs:file:///watch/inbox"]);
563        assert_eq!(cf.a2a_peers[0].name, "mesh");
564        assert_eq!(cf.log_level.as_deref(), Some("info"));
565    }
566
567    #[test]
568    fn unknown_key_is_rejected() {
569        // deny_unknown_fields: a typo'd key is a hard error, not silently ignored.
570        let e = ConfigFile::parse(r#"{ "max_token": 5 }"#).unwrap_err();
571        assert!(e.contains("parse error"), "got: {e}");
572        assert!(e.contains("max_token"), "names the key: {e}");
573        // Same for YAML — the typo is named, whatever the syntax.
574        let e = ConfigFile::parse("max_token: 5\n").unwrap_err();
575        assert!(
576            e.contains("parse error") && e.contains("max_token"),
577            "got: {e}"
578        );
579    }
580
581    #[test]
582    fn yaml_and_json_documents_type_identically() {
583        let yaml = r#"
584# the same document as parses_a_full_file, in YAML
585config_version: "1.0"
586model: claude-opus-4
587max_tokens: 2000000
588limits:
589  max_steps: 200
590  max_depth: 4
591  deadline_secs: 600
592mcp_servers:
593  - name: web
594    endpoint: https://web.example.com/mcp
595    headers:
596      Authorization: "Bearer {{secret:WEB_TOKEN}}"
597    tags:
598      "*": [untrusted_input]
599subscribe: [fs:file:///watch/inbox]
600a2a_peers:
601  - name: mesh
602    endpoint: unix:/run/peer.sock
603log_level: info
604intelligence_headers:
605  anthropic-version: "2023-06-01"
606"#;
607        let json = r#"{
608            "config_version": "1.0",
609            "model": "claude-opus-4",
610            "max_tokens": 2000000,
611            "limits": { "max_steps": 200, "max_depth": 4, "deadline_secs": 600 },
612            "mcp_servers": [
613                { "name": "web", "endpoint": "https://web.example.com/mcp",
614                  "headers": { "Authorization": "Bearer {{secret:WEB_TOKEN}}" },
615                  "tags": { "*": ["untrusted_input"] } }
616            ],
617            "subscribe": ["fs:file:///watch/inbox"],
618            "a2a_peers": [{ "name": "mesh", "endpoint": "unix:/run/peer.sock" }],
619            "log_level": "info",
620            "intelligence_headers": { "anthropic-version": "2023-06-01" }
621        }"#;
622        let from_yaml = ConfigFile::parse(yaml).expect("yaml parses");
623        let from_json = ConfigFile::parse(json).expect("json parses");
624        assert_eq!(from_yaml, from_json, "one document model, two syntaxes");
625        assert_eq!(from_yaml.limits.as_ref().unwrap().max_steps, Some(200));
626        assert_eq!(from_yaml.mcp_servers[0].tags["*"], vec!["untrusted_input"]);
627    }
628
629    #[test]
630    fn format_detection_by_extension_then_sniff() {
631        assert_eq!(
632            Format::detect(Some(Path::new("/etc/agentd/config.yaml")), "{}"),
633            Format::Yaml
634        );
635        assert_eq!(Format::detect(Some(Path::new("c.YML")), "{}"), Format::Yaml);
636        assert_eq!(
637            Format::detect(Some(Path::new("c.json")), "model: x"),
638            Format::Json
639        );
640        assert_eq!(
641            Format::detect(Some(Path::new("c.jsonc")), "model: x"),
642            Format::Json
643        );
644        // Unknown extension / no path: sniff the first significant character.
645        assert_eq!(
646            Format::detect(Some(Path::new("agentd.conf")), "  { \"a\": 1 }"),
647            Format::Json
648        );
649        assert_eq!(Format::detect(None, "// jsonc\n{ \"a\": 1 }"), Format::Json);
650        assert_eq!(Format::detect(None, "/* c */ [1]"), Format::Json);
651        assert_eq!(Format::detect(None, "# yaml\nmodel: x\n"), Format::Yaml);
652        assert_eq!(Format::detect(None, "model: x\n"), Format::Yaml);
653        assert_eq!(Format::detect(None, ""), Format::Yaml);
654    }
655
656    #[test]
657    fn merge_follows_json_merge_patch() {
658        let mut base = json!({
659            "model": "base",
660            "limits": {"max_steps": 1, "max_depth": 2},
661            "subscribe": ["a", "b"],
662            "intelligence_headers": {"h1": "v1"},
663            "log_level": "info"
664        });
665        merge_into(
666            &mut base,
667            json!({
668                "model": "over",                    // scalar: replaced
669                "limits": {"max_steps": 9},         // object: merged (max_depth kept)
670                "subscribe": ["c"],                 // list: REPLACED, not appended
671                "intelligence_headers": {"h2": "v2"}, // map: merged
672                "log_level": null                   // null: unset
673            }),
674        );
675        assert_eq!(
676            base,
677            json!({
678                "model": "over",
679                "limits": {"max_steps": 9, "max_depth": 2},
680                "subscribe": ["c"],
681                "intelligence_headers": {"h1": "v1", "h2": "v2"}
682            })
683        );
684        // A scalar in the way of an object overlay is replaced by the object.
685        let mut base = json!({"limits": 5});
686        merge_into(&mut base, json!({"limits": {"max_steps": 1}}));
687        assert_eq!(base, json!({"limits": {"max_steps": 1}}));
688    }
689
690    #[test]
691    fn multiple_files_merge_in_order_later_wins() {
692        let dir = tempfile::tempdir().unwrap();
693        let base = dir.path().join("base.yaml");
694        let prod = dir.path().join("prod.yaml");
695        let extra = dir.path().join("extra.json");
696        std::fs::write(
697            &base,
698            "model: base\nlimits:\n  max_steps: 1\n  max_depth: 2\nsubscribe: [a, b]\n",
699        )
700        .unwrap();
701        std::fs::write(
702            &prod,
703            "model: prod\nlimits:\n  max_steps: 9\nsubscribe: [c]\n",
704        )
705        .unwrap();
706        std::fs::write(
707            &extra,
708            r#"{ "log_level": "warn", "limits": { "max_depth": null } }"#,
709        )
710        .unwrap();
711        let paths: Vec<String> = [&base, &prod, &extra]
712            .iter()
713            .map(|p| p.to_str().unwrap().to_string())
714            .collect();
715        let (doc, loaded) = read_documents(&paths).unwrap();
716        assert_eq!(
717            doc,
718            json!({
719                "model": "prod",
720                "limits": {"max_steps": 9},
721                "subscribe": ["c"],
722                "log_level": "warn"
723            })
724        );
725        assert_eq!(loaded.len(), 3);
726        assert_eq!(loaded[0].1, Format::Yaml);
727        assert_eq!(loaded[2].1, Format::Json);
728        // An unknown key is attributed to the file that carries it.
729        std::fs::write(&prod, "modle: typo\n").unwrap();
730        let e = read_documents(&paths).unwrap_err();
731        assert!(e.contains("prod.yaml") && e.contains("modle"), "{e}");
732        // A missing file is an error naming it.
733        let e = read_documents(&["/no/such/agentd.yaml".to_string()]).unwrap_err();
734        assert!(e.contains("/no/such/agentd.yaml"), "{e}");
735    }
736
737    #[test]
738    fn a_non_mapping_document_is_rejected() {
739        let e = parse_document("- a\n- b\n", Format::Yaml).unwrap_err();
740        assert!(e.contains("mapping"), "{e}");
741        let e = parse_document("[1, 2]", Format::Json).unwrap_err();
742        assert!(e.contains("mapping"), "{e}");
743        // An empty YAML file is an empty config (nothing set) — not an error.
744        assert_eq!(
745            parse_document("# nothing yet\n", Format::Yaml).unwrap(),
746            json!({})
747        );
748        // A YAML syntax error names the line.
749        let e = parse_document("a: 1\n\tb: 2\n", Format::Yaml).unwrap_err();
750        assert!(e.contains("(yaml)") && e.contains("line 2"), "{e}");
751    }
752
753    #[test]
754    fn malformed_json_is_an_error() {
755        assert!(ConfigFile::parse("{ not json").is_err());
756    }
757
758    #[test]
759    fn jsonc_comments_are_stripped() {
760        let src = r#"{
761            // a line comment
762            "model": "m", /* block */ "max_tokens": 10,
763            "subscribe": ["http://x//path"]  // a // inside a string is data
764        }"#;
765        let cf = ConfigFile::parse(src).unwrap();
766        assert_eq!(cf.model.as_deref(), Some("m"));
767        assert_eq!(cf.max_tokens, Some(10));
768        // The `//` inside the string literal survived (not treated as a comment).
769        assert_eq!(cf.subscribe, vec!["http://x//path"]);
770    }
771
772    #[test]
773    fn non_ascii_round_trips_through_the_jsonc_stripper() {
774        // Silent corruption is the worst failure mode: mojibake'd text is still
775        // valid JSON, so a byte-wise stripper would hand the agent a subtly wrong
776        // instruction with nothing reporting an error. Every string here must come
777        // back byte-identical, INCLUDING the ones pressed up against a comment —
778        // that adjacency is exactly where a byte-wise stripper splits a sequence.
779        let model = "Ünïcøde — 日本語 μοντέλο";
780        let src = format!(
781            "{{\n  /* 日本語 block */\"model\": \"{model}\",/*é*/\n  \"subscribe\": [\"fs:file:///wätch/收件箱\"] // — trailing 日本語\n}}"
782        );
783        let cf = ConfigFile::parse(&src).unwrap();
784        assert_eq!(cf.model.as_deref(), Some(model), "mojibake in the value");
785        assert_eq!(cf.subscribe, vec!["fs:file:///wätch/收件箱"]);
786        // The stripper itself must be the identity on a comment-free document.
787        let plain = format!("{{ \"model\": \"{model}\" }}");
788        assert_eq!(strip_jsonc(&plain), plain);
789        // A \-escape adjacent to multibyte text must not eat the following byte.
790        let cf = ConfigFile::parse("{ \"model\": \"a\\\"—\\\\é\" }").unwrap();
791        assert_eq!(cf.model.as_deref(), Some("a\"—\\é"));
792    }
793
794    #[test]
795    fn schema_is_parseable_draft_2020_12() {
796        let s = config_schema();
797        assert_eq!(
798            s["$schema"],
799            json!("https://json-schema.org/draft/2020-12/schema")
800        );
801        assert_eq!(s["additionalProperties"], json!(false));
802        assert_eq!(
803            s["x-agentd-contract-version"],
804            json!(SCHEMA_CONTRACT_VERSION)
805        );
806        // It round-trips through serde_json as a valid document.
807        let text = serde_json::to_string(&s).unwrap();
808        let _: Value = serde_json::from_str(&text).unwrap();
809    }
810
811    #[test]
812    fn schema_properties_match_struct_fields() {
813        // The hand-written schema cannot silently diverge from the struct: its
814        // top-level `properties` keys must be EXACTLY the struct's fields.
815        let s = config_schema();
816        let props = s["properties"].as_object().unwrap();
817        let schema_keys: std::collections::BTreeSet<&str> =
818            props.keys().map(String::as_str).collect();
819        let struct_keys: std::collections::BTreeSet<&str> =
820            CONFIG_FILE_FIELDS.iter().copied().collect();
821        assert_eq!(
822            schema_keys, struct_keys,
823            "schema properties drifted from ConfigFile fields"
824        );
825    }
826
827    #[test]
828    fn config_file_fields_const_matches_a_full_deser() {
829        // Guard the CONFIG_FILE_FIELDS const itself: a fully-populated JSON object
830        // keyed by every const entry must deserialize (so a renamed/added struct
831        // field forces the const + schema to be updated together).
832        let mut obj = serde_json::Map::new();
833        for k in CONFIG_FILE_FIELDS {
834            let v = match *k {
835                "config_version" | "model" | "log_level" | "intelligence" => json!("x"),
836                "model_swap" => json!("finish-on-old"),
837                "max_tokens" => json!(1),
838                "limits" => json!({}),
839                "mcp_servers" => json!([{ "name": "a", "endpoint": "unix:/a.sock" }]),
840                "subscribe" => json!(["u"]),
841                "a2a_peers" => json!([{ "name": "p", "endpoint": "unix:/x" }]),
842                "intelligence_headers" => json!({ "h": "v" }),
843                other => panic!("CONFIG_FILE_FIELDS has an unmapped key {other}"),
844            };
845            obj.insert((*k).to_string(), v);
846        }
847        let text = serde_json::to_string(&Value::Object(obj)).unwrap();
848        ConfigFile::parse(&text).expect("every CONFIG_FILE_FIELDS key must deserialize");
849    }
850}