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