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}