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    #[test]
1905    fn exact_token_counting_parses_when_set() {
1906        let dir = tempfile::tempdir().unwrap();
1907        let path = dir.path().join("config.toml");
1908        let body = format!(
1909            "{}\n[limits]\nexact_token_counting = true\n",
1910            config_toml_without_limits()
1911        );
1912        std::fs::write(&path, body).unwrap();
1913        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1914        assert!(config.limits.exact_token_counting);
1915        // The other fields still fall back to their per-field defaults.
1916        assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1917    }
1918
1919    /// A valid full config-file body with the `[limits]` section removed, so
1920    /// tests can simulate a config written before the section existed (robust to
1921    /// unrelated fields being added). `[limits]` serializes as the final section.
1922    #[cfg(test)]
1923    fn config_toml_without_limits() -> String {
1924        let full = toml::to_string_pretty(&Config::default()).unwrap();
1925        format!("{}\n", full.split("[limits]").next().unwrap().trim_end())
1926    }
1927
1928    #[test]
1929    fn limits_absent_section_uses_defaults() {
1930        // A config file with no `[limits]` table still gets the bounded defaults.
1931        let dir = tempfile::tempdir().unwrap();
1932        let path = dir.path().join("config.toml");
1933        std::fs::write(&path, config_toml_without_limits()).unwrap();
1934        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1935        assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1936        assert_eq!(config.limits.default_max_iterations, Some(50));
1937        assert_eq!(config.limits.dead_cycles_before_relief, 10);
1938        assert_eq!(config.limits.finished_retention_secs, 300);
1939        // Off unless asked for: the wedge watchdog fails runs, so an upgrade
1940        // must not switch it on behind the operator's back.
1941        assert_eq!(config.limits.wedge_timeout_secs, 0);
1942    }
1943
1944    #[test]
1945    fn the_wedge_watchdog_is_off_until_it_is_configured() {
1946        let dir = tempfile::tempdir().unwrap();
1947        let path = dir.path().join("config.toml");
1948        let body = format!(
1949            "{}\n[limits]\nwedge_timeout_secs = 300\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_eq!(config.limits.wedge_timeout_secs, 300);
1955        // And the rest of the section keeps its own defaults.
1956        assert_eq!(config.limits.stall_timeout_secs, 60);
1957    }
1958
1959    #[test]
1960    fn limits_partial_section_fills_the_other_default() {
1961        // Setting only one field leaves the other at its per-field serde default.
1962        let dir = tempfile::tempdir().unwrap();
1963        let path = dir.path().join("config.toml");
1964        let body = format!(
1965            "{}\n[limits]\nmax_concurrent_inferences = 3\n",
1966            config_toml_without_limits()
1967        );
1968        std::fs::write(&path, body).unwrap();
1969        let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1970        assert_eq!(config.limits.max_concurrent_inferences, Some(3));
1971        assert_eq!(config.limits.default_max_iterations, Some(50));
1972    }
1973
1974    #[test]
1975    fn load_from_path_existing_provider_keys_skip_env_fallback() {
1976        // Every one of the 5 "env var fallback" `if field.is_none()` checks
1977        // in `load_from_path` has only ever been exercised on its `true`
1978        // (field absent, fall back to env) arm elsewhere in this file --
1979        // never on the `false` (field already set from the TOML file, skip
1980        // the env lookup) arm. `temp_env::with_vars` clears these process-global
1981        // env vars for the closure (and serializes against every other temp-env
1982        // test), so no concurrently-running test can be mid-set when we read.
1983        let unset: Vec<(&str, Option<&str>)> = PROVIDER_KEY_ENV_VARS
1984            .iter()
1985            .chain(["OLLAMA_HOST"].iter())
1986            .map(|&key| (key, None))
1987            .collect();
1988        temp_env::with_vars(unset, || {
1989            let dir = tempfile::tempdir().unwrap();
1990            let path = dir.path().join("config.toml");
1991            std::fs::write(
1992                &path,
1993                r#"
1994default_provider = "anthropic"
1995openrouter_api_key = "sk-or-existing"
1996ollama_base_url = "http://existing-ollama:11434"
1997agent_paths = []
1998
1999[providers]
2000anthropic_api_key = "sk-ant-existing"
2001openai_api_key = "sk-openai-existing"
2002google_api_key = "AIza-existing"
2003"#,
2004            )
2005            .unwrap();
2006
2007            let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
2008
2009            assert_eq!(
2010                config.providers.anthropic_api_key.as_deref(),
2011                Some("sk-ant-existing")
2012            );
2013            assert_eq!(
2014                config.providers.openai_api_key.as_deref(),
2015                Some("sk-openai-existing")
2016            );
2017            assert_eq!(
2018                config.providers.google_api_key.as_deref(),
2019                Some("AIza-existing")
2020            );
2021            assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-existing"));
2022            assert_eq!(
2023                config.ollama_base_url.as_deref(),
2024                Some("http://existing-ollama:11434")
2025            );
2026        });
2027    }
2028
2029    #[test]
2030    fn load_from_path_malformed_toml_returns_error() {
2031        let dir = tempfile::tempdir().unwrap();
2032        let path = dir.path().join("config.toml");
2033        std::fs::write(&path, "not valid toml [[[").unwrap();
2034        let result = Config::load_from_path(&path);
2035        assert!(result.is_err());
2036        assert!(result.unwrap_err().to_string().contains("Failed to parse"));
2037    }
2038
2039    #[test]
2040    fn load_from_path_unreadable_path_returns_error() {
2041        // A directory can't be read as a config file.
2042        let dir = tempfile::tempdir().unwrap();
2043        let result = Config::load_from_path(dir.path());
2044        assert!(result.is_err());
2045    }
2046
2047    #[test]
2048    fn save_to_path_writes_valid_toml_that_round_trips() {
2049        let dir = tempfile::tempdir().unwrap();
2050        let path = dir.path().join("nested").join("config.toml");
2051        let config = Config {
2052            default_provider: "google".to_string(),
2053            ..Config::default()
2054        };
2055        with_tracing(|| config.save_to_path(&path)).unwrap();
2056
2057        let loaded = with_tracing(|| Config::load_from_path(&path)).unwrap();
2058        assert_eq!(loaded.default_provider, "google");
2059    }
2060
2061    #[test]
2062    fn save_to_path_creates_parent_directory() {
2063        let dir = tempfile::tempdir().unwrap();
2064        let path = dir.path().join("a").join("b").join("config.toml");
2065        let config = Config::default();
2066        with_tracing(|| config.save_to_path(&path)).unwrap();
2067        assert!(path.exists());
2068    }
2069
2070    #[test]
2071    fn save_to_path_with_no_parent_skips_create_config_dir() {
2072        // `Path::parent()` returns `None` only for an empty path or a
2073        // filesystem root - `PathBuf::from("")` triggers the empty case
2074        // cross-platform, hitting the `if let Some(parent) = ...` block's
2075        // `None` arm (skip `create_config_dir`) without a platform-specific
2076        // root path. The subsequent `fs::write("")` then fails, which is
2077        // fine: this test only cares about the `None` branch being taken.
2078        let result = Config::default().save_to_path(&std::path::PathBuf::from(""));
2079        assert!(result.is_err());
2080    }
2081
2082    #[cfg(unix)]
2083    #[test]
2084    fn save_to_path_sets_restrictive_file_permissions() {
2085        use std::os::unix::fs::PermissionsExt;
2086        let dir = tempfile::tempdir().unwrap();
2087        let path = dir.path().join("config.toml");
2088        with_tracing(|| Config::default().save_to_path(&path)).unwrap();
2089        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2090        assert_eq!(mode & 0o777, 0o600);
2091    }
2092
2093    #[test]
2094    fn save_to_path_write_failure_returns_error() {
2095        // A directory at the exact target path forces `std::fs::write` to
2096        // fail with EISDIR, exercising `save_to_path`'s write-error `map_err`
2097        // arm (distinct from `save_to_path_creates_parent_directory`, which
2098        // exercises the parent-dir-creation path but always succeeds).
2099        let dir = tempfile::tempdir().unwrap();
2100        let path = dir.path().join("config.toml");
2101        std::fs::create_dir_all(&path).unwrap();
2102
2103        let result = Config::default().save_to_path(&path);
2104
2105        assert!(result.is_err());
2106        assert!(
2107            result
2108                .unwrap_err()
2109                .to_string()
2110                .contains("Failed to write config")
2111        );
2112    }
2113
2114    #[test]
2115    fn save_to_path_create_config_dir_failure_returns_error() {
2116        let dir = tempfile::tempdir().unwrap();
2117        let blocking_file = dir.path().join("not-a-dir");
2118        std::fs::write(&blocking_file, "").unwrap();
2119        let path = blocking_file.join("config.toml");
2120        let result = Config::default().save_to_path(&path);
2121        assert!(result.is_err());
2122        assert!(
2123            result
2124                .unwrap_err()
2125                .to_string()
2126                .contains("Failed to create config directory")
2127        );
2128    }
2129
2130    #[test]
2131    fn load_propagates_error_when_real_config_file_is_malformed() {
2132        // Every other `Config::load()` test sees either no file (defaults)
2133        // or a well-formed one, so `load()`'s `?` on `load_from_path(...)`
2134        // has never actually propagated an `Err`. Writing malformed TOML to
2135        // the guard's redirected `LEVIATH_CONFIG_PATH` forces that.
2136        with_isolated_config_path("load-malformed", |fake_dir| {
2137            std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
2138
2139            let result = Config::load();
2140
2141            assert!(result.is_err());
2142        });
2143    }
2144
2145    // ─── check_permissions_at ────────────────────────────────────────────
2146
2147    #[cfg(unix)]
2148    #[test]
2149    fn check_permissions_at_missing_file_is_noop() {
2150        let dir = tempfile::tempdir().unwrap();
2151        let path = dir.path().join("nonexistent.toml");
2152        check_permissions_at(&path); // must not panic
2153    }
2154
2155    #[cfg(unix)]
2156    #[test]
2157    fn check_permissions_at_fixes_overly_permissive_file() {
2158        use std::os::unix::fs::PermissionsExt;
2159        let dir = tempfile::tempdir().unwrap();
2160        let path = dir.path().join("config.toml");
2161        std::fs::write(&path, "").unwrap();
2162        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2163
2164        with_tracing(|| check_permissions_at(&path));
2165
2166        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2167        assert_eq!(mode & 0o777, 0o600);
2168    }
2169
2170    #[cfg(unix)]
2171    #[test]
2172    fn check_permissions_at_leaves_already_restrictive_file_alone() {
2173        use std::os::unix::fs::PermissionsExt;
2174        let dir = tempfile::tempdir().unwrap();
2175        let path = dir.path().join("config.toml");
2176        std::fs::write(&path, "").unwrap();
2177        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2178
2179        check_permissions_at(&path);
2180
2181        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2182        assert_eq!(mode & 0o777, 0o600);
2183    }
2184
2185    // On macOS/BSD, `chflags uchg` sets the user-immutable flag - settable
2186    // by a regular file owner without root - which blocks `chmod` (and thus
2187    // `std::fs::set_permissions`) with EPERM while leaving `exists()`/
2188    // The "fix failed" arm of `check_permissions_at` (a file that exists but
2189    // whose `chmod` fails) is exercised deterministically on every OS by
2190    // injecting a failing `ensure` fn - no `chflags uchg`/root trick, which was
2191    // macOS-only and left this branch uncovered on Linux CI.
2192    #[test]
2193    fn check_permissions_at_with_logs_when_fix_fails() {
2194        fn ensure_fails(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2195            Err(std::io::Error::other("simulated chmod failure"))
2196        }
2197        // Must not panic; the failure is only logged.
2198        with_tracing(|| {
2199            check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_fails)
2200        });
2201    }
2202
2203    #[test]
2204    fn check_permissions_at_with_logs_when_file_is_permissive() {
2205        fn ensure_permissive(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2206            Ok(Some(0o100644))
2207        }
2208        with_tracing(|| {
2209            check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_permissive)
2210        });
2211    }
2212
2213    // Portable failure injection for the hardening error arms of
2214    // `set_file_permissions`/`set_dir_permissions`. `leviath_sys`'s Windows
2215    // fallback is infallible (always `Ok`) - and even a missing path fails only
2216    // on Unix - so the only cross-platform way to reach the `Err` arm is to
2217    // inject a hardening op that fails (mirroring `check_permissions_at_with`).
2218    fn always_failing_secure(_path: &std::path::Path) -> std::io::Result<()> {
2219        Err(std::io::Error::other(
2220            "simulated permission-hardening failure",
2221        ))
2222    }
2223
2224    #[test]
2225    fn set_dir_permissions_error_branch_logs_not_panics() {
2226        with_tracing(|| {
2227            set_dir_permissions_with(
2228                std::path::Path::new("/does/not/matter"),
2229                always_failing_secure,
2230            )
2231        }); // hits the Err arm, must not panic
2232    }
2233
2234    // ─── create_config_dir / set_file_permissions / set_dir_permissions ───
2235    // (already path-parameterized - directly testable without touching the
2236    // real ~/.leviath/config.toml)
2237
2238    #[test]
2239    fn create_config_dir_creates_nested_dirs() {
2240        let dir = tempfile::tempdir().unwrap();
2241        let target = dir.path().join("a").join("b").join("c");
2242        create_config_dir(&target).unwrap();
2243        assert!(target.is_dir());
2244    }
2245
2246    #[cfg(unix)]
2247    #[test]
2248    fn create_config_dir_sets_restrictive_permissions() {
2249        use std::os::unix::fs::PermissionsExt;
2250        let dir = tempfile::tempdir().unwrap();
2251        let target = dir.path().join("leviath");
2252        create_config_dir(&target).unwrap();
2253        let mode = std::fs::metadata(&target).unwrap().permissions().mode();
2254        assert_eq!(mode & 0o777, 0o700);
2255    }
2256
2257    /// The config holds every provider API key, so it must never be readable by
2258    /// anyone else - not even for the instant between a `write` and a follow-up
2259    /// `chmod`. `write_private` creates the file with the mode already applied.
2260    #[cfg(unix)]
2261    /// `LEVIATH_HOME` must redirect the config too, not just the runs and
2262    /// agents directories.
2263    ///
2264    /// Without that redirect the consequence is concrete: a scratch environment
2265    /// that sets `LEVIATH_HOME` and runs `lev mcp add` writes to the developer's
2266    /// *real* `~/.leviath/config.toml` - the file holding every provider API key
2267    /// - while believing it is isolated.
2268    #[test]
2269    fn config_path_honors_leviath_home() {
2270        temp_env::with_vars(
2271            [
2272                ("LEVIATH_CONFIG_PATH", None::<&str>),
2273                ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2274            ],
2275            || {
2276                assert_eq!(
2277                    Config::config_path(),
2278                    std::path::PathBuf::from("/tmp/lev-cfg-test/.leviath/config.toml")
2279                );
2280            },
2281        );
2282    }
2283
2284    /// The narrower override still wins, so an explicit path is exact.
2285    #[test]
2286    fn config_path_prefers_the_explicit_override() {
2287        temp_env::with_vars(
2288            [
2289                ("LEVIATH_CONFIG_PATH", Some("/tmp/exact.toml")),
2290                ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2291            ],
2292            || {
2293                assert_eq!(
2294                    Config::config_path(),
2295                    std::path::PathBuf::from("/tmp/exact.toml")
2296                );
2297            },
2298        );
2299    }
2300
2301    /// The escape hatch for the permission floor: a user grants one named agent
2302    /// more than their global setting, in their own config rather than in the
2303    /// downloaded manifest.
2304    #[test]
2305    fn permissions_for_agent_overlays_the_named_grant_on_the_global() {
2306        let mut config = Config::default();
2307        config
2308            .tool_permissions
2309            .insert("shell".to_string(), ToolPolicy::Ask);
2310        config
2311            .tool_permissions
2312            .insert("write_file".to_string(), ToolPolicy::Deny);
2313        config.agent_tool_permissions.insert(
2314            "coder".to_string(),
2315            HashMap::from([("shell".to_string(), ToolPolicy::Allow)]),
2316        );
2317
2318        let coder = config.permissions_for_agent("coder");
2319        assert_eq!(coder.get("shell"), Some(&ToolPolicy::Allow), "granted");
2320        assert_eq!(
2321            coder.get("write_file"),
2322            Some(&ToolPolicy::Deny),
2323            "the rest of the global ceiling still applies"
2324        );
2325
2326        // Any other agent sees the global setting untouched.
2327        let other = config.permissions_for_agent("researcher");
2328        assert_eq!(other.get("shell"), Some(&ToolPolicy::Ask));
2329    }
2330
2331    /// Read-path grants mirror the tool-permission shape: a machine-wide list
2332    /// plus per-agent additions, resolved once per agent.
2333    #[test]
2334    fn read_path_grants_merge_global_and_per_agent() {
2335        let mut config = Config::default();
2336        assert!(
2337            !config.security.allow_blueprint_read_paths,
2338            "blueprint read paths must be opt-in"
2339        );
2340        assert!(config.read_path_grants_for_agent("cto").is_empty());
2341
2342        config.security.read_paths = vec!["~/.leviath/runs".to_string()];
2343        config.agent_read_paths.insert(
2344            "cto".to_string(),
2345            ReadPathGrants {
2346                allow: vec!["glob:~/design-docs/**".to_string()],
2347            },
2348        );
2349
2350        assert_eq!(
2351            config.read_path_grants_for_agent("cto"),
2352            vec![
2353                "~/.leviath/runs".to_string(),
2354                "glob:~/design-docs/**".to_string(),
2355            ]
2356        );
2357        // Any other agent gets the machine-wide grants only.
2358        assert_eq!(
2359            config.read_path_grants_for_agent("researcher"),
2360            vec!["~/.leviath/runs".to_string()]
2361        );
2362    }
2363
2364    /// One `tracing::debug!(?config)` would otherwise put every provider key in
2365    /// the logs.
2366    #[test]
2367    fn provider_config_debug_never_prints_the_keys() {
2368        let providers = ProviderConfig {
2369            anthropic_api_key: Some("sk-ant-SECRET-VALUE".to_string()),
2370            openai_api_key: Some("sk-openai-SECRET-VALUE".to_string()),
2371            google_api_key: Some("AIza-SECRET-VALUE".to_string()),
2372            claude_code_enabled: true,
2373            claude_code_binary: None,
2374            claude_code_effort: None,
2375            anthropic_cache_ttl: None,
2376            fallback_order: Vec::new(),
2377        };
2378        let rendered = format!("{providers:?}");
2379        assert!(!rendered.contains("SECRET-VALUE"), "key leaked: {rendered}");
2380        // "is it configured" is what a debug line is actually asking.
2381        assert!(rendered.contains("<set>"), "{rendered}");
2382        assert!(rendered.contains("claude_code_enabled: true"), "{rendered}");
2383
2384        let empty = format!(
2385            "{:?}",
2386            ProviderConfig {
2387                anthropic_api_key: None,
2388                openai_api_key: None,
2389                google_api_key: None,
2390                claude_code_enabled: false,
2391                claude_code_binary: None,
2392                claude_code_effort: None,
2393                anthropic_cache_ttl: None,
2394                fallback_order: Vec::new(),
2395            }
2396        );
2397        assert!(empty.contains("<unset>"), "{empty}");
2398    }
2399
2400    /// Unix-only: the assertion is about POSIX mode bits, which Windows does
2401    /// not have. `write_private`'s Windows path is a plain write, exercised by
2402    /// every other `save_to_path` test.
2403    #[cfg(unix)]
2404    #[test]
2405    fn saving_a_config_never_leaves_it_group_or_world_readable() {
2406        use std::os::unix::fs::PermissionsExt;
2407        let dir = tempfile::tempdir().unwrap();
2408        let path = dir.path().join("config.toml");
2409
2410        Config::default().save_to_path(&path).unwrap();
2411        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2412        assert_eq!(mode & 0o777, 0o600, "fresh config must be owner-only");
2413
2414        // Overwriting a file that somehow became permissive tightens it again.
2415        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2416        Config::default().save_to_path(&path).unwrap();
2417        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2418        assert_eq!(mode & 0o777, 0o600, "re-saving must re-tighten");
2419    }
2420
2421    #[cfg(unix)]
2422    #[test]
2423    fn set_dir_permissions_sets_0700() {
2424        use std::os::unix::fs::PermissionsExt;
2425        let dir = tempfile::tempdir().unwrap();
2426        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
2427        set_dir_permissions(dir.path());
2428        let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode();
2429        assert_eq!(mode & 0o777, 0o700);
2430    }
2431
2432    #[test]
2433    fn test_validate_keys_good_anthropic() {
2434        let config = Config {
2435            providers: ProviderConfig {
2436                anthropic_api_key: Some("sk-ant-test123".to_string()),
2437                openai_api_key: None,
2438                google_api_key: None,
2439                claude_code_enabled: false,
2440                claude_code_binary: None,
2441                claude_code_effort: None,
2442                anthropic_cache_ttl: None,
2443                fallback_order: Vec::new(),
2444            },
2445            ..Config::default()
2446        };
2447        assert!(config.validate_keys().is_empty());
2448    }
2449
2450    #[test]
2451    fn test_validate_keys_bad_anthropic() {
2452        let config = Config {
2453            providers: ProviderConfig {
2454                anthropic_api_key: Some("bad-key".to_string()),
2455                openai_api_key: None,
2456                google_api_key: None,
2457                claude_code_enabled: false,
2458                claude_code_binary: None,
2459                claude_code_effort: None,
2460                anthropic_cache_ttl: None,
2461                fallback_order: Vec::new(),
2462            },
2463            ..Config::default()
2464        };
2465        let warnings = config.validate_keys();
2466        assert_eq!(warnings.len(), 1);
2467        assert!(warnings[0].contains("Anthropic"));
2468    }
2469
2470    #[test]
2471    fn test_validate_keys_good_openai() {
2472        let config = Config {
2473            providers: ProviderConfig {
2474                anthropic_api_key: None,
2475                openai_api_key: Some("sk-test123".to_string()),
2476                google_api_key: None,
2477                claude_code_enabled: false,
2478                claude_code_binary: None,
2479                claude_code_effort: None,
2480                anthropic_cache_ttl: None,
2481                fallback_order: Vec::new(),
2482            },
2483            ..Config::default()
2484        };
2485        assert!(config.validate_keys().is_empty());
2486    }
2487
2488    #[test]
2489    fn test_validate_keys_bad_openai() {
2490        let config = Config {
2491            providers: ProviderConfig {
2492                anthropic_api_key: None,
2493                openai_api_key: Some("bad-key".to_string()),
2494                google_api_key: None,
2495                claude_code_enabled: false,
2496                claude_code_binary: None,
2497                claude_code_effort: None,
2498                anthropic_cache_ttl: None,
2499                fallback_order: Vec::new(),
2500            },
2501            ..Config::default()
2502        };
2503        let warnings = config.validate_keys();
2504        assert_eq!(warnings.len(), 1);
2505        assert!(warnings[0].contains("OpenAI"));
2506    }
2507
2508    #[test]
2509    fn test_validate_keys_no_keys() {
2510        let config = Config::default();
2511        assert!(config.validate_keys().is_empty());
2512    }
2513
2514    // ─── Config defaults ───────────────────────────────────────────────────
2515
2516    #[test]
2517    fn config_default_values() {
2518        let config = Config::default();
2519        assert_eq!(config.default_provider, "anthropic");
2520        assert!(config.providers.anthropic_api_key.is_none());
2521        assert!(config.providers.openai_api_key.is_none());
2522        assert!(config.providers.google_api_key.is_none());
2523        assert!(config.openrouter_api_key.is_none());
2524        assert!(config.ollama_base_url.is_none());
2525        assert!(config.mcp_servers.is_empty());
2526        assert!(config.default_model.is_none());
2527        assert!(config.model_capabilities.is_empty());
2528        assert!(config.tool_permissions.is_empty());
2529    }
2530
2531    // ─── TitleConfig ───────────────────────────────────────────────────────
2532
2533    #[test]
2534    fn title_config_default() {
2535        let tc = TitleConfig::default();
2536        assert!(tc.enabled);
2537        assert!(tc.provider.is_none());
2538        assert!(tc.model.is_none());
2539    }
2540
2541    #[test]
2542    fn title_config_serde_roundtrip() {
2543        let tc = TitleConfig {
2544            enabled: false,
2545            provider: Some("openai".to_string()),
2546            model: Some("gpt-5.4-mini".to_string()),
2547        };
2548        let json = serde_json::to_string(&tc).unwrap();
2549        let back: TitleConfig = serde_json::from_str(&json).unwrap();
2550        assert!(!back.enabled);
2551        assert_eq!(back.provider.as_deref(), Some("openai"));
2552        assert_eq!(back.model.as_deref(), Some("gpt-5.4-mini"));
2553    }
2554
2555    // ─── ToolPolicy ────────────────────────────────────────────────────────
2556
2557    #[test]
2558    fn tool_policy_default_is_ask() {
2559        let policy = ToolPolicy::default();
2560        assert_eq!(policy, ToolPolicy::Ask);
2561    }
2562
2563    #[test]
2564    fn tool_policy_serde_roundtrip() {
2565        for policy in [ToolPolicy::Allow, ToolPolicy::Ask, ToolPolicy::Deny] {
2566            let json = serde_json::to_string(&policy).unwrap();
2567            let back: ToolPolicy = serde_json::from_str(&json).unwrap();
2568            assert_eq!(policy, back);
2569        }
2570    }
2571
2572    #[test]
2573    fn tool_policy_snake_case_serialization() {
2574        assert_eq!(
2575            serde_json::to_string(&ToolPolicy::Allow).unwrap(),
2576            "\"allow\""
2577        );
2578        assert_eq!(serde_json::to_string(&ToolPolicy::Ask).unwrap(), "\"ask\"");
2579        assert_eq!(
2580            serde_json::to_string(&ToolPolicy::Deny).unwrap(),
2581            "\"deny\""
2582        );
2583    }
2584
2585    // ─── Config TOML parsing ───────────────────────────────────────────────
2586
2587    #[test]
2588    fn config_from_toml_with_all_fields() {
2589        let toml_content = r#"
2590default_provider = "openai"
2591openrouter_api_key = "sk-or-test"
2592ollama_base_url = "http://my-ollama:11434"
2593default_model = "gpt-5"
2594agent_paths = []
2595
2596[providers]
2597anthropic_api_key = "sk-ant-test"
2598openai_api_key = "sk-test"
2599google_api_key = "AIza-test"
2600
2601[tool_permissions]
2602bash = "deny"
2603read_file = "allow"
2604
2605[title]
2606enabled = false
2607provider = "anthropic"
2608model = "claude-haiku-4-5"
2609"#;
2610        let config: Config = toml::from_str(toml_content).unwrap();
2611        assert_eq!(config.default_provider, "openai");
2612        assert_eq!(
2613            config.providers.anthropic_api_key.as_deref(),
2614            Some("sk-ant-test")
2615        );
2616        assert_eq!(config.providers.openai_api_key.as_deref(), Some("sk-test"));
2617        assert_eq!(
2618            config.providers.google_api_key.as_deref(),
2619            Some("AIza-test")
2620        );
2621        assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2622        assert_eq!(
2623            config.ollama_base_url.as_deref(),
2624            Some("http://my-ollama:11434")
2625        );
2626        assert_eq!(config.default_model.as_deref(), Some("gpt-5"));
2627        assert!(!config.title.enabled);
2628        assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2629        assert_eq!(
2630            config.tool_permissions.get("read_file"),
2631            Some(&ToolPolicy::Allow)
2632        );
2633    }
2634
2635    #[test]
2636    fn config_from_minimal_toml() {
2637        let toml_content = r#"
2638default_provider = "anthropic"
2639agent_paths = []
2640
2641[providers]
2642"#;
2643        let config: Config = toml::from_str(toml_content).unwrap();
2644        assert_eq!(config.default_provider, "anthropic");
2645        assert!(config.providers.anthropic_api_key.is_none());
2646    }
2647
2648    #[test]
2649    fn the_three_lines_that_point_leviath_at_openrouter_are_enough() {
2650        // What a user writes by hand after reading the OpenRouter docs. Every
2651        // field on Config used to be required, so this failed with `missing
2652        // field `providers`` - a table they have no reason to know about, in a
2653        // message that says nothing about what to add.
2654        let config: Config = toml::from_str(
2655            r#"
2656default_provider = "openrouter"
2657default_model = "openai/gpt-4o-mini"
2658openrouter_api_key = "sk-or-test"
2659"#,
2660        )
2661        .expect("a hand-written OpenRouter config parses");
2662        assert_eq!(config.default_provider, "openrouter");
2663        assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2664        assert_eq!(config.default_model.as_deref(), Some("openai/gpt-4o-mini"));
2665    }
2666
2667    #[test]
2668    fn an_empty_config_file_parses_to_the_defaults() {
2669        // Pins the serde defaults against `Config::default` in both
2670        // directions: a field that gains one but not the other means a fresh
2671        // file and a fresh struct disagree about the same install.
2672        let parsed: Config = toml::from_str("").expect("an empty config parses");
2673        let default = Config::default();
2674        assert_eq!(parsed.default_provider, default.default_provider);
2675        assert_eq!(parsed.agent_paths, default.agent_paths);
2676        assert_eq!(parsed.openrouter_api_key, default.openrouter_api_key);
2677        assert_eq!(parsed.ollama_base_url, default.ollama_base_url);
2678        assert_eq!(parsed.default_model, default.default_model);
2679        assert_eq!(parsed.request_timeout_secs, default.request_timeout_secs);
2680        assert_eq!(
2681            parsed.providers.anthropic_api_key,
2682            default.providers.anthropic_api_key
2683        );
2684        assert_eq!(
2685            parsed.providers.claude_code_enabled,
2686            default.providers.claude_code_enabled
2687        );
2688    }
2689
2690    #[test]
2691    fn config_from_toml_with_mcp_servers() {
2692        let toml_content = r#"
2693default_provider = "anthropic"
2694agent_paths = []
2695
2696[providers]
2697
2698[[mcp_servers]]
2699name = "test-server"
2700command = "echo"
2701args = ["hello"]
2702"#;
2703        let config: Config = toml::from_str(toml_content).unwrap();
2704        assert_eq!(config.mcp_servers.len(), 1);
2705        assert_eq!(config.mcp_servers[0].name, "test-server");
2706    }
2707
2708    #[test]
2709    fn load_rejects_a_malformed_mcp_server_entry() {
2710        // An entry with neither `command` nor `url` can never connect, so it
2711        // must fail at load - naming the server - rather than silently drop its
2712        // tools until the first call.
2713        let dir = tempfile::tempdir().unwrap();
2714        let path = dir.path().join("config.toml");
2715        std::fs::write(
2716            &path,
2717            r#"
2718default_provider = "anthropic"
2719agent_paths = []
2720
2721[providers]
2722
2723[[mcp_servers]]
2724name = "broken"
2725"#,
2726        )
2727        .unwrap();
2728
2729        let err = Config::load_from_path(&path).expect_err("malformed entry must fail load");
2730        let msg = err.to_string();
2731        assert!(msg.contains("broken"), "must name the server: {msg}");
2732    }
2733
2734    #[test]
2735    fn load_accepts_a_well_formed_http_mcp_server() {
2736        let dir = tempfile::tempdir().unwrap();
2737        let path = dir.path().join("config.toml");
2738        std::fs::write(
2739            &path,
2740            r#"
2741default_provider = "anthropic"
2742agent_paths = []
2743
2744[providers]
2745
2746[[mcp_servers]]
2747name = "remote"
2748url = "https://mcp.example.com/mcp"
2749"#,
2750        )
2751        .unwrap();
2752
2753        let config = Config::load_from_path(&path).expect("valid http entry should load");
2754        assert_eq!(
2755            config.mcp_servers[0].url.as_deref(),
2756            Some("https://mcp.example.com/mcp")
2757        );
2758    }
2759
2760    #[test]
2761    fn config_from_toml_with_model_capabilities() {
2762        // A one-field entry, which is what someone correcting a wrong context
2763        // window actually writes. It used to fail to deserialize and be dropped
2764        // in silence (#338); now it parses and names only that field, so
2765        // everything it did not mention comes from the provider.
2766        let toml = r#"
2767[model_capabilities."my-custom-model"]
2768max_context_tokens = 1048576
2769"#;
2770        let config: Config = toml::from_str(toml).expect("a partial entry parses");
2771        let entry = config
2772            .model_capabilities
2773            .get("my-custom-model")
2774            .expect("the entry survives");
2775        assert_eq!(entry.max_context_tokens, Some(1_048_576));
2776        assert_eq!(
2777            entry.max_output_tokens, None,
2778            "an unmentioned field stays unset rather than defaulting"
2779        );
2780        assert_eq!(entry.supports_tools, None);
2781    }
2782
2783    /// A misspelled key is refused rather than ignored, so a typo cannot look
2784    /// like a working override.
2785    #[test]
2786    fn config_model_capabilities_rejects_an_unknown_key() {
2787        let toml = r#"
2788[model_capabilities."my-custom-model"]
2789max_contxt_tokens = 1048576
2790"#;
2791        assert!(toml::from_str::<Config>(toml).is_err());
2792    }
2793
2794    #[test]
2795    fn validate_keys_is_quiet_about_blank_keys() {
2796        let mut config = Config::default();
2797        config.providers.anthropic_api_key = Some(String::new());
2798        config.providers.openai_api_key = Some("   ".to_string());
2799        assert!(config.validate_keys().is_empty());
2800        // A genuinely wrong-looking key still warns.
2801        config.providers.anthropic_api_key = Some("nope".to_string());
2802        assert_eq!(config.validate_keys().len(), 1);
2803    }
2804
2805    #[test]
2806    fn validate_keys_both_bad() {
2807        let config = Config {
2808            providers: ProviderConfig {
2809                anthropic_api_key: Some("bad".to_string()),
2810                openai_api_key: Some("bad".to_string()),
2811                google_api_key: None,
2812                claude_code_enabled: false,
2813                claude_code_binary: None,
2814                claude_code_effort: None,
2815                anthropic_cache_ttl: None,
2816                fallback_order: Vec::new(),
2817            },
2818            ..Config::default()
2819        };
2820        let warnings = config.validate_keys();
2821        assert_eq!(warnings.len(), 2);
2822    }
2823
2824    // ─── config_path ───────────────────────────────────────────────────────
2825
2826    #[test]
2827    fn config_path_contains_leviath() {
2828        // Force `LEVIATH_CONFIG_PATH` unset (via `temp_env::with_var_unset`,
2829        // which also serializes against every other temp-env test) so
2830        // `config_path()` resolves to the real default, not a concurrently-set
2831        // override.
2832        temp_env::with_var_unset("LEVIATH_CONFIG_PATH", || {
2833            let path = Config::config_path();
2834            assert!(path.to_str().unwrap().contains(".leviath"));
2835            assert!(path.to_str().unwrap().ends_with("config.toml"));
2836        });
2837    }
2838
2839    // ─── Config save/load roundtrip ────────────────────────────────────────
2840
2841    #[test]
2842    fn config_toml_roundtrip() {
2843        let config = Config {
2844            default_provider: "openai".to_string(),
2845            providers: ProviderConfig {
2846                anthropic_api_key: Some("sk-ant-key".to_string()),
2847                openai_api_key: None,
2848                google_api_key: None,
2849                claude_code_enabled: false,
2850                claude_code_binary: None,
2851                claude_code_effort: None,
2852                anthropic_cache_ttl: None,
2853                fallback_order: Vec::new(),
2854            },
2855            tool_permissions: {
2856                let mut m = HashMap::new();
2857                m.insert("bash".to_string(), ToolPolicy::Deny);
2858                m
2859            },
2860            ..Config::default()
2861        };
2862
2863        let serialized = toml::to_string_pretty(&config).unwrap();
2864        let deserialized: Config = toml::from_str(&serialized).unwrap();
2865        assert_eq!(deserialized.default_provider, "openai");
2866        assert_eq!(
2867            deserialized.providers.anthropic_api_key.as_deref(),
2868            Some("sk-ant-key")
2869        );
2870        assert_eq!(
2871            deserialized.tool_permissions.get("bash"),
2872            Some(&ToolPolicy::Deny)
2873        );
2874    }
2875
2876    // ─── validate_keys: both keys valid ──────────────────────────────────
2877
2878    #[test]
2879    fn validate_keys_both_valid() {
2880        let config = Config {
2881            providers: ProviderConfig {
2882                anthropic_api_key: Some("sk-ant-good-key".to_string()),
2883                openai_api_key: Some("sk-good-key".to_string()),
2884                google_api_key: None,
2885                claude_code_enabled: false,
2886                claude_code_binary: None,
2887                claude_code_effort: None,
2888                anthropic_cache_ttl: None,
2889                fallback_order: Vec::new(),
2890            },
2891            ..Config::default()
2892        };
2893        assert!(config.validate_keys().is_empty());
2894    }
2895
2896    // ─── validate_keys: google key has no validation ─────────────────────
2897
2898    #[test]
2899    fn validate_keys_google_key_not_validated() {
2900        let config = Config {
2901            providers: ProviderConfig {
2902                anthropic_api_key: None,
2903                openai_api_key: None,
2904                google_api_key: Some("anything-goes".to_string()),
2905                claude_code_enabled: false,
2906                claude_code_binary: None,
2907                claude_code_effort: None,
2908                anthropic_cache_ttl: None,
2909                fallback_order: Vec::new(),
2910            },
2911            ..Config::default()
2912        };
2913        // Google key has no prefix validation
2914        assert!(config.validate_keys().is_empty());
2915    }
2916
2917    // ─── Config TOML parsing: registries ─────────────────────────────────
2918
2919    #[test]
2920    fn config_from_toml_custom_registries() {
2921        let toml_content = r#"
2922default_provider = "anthropic"
2923agent_paths = ["/my/agents"]
2924
2925[providers]
2926"#;
2927        let config: Config = toml::from_str(toml_content).unwrap();
2928        assert_eq!(config.agent_paths.len(), 1);
2929    }
2930
2931    // ─── Config save writes file ─────────────────────────────────────────
2932
2933    #[test]
2934    fn config_save_creates_file() {
2935        let dir = tempfile::tempdir().unwrap();
2936        let config_path = dir.path().join("subdir").join("config.toml");
2937        // We can't easily test Config::save() because it uses a fixed path,
2938        // but we can test the serialization and write manually
2939        let config = Config::default();
2940        let content = toml::to_string_pretty(&config).unwrap();
2941        std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
2942        std::fs::write(&config_path, &content).unwrap();
2943        assert!(config_path.exists());
2944        let loaded_content = std::fs::read_to_string(&config_path).unwrap();
2945        let loaded: Config = toml::from_str(&loaded_content).unwrap();
2946        assert_eq!(loaded.default_provider, "anthropic");
2947    }
2948
2949    // ─── TitleConfig serde from TOML ─────────────────────────────────────
2950
2951    #[test]
2952    fn title_config_from_toml_defaults() {
2953        let toml_content = r#"
2954default_provider = "anthropic"
2955agent_paths = []
2956
2957[providers]
2958"#;
2959        let config: Config = toml::from_str(toml_content).unwrap();
2960        assert!(config.title.enabled);
2961        assert!(config.title.provider.is_none());
2962        assert!(config.title.model.is_none());
2963    }
2964
2965    #[test]
2966    fn title_config_from_toml_disabled() {
2967        let toml_content = r#"
2968default_provider = "anthropic"
2969agent_paths = []
2970
2971[providers]
2972
2973[title]
2974enabled = false
2975"#;
2976        let config: Config = toml::from_str(toml_content).unwrap();
2977        assert!(!config.title.enabled);
2978    }
2979
2980    #[test]
2981    fn title_config_missing_enabled_key_uses_default_true() {
2982        // Unlike `title_config_from_toml_defaults` (which omits the whole
2983        // `[title]` table, falling back to `Config`'s own `#[serde(default)]`
2984        // for the field - never invoking `TitleConfig`'s own per-field
2985        // parsing at all), this includes `[title]` but omits `enabled`
2986        // specifically, forcing serde to deserialize `TitleConfig` field by
2987        // field and fall back to `default_true()` for the missing key.
2988        let toml_content = r#"
2989default_provider = "anthropic"
2990agent_paths = []
2991
2992[providers]
2993
2994[title]
2995provider = "openai"
2996"#;
2997        let config: Config = toml::from_str(toml_content).unwrap();
2998        assert!(config.title.enabled);
2999        assert_eq!(config.title.provider.as_deref(), Some("openai"));
3000    }
3001
3002    // ─── ToolPolicy in tool_permissions ───────────────────────────────────
3003
3004    #[test]
3005    fn config_tool_permissions_allow() {
3006        let toml_content = r#"
3007default_provider = "anthropic"
3008agent_paths = []
3009
3010[providers]
3011
3012[tool_permissions]
3013read_file = "allow"
3014write_file = "ask"
3015bash = "deny"
3016"#;
3017        let config: Config = toml::from_str(toml_content).unwrap();
3018        assert_eq!(
3019            config.tool_permissions.get("read_file"),
3020            Some(&ToolPolicy::Allow)
3021        );
3022        assert_eq!(
3023            config.tool_permissions.get("write_file"),
3024            Some(&ToolPolicy::Ask)
3025        );
3026        assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
3027    }
3028
3029    // ─── Config with agent_paths ─────────────────────────────────────────
3030
3031    #[test]
3032    fn config_with_agent_paths() {
3033        let toml_content = r#"
3034default_provider = "anthropic"
3035agent_paths = ["/home/user/agents", "/opt/agents"]
3036
3037[providers]
3038"#;
3039        let config: Config = toml::from_str(toml_content).unwrap();
3040        assert_eq!(config.agent_paths.len(), 2);
3041    }
3042
3043    // ─── Config load() ────────────────────────────────────────────────────
3044
3045    #[test]
3046    fn config_load_from_nonexistent_path_returns_default() {
3047        // Config::load() uses a fixed path; we can test indirectly by
3048        // verifying defaults are applied when no file exists.
3049        // We can't easily override the path, but we can verify default behavior.
3050        let config = Config::default();
3051        assert_eq!(config.default_provider, "anthropic");
3052        assert!(config.providers.anthropic_api_key.is_none());
3053    }
3054
3055    #[test]
3056    fn config_load_from_toml_string() {
3057        // Test the TOML parsing path of load() by parsing directly.
3058        let toml_content = r#"
3059default_provider = "openai"
3060agent_paths = []
3061
3062[providers]
3063anthropic_api_key = "sk-ant-test-key"
3064"#;
3065        let config: Config = toml::from_str(toml_content).unwrap();
3066        assert_eq!(config.default_provider, "openai");
3067        assert_eq!(
3068            config.providers.anthropic_api_key.as_deref(),
3069            Some("sk-ant-test-key")
3070        );
3071        // No [nudge] section ⇒ every field unset ⇒ built-in defaults apply.
3072        assert_eq!(config.nudge, leviath_core::NudgeConfig::default());
3073    }
3074
3075    #[test]
3076    fn config_parses_partial_nudge_section() {
3077        // A [nudge] section only pins the keys it names.
3078        let config: Config = toml::from_str(
3079            r#"
3080default_provider = "openai"
3081agent_paths = []
3082
3083[providers]
3084
3085[nudge]
3086enabled = false
3087"#,
3088        )
3089        .unwrap();
3090        assert_eq!(config.nudge.enabled, Some(false));
3091        assert_eq!(config.nudge.max, None);
3092        assert_eq!(config.nudge.text, None);
3093    }
3094
3095    #[test]
3096    fn config_save_and_load_with_file() {
3097        // Test Config::save() by writing to a temp location manually.
3098        let dir = tempfile::tempdir().unwrap();
3099        let config_path = dir.path().join("config.toml");
3100
3101        let config = Config {
3102            default_provider: "openai".to_string(),
3103            providers: ProviderConfig {
3104                anthropic_api_key: Some("sk-ant-test".to_string()),
3105                openai_api_key: Some("sk-test".to_string()),
3106                google_api_key: None,
3107                claude_code_enabled: false,
3108                claude_code_binary: None,
3109                claude_code_effort: None,
3110                anthropic_cache_ttl: None,
3111                fallback_order: Vec::new(),
3112            },
3113            openrouter_api_key: Some("sk-or-test".to_string()),
3114            default_model: Some("gpt-5".to_string()),
3115            ..Config::default()
3116        };
3117
3118        let content = toml::to_string_pretty(&config).unwrap();
3119        std::fs::write(&config_path, &content).unwrap();
3120
3121        let loaded_content = std::fs::read_to_string(&config_path).unwrap();
3122        let loaded: Config = toml::from_str(&loaded_content).unwrap();
3123
3124        assert_eq!(loaded.default_provider, "openai");
3125        assert_eq!(
3126            loaded.providers.anthropic_api_key.as_deref(),
3127            Some("sk-ant-test")
3128        );
3129        assert_eq!(loaded.default_model.as_deref(), Some("gpt-5"));
3130    }
3131
3132    #[test]
3133    fn config_create_config_dir_creates_parent() {
3134        let dir = tempfile::tempdir().unwrap();
3135        let new_dir = dir.path().join("nested").join("config");
3136        // create_config_dir is private, but we test indirectly via filesystem
3137        std::fs::create_dir_all(&new_dir).unwrap();
3138        assert!(new_dir.exists());
3139    }
3140
3141    #[test]
3142    fn config_default_title_enabled() {
3143        let config = Config::default();
3144        assert!(config.title.enabled);
3145    }
3146
3147    #[test]
3148    fn config_serialize_with_all_options() {
3149        let mut model_caps = HashMap::new();
3150        model_caps.insert(
3151            "my-model".to_string(),
3152            ModelCapabilityOverride {
3153                supports_temperature: Some(true),
3154                supports_streaming: Some(true),
3155                supports_tools: Some(true),
3156                supports_system_prompt: Some(true),
3157                max_context_tokens: Some(8192),
3158                max_output_tokens: Some(4096),
3159            },
3160        );
3161        let mut tool_perms = HashMap::new();
3162        tool_perms.insert("bash".to_string(), ToolPolicy::Allow);
3163
3164        let config = Config {
3165            default_provider: "anthropic".to_string(),
3166            providers: ProviderConfig {
3167                anthropic_api_key: Some("sk-ant-key".to_string()),
3168                openai_api_key: None,
3169                google_api_key: None,
3170                claude_code_enabled: false,
3171                claude_code_binary: None,
3172                claude_code_effort: None,
3173                anthropic_cache_ttl: None,
3174                fallback_order: Vec::new(),
3175            },
3176            agent_paths: vec![std::path::PathBuf::from("/my/agents")],
3177            openrouter_api_key: None,
3178            ollama_base_url: Some("http://custom:11434".to_string()),
3179            mcp_servers: vec![],
3180            default_model: None,
3181            model_capabilities: model_caps,
3182            model_providers: HashMap::new(),
3183            tool_permissions: tool_perms,
3184            agent_tool_permissions: HashMap::new(),
3185            safe_commands: crate::approvals::SafeCommands::default(),
3186            agent_safe_commands: HashMap::new(),
3187            title: TitleConfig {
3188                enabled: false,
3189                provider: Some("openai".to_string()),
3190                model: Some("gpt-5-mini".to_string()),
3191            },
3192            request_timeout_secs: None,
3193            rate_limits: HashMap::new(),
3194            taint_tracking: false,
3195            limits: LimitsConfig {
3196                mcp_idle_disconnect_secs: default_mcp_idle_disconnect_secs(),
3197                max_tool_call_write_bytes: None,
3198                max_run_write_bytes: None,
3199                max_concurrent_inferences: Some(4),
3200                max_concurrent_tools: 3,
3201                default_max_iterations: Some(99),
3202                exact_token_counting: false,
3203                script_shell_timeout_secs: 45,
3204                stall_timeout_secs: 90,
3205                dead_cycles_before_relief: 6,
3206                finished_retention_secs: 120,
3207                wedge_timeout_secs: 420,
3208                provider_failures_before_open: 5,
3209                provider_circuit_cooldown_secs: 120,
3210                interaction_timeout_secs: 120,
3211            },
3212            batch_tool_hint: true,
3213            shell_hint: false,
3214            nudge: leviath_core::NudgeConfig {
3215                enabled: Some(true),
3216                max: Some(2),
3217                text: Some("Use your tools.".to_string()),
3218            },
3219            webhook: WebhookConfig {
3220                max_retries: 5,
3221                base_delay_ms: 250,
3222                max_delay_ms: 10_000,
3223                timeout_secs: 7,
3224            },
3225            observability: ObservabilityConfig {
3226                enabled: true,
3227                exporter: TelemetryExporterKind::Stdout,
3228                endpoint: Some("http://collector:4318".to_string()),
3229                service_name: Some("leviath-prod".to_string()),
3230            },
3231            sandbox: Some(leviath_core::ToolSandboxConfig {
3232                kind: leviath_core::SandboxKind::Container,
3233                image: Some("ubuntu:24.04".to_string()),
3234                network: false,
3235                ..Default::default()
3236            }),
3237            tool_script_permissions: ScriptToolPermissions {
3238                http_get: ScriptPermission::Allow,
3239                http_post: ScriptPermission::Deny,
3240                shell: ScriptPermission::Deny,
3241                read_file: ScriptPermission::Inherit,
3242                write_file: ScriptPermission::Deny,
3243                env_var: ScriptPermission::Allow,
3244            },
3245            security: SecurityConfig {
3246                allowed_workdirs: Vec::new(),
3247                allow_seed_commands: false,
3248                allow_local_network: true,
3249                allow_env_vars: vec!["MY_PROVIDER_KEY".to_string()],
3250                allow_blueprint_read_paths: true,
3251                allow_blueprint_safe_commands: true,
3252                read_paths: vec!["~/.leviath/runs".to_string()],
3253                credential_store: leviath_core::CredentialStoreKind::Keychain,
3254                allow_blueprint_permissions: false,
3255                shell_env: leviath_core::ShellEnvMode::default(),
3256                shell_env_withhold: Vec::new(),
3257            },
3258            agent_read_paths: HashMap::from([(
3259                "cto".to_string(),
3260                ReadPathGrants {
3261                    allow: vec!["glob:~/design-docs/**".to_string()],
3262                },
3263            )]),
3264        };
3265
3266        let serialized = toml::to_string_pretty(&config).unwrap();
3267        let deserialized: Config = toml::from_str(&serialized).unwrap();
3268
3269        assert_eq!(deserialized.default_provider, "anthropic");
3270        assert_eq!(deserialized.limits.max_concurrent_inferences, Some(4));
3271        assert_eq!(deserialized.limits.max_concurrent_tools, 3);
3272        assert_eq!(deserialized.limits.script_shell_timeout_secs, 45);
3273        assert_eq!(deserialized.limits.dead_cycles_before_relief, 6);
3274        assert_eq!(deserialized.limits.finished_retention_secs, 120);
3275        assert_eq!(
3276            deserialized.tool_script_permissions.http_get,
3277            ScriptPermission::Allow
3278        );
3279        assert_eq!(
3280            deserialized.tool_script_permissions.shell,
3281            ScriptPermission::Deny
3282        );
3283        assert_eq!(
3284            deserialized.tool_script_permissions.write_file,
3285            ScriptPermission::Deny
3286        );
3287        // `shell_hint` defaults to true, so a `false` surviving the round trip
3288        // is what proves the field is actually written and read back.
3289        assert!(deserialized.batch_tool_hint);
3290        assert!(!deserialized.shell_hint);
3291        assert!(!deserialized.security.allow_seed_commands);
3292        assert!(deserialized.security.allow_blueprint_read_paths);
3293        assert_eq!(deserialized.security.read_paths, vec!["~/.leviath/runs"]);
3294        assert_eq!(
3295            deserialized.agent_read_paths.get("cto"),
3296            Some(&ReadPathGrants {
3297                allow: vec!["glob:~/design-docs/**".to_string()],
3298            })
3299        );
3300        assert_eq!(
3301            deserialized.nudge,
3302            leviath_core::NudgeConfig {
3303                enabled: Some(true),
3304                max: Some(2),
3305                text: Some("Use your tools.".to_string()),
3306            }
3307        );
3308        assert_eq!(deserialized.webhook.max_retries, 5);
3309        assert_eq!(deserialized.webhook.base_delay_ms, 250);
3310        assert_eq!(deserialized.webhook.max_delay_ms, 10_000);
3311        assert_eq!(deserialized.webhook.timeout_secs, 7);
3312        assert!(deserialized.observability.enabled);
3313        assert_eq!(
3314            deserialized.observability.exporter,
3315            TelemetryExporterKind::Stdout
3316        );
3317        assert_eq!(
3318            deserialized.observability.endpoint.as_deref(),
3319            Some("http://collector:4318")
3320        );
3321        assert_eq!(
3322            deserialized.observability.service_name.as_deref(),
3323            Some("leviath-prod")
3324        );
3325        assert_eq!(deserialized.limits.default_max_iterations, Some(99));
3326        assert_eq!(
3327            deserialized.providers.anthropic_api_key.as_deref(),
3328            Some("sk-ant-key")
3329        );
3330        assert_eq!(deserialized.agent_paths.len(), 1);
3331        assert!(deserialized.model_capabilities.contains_key("my-model"));
3332        assert_eq!(
3333            deserialized.tool_permissions.get("bash"),
3334            Some(&ToolPolicy::Allow)
3335        );
3336        assert!(!deserialized.title.enabled);
3337        assert_eq!(deserialized.title.provider.as_deref(), Some("openai"));
3338        let sandbox = deserialized.sandbox.expect("sandbox round-trips");
3339        assert_eq!(sandbox.kind, leviath_core::SandboxKind::Container);
3340        assert_eq!(sandbox.image.as_deref(), Some("ubuntu:24.04"));
3341        assert!(!sandbox.network);
3342    }
3343
3344    // ─── Config with multiple model_capabilities ─────────────────────────
3345
3346    #[test]
3347    fn config_multiple_model_capabilities() {
3348        let toml_content = r#"
3349default_provider = "anthropic"
3350agent_paths = []
3351
3352[providers]
3353
3354[model_capabilities."model-a"]
3355supports_temperature = true
3356supports_streaming = true
3357supports_tools = true
3358supports_system_prompt = true
3359max_context_tokens = 8192
3360max_output_tokens = 4096
3361
3362[model_capabilities."model-b"]
3363supports_temperature = false
3364supports_streaming = false
3365supports_tools = false
3366supports_system_prompt = false
3367max_context_tokens = 2048
3368max_output_tokens = 1024
3369"#;
3370        let config: Config = toml::from_str(toml_content).unwrap();
3371        assert_eq!(config.model_capabilities.len(), 2);
3372        let caps_a = config.model_capabilities.get("model-a").unwrap();
3373        assert_eq!(caps_a.supports_temperature, Some(true));
3374        assert_eq!(caps_a.max_context_tokens, Some(8192));
3375        let caps_b = config.model_capabilities.get("model-b").unwrap();
3376        assert_eq!(caps_b.supports_temperature, Some(false));
3377        assert_eq!(caps_b.max_context_tokens, Some(2048));
3378    }
3379}