Skip to main content

mecha_core/
config.rs

1//! Layered configuration.
2//!
3//! Later layers win, field by field:
4//!   1. built-in defaults
5//!   2. `~/.mecha/config.toml`
6//!   3. `./mecha.toml` in the working directory (project-local)
7//!   4. environment variables
8//!   5. CLI flags (applied by the caller, not here)
9
10use crate::message::Effort;
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(default, deny_unknown_fields)]
18pub struct Config {
19    /// Which entry in `providers` to use when `--provider` isn't given.
20    pub default_provider: String,
21    pub providers: BTreeMap<String, ProviderConfig>,
22    pub agent: AgentConfig,
23    pub tools: ToolsConfig,
24    pub security: SecurityConfig,
25    /// How `shell` is confined. See [`crate::sandbox`].
26    pub sandbox: crate::sandbox::SandboxConfig,
27    /// MCP servers to connect to at startup.
28    #[serde(rename = "mcp")]
29    pub mcp: Vec<McpServerConfig>,
30    /// Subagents the parent may delegate to, each exposed as one tool.
31    #[serde(rename = "subagent")]
32    pub subagents: Vec<crate::subagent::SubagentProfile>,
33    /// Search backends, in preference order. The chain falls through on
34    /// failure, which is what makes stacking two free tiers viable.
35    #[serde(rename = "search")]
36    pub search: Vec<SearchBackendConfig>,
37    /// User commands run at loop lifecycle points. See [`crate::hooks`].
38    #[serde(rename = "hook")]
39    pub hooks: Vec<HookConfig>,
40    /// Outbound tools staged for user review instead of executed. See
41    /// [`crate::outbox`].
42    pub outbox: OutboxConfig,
43    /// Retention for `~/.mecha/work/`. See [`crate::work`].
44    pub work: WorkConfig,
45    /// Tunables for `mecha slack`. Global-file only; see [`SlackConfig`].
46    pub slack: SlackConfig,
47    /// Inter-agent messages between mecha sessions on this machine. See
48    /// [`crate::mailbox`].
49    pub messages: MessagesConfig,
50    /// Which of `~/.mecha/skills/` a run carries. See [`crate::skill`].
51    pub skills: SkillsConfig,
52    /// The tailnet web surface (`mecha serve`). Global-file only, like
53    /// `[slack]`: it names a listening port and the one identity allowed
54    /// through the door. (Replaces the opaque `toml::Value` bridge main
55    /// carried while this arc was in flight.)
56    pub web: WebConfig,
57}
58
59/// Which skills a run carries.
60///
61/// **There is no way to author a skill here, and that absence is the whole
62/// design.** A skill body only ever comes from `~/.mecha/skills/`, which the
63/// user writes by hand; config names skills, and naming is not authoring. The
64/// threat this forecloses is the one Datadog named — *a cloned repository can
65/// bring skills into a trusted session even if the developer never installed
66/// one from a marketplace* — and it is foreclosed the same way it is for
67/// `[[trigger]]`: by there being nowhere to put one.
68///
69/// A project's `mecha.toml` may still narrow the set, because narrowing is
70/// always safe and a repository saying "these three are the relevant ones" is
71/// useful. It may never widen it: see [`SkillsLayer`] for how that is
72/// enforced rather than asked for.
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74#[serde(default, deny_unknown_fields)]
75pub struct SkillsConfig {
76    /// Skills to carry. Empty means every skill in the store.
77    pub enabled: Vec<String>,
78    /// Skills to withhold, applied after `enabled` so it wins.
79    pub disabled: Vec<String>,
80    /// Where the store lives. Defaults to `~/.mecha/skills`.
81    ///
82    /// Global-file only — a project layer naming its own directory would be
83    /// the authoring hole this type exists to close, wearing a different hat.
84    pub dir: Option<PathBuf>,
85}
86
87/// Messaging between this machine's own mecha sessions.
88///
89/// Receiver-side policy, so it loads from the global file only, never a
90/// project's `mecha.toml`: a cloned repository must not be able to set
91/// `inbound = "accept"` on someone's session. Enforced structurally:
92/// `merge_file` strips the section from project layers, loudly.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(default, deny_unknown_fields)]
95pub struct MessagesConfig {
96    /// Off by default, like outbox routing: a mailbox is a policy decision.
97    pub enabled: bool,
98    /// Where messages live. Defaults to `~/.mecha/messages`
99    /// (or `$MECHA_MESSAGES_DIR`).
100    pub dir: Option<PathBuf>,
101    /// What a run does with inbound messages: `accept` folds them in at turn
102    /// boundaries, `hold` leaves them for `mecha msg`. Unset — the default —
103    /// resolves per surface: attended front-ends hold, unattended runs
104    /// accept. See [`crate::mailbox::InboundPolicy`].
105    pub inbound: Option<crate::mailbox::InboundPolicy>,
106    /// Pending messages one recipient may hold before senders are refused.
107    pub pending_cap: usize,
108    /// Largest message body, in bytes.
109    pub max_body_bytes: usize,
110    /// Resolved (delivered/dismissed) messages kept per recipient before the
111    /// oldest are pruned. Retention, so the per-turn claim scan stays bounded.
112    pub keep: usize,
113}
114
115impl Default for MessagesConfig {
116    fn default() -> Self {
117        MessagesConfig {
118            enabled: false,
119            dir: None,
120            inbound: None,
121            pending_cap: crate::mailbox::DEFAULT_PENDING_CAP,
122            max_body_bytes: crate::mailbox::DEFAULT_MAX_BODY_BYTES,
123            keep: crate::mailbox::DEFAULT_KEEP_RESOLVED,
124        }
125    }
126}
127
128/// Which tools are outbox-routed, and where staged items live.
129#[derive(Debug, Clone, Default, Serialize, Deserialize)]
130#[serde(default, deny_unknown_fields)]
131pub struct OutboxConfig {
132    /// Registry names (`email__send`, `web__fetch`). A call to one of these
133    /// is staged as a draft the user reviews with `mecha outbox`; the tool
134    /// itself never runs until they release it. Empty means the outbox is
135    /// off, which is the default — routing a tool is a policy decision.
136    pub tools: Vec<String>,
137    /// Where items are staged. Defaults to `~/.mecha/outbox`
138    /// (or `$MECHA_OUTBOX_DIR`).
139    pub dir: Option<PathBuf>,
140    /// Which of the routed names are *publications* rather than messages
141    /// (`factory__bundle_publish`, `factory__bundle_alias`). They stage
142    /// identically; they are **reviewed** differently — the reviewable object
143    /// is the rendered page, `edit` is refused, and the writing-reflection
144    /// miner skips them so a changed directory path never becomes a voice
145    /// rule. See [`crate::outbox::OutboxKind`].
146    ///
147    /// Config's to declare, not the tool's: the loop must not learn what a
148    /// publish is, and a third-party MCP server cannot be trusted to say.
149    pub publish_tools: Vec<String>,
150}
151
152/// How much of a producer's generated output survives a `mecha work clean`.
153///
154/// A policy rather than an intention: the lesson of this project is that
155/// anything without one becomes a pile nobody opens. The number is small on
156/// purpose — the directory is scratch, and what matters is published.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(default, deny_unknown_fields)]
159pub struct WorkConfig {
160    /// Entries kept per producer, newest first.
161    pub keep: usize,
162}
163
164/// `[slack]` — tunables for the Slack remote control. **Nothing here grants
165/// anything.** Who may drive the agent lives in `~/.mecha/slack/binding.json`,
166/// a store rather than config, for the reason `[messages]` is global-only and
167/// then some: a project file arrives with a cloned repository, and a repo that
168/// could name a Slack owner would have been handed the remote control.
169#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
170#[serde(default)]
171pub struct SlackConfig {
172    /// Threads that may have a run in flight at once. At the cap the connector
173    /// refuses and says so, rather than queueing: a run that starts twenty
174    /// minutes later against a workspace that has moved is worse than an
175    /// honest refusal.
176    pub max_concurrent: usize,
177    /// How long an approval card waits before the call is refused as
178    /// unanswered. Never a denial by the user — see `Decision::Blocked`.
179    pub approval_timeout_secs: u64,
180    /// `ask` (the default), `allow`, or `read-only`, for a thread nobody has
181    /// set a mode on.
182    pub default_mode: String,
183    pub max_turns: u32,
184    pub max_cost_usd: Option<f64>,
185    /// Flush a streamed chunk once this much text has accumulated, or this
186    /// long has passed — whichever comes first. Size first is Slack's own
187    /// guidance; the timer is so a slow model still shows progress.
188    pub stream_flush_chars: usize,
189    pub stream_flush_ms: u64,
190    /// Largest file to move between a workspace and Slack, in **both**
191    /// directions: an attachment fetched into a run's workspace, and anything
192    /// `/send` puts back. Slack allows 1 GB; a remote control does not need
193    /// to. One number rather than two, because "how big is too big to move
194    /// over this link" is one question and two answers to it drift.
195    pub max_upload_mb: u64,
196    /// Narrow the tool surface for Slack-driven runs.
197    ///
198    /// Empty means "everything configured", which is the default and is
199    /// usually too much: measured on the first live run, the schemas of every
200    /// wired MCP server cost ~7–8k input tokens *per turn* before any work
201    /// happened — against a 32k window whose compaction threshold is 21,845,
202    /// a run starts a third of the way there. A phone rarely needs the mail
203    /// and the calendar and the factory at once, and naming what it does need
204    /// is the cheapest context this system has to give.
205    pub tools: Vec<String>,
206}
207
208impl Default for SlackConfig {
209    fn default() -> Self {
210        SlackConfig {
211            max_concurrent: 3,
212            approval_timeout_secs: 600,
213            default_mode: "ask".into(),
214            max_turns: 40,
215            max_cost_usd: None,
216            stream_flush_chars: 800,
217            stream_flush_ms: 1000,
218            max_upload_mb: 25,
219            tools: Vec::new(),
220        }
221    }
222}
223
224impl Default for WorkConfig {
225    fn default() -> Self {
226        WorkConfig {
227            keep: crate::work::DEFAULT_KEEP,
228        }
229    }
230}
231
232/// One hook: a command run at a lifecycle point, with the event payload as
233/// JSON on stdin.
234#[derive(Debug, Clone, Default, Serialize, Deserialize)]
235#[serde(default, deny_unknown_fields)]
236pub struct HookConfig {
237    /// `pre_tool` | `post_tool` | `session_end`. An unknown event is a startup
238    /// error, not a warning — a policy hook that never fires because its event
239    /// name has a typo is the silently-degrading-sandbox mistake again.
240    pub event: String,
241    /// Run via `sh -c`, as the user, in the workspace.
242    pub command: String,
243    /// Only fire for these tools (`pre_tool`/`post_tool`). Empty means all.
244    pub tools: Vec<String>,
245    /// Kill the hook after this long. The default is deliberately short: a
246    /// `pre_tool` hook is on the critical path of every call it matches.
247    pub timeout_secs: Option<u64>,
248}
249
250impl Default for Config {
251    fn default() -> Self {
252        let mut providers = BTreeMap::new();
253        providers.insert(
254            "anthropic".to_string(),
255            ProviderConfig {
256                kind: "anthropic".to_string(),
257                model: Some(crate::provider::anthropic::DEFAULT_MODEL.to_string()),
258                api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
259                api_key: None,
260                base_url: None,
261                input_price_per_mtok: None,
262                output_price_per_mtok: None,
263                temperature: None,
264                seed: None,
265                context_window: None,
266                // `None`, not `Some(true)`: the per-kind default in
267                // `vision_enabled` is the one place that decision is made,
268                // and writing it here too would be a second copy that can
269                // drift from it.
270                vision: None,
271                max_retries: None,
272                retry_after_cap_secs: None,
273                fallbacks: Vec::new(),
274            },
275        );
276        Config {
277            default_provider: "anthropic".to_string(),
278            providers,
279            agent: AgentConfig::default(),
280            tools: ToolsConfig::default(),
281            security: SecurityConfig::default(),
282            skills: SkillsConfig::default(),
283            sandbox: crate::sandbox::SandboxConfig::default(),
284            mcp: Vec::new(),
285            subagents: Vec::new(),
286            search: Vec::new(),
287            hooks: Vec::new(),
288            outbox: OutboxConfig::default(),
289            work: WorkConfig::default(),
290            slack: SlackConfig::default(),
291            messages: MessagesConfig::default(),
292            web: WebConfig::default(),
293        }
294    }
295}
296
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
298#[serde(default, deny_unknown_fields)]
299pub struct ProviderConfig {
300    /// `anthropic` | `openai` | `local`
301    pub kind: String,
302    pub model: Option<String>,
303    /// Environment variable holding the key. Preferred over `api_key`.
304    pub api_key_env: Option<String>,
305    /// Inline key. Convenient, but it lands in a file on disk — prefer the env var.
306    pub api_key: Option<String>,
307    pub base_url: Option<String>,
308    /// Per-million-token prices, so budgets and reporting can be in dollars.
309    /// Leave unset for a local model — the marginal cost really is zero.
310    pub input_price_per_mtok: Option<f64>,
311    pub output_price_per_mtok: Option<f64>,
312    /// Sampling temperature, sent verbatim by providers that accept one. Unset
313    /// means the server's default. Do not reach for 0.0 to get repeatability:
314    /// measured on qwen3.6, greedy decoding walks into verbatim repetition
315    /// loops that sampling noise would have broken. Pin the server's own
316    /// default value and set `seed` instead — same distribution, repeatable
317    /// draws. The Anthropic API rejects the parameter, so setting this on an
318    /// `anthropic` provider is a startup error rather than a silent no-op.
319    pub temperature: Option<f64>,
320    /// Sampling seed, for repeatable draws at a nonzero temperature. Only as
321    /// deterministic as the backend: llama-server repeats exactly when requests
322    /// run one at a time, and does not once concurrent requests share a batch.
323    /// Rejected on `anthropic` for the same reason as `temperature`.
324    pub seed: Option<u64>,
325    /// How many tokens this model's context holds — for a local server, the
326    /// `-c` it was started with.
327    ///
328    /// Nothing here can discover this: a provider reports how many tokens a
329    /// prompt *used*, never how many are left. Without it the compaction
330    /// threshold has to be an absolute number somebody remembers to set, and
331    /// when nobody does, a long session dies on a raw
332    /// `exceed_context_size_error` from the server with the whole run lost.
333    /// With it, [`AgentConfig::compact_at`] derives a threshold and the CLI
334    /// can show how much room is left.
335    pub context_window: Option<u64>,
336    /// Whether the model on the other end can see an image.
337    ///
338    /// Declared rather than discovered, for the same reason `context_window`
339    /// is: the Anthropic API has no endpoint that answers it. llama-server
340    /// *does* — `GET /props` reports `modalities.vision` — so preflight
341    /// checks the two against each other and warns on a disagreement in
342    /// **either** direction. Both directions matter and for different
343    /// reasons: declared-but-not-served means every image silently degrades
344    /// to a line of text, and served-but-not-declared means a projector is
345    /// loaded, paid for in memory, and never used.
346    ///
347    /// Unset defaults to `true` for `kind = "anthropic"` (every Claude model
348    /// in the family sees) and `false` everywhere else. False is the safe
349    /// default for a local server because the failure it prevents is the
350    /// expensive one: an `image_url` part sent to a text-only llama-server is
351    /// a failed request, where an image rendered as text is merely a model
352    /// that cannot see — which is what it was before.
353    ///
354    /// **A vision model is two files.** The weights carry the language model;
355    /// the vision tower ships beside them as a separate `mmproj-*.gguf` that
356    /// `--mmproj` must name. `--mmproj-auto` is on by default and only fires
357    /// for `-hf` downloads, so every start script here using `-m <path>` gets
358    /// nothing from it. See `docs/LLAMA-SERVER.md`.
359    pub vision: Option<bool>,
360    /// Retries per request on transient failures — 429, 5xx, transport. 0
361    /// disables. Unset means 3. Auth, billing, invalid-request and
362    /// context-overflow errors are never retried: the same payload fails the
363    /// same way, and overflow belongs to the compaction path.
364    pub max_retries: Option<u32>,
365    /// A `Retry-After` above this many seconds is surfaced as a failure
366    /// instead of slept through (default 60) — a provider can name a wait
367    /// long enough that the process is simply asleep, and control never
368    /// returns to a layer that could fall back instead.
369    pub retry_after_cap_secs: Option<u64>,
370    /// Provider entries to try, in order, when this one exhausts its retries
371    /// on a *transient* failure. Turn-local: the next turn starts from this
372    /// provider again. Each fallback answers with its own model. Empty —
373    /// the default — means strict: fail rather than silently answer with a
374    /// different model. `mecha eval` never falls back regardless: a
375    /// scorecard grades the model it names.
376    pub fallbacks: Vec<String>,
377}
378
379impl ProviderConfig {
380    /// Whether to render images onto this provider's wire.
381    ///
382    /// The default is per-kind rather than a flat `false` because the two
383    /// kinds know different amounts: every model in the Anthropic family
384    /// this harness speaks to has vision, and nothing about a local server
385    /// is knowable from config alone.
386    pub fn vision_enabled(&self) -> bool {
387        self.vision
388            .unwrap_or(matches!(self.kind.as_str(), "anthropic"))
389    }
390
391    /// Prices, if configured. Both halves are required: knowing one is worse
392    /// than knowing neither, because it silently under-reports.
393    pub fn pricing(&self) -> Option<crate::message::Pricing> {
394        match (self.input_price_per_mtok, self.output_price_per_mtok) {
395            (Some(input), Some(output)) => Some(crate::message::Pricing {
396                input_per_mtok: input,
397                output_per_mtok: output,
398                ..Default::default()
399            }),
400            _ => None,
401        }
402    }
403
404    pub fn resolve_api_key(&self) -> Option<String> {
405        if let Some(var) = &self.api_key_env {
406            if let Ok(v) = std::env::var(var) {
407                if !v.is_empty() {
408                    return Some(v);
409                }
410            }
411        }
412        self.api_key.clone().filter(|k| !k.is_empty())
413    }
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
417#[serde(default, deny_unknown_fields)]
418pub struct AgentConfig {
419    pub system_prompt: Option<String>,
420    /// Read the system prompt from a file. Wins over `system_prompt`.
421    pub system_prompt_file: Option<PathBuf>,
422    /// Hard stop on runaway loops: how many model turns one run may take.
423    pub max_turns: u32,
424    pub max_tokens: u32,
425    pub effort: Option<Effort>,
426    pub thinking: bool,
427    /// Mark the tools + system prefix as cacheable.
428    pub cache_prompt: bool,
429    /// When the turn budget runs out, spend one more turn with the tools
430    /// removed so the model has to answer with what it has. Without this a
431    /// model that never stops searching returns nothing at all.
432    pub force_final_answer: bool,
433    /// Stop once this many output tokens have been generated in one run.
434    /// `max_turns` bounds the number of round trips; this bounds their size,
435    /// which is what actually runs up a bill.
436    pub max_output_tokens: Option<u64>,
437    /// Stop once one run has cost this much. Requires prices on the provider.
438    pub max_cost_usd: Option<f64>,
439    /// Summarise the middle of the conversation once the prompt passes this
440    /// many tokens.
441    ///
442    /// Measured against what the provider *reported* for the last turn rather
443    /// than an estimate, so it tracks the real prompt including cached tokens.
444    /// Unset by default: compaction is lossy, and silently paraphrasing
445    /// someone's conversation because it got long is a decision they should
446    /// make. Set it to roughly two thirds of the model's context window — or
447    /// set `context_window` on the provider and let
448    /// [`AgentConfig::compact_at`] work it out.
449    pub compact_at_tokens: Option<u64>,
450    /// IANA timezone name for the user, e.g. `America/New_York`. Unset means
451    /// the machine's. See [`AgentConfig::timezone`].
452    pub timezone: Option<String>,
453    /// Turns kept verbatim after a compaction. The recent ones are where the
454    /// work is; a summary of the last two turns is worse than the turns.
455    pub compact_keep_recent: usize,
456    /// Stop a run that repeats an identical tool call, with an identical
457    /// result, right after a compaction (`StopCause::Loop`).
458    ///
459    /// On by default — the asymmetry is deliberate. A general repeated-call
460    /// detector would need a measurement to justify watching all of ordinary
461    /// work; this one exists to escape the specific loop that burns unbounded
462    /// tokens at the largest prompts a run will ever send, and a no-config
463    /// user should get that protection. Identical arguments with a *changing*
464    /// result is polling and never trips it.
465    pub loop_guard: bool,
466    /// Tell a run when an approach has stopped teaching it anything
467    /// (`docs/GOAL-SYSTEM-DESIGN.md` §9.1).
468    ///
469    /// On by default, beside `loop_guard`, and the pair is the point: the
470    /// guard *ends* a run that is re-living what a compaction dropped, and
471    /// this speaks to one that is going nowhere while there is still something
472    /// to do about it. It spends nothing — the run was going to happen — so
473    /// there is no cost to weigh against the no-config user getting it. Off is
474    /// for pinning a scorecard, where any harness-authored text is part of
475    /// what a case measures.
476    pub boredom: bool,
477    /// Check each summary against the transcript it replaces before
478    /// installing it, and regenerate once with the omissions named.
479    ///
480    /// Summaries fail by *omission* — they preserve what is true and drop
481    /// task-critical specifics — and the producer cannot see its own gaps.
482    /// A separate grounded comparison can: it reads both texts side by side,
483    /// which is a different task from generating either. Measured elsewhere
484    /// (Slipstream) at +6.4–8.8 points on SWE-bench Verified for under 1%
485    /// latency, with ~90% of catches being omissions. Costs one extra
486    /// request per compaction, two when a regeneration is needed.
487    pub compact_validate: bool,
488    /// Escalate an ambiguous completed step to a quarantined model call
489    /// (`docs/GOAL-SYSTEM-DESIGN.md` §5.5 — a span far longer than its
490    /// siblings, or a step whose own words claim a check its calls never
491    /// made) instead of staying silent.
492    ///
493    /// **Off by default**, unlike `boredom`/`compact_validate`: those ship on
494    /// because each was argued from a measurement (boredom costs nothing;
495    /// compact_validate's omission-catch rate was measured elsewhere). This
496    /// one has no corpus yet — the pre-filter's thresholds are argued, not
497    /// measured, same honesty as `step.rs`'s own constants — so it follows
498    /// `compact_at_tokens`'s posture instead: unset until a person decides to
499    /// spend the model call.
500    pub step_escalation: bool,
501}
502
503impl Default for AgentConfig {
504    fn default() -> Self {
505        AgentConfig {
506            system_prompt: None,
507            system_prompt_file: None,
508            max_turns: 40,
509            // Streaming is the default, so there's no HTTP-timeout reason to
510            // keep this small; leave room for thinking plus the answer.
511            max_tokens: 64_000,
512            effort: Some(Effort::High),
513            thinking: true,
514            cache_prompt: true,
515            force_final_answer: true,
516            // Unset by default: a ceiling that surprises you mid-task is worse
517            // than no ceiling. Set them once you run things unattended.
518            max_output_tokens: None,
519            max_cost_usd: None,
520            compact_at_tokens: None,
521            timezone: None,
522            compact_keep_recent: 6,
523            loop_guard: true,
524            boredom: true,
525            compact_validate: true,
526            step_escalation: false,
527        }
528    }
529}
530
531impl AgentConfig {
532    /// The user's IANA timezone (`America/New_York`), when it is not the
533    /// machine's.
534    ///
535    /// A server runs in UTC and the model has no clock, so without this every
536    /// "what's on Thursday" is answered in the wrong zone — and wrongly in a
537    /// way that looks right, since the times are internally consistent. An
538    /// IANA name rather than an offset, because an offset is wrong twice a
539    /// year.
540    pub fn timezone(&self) -> Option<chrono_tz::Tz> {
541        let name = self.timezone.as_deref()?;
542        match name.parse::<chrono_tz::Tz>() {
543            Ok(tz) => Some(tz),
544            Err(_) => {
545                tracing::warn!("unknown [agent] timezone `{name}`; using the machine's");
546                None
547            }
548        }
549    }
550
551    /// Fraction of a known context window at which to start compacting.
552    ///
553    /// Two thirds, because the threshold is checked *between* turns against
554    /// what the last one reported: the next turn still has to fit the model's
555    /// reply, and a burst of parallel tool results can add several thousand
556    /// tokens before anything gets to look again. Leaving a third of the
557    /// window is what makes the reactive check safe.
558    pub const COMPACT_FRACTION: f64 = 0.66;
559
560    /// Where compaction kicks in for a run: the explicit setting if there is
561    /// one, otherwise derived from the provider's context window.
562    ///
563    /// Deriving it is what turns compaction from something you must remember
564    /// to configure into something that just works — and the failure it
565    /// prevents is total, not gradual: one turn over the window and the
566    /// server refuses the request outright.
567    pub fn compact_at(&self, context_window: Option<u64>) -> Option<u64> {
568        self.compact_at_tokens
569            .or_else(|| context_window.map(|w| (w as f64 * Self::COMPACT_FRACTION) as u64))
570    }
571
572    pub fn resolve_system_prompt(&self) -> Result<Option<String>> {
573        if let Some(path) = &self.system_prompt_file {
574            let text = std::fs::read_to_string(path)
575                .with_context(|| format!("reading system_prompt_file {}", path.display()))?;
576            return Ok(Some(text));
577        }
578        Ok(self.system_prompt.clone())
579    }
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize)]
583#[serde(default, deny_unknown_fields)]
584pub struct ToolsConfig {
585    /// Built-in tools to register. Empty means "all of them".
586    pub enabled: Vec<String>,
587    /// Built-in tools to withhold, applied after `enabled`.
588    pub disabled: Vec<String>,
589    /// Filesystem tools refuse to touch anything outside this root.
590    pub workspace: Option<PathBuf>,
591    /// Default answer when nothing is watching to approve a call.
592    pub permission_mode: PermissionMode,
593    pub shell_timeout_secs: u64,
594    /// The byte budget one turn's tool results share, divided across the
595    /// batch. Oversized results are spilled to a file in full and cut in the
596    /// transcript, with the marker naming the path and the line to resume
597    /// from. Unset means derive it from the provider's context window — see
598    /// [`ToolsConfig::resolved_output_budget`].
599    pub output_budget_bytes: Option<usize>,
600}
601
602impl ToolsConfig {
603    /// Ceiling when nothing pins the budget: right for the wide-window
604    /// frontier models the number was originally chosen against.
605    const OUTPUT_BUDGET_MAX: usize = 24_000;
606    /// Floor: below this, a single `cargo build` error listing stops fitting
607    /// and every result arrives pre-truncated — a budget that starves the
608    /// model of its own results is worse than a tight window.
609    const OUTPUT_BUDGET_MIN: usize = 6_000;
610
611    /// The per-turn tool-output budget, window-proportional when unpinned.
612    ///
613    /// An eighth of the window in tokens, ~3 bytes per token. The constraint
614    /// it serves: the between-turns compaction check reads the *previous*
615    /// turn's prompt size, so one turn's results must not leap the gap
616    /// between the threshold (two thirds of the window) and the window
617    /// itself — a third of the window, shared with the model's own output.
618    /// The old flat 24 KB is ~8–12k tokens of numeric data, *larger* than
619    /// that gap at a 32k window: on the 2026-08-07 Terminal-Bench subset a
620    /// trial jumped from under the threshold to 45k tokens in one turn and
621    /// died on the overflow. An eighth of the window (12,288 bytes at 32k)
622    /// keeps even token-dense results inside the gap with room for output.
623    pub fn resolved_output_budget(&self, context_window: Option<u64>) -> usize {
624        if let Some(pinned) = self.output_budget_bytes {
625            return pinned;
626        }
627        match context_window {
628            Some(window) => {
629                ((window as usize / 8) * 3).clamp(Self::OUTPUT_BUDGET_MIN, Self::OUTPUT_BUDGET_MAX)
630            }
631            None => Self::OUTPUT_BUDGET_MAX,
632        }
633    }
634}
635
636impl Default for ToolsConfig {
637    fn default() -> Self {
638        ToolsConfig {
639            enabled: Vec::new(),
640            disabled: Vec::new(),
641            workspace: None,
642            permission_mode: PermissionMode::Ask,
643            shell_timeout_secs: 120,
644            output_budget_bytes: None,
645        }
646    }
647}
648
649/// Defenses against the *lethal trifecta*: private data, untrusted content, and
650/// a way to send data out. An agent holding all three can be turned into an
651/// exfiltration tool by instructions hidden in the content it reads — a
652/// calendar invite title, an email footer, a web page.
653///
654/// The mitigation is structural, not a filter: once both private data and
655/// untrusted content have entered a conversation, refuse to let it send.
656#[derive(Debug, Clone, Serialize, Deserialize)]
657#[serde(default, deny_unknown_fields)]
658pub struct SecurityConfig {
659    pub trifecta: TrifectaPolicy,
660    /// Refuse HTTP requests to loopback, private, and link-local addresses.
661    /// Without this, `http_fetch` reaches your LAN and cloud metadata endpoints.
662    pub block_private_ips: bool,
663    /// If non-empty, HTTP requests may only go to these hosts (suffix match).
664    pub allowed_domains: Vec<String>,
665    /// Hosts that are always refused, checked before `allowed_domains`.
666    pub blocked_domains: Vec<String>,
667    /// Wrap third-party content in a marker telling the model to treat it as
668    /// data rather than instructions. Weak on its own — defense in depth.
669    pub mark_untrusted_output: bool,
670    /// Block *every* outbound call once private data is in context, whether or
671    /// not untrusted content has arrived.
672    ///
673    /// This is a different control from `trifecta`, guarding a different
674    /// threat. The trifecta interlock stops an *injection* turning the agent
675    /// into an exfiltration tool; it deliberately allows sends that happen
676    /// before any third-party content exists, because nothing could have
677    /// influenced them yet. That still lets the agent put your private data
678    /// into a search query because you asked it to, or because it judged that
679    /// helpful — an ordinary privacy leak rather than an attack.
680    ///
681    /// Turn this on when private data must not leave at all. It is
682    /// restrictive: it makes "read my notes, then look something up" fail.
683    pub block_sends_after_private: bool,
684}
685
686impl Default for SecurityConfig {
687    fn default() -> Self {
688        SecurityConfig {
689            trifecta: TrifectaPolicy::Block,
690            block_private_ips: true,
691            allowed_domains: Vec::new(),
692            blocked_domains: Vec::new(),
693            mark_untrusted_output: true,
694            // Off by default: it breaks common, legitimate workflows, and the
695            // right answer for most people is capability separation (put
696            // search in a subagent with no filesystem access) rather than a
697            // blanket ban.
698            block_sends_after_private: false,
699        }
700    }
701}
702
703#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
704#[serde(rename_all = "kebab-case")]
705pub enum TrifectaPolicy {
706    /// Refuse the send outright. The default.
707    Block,
708    /// Ask a human. Only meaningful when someone is watching.
709    Ask,
710    /// Allow it. Appropriate only when the "untrusted" content is in fact
711    /// trusted — e.g. an allowlist of internal hosts.
712    Allow,
713}
714
715/// Capabilities to force on a server's tools. Absent flags leave the server's
716/// own declaration alone; there is deliberately no way to switch one off.
717#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
718#[serde(default, deny_unknown_fields)]
719pub struct CapabilityOverride {
720    pub private_data: bool,
721    pub untrusted_input: bool,
722    pub external_send: bool,
723    pub destructive: bool,
724}
725
726impl From<CapabilityOverride> for crate::tool::Capabilities {
727    fn from(o: CapabilityOverride) -> Self {
728        crate::tool::Capabilities {
729            private_data: o.private_data,
730            untrusted_input: o.untrusted_input,
731            external_send: o.external_send,
732            destructive: o.destructive,
733        }
734    }
735}
736
737#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
738#[serde(rename_all = "kebab-case")]
739pub enum PermissionMode {
740    /// Prompt before anything that isn't read-only.
741    Ask,
742    /// Run everything without asking. For trusted, headless work.
743    Allow,
744    /// Read-only tools run; everything else is refused.
745    ReadOnly,
746}
747
748#[derive(Debug, Clone, Default, Serialize, Deserialize)]
749#[serde(default, deny_unknown_fields)]
750pub struct SearchBackendConfig {
751    /// `exa` | `tavily` | `searxng`
752    pub kind: String,
753    /// Environment variable holding the key. Preferred over `api_key`.
754    pub api_key_env: Option<String>,
755    pub api_key: Option<String>,
756    /// Required for `searxng` (your instance); optional override elsewhere.
757    pub base_url: Option<String>,
758    pub disabled: bool,
759    /// Try this backend first when the caller asked for a deep search.
760    ///
761    /// The chain is preference-ordered and first-to-answer-wins, which makes
762    /// the free backend the right head for ordinary lookups and the wrong one
763    /// for a research question. This is how config says "this backend earns
764    /// its price on the hard ones" without the loop learning what any backend
765    /// costs. It reorders and never filters, so every backend stays reachable
766    /// as a fallback at either depth.
767    #[serde(default)]
768    pub prefer_deep: bool,
769}
770
771impl SearchBackendConfig {
772    pub fn resolve_api_key(&self) -> Option<String> {
773        if let Some(var) = &self.api_key_env {
774            if let Ok(v) = std::env::var(var) {
775                if !v.is_empty() {
776                    return Some(v);
777                }
778            }
779        }
780        self.api_key.clone().filter(|k| !k.is_empty())
781    }
782}
783
784#[derive(Debug, Clone, Default, Serialize, Deserialize)]
785#[serde(default, deny_unknown_fields)]
786pub struct McpServerConfig {
787    /// Prefixed onto every tool the server exposes, so two servers can both
788    /// have a `search` without colliding.
789    pub name: String,
790    pub command: String,
791    pub args: Vec<String>,
792    /// Values handed to the server explicitly. Use this for a token the server
793    /// needs, so granting it is a decision written down rather than a
794    /// side-effect of what happened to be exported.
795    pub env: BTreeMap<String, String>,
796    /// Variables inherited from mecha's own environment, by name.
797    ///
798    /// Empty by default, and that default is the point: an MCP server is
799    /// third-party code, and a process that inherits your whole environment
800    /// inherits every provider key in it. `PATH`, `HOME`, `LANG`, `LC_ALL` and
801    /// `TZ` always pass through — without them most runtimes cannot start.
802    pub env_passthrough: Vec<String>,
803    /// Confine this server with the configured `[sandbox]` backend.
804    ///
805    /// Off by default because a confined server sees only the workspace and,
806    /// unless allowed, no network — which is wrong for most of the servers
807    /// people actually run. Worth turning on for anything you did not write.
808    pub sandbox: bool,
809    /// Network for this server alone, overriding `[sandbox] network`.
810    ///
811    /// The case this exists for: a third-party server that has to reach its own
812    /// API, confined, while `shell` still has no way off the machine. With one
813    /// shared switch you would have to open `shell` to satisfy the server.
814    pub network: Option<bool>,
815    /// Register this server's tools under their own names, without the
816    /// `<name>__` prefix. Unset means prefixed — the default that lets two
817    /// servers both expose a `search`. Turn it off for a server whose tools
818    /// already carry their own namespace (`kg_*`), where the prefix is pure
819    /// stutter the model types in every call. The setting is a promise of
820    /// distinct names: an unprefixed tool that collides with anything
821    /// already registered fails startup loudly rather than shadowing it.
822    pub prefix_tools: Option<bool>,
823    /// Capabilities forced onto every tool this server exposes, on top of
824    /// whatever it declares for itself.
825    ///
826    /// MCP capability flags come from the server's own `annotations`, which
827    /// means a third-party server decides how much the interlock distrusts it.
828    /// An unannotated tool is treated as private-but-trusted — wrong in the
829    /// dangerous direction for anything that reaches the open world. A Google
830    /// Docs server is the worked example: a document someone shared with you is
831    /// third-party text, and writing into a document an attacker can read is an
832    /// exfiltration channel, so it is all three legs at once and says none of
833    /// them.
834    ///
835    /// Only ever widens — see [`crate::tool::Capabilities::union`].
836    pub capabilities: CapabilityOverride,
837    /// Skip this server without deleting its config.
838    pub disabled: bool,
839}
840
841impl Config {
842    pub fn global_path() -> Option<PathBuf> {
843        crate::work::mecha_home()
844            .ok()
845            .map(|h| h.join("config.toml"))
846    }
847
848    pub const PROJECT_FILE: &'static str = "mecha.toml";
849
850    /// Load defaults, then the global file, then the project file, then env.
851    pub fn load(project_dir: &Path) -> Result<Self> {
852        let mut cfg = Config::default();
853        // Harness overrides sit between defaults and every file layer: an
854        // accepted, measured change applies everywhere, and anything the
855        // user writes in a config file overwrites it. See `harness.rs`.
856        crate::harness::apply_accepted_overrides(&mut cfg);
857        if let Some(path) = Self::global_path() {
858            if path.exists() {
859                cfg.merge_file(&path, LayerTrust::Global)?;
860            }
861        }
862        let project = project_dir.join(Self::PROJECT_FILE);
863        if project.exists() {
864            cfg.merge_file(&project, LayerTrust::Project)?;
865        }
866        cfg.merge_env();
867        Ok(cfg)
868    }
869
870    /// Defaults plus `~/.mecha/config.toml` plus env — no project layer.
871    ///
872    /// For runs that must not be configurable by whatever directory they happen
873    /// to start in. A `mecha.toml` arrives with a cloned repository, and it can
874    /// name MCP servers to spawn, hooks to execute and tools to enable; that is
875    /// a reasonable bargain when a person is sitting there having just decided
876    /// to work in that repository, and not one at all for a
877    /// [`crate::trigger`] firing at 03:00 with nobody watching.
878    pub fn load_global() -> Result<Self> {
879        let mut cfg = Config::default();
880        // Same override layer as `load`: a trigger run benefits from an
881        // accepted change exactly as an interactive one does.
882        crate::harness::apply_accepted_overrides(&mut cfg);
883        if let Some(path) = Self::global_path() {
884            if path.exists() {
885                cfg.merge_file(&path, LayerTrust::Global)?;
886            }
887        }
888        cfg.merge_env();
889        Ok(cfg)
890    }
891
892    fn merge_file(&mut self, path: &Path, trust: LayerTrust) -> Result<()> {
893        let text =
894            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
895        let mut layer: ConfigLayer =
896            toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
897        // `[messages]` is receiver-side admission policy, and a project file
898        // arrives with a cloned repository — it must not be able to switch a
899        // session's inbound handling to `accept`. Dropped loudly rather than
900        // silently: an ignored section that looks applied is the
901        // silently-degrading-sandbox shape.
902        if trust == LayerTrust::Project && layer.messages.take().is_some() {
903            tracing::warn!(
904                "[messages] in {} is ignored — messaging policy loads from the \
905                 global config only",
906                path.display()
907            );
908        }
909        // `[slack]` for the same reason, and a stronger one: a project file
910        // arrives with a cloned repository, and Slack is the remote control.
911        if trust == LayerTrust::Project && layer.slack.take().is_some() {
912            tracing::warn!(
913                "[slack] in {} is ignored — the Slack surface loads from the \
914                 global config only",
915                path.display()
916            );
917        }
918        // `[web]` for the `[slack]` reason, in its web costume: the section
919        // names the remote control's door — a listening port and the one
920        // identity allowed through it.
921        if trust == LayerTrust::Project && layer.web.take().is_some() {
922            tracing::warn!(
923                "[web] in {} is ignored — the web surface loads from the \
924                 global config only",
925                path.display()
926            );
927        }
928        // `[skills]` from a project layer may only ever *narrow*, and that is
929        // enforced here rather than asked for. `dir` is dropped outright — a
930        // project naming its own skill directory is the authoring hole
931        // `SkillsConfig` exists to close, wearing a different hat — and
932        // `enabled` is intersected with what is already selected rather than
933        // replacing it, so a repository cannot turn on a skill the user did
934        // not. `disabled` is left alone: withholding is always safe.
935        if trust == LayerTrust::Project {
936            if let Some(skills) = layer.skills.as_mut() {
937                if skills.dir.take().is_some() {
938                    tracing::warn!(
939                        "[skills] dir in {} is ignored — the skill store loads from the \
940                         global config only",
941                        path.display()
942                    );
943                }
944                if let Some(wanted) = skills.enabled.as_mut() {
945                    let already = &self.skills.enabled;
946                    if !already.is_empty() {
947                        wanted.retain(|name| already.contains(name));
948                    }
949                    // An empty global list means "everything", so a project
950                    // list stands as written — still a narrowing, since the
951                    // baseline was the whole store.
952                }
953                // `disabled` unions, and the union has to happen *here*
954                // rather than being left to `apply`, which assigns. A project
955                // shipping `disabled = []` would otherwise wipe the user's
956                // global list and carry the very skill they withheld —
957                // widening by writing an empty list, which is the exact hole
958                // this layer exists to close. Folding the global list into
959                // the project's makes the later assignment a union by
960                // construction.
961                if let Some(withheld) = skills.disabled.as_mut() {
962                    for name in &self.skills.disabled {
963                        if !withheld.contains(name) {
964                            withheld.push(name.clone());
965                        }
966                    }
967                }
968            }
969        }
970        layer.apply(self);
971        Ok(())
972    }
973
974    fn merge_env(&mut self) {
975        if let Ok(v) = std::env::var("MECHA_PROVIDER") {
976            self.default_provider = v;
977        }
978        if let Ok(v) = std::env::var("MECHA_MODEL") {
979            let name = self.default_provider.clone();
980            if let Some(p) = self.providers.get_mut(&name) {
981                p.model = Some(v);
982            }
983        }
984        if let Ok(v) = std::env::var("MECHA_EFFORT") {
985            if let Ok(e) = v.parse() {
986                self.agent.effort = Some(e);
987            }
988        }
989    }
990
991    pub fn provider(&self, name: Option<&str>) -> Result<(String, &ProviderConfig)> {
992        let name = name.unwrap_or(&self.default_provider).to_string();
993        let cfg = self.providers.get(&name).with_context(|| {
994            format!(
995                "no provider named {name:?}. Configured: {}",
996                self.providers
997                    .keys()
998                    .cloned()
999                    .collect::<Vec<_>>()
1000                    .join(", ")
1001            )
1002        })?;
1003        Ok((name, cfg))
1004    }
1005
1006    /// Write this config to `path`, creating parent directories.
1007    pub fn save(&self, path: &Path) -> Result<()> {
1008        if let Some(parent) = path.parent() {
1009            std::fs::create_dir_all(parent)?;
1010        }
1011        let text = toml::to_string_pretty(self)?;
1012        std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
1013        Ok(())
1014    }
1015}
1016
1017/// Which file a layer came from, deciding what it may set.
1018#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019enum LayerTrust {
1020    Global,
1021    Project,
1022}
1023
1024/// Tunables for `mecha serve` — the tailnet web surface.
1025///
1026/// Global-file only, enforced in `merge_file`: a project file arrives with a
1027/// cloned repository, and this section names a listening port and the one
1028/// identity allowed through the door.
1029#[derive(Debug, Clone, Serialize, Deserialize)]
1030#[serde(default, deny_unknown_fields)]
1031pub struct WebConfig {
1032    /// Port bound on 127.0.0.1 — `tailscale serve` fronts it, and there is
1033    /// deliberately no setting to bind wider (the `dsh` refusal, adopted).
1034    pub port: u16,
1035    /// The Tailscale login every request must carry in
1036    /// `Tailscale-User-Login`, which `tailscale serve` injects. Unset means
1037    /// `mecha serve` refuses to start: a door with no owner check must not
1038    /// open at all.
1039    pub owner_login: Option<String>,
1040    /// Directory holding the built web app (`web/dist`). Unset serves the
1041    /// API routes only, which is what tests and a headless box want.
1042    pub assets: Option<PathBuf>,
1043    /// Where the TTS server's voice references live — the host side of the
1044    /// directory the Chatterbox container mounts read-only as `/voices`
1045    /// (each `<name>.wav` is a cloning reference; the file *is* the voice).
1046    /// Unset disables voice cloning from the settings page, which is the
1047    /// honest default: nothing here can guess where a container's mount
1048    /// points, and writing WAVs into a wrong directory would litter it
1049    /// silently.
1050    pub voices_dir: Option<PathBuf>,
1051}
1052
1053impl Default for WebConfig {
1054    fn default() -> Self {
1055        Self {
1056            // "mecha" typed on a phone keypad.
1057            port: 63242,
1058            owner_login: None,
1059            assets: None,
1060            voices_dir: None,
1061        }
1062    }
1063}
1064
1065#[derive(Debug, Default, Deserialize)]
1066#[serde(deny_unknown_fields)]
1067struct WebLayer {
1068    port: Option<u16>,
1069    owner_login: Option<String>,
1070    assets: Option<PathBuf>,
1071    voices_dir: Option<PathBuf>,
1072}
1073
1074/// A partially-specified config file. Every field is optional so a project file
1075/// can override one setting without restating the rest.
1076#[derive(Debug, Default, Deserialize)]
1077#[serde(deny_unknown_fields)]
1078struct ConfigLayer {
1079    default_provider: Option<String>,
1080    providers: Option<BTreeMap<String, ProviderConfig>>,
1081    agent: Option<AgentLayer>,
1082    tools: Option<ToolsLayer>,
1083    security: Option<SecurityLayer>,
1084    #[serde(rename = "mcp")]
1085    mcp: Option<Vec<McpServerConfig>>,
1086    #[serde(rename = "subagent")]
1087    subagents: Option<Vec<crate::subagent::SubagentProfile>>,
1088    #[serde(rename = "search")]
1089    search: Option<Vec<SearchBackendConfig>>,
1090    #[serde(rename = "hook")]
1091    hooks: Option<Vec<HookConfig>>,
1092    sandbox: Option<SandboxLayer>,
1093    outbox: Option<OutboxLayer>,
1094    work: Option<WorkLayer>,
1095    slack: Option<SlackLayer>,
1096    messages: Option<MessagesLayer>,
1097    skills: Option<SkillsLayer>,
1098    web: Option<WebLayer>,
1099}
1100
1101/// A layer's opinion about which skills to carry.
1102///
1103/// The merge is **narrowing-only, structurally**, which is why this cannot be
1104/// the usual "later layer wins" assignment:
1105///
1106/// - `enabled` **intersects** with what is already selected. A project asking
1107///   for a skill the global layer did not enable gets nothing, so the list can
1108///   only ever shrink.
1109/// - `disabled` **unions**. Withholding is always allowed.
1110///
1111/// The alternative — assignment, with a note asking project files not to
1112/// widen — is the shape this repository refuses everywhere else: a rule that
1113/// holds until the first person in a hurry. Note that the global layer is
1114/// where an intersection starts from nothing, so it assigns rather than
1115/// intersects; the distinction is [`LayerTrust`], applied in
1116/// [`Config::merge_file`].
1117#[derive(Debug, Default, Deserialize)]
1118#[serde(deny_unknown_fields)]
1119struct SkillsLayer {
1120    enabled: Option<Vec<String>>,
1121    disabled: Option<Vec<String>>,
1122    dir: Option<PathBuf>,
1123}
1124
1125#[derive(Debug, Default, Deserialize)]
1126#[serde(deny_unknown_fields)]
1127struct MessagesLayer {
1128    enabled: Option<bool>,
1129    dir: Option<PathBuf>,
1130    inbound: Option<crate::mailbox::InboundPolicy>,
1131    pending_cap: Option<usize>,
1132    max_body_bytes: Option<usize>,
1133    keep: Option<usize>,
1134}
1135
1136#[derive(Debug, Default, Deserialize)]
1137#[serde(deny_unknown_fields)]
1138struct WorkLayer {
1139    keep: Option<usize>,
1140}
1141
1142#[derive(Debug, Default, Deserialize)]
1143#[serde(deny_unknown_fields)]
1144struct SlackLayer {
1145    max_concurrent: Option<usize>,
1146    approval_timeout_secs: Option<u64>,
1147    default_mode: Option<String>,
1148    max_turns: Option<u32>,
1149    max_cost_usd: Option<f64>,
1150    stream_flush_chars: Option<usize>,
1151    stream_flush_ms: Option<u64>,
1152    max_upload_mb: Option<u64>,
1153    tools: Option<Vec<String>>,
1154}
1155
1156#[derive(Debug, Default, Deserialize)]
1157#[serde(deny_unknown_fields)]
1158struct OutboxLayer {
1159    tools: Option<Vec<String>>,
1160    dir: Option<PathBuf>,
1161    publish_tools: Option<Vec<String>>,
1162}
1163
1164#[derive(Debug, Default, Deserialize)]
1165#[serde(deny_unknown_fields)]
1166struct AgentLayer {
1167    system_prompt: Option<String>,
1168    system_prompt_file: Option<PathBuf>,
1169    max_turns: Option<u32>,
1170    max_tokens: Option<u32>,
1171    effort: Option<Effort>,
1172    thinking: Option<bool>,
1173    cache_prompt: Option<bool>,
1174    force_final_answer: Option<bool>,
1175    max_output_tokens: Option<u64>,
1176    max_cost_usd: Option<f64>,
1177    compact_at_tokens: Option<u64>,
1178    compact_keep_recent: Option<usize>,
1179    compact_validate: Option<bool>,
1180    loop_guard: Option<bool>,
1181    boredom: Option<bool>,
1182    step_escalation: Option<bool>,
1183    timezone: Option<String>,
1184}
1185
1186#[derive(Debug, Default, Deserialize)]
1187#[serde(deny_unknown_fields)]
1188struct SecurityLayer {
1189    trifecta: Option<TrifectaPolicy>,
1190    block_private_ips: Option<bool>,
1191    allowed_domains: Option<Vec<String>>,
1192    blocked_domains: Option<Vec<String>>,
1193    mark_untrusted_output: Option<bool>,
1194    block_sends_after_private: Option<bool>,
1195}
1196
1197#[derive(Debug, Default, Deserialize)]
1198#[serde(deny_unknown_fields)]
1199struct SandboxLayer {
1200    kind: Option<crate::sandbox::Backend>,
1201    network: Option<bool>,
1202    writable: Option<Vec<PathBuf>>,
1203    readable: Option<Vec<PathBuf>>,
1204    env: Option<Vec<String>>,
1205    image: Option<String>,
1206    memory_mb: Option<u64>,
1207    cpus: Option<f64>,
1208}
1209
1210#[derive(Debug, Default, Deserialize)]
1211#[serde(deny_unknown_fields)]
1212struct ToolsLayer {
1213    enabled: Option<Vec<String>>,
1214    disabled: Option<Vec<String>>,
1215    workspace: Option<PathBuf>,
1216    permission_mode: Option<PermissionMode>,
1217    shell_timeout_secs: Option<u64>,
1218    output_budget_bytes: Option<usize>,
1219}
1220
1221impl ConfigLayer {
1222    fn apply(self, cfg: &mut Config) {
1223        if let Some(v) = self.default_provider {
1224            cfg.default_provider = v;
1225        }
1226        // Providers merge by key so a project file can add a local endpoint
1227        // without redeclaring the Anthropic one.
1228        if let Some(providers) = self.providers {
1229            cfg.providers.extend(providers);
1230        }
1231        if let Some(a) = self.agent {
1232            let t = &mut cfg.agent;
1233            if a.system_prompt.is_some() {
1234                t.system_prompt = a.system_prompt;
1235            }
1236            if a.system_prompt_file.is_some() {
1237                t.system_prompt_file = a.system_prompt_file;
1238            }
1239            if let Some(v) = a.max_turns {
1240                t.max_turns = v;
1241            }
1242            if let Some(v) = a.max_tokens {
1243                t.max_tokens = v;
1244            }
1245            if a.effort.is_some() {
1246                t.effort = a.effort;
1247            }
1248            if let Some(v) = a.thinking {
1249                t.thinking = v;
1250            }
1251            if let Some(v) = a.cache_prompt {
1252                t.cache_prompt = v;
1253            }
1254            if let Some(v) = a.force_final_answer {
1255                t.force_final_answer = v;
1256            }
1257            if a.max_output_tokens.is_some() {
1258                t.max_output_tokens = a.max_output_tokens;
1259            }
1260            if a.max_cost_usd.is_some() {
1261                t.max_cost_usd = a.max_cost_usd;
1262            }
1263            if a.compact_at_tokens.is_some() {
1264                t.compact_at_tokens = a.compact_at_tokens;
1265            }
1266            if let Some(v) = a.compact_keep_recent {
1267                t.compact_keep_recent = v;
1268            }
1269            if let Some(v) = a.compact_validate {
1270                t.compact_validate = v;
1271            }
1272            if let Some(v) = a.boredom {
1273                t.boredom = v;
1274            }
1275            if let Some(v) = a.loop_guard {
1276                t.loop_guard = v;
1277            }
1278            if let Some(v) = a.step_escalation {
1279                t.step_escalation = v;
1280            }
1281            if a.timezone.is_some() {
1282                t.timezone = a.timezone;
1283            }
1284        }
1285        if let Some(x) = self.tools {
1286            let t = &mut cfg.tools;
1287            if let Some(v) = x.enabled {
1288                t.enabled = v;
1289            }
1290            if let Some(v) = x.disabled {
1291                t.disabled = v;
1292            }
1293            if x.workspace.is_some() {
1294                t.workspace = x.workspace;
1295            }
1296            if let Some(v) = x.permission_mode {
1297                t.permission_mode = v;
1298            }
1299            if let Some(v) = x.shell_timeout_secs {
1300                t.shell_timeout_secs = v;
1301            }
1302            if let Some(v) = x.output_budget_bytes {
1303                t.output_budget_bytes = Some(v);
1304            }
1305        }
1306        if let Some(x) = self.security {
1307            let t = &mut cfg.security;
1308            if let Some(v) = x.trifecta {
1309                t.trifecta = v;
1310            }
1311            if let Some(v) = x.block_private_ips {
1312                t.block_private_ips = v;
1313            }
1314            if let Some(v) = x.allowed_domains {
1315                t.allowed_domains = v;
1316            }
1317            if let Some(v) = x.blocked_domains {
1318                t.blocked_domains = v;
1319            }
1320            if let Some(v) = x.mark_untrusted_output {
1321                t.mark_untrusted_output = v;
1322            }
1323            if let Some(v) = x.block_sends_after_private {
1324                t.block_sends_after_private = v;
1325            }
1326        }
1327        if let Some(x) = self.sandbox {
1328            let t = &mut cfg.sandbox;
1329            if let Some(v) = x.kind {
1330                t.kind = v;
1331            }
1332            if let Some(v) = x.network {
1333                t.network = v;
1334            }
1335            if let Some(v) = x.writable {
1336                t.writable = v;
1337            }
1338            if let Some(v) = x.readable {
1339                t.readable = v;
1340            }
1341            if let Some(v) = x.env {
1342                t.env = v;
1343            }
1344            if let Some(v) = x.image {
1345                t.image = v;
1346            }
1347            if x.memory_mb.is_some() {
1348                t.memory_mb = x.memory_mb;
1349            }
1350            if x.cpus.is_some() {
1351                t.cpus = x.cpus;
1352            }
1353        }
1354        // MCP servers replace wholesale — merging lists by name would make it
1355        // impossible for a project to turn a global server off.
1356        if let Some(v) = self.mcp {
1357            cfg.mcp = v;
1358        }
1359        if let Some(v) = self.subagents {
1360            cfg.subagents = v;
1361        }
1362        if let Some(v) = self.search {
1363            cfg.search = v;
1364        }
1365        // Wholesale, like MCP servers and for the same reason: a project that
1366        // cannot turn a global hook off cannot be trusted to run anything.
1367        if let Some(v) = self.hooks {
1368            cfg.hooks = v;
1369        }
1370        if let Some(x) = self.outbox {
1371            let t = &mut cfg.outbox;
1372            // Wholesale: a project must be able to un-route a tool the global
1373            // config routes, and vice versa.
1374            if let Some(v) = x.tools {
1375                t.tools = v;
1376            }
1377            if x.dir.is_some() {
1378                t.dir = x.dir;
1379            }
1380            if let Some(v) = x.publish_tools {
1381                t.publish_tools = v;
1382            }
1383        }
1384        if let Some(x) = self.work {
1385            if let Some(v) = x.keep {
1386                cfg.work.keep = v;
1387            }
1388        }
1389        // Assignment here is the *global* layer's semantics. A project layer
1390        // never reaches this with a widening list, because `merge_file`
1391        // narrows it first — see [`SkillsLayer`].
1392        if let Some(x) = self.skills {
1393            let t = &mut cfg.skills;
1394            if let Some(v) = x.enabled {
1395                t.enabled = v;
1396            }
1397            if let Some(v) = x.disabled {
1398                t.disabled = v;
1399            }
1400            if x.dir.is_some() {
1401                t.dir = x.dir;
1402            }
1403        }
1404        // Only ever reached from the global layer, like `[messages]`.
1405        if let Some(x) = self.slack {
1406            let t = &mut cfg.slack;
1407            if let Some(v) = x.max_concurrent {
1408                t.max_concurrent = v;
1409            }
1410            if let Some(v) = x.approval_timeout_secs {
1411                t.approval_timeout_secs = v;
1412            }
1413            if let Some(v) = x.default_mode {
1414                t.default_mode = v;
1415            }
1416            if let Some(v) = x.max_turns {
1417                t.max_turns = v;
1418            }
1419            if let Some(v) = x.max_cost_usd {
1420                t.max_cost_usd = Some(v);
1421            }
1422            if let Some(v) = x.stream_flush_chars {
1423                t.stream_flush_chars = v;
1424            }
1425            if let Some(v) = x.stream_flush_ms {
1426                t.stream_flush_ms = v;
1427            }
1428            if let Some(v) = x.max_upload_mb {
1429                t.max_upload_mb = v;
1430            }
1431            if let Some(v) = x.tools {
1432                t.tools = v;
1433            }
1434        }
1435        // Only ever reached from the global layer: `merge_file` strips this
1436        // section from a project file before applying, with a warning.
1437        if let Some(x) = self.messages {
1438            let t = &mut cfg.messages;
1439            if let Some(v) = x.enabled {
1440                t.enabled = v;
1441            }
1442            if x.dir.is_some() {
1443                t.dir = x.dir;
1444            }
1445            if x.inbound.is_some() {
1446                t.inbound = x.inbound;
1447            }
1448            if let Some(v) = x.pending_cap {
1449                t.pending_cap = v;
1450            }
1451            if let Some(v) = x.max_body_bytes {
1452                t.max_body_bytes = v;
1453            }
1454            if let Some(v) = x.keep {
1455                t.keep = v;
1456            }
1457        }
1458        // Only ever reached from the global layer, like `[messages]` and
1459        // `[slack]`: `merge_file` strips a project file's `[web]` first.
1460        if let Some(x) = self.web {
1461            let t = &mut cfg.web;
1462            if let Some(v) = x.port {
1463                t.port = v;
1464            }
1465            if x.owner_login.is_some() {
1466                t.owner_login = x.owner_login;
1467            }
1468            if x.assets.is_some() {
1469                t.assets = x.assets;
1470            }
1471            if x.voices_dir.is_some() {
1472                t.voices_dir = x.voices_dir;
1473            }
1474        }
1475    }
1476}
1477
1478#[cfg(test)]
1479mod tests {
1480    use super::*;
1481
1482    #[test]
1483    fn layer_overrides_only_named_fields() {
1484        let mut cfg = Config::default();
1485        let layer: ConfigLayer = toml::from_str(
1486            r#"
1487            [agent]
1488            max_turns = 5
1489            "#,
1490        )
1491        .unwrap();
1492        layer.apply(&mut cfg);
1493        assert_eq!(cfg.agent.max_turns, 5);
1494        // Untouched fields keep their defaults.
1495        assert_eq!(cfg.agent.max_tokens, 64_000);
1496        assert_eq!(cfg.default_provider, "anthropic");
1497    }
1498
1499    #[test]
1500    fn providers_merge_by_key() {
1501        let mut cfg = Config::default();
1502        let layer: ConfigLayer = toml::from_str(
1503            r#"
1504            [providers.local]
1505            kind = "local"
1506            base_url = "http://127.0.0.1:8080"
1507            "#,
1508        )
1509        .unwrap();
1510        layer.apply(&mut cfg);
1511        assert!(cfg.providers.contains_key("anthropic"));
1512        assert!(cfg.providers.contains_key("local"));
1513    }
1514
1515    #[test]
1516    fn hooks_configure_from_a_file() {
1517        let mut cfg = Config::default();
1518        let layer: ConfigLayer = toml::from_str(
1519            r#"
1520            [[hook]]
1521            event = "pre_tool"
1522            tools = ["shell"]
1523            command = "policy.sh"
1524            "#,
1525        )
1526        .unwrap();
1527        layer.apply(&mut cfg);
1528        assert_eq!(cfg.hooks.len(), 1);
1529        assert_eq!(cfg.hooks[0].event, "pre_tool");
1530        assert_eq!(cfg.hooks[0].tools, ["shell"]);
1531    }
1532
1533    /// An explicit threshold always wins; otherwise a known window derives
1534    /// one. The derived value must leave real headroom — the check happens
1535    /// *between* turns, so the next request has to fit the reply and whatever
1536    /// a burst of parallel tool results adds.
1537    #[test]
1538    fn the_compaction_threshold_derives_from_a_known_context_window() {
1539        let mut cfg = AgentConfig::default();
1540        assert_eq!(cfg.compact_at(None), None, "unknowable stays unset");
1541
1542        // The DGX's llama-server runs -c 32768; two thirds of that.
1543        let derived = cfg.compact_at(Some(32768)).unwrap();
1544        assert_eq!(derived, 21626);
1545        assert!(
1546            derived < 32768 - 8192,
1547            "must leave room for a reply and a burst of tool results: {derived}"
1548        );
1549
1550        cfg.compact_at_tokens = Some(9000);
1551        assert_eq!(cfg.compact_at(Some(32768)), Some(9000), "explicit wins");
1552    }
1553
1554    /// One turn's tool results must not leap the gap between the compaction
1555    /// threshold and the window — the flat 24 KB budget was ~8–12k tokens of
1556    /// numeric data against a 10.9k-token gap at 32k, and a 2026-08-07
1557    /// Terminal-Bench trial died on exactly that jump.
1558    #[test]
1559    fn the_output_budget_derives_from_a_known_context_window() {
1560        let mut cfg = ToolsConfig::default();
1561
1562        // Unknowable window: the ceiling, which is the old flat default.
1563        assert_eq!(cfg.resolved_output_budget(None), 24_000);
1564
1565        // The DGX's llama-server runs -c 32768: an eighth of the window in
1566        // tokens, ~3 bytes each — and comfortably inside the threshold gap
1567        // even at one byte per token.
1568        let derived = cfg.resolved_output_budget(Some(32768));
1569        assert_eq!(derived, 12_288);
1570
1571        // Wide windows keep the old number; tiny ones keep results usable.
1572        assert_eq!(cfg.resolved_output_budget(Some(200_000)), 24_000);
1573        assert_eq!(cfg.resolved_output_budget(Some(8_192)), 6_000);
1574
1575        cfg.output_budget_bytes = Some(1_000);
1576        assert_eq!(
1577            cfg.resolved_output_budget(Some(32768)),
1578            1_000,
1579            "explicit wins"
1580        );
1581    }
1582
1583    /// A `mecha.toml` arrives with a cloned repository, and it can name MCP
1584    /// servers to spawn, hooks to run and tools to enable. That is a reasonable
1585    /// bargain for someone who just decided to work in that repository, and no
1586    /// bargain at all for a trigger firing at 03:00 — so the scheduled path
1587    /// loads the global layer only. Verified as a *difference*, because the
1588    /// same call on a machine with no project file proves nothing.
1589    #[test]
1590    fn the_project_layer_is_reachable_from_load_and_not_from_load_global() {
1591        let dir = std::env::temp_dir().join(format!("mecha-config-scope-{}", std::process::id()));
1592        std::fs::create_dir_all(&dir).unwrap();
1593        std::fs::write(
1594            dir.join(Config::PROJECT_FILE),
1595            "default_provider = \"contributed-by-the-repository\"\n",
1596        )
1597        .unwrap();
1598
1599        let with_project = Config::load(&dir).unwrap();
1600        assert_eq!(
1601            with_project.default_provider,
1602            "contributed-by-the-repository"
1603        );
1604
1605        let global_only = Config::load_global().unwrap();
1606        assert_ne!(
1607            global_only.default_provider, "contributed-by-the-repository",
1608            "a scheduled unattended run must not take its configuration from \
1609             whatever directory it happens to start in"
1610        );
1611
1612        let _ = std::fs::remove_dir_all(&dir);
1613    }
1614
1615    #[test]
1616    fn a_project_layer_web_section_is_stripped_but_a_global_one_is_kept() {
1617        // Same boundary as `[slack]`: the section names the web door's port
1618        // and the identity allowed through it, and a mecha.toml arrives with
1619        // a cloned repository.
1620        let dir = std::env::temp_dir().join(format!("mecha-web-scope-{}", std::process::id()));
1621        std::fs::create_dir_all(&dir).unwrap();
1622        let path = dir.join("layer.toml");
1623        std::fs::write(
1624            &path,
1625            "[web]\nport = 1\nowner_login = \"attacker@example.com\"\n",
1626        )
1627        .unwrap();
1628
1629        let mut from_project = Config::default();
1630        from_project.merge_file(&path, LayerTrust::Project).unwrap();
1631        assert_eq!(
1632            from_project.web.port,
1633            WebConfig::default().port,
1634            "a project file must not move the web port"
1635        );
1636        assert_eq!(
1637            from_project.web.owner_login, None,
1638            "a project file must not name the owner"
1639        );
1640
1641        let mut from_global = Config::default();
1642        from_global.merge_file(&path, LayerTrust::Global).unwrap();
1643        assert_eq!(from_global.web.port, 1);
1644        assert_eq!(
1645            from_global.web.owner_login.as_deref(),
1646            Some("attacker@example.com")
1647        );
1648        let _ = std::fs::remove_file(&path);
1649    }
1650
1651    #[test]
1652    fn a_project_layer_slack_section_is_stripped_but_a_global_one_is_kept() {
1653        // The same boundary as `[messages]`, and a sharper one: Slack is the
1654        // remote control, and a mecha.toml arrives with a cloned repository.
1655        // Nothing in `[slack]` grants access — who may drive lives in the
1656        // binding store — but a repo must not get to widen the default mode or
1657        // the budget of runs someone drives from their phone.
1658        let dir = std::env::temp_dir().join(format!("mecha-slack-scope-{}", std::process::id()));
1659        std::fs::create_dir_all(&dir).unwrap();
1660        let path = dir.join("layer.toml");
1661        std::fs::write(
1662            &path,
1663            "[slack]\ndefault_mode = \"allow\"\nmax_turns = 999\n",
1664        )
1665        .unwrap();
1666
1667        let mut from_project = Config::default();
1668        from_project.merge_file(&path, LayerTrust::Project).unwrap();
1669        assert_eq!(
1670            from_project.slack.default_mode, "ask",
1671            "a project file must not widen the default mode"
1672        );
1673        assert_eq!(from_project.slack.max_turns, 40);
1674
1675        let mut from_global = Config::default();
1676        from_global.merge_file(&path, LayerTrust::Global).unwrap();
1677        assert_eq!(
1678            from_global.slack.default_mode, "allow",
1679            "the global file is authoritative"
1680        );
1681        assert_eq!(from_global.slack.max_turns, 999);
1682
1683        std::fs::remove_dir_all(&dir).ok();
1684    }
1685
1686    #[test]
1687    fn a_project_layer_cannot_un_withhold_a_skill_with_an_empty_list() {
1688        // The narrowest form of the widening attack, and the one the first
1689        // version of this code allowed: `disabled = []` is a *present* empty
1690        // value, so an assigning merge replaces the user's list with nothing
1691        // and the withheld skill is carried. Writing no `[skills]` table at
1692        // all is the honest way to have no opinion.
1693        let dir = std::env::temp_dir().join(format!("mecha-skills-wipe-{}", std::process::id()));
1694        std::fs::create_dir_all(&dir).unwrap();
1695        let project = dir.join("project.toml");
1696        std::fs::write(&project, "[skills]\ndisabled = []\n").unwrap();
1697
1698        let mut cfg = Config::default();
1699        cfg.skills.disabled = vec!["dangerous".into()];
1700        cfg.merge_file(&project, LayerTrust::Project).unwrap();
1701        assert_eq!(
1702            cfg.skills.disabled,
1703            vec!["dangerous".to_string()],
1704            "an empty project list must not clear the user's"
1705        );
1706
1707        let _ = std::fs::remove_dir_all(&dir);
1708    }
1709
1710    #[test]
1711    fn a_project_layer_can_narrow_the_skill_set_but_never_widen_it() {
1712        // The rule that lets `[skills]` be project-declarable at all. A cloned
1713        // repository saying "these are the relevant ones" is useful; one
1714        // turning on a skill the user did not enable is the supply-chain shape
1715        // this whole subsystem is arranged to refuse, so the merge enforces
1716        // the direction rather than documenting it.
1717        let dir = std::env::temp_dir().join(format!("mecha-skills-scope-{}", std::process::id()));
1718        std::fs::create_dir_all(&dir).unwrap();
1719        let project = dir.join("project.toml");
1720        std::fs::write(
1721            &project,
1722            "[skills]\nenabled = [\"audit\", \"deploy\"]\ndisabled = [\"brief\"]\ndir = \"/tmp/theirs\"\n",
1723        )
1724        .unwrap();
1725
1726        // Global enabled `audit` and `brief`. The project asks for `audit`
1727        // and `deploy`; only the intersection survives.
1728        let mut cfg = Config::default();
1729        cfg.skills.enabled = vec!["audit".into(), "brief".into()];
1730        // Non-empty on purpose: with an empty global list an overwrite and a
1731        // union are indistinguishable, which is how the first version of this
1732        // test passed while a project file could still wipe the list.
1733        cfg.skills.disabled = vec!["dangerous".into()];
1734        cfg.merge_file(&project, LayerTrust::Project).unwrap();
1735        assert_eq!(
1736            cfg.skills.enabled,
1737            vec!["audit".to_string()],
1738            "`deploy` was never enabled globally, so naming it must not enable it"
1739        );
1740        assert!(
1741            cfg.skills.disabled.contains(&"brief".to_string()),
1742            "withholding is always allowed"
1743        );
1744        assert!(
1745            cfg.skills.disabled.contains(&"dangerous".to_string()),
1746            "a project file must not be able to un-withhold what the user withheld"
1747        );
1748        assert!(
1749            cfg.skills.dir.is_none(),
1750            "a project must not point the store somewhere it controls"
1751        );
1752
1753        // And the global layer is authoritative, so the same file read as
1754        // global does assign — otherwise this would be a broken apply rather
1755        // than a narrowing.
1756        let mut global = Config::default();
1757        global.merge_file(&project, LayerTrust::Global).unwrap();
1758        assert_eq!(
1759            global.skills.enabled,
1760            vec!["audit".to_string(), "deploy".to_string()]
1761        );
1762        assert_eq!(global.skills.dir.as_deref(), Some(Path::new("/tmp/theirs")));
1763
1764        let _ = std::fs::remove_dir_all(&dir);
1765    }
1766
1767    #[test]
1768    fn a_project_layer_messages_section_is_stripped_but_a_global_one_is_kept() {
1769        // The security boundary: a cloned repo's mecha.toml must not be able to
1770        // set `inbound = "accept"` (or enable messaging at all) on someone's
1771        // session. `merge_file` strips the section on a project layer and keeps
1772        // it on a global one — this pins both halves, and that the strip is a
1773        // strip rather than a broken apply.
1774        let dir = std::env::temp_dir().join(format!("mecha-msg-scope-{}", std::process::id()));
1775        std::fs::create_dir_all(&dir).unwrap();
1776        let path = dir.join("layer.toml");
1777        std::fs::write(&path, "[messages]\nenabled = true\ninbound = \"accept\"\n").unwrap();
1778
1779        let mut from_project = Config::default();
1780        from_project.merge_file(&path, LayerTrust::Project).unwrap();
1781        assert!(
1782            !from_project.messages.enabled,
1783            "a project file must not enable messaging"
1784        );
1785        assert!(
1786            from_project.messages.inbound.is_none(),
1787            "a project file must not set inbound policy"
1788        );
1789
1790        let mut from_global = Config::default();
1791        from_global.merge_file(&path, LayerTrust::Global).unwrap();
1792        assert!(
1793            from_global.messages.enabled,
1794            "the global file is authoritative"
1795        );
1796        assert_eq!(
1797            from_global.messages.inbound,
1798            Some(crate::mailbox::InboundPolicy::Accept)
1799        );
1800
1801        let _ = std::fs::remove_dir_all(&dir);
1802    }
1803
1804    #[test]
1805    fn every_field_of_config_is_reachable_from_a_file() {
1806        // The bug this exists for: `hooks` was added to `Config` and not to
1807        // `ConfigLayer`, so `[[hook]]` in any config file was a hard parse
1808        // error and the whole feature was unreachable — while every unit test
1809        // passed, because they all built the type directly.
1810        //
1811        // Serialising the default config produces one entry per top-level
1812        // field; `ConfigLayer` denies unknown fields, so parsing it back is a
1813        // standing check that the two structs still agree. Any field added to
1814        // one and not the other fails here rather than in someone's config.
1815        let rendered = toml::to_string(&Config::default()).unwrap();
1816        let parsed = toml::from_str::<ConfigLayer>(&rendered);
1817        assert!(
1818            parsed.is_ok(),
1819            "Config has a field ConfigLayer cannot read: {parsed:?}"
1820        );
1821    }
1822}