Skip to main content

leviath_cli/
config.rs

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