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