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}
51
52/// Messaging between this machine's own mecha sessions.
53///
54/// Receiver-side policy, so it loads from the global file only, never a
55/// project's `mecha.toml`: a cloned repository must not be able to set
56/// `inbound = "accept"` on someone's session. Enforced structurally:
57/// `merge_file` strips the section from project layers, loudly.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(default, deny_unknown_fields)]
60pub struct MessagesConfig {
61    /// Off by default, like outbox routing: a mailbox is a policy decision.
62    pub enabled: bool,
63    /// Where messages live. Defaults to `~/.mecha/messages`
64    /// (or `$MECHA_MESSAGES_DIR`).
65    pub dir: Option<PathBuf>,
66    /// What a run does with inbound messages: `accept` folds them in at turn
67    /// boundaries, `hold` leaves them for `mecha msg`. Unset — the default —
68    /// resolves per surface: attended front-ends hold, unattended runs
69    /// accept. See [`crate::mailbox::InboundPolicy`].
70    pub inbound: Option<crate::mailbox::InboundPolicy>,
71    /// Pending messages one recipient may hold before senders are refused.
72    pub pending_cap: usize,
73    /// Largest message body, in bytes.
74    pub max_body_bytes: usize,
75    /// Resolved (delivered/dismissed) messages kept per recipient before the
76    /// oldest are pruned. Retention, so the per-turn claim scan stays bounded.
77    pub keep: usize,
78}
79
80impl Default for MessagesConfig {
81    fn default() -> Self {
82        MessagesConfig {
83            enabled: false,
84            dir: None,
85            inbound: None,
86            pending_cap: crate::mailbox::DEFAULT_PENDING_CAP,
87            max_body_bytes: crate::mailbox::DEFAULT_MAX_BODY_BYTES,
88            keep: crate::mailbox::DEFAULT_KEEP_RESOLVED,
89        }
90    }
91}
92
93/// Which tools are outbox-routed, and where staged items live.
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
95#[serde(default, deny_unknown_fields)]
96pub struct OutboxConfig {
97    /// Registry names (`email__send`, `web__fetch`). A call to one of these
98    /// is staged as a draft the user reviews with `mecha outbox`; the tool
99    /// itself never runs until they release it. Empty means the outbox is
100    /// off, which is the default — routing a tool is a policy decision.
101    pub tools: Vec<String>,
102    /// Where items are staged. Defaults to `~/.mecha/outbox`
103    /// (or `$MECHA_OUTBOX_DIR`).
104    pub dir: Option<PathBuf>,
105    /// Which of the routed names are *publications* rather than messages
106    /// (`factory__bundle_publish`, `factory__bundle_alias`). They stage
107    /// identically; they are **reviewed** differently — the reviewable object
108    /// is the rendered page, `edit` is refused, and the writing-reflection
109    /// miner skips them so a changed directory path never becomes a voice
110    /// rule. See [`crate::outbox::OutboxKind`].
111    ///
112    /// Config's to declare, not the tool's: the loop must not learn what a
113    /// publish is, and a third-party MCP server cannot be trusted to say.
114    pub publish_tools: Vec<String>,
115}
116
117/// How much of a producer's generated output survives a `mecha work clean`.
118///
119/// A policy rather than an intention: the lesson of this project is that
120/// anything without one becomes a pile nobody opens. The number is small on
121/// purpose — the directory is scratch, and what matters is published.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(default, deny_unknown_fields)]
124pub struct WorkConfig {
125    /// Entries kept per producer, newest first.
126    pub keep: usize,
127}
128
129/// `[slack]` — tunables for the Slack remote control. **Nothing here grants
130/// anything.** Who may drive the agent lives in `~/.mecha/slack/binding.json`,
131/// a store rather than config, for the reason `[messages]` is global-only and
132/// then some: a project file arrives with a cloned repository, and a repo that
133/// could name a Slack owner would have been handed the remote control.
134#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
135#[serde(default)]
136pub struct SlackConfig {
137    /// Threads that may have a run in flight at once. At the cap the connector
138    /// refuses and says so, rather than queueing: a run that starts twenty
139    /// minutes later against a workspace that has moved is worse than an
140    /// honest refusal.
141    pub max_concurrent: usize,
142    /// How long an approval card waits before the call is refused as
143    /// unanswered. Never a denial by the user — see `Decision::Blocked`.
144    pub approval_timeout_secs: u64,
145    /// `ask` (the default), `allow`, or `read-only`, for a thread nobody has
146    /// set a mode on.
147    pub default_mode: String,
148    pub max_turns: u32,
149    pub max_cost_usd: Option<f64>,
150    /// Flush a streamed chunk once this much text has accumulated, or this
151    /// long has passed — whichever comes first. Size first is Slack's own
152    /// guidance; the timer is so a slow model still shows progress.
153    pub stream_flush_chars: usize,
154    pub stream_flush_ms: u64,
155    /// Largest attachment fetched into a run's workspace. Slack allows 1 GB;
156    /// a remote control does not need to.
157    pub max_upload_mb: u64,
158    /// Narrow the tool surface for Slack-driven runs.
159    ///
160    /// Empty means "everything configured", which is the default and is
161    /// usually too much: measured on the first live run, the schemas of every
162    /// wired MCP server cost ~7–8k input tokens *per turn* before any work
163    /// happened — against a 32k window whose compaction threshold is 21,845,
164    /// a run starts a third of the way there. A phone rarely needs the mail
165    /// and the calendar and the factory at once, and naming what it does need
166    /// is the cheapest context this system has to give.
167    pub tools: Vec<String>,
168}
169
170impl Default for SlackConfig {
171    fn default() -> Self {
172        SlackConfig {
173            max_concurrent: 3,
174            approval_timeout_secs: 600,
175            default_mode: "ask".into(),
176            max_turns: 40,
177            max_cost_usd: None,
178            stream_flush_chars: 800,
179            stream_flush_ms: 1000,
180            max_upload_mb: 25,
181            tools: Vec::new(),
182        }
183    }
184}
185
186impl Default for WorkConfig {
187    fn default() -> Self {
188        WorkConfig {
189            keep: crate::work::DEFAULT_KEEP,
190        }
191    }
192}
193
194/// One hook: a command run at a lifecycle point, with the event payload as
195/// JSON on stdin.
196#[derive(Debug, Clone, Default, Serialize, Deserialize)]
197#[serde(default, deny_unknown_fields)]
198pub struct HookConfig {
199    /// `pre_tool` | `post_tool` | `session_end`. An unknown event is a startup
200    /// error, not a warning — a policy hook that never fires because its event
201    /// name has a typo is the silently-degrading-sandbox mistake again.
202    pub event: String,
203    /// Run via `sh -c`, as the user, in the workspace.
204    pub command: String,
205    /// Only fire for these tools (`pre_tool`/`post_tool`). Empty means all.
206    pub tools: Vec<String>,
207    /// Kill the hook after this long. The default is deliberately short: a
208    /// `pre_tool` hook is on the critical path of every call it matches.
209    pub timeout_secs: Option<u64>,
210}
211
212impl Default for Config {
213    fn default() -> Self {
214        let mut providers = BTreeMap::new();
215        providers.insert(
216            "anthropic".to_string(),
217            ProviderConfig {
218                kind: "anthropic".to_string(),
219                model: Some(crate::provider::anthropic::DEFAULT_MODEL.to_string()),
220                api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
221                api_key: None,
222                base_url: None,
223                input_price_per_mtok: None,
224                output_price_per_mtok: None,
225                temperature: None,
226                seed: None,
227                context_window: None,
228                max_retries: None,
229                retry_after_cap_secs: None,
230                fallbacks: Vec::new(),
231            },
232        );
233        Config {
234            default_provider: "anthropic".to_string(),
235            providers,
236            agent: AgentConfig::default(),
237            tools: ToolsConfig::default(),
238            security: SecurityConfig::default(),
239            sandbox: crate::sandbox::SandboxConfig::default(),
240            mcp: Vec::new(),
241            subagents: Vec::new(),
242            search: Vec::new(),
243            hooks: Vec::new(),
244            outbox: OutboxConfig::default(),
245            work: WorkConfig::default(),
246            slack: SlackConfig::default(),
247            messages: MessagesConfig::default(),
248        }
249    }
250}
251
252#[derive(Debug, Clone, Default, Serialize, Deserialize)]
253#[serde(default, deny_unknown_fields)]
254pub struct ProviderConfig {
255    /// `anthropic` | `openai` | `local`
256    pub kind: String,
257    pub model: Option<String>,
258    /// Environment variable holding the key. Preferred over `api_key`.
259    pub api_key_env: Option<String>,
260    /// Inline key. Convenient, but it lands in a file on disk — prefer the env var.
261    pub api_key: Option<String>,
262    pub base_url: Option<String>,
263    /// Per-million-token prices, so budgets and reporting can be in dollars.
264    /// Leave unset for a local model — the marginal cost really is zero.
265    pub input_price_per_mtok: Option<f64>,
266    pub output_price_per_mtok: Option<f64>,
267    /// Sampling temperature, sent verbatim by providers that accept one. Unset
268    /// means the server's default. Do not reach for 0.0 to get repeatability:
269    /// measured on qwen3.6, greedy decoding walks into verbatim repetition
270    /// loops that sampling noise would have broken. Pin the server's own
271    /// default value and set `seed` instead — same distribution, repeatable
272    /// draws. The Anthropic API rejects the parameter, so setting this on an
273    /// `anthropic` provider is a startup error rather than a silent no-op.
274    pub temperature: Option<f64>,
275    /// Sampling seed, for repeatable draws at a nonzero temperature. Only as
276    /// deterministic as the backend: llama-server repeats exactly when requests
277    /// run one at a time, and does not once concurrent requests share a batch.
278    /// Rejected on `anthropic` for the same reason as `temperature`.
279    pub seed: Option<u64>,
280    /// How many tokens this model's context holds — for a local server, the
281    /// `-c` it was started with.
282    ///
283    /// Nothing here can discover this: a provider reports how many tokens a
284    /// prompt *used*, never how many are left. Without it the compaction
285    /// threshold has to be an absolute number somebody remembers to set, and
286    /// when nobody does, a long session dies on a raw
287    /// `exceed_context_size_error` from the server with the whole run lost.
288    /// With it, [`AgentConfig::compact_at`] derives a threshold and the CLI
289    /// can show how much room is left.
290    pub context_window: Option<u64>,
291    /// Retries per request on transient failures — 429, 5xx, transport. 0
292    /// disables. Unset means 3. Auth, billing, invalid-request and
293    /// context-overflow errors are never retried: the same payload fails the
294    /// same way, and overflow belongs to the compaction path.
295    pub max_retries: Option<u32>,
296    /// A `Retry-After` above this many seconds is surfaced as a failure
297    /// instead of slept through (default 60) — a provider can name a wait
298    /// long enough that the process is simply asleep, and control never
299    /// returns to a layer that could fall back instead.
300    pub retry_after_cap_secs: Option<u64>,
301    /// Provider entries to try, in order, when this one exhausts its retries
302    /// on a *transient* failure. Turn-local: the next turn starts from this
303    /// provider again. Each fallback answers with its own model. Empty —
304    /// the default — means strict: fail rather than silently answer with a
305    /// different model. `mecha eval` never falls back regardless: a
306    /// scorecard grades the model it names.
307    pub fallbacks: Vec<String>,
308}
309
310impl ProviderConfig {
311    /// Prices, if configured. Both halves are required: knowing one is worse
312    /// than knowing neither, because it silently under-reports.
313    pub fn pricing(&self) -> Option<crate::message::Pricing> {
314        match (self.input_price_per_mtok, self.output_price_per_mtok) {
315            (Some(input), Some(output)) => Some(crate::message::Pricing {
316                input_per_mtok: input,
317                output_per_mtok: output,
318                ..Default::default()
319            }),
320            _ => None,
321        }
322    }
323
324    pub fn resolve_api_key(&self) -> Option<String> {
325        if let Some(var) = &self.api_key_env {
326            if let Ok(v) = std::env::var(var) {
327                if !v.is_empty() {
328                    return Some(v);
329                }
330            }
331        }
332        self.api_key.clone().filter(|k| !k.is_empty())
333    }
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(default, deny_unknown_fields)]
338pub struct AgentConfig {
339    pub system_prompt: Option<String>,
340    /// Read the system prompt from a file. Wins over `system_prompt`.
341    pub system_prompt_file: Option<PathBuf>,
342    /// Hard stop on runaway loops: how many model turns one run may take.
343    pub max_turns: u32,
344    pub max_tokens: u32,
345    pub effort: Option<Effort>,
346    pub thinking: bool,
347    /// Mark the tools + system prefix as cacheable.
348    pub cache_prompt: bool,
349    /// When the turn budget runs out, spend one more turn with the tools
350    /// removed so the model has to answer with what it has. Without this a
351    /// model that never stops searching returns nothing at all.
352    pub force_final_answer: bool,
353    /// Stop once this many output tokens have been generated in one run.
354    /// `max_turns` bounds the number of round trips; this bounds their size,
355    /// which is what actually runs up a bill.
356    pub max_output_tokens: Option<u64>,
357    /// Stop once one run has cost this much. Requires prices on the provider.
358    pub max_cost_usd: Option<f64>,
359    /// Summarise the middle of the conversation once the prompt passes this
360    /// many tokens.
361    ///
362    /// Measured against what the provider *reported* for the last turn rather
363    /// than an estimate, so it tracks the real prompt including cached tokens.
364    /// Unset by default: compaction is lossy, and silently paraphrasing
365    /// someone's conversation because it got long is a decision they should
366    /// make. Set it to roughly two thirds of the model's context window — or
367    /// set `context_window` on the provider and let
368    /// [`AgentConfig::compact_at`] work it out.
369    pub compact_at_tokens: Option<u64>,
370    /// IANA timezone name for the user, e.g. `America/New_York`. Unset means
371    /// the machine's. See [`AgentConfig::timezone`].
372    pub timezone: Option<String>,
373    /// Turns kept verbatim after a compaction. The recent ones are where the
374    /// work is; a summary of the last two turns is worse than the turns.
375    pub compact_keep_recent: usize,
376    /// Stop a run that repeats an identical tool call, with an identical
377    /// result, right after a compaction (`StopCause::Loop`).
378    ///
379    /// On by default — the asymmetry is deliberate. A general repeated-call
380    /// detector would need a measurement to justify watching all of ordinary
381    /// work; this one exists to escape the specific loop that burns unbounded
382    /// tokens at the largest prompts a run will ever send, and a no-config
383    /// user should get that protection. Identical arguments with a *changing*
384    /// result is polling and never trips it.
385    pub loop_guard: bool,
386    /// Check each summary against the transcript it replaces before
387    /// installing it, and regenerate once with the omissions named.
388    ///
389    /// Summaries fail by *omission* — they preserve what is true and drop
390    /// task-critical specifics — and the producer cannot see its own gaps.
391    /// A separate grounded comparison can: it reads both texts side by side,
392    /// which is a different task from generating either. Measured elsewhere
393    /// (Slipstream) at +6.4–8.8 points on SWE-bench Verified for under 1%
394    /// latency, with ~90% of catches being omissions. Costs one extra
395    /// request per compaction, two when a regeneration is needed.
396    pub compact_validate: bool,
397}
398
399impl Default for AgentConfig {
400    fn default() -> Self {
401        AgentConfig {
402            system_prompt: None,
403            system_prompt_file: None,
404            max_turns: 40,
405            // Streaming is the default, so there's no HTTP-timeout reason to
406            // keep this small; leave room for thinking plus the answer.
407            max_tokens: 64_000,
408            effort: Some(Effort::High),
409            thinking: true,
410            cache_prompt: true,
411            force_final_answer: true,
412            // Unset by default: a ceiling that surprises you mid-task is worse
413            // than no ceiling. Set them once you run things unattended.
414            max_output_tokens: None,
415            max_cost_usd: None,
416            compact_at_tokens: None,
417            timezone: None,
418            compact_keep_recent: 6,
419            loop_guard: true,
420            compact_validate: true,
421        }
422    }
423}
424
425impl AgentConfig {
426    /// The user's IANA timezone (`America/New_York`), when it is not the
427    /// machine's.
428    ///
429    /// A server runs in UTC and the model has no clock, so without this every
430    /// "what's on Thursday" is answered in the wrong zone — and wrongly in a
431    /// way that looks right, since the times are internally consistent. An
432    /// IANA name rather than an offset, because an offset is wrong twice a
433    /// year.
434    pub fn timezone(&self) -> Option<chrono_tz::Tz> {
435        let name = self.timezone.as_deref()?;
436        match name.parse::<chrono_tz::Tz>() {
437            Ok(tz) => Some(tz),
438            Err(_) => {
439                tracing::warn!("unknown [agent] timezone `{name}`; using the machine's");
440                None
441            }
442        }
443    }
444
445    /// Fraction of a known context window at which to start compacting.
446    ///
447    /// Two thirds, because the threshold is checked *between* turns against
448    /// what the last one reported: the next turn still has to fit the model's
449    /// reply, and a burst of parallel tool results can add several thousand
450    /// tokens before anything gets to look again. Leaving a third of the
451    /// window is what makes the reactive check safe.
452    pub const COMPACT_FRACTION: f64 = 0.66;
453
454    /// Where compaction kicks in for a run: the explicit setting if there is
455    /// one, otherwise derived from the provider's context window.
456    ///
457    /// Deriving it is what turns compaction from something you must remember
458    /// to configure into something that just works — and the failure it
459    /// prevents is total, not gradual: one turn over the window and the
460    /// server refuses the request outright.
461    pub fn compact_at(&self, context_window: Option<u64>) -> Option<u64> {
462        self.compact_at_tokens
463            .or_else(|| context_window.map(|w| (w as f64 * Self::COMPACT_FRACTION) as u64))
464    }
465
466    pub fn resolve_system_prompt(&self) -> Result<Option<String>> {
467        if let Some(path) = &self.system_prompt_file {
468            let text = std::fs::read_to_string(path)
469                .with_context(|| format!("reading system_prompt_file {}", path.display()))?;
470            return Ok(Some(text));
471        }
472        Ok(self.system_prompt.clone())
473    }
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize)]
477#[serde(default, deny_unknown_fields)]
478pub struct ToolsConfig {
479    /// Built-in tools to register. Empty means "all of them".
480    pub enabled: Vec<String>,
481    /// Built-in tools to withhold, applied after `enabled`.
482    pub disabled: Vec<String>,
483    /// Filesystem tools refuse to touch anything outside this root.
484    pub workspace: Option<PathBuf>,
485    /// Default answer when nothing is watching to approve a call.
486    pub permission_mode: PermissionMode,
487    pub shell_timeout_secs: u64,
488    /// The byte budget one turn's tool results share, divided across the
489    /// batch. Oversized results are spilled to a file in full and cut in the
490    /// transcript, with the marker naming the path and the line to resume
491    /// from. Unset means derive it from the provider's context window — see
492    /// [`ToolsConfig::resolved_output_budget`].
493    pub output_budget_bytes: Option<usize>,
494}
495
496impl ToolsConfig {
497    /// Ceiling when nothing pins the budget: right for the wide-window
498    /// frontier models the number was originally chosen against.
499    const OUTPUT_BUDGET_MAX: usize = 24_000;
500    /// Floor: below this, a single `cargo build` error listing stops fitting
501    /// and every result arrives pre-truncated — a budget that starves the
502    /// model of its own results is worse than a tight window.
503    const OUTPUT_BUDGET_MIN: usize = 6_000;
504
505    /// The per-turn tool-output budget, window-proportional when unpinned.
506    ///
507    /// An eighth of the window in tokens, ~3 bytes per token. The constraint
508    /// it serves: the between-turns compaction check reads the *previous*
509    /// turn's prompt size, so one turn's results must not leap the gap
510    /// between the threshold (two thirds of the window) and the window
511    /// itself — a third of the window, shared with the model's own output.
512    /// The old flat 24 KB is ~8–12k tokens of numeric data, *larger* than
513    /// that gap at a 32k window: on the 2026-08-07 Terminal-Bench subset a
514    /// trial jumped from under the threshold to 45k tokens in one turn and
515    /// died on the overflow. An eighth of the window (12,288 bytes at 32k)
516    /// keeps even token-dense results inside the gap with room for output.
517    pub fn resolved_output_budget(&self, context_window: Option<u64>) -> usize {
518        if let Some(pinned) = self.output_budget_bytes {
519            return pinned;
520        }
521        match context_window {
522            Some(window) => {
523                ((window as usize / 8) * 3).clamp(Self::OUTPUT_BUDGET_MIN, Self::OUTPUT_BUDGET_MAX)
524            }
525            None => Self::OUTPUT_BUDGET_MAX,
526        }
527    }
528}
529
530impl Default for ToolsConfig {
531    fn default() -> Self {
532        ToolsConfig {
533            enabled: Vec::new(),
534            disabled: Vec::new(),
535            workspace: None,
536            permission_mode: PermissionMode::Ask,
537            shell_timeout_secs: 120,
538            output_budget_bytes: None,
539        }
540    }
541}
542
543/// Defenses against the *lethal trifecta*: private data, untrusted content, and
544/// a way to send data out. An agent holding all three can be turned into an
545/// exfiltration tool by instructions hidden in the content it reads — a
546/// calendar invite title, an email footer, a web page.
547///
548/// The mitigation is structural, not a filter: once both private data and
549/// untrusted content have entered a conversation, refuse to let it send.
550#[derive(Debug, Clone, Serialize, Deserialize)]
551#[serde(default, deny_unknown_fields)]
552pub struct SecurityConfig {
553    pub trifecta: TrifectaPolicy,
554    /// Refuse HTTP requests to loopback, private, and link-local addresses.
555    /// Without this, `http_fetch` reaches your LAN and cloud metadata endpoints.
556    pub block_private_ips: bool,
557    /// If non-empty, HTTP requests may only go to these hosts (suffix match).
558    pub allowed_domains: Vec<String>,
559    /// Hosts that are always refused, checked before `allowed_domains`.
560    pub blocked_domains: Vec<String>,
561    /// Wrap third-party content in a marker telling the model to treat it as
562    /// data rather than instructions. Weak on its own — defense in depth.
563    pub mark_untrusted_output: bool,
564    /// Block *every* outbound call once private data is in context, whether or
565    /// not untrusted content has arrived.
566    ///
567    /// This is a different control from `trifecta`, guarding a different
568    /// threat. The trifecta interlock stops an *injection* turning the agent
569    /// into an exfiltration tool; it deliberately allows sends that happen
570    /// before any third-party content exists, because nothing could have
571    /// influenced them yet. That still lets the agent put your private data
572    /// into a search query because you asked it to, or because it judged that
573    /// helpful — an ordinary privacy leak rather than an attack.
574    ///
575    /// Turn this on when private data must not leave at all. It is
576    /// restrictive: it makes "read my notes, then look something up" fail.
577    pub block_sends_after_private: bool,
578}
579
580impl Default for SecurityConfig {
581    fn default() -> Self {
582        SecurityConfig {
583            trifecta: TrifectaPolicy::Block,
584            block_private_ips: true,
585            allowed_domains: Vec::new(),
586            blocked_domains: Vec::new(),
587            mark_untrusted_output: true,
588            // Off by default: it breaks common, legitimate workflows, and the
589            // right answer for most people is capability separation (put
590            // search in a subagent with no filesystem access) rather than a
591            // blanket ban.
592            block_sends_after_private: false,
593        }
594    }
595}
596
597#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
598#[serde(rename_all = "kebab-case")]
599pub enum TrifectaPolicy {
600    /// Refuse the send outright. The default.
601    Block,
602    /// Ask a human. Only meaningful when someone is watching.
603    Ask,
604    /// Allow it. Appropriate only when the "untrusted" content is in fact
605    /// trusted — e.g. an allowlist of internal hosts.
606    Allow,
607}
608
609/// Capabilities to force on a server's tools. Absent flags leave the server's
610/// own declaration alone; there is deliberately no way to switch one off.
611#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
612#[serde(default, deny_unknown_fields)]
613pub struct CapabilityOverride {
614    pub private_data: bool,
615    pub untrusted_input: bool,
616    pub external_send: bool,
617    pub destructive: bool,
618}
619
620impl From<CapabilityOverride> for crate::tool::Capabilities {
621    fn from(o: CapabilityOverride) -> Self {
622        crate::tool::Capabilities {
623            private_data: o.private_data,
624            untrusted_input: o.untrusted_input,
625            external_send: o.external_send,
626            destructive: o.destructive,
627        }
628    }
629}
630
631#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
632#[serde(rename_all = "kebab-case")]
633pub enum PermissionMode {
634    /// Prompt before anything that isn't read-only.
635    Ask,
636    /// Run everything without asking. For trusted, headless work.
637    Allow,
638    /// Read-only tools run; everything else is refused.
639    ReadOnly,
640}
641
642#[derive(Debug, Clone, Default, Serialize, Deserialize)]
643#[serde(default, deny_unknown_fields)]
644pub struct SearchBackendConfig {
645    /// `exa` | `tavily` | `searxng`
646    pub kind: String,
647    /// Environment variable holding the key. Preferred over `api_key`.
648    pub api_key_env: Option<String>,
649    pub api_key: Option<String>,
650    /// Required for `searxng` (your instance); optional override elsewhere.
651    pub base_url: Option<String>,
652    pub disabled: bool,
653}
654
655impl SearchBackendConfig {
656    pub fn resolve_api_key(&self) -> Option<String> {
657        if let Some(var) = &self.api_key_env {
658            if let Ok(v) = std::env::var(var) {
659                if !v.is_empty() {
660                    return Some(v);
661                }
662            }
663        }
664        self.api_key.clone().filter(|k| !k.is_empty())
665    }
666}
667
668#[derive(Debug, Clone, Default, Serialize, Deserialize)]
669#[serde(default, deny_unknown_fields)]
670pub struct McpServerConfig {
671    /// Prefixed onto every tool the server exposes, so two servers can both
672    /// have a `search` without colliding.
673    pub name: String,
674    pub command: String,
675    pub args: Vec<String>,
676    /// Values handed to the server explicitly. Use this for a token the server
677    /// needs, so granting it is a decision written down rather than a
678    /// side-effect of what happened to be exported.
679    pub env: BTreeMap<String, String>,
680    /// Variables inherited from mecha's own environment, by name.
681    ///
682    /// Empty by default, and that default is the point: an MCP server is
683    /// third-party code, and a process that inherits your whole environment
684    /// inherits every provider key in it. `PATH`, `HOME`, `LANG`, `LC_ALL` and
685    /// `TZ` always pass through — without them most runtimes cannot start.
686    pub env_passthrough: Vec<String>,
687    /// Confine this server with the configured `[sandbox]` backend.
688    ///
689    /// Off by default because a confined server sees only the workspace and,
690    /// unless allowed, no network — which is wrong for most of the servers
691    /// people actually run. Worth turning on for anything you did not write.
692    pub sandbox: bool,
693    /// Network for this server alone, overriding `[sandbox] network`.
694    ///
695    /// The case this exists for: a third-party server that has to reach its own
696    /// API, confined, while `shell` still has no way off the machine. With one
697    /// shared switch you would have to open `shell` to satisfy the server.
698    pub network: Option<bool>,
699    /// Register this server's tools under their own names, without the
700    /// `<name>__` prefix. Unset means prefixed — the default that lets two
701    /// servers both expose a `search`. Turn it off for a server whose tools
702    /// already carry their own namespace (`kg_*`), where the prefix is pure
703    /// stutter the model types in every call. The setting is a promise of
704    /// distinct names: an unprefixed tool that collides with anything
705    /// already registered fails startup loudly rather than shadowing it.
706    pub prefix_tools: Option<bool>,
707    /// Capabilities forced onto every tool this server exposes, on top of
708    /// whatever it declares for itself.
709    ///
710    /// MCP capability flags come from the server's own `annotations`, which
711    /// means a third-party server decides how much the interlock distrusts it.
712    /// An unannotated tool is treated as private-but-trusted — wrong in the
713    /// dangerous direction for anything that reaches the open world. A Google
714    /// Docs server is the worked example: a document someone shared with you is
715    /// third-party text, and writing into a document an attacker can read is an
716    /// exfiltration channel, so it is all three legs at once and says none of
717    /// them.
718    ///
719    /// Only ever widens — see [`crate::tool::Capabilities::union`].
720    pub capabilities: CapabilityOverride,
721    /// Skip this server without deleting its config.
722    pub disabled: bool,
723}
724
725impl Config {
726    pub fn global_path() -> Option<PathBuf> {
727        crate::work::mecha_home()
728            .ok()
729            .map(|h| h.join("config.toml"))
730    }
731
732    pub const PROJECT_FILE: &'static str = "mecha.toml";
733
734    /// Load defaults, then the global file, then the project file, then env.
735    pub fn load(project_dir: &Path) -> Result<Self> {
736        let mut cfg = Config::default();
737        if let Some(path) = Self::global_path() {
738            if path.exists() {
739                cfg.merge_file(&path, LayerTrust::Global)?;
740            }
741        }
742        let project = project_dir.join(Self::PROJECT_FILE);
743        if project.exists() {
744            cfg.merge_file(&project, LayerTrust::Project)?;
745        }
746        cfg.merge_env();
747        Ok(cfg)
748    }
749
750    /// Defaults plus `~/.mecha/config.toml` plus env — no project layer.
751    ///
752    /// For runs that must not be configurable by whatever directory they happen
753    /// to start in. A `mecha.toml` arrives with a cloned repository, and it can
754    /// name MCP servers to spawn, hooks to execute and tools to enable; that is
755    /// a reasonable bargain when a person is sitting there having just decided
756    /// to work in that repository, and not one at all for a
757    /// [`crate::trigger`] firing at 03:00 with nobody watching.
758    pub fn load_global() -> Result<Self> {
759        let mut cfg = Config::default();
760        if let Some(path) = Self::global_path() {
761            if path.exists() {
762                cfg.merge_file(&path, LayerTrust::Global)?;
763            }
764        }
765        cfg.merge_env();
766        Ok(cfg)
767    }
768
769    fn merge_file(&mut self, path: &Path, trust: LayerTrust) -> Result<()> {
770        let text =
771            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
772        let mut layer: ConfigLayer =
773            toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
774        // `[messages]` is receiver-side admission policy, and a project file
775        // arrives with a cloned repository — it must not be able to switch a
776        // session's inbound handling to `accept`. Dropped loudly rather than
777        // silently: an ignored section that looks applied is the
778        // silently-degrading-sandbox shape.
779        if trust == LayerTrust::Project && layer.messages.take().is_some() {
780            tracing::warn!(
781                "[messages] in {} is ignored — messaging policy loads from the \
782                 global config only",
783                path.display()
784            );
785        }
786        // `[slack]` for the same reason, and a stronger one: a project file
787        // arrives with a cloned repository, and Slack is the remote control.
788        if trust == LayerTrust::Project && layer.slack.take().is_some() {
789            tracing::warn!(
790                "[slack] in {} is ignored — the Slack surface loads from the \
791                 global config only",
792                path.display()
793            );
794        }
795        layer.apply(self);
796        Ok(())
797    }
798
799    fn merge_env(&mut self) {
800        if let Ok(v) = std::env::var("MECHA_PROVIDER") {
801            self.default_provider = v;
802        }
803        if let Ok(v) = std::env::var("MECHA_MODEL") {
804            let name = self.default_provider.clone();
805            if let Some(p) = self.providers.get_mut(&name) {
806                p.model = Some(v);
807            }
808        }
809        if let Ok(v) = std::env::var("MECHA_EFFORT") {
810            if let Ok(e) = v.parse() {
811                self.agent.effort = Some(e);
812            }
813        }
814    }
815
816    pub fn provider(&self, name: Option<&str>) -> Result<(String, &ProviderConfig)> {
817        let name = name.unwrap_or(&self.default_provider).to_string();
818        let cfg = self.providers.get(&name).with_context(|| {
819            format!(
820                "no provider named {name:?}. Configured: {}",
821                self.providers
822                    .keys()
823                    .cloned()
824                    .collect::<Vec<_>>()
825                    .join(", ")
826            )
827        })?;
828        Ok((name, cfg))
829    }
830
831    /// Write this config to `path`, creating parent directories.
832    pub fn save(&self, path: &Path) -> Result<()> {
833        if let Some(parent) = path.parent() {
834            std::fs::create_dir_all(parent)?;
835        }
836        let text = toml::to_string_pretty(self)?;
837        std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
838        Ok(())
839    }
840}
841
842/// Which file a layer came from, deciding what it may set.
843#[derive(Debug, Clone, Copy, PartialEq, Eq)]
844enum LayerTrust {
845    Global,
846    Project,
847}
848
849/// A partially-specified config file. Every field is optional so a project file
850/// can override one setting without restating the rest.
851#[derive(Debug, Default, Deserialize)]
852#[serde(deny_unknown_fields)]
853struct ConfigLayer {
854    default_provider: Option<String>,
855    providers: Option<BTreeMap<String, ProviderConfig>>,
856    agent: Option<AgentLayer>,
857    tools: Option<ToolsLayer>,
858    security: Option<SecurityLayer>,
859    #[serde(rename = "mcp")]
860    mcp: Option<Vec<McpServerConfig>>,
861    #[serde(rename = "subagent")]
862    subagents: Option<Vec<crate::subagent::SubagentProfile>>,
863    #[serde(rename = "search")]
864    search: Option<Vec<SearchBackendConfig>>,
865    #[serde(rename = "hook")]
866    hooks: Option<Vec<HookConfig>>,
867    sandbox: Option<SandboxLayer>,
868    outbox: Option<OutboxLayer>,
869    work: Option<WorkLayer>,
870    slack: Option<SlackLayer>,
871    messages: Option<MessagesLayer>,
872}
873
874#[derive(Debug, Default, Deserialize)]
875#[serde(deny_unknown_fields)]
876struct MessagesLayer {
877    enabled: Option<bool>,
878    dir: Option<PathBuf>,
879    inbound: Option<crate::mailbox::InboundPolicy>,
880    pending_cap: Option<usize>,
881    max_body_bytes: Option<usize>,
882    keep: Option<usize>,
883}
884
885#[derive(Debug, Default, Deserialize)]
886#[serde(deny_unknown_fields)]
887struct WorkLayer {
888    keep: Option<usize>,
889}
890
891#[derive(Debug, Default, Deserialize)]
892#[serde(deny_unknown_fields)]
893struct SlackLayer {
894    max_concurrent: Option<usize>,
895    approval_timeout_secs: Option<u64>,
896    default_mode: Option<String>,
897    max_turns: Option<u32>,
898    max_cost_usd: Option<f64>,
899    stream_flush_chars: Option<usize>,
900    stream_flush_ms: Option<u64>,
901    max_upload_mb: Option<u64>,
902    tools: Option<Vec<String>>,
903}
904
905#[derive(Debug, Default, Deserialize)]
906#[serde(deny_unknown_fields)]
907struct OutboxLayer {
908    tools: Option<Vec<String>>,
909    dir: Option<PathBuf>,
910    publish_tools: Option<Vec<String>>,
911}
912
913#[derive(Debug, Default, Deserialize)]
914#[serde(deny_unknown_fields)]
915struct AgentLayer {
916    system_prompt: Option<String>,
917    system_prompt_file: Option<PathBuf>,
918    max_turns: Option<u32>,
919    max_tokens: Option<u32>,
920    effort: Option<Effort>,
921    thinking: Option<bool>,
922    cache_prompt: Option<bool>,
923    force_final_answer: Option<bool>,
924    max_output_tokens: Option<u64>,
925    max_cost_usd: Option<f64>,
926    compact_at_tokens: Option<u64>,
927    compact_keep_recent: Option<usize>,
928    compact_validate: Option<bool>,
929    loop_guard: Option<bool>,
930    timezone: Option<String>,
931}
932
933#[derive(Debug, Default, Deserialize)]
934#[serde(deny_unknown_fields)]
935struct SecurityLayer {
936    trifecta: Option<TrifectaPolicy>,
937    block_private_ips: Option<bool>,
938    allowed_domains: Option<Vec<String>>,
939    blocked_domains: Option<Vec<String>>,
940    mark_untrusted_output: Option<bool>,
941    block_sends_after_private: Option<bool>,
942}
943
944#[derive(Debug, Default, Deserialize)]
945#[serde(deny_unknown_fields)]
946struct SandboxLayer {
947    kind: Option<crate::sandbox::Backend>,
948    network: Option<bool>,
949    writable: Option<Vec<PathBuf>>,
950    readable: Option<Vec<PathBuf>>,
951    env: Option<Vec<String>>,
952    image: Option<String>,
953    memory_mb: Option<u64>,
954    cpus: Option<f64>,
955}
956
957#[derive(Debug, Default, Deserialize)]
958#[serde(deny_unknown_fields)]
959struct ToolsLayer {
960    enabled: Option<Vec<String>>,
961    disabled: Option<Vec<String>>,
962    workspace: Option<PathBuf>,
963    permission_mode: Option<PermissionMode>,
964    shell_timeout_secs: Option<u64>,
965    output_budget_bytes: Option<usize>,
966}
967
968impl ConfigLayer {
969    fn apply(self, cfg: &mut Config) {
970        if let Some(v) = self.default_provider {
971            cfg.default_provider = v;
972        }
973        // Providers merge by key so a project file can add a local endpoint
974        // without redeclaring the Anthropic one.
975        if let Some(providers) = self.providers {
976            cfg.providers.extend(providers);
977        }
978        if let Some(a) = self.agent {
979            let t = &mut cfg.agent;
980            if a.system_prompt.is_some() {
981                t.system_prompt = a.system_prompt;
982            }
983            if a.system_prompt_file.is_some() {
984                t.system_prompt_file = a.system_prompt_file;
985            }
986            if let Some(v) = a.max_turns {
987                t.max_turns = v;
988            }
989            if let Some(v) = a.max_tokens {
990                t.max_tokens = v;
991            }
992            if a.effort.is_some() {
993                t.effort = a.effort;
994            }
995            if let Some(v) = a.thinking {
996                t.thinking = v;
997            }
998            if let Some(v) = a.cache_prompt {
999                t.cache_prompt = v;
1000            }
1001            if let Some(v) = a.force_final_answer {
1002                t.force_final_answer = v;
1003            }
1004            if a.max_output_tokens.is_some() {
1005                t.max_output_tokens = a.max_output_tokens;
1006            }
1007            if a.max_cost_usd.is_some() {
1008                t.max_cost_usd = a.max_cost_usd;
1009            }
1010            if a.compact_at_tokens.is_some() {
1011                t.compact_at_tokens = a.compact_at_tokens;
1012            }
1013            if let Some(v) = a.compact_keep_recent {
1014                t.compact_keep_recent = v;
1015            }
1016            if let Some(v) = a.compact_validate {
1017                t.compact_validate = v;
1018            }
1019            if let Some(v) = a.loop_guard {
1020                t.loop_guard = v;
1021            }
1022            if a.timezone.is_some() {
1023                t.timezone = a.timezone;
1024            }
1025        }
1026        if let Some(x) = self.tools {
1027            let t = &mut cfg.tools;
1028            if let Some(v) = x.enabled {
1029                t.enabled = v;
1030            }
1031            if let Some(v) = x.disabled {
1032                t.disabled = v;
1033            }
1034            if x.workspace.is_some() {
1035                t.workspace = x.workspace;
1036            }
1037            if let Some(v) = x.permission_mode {
1038                t.permission_mode = v;
1039            }
1040            if let Some(v) = x.shell_timeout_secs {
1041                t.shell_timeout_secs = v;
1042            }
1043            if let Some(v) = x.output_budget_bytes {
1044                t.output_budget_bytes = Some(v);
1045            }
1046        }
1047        if let Some(x) = self.security {
1048            let t = &mut cfg.security;
1049            if let Some(v) = x.trifecta {
1050                t.trifecta = v;
1051            }
1052            if let Some(v) = x.block_private_ips {
1053                t.block_private_ips = v;
1054            }
1055            if let Some(v) = x.allowed_domains {
1056                t.allowed_domains = v;
1057            }
1058            if let Some(v) = x.blocked_domains {
1059                t.blocked_domains = v;
1060            }
1061            if let Some(v) = x.mark_untrusted_output {
1062                t.mark_untrusted_output = v;
1063            }
1064            if let Some(v) = x.block_sends_after_private {
1065                t.block_sends_after_private = v;
1066            }
1067        }
1068        if let Some(x) = self.sandbox {
1069            let t = &mut cfg.sandbox;
1070            if let Some(v) = x.kind {
1071                t.kind = v;
1072            }
1073            if let Some(v) = x.network {
1074                t.network = v;
1075            }
1076            if let Some(v) = x.writable {
1077                t.writable = v;
1078            }
1079            if let Some(v) = x.readable {
1080                t.readable = v;
1081            }
1082            if let Some(v) = x.env {
1083                t.env = v;
1084            }
1085            if let Some(v) = x.image {
1086                t.image = v;
1087            }
1088            if x.memory_mb.is_some() {
1089                t.memory_mb = x.memory_mb;
1090            }
1091            if x.cpus.is_some() {
1092                t.cpus = x.cpus;
1093            }
1094        }
1095        // MCP servers replace wholesale — merging lists by name would make it
1096        // impossible for a project to turn a global server off.
1097        if let Some(v) = self.mcp {
1098            cfg.mcp = v;
1099        }
1100        if let Some(v) = self.subagents {
1101            cfg.subagents = v;
1102        }
1103        if let Some(v) = self.search {
1104            cfg.search = v;
1105        }
1106        // Wholesale, like MCP servers and for the same reason: a project that
1107        // cannot turn a global hook off cannot be trusted to run anything.
1108        if let Some(v) = self.hooks {
1109            cfg.hooks = v;
1110        }
1111        if let Some(x) = self.outbox {
1112            let t = &mut cfg.outbox;
1113            // Wholesale: a project must be able to un-route a tool the global
1114            // config routes, and vice versa.
1115            if let Some(v) = x.tools {
1116                t.tools = v;
1117            }
1118            if x.dir.is_some() {
1119                t.dir = x.dir;
1120            }
1121            if let Some(v) = x.publish_tools {
1122                t.publish_tools = v;
1123            }
1124        }
1125        if let Some(x) = self.work {
1126            if let Some(v) = x.keep {
1127                cfg.work.keep = v;
1128            }
1129        }
1130        // Only ever reached from the global layer, like `[messages]`.
1131        if let Some(x) = self.slack {
1132            let t = &mut cfg.slack;
1133            if let Some(v) = x.max_concurrent {
1134                t.max_concurrent = v;
1135            }
1136            if let Some(v) = x.approval_timeout_secs {
1137                t.approval_timeout_secs = v;
1138            }
1139            if let Some(v) = x.default_mode {
1140                t.default_mode = v;
1141            }
1142            if let Some(v) = x.max_turns {
1143                t.max_turns = v;
1144            }
1145            if let Some(v) = x.max_cost_usd {
1146                t.max_cost_usd = Some(v);
1147            }
1148            if let Some(v) = x.stream_flush_chars {
1149                t.stream_flush_chars = v;
1150            }
1151            if let Some(v) = x.stream_flush_ms {
1152                t.stream_flush_ms = v;
1153            }
1154            if let Some(v) = x.max_upload_mb {
1155                t.max_upload_mb = v;
1156            }
1157            if let Some(v) = x.tools {
1158                t.tools = v;
1159            }
1160        }
1161        // Only ever reached from the global layer: `merge_file` strips this
1162        // section from a project file before applying, with a warning.
1163        if let Some(x) = self.messages {
1164            let t = &mut cfg.messages;
1165            if let Some(v) = x.enabled {
1166                t.enabled = v;
1167            }
1168            if x.dir.is_some() {
1169                t.dir = x.dir;
1170            }
1171            if x.inbound.is_some() {
1172                t.inbound = x.inbound;
1173            }
1174            if let Some(v) = x.pending_cap {
1175                t.pending_cap = v;
1176            }
1177            if let Some(v) = x.max_body_bytes {
1178                t.max_body_bytes = v;
1179            }
1180            if let Some(v) = x.keep {
1181                t.keep = v;
1182            }
1183        }
1184    }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use super::*;
1190
1191    #[test]
1192    fn layer_overrides_only_named_fields() {
1193        let mut cfg = Config::default();
1194        let layer: ConfigLayer = toml::from_str(
1195            r#"
1196            [agent]
1197            max_turns = 5
1198            "#,
1199        )
1200        .unwrap();
1201        layer.apply(&mut cfg);
1202        assert_eq!(cfg.agent.max_turns, 5);
1203        // Untouched fields keep their defaults.
1204        assert_eq!(cfg.agent.max_tokens, 64_000);
1205        assert_eq!(cfg.default_provider, "anthropic");
1206    }
1207
1208    #[test]
1209    fn providers_merge_by_key() {
1210        let mut cfg = Config::default();
1211        let layer: ConfigLayer = toml::from_str(
1212            r#"
1213            [providers.local]
1214            kind = "local"
1215            base_url = "http://127.0.0.1:8080"
1216            "#,
1217        )
1218        .unwrap();
1219        layer.apply(&mut cfg);
1220        assert!(cfg.providers.contains_key("anthropic"));
1221        assert!(cfg.providers.contains_key("local"));
1222    }
1223
1224    #[test]
1225    fn hooks_configure_from_a_file() {
1226        let mut cfg = Config::default();
1227        let layer: ConfigLayer = toml::from_str(
1228            r#"
1229            [[hook]]
1230            event = "pre_tool"
1231            tools = ["shell"]
1232            command = "policy.sh"
1233            "#,
1234        )
1235        .unwrap();
1236        layer.apply(&mut cfg);
1237        assert_eq!(cfg.hooks.len(), 1);
1238        assert_eq!(cfg.hooks[0].event, "pre_tool");
1239        assert_eq!(cfg.hooks[0].tools, ["shell"]);
1240    }
1241
1242    /// An explicit threshold always wins; otherwise a known window derives
1243    /// one. The derived value must leave real headroom — the check happens
1244    /// *between* turns, so the next request has to fit the reply and whatever
1245    /// a burst of parallel tool results adds.
1246    #[test]
1247    fn the_compaction_threshold_derives_from_a_known_context_window() {
1248        let mut cfg = AgentConfig::default();
1249        assert_eq!(cfg.compact_at(None), None, "unknowable stays unset");
1250
1251        // The DGX's llama-server runs -c 32768; two thirds of that.
1252        let derived = cfg.compact_at(Some(32768)).unwrap();
1253        assert_eq!(derived, 21626);
1254        assert!(
1255            derived < 32768 - 8192,
1256            "must leave room for a reply and a burst of tool results: {derived}"
1257        );
1258
1259        cfg.compact_at_tokens = Some(9000);
1260        assert_eq!(cfg.compact_at(Some(32768)), Some(9000), "explicit wins");
1261    }
1262
1263    /// One turn's tool results must not leap the gap between the compaction
1264    /// threshold and the window — the flat 24 KB budget was ~8–12k tokens of
1265    /// numeric data against a 10.9k-token gap at 32k, and a 2026-08-07
1266    /// Terminal-Bench trial died on exactly that jump.
1267    #[test]
1268    fn the_output_budget_derives_from_a_known_context_window() {
1269        let mut cfg = ToolsConfig::default();
1270
1271        // Unknowable window: the ceiling, which is the old flat default.
1272        assert_eq!(cfg.resolved_output_budget(None), 24_000);
1273
1274        // The DGX's llama-server runs -c 32768: an eighth of the window in
1275        // tokens, ~3 bytes each — and comfortably inside the threshold gap
1276        // even at one byte per token.
1277        let derived = cfg.resolved_output_budget(Some(32768));
1278        assert_eq!(derived, 12_288);
1279
1280        // Wide windows keep the old number; tiny ones keep results usable.
1281        assert_eq!(cfg.resolved_output_budget(Some(200_000)), 24_000);
1282        assert_eq!(cfg.resolved_output_budget(Some(8_192)), 6_000);
1283
1284        cfg.output_budget_bytes = Some(1_000);
1285        assert_eq!(
1286            cfg.resolved_output_budget(Some(32768)),
1287            1_000,
1288            "explicit wins"
1289        );
1290    }
1291
1292    /// A `mecha.toml` arrives with a cloned repository, and it can name MCP
1293    /// servers to spawn, hooks to run and tools to enable. That is a reasonable
1294    /// bargain for someone who just decided to work in that repository, and no
1295    /// bargain at all for a trigger firing at 03:00 — so the scheduled path
1296    /// loads the global layer only. Verified as a *difference*, because the
1297    /// same call on a machine with no project file proves nothing.
1298    #[test]
1299    fn the_project_layer_is_reachable_from_load_and_not_from_load_global() {
1300        let dir = std::env::temp_dir().join(format!("mecha-config-scope-{}", std::process::id()));
1301        std::fs::create_dir_all(&dir).unwrap();
1302        std::fs::write(
1303            dir.join(Config::PROJECT_FILE),
1304            "default_provider = \"contributed-by-the-repository\"\n",
1305        )
1306        .unwrap();
1307
1308        let with_project = Config::load(&dir).unwrap();
1309        assert_eq!(
1310            with_project.default_provider,
1311            "contributed-by-the-repository"
1312        );
1313
1314        let global_only = Config::load_global().unwrap();
1315        assert_ne!(
1316            global_only.default_provider, "contributed-by-the-repository",
1317            "a scheduled unattended run must not take its configuration from \
1318             whatever directory it happens to start in"
1319        );
1320
1321        let _ = std::fs::remove_dir_all(&dir);
1322    }
1323
1324    #[test]
1325    fn a_project_layer_slack_section_is_stripped_but_a_global_one_is_kept() {
1326        // The same boundary as `[messages]`, and a sharper one: Slack is the
1327        // remote control, and a mecha.toml arrives with a cloned repository.
1328        // Nothing in `[slack]` grants access — who may drive lives in the
1329        // binding store — but a repo must not get to widen the default mode or
1330        // the budget of runs someone drives from their phone.
1331        let dir = std::env::temp_dir().join(format!("mecha-slack-scope-{}", std::process::id()));
1332        std::fs::create_dir_all(&dir).unwrap();
1333        let path = dir.join("layer.toml");
1334        std::fs::write(
1335            &path,
1336            "[slack]\ndefault_mode = \"allow\"\nmax_turns = 999\n",
1337        )
1338        .unwrap();
1339
1340        let mut from_project = Config::default();
1341        from_project.merge_file(&path, LayerTrust::Project).unwrap();
1342        assert_eq!(
1343            from_project.slack.default_mode, "ask",
1344            "a project file must not widen the default mode"
1345        );
1346        assert_eq!(from_project.slack.max_turns, 40);
1347
1348        let mut from_global = Config::default();
1349        from_global.merge_file(&path, LayerTrust::Global).unwrap();
1350        assert_eq!(
1351            from_global.slack.default_mode, "allow",
1352            "the global file is authoritative"
1353        );
1354        assert_eq!(from_global.slack.max_turns, 999);
1355
1356        std::fs::remove_dir_all(&dir).ok();
1357    }
1358
1359    #[test]
1360    fn a_project_layer_messages_section_is_stripped_but_a_global_one_is_kept() {
1361        // The security boundary: a cloned repo's mecha.toml must not be able to
1362        // set `inbound = "accept"` (or enable messaging at all) on someone's
1363        // session. `merge_file` strips the section on a project layer and keeps
1364        // it on a global one — this pins both halves, and that the strip is a
1365        // strip rather than a broken apply.
1366        let dir = std::env::temp_dir().join(format!("mecha-msg-scope-{}", std::process::id()));
1367        std::fs::create_dir_all(&dir).unwrap();
1368        let path = dir.join("layer.toml");
1369        std::fs::write(&path, "[messages]\nenabled = true\ninbound = \"accept\"\n").unwrap();
1370
1371        let mut from_project = Config::default();
1372        from_project.merge_file(&path, LayerTrust::Project).unwrap();
1373        assert!(
1374            !from_project.messages.enabled,
1375            "a project file must not enable messaging"
1376        );
1377        assert!(
1378            from_project.messages.inbound.is_none(),
1379            "a project file must not set inbound policy"
1380        );
1381
1382        let mut from_global = Config::default();
1383        from_global.merge_file(&path, LayerTrust::Global).unwrap();
1384        assert!(
1385            from_global.messages.enabled,
1386            "the global file is authoritative"
1387        );
1388        assert_eq!(
1389            from_global.messages.inbound,
1390            Some(crate::mailbox::InboundPolicy::Accept)
1391        );
1392
1393        let _ = std::fs::remove_dir_all(&dir);
1394    }
1395
1396    #[test]
1397    fn every_field_of_config_is_reachable_from_a_file() {
1398        // The bug this exists for: `hooks` was added to `Config` and not to
1399        // `ConfigLayer`, so `[[hook]]` in any config file was a hard parse
1400        // error and the whole feature was unreachable — while every unit test
1401        // passed, because they all built the type directly.
1402        //
1403        // Serialising the default config produces one entry per top-level
1404        // field; `ConfigLayer` denies unknown fields, so parsing it back is a
1405        // standing check that the two structs still agree. Any field added to
1406        // one and not the other fails here rather than in someone's config.
1407        let rendered = toml::to_string(&Config::default()).unwrap();
1408        let parsed = toml::from_str::<ConfigLayer>(&rendered);
1409        assert!(
1410            parsed.is_ok(),
1411            "Config has a field ConfigLayer cannot read: {parsed:?}"
1412        );
1413    }
1414}