Skip to main content

leviath_cli/
config.rs

1//! CLI configuration management.
2
3use leviath_mcp::MCPServerConfig;
4use leviath_providers::ModelCapabilities;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9/// Whether a tool call should execute automatically or require user approval.
10///
11/// The effective policy for a tool is resolved by narrowest scope first:
12/// launch-flag > stage > agent > global config > built-in default.
13#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "snake_case")]
15pub enum ToolPolicy {
16    /// Execute without prompting.
17    Allow,
18    /// Ask the user before each call (or once per session with `allow_session`).
19    #[default]
20    Ask,
21    /// Never execute - return a denied error to the model.
22    Deny,
23}
24
25// `TitleConfig` (plain data used by the engine's title generation) lives in
26// `leviath_core::config` so `leviath-runtime` can reference it without a CLI
27// dependency. Re-exported here so `crate::config::TitleConfig` paths resolve.
28pub use leviath_core::config::TitleConfig;
29
30// Same arrangement for the `[observability]` section: the plain data lives in
31// `leviath_core::config` (the telemetry sink crate reads it), re-exported here.
32pub use leviath_core::config::{ObservabilityConfig, TelemetryExporterKind};
33
34/// Permission for one Rhai *script-tool* host function (Layer 3 of the
35/// four-layer permission model). Gates what a registered script may *do*,
36/// independent of
37/// whether the tool itself is visible ([`available_tools`]) or approved at
38/// runtime ([`ToolPolicy`]).
39///
40/// [`available_tools`]: leviath_core::blueprint::Stage::available_tools
41#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum ScriptPermission {
44    /// The host function may run.
45    Allow,
46    /// The host function is blocked - the call returns a `[denied]` error.
47    Deny,
48    /// Defer to the agent's own `tool_permissions` for the equivalent built-in
49    /// (`read_file`/`shell`): permitted only when that resolves to
50    /// [`ToolPolicy::Allow`]. For the network/env functions (`http_get`,
51    /// `http_post`, `env_var`), which have no built-in equivalent, `Inherit`
52    /// permits the call (they're needed for tools to be useful, and the tool
53    /// itself is still gated by Layers 1/2/4).
54    #[default]
55    Inherit,
56}
57
58/// Per-host-function permissions for Rhai script tools (`[tool_script_permissions]`).
59///
60/// Every field defaults to [`ScriptPermission::Inherit`], so an unconfigured
61/// install lets network/env functions run while file/shell functions defer to
62/// the agent's own tool permissions.
63#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
64pub struct ScriptToolPermissions {
65    /// Permission for `http_get`.
66    #[serde(default)]
67    pub http_get: ScriptPermission,
68    /// Permission for `http_post`.
69    #[serde(default)]
70    pub http_post: ScriptPermission,
71    /// Permission for `shell`.
72    #[serde(default)]
73    pub shell: ScriptPermission,
74    /// Permission for `read_file`.
75    #[serde(default)]
76    pub read_file: ScriptPermission,
77    /// Permission for `write_file`.
78    #[serde(default)]
79    pub write_file: ScriptPermission,
80    /// Permission for `env_var`.
81    #[serde(default)]
82    pub env_var: ScriptPermission,
83}
84
85/// CLI configuration.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct Config {
88    /// Default provider
89    pub default_provider: String,
90
91    /// Provider API keys
92    pub providers: ProviderConfig,
93
94    /// Agent project paths
95    pub agent_paths: Vec<PathBuf>,
96
97    /// OpenRouter API key
98    pub openrouter_api_key: Option<String>,
99
100    /// Ollama base URL (default http://localhost:11434)
101    pub ollama_base_url: Option<String>,
102
103    /// MCP server configurations
104    #[serde(default)]
105    pub mcp_servers: Vec<MCPServerConfig>,
106
107    /// Default model override
108    pub default_model: Option<String>,
109
110    /// Per-model capability overrides. Key is model ID (e.g. "my-local-llama").
111    /// Takes precedence over the provider's built-in capability table.
112    #[serde(default)]
113    pub model_capabilities: HashMap<String, ModelCapabilities>,
114
115    /// Optional overrides for Rhai *script providers*. Key is the
116    /// provider name an agent references (e.g. `"groq"`). A script activates by
117    /// being referenced + its `.rhai` file existing in the providers dir; an
118    /// entry here only supplies overrides (an API key not read from env, a
119    /// `base_url`, a `rate_limit`, a differently-named `script`, or extra keys
120    /// forwarded to the script's `initialize`).
121    #[serde(default)]
122    pub model_providers: HashMap<String, ModelProviderConfig>,
123
124    /// Global tool permission overrides.
125    ///
126    /// Keys are tool names (e.g. `"bash"`, `"write_file"`). Values override the
127    /// built-in defaults, and act as a **ceiling** that a blueprint's own
128    /// `[tool_permissions]` may tighten but never loosen - see
129    /// [`crate::tools::resolve_policy`]. To grant one agent more than this
130    /// without loosening it everywhere, use [`Self::agent_tool_permissions`].
131    #[serde(default)]
132    pub tool_permissions: HashMap<String, ToolPolicy>,
133
134    /// Per-agent tool permission grants, keyed by agent name.
135    ///
136    /// ```toml
137    /// [agent_tool_permissions.coder]
138    /// shell = "allow"
139    /// ```
140    ///
141    /// This is the escape hatch for the ceiling in [`Self::tool_permissions`].
142    /// Because a blueprint may only tighten what the user configured, a global
143    /// `shell = "ask"` would otherwise stop a trusted agent from pre-approving
144    /// its own shell. Naming the agent here is the user saying "I trust this
145    /// one" - a decision that lives in the user's config, not the downloaded
146    /// manifest's. Entries replace the global value for that agent, and are then
147    /// the ceiling the blueprint is clamped against.
148    #[serde(default)]
149    pub agent_tool_permissions: HashMap<String, HashMap<String, ToolPolicy>>,
150
151    /// Title-generation configuration.
152    ///
153    /// Controls whether a short human-readable title is auto-generated from
154    /// the task prompt at worker startup.
155    #[serde(default)]
156    pub title: TitleConfig,
157
158    /// Request timeout in seconds for HTTP calls to provider APIs. Unset, the
159    /// providers fall back to the unified 15-minute ceiling
160    /// (`leviath_providers::DEFAULT_INFERENCE_TIMEOUT_SECS`) - there is
161    /// always SOME timeout, because a call that never completes wedges its
162    /// run with no error. A stage's `[stages.<name>.model]
163    /// request_timeout_secs` overrides either value for that stage's requests.
164    pub request_timeout_secs: Option<u64>,
165
166    /// Client-side rate limits for the built-in providers, keyed by provider
167    /// name (`anthropic`, `openai`, `google`, `openrouter`).
168    ///
169    /// ```toml
170    /// [rate_limits.anthropic]
171    /// requests_per_minute = 50
172    /// tokens_per_minute = 40000
173    /// ```
174    ///
175    /// Script providers configure theirs via
176    /// `[model_providers.<name>] rate_limit` instead.
177    #[serde(default)]
178    pub rate_limits: HashMap<String, leviath_providers::RateLimitConfig>,
179
180    /// Global master switch for taint tracking / data-flow enforcement.
181    ///
182    /// **Off by default (opt-in).** When `true`, every agent enforces taint
183    /// tracking by default; individual agents or stages can opt out via a
184    /// `[security] taint_tracking = false` block. When `false`, an agent still
185    /// opts *in* by setting `taint_tracking = true` in its own `[security]`.
186    #[serde(default)]
187    pub taint_tracking: bool,
188
189    /// Runtime resource limits (inference concurrency + iteration caps).
190    #[serde(default)]
191    pub limits: LimitsConfig,
192
193    /// Global master switch for the batch-tool-calls system-prompt hint.
194    ///
195    /// **On by default (opt-out).** When `true`, every stage's request carries a
196    /// short hint telling the model it may emit several `tool_use` blocks in one
197    /// response and should batch *independent* operations (but never dependent
198    /// ones) to cut API round trips. Individual agents or stages can opt out by
199    /// setting `batch_tool_hint = false` in their `[agent]` / `[stages.<name>]`
200    /// blocks; when this global is `false`, they opt back *in* by setting it to
201    /// `true` at the narrower scope.
202    #[serde(default = "default_true")]
203    pub batch_tool_hint: bool,
204
205    /// Global master switch for the platform shell hint.
206    ///
207    /// **On by default (opt-out).** When `true`, a stage that advertises the
208    /// `shell` tool carries a short system block describing the shell it will
209    /// actually get, so the model doesn't spend iterations discovering it. The
210    /// hint is emitted only where the platform warrants one (today: Windows,
211    /// where commands run through `cmd.exe /C` rather than a POSIX shell), so
212    /// on Linux and macOS this toggle costs nothing either way. Individual
213    /// agents or stages override it with `shell_hint` in their `[agent]` /
214    /// `[stages.<name>]` blocks.
215    #[serde(default = "default_true")]
216    pub shell_hint: bool,
217
218    /// Machine-wide defaults for the empty-response nudge (`[nudge]`): the
219    /// `[System]` message injected when a stage's model replies with text
220    /// before making any tool call. All three keys (`enabled`, `max`, `text`)
221    /// are optional; an agent's `[agent.nudge]` or a stage's
222    /// `[stages.<name>.nudge]` overrides each field independently. See
223    /// [`leviath_core::resolve_nudge`].
224    #[serde(default)]
225    pub nudge: leviath_core::NudgeConfig,
226
227    /// Completion-webhook delivery tuning (retry/backoff/timeout).
228    #[serde(default)]
229    pub webhook: WebhookConfig,
230
231    /// Structured observability export (OpenTelemetry). Off by default; when
232    /// enabled the daemon exports run/stage/inference/tool spans, metrics, and
233    /// trace-correlated log records for every agent run. The standard
234    /// `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_SERVICE_NAME` env vars fill any
235    /// hole the file leaves, same as the provider keys.
236    #[serde(default)]
237    pub observability: ObservabilityConfig,
238
239    /// Machine-wide default sandbox for tool execution. An agent's own
240    /// `[sandbox]` (or a stage's) overrides this; when unset, agents run tools
241    /// on the host unless they opt in themselves. See
242    /// [`leviath_core::resolve_sandbox`].
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub sandbox: Option<leviath_core::ToolSandboxConfig>,
245
246    /// Per-host-function permissions for Rhai script tools (Layer 3). Gates what
247    /// a registered script tool may *do* (network, shell, file, env access).
248    #[serde(default)]
249    pub tool_script_permissions: ScriptToolPermissions,
250
251    /// Machine-wide security switches that aren't part of the per-tool
252    /// permission cascade. (The global taint master switch stays the top-level
253    /// [`Self::taint_tracking`] key for back-compat.)
254    #[serde(default)]
255    pub security: SecurityConfig,
256
257    /// Per-agent read grants, keyed by agent name - the itemized counterpart
258    /// of [`SecurityConfig::allow_blueprint_read_paths`], analogous to
259    /// [`Self::agent_tool_permissions`]:
260    ///
261    /// ```toml
262    /// [agent_read_paths.cto]
263    /// allow = ["~/.leviath/runs", "glob:~/design-docs/**"]
264    /// ```
265    ///
266    /// Naming the agent here is the user saying "I trust this one to read
267    /// these" - a decision that lives in the user's config, not the
268    /// downloaded manifest. As with `[security] read_paths`, a grant only
269    /// takes effect for a path the blueprint also declares.
270    #[serde(default)]
271    pub agent_read_paths: HashMap<String, ReadPathGrants>,
272}
273
274/// One agent's entry in `[agent_read_paths.<name>]`.
275#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
276pub struct ReadPathGrants {
277    /// Granted entries, same forms as a blueprint's `[read_paths] allow`.
278    #[serde(default)]
279    pub allow: Vec<String>,
280}
281
282/// `[security]` in `~/.leviath/config.toml`.
283///
284/// Distinct from a *blueprint's* `[security]` block, which configures taint
285/// tracking for one agent - this one holds machine-wide switches.
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
287pub struct SecurityConfig {
288    /// Whether a blueprint's `seed = { command = "..." }` regions may run.
289    ///
290    /// **On by default.** A command seed executes at spawn - before the first
291    /// inference, and therefore before any tool-approval prompt - so it is the
292    /// one place a manifest can run something without the user being asked.
293    /// It is still confined to the run's workdir, routed through the entry
294    /// stage's sandbox when the agent declares one, and capped by
295    /// `[limits] script_shell_timeout_secs`. Set this to `false` to refuse them
296    /// machine-wide, or pass `--no-seed-commands` for a single run. Inspect an
297    /// agent's command seeds before installing it with `lev validate <path>`.
298    #[serde(default = "default_true")]
299    pub allow_seed_commands: bool,
300
301    /// Whether agent-driven fetches may reach loopback, private, and link-local
302    /// addresses.
303    ///
304    /// **Off by default.** An agent's `web_fetch` URL is chosen by the model out
305    /// of context an attacker can influence - a search result, a page fetched a
306    /// moment ago, an issue body - so an unrestricted fetch makes the agent a
307    /// confused deputy *inside* the user's network. The concrete targets are
308    /// `http://169.254.169.254/…` (cloud metadata, which returns instance
309    /// credentials), `http://127.0.0.1:3000/api/…` (the user's own `lev serve`),
310    /// and anything on the LAN.
311    ///
312    /// Turn this on when the agent is genuinely meant to talk to something local -
313    /// a self-hosted model, a dev server under test. It applies to the script
314    /// host's `http_get`/`http_post` and to redirect following; see
315    /// [`leviath_core::net`].
316    #[serde(default)]
317    pub allow_local_network: bool,
318
319    /// Credential-shaped environment variables that agent scripts may read.
320    ///
321    /// A Rhai script tool or script provider calling `env_var("NAME")` gets any
322    /// ordinary variable - `PATH`, `TZ`, an app's own config. A name that *looks
323    /// like a credential* (see [`leviath_core::secrets::is_sensitive_env_name`])
324    /// is refused unless it appears here, because a two-line script tool reading
325    /// `ANTHROPIC_API_KEY` and POSTing it elsewhere was otherwise a working
326    /// exfiltration path with no prompt anywhere in it.
327    ///
328    /// List the exact names a script legitimately needs - typically the key for
329    /// a custom provider script:
330    ///
331    /// ```toml
332    /// [security]
333    /// allow_env_vars = ["MY_PROVIDER_KEY"]
334    /// ```
335    ///
336    /// Matching is case-insensitive and exact. There is no wildcard: `"*"` is
337    /// read as a variable literally named `*`, not as "allow everything".
338    #[serde(default)]
339    pub allow_env_vars: Vec<String>,
340
341    /// Whether a blueprint's `[read_paths]` declarations are honored as-is.
342    ///
343    /// **Off by default.** A `[read_paths]` block travels inside the
344    /// `agent.leviath` you installed, and a manifest may only *tighten* what
345    /// your config allows, never widen it - otherwise any agent package could
346    /// read `~/.ssh`, this very config file (your API keys), or a password
347    /// store by shipping one TOML line. With this off, an agent's declared
348    /// read paths are inert until you grant them via [`Self::read_paths`] or
349    /// `[agent_read_paths.<name>]`. Turning it on says "any blueprint I run
350    /// may read every path it declares" - reads only, each access still
351    /// resolves symlinks and must land inside a declared entry, but prefer
352    /// the per-agent grant for anything you did not author yourself.
353    #[serde(default)]
354    pub allow_blueprint_read_paths: bool,
355
356    /// Machine-wide read grants for agents that declare `[read_paths]`.
357    ///
358    /// Entries use the same three forms as a blueprint's `[read_paths] allow`:
359    /// an exact path (grants its subtree), `glob:` and `regex:` patterns
360    /// (matched against the symlink-resolved real path, written with `/` on
361    /// every OS, regex auto-anchored). `~/` expands to your home; a relative
362    /// entry resolves against the run's workdir.
363    ///
364    /// ```toml
365    /// [security]
366    /// read_paths = ["~/.leviath/runs", "glob:~/design-docs/**"]
367    /// ```
368    ///
369    /// A grant only takes effect for a path the running blueprint *also*
370    /// declares - by itself it grants nothing, so listing a directory here
371    /// does not open it to agents that never asked.
372    #[serde(default)]
373    pub read_paths: Vec<String>,
374
375    /// Where provider API keys and MCP OAuth tokens are kept.
376    ///
377    /// **`file` by default** - `~/.leviath/config.toml` and
378    /// `~/.leviath/mcp-auth.json`, both created `0600` so they are never even
379    /// briefly world-readable. This is what Claude Code and Codex do, and it is
380    /// the only backend that works headless, in a container, over SSH, and on a
381    /// CI runner.
382    ///
383    /// Set it to `keychain` to move secrets into the OS credential store (macOS
384    /// Keychain, Windows Credential Manager, Secret Service elsewhere), so a
385    /// stolen `~/.leviath` directory yields nothing:
386    ///
387    /// ```toml
388    /// [security]
389    /// credential_store = "keychain"
390    /// ```
391    ///
392    /// Then run `lev auth migrate` to move the secrets you already have. It is
393    /// opt-in rather than the default because an unavailable keychain is not a
394    /// degraded experience but a broken one - every inference fails at once -
395    /// and the environments Leviath is most useful in are the least likely to
396    /// have a working credential store. `lev auth status` reports whether this
397    /// machine actually has one.
398    #[serde(default)]
399    pub credential_store: leviath_core::CredentialStoreKind,
400}
401
402impl Default for SecurityConfig {
403    fn default() -> Self {
404        Self {
405            allow_seed_commands: true,
406            allow_local_network: false,
407            allow_env_vars: Vec::new(),
408            allow_blueprint_read_paths: false,
409            read_paths: Vec::new(),
410            credential_store: leviath_core::CredentialStoreKind::File,
411        }
412    }
413}
414
415fn default_true() -> bool {
416    true
417}
418
419fn default_max_concurrent_inferences() -> Option<usize> {
420    Some(8)
421}
422
423fn default_default_max_iterations() -> Option<usize> {
424    Some(50)
425}
426
427fn default_max_concurrent_tools() -> usize {
428    8
429}
430
431fn default_script_shell_timeout_secs() -> u64 {
432    60
433}
434
435fn default_stall_timeout_secs() -> u64 {
436    leviath_runtime::pipeline::DEFAULT_STALL_TIMEOUT_SECS
437}
438
439fn default_dead_cycles_before_relief() -> u32 {
440    leviath_runtime::host::DEFAULT_DEAD_CYCLES_BEFORE_RELIEF
441}
442
443fn default_finished_retention_secs() -> u64 {
444    leviath_runtime::host::DEFAULT_FINISHED_RETENTION_SECS
445}
446
447fn default_wedge_timeout_secs() -> u64 {
448    leviath_runtime::pipeline::DEFAULT_WEDGE_TIMEOUT_SECS
449}
450
451fn default_provider_failures_before_open() -> u32 {
452    leviath_runtime::pipeline::DEFAULT_FAILURES_BEFORE_OPEN
453}
454
455fn default_provider_circuit_cooldown_secs() -> u64 {
456    leviath_runtime::pipeline::DEFAULT_CIRCUIT_COOLDOWN_SECS
457}
458
459fn default_interaction_timeout_secs() -> u64 {
460    leviath_runtime::interaction_hub::DEFAULT_INTERACTION_TIMEOUT_SECS
461}
462
463/// Runtime resource limits with safe defaults baked in.
464///
465/// Both fields default to a bounded value so a fresh install can't accidentally
466/// run unbounded inference concurrency or an unbounded agent loop. Set a field
467/// explicitly in `[limits]` to raise or lower it.
468#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct LimitsConfig {
470    /// Global fallback cap on concurrent inference requests for any model
471    /// without its own per-model pool entry. Defaults to `Some(8)`; omit or set
472    /// a large number to effectively unbound it.
473    #[serde(default = "default_max_concurrent_inferences")]
474    pub max_concurrent_inferences: Option<usize>,
475
476    /// Size of the shared tool-execution worker pool - the number of agents whose
477    /// tool batches may run concurrently across the whole daemon (the tool-lane
478    /// counterpart of `max_concurrent_inferences`). Defaults to `8`. Clamped to at
479    /// least 1.
480    #[serde(default = "default_max_concurrent_tools")]
481    pub max_concurrent_tools: usize,
482
483    /// Fallback `max_iterations` applied to a stage that does not set its own,
484    /// so an agent can't loop forever with no completion signal. Defaults to
485    /// `Some(50)`. A stage's explicit `max_iterations` always wins.
486    #[serde(default = "default_default_max_iterations")]
487    pub default_max_iterations: Option<usize>,
488
489    /// Opt-in exact pre-inference token budgeting. When `true`, each agent
490    /// inference is preceded by an exact token count of the assembled request
491    /// (via the provider's `count_tokens`, which uses a remote endpoint for
492    /// Anthropic/Gemini and a local heuristic otherwise) and is rejected before
493    /// sending if it would exceed the model's context window. Off by default:
494    /// normal budgeting uses cheap local estimates, and this adds a network
495    /// round-trip per inference for providers with a remote count endpoint.
496    #[serde(default)]
497    pub exact_token_counting: bool,
498
499    /// Wall-clock timeout (seconds) for a Rhai script tool's `shell()` host call,
500    /// mirroring the built-in shell tool's own 60-second cap so a script can't
501    /// hang an agent on a runaway command. Defaults to `60`.
502    #[serde(default = "default_script_shell_timeout_secs")]
503    pub script_shell_timeout_secs: u64,
504
505    /// How long (seconds) a run may sit ready to work but unable to dispatch
506    /// before it is failed instead of left running.
507    ///
508    /// This only ever fires for something the runtime cannot resolve on its own -
509    /// today, a stage whose provider is not configured. Waiting for a busy
510    /// model's inference pool is ordinary backpressure and is never failed, no
511    /// matter how long it takes. Defaults to `60`; `0` disables the watchdog and
512    /// restores the old behaviour of waiting indefinitely.
513    ///
514    /// Read once at daemon start, so a change needs a daemon restart.
515    #[serde(default = "default_stall_timeout_secs")]
516    pub stall_timeout_secs: u64,
517
518    /// How many consecutive safety re-drives may find the tool lane full and no
519    /// run moving before the daemon widens the lane to break the jam.
520    ///
521    /// The daemon re-drives itself every 30 seconds, so the default of `10` is
522    /// five minutes of a full lane going nowhere. Relief only ever *adds*
523    /// capacity, never cancels anything, and is capped at one extra lane's worth
524    /// over the daemon's life, so it cannot run away.
525    ///
526    /// `0` turns relief off. Detection and reporting stay on either way, so
527    /// `lev ps` and the metrics still show the streak.
528    ///
529    /// Read once at daemon start, so a change needs a daemon restart.
530    #[serde(default = "default_dead_cycles_before_relief")]
531    pub dead_cycles_before_relief: u32,
532
533    /// How long (seconds) a run keeps its place in `lev ps` after the daemon
534    /// unloads it from memory.
535    ///
536    /// A terminal run used to leave the listing the moment it was unloaded,
537    /// which made a run that died on its first inference look exactly like a run
538    /// that had never been spawned. A scheduler polling the listing could only
539    /// tell the two apart with a stopwatch, and issue #205 is what that cost:
540    /// forty minutes of spawning work, timing out, and spawning it again.
541    ///
542    /// Defaults to `300`. `0` drops a run as soon as it finishes, which is the
543    /// old behaviour. The record lives in memory, so a restart clears it
544    /// whatever this is set to.
545    ///
546    /// Read once at daemon start, so a change needs a daemon restart.
547    #[serde(default = "default_finished_retention_secs")]
548    pub finished_retention_secs: u64,
549    /// How long (seconds) a run may sit in a state no part of the engine can
550    /// reach before it is failed instead of left reported as running.
551    ///
552    /// Not a general "this run looks slow" timeout, and never fires on one. An
553    /// agent waiting on the model, on a tool, on its sub-agents, or on a person
554    /// is holding the marker that says so, and is exempt however long it takes.
555    /// This only catches an agent holding *no* marker at all, which the engine's
556    /// own invariants say cannot happen and which nothing will ever look at
557    /// again. Such a run stays `running` in `meta.json` for the life of the
558    /// daemon and keeps whatever capacity an external scheduler assigned it,
559    /// which is issue #202.
560    ///
561    /// Defaults to `0`, which is off: this fails runs, and an upgrade that
562    /// starts killing work nobody asked it to kill is worse than the leak. `300`
563    /// is a reasonable value to set. Turning it on is also a way to find out
564    /// whether it is happening to you, since it says so in the log and in the
565    /// run's error.
566    ///
567    /// Read once at daemon start, so a change needs a daemon restart.
568    #[serde(default = "default_wedge_timeout_secs")]
569    pub wedge_timeout_secs: u64,
570    /// How many consecutive provider-fatal failures (out of credits, rejected
571    /// key) take a provider out of service for every run.
572    ///
573    /// Defaults to `3`. One 402 can just be a request asking for more output
574    /// tokens than the balance covers; three in a row is the account. While a
575    /// provider is out, runs move to their next candidate, and runs with none
576    /// left are failed by the stall watchdog rather than left "running".
577    ///
578    /// `0` disables the breaker, leaving per-run failover on its own.
579    ///
580    /// Read once at daemon start, so a change needs a daemon restart.
581    #[serde(default = "default_provider_failures_before_open")]
582    pub provider_failures_before_open: u32,
583
584    /// How long a provider stays out of service before one request is let
585    /// through to see whether it recovered.
586    ///
587    /// Defaults to `300` (five minutes). That probe either succeeds, which puts
588    /// the provider straight back into service, or fails and restarts the wait,
589    /// so topping up an account brings the factory back with no restart.
590    ///
591    /// Read once at daemon start, so a change needs a daemon restart.
592    #[serde(default = "default_provider_circuit_cooldown_secs")]
593    pub provider_circuit_cooldown_secs: u64,
594    /// How long (seconds) a prompt may go unanswered before the daemon resolves
595    /// it itself and lets the run carry on.
596    ///
597    /// Covers every prompt that waits on a person: an agent's `ask_user_*` /
598    /// `present_for_review` call, a tool-approval prompt, a taint gate, and a
599    /// blueprint interaction point. Before this existed, a run whose operator
600    /// had walked away sat in `WaitingInput` holding its slot until the daemon
601    /// restarted - hours, in the report that prompted it (issue #204).
602    ///
603    /// Expiry resolves the prompt exactly as cancelling it would: a tool
604    /// approval and a taint gate **deny**, an `ask_user_*` call is told nobody
605    /// answered, and an interaction point proceeds with no user text. Nothing is
606    /// approved on the strength of a timeout.
607    ///
608    /// Defaults to `3600` (one hour); `0` waits indefinitely.
609    ///
610    /// Read once at daemon start, so a change needs a daemon restart.
611    #[serde(default = "default_interaction_timeout_secs")]
612    pub interaction_timeout_secs: u64,
613}
614
615impl Default for LimitsConfig {
616    fn default() -> Self {
617        Self {
618            max_concurrent_inferences: default_max_concurrent_inferences(),
619            max_concurrent_tools: default_max_concurrent_tools(),
620            default_max_iterations: default_default_max_iterations(),
621            exact_token_counting: false,
622            script_shell_timeout_secs: default_script_shell_timeout_secs(),
623            stall_timeout_secs: default_stall_timeout_secs(),
624            dead_cycles_before_relief: default_dead_cycles_before_relief(),
625            finished_retention_secs: default_finished_retention_secs(),
626            wedge_timeout_secs: default_wedge_timeout_secs(),
627            provider_failures_before_open: default_provider_failures_before_open(),
628            provider_circuit_cooldown_secs: default_provider_circuit_cooldown_secs(),
629            interaction_timeout_secs: default_interaction_timeout_secs(),
630        }
631    }
632}
633
634fn default_webhook_max_retries() -> u32 {
635    3
636}
637
638fn default_webhook_base_delay_ms() -> u64 {
639    500
640}
641
642fn default_webhook_max_delay_ms() -> u64 {
643    30_000
644}
645
646fn default_webhook_timeout_secs() -> u64 {
647    10
648}
649
650/// Completion-webhook delivery tuning.
651///
652/// A completion webhook is POSTed when a run reaches a terminal status. Delivery
653/// retries on transient failures (network errors, timeouts, 5xx, 429, 408) with
654/// exponential backoff. Each field has a safe default so `[webhook]` can be
655/// omitted entirely.
656#[derive(Debug, Clone, Serialize, Deserialize)]
657pub struct WebhookConfig {
658    /// Number of retries **after** the first attempt (so total sends is
659    /// `max_retries + 1`). Defaults to `3`. Set `0` to disable retries.
660    #[serde(default = "default_webhook_max_retries")]
661    pub max_retries: u32,
662
663    /// Base backoff before the first retry, in milliseconds. Subsequent retries
664    /// double it (capped at `max_delay_ms`). Defaults to `500`.
665    #[serde(default = "default_webhook_base_delay_ms")]
666    pub base_delay_ms: u64,
667
668    /// Upper bound on any single backoff delay, in milliseconds. Defaults to
669    /// `30_000` (30s).
670    #[serde(default = "default_webhook_max_delay_ms")]
671    pub max_delay_ms: u64,
672
673    /// Per-attempt request timeout, in seconds. Defaults to `10`.
674    #[serde(default = "default_webhook_timeout_secs")]
675    pub timeout_secs: u64,
676}
677
678impl Default for WebhookConfig {
679    fn default() -> Self {
680        Self {
681            max_retries: default_webhook_max_retries(),
682            base_delay_ms: default_webhook_base_delay_ms(),
683            max_delay_ms: default_webhook_max_delay_ms(),
684            timeout_secs: default_webhook_timeout_secs(),
685        }
686    }
687}
688
689/// Provider configuration.
690///
691/// `Debug` is hand-written (see below) so the keys cannot be printed.
692#[derive(Clone, Serialize, Deserialize)]
693pub struct ProviderConfig {
694    /// Anthropic API key
695    pub anthropic_api_key: Option<String>,
696
697    /// OpenAI API key
698    pub openai_api_key: Option<String>,
699
700    /// Google AI (Gemini) API key
701    pub google_api_key: Option<String>,
702
703    /// Whether the Claude Code CLI transport is enabled.
704    ///
705    /// **Opt-in, and never selected for the user.** The CLI injects its own
706    /// context into every call - including the account email address on the
707    /// OAuth (subscription) path - which cannot be disabled. `lev setup` offers
708    /// it and defaults to declining, so a user who presses Enter through the
709    /// wizard ends up with it off.
710    #[serde(default)]
711    pub claude_code_enabled: bool,
712
713    /// Path to the `claude` executable. `None` resolves `claude` on `PATH`.
714    #[serde(default)]
715    pub claude_code_binary: Option<String>,
716
717    /// Reasoning effort for the Claude Code transport: `low` | `medium` |
718    /// `high` | `xhigh` | `max`.
719    ///
720    /// Always sent explicitly. Left to itself the CLI picks `high` with adaptive
721    /// thinking, spending output tokens and latency Leviath never asked for.
722    /// `None` uses [`leviath_providers::claude_code::DEFAULT_EFFORT`].
723    #[serde(default)]
724    pub claude_code_effort: Option<String>,
725
726    /// Host-wide failover chain, as `"provider/model"` entries, best first.
727    ///
728    /// Tried after a stage's own `models` list and the default model when the
729    /// provider in use stops answering (out of credits, rejected key). Entries
730    /// naming an unregistered provider are skipped, and a malformed entry is
731    /// ignored with a warning rather than failing the load.
732    ///
733    /// `provider/model` rather than a bare provider name because a failover
734    /// target needs a model to send; there is no sensible default per provider.
735    /// A blueprint that names one model has nowhere to go without this, which
736    /// is exactly how issue #201 took every agent down at once.
737    #[serde(default)]
738    pub fallback_order: Vec<String>,
739}
740
741/// Hand-written so the API keys can never be printed.
742///
743/// A `#[derive(Debug)]` here meant one `tracing::debug!(?config)` anywhere in
744/// the workspace - or one `dbg!`, or an `anyhow` context that formats a struct
745/// holding this - would put every provider key into the logs. Nothing did that
746/// today, which is exactly when it is cheap to foreclose: the type now cannot
747/// leak, so nobody has to remember not to.
748///
749/// Reports whether each key is *set*, which is what a debug line is actually
750/// asking, and mirrors the `RedactedConfig` the `/api/config` handler returns.
751impl std::fmt::Debug for ProviderConfig {
752    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
753        f.debug_struct("ProviderConfig")
754            .field("anthropic_api_key", &redacted(&self.anthropic_api_key))
755            .field("openai_api_key", &redacted(&self.openai_api_key))
756            .field("google_api_key", &redacted(&self.google_api_key))
757            .field("claude_code_enabled", &self.claude_code_enabled)
758            .field("claude_code_binary", &self.claude_code_binary)
759            .field("claude_code_effort", &self.claude_code_effort)
760            .field("fallback_order", &self.fallback_order)
761            .finish()
762    }
763}
764
765/// `"<set>"` or `"<unset>"` for an optional secret, for [`Debug`] output.
766fn redacted(value: &Option<String>) -> &'static str {
767    match value {
768        Some(_) => "<set>",
769        None => "<unset>",
770    }
771}
772
773/// Optional overrides for a Rhai script provider, from `[model_providers.<name>]`.
774///
775/// Every field is optional. Keys not recognized below flow into [`Self::extra`]
776/// and are forwarded to the script's `initialize(config)` alongside `base_url`
777/// and `api_key`.
778#[derive(Debug, Clone, Serialize, Deserialize, Default)]
779pub struct ModelProviderConfig {
780    /// Script filename stem or path. Defaults to `<name>.rhai` in the providers
781    /// directory (`~/.leviath/providers/`).
782    #[serde(default)]
783    pub script: Option<String>,
784
785    /// API key forwarded to the script as `config.api_key` (a script may instead
786    /// read its own environment variable).
787    #[serde(default)]
788    pub api_key: Option<String>,
789
790    /// Base URL forwarded to the script as `config.base_url`.
791    #[serde(default)]
792    pub base_url: Option<String>,
793
794    /// Rate limit enforced by the Rust wrapper (requests/tokens per minute).
795    #[serde(default)]
796    pub rate_limit: Option<leviath_providers::RateLimitConfig>,
797
798    /// Any additional keys, forwarded verbatim into the script's `initialize`.
799    #[serde(flatten)]
800    pub extra: HashMap<String, toml::Value>,
801}
802
803impl Default for Config {
804    fn default() -> Self {
805        Self {
806            default_provider: "anthropic".to_string(),
807            providers: ProviderConfig {
808                anthropic_api_key: None,
809                openai_api_key: None,
810                google_api_key: None,
811                claude_code_enabled: false,
812                claude_code_binary: None,
813                claude_code_effort: None,
814                fallback_order: Vec::new(),
815            },
816            agent_paths: Vec::new(),
817            openrouter_api_key: None,
818            ollama_base_url: None,
819            mcp_servers: Vec::new(),
820            default_model: None,
821            model_capabilities: HashMap::new(),
822            model_providers: HashMap::new(),
823            tool_permissions: HashMap::new(),
824            agent_tool_permissions: HashMap::new(),
825            title: TitleConfig::default(),
826            request_timeout_secs: None,
827            rate_limits: HashMap::new(),
828            taint_tracking: false,
829            limits: LimitsConfig::default(),
830            batch_tool_hint: true,
831            shell_hint: true,
832            nudge: leviath_core::NudgeConfig::default(),
833            webhook: WebhookConfig::default(),
834            observability: ObservabilityConfig::default(),
835            sandbox: None,
836            tool_script_permissions: ScriptToolPermissions::default(),
837            security: SecurityConfig::default(),
838            agent_read_paths: HashMap::new(),
839        }
840    }
841}
842
843impl Config {
844    /// The permission ceiling to apply to `agent_name`: the global
845    /// `[tool_permissions]` with that agent's `[agent_tool_permissions.<name>]`
846    /// entries laid over it.
847    ///
848    /// Returned by value (rather than as two maps threaded through
849    /// [`crate::tools::resolve_policy`]) so the ceiling is resolved exactly once,
850    /// at spawn, and every later lookup reads a single flat map.
851    pub fn permissions_for_agent(&self, agent_name: &str) -> HashMap<String, ToolPolicy> {
852        let mut merged = self.tool_permissions.clone();
853        if let Some(per_agent) = self.agent_tool_permissions.get(agent_name) {
854            merged.extend(per_agent.iter().map(|(k, v)| (k.clone(), *v)));
855        }
856        merged
857    }
858
859    /// Every read-path grant that applies to `agent_name`: the machine-wide
860    /// `[security] read_paths` list plus that agent's
861    /// `[agent_read_paths.<name>]` entries. Resolved once at spawn, mirroring
862    /// [`Self::permissions_for_agent`].
863    pub fn read_path_grants_for_agent(&self, agent_name: &str) -> Vec<String> {
864        let mut grants = self.security.read_paths.clone();
865        if let Some(per_agent) = self.agent_read_paths.get(agent_name) {
866            grants.extend(per_agent.allow.iter().cloned());
867        }
868        grants
869    }
870
871    /// Load configuration from the default location (~/.leviath/config.toml).
872    ///
873    /// After loading from file (or using defaults), environment variables are
874    /// checked as fallbacks. Env vars override config file values if set.
875    pub fn load() -> anyhow::Result<Self> {
876        // In the crate's own test build, refuse to read the *real* environment.
877        //
878        // `Config::load()` reads process-wide state, and `cargo test` runs tests
879        // in parallel threads of one process. `temp_env` serializes its own
880        // calls behind a global lock, but a test that reaches this function
881        // without going through that lock races every test that holds it - so
882        // it sees whatever variables happen to be set or unset at that instant.
883        // That is not hypothetical: the `serve` CORS test failed on CI in two
884        // different places depending on when it lost the race, each time
885        // accusing code that was correct.
886        //
887        // Making it a hard error rather than an audit means the next test to
888        // reach here unisolated fails immediately and locally, with the fix in
889        // the message, instead of flaking on someone else's pull request months
890        // later.
891        #[cfg(test)]
892        assert!(
893            std::env::var_os("LEVIATH_CONFIG_PATH").is_some(),
894            "Config::load() reached from a test that has not isolated the \
895             environment. Wrap the test in `config::with_isolated_config_path` \
896             (or `..._async`), which both points this at a scratch config and \
897             takes the same process-wide lock every other env-touching test \
898             holds. Without it this test races them and fails intermittently, \
899             somewhere else."
900        );
901
902        // Load a `.env` from the current directory only.
903        //
904        // `dotenvy::dotenv()` searches the cwd *and every ancestor*, which is
905        // the wrong shape for a coding agent: `lev` is designed to be run inside
906        // cloned repositories, so an untrusted repo's `.env` - or one in any
907        // directory above it - was loaded into the process environment. That is
908        // load-bearing well beyond provider keys: `PATH` and `SHELL` decide what
909        // gets executed, `EDITOR`/`VISUAL` are split and spawned, `OLLAMA_HOST`
910        // redirects inference to an attacker's endpoint, `LEVIATH_HOME`
911        // relocates the directories agent scripts are discovered from, and
912        // `LEVIATH_API_TOKEN` sets a known credential on the agent-spawning API.
913        //
914        // `from_filename` reads only `./.env`. Still the user's own working
915        // directory, so this is not a trust boundary on its own - but it is one
916        // directory the user chose rather than an unbounded walk up the tree.
917        //
918        // `LEVIATH_SKIP_DOTENV` lets tests isolate `Config::load()` completely.
919        if std::env::var_os("LEVIATH_SKIP_DOTENV").is_none() {
920            let _ = dotenvy::from_filename(".env");
921        }
922
923        let config = Self::load_from_path(&Self::config_path())?;
924
925        // Check config file permissions on Unix
926        check_permissions();
927
928        Ok(config)
929    }
930
931    /// Core of `load()`, parameterized by path so it can be exercised in
932    /// tests against a tempfile instead of the real `~/.leviath/config.toml`.
933    fn load_from_path(path: &std::path::Path) -> anyhow::Result<Self> {
934        let mut config = if !path.exists() {
935            let path_display = path.display();
936            tracing::debug!("No config file found at {}, using defaults", path_display);
937            Self::default()
938        } else {
939            let content = std::fs::read_to_string(path).map_err(|e| {
940                anyhow::anyhow!("Failed to read config from '{}': {}", path.display(), e)
941            })?;
942
943            let c: Self = toml::from_str(&content)
944                .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
945
946            // Catch a malformed MCP server entry here, at load, rather than at
947            // the first tool call: a typo that drops a server's tools should
948            // fail loudly and immediately.
949            for server in &c.mcp_servers {
950                server.validate()?;
951            }
952
953            let path_display = path.display();
954            tracing::debug!("Loaded config from {}", path_display);
955            c
956        };
957
958        // Env var fallbacks (env vars override config file if set)
959        if config.providers.anthropic_api_key.is_none() {
960            config.providers.anthropic_api_key = std::env::var("ANTHROPIC_API_KEY").ok();
961        }
962        if config.providers.openai_api_key.is_none() {
963            config.providers.openai_api_key = std::env::var("OPENAI_API_KEY").ok();
964        }
965        if config.providers.google_api_key.is_none() {
966            config.providers.google_api_key = std::env::var("GOOGLE_API_KEY").ok();
967        }
968        if config.openrouter_api_key.is_none() {
969            config.openrouter_api_key = std::env::var("OPENROUTER_API_KEY").ok();
970        }
971        // OLLAMA_HOST is the standard env var for Ollama
972        if config.ollama_base_url.is_none() {
973            config.ollama_base_url = std::env::var("OLLAMA_HOST").ok();
974        }
975
976        config.fill_from_credential_store();
977
978        Ok(config)
979    }
980
981    /// Fill any provider key still unset from the configured credential store.
982    fn fill_from_credential_store(&mut self) {
983        let resolved = crate::credentials::store_for(self.security.credential_store);
984        self.fill_from_credential_store_with(resolved);
985    }
986
987    /// Core of [`fill_from_credential_store`](Self::fill_from_credential_store)
988    /// with the backend already resolved.
989    ///
990    /// Runs *after* the file and the environment, so precedence is file > env >
991    /// keychain: what the user can see wins over what they cannot. In keychain
992    /// mode `lev auth migrate` strips the keys out of the file, so in practice
993    /// the keychain is the only source - but a key left behind by hand keeps
994    /// working rather than being silently ignored, and `lev auth status` reports
995    /// when a secret exists in both places.
996    ///
997    /// A store that cannot be opened is a warning, not a hard failure. The user
998    /// may still have working keys in their environment, and refusing to load
999    /// the config at all would take down `lev auth status` - the one command
1000    /// that can explain what is wrong. The resolution is the caller's so that
1001    /// path is testable: "no store is installed in this process" is not the same
1002    /// as "this machine has no keychain", and on a developer's Mac the first
1003    /// silently becomes the second.
1004    fn fill_from_credential_store_with(&mut self, resolved: crate::credentials::Resolved) {
1005        match resolved {
1006            Ok(Some(store)) => self.apply_credential_store(store.as_ref()),
1007            // The file backend keeps its keys in this struct already.
1008            Ok(None) => {}
1009            Err(e) => {
1010                tracing::warn!("{e}. Falling back to keys from the config file and environment.");
1011            }
1012        }
1013    }
1014
1015    /// Overlay `store`'s secrets onto whichever provider keys are still unset.
1016    fn apply_credential_store(&mut self, store: &dyn leviath_core::CredentialStore) {
1017        let accounts: Vec<String> = crate::credentials::PROVIDER_KEYS
1018            .iter()
1019            .map(|p| leviath_core::provider_account(p))
1020            .collect();
1021        let mut found = store.read_all(&accounts);
1022        let mut take = |provider: &str| found.remove(&leviath_core::provider_account(provider));
1023
1024        let anthropic = take("anthropic");
1025        let openai = take("openai");
1026        let google = take("google");
1027        let openrouter = take("openrouter");
1028
1029        self.providers.anthropic_api_key = self.providers.anthropic_api_key.take().or(anthropic);
1030        self.providers.openai_api_key = self.providers.openai_api_key.take().or(openai);
1031        self.providers.google_api_key = self.providers.google_api_key.take().or(google);
1032        self.openrouter_api_key = self.openrouter_api_key.take().or(openrouter);
1033    }
1034
1035    /// This config with every provider API key removed.
1036    ///
1037    /// What gets serialized in keychain mode: the secrets go to the OS store and
1038    /// the file keeps only the settings. Returning a stripped copy rather than
1039    /// mutating in place matters - the caller is usually saving a config it is
1040    /// still going to use for inference, and blanking its keys would break the
1041    /// run that triggered the save.
1042    fn without_secrets(&self) -> Self {
1043        let mut copy = self.clone();
1044        copy.providers.anthropic_api_key = None;
1045        copy.providers.openai_api_key = None;
1046        copy.providers.google_api_key = None;
1047        copy.openrouter_api_key = None;
1048        copy
1049    }
1050
1051    /// Every provider key currently set, as `(account, secret)` pairs.
1052    pub(crate) fn provider_secrets(&self) -> Vec<(String, String)> {
1053        [
1054            ("anthropic", self.providers.anthropic_api_key.as_deref()),
1055            ("openai", self.providers.openai_api_key.as_deref()),
1056            ("google", self.providers.google_api_key.as_deref()),
1057            ("openrouter", self.openrouter_api_key.as_deref()),
1058        ]
1059        .into_iter()
1060        .filter_map(|(name, key)| {
1061            key.map(|k| (leviath_core::provider_account(name), k.to_string()))
1062        })
1063        .collect()
1064    }
1065
1066    /// Save configuration to a path, parameterized so it can be exercised in
1067    /// tests against a tempfile instead of the real `~/.leviath/config.toml`.
1068    /// `pub(crate)` so in-crate callers (e.g. the `setup` wizard) can inject a
1069    /// path; production writes to [`Self::config_path`].
1070    pub(crate) fn save_to_path(&self, path: &std::path::Path) -> anyhow::Result<()> {
1071        // Create parent directory if needed
1072        if let Some(parent) = path.parent() {
1073            create_config_dir(parent)?;
1074        }
1075
1076        // In keychain mode the secrets belong in the OS store, and the file
1077        // keeps only the settings - otherwise `lev setup` would helpfully write
1078        // every key back into `config.toml` and quietly undo the migration.
1079        //
1080        // A store that cannot be written is *not* silently downgraded to writing
1081        // the keys into the file: a user who asked for the keychain would end up
1082        // with plaintext keys on disk and no indication of it.
1083        let resolved = crate::credentials::store_for(self.security.credential_store);
1084        self.write_to(path, resolved)
1085    }
1086
1087    /// Core of [`save_to_path`](Self::save_to_path) with the backend already
1088    /// resolved - see
1089    /// [`fill_from_credential_store_with`](Self::fill_from_credential_store_with)
1090    /// for why the resolution is the caller's.
1091    fn write_to(
1092        &self,
1093        path: &std::path::Path,
1094        resolved: crate::credentials::Resolved,
1095    ) -> anyhow::Result<()> {
1096        let to_write = match resolved.map_err(|e| anyhow::anyhow!("{e}"))? {
1097            Some(store) => {
1098                for (account, secret) in self.provider_secrets() {
1099                    store
1100                        .set(&account, &secret)
1101                        .map_err(|e| anyhow::anyhow!("failed to store {account}: {e}"))?;
1102                }
1103                self.without_secrets()
1104            }
1105            None => self.clone(),
1106        };
1107
1108        // Config contains only primitive-typed fields; toml serialization is infallible.
1109        let content =
1110            toml::to_string_pretty(&to_write).expect("Config serialization is infallible");
1111
1112        // `write_private`, not `fs::write` + `chmod`. This file holds every
1113        // provider API key, and the two-step version left it at the umask
1114        // default (typically 0644) between the write and the mode change - so
1115        // every save had a moment where any local user could read the keys.
1116        leviath_sys::write_private(path, content.as_bytes()).map_err(|e| {
1117            anyhow::anyhow!("Failed to write config to '{}': {}", path.display(), e)
1118        })?;
1119
1120        let path_display = path.display();
1121        tracing::debug!("Saved config to {}", path_display);
1122        Ok(())
1123    }
1124
1125    /// Load a config from an explicit path (`lev mcp` uses this to read the
1126    /// file it is about to rewrite). Public wrapper over the tested `load_from_path`.
1127    pub fn load_from_path_public(path: &std::path::Path) -> anyhow::Result<Self> {
1128        Self::load_from_path(path)
1129    }
1130
1131    /// Save a config to an explicit path. Public wrapper over `save_to_path`, for `lev mcp` rewriting the config file.
1132    pub fn save_to_path_public(&self, path: &std::path::Path) -> anyhow::Result<()> {
1133        self.save_to_path(path)
1134    }
1135
1136    /// Get the path to the config file.
1137    ///
1138    /// Two overrides, narrowest first: `LEVIATH_CONFIG_PATH` names this file
1139    /// exactly, and `LEVIATH_HOME` (via [`leviath_core::data_dir`]) redirects it
1140    /// along with every other home-relative path.
1141    ///
1142    /// Honoring both matters. `LEVIATH_HOME`'s whole purpose is to "redirect
1143    /// every home-relative path at once" - that is what its doc says and what
1144    /// tests, sandboxed runs and scratch environments rely on - so a config
1145    /// path that quietly ignored it would let a run that believes it is
1146    /// isolated read *and write* the developer's real `~/.leviath/config.toml`,
1147    /// the file holding every provider API key. Found by doing exactly that
1148    /// during live testing.
1149    pub fn config_path() -> PathBuf {
1150        if let Ok(override_path) = std::env::var("LEVIATH_CONFIG_PATH") {
1151            return PathBuf::from(override_path);
1152        }
1153        leviath_core::data_dir()
1154            .unwrap_or_default()
1155            .join("config.toml")
1156    }
1157
1158    // Tests for the two overrides live in the `tests` module below; see
1159    // `config_path_honors_leviath_home`.
1160
1161    /// Validate API key formats and return warnings for suspicious keys.
1162    pub fn validate_keys(&self) -> Vec<String> {
1163        // A blank key means "not configured" (that is what `lev setup` writes
1164        // for a provider the user skipped), so it earns no warning - warning
1165        // about the shape of a key nobody set is noise that trains users to
1166        // ignore the ones that matter.
1167        let mut warnings = Vec::new();
1168        if let Some(key) = self.providers.anthropic_api_key.as_deref()
1169            && !key.trim().is_empty()
1170            && !key.starts_with("sk-ant-")
1171        {
1172            warnings.push(
1173                "Anthropic API key doesn't start with 'sk-ant-' - verify it's correct".to_string(),
1174            );
1175        }
1176        if let Some(key) = self.providers.openai_api_key.as_deref()
1177            && !key.trim().is_empty()
1178            && !key.starts_with("sk-")
1179        {
1180            warnings
1181                .push("OpenAI API key doesn't start with 'sk-' - verify it's correct".to_string());
1182        }
1183        warnings
1184    }
1185}
1186
1187/// The canonical `LEVIATH_HOME`-aware resolvers live in
1188/// [`leviath_core::paths`]; these re-exports keep this crate's established
1189/// names pointing at that single definition instead of carrying a byte-for-
1190/// byte copy of it (which is exactly how the override once diverged between
1191/// components). `Config::config_path()` stays separate: it has its own
1192/// narrower `LEVIATH_CONFIG_PATH` override above.
1193pub use leviath_core::paths::home_dir as leviath_home_dir;
1194pub use leviath_core::paths::providers_dir;
1195
1196/// Create the config directory with restrictive permissions.
1197fn create_config_dir(dir: &std::path::Path) -> anyhow::Result<()> {
1198    std::fs::create_dir_all(dir)
1199        .map_err(|e| anyhow::anyhow!("Failed to create config directory: {}", e))?;
1200    set_dir_permissions(dir);
1201    Ok(())
1202}
1203
1204/// Check permissions on the config file and auto-fix if too permissive.
1205///
1206/// A no-op on non-Unix platforms - see [`leviath_sys::ensure_file_private`].
1207fn check_permissions() {
1208    check_permissions_at(&Config::config_path());
1209}
1210
1211/// Core of [`check_permissions`], parameterized by path so it can be exercised
1212/// in tests against a tempfile instead of the real config path.
1213///
1214/// The permission mechanism (metadata probe + `chmod`) lives in `leviath_sys`;
1215/// this function owns only the policy of what to log for each outcome.
1216fn check_permissions_at(path: &std::path::Path) {
1217    check_permissions_at_with(path, leviath_sys::ensure_file_private);
1218}
1219
1220/// Core of [`check_permissions_at`] with the permission-hardening operation
1221/// injected, so the "fix failed" arm can be covered deterministically on every
1222/// OS. On disk that `Err` only occurs when a file exists but `chmod` fails -
1223/// forcing that without root differs per platform (macOS `chflags uchg`, no
1224/// portable Linux equivalent), so a `fn` pointer is injected instead of relying
1225/// on an OS-specific trick. A `fn` pointer (not `impl Fn`) keeps this to a
1226/// single monomorphization.
1227fn check_permissions_at_with(
1228    path: &std::path::Path,
1229    ensure: fn(&std::path::Path) -> std::io::Result<Option<u32>>,
1230) {
1231    match ensure(path) {
1232        Ok(Some(old_mode)) => {
1233            let masked_mode = old_mode & 0o777;
1234            tracing::warn!(
1235                "Config file has overly permissive permissions ({:o}), fixing to 600",
1236                masked_mode
1237            );
1238        }
1239        Ok(None) => {}
1240        Err(e) => tracing::warn!("Failed to fix config file permissions: {}", e),
1241    }
1242}
1243
1244/// Set restrictive permissions on the config directory.
1245fn set_dir_permissions(path: &std::path::Path) {
1246    set_dir_permissions_with(path, leviath_sys::secure_dir_perms);
1247}
1248
1249/// Core of [`set_dir_permissions`] with the hardening operation injected; see
1250/// [`set_file_permissions_with`] for why.
1251fn set_dir_permissions_with(
1252    path: &std::path::Path,
1253    secure: fn(&std::path::Path) -> std::io::Result<()>,
1254) {
1255    if let Err(e) = secure(path) {
1256        tracing::warn!("Failed to set config directory permissions: {}", e);
1257    }
1258}
1259
1260/// Serializes any test, anywhere in the crate, that mutates the process's
1261/// current working directory (via `std::env::set_current_dir`) or whose
1262/// assertions implicitly depend on it. Declared here (not inside `mod tests`)
1263/// so it's reachable crate-wide: a per-file lock (as in
1264/// `commands/run/manifest.rs`'s CWD-dependent `find_manifest` tests) would not
1265/// serialize against a CWD-mutating test in a different file. (Env-var
1266/// isolation, by contrast, goes through the `temp-env` crate's own global
1267/// lock; `set_current_dir` is not an env var, so it keeps this dedicated lock.)
1268#[cfg(test)]
1269pub(crate) static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1270
1271/// RAII guard that releases [`CWD_LOCK`] and restores the process's
1272/// original working directory on drop.
1273///
1274/// Wraps the `MutexGuard` inside a private field specifically so it can be held
1275/// across an `.await` in an async test without tripping clippy's
1276/// `await_holding_lock` lint, which only looks for a directly-visible
1277/// `MutexGuard` local - not one hidden inside a wrapper struct's field.
1278/// That's not working around a real risk: each `#[tokio::test]` gets its
1279/// own private single-threaded runtime, so holding this across an await
1280/// can't starve another task in the *same* test: it only serializes
1281/// against other CWD-mutating tests, which is exactly the intended effect.
1282///
1283/// Was `#[cfg(unix)]` as well, because its only caller -
1284/// `commands/list.rs`'s `execute_falls_back_to_default_cwd_when_current_dir_is_gone` -
1285/// is Unix-only (the race it reproduces, deleting a directory that is the
1286/// process's live CWD, is a sharing violation on Windows rather than a
1287/// reproducible state), which made it dead code there under `-D warnings`.
1288/// `a_dot_env_in_the_working_directory_is_read` is a second caller that must run
1289/// on every platform, so the gate is gone and the dead-code concern with it.
1290#[cfg(test)]
1291pub(crate) struct CwdTestGuard {
1292    original_cwd: std::path::PathBuf,
1293    _lock: std::sync::MutexGuard<'static, ()>,
1294}
1295
1296#[cfg(test)]
1297impl Drop for CwdTestGuard {
1298    fn drop(&mut self) {
1299        let _ = std::env::set_current_dir(&self.original_cwd);
1300    }
1301}
1302
1303/// Acquire [`CWD_LOCK`] and snapshot the current working directory so it can
1304/// be restored automatically when the returned guard drops.
1305#[cfg(test)]
1306pub(crate) fn isolate_cwd_for_test() -> CwdTestGuard {
1307    let lock = CWD_LOCK
1308        .lock()
1309        .unwrap_or_else(std::sync::PoisonError::into_inner);
1310    let original_cwd = std::env::current_dir().expect("current dir must be readable at test start");
1311    CwdTestGuard {
1312        original_cwd,
1313        _lock: lock,
1314    }
1315}
1316
1317/// Provider API key env vars that `Config::load()` (via `dotenvy::dotenv()`)
1318/// loads into the process env regardless of which config file path is used --
1319/// so redirecting the config path alone isn't enough; these must be cleared
1320/// too by [`config_isolation_vars`].
1321#[cfg(test)]
1322const PROVIDER_KEY_ENV_VARS: &[&str] = &[
1323    "ANTHROPIC_API_KEY",
1324    "OPENAI_API_KEY",
1325    "GOOGLE_API_KEY",
1326    "OPENROUTER_API_KEY",
1327];
1328
1329/// Create a fresh, empty temp directory to stand in for the config directory.
1330#[cfg(test)]
1331fn make_fake_config_dir(unique: &str) -> std::path::PathBuf {
1332    let fake_dir = std::env::temp_dir().join(format!("lev-fake-config-{unique}"));
1333    let _ = std::fs::create_dir_all(&fake_dir);
1334    fake_dir
1335}
1336
1337/// The env overrides that isolate `Config::load()` from the real environment:
1338/// point `LEVIATH_CONFIG_PATH` at a nonexistent file in `fake_dir`, set
1339/// `LEVIATH_SKIP_DOTENV`, and clear every provider API key (so no real, billed
1340/// inference call can be made). Consumed by [`with_isolated_config_path`] and
1341/// its async twin, which hand it to `temp_env` for scoped set-and-restore.
1342///
1343/// `pub(crate)` because `temp_env` serializes process-wide and holds its lock
1344/// across the closure, so a test needing *these* overrides plus others (the
1345/// `lev doctor` tests also redirect `LEVIATH_HOME` and `LEVIATH_RUNS_DIR`)
1346/// cannot nest a second `temp_env` call inside the wrapper - it has to build
1347/// one combined list from this one.
1348#[cfg(test)]
1349pub(crate) fn config_isolation_vars(
1350    fake_dir: &std::path::Path,
1351) -> Vec<(&'static str, Option<std::ffi::OsString>)> {
1352    let mut vars: Vec<(&'static str, Option<std::ffi::OsString>)> = vec![
1353        (
1354            "LEVIATH_CONFIG_PATH",
1355            Some(fake_dir.join("config.toml").into_os_string()),
1356        ),
1357        ("LEVIATH_SKIP_DOTENV", Some(std::ffi::OsString::from("1"))),
1358    ];
1359    for &key in PROVIDER_KEY_ENV_VARS {
1360        vars.push((key, None));
1361    }
1362    vars
1363}
1364
1365/// Runs `f` with `Config::load()` isolated from the real environment (see
1366/// [`config_isolation_vars`]), passing it the fake config directory so tests
1367/// that need to plant a `config.toml` can. `temp_env::with_vars` sets the
1368/// overrides, runs the closure, and restores the prior values afterwards --
1369/// serialized process-wide against every other temp-env test, so no hand-rolled
1370/// lock is needed. The closure-scoped form (not an RAII guard) is required
1371/// because edition 2024 makes `set_var` `unsafe`, which the crate forbids.
1372#[cfg(test)]
1373pub(crate) fn with_isolated_config_path<R>(
1374    unique: &str,
1375    f: impl FnOnce(&std::path::Path) -> R,
1376) -> R {
1377    let fake_dir = make_fake_config_dir(unique);
1378    let result = temp_env::with_vars(config_isolation_vars(&fake_dir), || f(&fake_dir));
1379    let _ = std::fs::remove_dir_all(&fake_dir);
1380    result
1381}
1382
1383/// Async counterpart of [`with_isolated_config_path`] for `#[tokio::test]`s.
1384/// The isolation env vars stay in place across every `.await` in `fut`.
1385#[cfg(test)]
1386pub(crate) async fn with_isolated_config_path_async<R, Fut>(
1387    unique: &str,
1388    f: impl FnOnce(std::path::PathBuf) -> Fut,
1389) -> R
1390where
1391    Fut: std::future::Future<Output = R>,
1392{
1393    let fake_dir = make_fake_config_dir(unique);
1394    let result =
1395        temp_env::async_with_vars(config_isolation_vars(&fake_dir), f(fake_dir.clone())).await;
1396    let _ = std::fs::remove_dir_all(&fake_dir);
1397    result
1398}
1399
1400#[cfg(test)]
1401mod dotenv_tests {
1402    use super::*;
1403
1404    /// `Config::load()` reads `./.env`, and every isolated test sets
1405    /// `LEVIATH_SKIP_DOTENV` - so that branch would otherwise never run.
1406    ///
1407    /// Leaving it to the tests that read the real environment would leave it to
1408    /// exactly the tests that race. Covered deliberately here
1409    /// instead: still inside `temp_env` (so it holds the same process-wide lock
1410    /// as everything else) and still pointed at a scratch config, but with the
1411    /// skip flag cleared so the `.env` read actually happens. The probe
1412    /// variable is listed in the same call so `temp_env` removes it afterwards
1413    /// rather than leaking it into the rest of the run.
1414    #[test]
1415    fn a_dot_env_in_the_working_directory_is_read() {
1416        let dir = make_fake_config_dir("dotenv-read");
1417        std::fs::write(dir.join(".env"), "LEV_DOTENV_PROBE=seen\n").unwrap();
1418
1419        // Scoped so the CWD guard drops - restoring the working directory -
1420        // before the cleanup below. Windows refuses to remove a directory that
1421        // is some process's live CWD.
1422        {
1423            let _cwd = isolate_cwd_for_test();
1424            std::env::set_current_dir(&dir).unwrap();
1425
1426            temp_env::with_vars(
1427                [
1428                    (
1429                        "LEVIATH_CONFIG_PATH",
1430                        Some(dir.join("config.toml").into_os_string()),
1431                    ),
1432                    ("LEVIATH_SKIP_DOTENV", None),
1433                    ("LEV_DOTENV_PROBE", None),
1434                ],
1435                || {
1436                    let loaded = Config::load();
1437                    assert!(loaded.is_ok(), "a missing config file is not an error");
1438                    assert_eq!(
1439                        std::env::var("LEV_DOTENV_PROBE").ok().as_deref(),
1440                        Some("seen"),
1441                        "the .env beside the working directory was read"
1442                    );
1443                },
1444            );
1445        }
1446        let _ = std::fs::remove_dir_all(&dir);
1447    }
1448}
1449
1450#[cfg(test)]
1451mod tests {
1452    /// Saving with a keychain that cannot be reached must fail rather than
1453    /// quietly writing the keys into the file. A user who asked for the keychain
1454    /// would otherwise end up with plaintext keys on disk and no sign of it.
1455    #[test]
1456    fn saving_with_an_unreachable_keychain_writes_nothing() {
1457        let dir = tempfile::tempdir().unwrap();
1458        let path = dir.path().join("config.toml");
1459        let mut config = Config::default();
1460        config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1461        config.providers.anthropic_api_key = Some("sk-ant".to_string());
1462
1463        assert!(
1464            config
1465                .write_to(&path, Err("no keychain".to_string()))
1466                .is_err()
1467        );
1468        assert!(!path.exists(), "no file may be written at all");
1469    }
1470
1471    /// The same for a store that is reachable but refuses the write.
1472    #[test]
1473    fn saving_to_a_store_that_refuses_the_write_writes_nothing() {
1474        use leviath_core::CredentialStore as _;
1475
1476        struct Refuses;
1477        impl leviath_core::CredentialStore for Refuses {
1478            fn get(&self, _: &str) -> Result<Option<String>, String> {
1479                Ok(None)
1480            }
1481            fn set(&self, _: &str, _: &str) -> Result<(), String> {
1482                Err("read-only keychain".to_string())
1483            }
1484            fn delete(&self, _: &str) -> Result<bool, String> {
1485                Err("read-only keychain".to_string())
1486            }
1487        }
1488
1489        let dir = tempfile::tempdir().unwrap();
1490        let path = dir.path().join("config.toml");
1491        let mut config = Config::default();
1492        config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1493        config.providers.anthropic_api_key = Some("sk-ant".to_string());
1494
1495        // The other two answers are part of the contract even though `write_to`
1496        // only needs `set`; a store impl has to answer all three.
1497        assert_eq!(Refuses.get("provider/anthropic").unwrap(), None);
1498        assert!(Refuses.delete("provider/anthropic").is_err());
1499
1500        let err = config
1501            .write_to(&path, Ok(Some(Box::new(Refuses))))
1502            .expect_err("a refused write is not a save");
1503        assert!(err.to_string().contains("failed to store"), "{err}");
1504        assert!(!path.exists(), "no file may be written at all");
1505    }
1506
1507    /// And the successful keychain path: the secrets go to the store and the
1508    /// file keeps only the settings.
1509    #[test]
1510    fn saving_in_keychain_mode_puts_the_secrets_in_the_store_not_the_file() {
1511        use leviath_core::CredentialStore;
1512
1513        let dir = tempfile::tempdir().unwrap();
1514        let path = dir.path().join("config.toml");
1515        let mut config = Config::default();
1516        config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1517        config.providers.anthropic_api_key = Some("sk-ant-secret".to_string());
1518        config.default_model = Some("some-model".to_string());
1519
1520        let store = std::sync::Arc::new(leviath_core::MemoryStore::new());
1521        struct Shared(std::sync::Arc<leviath_core::MemoryStore>);
1522        impl CredentialStore for Shared {
1523            fn get(&self, a: &str) -> Result<Option<String>, String> {
1524                self.0.get(a)
1525            }
1526            fn set(&self, a: &str, s: &str) -> Result<(), String> {
1527                self.0.set(a, s)
1528            }
1529            fn delete(&self, a: &str) -> Result<bool, String> {
1530                self.0.delete(a)
1531            }
1532        }
1533
1534        config
1535            .write_to(&path, Ok(Some(Box::new(Shared(store.clone())))))
1536            .unwrap();
1537
1538        // `delete` completes the trait; `write_to` itself never needs it.
1539        assert!(
1540            Shared(store.clone())
1541                .delete(&leviath_core::provider_account("anthropic"))
1542                .unwrap()
1543        );
1544        store
1545            .set(
1546                &leviath_core::provider_account("anthropic"),
1547                "sk-ant-secret",
1548            )
1549            .unwrap();
1550
1551        let written = std::fs::read_to_string(&path).unwrap();
1552        assert!(!written.contains("sk-ant-secret"), "{written}");
1553        assert!(
1554            written.contains("some-model"),
1555            "settings survive: {written}"
1556        );
1557        // Read back through the same wrapper `write_to` was handed, so all
1558        // three of its methods are exercised.
1559        assert_eq!(
1560            Shared(store.clone())
1561                .get(&leviath_core::provider_account("anthropic"))
1562                .unwrap()
1563                .as_deref(),
1564            Some("sk-ant-secret")
1565        );
1566    }
1567
1568    /// The keychain fills only what the file and the environment left unset --
1569    /// what the user can see wins over what they cannot.
1570    #[test]
1571    fn the_credential_store_fills_only_the_keys_that_are_unset() {
1572        use leviath_core::{CredentialStore, MemoryStore};
1573
1574        let store = MemoryStore::new();
1575        store
1576            .set(
1577                &leviath_core::provider_account("anthropic"),
1578                "from-keychain",
1579            )
1580            .unwrap();
1581        store
1582            .set(&leviath_core::provider_account("openai"), "openai-keychain")
1583            .unwrap();
1584        store
1585            .set(&leviath_core::provider_account("google"), "google-keychain")
1586            .unwrap();
1587        store
1588            .set(&leviath_core::provider_account("openrouter"), "or-keychain")
1589            .unwrap();
1590
1591        let mut config = Config::default();
1592        // Already set from the file: the keychain must not overwrite it.
1593        config.providers.anthropic_api_key = Some("from-file".to_string());
1594        config.apply_credential_store(&store);
1595
1596        assert_eq!(
1597            config.providers.anthropic_api_key.as_deref(),
1598            Some("from-file"),
1599            "an existing key wins over the keychain"
1600        );
1601        assert_eq!(
1602            config.providers.openai_api_key.as_deref(),
1603            Some("openai-keychain")
1604        );
1605        assert_eq!(
1606            config.providers.google_api_key.as_deref(),
1607            Some("google-keychain")
1608        );
1609        assert_eq!(config.openrouter_api_key.as_deref(), Some("or-keychain"));
1610    }
1611
1612    /// An empty store leaves everything alone rather than blanking keys.
1613    #[test]
1614    fn an_empty_credential_store_changes_nothing() {
1615        let mut config = Config::default();
1616        config.providers.openai_api_key = Some("keep-me".to_string());
1617        config.apply_credential_store(&leviath_core::MemoryStore::new());
1618        assert_eq!(config.providers.openai_api_key.as_deref(), Some("keep-me"));
1619        assert!(config.providers.anthropic_api_key.is_none());
1620    }
1621
1622    /// The three resolutions the loader can get back. A keychain that was asked
1623    /// for but is unreachable must warn and carry on - refusing to load the
1624    /// config would take down `lev auth status`, the one command that can
1625    /// explain the problem.
1626    #[test]
1627    fn an_unreachable_credential_store_does_not_stop_the_config_loading() {
1628        use leviath_core::{CredentialStore, MemoryStore};
1629
1630        let mut config = Config::default();
1631        config.fill_from_credential_store_with(Err("no keychain here".to_string()));
1632        assert!(config.providers.anthropic_api_key.is_none());
1633
1634        // The file backend: nothing to overlay.
1635        let mut config = Config::default();
1636        config.providers.openai_api_key = Some("k".to_string());
1637        config.fill_from_credential_store_with(Ok(None));
1638        assert_eq!(config.providers.openai_api_key.as_deref(), Some("k"));
1639
1640        // A working store fills the gap.
1641        let store = MemoryStore::new();
1642        store
1643            .set(&leviath_core::provider_account("anthropic"), "filled")
1644            .unwrap();
1645        let mut config = Config::default();
1646        config.fill_from_credential_store_with(Ok(Some(Box::new(store))));
1647        assert_eq!(
1648            config.providers.anthropic_api_key.as_deref(),
1649            Some("filled")
1650        );
1651    }
1652
1653    #[test]
1654    fn provider_secrets_lists_every_set_key_and_nothing_else() {
1655        let mut config = Config::default();
1656        assert!(config.provider_secrets().is_empty());
1657
1658        config.providers.anthropic_api_key = Some("a".to_string());
1659        config.openrouter_api_key = Some("o".to_string());
1660        let secrets = config.provider_secrets();
1661        assert_eq!(secrets.len(), 2);
1662        assert!(secrets.contains(&("provider/anthropic".to_string(), "a".to_string())));
1663        assert!(secrets.contains(&("provider/openrouter".to_string(), "o".to_string())));
1664    }
1665
1666    /// `without_secrets` must return a *copy*: the caller is usually saving a
1667    /// config it is still going to run with, and blanking its keys in place
1668    /// would break that run.
1669    #[test]
1670    fn without_secrets_strips_a_copy_and_leaves_the_original_usable() {
1671        let mut config = Config::default();
1672        config.providers.anthropic_api_key = Some("a".to_string());
1673        config.providers.openai_api_key = Some("b".to_string());
1674        config.providers.google_api_key = Some("c".to_string());
1675        config.openrouter_api_key = Some("d".to_string());
1676        config.default_model = Some("m".to_string());
1677
1678        let stripped = config.without_secrets();
1679        assert!(stripped.provider_secrets().is_empty(), "no keys survive");
1680        assert_eq!(stripped.default_model.as_deref(), Some("m"), "settings do");
1681        assert_eq!(
1682            config.providers.anthropic_api_key.as_deref(),
1683            Some("a"),
1684            "the original is untouched"
1685        );
1686    }
1687
1688    use super::*;
1689    use crate::test_support::with_tracing;
1690
1691    // ─── leviath_home_dir ────────────────────────────────────────────────────
1692
1693    #[test]
1694    fn leviath_home_dir_uses_override_when_set() {
1695        temp_env::with_var(
1696            "LEVIATH_HOME",
1697            Some("/tmp/leviath-home-override-test"),
1698            || {
1699                assert_eq!(
1700                    leviath_home_dir(),
1701                    Some(std::path::PathBuf::from("/tmp/leviath-home-override-test"))
1702                );
1703            },
1704        );
1705    }
1706
1707    #[test]
1708    fn leviath_home_dir_falls_back_to_dirs_home_dir_when_unset() {
1709        temp_env::with_var_unset("LEVIATH_HOME", || {
1710            assert_eq!(leviath_home_dir(), dirs::home_dir());
1711        });
1712    }
1713
1714    // ─── load_from_path / save_to_path (path-parameterized for testability) ─
1715
1716    #[test]
1717    fn load_from_path_missing_file_returns_defaults() {
1718        let dir = tempfile::tempdir().unwrap();
1719        let path = dir.path().join("config.toml");
1720        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1721        assert_eq!(config.default_provider, "anthropic");
1722    }
1723
1724    #[test]
1725    fn load_from_path_valid_toml_is_parsed() {
1726        let dir = tempfile::tempdir().unwrap();
1727        let path = dir.path().join("config.toml");
1728        let original = Config {
1729            default_provider: "openai".to_string(),
1730            ..Config::default()
1731        };
1732        std::fs::write(&path, toml::to_string_pretty(&original).unwrap()).unwrap();
1733        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1734        assert_eq!(config.default_provider, "openai");
1735    }
1736
1737    #[test]
1738    fn limits_default_to_bounded_values() {
1739        let limits = LimitsConfig::default();
1740        assert_eq!(limits.max_concurrent_inferences, Some(8));
1741        assert_eq!(limits.default_max_iterations, Some(50));
1742        // Exact token counting is opt-in, off by default.
1743        assert!(!limits.exact_token_counting);
1744        // Relief is on by default: ten 30-second cycles of a full lane going
1745        // nowhere before the daemon widens it.
1746        assert_eq!(limits.dead_cycles_before_relief, 10);
1747        // A finished run stays listed for five minutes, so a scheduler polling
1748        // about once a minute still learns how it ended.
1749        assert_eq!(limits.finished_retention_secs, 300);
1750        // An unanswered prompt releases after an hour rather than holding its
1751        // run's slot until the daemon restarts (issue #204).
1752        assert_eq!(limits.interaction_timeout_secs, 3600);
1753        // And the top-level Config carries the same defaults.
1754        assert_eq!(Config::default().limits.max_concurrent_inferences, Some(8));
1755    }
1756
1757    /// A config written before the field existed still gets the hour, and an
1758    /// explicit `0` still means "wait for a person however long it takes".
1759    #[test]
1760    fn interaction_timeout_defaults_and_parses() {
1761        let dir = tempfile::tempdir().unwrap();
1762        let load = |body: String| {
1763            let path = dir.path().join(format!("{}.toml", body.len()));
1764            std::fs::write(&path, body).unwrap();
1765            with_tracing(|| Config::load_from_path(&path)).unwrap()
1766        };
1767
1768        let old = load(format!(
1769            "{}\n[limits]\nmax_concurrent_tools = 4\n",
1770            config_toml_without_limits()
1771        ));
1772        assert_eq!(old.limits.interaction_timeout_secs, 3600);
1773
1774        let disabled = load(format!(
1775            "{}\n[limits]\ninteraction_timeout_secs = 0\n",
1776            config_toml_without_limits()
1777        ));
1778        assert_eq!(disabled.limits.interaction_timeout_secs, 0);
1779    }
1780
1781    #[test]
1782    fn exact_token_counting_parses_when_set() {
1783        let dir = tempfile::tempdir().unwrap();
1784        let path = dir.path().join("config.toml");
1785        let body = format!(
1786            "{}\n[limits]\nexact_token_counting = true\n",
1787            config_toml_without_limits()
1788        );
1789        std::fs::write(&path, body).unwrap();
1790        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1791        assert!(config.limits.exact_token_counting);
1792        // The other fields still fall back to their per-field defaults.
1793        assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1794    }
1795
1796    /// A valid full config-file body with the `[limits]` section removed, so
1797    /// tests can simulate a config written before the section existed (robust to
1798    /// unrelated fields being added). `[limits]` serializes as the final section.
1799    #[cfg(test)]
1800    fn config_toml_without_limits() -> String {
1801        let full = toml::to_string_pretty(&Config::default()).unwrap();
1802        format!("{}\n", full.split("[limits]").next().unwrap().trim_end())
1803    }
1804
1805    #[test]
1806    fn limits_absent_section_uses_defaults() {
1807        // A config file with no `[limits]` table still gets the bounded defaults.
1808        let dir = tempfile::tempdir().unwrap();
1809        let path = dir.path().join("config.toml");
1810        std::fs::write(&path, config_toml_without_limits()).unwrap();
1811        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1812        assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1813        assert_eq!(config.limits.default_max_iterations, Some(50));
1814        assert_eq!(config.limits.dead_cycles_before_relief, 10);
1815        assert_eq!(config.limits.finished_retention_secs, 300);
1816        // Off unless asked for: the wedge watchdog fails runs, so an upgrade
1817        // must not switch it on behind the operator's back.
1818        assert_eq!(config.limits.wedge_timeout_secs, 0);
1819    }
1820
1821    #[test]
1822    fn the_wedge_watchdog_is_off_until_it_is_configured() {
1823        let dir = tempfile::tempdir().unwrap();
1824        let path = dir.path().join("config.toml");
1825        let body = format!(
1826            "{}\n[limits]\nwedge_timeout_secs = 300\n",
1827            config_toml_without_limits()
1828        );
1829        std::fs::write(&path, body).unwrap();
1830        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1831        assert_eq!(config.limits.wedge_timeout_secs, 300);
1832        // And the rest of the section keeps its own defaults.
1833        assert_eq!(config.limits.stall_timeout_secs, 60);
1834    }
1835
1836    #[test]
1837    fn limits_partial_section_fills_the_other_default() {
1838        // Setting only one field leaves the other at its per-field serde default.
1839        let dir = tempfile::tempdir().unwrap();
1840        let path = dir.path().join("config.toml");
1841        let body = format!(
1842            "{}\n[limits]\nmax_concurrent_inferences = 3\n",
1843            config_toml_without_limits()
1844        );
1845        std::fs::write(&path, body).unwrap();
1846        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1847        assert_eq!(config.limits.max_concurrent_inferences, Some(3));
1848        assert_eq!(config.limits.default_max_iterations, Some(50));
1849    }
1850
1851    #[test]
1852    fn load_from_path_existing_provider_keys_skip_env_fallback() {
1853        // Every one of the 5 "env var fallback" `if field.is_none()` checks
1854        // in `load_from_path` has only ever been exercised on its `true`
1855        // (field absent, fall back to env) arm elsewhere in this file --
1856        // never on the `false` (field already set from the TOML file, skip
1857        // the env lookup) arm. `temp_env::with_vars` clears these process-global
1858        // env vars for the closure (and serializes against every other temp-env
1859        // test), so no concurrently-running test can be mid-set when we read.
1860        let unset: Vec<(&str, Option<&str>)> = PROVIDER_KEY_ENV_VARS
1861            .iter()
1862            .chain(["OLLAMA_HOST"].iter())
1863            .map(|&key| (key, None))
1864            .collect();
1865        temp_env::with_vars(unset, || {
1866            let dir = tempfile::tempdir().unwrap();
1867            let path = dir.path().join("config.toml");
1868            std::fs::write(
1869                &path,
1870                r#"
1871default_provider = "anthropic"
1872openrouter_api_key = "sk-or-existing"
1873ollama_base_url = "http://existing-ollama:11434"
1874agent_paths = []
1875
1876[providers]
1877anthropic_api_key = "sk-ant-existing"
1878openai_api_key = "sk-openai-existing"
1879google_api_key = "AIza-existing"
1880"#,
1881            )
1882            .unwrap();
1883
1884            let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1885
1886            assert_eq!(
1887                config.providers.anthropic_api_key.as_deref(),
1888                Some("sk-ant-existing")
1889            );
1890            assert_eq!(
1891                config.providers.openai_api_key.as_deref(),
1892                Some("sk-openai-existing")
1893            );
1894            assert_eq!(
1895                config.providers.google_api_key.as_deref(),
1896                Some("AIza-existing")
1897            );
1898            assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-existing"));
1899            assert_eq!(
1900                config.ollama_base_url.as_deref(),
1901                Some("http://existing-ollama:11434")
1902            );
1903        });
1904    }
1905
1906    #[test]
1907    fn load_from_path_malformed_toml_returns_error() {
1908        let dir = tempfile::tempdir().unwrap();
1909        let path = dir.path().join("config.toml");
1910        std::fs::write(&path, "not valid toml [[[").unwrap();
1911        let result = Config::load_from_path(&path);
1912        assert!(result.is_err());
1913        assert!(result.unwrap_err().to_string().contains("Failed to parse"));
1914    }
1915
1916    #[test]
1917    fn load_from_path_unreadable_path_returns_error() {
1918        // A directory can't be read as a config file.
1919        let dir = tempfile::tempdir().unwrap();
1920        let result = Config::load_from_path(dir.path());
1921        assert!(result.is_err());
1922    }
1923
1924    #[test]
1925    fn save_to_path_writes_valid_toml_that_round_trips() {
1926        let dir = tempfile::tempdir().unwrap();
1927        let path = dir.path().join("nested").join("config.toml");
1928        let config = Config {
1929            default_provider: "google".to_string(),
1930            ..Config::default()
1931        };
1932        with_tracing(|| config.save_to_path(&path)).unwrap();
1933
1934        let loaded = with_tracing(|| Config::load_from_path(&path)).unwrap();
1935        assert_eq!(loaded.default_provider, "google");
1936    }
1937
1938    #[test]
1939    fn save_to_path_creates_parent_directory() {
1940        let dir = tempfile::tempdir().unwrap();
1941        let path = dir.path().join("a").join("b").join("config.toml");
1942        let config = Config::default();
1943        with_tracing(|| config.save_to_path(&path)).unwrap();
1944        assert!(path.exists());
1945    }
1946
1947    #[test]
1948    fn save_to_path_with_no_parent_skips_create_config_dir() {
1949        // `Path::parent()` returns `None` only for an empty path or a
1950        // filesystem root - `PathBuf::from("")` triggers the empty case
1951        // cross-platform, hitting the `if let Some(parent) = ...` block's
1952        // `None` arm (skip `create_config_dir`) without a platform-specific
1953        // root path. The subsequent `fs::write("")` then fails, which is
1954        // fine: this test only cares about the `None` branch being taken.
1955        let result = Config::default().save_to_path(&std::path::PathBuf::from(""));
1956        assert!(result.is_err());
1957    }
1958
1959    #[cfg(unix)]
1960    #[test]
1961    fn save_to_path_sets_restrictive_file_permissions() {
1962        use std::os::unix::fs::PermissionsExt;
1963        let dir = tempfile::tempdir().unwrap();
1964        let path = dir.path().join("config.toml");
1965        with_tracing(|| Config::default().save_to_path(&path)).unwrap();
1966        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1967        assert_eq!(mode & 0o777, 0o600);
1968    }
1969
1970    #[test]
1971    fn save_to_path_write_failure_returns_error() {
1972        // A directory at the exact target path forces `std::fs::write` to
1973        // fail with EISDIR, exercising `save_to_path`'s write-error `map_err`
1974        // arm (distinct from `save_to_path_creates_parent_directory`, which
1975        // exercises the parent-dir-creation path but always succeeds).
1976        let dir = tempfile::tempdir().unwrap();
1977        let path = dir.path().join("config.toml");
1978        std::fs::create_dir_all(&path).unwrap();
1979
1980        let result = Config::default().save_to_path(&path);
1981
1982        assert!(result.is_err());
1983        assert!(
1984            result
1985                .unwrap_err()
1986                .to_string()
1987                .contains("Failed to write config")
1988        );
1989    }
1990
1991    #[test]
1992    fn save_to_path_create_config_dir_failure_returns_error() {
1993        let dir = tempfile::tempdir().unwrap();
1994        let blocking_file = dir.path().join("not-a-dir");
1995        std::fs::write(&blocking_file, "").unwrap();
1996        let path = blocking_file.join("config.toml");
1997        let result = Config::default().save_to_path(&path);
1998        assert!(result.is_err());
1999        assert!(
2000            result
2001                .unwrap_err()
2002                .to_string()
2003                .contains("Failed to create config directory")
2004        );
2005    }
2006
2007    #[test]
2008    fn load_propagates_error_when_real_config_file_is_malformed() {
2009        // Every other `Config::load()` test sees either no file (defaults)
2010        // or a well-formed one, so `load()`'s `?` on `load_from_path(...)`
2011        // has never actually propagated an `Err`. Writing malformed TOML to
2012        // the guard's redirected `LEVIATH_CONFIG_PATH` forces that.
2013        with_isolated_config_path("load-malformed", |fake_dir| {
2014            std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
2015
2016            let result = Config::load();
2017
2018            assert!(result.is_err());
2019        });
2020    }
2021
2022    // ─── check_permissions_at ────────────────────────────────────────────
2023
2024    #[cfg(unix)]
2025    #[test]
2026    fn check_permissions_at_missing_file_is_noop() {
2027        let dir = tempfile::tempdir().unwrap();
2028        let path = dir.path().join("nonexistent.toml");
2029        check_permissions_at(&path); // must not panic
2030    }
2031
2032    #[cfg(unix)]
2033    #[test]
2034    fn check_permissions_at_fixes_overly_permissive_file() {
2035        use std::os::unix::fs::PermissionsExt;
2036        let dir = tempfile::tempdir().unwrap();
2037        let path = dir.path().join("config.toml");
2038        std::fs::write(&path, "").unwrap();
2039        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2040
2041        with_tracing(|| check_permissions_at(&path));
2042
2043        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2044        assert_eq!(mode & 0o777, 0o600);
2045    }
2046
2047    #[cfg(unix)]
2048    #[test]
2049    fn check_permissions_at_leaves_already_restrictive_file_alone() {
2050        use std::os::unix::fs::PermissionsExt;
2051        let dir = tempfile::tempdir().unwrap();
2052        let path = dir.path().join("config.toml");
2053        std::fs::write(&path, "").unwrap();
2054        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2055
2056        check_permissions_at(&path);
2057
2058        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2059        assert_eq!(mode & 0o777, 0o600);
2060    }
2061
2062    // On macOS/BSD, `chflags uchg` sets the user-immutable flag - settable
2063    // by a regular file owner without root - which blocks `chmod` (and thus
2064    // `std::fs::set_permissions`) with EPERM while leaving `exists()`/
2065    // The "fix failed" arm of `check_permissions_at` (a file that exists but
2066    // whose `chmod` fails) is exercised deterministically on every OS by
2067    // injecting a failing `ensure` fn - no `chflags uchg`/root trick, which was
2068    // macOS-only and left this branch uncovered on Linux CI.
2069    #[test]
2070    fn check_permissions_at_with_logs_when_fix_fails() {
2071        fn ensure_fails(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2072            Err(std::io::Error::other("simulated chmod failure"))
2073        }
2074        // Must not panic; the failure is only logged.
2075        with_tracing(|| {
2076            check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_fails)
2077        });
2078    }
2079
2080    #[test]
2081    fn check_permissions_at_with_logs_when_file_is_permissive() {
2082        fn ensure_permissive(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2083            Ok(Some(0o100644))
2084        }
2085        with_tracing(|| {
2086            check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_permissive)
2087        });
2088    }
2089
2090    // Portable failure injection for the hardening error arms of
2091    // `set_file_permissions`/`set_dir_permissions`. `leviath_sys`'s Windows
2092    // fallback is infallible (always `Ok`) - and even a missing path fails only
2093    // on Unix - so the only cross-platform way to reach the `Err` arm is to
2094    // inject a hardening op that fails (mirroring `check_permissions_at_with`).
2095    fn always_failing_secure(_path: &std::path::Path) -> std::io::Result<()> {
2096        Err(std::io::Error::other(
2097            "simulated permission-hardening failure",
2098        ))
2099    }
2100
2101    #[test]
2102    fn set_dir_permissions_error_branch_logs_not_panics() {
2103        with_tracing(|| {
2104            set_dir_permissions_with(
2105                std::path::Path::new("/does/not/matter"),
2106                always_failing_secure,
2107            )
2108        }); // hits the Err arm, must not panic
2109    }
2110
2111    // ─── create_config_dir / set_file_permissions / set_dir_permissions ───
2112    // (already path-parameterized - directly testable without touching the
2113    // real ~/.leviath/config.toml)
2114
2115    #[test]
2116    fn create_config_dir_creates_nested_dirs() {
2117        let dir = tempfile::tempdir().unwrap();
2118        let target = dir.path().join("a").join("b").join("c");
2119        create_config_dir(&target).unwrap();
2120        assert!(target.is_dir());
2121    }
2122
2123    #[cfg(unix)]
2124    #[test]
2125    fn create_config_dir_sets_restrictive_permissions() {
2126        use std::os::unix::fs::PermissionsExt;
2127        let dir = tempfile::tempdir().unwrap();
2128        let target = dir.path().join("leviath");
2129        create_config_dir(&target).unwrap();
2130        let mode = std::fs::metadata(&target).unwrap().permissions().mode();
2131        assert_eq!(mode & 0o777, 0o700);
2132    }
2133
2134    /// The config holds every provider API key, so it must never be readable by
2135    /// anyone else - not even for the instant between a `write` and a follow-up
2136    /// `chmod`. `write_private` creates the file with the mode already applied.
2137    #[cfg(unix)]
2138    /// `LEVIATH_HOME` must redirect the config too, not just the runs and
2139    /// agents directories.
2140    ///
2141    /// Without that redirect the consequence is concrete: a scratch environment
2142    /// that sets `LEVIATH_HOME` and runs `lev mcp add` writes to the developer's
2143    /// *real* `~/.leviath/config.toml` - the file holding every provider API key
2144    /// - while believing it is isolated.
2145    #[test]
2146    fn config_path_honors_leviath_home() {
2147        temp_env::with_vars(
2148            [
2149                ("LEVIATH_CONFIG_PATH", None::<&str>),
2150                ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2151            ],
2152            || {
2153                assert_eq!(
2154                    Config::config_path(),
2155                    std::path::PathBuf::from("/tmp/lev-cfg-test/.leviath/config.toml")
2156                );
2157            },
2158        );
2159    }
2160
2161    /// The narrower override still wins, so an explicit path is exact.
2162    #[test]
2163    fn config_path_prefers_the_explicit_override() {
2164        temp_env::with_vars(
2165            [
2166                ("LEVIATH_CONFIG_PATH", Some("/tmp/exact.toml")),
2167                ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2168            ],
2169            || {
2170                assert_eq!(
2171                    Config::config_path(),
2172                    std::path::PathBuf::from("/tmp/exact.toml")
2173                );
2174            },
2175        );
2176    }
2177
2178    /// The escape hatch for the permission floor: a user grants one named agent
2179    /// more than their global setting, in their own config rather than in the
2180    /// downloaded manifest.
2181    #[test]
2182    fn permissions_for_agent_overlays_the_named_grant_on_the_global() {
2183        let mut config = Config::default();
2184        config
2185            .tool_permissions
2186            .insert("shell".to_string(), ToolPolicy::Ask);
2187        config
2188            .tool_permissions
2189            .insert("write_file".to_string(), ToolPolicy::Deny);
2190        config.agent_tool_permissions.insert(
2191            "coder".to_string(),
2192            HashMap::from([("shell".to_string(), ToolPolicy::Allow)]),
2193        );
2194
2195        let coder = config.permissions_for_agent("coder");
2196        assert_eq!(coder.get("shell"), Some(&ToolPolicy::Allow), "granted");
2197        assert_eq!(
2198            coder.get("write_file"),
2199            Some(&ToolPolicy::Deny),
2200            "the rest of the global ceiling still applies"
2201        );
2202
2203        // Any other agent sees the global setting untouched.
2204        let other = config.permissions_for_agent("researcher");
2205        assert_eq!(other.get("shell"), Some(&ToolPolicy::Ask));
2206    }
2207
2208    /// Read-path grants mirror the tool-permission shape: a machine-wide list
2209    /// plus per-agent additions, resolved once per agent.
2210    #[test]
2211    fn read_path_grants_merge_global_and_per_agent() {
2212        let mut config = Config::default();
2213        assert!(
2214            !config.security.allow_blueprint_read_paths,
2215            "blueprint read paths must be opt-in"
2216        );
2217        assert!(config.read_path_grants_for_agent("cto").is_empty());
2218
2219        config.security.read_paths = vec!["~/.leviath/runs".to_string()];
2220        config.agent_read_paths.insert(
2221            "cto".to_string(),
2222            ReadPathGrants {
2223                allow: vec!["glob:~/design-docs/**".to_string()],
2224            },
2225        );
2226
2227        assert_eq!(
2228            config.read_path_grants_for_agent("cto"),
2229            vec![
2230                "~/.leviath/runs".to_string(),
2231                "glob:~/design-docs/**".to_string(),
2232            ]
2233        );
2234        // Any other agent gets the machine-wide grants only.
2235        assert_eq!(
2236            config.read_path_grants_for_agent("researcher"),
2237            vec!["~/.leviath/runs".to_string()]
2238        );
2239    }
2240
2241    /// One `tracing::debug!(?config)` would otherwise put every provider key in
2242    /// the logs.
2243    #[test]
2244    fn provider_config_debug_never_prints_the_keys() {
2245        let providers = ProviderConfig {
2246            anthropic_api_key: Some("sk-ant-SECRET-VALUE".to_string()),
2247            openai_api_key: Some("sk-openai-SECRET-VALUE".to_string()),
2248            google_api_key: Some("AIza-SECRET-VALUE".to_string()),
2249            claude_code_enabled: true,
2250            claude_code_binary: None,
2251            claude_code_effort: None,
2252            fallback_order: Vec::new(),
2253        };
2254        let rendered = format!("{providers:?}");
2255        assert!(!rendered.contains("SECRET-VALUE"), "key leaked: {rendered}");
2256        // "is it configured" is what a debug line is actually asking.
2257        assert!(rendered.contains("<set>"), "{rendered}");
2258        assert!(rendered.contains("claude_code_enabled: true"), "{rendered}");
2259
2260        let empty = format!(
2261            "{:?}",
2262            ProviderConfig {
2263                anthropic_api_key: None,
2264                openai_api_key: None,
2265                google_api_key: None,
2266                claude_code_enabled: false,
2267                claude_code_binary: None,
2268                claude_code_effort: None,
2269                fallback_order: Vec::new(),
2270            }
2271        );
2272        assert!(empty.contains("<unset>"), "{empty}");
2273    }
2274
2275    /// Unix-only: the assertion is about POSIX mode bits, which Windows does
2276    /// not have. `write_private`'s Windows path is a plain write, exercised by
2277    /// every other `save_to_path` test.
2278    #[cfg(unix)]
2279    #[test]
2280    fn saving_a_config_never_leaves_it_group_or_world_readable() {
2281        use std::os::unix::fs::PermissionsExt;
2282        let dir = tempfile::tempdir().unwrap();
2283        let path = dir.path().join("config.toml");
2284
2285        Config::default().save_to_path(&path).unwrap();
2286        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2287        assert_eq!(mode & 0o777, 0o600, "fresh config must be owner-only");
2288
2289        // Overwriting a file that somehow became permissive tightens it again.
2290        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2291        Config::default().save_to_path(&path).unwrap();
2292        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2293        assert_eq!(mode & 0o777, 0o600, "re-saving must re-tighten");
2294    }
2295
2296    #[cfg(unix)]
2297    #[test]
2298    fn set_dir_permissions_sets_0700() {
2299        use std::os::unix::fs::PermissionsExt;
2300        let dir = tempfile::tempdir().unwrap();
2301        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
2302        set_dir_permissions(dir.path());
2303        let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode();
2304        assert_eq!(mode & 0o777, 0o700);
2305    }
2306
2307    #[test]
2308    fn test_validate_keys_good_anthropic() {
2309        let config = Config {
2310            providers: ProviderConfig {
2311                anthropic_api_key: Some("sk-ant-test123".to_string()),
2312                openai_api_key: None,
2313                google_api_key: None,
2314                claude_code_enabled: false,
2315                claude_code_binary: None,
2316                claude_code_effort: None,
2317                fallback_order: Vec::new(),
2318            },
2319            ..Config::default()
2320        };
2321        assert!(config.validate_keys().is_empty());
2322    }
2323
2324    #[test]
2325    fn test_validate_keys_bad_anthropic() {
2326        let config = Config {
2327            providers: ProviderConfig {
2328                anthropic_api_key: Some("bad-key".to_string()),
2329                openai_api_key: None,
2330                google_api_key: None,
2331                claude_code_enabled: false,
2332                claude_code_binary: None,
2333                claude_code_effort: None,
2334                fallback_order: Vec::new(),
2335            },
2336            ..Config::default()
2337        };
2338        let warnings = config.validate_keys();
2339        assert_eq!(warnings.len(), 1);
2340        assert!(warnings[0].contains("Anthropic"));
2341    }
2342
2343    #[test]
2344    fn test_validate_keys_good_openai() {
2345        let config = Config {
2346            providers: ProviderConfig {
2347                anthropic_api_key: None,
2348                openai_api_key: Some("sk-test123".to_string()),
2349                google_api_key: None,
2350                claude_code_enabled: false,
2351                claude_code_binary: None,
2352                claude_code_effort: None,
2353                fallback_order: Vec::new(),
2354            },
2355            ..Config::default()
2356        };
2357        assert!(config.validate_keys().is_empty());
2358    }
2359
2360    #[test]
2361    fn test_validate_keys_bad_openai() {
2362        let config = Config {
2363            providers: ProviderConfig {
2364                anthropic_api_key: None,
2365                openai_api_key: Some("bad-key".to_string()),
2366                google_api_key: None,
2367                claude_code_enabled: false,
2368                claude_code_binary: None,
2369                claude_code_effort: None,
2370                fallback_order: Vec::new(),
2371            },
2372            ..Config::default()
2373        };
2374        let warnings = config.validate_keys();
2375        assert_eq!(warnings.len(), 1);
2376        assert!(warnings[0].contains("OpenAI"));
2377    }
2378
2379    #[test]
2380    fn test_validate_keys_no_keys() {
2381        let config = Config::default();
2382        assert!(config.validate_keys().is_empty());
2383    }
2384
2385    // ─── Config defaults ───────────────────────────────────────────────────
2386
2387    #[test]
2388    fn config_default_values() {
2389        let config = Config::default();
2390        assert_eq!(config.default_provider, "anthropic");
2391        assert!(config.providers.anthropic_api_key.is_none());
2392        assert!(config.providers.openai_api_key.is_none());
2393        assert!(config.providers.google_api_key.is_none());
2394        assert!(config.openrouter_api_key.is_none());
2395        assert!(config.ollama_base_url.is_none());
2396        assert!(config.mcp_servers.is_empty());
2397        assert!(config.default_model.is_none());
2398        assert!(config.model_capabilities.is_empty());
2399        assert!(config.tool_permissions.is_empty());
2400    }
2401
2402    // ─── TitleConfig ───────────────────────────────────────────────────────
2403
2404    #[test]
2405    fn title_config_default() {
2406        let tc = TitleConfig::default();
2407        assert!(tc.enabled);
2408        assert!(tc.provider.is_none());
2409        assert!(tc.model.is_none());
2410    }
2411
2412    #[test]
2413    fn title_config_serde_roundtrip() {
2414        let tc = TitleConfig {
2415            enabled: false,
2416            provider: Some("openai".to_string()),
2417            model: Some("gpt-5.4-mini".to_string()),
2418        };
2419        let json = serde_json::to_string(&tc).unwrap();
2420        let back: TitleConfig = serde_json::from_str(&json).unwrap();
2421        assert!(!back.enabled);
2422        assert_eq!(back.provider.as_deref(), Some("openai"));
2423        assert_eq!(back.model.as_deref(), Some("gpt-5.4-mini"));
2424    }
2425
2426    // ─── ToolPolicy ────────────────────────────────────────────────────────
2427
2428    #[test]
2429    fn tool_policy_default_is_ask() {
2430        let policy = ToolPolicy::default();
2431        assert_eq!(policy, ToolPolicy::Ask);
2432    }
2433
2434    #[test]
2435    fn tool_policy_serde_roundtrip() {
2436        for policy in [ToolPolicy::Allow, ToolPolicy::Ask, ToolPolicy::Deny] {
2437            let json = serde_json::to_string(&policy).unwrap();
2438            let back: ToolPolicy = serde_json::from_str(&json).unwrap();
2439            assert_eq!(policy, back);
2440        }
2441    }
2442
2443    #[test]
2444    fn tool_policy_snake_case_serialization() {
2445        assert_eq!(
2446            serde_json::to_string(&ToolPolicy::Allow).unwrap(),
2447            "\"allow\""
2448        );
2449        assert_eq!(serde_json::to_string(&ToolPolicy::Ask).unwrap(), "\"ask\"");
2450        assert_eq!(
2451            serde_json::to_string(&ToolPolicy::Deny).unwrap(),
2452            "\"deny\""
2453        );
2454    }
2455
2456    // ─── Config TOML parsing ───────────────────────────────────────────────
2457
2458    #[test]
2459    fn config_from_toml_with_all_fields() {
2460        let toml_content = r#"
2461default_provider = "openai"
2462openrouter_api_key = "sk-or-test"
2463ollama_base_url = "http://my-ollama:11434"
2464default_model = "gpt-5"
2465agent_paths = []
2466
2467[providers]
2468anthropic_api_key = "sk-ant-test"
2469openai_api_key = "sk-test"
2470google_api_key = "AIza-test"
2471
2472[tool_permissions]
2473bash = "deny"
2474read_file = "allow"
2475
2476[title]
2477enabled = false
2478provider = "anthropic"
2479model = "claude-haiku-4-5"
2480"#;
2481        let config: Config = toml::from_str(toml_content).unwrap();
2482        assert_eq!(config.default_provider, "openai");
2483        assert_eq!(
2484            config.providers.anthropic_api_key.as_deref(),
2485            Some("sk-ant-test")
2486        );
2487        assert_eq!(config.providers.openai_api_key.as_deref(), Some("sk-test"));
2488        assert_eq!(
2489            config.providers.google_api_key.as_deref(),
2490            Some("AIza-test")
2491        );
2492        assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2493        assert_eq!(
2494            config.ollama_base_url.as_deref(),
2495            Some("http://my-ollama:11434")
2496        );
2497        assert_eq!(config.default_model.as_deref(), Some("gpt-5"));
2498        assert!(!config.title.enabled);
2499        assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2500        assert_eq!(
2501            config.tool_permissions.get("read_file"),
2502            Some(&ToolPolicy::Allow)
2503        );
2504    }
2505
2506    #[test]
2507    fn config_from_minimal_toml() {
2508        let toml_content = r#"
2509default_provider = "anthropic"
2510agent_paths = []
2511
2512[providers]
2513"#;
2514        let config: Config = toml::from_str(toml_content).unwrap();
2515        assert_eq!(config.default_provider, "anthropic");
2516        assert!(config.providers.anthropic_api_key.is_none());
2517    }
2518
2519    #[test]
2520    fn config_from_toml_with_mcp_servers() {
2521        let toml_content = r#"
2522default_provider = "anthropic"
2523agent_paths = []
2524
2525[providers]
2526
2527[[mcp_servers]]
2528name = "test-server"
2529command = "echo"
2530args = ["hello"]
2531"#;
2532        let config: Config = toml::from_str(toml_content).unwrap();
2533        assert_eq!(config.mcp_servers.len(), 1);
2534        assert_eq!(config.mcp_servers[0].name, "test-server");
2535    }
2536
2537    #[test]
2538    fn load_rejects_a_malformed_mcp_server_entry() {
2539        // An entry with neither `command` nor `url` can never connect, so it
2540        // must fail at load - naming the server - rather than silently drop its
2541        // tools until the first call.
2542        let dir = tempfile::tempdir().unwrap();
2543        let path = dir.path().join("config.toml");
2544        std::fs::write(
2545            &path,
2546            r#"
2547default_provider = "anthropic"
2548agent_paths = []
2549
2550[providers]
2551
2552[[mcp_servers]]
2553name = "broken"
2554"#,
2555        )
2556        .unwrap();
2557
2558        let err = Config::load_from_path(&path).expect_err("malformed entry must fail load");
2559        let msg = err.to_string();
2560        assert!(msg.contains("broken"), "must name the server: {msg}");
2561    }
2562
2563    #[test]
2564    fn load_accepts_a_well_formed_http_mcp_server() {
2565        let dir = tempfile::tempdir().unwrap();
2566        let path = dir.path().join("config.toml");
2567        std::fs::write(
2568            &path,
2569            r#"
2570default_provider = "anthropic"
2571agent_paths = []
2572
2573[providers]
2574
2575[[mcp_servers]]
2576name = "remote"
2577url = "https://mcp.example.com/mcp"
2578"#,
2579        )
2580        .unwrap();
2581
2582        let config = Config::load_from_path(&path).expect("valid http entry should load");
2583        assert_eq!(
2584            config.mcp_servers[0].url.as_deref(),
2585            Some("https://mcp.example.com/mcp")
2586        );
2587    }
2588
2589    #[test]
2590    fn config_from_toml_with_model_capabilities() {
2591        let toml_content = r#"
2592default_provider = "anthropic"
2593agent_paths = []
2594
2595[providers]
2596
2597[model_capabilities."my-custom-model"]
2598supports_temperature = true
2599supports_streaming = false
2600supports_tools = true
2601supports_system_prompt = true
2602max_context_tokens = 4096
2603max_output_tokens = 2048
2604"#;
2605        let config: Config = toml::from_str(toml_content).unwrap();
2606        let caps = config.model_capabilities.get("my-custom-model").unwrap();
2607        assert!(caps.supports_temperature);
2608        assert!(!caps.supports_streaming);
2609        assert_eq!(caps.max_context_tokens, 4096);
2610        assert_eq!(caps.max_output_tokens, 2048);
2611    }
2612
2613    // ─── validate_keys with both keys ──────────────────────────────────────
2614
2615    /// A blank key means "not configured" (what `lev setup` writes for a
2616    /// skipped provider), so it must not draw a shape warning - noise about
2617    /// keys nobody set trains users to ignore the warnings that matter.
2618    #[test]
2619    fn validate_keys_is_quiet_about_blank_keys() {
2620        let mut config = Config::default();
2621        config.providers.anthropic_api_key = Some(String::new());
2622        config.providers.openai_api_key = Some("   ".to_string());
2623        assert!(config.validate_keys().is_empty());
2624        // A genuinely wrong-looking key still warns.
2625        config.providers.anthropic_api_key = Some("nope".to_string());
2626        assert_eq!(config.validate_keys().len(), 1);
2627    }
2628
2629    #[test]
2630    fn validate_keys_both_bad() {
2631        let config = Config {
2632            providers: ProviderConfig {
2633                anthropic_api_key: Some("bad".to_string()),
2634                openai_api_key: Some("bad".to_string()),
2635                google_api_key: None,
2636                claude_code_enabled: false,
2637                claude_code_binary: None,
2638                claude_code_effort: None,
2639                fallback_order: Vec::new(),
2640            },
2641            ..Config::default()
2642        };
2643        let warnings = config.validate_keys();
2644        assert_eq!(warnings.len(), 2);
2645    }
2646
2647    // ─── config_path ───────────────────────────────────────────────────────
2648
2649    #[test]
2650    fn config_path_contains_leviath() {
2651        // Force `LEVIATH_CONFIG_PATH` unset (via `temp_env::with_var_unset`,
2652        // which also serializes against every other temp-env test) so
2653        // `config_path()` resolves to the real default, not a concurrently-set
2654        // override.
2655        temp_env::with_var_unset("LEVIATH_CONFIG_PATH", || {
2656            let path = Config::config_path();
2657            assert!(path.to_str().unwrap().contains(".leviath"));
2658            assert!(path.to_str().unwrap().ends_with("config.toml"));
2659        });
2660    }
2661
2662    // ─── Config save/load roundtrip ────────────────────────────────────────
2663
2664    #[test]
2665    fn config_toml_roundtrip() {
2666        let config = Config {
2667            default_provider: "openai".to_string(),
2668            providers: ProviderConfig {
2669                anthropic_api_key: Some("sk-ant-key".to_string()),
2670                openai_api_key: None,
2671                google_api_key: None,
2672                claude_code_enabled: false,
2673                claude_code_binary: None,
2674                claude_code_effort: None,
2675                fallback_order: Vec::new(),
2676            },
2677            tool_permissions: {
2678                let mut m = HashMap::new();
2679                m.insert("bash".to_string(), ToolPolicy::Deny);
2680                m
2681            },
2682            ..Config::default()
2683        };
2684
2685        let serialized = toml::to_string_pretty(&config).unwrap();
2686        let deserialized: Config = toml::from_str(&serialized).unwrap();
2687        assert_eq!(deserialized.default_provider, "openai");
2688        assert_eq!(
2689            deserialized.providers.anthropic_api_key.as_deref(),
2690            Some("sk-ant-key")
2691        );
2692        assert_eq!(
2693            deserialized.tool_permissions.get("bash"),
2694            Some(&ToolPolicy::Deny)
2695        );
2696    }
2697
2698    // ─── validate_keys: both keys valid ──────────────────────────────────
2699
2700    #[test]
2701    fn validate_keys_both_valid() {
2702        let config = Config {
2703            providers: ProviderConfig {
2704                anthropic_api_key: Some("sk-ant-good-key".to_string()),
2705                openai_api_key: Some("sk-good-key".to_string()),
2706                google_api_key: None,
2707                claude_code_enabled: false,
2708                claude_code_binary: None,
2709                claude_code_effort: None,
2710                fallback_order: Vec::new(),
2711            },
2712            ..Config::default()
2713        };
2714        assert!(config.validate_keys().is_empty());
2715    }
2716
2717    // ─── validate_keys: google key has no validation ─────────────────────
2718
2719    #[test]
2720    fn validate_keys_google_key_not_validated() {
2721        let config = Config {
2722            providers: ProviderConfig {
2723                anthropic_api_key: None,
2724                openai_api_key: None,
2725                google_api_key: Some("anything-goes".to_string()),
2726                claude_code_enabled: false,
2727                claude_code_binary: None,
2728                claude_code_effort: None,
2729                fallback_order: Vec::new(),
2730            },
2731            ..Config::default()
2732        };
2733        // Google key has no prefix validation
2734        assert!(config.validate_keys().is_empty());
2735    }
2736
2737    // ─── Config TOML parsing: registries ─────────────────────────────────
2738
2739    #[test]
2740    fn config_from_toml_custom_registries() {
2741        let toml_content = r#"
2742default_provider = "anthropic"
2743agent_paths = ["/my/agents"]
2744
2745[providers]
2746"#;
2747        let config: Config = toml::from_str(toml_content).unwrap();
2748        assert_eq!(config.agent_paths.len(), 1);
2749    }
2750
2751    // ─── Config save writes file ─────────────────────────────────────────
2752
2753    #[test]
2754    fn config_save_creates_file() {
2755        let dir = tempfile::tempdir().unwrap();
2756        let config_path = dir.path().join("subdir").join("config.toml");
2757        // We can't easily test Config::save() because it uses a fixed path,
2758        // but we can test the serialization and write manually
2759        let config = Config::default();
2760        let content = toml::to_string_pretty(&config).unwrap();
2761        std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
2762        std::fs::write(&config_path, &content).unwrap();
2763        assert!(config_path.exists());
2764        let loaded_content = std::fs::read_to_string(&config_path).unwrap();
2765        let loaded: Config = toml::from_str(&loaded_content).unwrap();
2766        assert_eq!(loaded.default_provider, "anthropic");
2767    }
2768
2769    // ─── TitleConfig serde from TOML ─────────────────────────────────────
2770
2771    #[test]
2772    fn title_config_from_toml_defaults() {
2773        let toml_content = r#"
2774default_provider = "anthropic"
2775agent_paths = []
2776
2777[providers]
2778"#;
2779        let config: Config = toml::from_str(toml_content).unwrap();
2780        assert!(config.title.enabled);
2781        assert!(config.title.provider.is_none());
2782        assert!(config.title.model.is_none());
2783    }
2784
2785    #[test]
2786    fn title_config_from_toml_disabled() {
2787        let toml_content = r#"
2788default_provider = "anthropic"
2789agent_paths = []
2790
2791[providers]
2792
2793[title]
2794enabled = false
2795"#;
2796        let config: Config = toml::from_str(toml_content).unwrap();
2797        assert!(!config.title.enabled);
2798    }
2799
2800    #[test]
2801    fn title_config_missing_enabled_key_uses_default_true() {
2802        // Unlike `title_config_from_toml_defaults` (which omits the whole
2803        // `[title]` table, falling back to `Config`'s own `#[serde(default)]`
2804        // for the field - never invoking `TitleConfig`'s own per-field
2805        // parsing at all), this includes `[title]` but omits `enabled`
2806        // specifically, forcing serde to deserialize `TitleConfig` field by
2807        // field and fall back to `default_true()` for the missing key.
2808        let toml_content = r#"
2809default_provider = "anthropic"
2810agent_paths = []
2811
2812[providers]
2813
2814[title]
2815provider = "openai"
2816"#;
2817        let config: Config = toml::from_str(toml_content).unwrap();
2818        assert!(config.title.enabled);
2819        assert_eq!(config.title.provider.as_deref(), Some("openai"));
2820    }
2821
2822    // ─── ToolPolicy in tool_permissions ───────────────────────────────────
2823
2824    #[test]
2825    fn config_tool_permissions_allow() {
2826        let toml_content = r#"
2827default_provider = "anthropic"
2828agent_paths = []
2829
2830[providers]
2831
2832[tool_permissions]
2833read_file = "allow"
2834write_file = "ask"
2835bash = "deny"
2836"#;
2837        let config: Config = toml::from_str(toml_content).unwrap();
2838        assert_eq!(
2839            config.tool_permissions.get("read_file"),
2840            Some(&ToolPolicy::Allow)
2841        );
2842        assert_eq!(
2843            config.tool_permissions.get("write_file"),
2844            Some(&ToolPolicy::Ask)
2845        );
2846        assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2847    }
2848
2849    // ─── Config with agent_paths ─────────────────────────────────────────
2850
2851    #[test]
2852    fn config_with_agent_paths() {
2853        let toml_content = r#"
2854default_provider = "anthropic"
2855agent_paths = ["/home/user/agents", "/opt/agents"]
2856
2857[providers]
2858"#;
2859        let config: Config = toml::from_str(toml_content).unwrap();
2860        assert_eq!(config.agent_paths.len(), 2);
2861    }
2862
2863    // ─── Config load() ────────────────────────────────────────────────────
2864
2865    #[test]
2866    fn config_load_from_nonexistent_path_returns_default() {
2867        // Config::load() uses a fixed path; we can test indirectly by
2868        // verifying defaults are applied when no file exists.
2869        // We can't easily override the path, but we can verify default behavior.
2870        let config = Config::default();
2871        assert_eq!(config.default_provider, "anthropic");
2872        assert!(config.providers.anthropic_api_key.is_none());
2873    }
2874
2875    #[test]
2876    fn config_load_from_toml_string() {
2877        // Test the TOML parsing path of load() by parsing directly.
2878        let toml_content = r#"
2879default_provider = "openai"
2880agent_paths = []
2881
2882[providers]
2883anthropic_api_key = "sk-ant-test-key"
2884"#;
2885        let config: Config = toml::from_str(toml_content).unwrap();
2886        assert_eq!(config.default_provider, "openai");
2887        assert_eq!(
2888            config.providers.anthropic_api_key.as_deref(),
2889            Some("sk-ant-test-key")
2890        );
2891        // No [nudge] section ⇒ every field unset ⇒ built-in defaults apply.
2892        assert_eq!(config.nudge, leviath_core::NudgeConfig::default());
2893    }
2894
2895    #[test]
2896    fn config_parses_partial_nudge_section() {
2897        // A [nudge] section only pins the keys it names.
2898        let config: Config = toml::from_str(
2899            r#"
2900default_provider = "openai"
2901agent_paths = []
2902
2903[providers]
2904
2905[nudge]
2906enabled = false
2907"#,
2908        )
2909        .unwrap();
2910        assert_eq!(config.nudge.enabled, Some(false));
2911        assert_eq!(config.nudge.max, None);
2912        assert_eq!(config.nudge.text, None);
2913    }
2914
2915    #[test]
2916    fn config_save_and_load_with_file() {
2917        // Test Config::save() by writing to a temp location manually.
2918        let dir = tempfile::tempdir().unwrap();
2919        let config_path = dir.path().join("config.toml");
2920
2921        let config = Config {
2922            default_provider: "openai".to_string(),
2923            providers: ProviderConfig {
2924                anthropic_api_key: Some("sk-ant-test".to_string()),
2925                openai_api_key: Some("sk-test".to_string()),
2926                google_api_key: None,
2927                claude_code_enabled: false,
2928                claude_code_binary: None,
2929                claude_code_effort: None,
2930                fallback_order: Vec::new(),
2931            },
2932            openrouter_api_key: Some("sk-or-test".to_string()),
2933            default_model: Some("gpt-5".to_string()),
2934            ..Config::default()
2935        };
2936
2937        let content = toml::to_string_pretty(&config).unwrap();
2938        std::fs::write(&config_path, &content).unwrap();
2939
2940        let loaded_content = std::fs::read_to_string(&config_path).unwrap();
2941        let loaded: Config = toml::from_str(&loaded_content).unwrap();
2942
2943        assert_eq!(loaded.default_provider, "openai");
2944        assert_eq!(
2945            loaded.providers.anthropic_api_key.as_deref(),
2946            Some("sk-ant-test")
2947        );
2948        assert_eq!(loaded.default_model.as_deref(), Some("gpt-5"));
2949    }
2950
2951    #[test]
2952    fn config_create_config_dir_creates_parent() {
2953        let dir = tempfile::tempdir().unwrap();
2954        let new_dir = dir.path().join("nested").join("config");
2955        // create_config_dir is private, but we test indirectly via filesystem
2956        std::fs::create_dir_all(&new_dir).unwrap();
2957        assert!(new_dir.exists());
2958    }
2959
2960    #[test]
2961    fn config_default_title_enabled() {
2962        let config = Config::default();
2963        assert!(config.title.enabled);
2964    }
2965
2966    #[test]
2967    fn config_serialize_with_all_options() {
2968        let mut model_caps = HashMap::new();
2969        model_caps.insert(
2970            "my-model".to_string(),
2971            ModelCapabilities {
2972                supports_temperature: true,
2973                supports_streaming: true,
2974                supports_tools: true,
2975                supports_system_prompt: true,
2976                max_context_tokens: 8192,
2977                max_output_tokens: 4096,
2978            },
2979        );
2980        let mut tool_perms = HashMap::new();
2981        tool_perms.insert("bash".to_string(), ToolPolicy::Allow);
2982
2983        let config = Config {
2984            default_provider: "anthropic".to_string(),
2985            providers: ProviderConfig {
2986                anthropic_api_key: Some("sk-ant-key".to_string()),
2987                openai_api_key: None,
2988                google_api_key: None,
2989                claude_code_enabled: false,
2990                claude_code_binary: None,
2991                claude_code_effort: None,
2992                fallback_order: Vec::new(),
2993            },
2994            agent_paths: vec![std::path::PathBuf::from("/my/agents")],
2995            openrouter_api_key: None,
2996            ollama_base_url: Some("http://custom:11434".to_string()),
2997            mcp_servers: vec![],
2998            default_model: None,
2999            model_capabilities: model_caps,
3000            model_providers: HashMap::new(),
3001            tool_permissions: tool_perms,
3002            agent_tool_permissions: HashMap::new(),
3003            title: TitleConfig {
3004                enabled: false,
3005                provider: Some("openai".to_string()),
3006                model: Some("gpt-5-mini".to_string()),
3007            },
3008            request_timeout_secs: None,
3009            rate_limits: HashMap::new(),
3010            taint_tracking: false,
3011            limits: LimitsConfig {
3012                max_concurrent_inferences: Some(4),
3013                max_concurrent_tools: 3,
3014                default_max_iterations: Some(99),
3015                exact_token_counting: false,
3016                script_shell_timeout_secs: 45,
3017                stall_timeout_secs: 90,
3018                dead_cycles_before_relief: 6,
3019                finished_retention_secs: 120,
3020                wedge_timeout_secs: 420,
3021                provider_failures_before_open: 5,
3022                provider_circuit_cooldown_secs: 120,
3023                interaction_timeout_secs: 120,
3024            },
3025            batch_tool_hint: true,
3026            shell_hint: false,
3027            nudge: leviath_core::NudgeConfig {
3028                enabled: Some(true),
3029                max: Some(2),
3030                text: Some("Use your tools.".to_string()),
3031            },
3032            webhook: WebhookConfig {
3033                max_retries: 5,
3034                base_delay_ms: 250,
3035                max_delay_ms: 10_000,
3036                timeout_secs: 7,
3037            },
3038            observability: ObservabilityConfig {
3039                enabled: true,
3040                exporter: TelemetryExporterKind::Stdout,
3041                endpoint: Some("http://collector:4318".to_string()),
3042                service_name: Some("leviath-prod".to_string()),
3043            },
3044            sandbox: Some(leviath_core::ToolSandboxConfig {
3045                kind: leviath_core::SandboxKind::Container,
3046                image: Some("ubuntu:24.04".to_string()),
3047                network: false,
3048                ..Default::default()
3049            }),
3050            tool_script_permissions: ScriptToolPermissions {
3051                http_get: ScriptPermission::Allow,
3052                http_post: ScriptPermission::Deny,
3053                shell: ScriptPermission::Deny,
3054                read_file: ScriptPermission::Inherit,
3055                write_file: ScriptPermission::Deny,
3056                env_var: ScriptPermission::Allow,
3057            },
3058            security: SecurityConfig {
3059                allow_seed_commands: false,
3060                allow_local_network: true,
3061                allow_env_vars: vec!["MY_PROVIDER_KEY".to_string()],
3062                allow_blueprint_read_paths: true,
3063                read_paths: vec!["~/.leviath/runs".to_string()],
3064                credential_store: leviath_core::CredentialStoreKind::Keychain,
3065            },
3066            agent_read_paths: HashMap::from([(
3067                "cto".to_string(),
3068                ReadPathGrants {
3069                    allow: vec!["glob:~/design-docs/**".to_string()],
3070                },
3071            )]),
3072        };
3073
3074        let serialized = toml::to_string_pretty(&config).unwrap();
3075        let deserialized: Config = toml::from_str(&serialized).unwrap();
3076
3077        assert_eq!(deserialized.default_provider, "anthropic");
3078        assert_eq!(deserialized.limits.max_concurrent_inferences, Some(4));
3079        assert_eq!(deserialized.limits.max_concurrent_tools, 3);
3080        assert_eq!(deserialized.limits.script_shell_timeout_secs, 45);
3081        assert_eq!(deserialized.limits.dead_cycles_before_relief, 6);
3082        assert_eq!(deserialized.limits.finished_retention_secs, 120);
3083        assert_eq!(
3084            deserialized.tool_script_permissions.http_get,
3085            ScriptPermission::Allow
3086        );
3087        assert_eq!(
3088            deserialized.tool_script_permissions.shell,
3089            ScriptPermission::Deny
3090        );
3091        assert_eq!(
3092            deserialized.tool_script_permissions.write_file,
3093            ScriptPermission::Deny
3094        );
3095        // `shell_hint` defaults to true, so a `false` surviving the round trip
3096        // is what proves the field is actually written and read back.
3097        assert!(deserialized.batch_tool_hint);
3098        assert!(!deserialized.shell_hint);
3099        assert!(!deserialized.security.allow_seed_commands);
3100        assert!(deserialized.security.allow_blueprint_read_paths);
3101        assert_eq!(deserialized.security.read_paths, vec!["~/.leviath/runs"]);
3102        assert_eq!(
3103            deserialized.agent_read_paths.get("cto"),
3104            Some(&ReadPathGrants {
3105                allow: vec!["glob:~/design-docs/**".to_string()],
3106            })
3107        );
3108        assert_eq!(
3109            deserialized.nudge,
3110            leviath_core::NudgeConfig {
3111                enabled: Some(true),
3112                max: Some(2),
3113                text: Some("Use your tools.".to_string()),
3114            }
3115        );
3116        assert_eq!(deserialized.webhook.max_retries, 5);
3117        assert_eq!(deserialized.webhook.base_delay_ms, 250);
3118        assert_eq!(deserialized.webhook.max_delay_ms, 10_000);
3119        assert_eq!(deserialized.webhook.timeout_secs, 7);
3120        assert!(deserialized.observability.enabled);
3121        assert_eq!(
3122            deserialized.observability.exporter,
3123            TelemetryExporterKind::Stdout
3124        );
3125        assert_eq!(
3126            deserialized.observability.endpoint.as_deref(),
3127            Some("http://collector:4318")
3128        );
3129        assert_eq!(
3130            deserialized.observability.service_name.as_deref(),
3131            Some("leviath-prod")
3132        );
3133        assert_eq!(deserialized.limits.default_max_iterations, Some(99));
3134        assert_eq!(
3135            deserialized.providers.anthropic_api_key.as_deref(),
3136            Some("sk-ant-key")
3137        );
3138        assert_eq!(deserialized.agent_paths.len(), 1);
3139        assert!(deserialized.model_capabilities.contains_key("my-model"));
3140        assert_eq!(
3141            deserialized.tool_permissions.get("bash"),
3142            Some(&ToolPolicy::Allow)
3143        );
3144        assert!(!deserialized.title.enabled);
3145        assert_eq!(deserialized.title.provider.as_deref(), Some("openai"));
3146        let sandbox = deserialized.sandbox.expect("sandbox round-trips");
3147        assert_eq!(sandbox.kind, leviath_core::SandboxKind::Container);
3148        assert_eq!(sandbox.image.as_deref(), Some("ubuntu:24.04"));
3149        assert!(!sandbox.network);
3150    }
3151
3152    // ─── Config with multiple model_capabilities ─────────────────────────
3153
3154    #[test]
3155    fn config_multiple_model_capabilities() {
3156        let toml_content = r#"
3157default_provider = "anthropic"
3158agent_paths = []
3159
3160[providers]
3161
3162[model_capabilities."model-a"]
3163supports_temperature = true
3164supports_streaming = true
3165supports_tools = true
3166supports_system_prompt = true
3167max_context_tokens = 8192
3168max_output_tokens = 4096
3169
3170[model_capabilities."model-b"]
3171supports_temperature = false
3172supports_streaming = false
3173supports_tools = false
3174supports_system_prompt = false
3175max_context_tokens = 2048
3176max_output_tokens = 1024
3177"#;
3178        let config: Config = toml::from_str(toml_content).unwrap();
3179        assert_eq!(config.model_capabilities.len(), 2);
3180        let caps_a = config.model_capabilities.get("model-a").unwrap();
3181        assert!(caps_a.supports_temperature);
3182        assert_eq!(caps_a.max_context_tokens, 8192);
3183        let caps_b = config.model_capabilities.get("model-b").unwrap();
3184        assert!(!caps_b.supports_temperature);
3185        assert_eq!(caps_b.max_context_tokens, 2048);
3186    }
3187}