Skip to main content

codewhale_config/
lib.rs

1pub mod auth_source;
2pub mod catalog;
3mod harness;
4pub mod model_reference;
5pub mod models_dev;
6pub mod persistence;
7pub mod pricing;
8pub mod provider;
9mod provider_defaults;
10mod provider_kind;
11pub mod route;
12pub mod setup_state;
13pub mod user_constitution;
14pub use harness::{
15    HarnessCompactionStrategy, HarnessPosture, HarnessPostureKind, HarnessProfile,
16    HarnessSafetyPosture, HarnessToolSurface, built_in_harness_profiles,
17};
18pub use model_reference::{Modality, ModelReferenceCard, ModelReferenceDatabase};
19pub(crate) use provider_defaults::*;
20pub use provider_kind::ProviderKind;
21pub use setup_state::{
22    ConstitutionAuthoring, ConstitutionChoice, ConstitutionSource, ConstitutionValidity,
23    InheritedConfigFacts, RuntimePostureSource, SetupState, SetupStep, StepEntry, StepStatus,
24};
25pub use user_constitution::{
26    AutonomyPreference, UntrustedDraftParse, UserConstitution, UserConstitutionLoad,
27};
28
29use std::collections::{BTreeMap, BTreeSet};
30use std::ffi::{OsStr, OsString};
31use std::fmt;
32use std::fs;
33#[cfg(unix)]
34use std::io::Read;
35use std::io::Write;
36use std::path::{Component, Path, PathBuf};
37use std::sync::OnceLock;
38
39use anyhow::{Context, Result, bail};
40pub use auth_source::{AuthSourceKind, ProviderAuthSourceToml};
41pub use codewhale_execpolicy::ToolAskRule;
42use codewhale_execpolicy::{ExecPolicyEngine, Ruleset};
43use codewhale_secrets::SecretSource;
44pub use codewhale_secrets::Secrets;
45use serde::{Deserialize, Serialize};
46
47#[cfg(unix)]
48use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
49
50pub const CONFIG_FILE_NAME: &str = "config.toml";
51pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
52
53fn http_headers_are_effectively_empty(headers: &BTreeMap<String, String>) -> bool {
54    !headers
55        .iter()
56        .any(|(name, value)| !name.trim().is_empty() && !value.trim().is_empty())
57}
58
59/// Whether an HTTP header can carry the model provider's primary credential.
60///
61/// Header names are case-insensitive. Keeping this classifier in shared config
62/// prevents `auth_mode = "none"` from disabling a generated bearer token while
63/// still leaking the same credential through a configured alternate dialect.
64#[must_use]
65pub fn is_upstream_auth_header(name: &str) -> bool {
66    let name = name.trim();
67    // Configured gateways use more credential dialects than the three headers
68    // generated by Codewhale itself. `auth_mode = "none"` is an endpoint
69    // contract, so suppress every credential-shaped request header instead of
70    // allowing the same secret through Proxy-Authorization, X-Auth-Token,
71    // X-Access-Token, X-Goog-Api-Key, or another *-token/*-api-key spelling.
72    is_sensitive_config_key(name) || name.eq_ignore_ascii_case("cookie")
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, Default)]
76pub struct ProviderConfigToml {
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub api_key: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub base_url: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub model: Option<String>,
83    #[serde(
84        default,
85        skip_serializing_if = "Option::is_none",
86        alias = "contextWindow",
87        alias = "context_window_tokens",
88        alias = "contextWindowTokens",
89        alias = "context_length",
90        alias = "contextLength"
91    )]
92    pub context_window: Option<u32>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub mode: Option<String>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub auth_mode: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub insecure_skip_tls_verify: Option<bool>,
99    #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
100    pub http_headers: BTreeMap<String, String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub path_suffix: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub auth: Option<ProviderAuthSourceToml>,
105}
106
107impl ProviderConfigToml {
108    #[must_use]
109    pub fn is_empty(&self) -> bool {
110        let blank = |value: Option<&String>| value.is_none_or(|value| value.trim().is_empty());
111
112        blank(self.api_key.as_ref())
113            && blank(self.base_url.as_ref())
114            && blank(self.model.as_ref())
115            && self.context_window.is_none()
116            && blank(self.mode.as_ref())
117            && blank(self.auth_mode.as_ref())
118            && self.insecure_skip_tls_verify.is_none()
119            && http_headers_are_effectively_empty(&self.http_headers)
120            && blank(self.path_suffix.as_ref())
121            && self.auth.is_none()
122    }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, Default)]
126pub struct ProvidersToml {
127    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
128    pub deepseek: ProviderConfigToml,
129    #[serde(
130        default,
131        skip_serializing_if = "ProviderConfigToml::is_empty",
132        alias = "deepseek-anthropic",
133        alias = "deepseekAnthropic",
134        alias = "deepseek-claude",
135        alias = "deepseek_claude"
136    )]
137    pub deepseek_anthropic: ProviderConfigToml,
138    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
139    pub nvidia_nim: ProviderConfigToml,
140    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
141    pub openai: ProviderConfigToml,
142    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
143    pub atlascloud: ProviderConfigToml,
144    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
145    pub wanjie_ark: ProviderConfigToml,
146    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
147    pub volcengine: ProviderConfigToml,
148    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
149    pub openrouter: ProviderConfigToml,
150    #[serde(
151        default,
152        skip_serializing_if = "ProviderConfigToml::is_empty",
153        alias = "xiaomi",
154        alias = "mimo",
155        alias = "xiaomimimo"
156    )]
157    pub xiaomi_mimo: ProviderConfigToml,
158    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
159    pub novita: ProviderConfigToml,
160    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
161    pub fireworks: ProviderConfigToml,
162    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
163    pub siliconflow: ProviderConfigToml,
164    #[serde(
165        default,
166        skip_serializing_if = "ProviderConfigToml::is_empty",
167        alias = "siliconflow-CN",
168        alias = "siliconflow-cn"
169    )]
170    pub siliconflow_cn: ProviderConfigToml,
171    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
172    pub arcee: ProviderConfigToml,
173    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
174    pub moonshot: ProviderConfigToml,
175    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
176    pub sglang: ProviderConfigToml,
177    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
178    pub vllm: ProviderConfigToml,
179    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
180    pub ollama: ProviderConfigToml,
181    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
182    pub huggingface: ProviderConfigToml,
183    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
184    pub together: ProviderConfigToml,
185    #[serde(
186        default,
187        skip_serializing_if = "ProviderConfigToml::is_empty",
188        alias = "baidu-qianfan",
189        alias = "baidu_qianfan",
190        alias = "baidu"
191    )]
192    pub qianfan: ProviderConfigToml,
193    #[serde(
194        default,
195        skip_serializing_if = "ProviderConfigToml::is_empty",
196        alias = "openai-codex",
197        alias = "openai_codex",
198        alias = "codex",
199        alias = "chatgpt",
200        alias = "chatgpt-codex"
201    )]
202    pub openai_codex: ProviderConfigToml,
203    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
204    pub anthropic: ProviderConfigToml,
205    #[serde(
206        default,
207        skip_serializing_if = "ProviderConfigToml::is_empty",
208        alias = "open-model",
209        alias = "open_model"
210    )]
211    pub openmodel: ProviderConfigToml,
212    #[serde(
213        default,
214        skip_serializing_if = "ProviderConfigToml::is_empty",
215        alias = "z-ai",
216        alias = "z_ai",
217        alias = "z.ai",
218        alias = "zhipu",
219        alias = "zhipuai",
220        alias = "bigmodel",
221        alias = "big-model"
222    )]
223    pub zai: ProviderConfigToml,
224    #[serde(
225        default,
226        skip_serializing_if = "ProviderConfigToml::is_empty",
227        alias = "step-fun",
228        alias = "step_fun",
229        alias = "stepfun",
230        alias = "stepflash",
231        alias = "step-flash",
232        alias = "step_flash"
233    )]
234    pub stepfun: ProviderConfigToml,
235    #[serde(
236        default,
237        skip_serializing_if = "ProviderConfigToml::is_empty",
238        alias = "mini-max",
239        alias = "mini_max",
240        alias = "minimax"
241    )]
242    pub minimax: ProviderConfigToml,
243    #[serde(
244        default,
245        skip_serializing_if = "ProviderConfigToml::is_empty",
246        alias = "minimax-anthropic",
247        alias = "minimaxAnthropic",
248        alias = "mini-max-anthropic",
249        alias = "mini_max_anthropic"
250    )]
251    pub minimax_anthropic: ProviderConfigToml,
252    #[serde(
253        default,
254        skip_serializing_if = "ProviderConfigToml::is_empty",
255        alias = "deep-infra",
256        alias = "deep_infra"
257    )]
258    pub deepinfra: ProviderConfigToml,
259    #[serde(
260        default,
261        skip_serializing_if = "ProviderConfigToml::is_empty",
262        alias = "sakana-ai",
263        alias = "sakana_ai",
264        alias = "fugu"
265    )]
266    pub sakana: ProviderConfigToml,
267    #[serde(
268        default,
269        skip_serializing_if = "ProviderConfigToml::is_empty",
270        alias = "long-cat",
271        alias = "meituan-longcat",
272        alias = "meituan"
273    )]
274    pub longcat: ProviderConfigToml,
275    #[serde(
276        default,
277        skip_serializing_if = "ProviderConfigToml::is_empty",
278        alias = "meta-ai",
279        alias = "meta_ai",
280        alias = "meta-model-api",
281        alias = "meta_model_api",
282        alias = "muse",
283        alias = "muse-spark"
284    )]
285    pub meta: ProviderConfigToml,
286    #[serde(
287        default,
288        skip_serializing_if = "ProviderConfigToml::is_empty",
289        alias = "x-ai",
290        alias = "x_ai",
291        alias = "grok"
292    )]
293    pub xai: ProviderConfigToml,
294    /// Catch-all table for the dynamic OpenAI-compatible custom provider
295    /// identity (#1519). Arbitrary `[providers.<name>]` tables are handled by
296    /// the tui-side flatten map; this named slot keeps the canonical
297    /// `ProviderKind::Custom` lookups total without leaking into another
298    /// provider's config.
299    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
300    pub custom: ProviderConfigToml,
301}
302
303/// Sibling `permissions.toml` schema.
304///
305/// Each rule is a typed condition that can deny, allow, or ask before a tool
306/// invocation. UI actions that persist deny/allow rules are future work; the
307/// approval card still saves ask rules.
308#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
309#[serde(deny_unknown_fields)]
310pub struct PermissionsToml {
311    #[serde(default, skip_serializing_if = "Vec::is_empty")]
312    pub rules: Vec<ToolAskRule>,
313}
314
315impl PermissionsToml {
316    #[must_use]
317    pub fn is_empty(&self) -> bool {
318        self.rules.is_empty()
319    }
320
321    #[must_use]
322    pub fn ruleset(&self) -> Ruleset {
323        use codewhale_execpolicy::PermissionAction;
324        let mut denied = Vec::new();
325        let mut trusted = Vec::new();
326        let mut ask_rules = Vec::new();
327
328        for rule in &self.rules {
329            match rule.action {
330                PermissionAction::Deny => {
331                    // Command-based deny rules are promoted to denied_prefixes
332                    // so they are caught by execpolicy's deny-always-wins check.
333                    if let Some(cmd) = &rule.command {
334                        denied.push(cmd.clone());
335                    }
336                    // Always keep in ask_rules for path-based and tool-only matching.
337                    ask_rules.push(rule.clone());
338                }
339                PermissionAction::Allow => {
340                    // Command-based allow rules are promoted to trusted_prefixes
341                    // for arity-aware matching.  Path-only allow rules are
342                    // handled through ask_rules (they skip the approval prompt).
343                    if let Some(cmd) = &rule.command {
344                        trusted.push(cmd.clone());
345                    }
346                    // Keep in ask_rules so path-only allow rules also work.
347                    ask_rules.push(rule.clone());
348                }
349                PermissionAction::Ask => {
350                    ask_rules.push(rule.clone());
351                }
352            }
353        }
354
355        Ruleset::user(trusted, denied).with_ask_rules(ask_rules)
356    }
357}
358
359impl ProvidersToml {
360    #[must_use]
361    pub fn is_empty(&self) -> bool {
362        ProviderKind::all()
363            .iter()
364            .all(|provider| self.for_provider(*provider).is_empty())
365    }
366
367    #[must_use]
368    pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
369        match provider {
370            ProviderKind::Deepseek => &self.deepseek,
371            ProviderKind::DeepseekAnthropic => &self.deepseek_anthropic,
372            ProviderKind::NvidiaNim => &self.nvidia_nim,
373            ProviderKind::Openai => &self.openai,
374            ProviderKind::Atlascloud => &self.atlascloud,
375            ProviderKind::WanjieArk => &self.wanjie_ark,
376            ProviderKind::Volcengine => &self.volcengine,
377            ProviderKind::Openrouter => &self.openrouter,
378            ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
379            ProviderKind::Novita => &self.novita,
380            ProviderKind::Fireworks => &self.fireworks,
381            ProviderKind::Siliconflow => &self.siliconflow,
382            ProviderKind::SiliconflowCN => &self.siliconflow_cn,
383            ProviderKind::Arcee => &self.arcee,
384            ProviderKind::Moonshot => &self.moonshot,
385            ProviderKind::Sglang => &self.sglang,
386            ProviderKind::Vllm => &self.vllm,
387            ProviderKind::Ollama => &self.ollama,
388            ProviderKind::Huggingface => &self.huggingface,
389            ProviderKind::Together => &self.together,
390            ProviderKind::Qianfan => &self.qianfan,
391            ProviderKind::OpenaiCodex => &self.openai_codex,
392            ProviderKind::Anthropic => &self.anthropic,
393            ProviderKind::Openmodel => &self.openmodel,
394            ProviderKind::Zai => &self.zai,
395            ProviderKind::Stepfun => &self.stepfun,
396            ProviderKind::Minimax => &self.minimax,
397            ProviderKind::MinimaxAnthropic => &self.minimax_anthropic,
398            ProviderKind::Deepinfra => &self.deepinfra,
399            ProviderKind::Sakana => &self.sakana,
400            ProviderKind::LongCat => &self.longcat,
401            ProviderKind::Meta => &self.meta,
402            ProviderKind::Xai => &self.xai,
403            ProviderKind::Custom => &self.custom,
404        }
405    }
406
407    pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
408        match provider {
409            ProviderKind::Deepseek => &mut self.deepseek,
410            ProviderKind::DeepseekAnthropic => &mut self.deepseek_anthropic,
411            ProviderKind::NvidiaNim => &mut self.nvidia_nim,
412            ProviderKind::Openai => &mut self.openai,
413            ProviderKind::Atlascloud => &mut self.atlascloud,
414            ProviderKind::WanjieArk => &mut self.wanjie_ark,
415            ProviderKind::Volcengine => &mut self.volcengine,
416            ProviderKind::Openrouter => &mut self.openrouter,
417            ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
418            ProviderKind::Novita => &mut self.novita,
419            ProviderKind::Fireworks => &mut self.fireworks,
420            ProviderKind::Siliconflow => &mut self.siliconflow,
421            ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
422            ProviderKind::Arcee => &mut self.arcee,
423            ProviderKind::Moonshot => &mut self.moonshot,
424            ProviderKind::Sglang => &mut self.sglang,
425            ProviderKind::Vllm => &mut self.vllm,
426            ProviderKind::Ollama => &mut self.ollama,
427            ProviderKind::Huggingface => &mut self.huggingface,
428            ProviderKind::Together => &mut self.together,
429            ProviderKind::Qianfan => &mut self.qianfan,
430            ProviderKind::OpenaiCodex => &mut self.openai_codex,
431            ProviderKind::Anthropic => &mut self.anthropic,
432            ProviderKind::Openmodel => &mut self.openmodel,
433            ProviderKind::Zai => &mut self.zai,
434            ProviderKind::Stepfun => &mut self.stepfun,
435            ProviderKind::Minimax => &mut self.minimax,
436            ProviderKind::MinimaxAnthropic => &mut self.minimax_anthropic,
437            ProviderKind::Deepinfra => &mut self.deepinfra,
438            ProviderKind::Sakana => &mut self.sakana,
439            ProviderKind::LongCat => &mut self.longcat,
440            ProviderKind::Meta => &mut self.meta,
441            ProviderKind::Xai => &mut self.xai,
442            ProviderKind::Custom => &mut self.custom,
443        }
444    }
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize, Default)]
448pub struct ConfigToml {
449    /// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek`
450    /// and `codewhale-tui` can share a single config file.
451    pub api_key: Option<String>,
452    /// TUI-compatible DeepSeek base URL.
453    pub base_url: Option<String>,
454    /// Optional extra HTTP headers forwarded to model API requests.
455    #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
456    pub http_headers: BTreeMap<String, String>,
457    /// TUI-compatible default DeepSeek model.
458    pub default_text_model: Option<String>,
459    #[serde(default)]
460    pub provider: ProviderKind,
461    pub model: Option<String>,
462    pub auth_mode: Option<String>,
463    pub output_mode: Option<String>,
464    pub verbosity: Option<String>,
465    pub log_level: Option<String>,
466    pub telemetry: Option<bool>,
467    pub approval_policy: Option<String>,
468    pub sandbox_mode: Option<String>,
469    /// Native tool catalog controls shared with `codewhale-tui`.
470    #[serde(default)]
471    pub tools: Option<ToolsToml>,
472    #[serde(default, skip_serializing_if = "ProvidersToml::is_empty")]
473    pub providers: ProvidersToml,
474    /// Provider fallback chain (#2574). TUI runtime code may advance through
475    /// these providers after recoverable provider errors; config resolution
476    /// itself still reports the selected primary provider.
477    #[serde(default, skip_serializing_if = "Vec::is_empty")]
478    pub fallback_providers: Vec<ProviderKind>,
479    /// Per-domain network policy (#135). When absent, network tools fall back
480    /// to a permissive default that mirrors pre-v0.7.0 behavior.
481    #[serde(default)]
482    pub network: Option<NetworkPolicyToml>,
483    /// Verifier-preview behavior (#2093). When absent, verifier tools keep the
484    /// shipped defaults: disabled automatic preview and hunt verdict mapping.
485    #[serde(default)]
486    pub verifier: Option<VerifierConfigToml>,
487    /// Community skill installer settings (#140). Mirrors
488    /// [`SkillsToml`] from the TUI side; the dispatcher consults
489    /// `registry_url` when running `deepseek skill install`.
490    #[serde(default)]
491    pub skills: Option<SkillsToml>,
492    /// Workspace side-git snapshots (#137). The live TUI defaults this to
493    /// enabled with 7-day retention when absent.
494    #[serde(default)]
495    pub snapshots: Option<SnapshotsToml>,
496    /// Post-edit LSP diagnostics injection (#136). When absent, the engine
497    /// applies the defaults documented in [`LspConfigToml`].
498    #[serde(default)]
499    pub lsp: Option<LspConfigToml>,
500    /// Per-model harness profiles (#2693). Runtime wiring lands in follow-up
501    /// v0.9 slices; this is the durable config data model.
502    #[serde(default)]
503    pub harness_profiles: Vec<HarnessProfile>,
504    /// Optional 1-8 hotbar slot bindings (#2064). When absent, the TUI falls
505    /// back to the built-in default slots.
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub hotbar: Option<Vec<HotbarBindingToml>>,
508    /// App-server hook sink configuration. Kept separate from the TUI
509    /// lifecycle `[hooks]` table so config rewrites preserve existing hooks.
510    #[serde(default)]
511    pub hook_sinks: Option<HookSinksToml>,
512    /// Agent Fleet trust and security policy (#3165). When absent, fleet
513    /// workers inherit conservative Sandbox defaults.
514    #[serde(default)]
515    pub fleet: Option<FleetConfigToml>,
516    /// Workflow automatic-launch, approval, isolation, and activity
517    /// persistence knobs (#4128 / Section 2.11). When absent, consumers use
518    /// [`WorkflowConfigToml::default`].
519    #[serde(default)]
520    pub workflow: Option<WorkflowConfigToml>,
521    #[serde(flatten)]
522    pub extras: BTreeMap<String, toml::Value>,
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526enum ProviderConfigField {
527    ApiKey,
528    BaseUrl,
529    Model,
530    ContextWindow,
531    Mode,
532    AuthMode,
533    InsecureSkipTlsVerify,
534    HttpHeaders,
535    PathSuffix,
536}
537
538impl ProviderConfigField {
539    fn parse(key: &str) -> Option<Self> {
540        Some(match key {
541            "api_key" => Self::ApiKey,
542            "base_url" => Self::BaseUrl,
543            "model" => Self::Model,
544            "context_window" | "context_window_tokens" => Self::ContextWindow,
545            "mode" => Self::Mode,
546            "auth_mode" => Self::AuthMode,
547            "insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
548            "http_headers" => Self::HttpHeaders,
549            "path_suffix" => Self::PathSuffix,
550            _ => return None,
551        })
552    }
553
554    fn key(self) -> &'static str {
555        match self {
556            Self::ApiKey => "api_key",
557            Self::BaseUrl => "base_url",
558            Self::Model => "model",
559            Self::ContextWindow => "context_window",
560            Self::Mode => "mode",
561            Self::AuthMode => "auth_mode",
562            Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
563            Self::HttpHeaders => "http_headers",
564            Self::PathSuffix => "path_suffix",
565        }
566    }
567}
568
569fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
570    let suffix = key.strip_prefix("providers.")?;
571    let (provider_key, field_key) = suffix.split_once('.')?;
572    let field = ProviderConfigField::parse(field_key)?;
573    let provider = ProviderKind::ALL
574        .iter()
575        .copied()
576        .find(|kind| kind.provider().provider_config_key() == provider_key)?;
577    Some((provider, field))
578}
579
580fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
581    format!(
582        "providers.{}.{}",
583        provider.provider().provider_config_key(),
584        field.key()
585    )
586}
587
588fn get_provider_config_value(
589    config: &ProviderConfigToml,
590    field: ProviderConfigField,
591) -> Option<String> {
592    match field {
593        ProviderConfigField::ApiKey => config.api_key.clone(),
594        ProviderConfigField::BaseUrl => config.base_url.clone(),
595        ProviderConfigField::Model => config.model.clone(),
596        ProviderConfigField::ContextWindow => config.context_window.map(|value| value.to_string()),
597        ProviderConfigField::Mode => config.mode.clone(),
598        ProviderConfigField::AuthMode => config.auth_mode.clone(),
599        ProviderConfigField::InsecureSkipTlsVerify => config
600            .insecure_skip_tls_verify
601            .map(|value| value.to_string()),
602        ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
603        ProviderConfigField::PathSuffix => config.path_suffix.clone(),
604    }
605}
606
607fn get_provider_config_display_value(
608    config: &ProviderConfigToml,
609    field: ProviderConfigField,
610) -> Option<String> {
611    match field {
612        ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
613        ProviderConfigField::HttpHeaders => {
614            serialize_http_headers_for_display(&config.http_headers)
615        }
616        _ => get_provider_config_value(config, field),
617    }
618}
619
620fn parse_context_window(value: &str) -> Result<u32> {
621    let parsed = value.trim().parse::<u32>().with_context(|| {
622        format!("invalid context_window '{value}': expected a positive token count")
623    })?;
624    if parsed == 0 {
625        bail!("context_window must be greater than 0");
626    }
627    Ok(parsed)
628}
629
630fn set_provider_config_value(
631    config: &mut ConfigToml,
632    provider: ProviderKind,
633    field: ProviderConfigField,
634    value: &str,
635) -> Result<()> {
636    match field {
637        ProviderConfigField::ApiKey => {
638            let value = value.to_string();
639            config.providers.for_provider_mut(provider).api_key = Some(value.clone());
640            if provider == ProviderKind::Deepseek {
641                config.api_key = Some(value);
642            }
643        }
644        ProviderConfigField::BaseUrl => {
645            let value = value.to_string();
646            config.providers.for_provider_mut(provider).base_url = Some(value.clone());
647            if provider == ProviderKind::Deepseek {
648                config.base_url = Some(value);
649            }
650        }
651        ProviderConfigField::Model => {
652            let value = value.to_string();
653            config.providers.for_provider_mut(provider).model = Some(value.clone());
654            if provider == ProviderKind::Deepseek {
655                config.default_text_model = Some(value);
656            }
657        }
658        ProviderConfigField::ContextWindow => {
659            config.providers.for_provider_mut(provider).context_window =
660                Some(parse_context_window(value)?);
661        }
662        ProviderConfigField::Mode => {
663            config.providers.for_provider_mut(provider).mode = Some(value.to_string());
664        }
665        ProviderConfigField::AuthMode => {
666            config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
667        }
668        ProviderConfigField::InsecureSkipTlsVerify => {
669            config
670                .providers
671                .for_provider_mut(provider)
672                .insecure_skip_tls_verify = Some(parse_bool(value)?);
673        }
674        ProviderConfigField::HttpHeaders => {
675            let headers = parse_http_headers(value)?;
676            config.providers.for_provider_mut(provider).http_headers = headers.clone();
677            if provider == ProviderKind::Deepseek {
678                config.http_headers = headers;
679            }
680        }
681        ProviderConfigField::PathSuffix => {
682            config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
683        }
684    }
685    Ok(())
686}
687
688fn unset_provider_config_value(
689    config: &mut ConfigToml,
690    provider: ProviderKind,
691    field: ProviderConfigField,
692) {
693    match field {
694        ProviderConfigField::ApiKey => {
695            config.providers.for_provider_mut(provider).api_key = None;
696            if provider == ProviderKind::Deepseek {
697                config.api_key = None;
698            }
699        }
700        ProviderConfigField::BaseUrl => {
701            config.providers.for_provider_mut(provider).base_url = None;
702            if provider == ProviderKind::Deepseek {
703                config.base_url = None;
704            }
705        }
706        ProviderConfigField::Model => {
707            config.providers.for_provider_mut(provider).model = None;
708            if provider == ProviderKind::Deepseek {
709                config.default_text_model = None;
710            }
711        }
712        ProviderConfigField::ContextWindow => {
713            config.providers.for_provider_mut(provider).context_window = None;
714        }
715        ProviderConfigField::Mode => {
716            config.providers.for_provider_mut(provider).mode = None;
717        }
718        ProviderConfigField::AuthMode => {
719            config.providers.for_provider_mut(provider).auth_mode = None;
720        }
721        ProviderConfigField::InsecureSkipTlsVerify => {
722            config
723                .providers
724                .for_provider_mut(provider)
725                .insecure_skip_tls_verify = None;
726        }
727        ProviderConfigField::HttpHeaders => {
728            config
729                .providers
730                .for_provider_mut(provider)
731                .http_headers
732                .clear();
733            if provider == ProviderKind::Deepseek {
734                config.http_headers.clear();
735            }
736        }
737        ProviderConfigField::PathSuffix => {
738            config.providers.for_provider_mut(provider).path_suffix = None;
739        }
740    }
741}
742
743fn insert_provider_config_values(
744    out: &mut BTreeMap<String, String>,
745    provider: ProviderKind,
746    config: &ProviderConfigToml,
747) {
748    if let Some(v) = config.api_key.as_ref() {
749        out.insert(
750            provider_config_key(provider, ProviderConfigField::ApiKey),
751            redact_secret(v),
752        );
753    }
754    if let Some(v) = config.base_url.as_ref() {
755        out.insert(
756            provider_config_key(provider, ProviderConfigField::BaseUrl),
757            v.clone(),
758        );
759    }
760    if let Some(v) = config.model.as_ref() {
761        out.insert(
762            provider_config_key(provider, ProviderConfigField::Model),
763            v.clone(),
764        );
765    }
766    if let Some(v) = config.context_window {
767        out.insert(
768            provider_config_key(provider, ProviderConfigField::ContextWindow),
769            v.to_string(),
770        );
771    }
772    if let Some(v) = config.mode.as_ref() {
773        out.insert(
774            provider_config_key(provider, ProviderConfigField::Mode),
775            v.clone(),
776        );
777    }
778    if let Some(v) = config.auth_mode.as_ref() {
779        out.insert(
780            provider_config_key(provider, ProviderConfigField::AuthMode),
781            v.clone(),
782        );
783    }
784    if let Some(v) = config.insecure_skip_tls_verify {
785        out.insert(
786            provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
787            v.to_string(),
788        );
789    }
790    if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
791        out.insert(
792            provider_config_key(provider, ProviderConfigField::HttpHeaders),
793            v,
794        );
795    }
796    if let Some(v) = config.path_suffix.as_ref() {
797        out.insert(
798            provider_config_key(provider, ProviderConfigField::PathSuffix),
799            v.clone(),
800        );
801    }
802}
803
804impl ConfigToml {
805    /// Resolve the first configured harness profile for a provider/model route.
806    ///
807    /// This helper is deliberately dormant for v0.9: callers may display or
808    /// test the resolved profile, but runtime provider/model routing and prompt
809    /// shaping remain unchanged until a later, explicit integration slice.
810    #[must_use]
811    pub fn resolve_harness_profile(
812        &self,
813        provider_route: &str,
814        model: &str,
815    ) -> Option<&HarnessProfile> {
816        self.harness_profiles
817            .iter()
818            .chain(built_in_harness_profiles().iter())
819            .find(|profile| profile.matches_route(provider_route, model))
820    }
821
822    /// Resolve durable hotbar config into normalized 1-8 slot bindings.
823    ///
824    /// `known_action_ids` is supplied by the TUI action registry in later
825    /// slices. Unknown actions are preserved so the UI can render a disabled
826    /// `?` cell instead of silently deleting user config.
827    #[must_use]
828    pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
829        resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
830    }
831}
832
833/// Ordered primary-plus-fallback provider list for future provider routing.
834///
835/// The helper is intentionally dormant: constructing or parsing a chain does
836/// not change [`ConfigToml::resolve_runtime_options`].
837#[derive(Debug, Clone, PartialEq, Eq)]
838pub struct ProviderChain {
839    providers: Vec<ProviderKind>,
840    position: usize,
841}
842
843pub const HOTBAR_SLOT_COUNT: u8 = 8;
844
845pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
846    "voice.toggle",
847    "session.compact",
848    "mode.plan",
849    "mode.agent",
850    "mode.operate",
851    "palette.open",
852    "sidebar.toggle",
853    "trust.toggle",
854];
855
856/// On-disk schema for one `[[hotbar]]` table.
857#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
858#[serde(deny_unknown_fields)]
859pub struct HotbarBindingToml {
860    pub slot: u8,
861    pub action: String,
862    #[serde(default)]
863    pub label: Option<String>,
864}
865
866/// Validated hotbar binding used by future render/dispatch layers.
867#[derive(Debug, Clone, PartialEq, Eq)]
868pub struct HotbarBinding {
869    pub slot: u8,
870    pub action: String,
871    pub label: Option<String>,
872}
873
874/// Non-fatal hotbar config issue. Invalid slots are skipped; duplicate slots
875/// use the last binding; unknown actions are kept for UI feedback.
876#[derive(Debug, Clone, PartialEq, Eq)]
877pub enum HotbarConfigWarning {
878    SlotOutOfRange {
879        slot: u8,
880        action: String,
881    },
882    DuplicateSlot {
883        slot: u8,
884        previous_action: String,
885        replacement_action: String,
886    },
887    UnknownAction {
888        slot: u8,
889        action: String,
890    },
891}
892
893impl fmt::Display for HotbarConfigWarning {
894    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
895        match self {
896            Self::SlotOutOfRange { slot, action } => write!(
897                f,
898                "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
899            ),
900            Self::DuplicateSlot {
901                slot,
902                previous_action,
903                replacement_action,
904            } => write!(
905                f,
906                "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
907            ),
908            Self::UnknownAction { slot, action } => write!(
909                f,
910                "hotbar slot {slot} references unknown action '{action}'; keeping binding"
911            ),
912        }
913    }
914}
915
916#[derive(Debug, Clone, PartialEq, Eq)]
917pub struct HotbarConfigResolution {
918    pub bindings: Vec<HotbarBinding>,
919    pub warnings: Vec<HotbarConfigWarning>,
920}
921
922#[must_use]
923pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
924    DEFAULT_HOTBAR_ACTIONS
925        .iter()
926        .enumerate()
927        .map(|(idx, action)| HotbarBinding {
928            slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
929            action: (*action).to_string(),
930            label: None,
931        })
932        .collect()
933}
934
935/// The default hotbar slots in on-disk (`[[hotbar]]`) form. Since #3807 an
936/// absent `hotbar` key means "hidden", so `/hotbar on` persists these explicit
937/// bindings rather than deleting the key. Kept in terms of
938/// [`default_hotbar_bindings`] so `DEFAULT_HOTBAR_ACTIONS` stays the single
939/// source of truth.
940#[must_use]
941pub fn default_hotbar_bindings_toml() -> Vec<HotbarBindingToml> {
942    default_hotbar_bindings()
943        .into_iter()
944        .map(|binding| HotbarBindingToml {
945            slot: binding.slot,
946            action: binding.action,
947            label: binding.label,
948        })
949        .collect()
950}
951
952#[must_use]
953pub fn resolve_hotbar_bindings(
954    configured: Option<&[HotbarBindingToml]>,
955    known_action_ids: &[&str],
956) -> HotbarConfigResolution {
957    let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
958    let mut warnings = Vec::new();
959
960    let source = match configured {
961        Some(bindings) => bindings
962            .iter()
963            .map(|binding| HotbarBinding {
964                slot: binding.slot,
965                action: binding.action.clone(),
966                label: binding.label.clone(),
967            })
968            .collect::<Vec<_>>(),
969        // #3807: an absent `hotbar` key means the Hotbar is hidden until the
970        // user opts in (via the setup wizard or `/hotbar on`). Only an explicit
971        // `[[hotbar]]` config produces bindings. `Some([])` stays "disabled".
972        None => Vec::new(),
973    };
974
975    let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
976    for binding in source {
977        if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
978            warnings.push(HotbarConfigWarning::SlotOutOfRange {
979                slot: binding.slot,
980                action: binding.action,
981            });
982            continue;
983        }
984        if !known.is_empty() && !known.contains(binding.action.as_str()) {
985            warnings.push(HotbarConfigWarning::UnknownAction {
986                slot: binding.slot,
987                action: binding.action.clone(),
988            });
989        }
990        if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
991            warnings.push(HotbarConfigWarning::DuplicateSlot {
992                slot: binding.slot,
993                previous_action: previous.action,
994                replacement_action: binding.action,
995            });
996        }
997    }
998
999    HotbarConfigResolution {
1000        bindings: by_slot.into_values().collect(),
1001        warnings,
1002    }
1003}
1004
1005impl ProviderChain {
1006    #[must_use]
1007    pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
1008        let mut providers = vec![active];
1009        for fallback in fallbacks {
1010            if *fallback != active && !providers.contains(fallback) {
1011                providers.push(*fallback);
1012            }
1013        }
1014        Self {
1015            providers,
1016            position: 0,
1017        }
1018    }
1019
1020    #[must_use]
1021    pub fn providers(&self) -> &[ProviderKind] {
1022        &self.providers
1023    }
1024
1025    #[must_use]
1026    pub fn position(&self) -> usize {
1027        self.position
1028    }
1029
1030    #[must_use]
1031    pub fn current(&self) -> ProviderKind {
1032        self.providers
1033            .get(self.position)
1034            .copied()
1035            .or_else(|| self.providers.first().copied())
1036            .unwrap_or_default()
1037    }
1038
1039    #[must_use]
1040    pub fn has_next(&self) -> bool {
1041        self.position + 1 < self.providers.len()
1042    }
1043
1044    pub fn advance(&mut self) -> Option<ProviderKind> {
1045        if !self.has_next() {
1046            return None;
1047        }
1048        self.position += 1;
1049        Some(self.current())
1050    }
1051
1052    pub fn reset(&mut self) {
1053        self.position = 0;
1054    }
1055
1056    #[must_use]
1057    pub fn is_fallback_active(&self) -> bool {
1058        self.position > 0
1059    }
1060
1061    /// Count the current provider plus untried chain entries.
1062    #[must_use]
1063    pub fn remaining(&self) -> usize {
1064        self.providers.len() - self.position
1065    }
1066}
1067
1068#[cfg(test)]
1069mod provider_chain_tests {
1070    use super::*;
1071
1072    #[test]
1073    fn current_on_empty_chain_returns_default_provider() {
1074        let chain = ProviderChain {
1075            providers: vec![],
1076            position: 0,
1077        };
1078        assert_eq!(chain.current(), ProviderKind::default());
1079    }
1080}
1081
1082/// On-disk schema for the `[hook_sinks]` table.
1083#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1084pub struct HookSinksToml {
1085    /// Unix domain socket path used by the app-server event sink.
1086    ///
1087    /// When unset, no Unix socket sink is registered. There is deliberately no
1088    /// shared `/tmp` default because socket ownership should be explicit.
1089    #[serde(default)]
1090    pub unix_socket_path: Option<PathBuf>,
1091}
1092
1093/// On-disk schema for the `[skills]` table (#140). See `config.example.toml`
1094/// for documentation.
1095#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1096pub struct SkillsToml {
1097    /// Curated registry index URL. When unset, the TUI falls back to the
1098    /// bundled default (community-curated GitHub raw).
1099    #[serde(default)]
1100    pub registry_url: Option<String>,
1101    /// Per-skill maximum *uncompressed* size in bytes. When unset, the TUI
1102    /// uses 5 MiB.
1103    #[serde(default)]
1104    pub max_install_size_bytes: Option<u64>,
1105}
1106
1107/// On-disk schema for the `[tools]` table (#2076).
1108#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1109pub struct ToolsToml {
1110    /// Native tool names to keep loaded outside the default core catalog.
1111    #[serde(default)]
1112    pub always_load: Vec<String>,
1113}
1114
1115/// On-disk schema for the `[snapshots]` table (#137). See
1116/// `config.example.toml` for documentation.
1117#[derive(Debug, Clone, Serialize, Deserialize)]
1118pub struct SnapshotsToml {
1119    #[serde(default = "default_snapshots_enabled")]
1120    pub enabled: bool,
1121    #[serde(default = "default_snapshot_max_age_days")]
1122    pub max_age_days: u64,
1123}
1124
1125fn default_snapshots_enabled() -> bool {
1126    true
1127}
1128
1129fn default_snapshot_max_age_days() -> u64 {
1130    7
1131}
1132
1133impl Default for SnapshotsToml {
1134    fn default() -> Self {
1135        Self {
1136            enabled: default_snapshots_enabled(),
1137            max_age_days: default_snapshot_max_age_days(),
1138        }
1139    }
1140}
1141
1142/// On-disk schema for the `[fleet]` table (#3165). See `config.example.toml`
1143/// and `docs/FLEET.md` for documentation.
1144#[derive(Debug, Clone, Serialize, Deserialize)]
1145pub struct FleetConfigToml {
1146    /// Default trust level for fleet workers. One of `"sandbox"`, `"local"`,
1147    /// `"remote-verified"`, or `"operator"`. Defaults to `"sandbox"`.
1148    #[serde(default = "default_fleet_trust_level_str")]
1149    pub default_trust_level: String,
1150    /// Require identity verification for remote (SSH) workers before
1151    /// granting them `remote-verified` trust. Defaults to true.
1152    #[serde(default = "default_fleet_require_identity")]
1153    pub require_identity_verification: bool,
1154    /// Maximum trust level any worker may have (`"sandbox"`, `"local"`,
1155    /// `"remote-verified"`, or `"operator"`). Defaults to `"operator"`.
1156    #[serde(default = "default_fleet_max_trust_level_str")]
1157    pub max_trust_level: String,
1158    /// User-defined and built-in role presets.
1159    ///
1160    /// Each role defines default tool profiles, capabilities, budgets, and
1161    /// trust settings that task specs can reference by name. Built-in roles
1162    /// (`smoke-runner`, `reviewer`, `builder`, `read-only`) are always
1163    /// available; user-defined roles in config override or extend them.
1164    #[serde(default)]
1165    pub roles: BTreeMap<String, FleetRolePreset>,
1166    /// Fleet profile vocabulary (#3167). Profiles group role semantics,
1167    /// loadout hints, permission defaults, and delegation bounds. They are
1168    /// config-only in this slice; executor/model routing wiring lands later.
1169    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1170    pub profiles: BTreeMap<String, FleetProfile>,
1171    /// Headless worker execution hardening (#3027).
1172    #[serde(default)]
1173    pub exec: FleetExecConfig,
1174}
1175
1176/// Canonical recursion-depth policy for the headless worker runtime.
1177///
1178/// Single source of truth shared by BOTH standalone sub-agents and fleet
1179/// workers so the two cannot drift into "two moving targets":
1180/// - [`DEFAULT_SPAWN_DEPTH`] is the default recursion budget (the sub-agent
1181///   runtime's `DEFAULT_MAX_SPAWN_DEPTH` is defined as this value).
1182/// - [`MAX_SPAWN_DEPTH_CEILING`] is the opt-in safety cap; every configured
1183///   value (fleet `max_spawn_depth`, the `agent` tool's `max_depth`) clamps to it.
1184///
1185/// A worker runs at `spawn_depth = 0` and may spawn while
1186/// `spawn_depth + 1 <= max_spawn_depth`, so a depth of N affords N nested
1187/// delegation levels below the root worker. The default of 3 affords at least
1188/// three recursion levels out of the box; the root worker still runs at
1189/// depth 0 even when the budget is 0.
1190pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
1191pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 900;
1192pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
1193pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
1194
1195/// Hard ceiling on recursion depth for any worker/sub-agent. The default stays
1196/// conservative at [`DEFAULT_SPAWN_DEPTH`], while explicit config can opt into
1197/// deeper trees for direct-API providers that can tolerate the fanout.
1198/// Raising this single constant lifts the limit everywhere (the fleet clamp
1199/// and `agent` validation both read it).
1200pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
1201
1202/// Headless worker execution constraints (#3027).
1203///
1204/// These limits apply to all fleet workers and sub-agents spawned through
1205/// the headless worker runtime. Task specs can tighten but not loosen them.
1206#[derive(Debug, Clone, Serialize, Deserialize)]
1207pub struct FleetExecConfig {
1208    /// Tools that are always allowed regardless of role or task spec.
1209    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1210    pub allowed_tools: Vec<String>,
1211    /// Tools that are always disallowed, overriding role and task spec.
1212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1213    pub disallowed_tools: Vec<String>,
1214    /// Hard ceiling on sub-agent steps (tool calls + model turns).
1215    /// Workers that exceed this are terminated. Default: unbounded (u32::MAX).
1216    #[serde(default = "default_fleet_max_turns")]
1217    pub max_turns: u32,
1218    /// Recursive child-agent budget for headless fleet workers.
1219    /// Defaults to [`DEFAULT_SPAWN_DEPTH`] (3) so a fleet worker has the SAME
1220    /// recursion budget as a standalone sub-agent — fleet and sub-agents are one
1221    /// substrate, not two. Set 0 to block child `agent` calls (the root worker
1222    /// still runs); the value is clamped to [`MAX_SPAWN_DEPTH_CEILING`].
1223    #[serde(default = "default_fleet_max_spawn_depth")]
1224    pub max_spawn_depth: u32,
1225    /// Extra system prompt text appended to every headless worker.
1226    /// Useful for injecting org-wide policy or behavior constraints.
1227    #[serde(default, skip_serializing_if = "String::is_empty")]
1228    pub append_system_prompt: String,
1229    /// Output format for fleet worker results.
1230    /// `"text"` (default) or `"stream-json"` for newline-delimited JSON events.
1231    #[serde(default = "default_fleet_output_format")]
1232    pub output_format: String,
1233}
1234
1235fn default_fleet_max_turns() -> u32 {
1236    u32::MAX
1237}
1238
1239fn default_fleet_max_spawn_depth() -> u32 {
1240    DEFAULT_SPAWN_DEPTH
1241}
1242
1243fn default_fleet_output_format() -> String {
1244    "text".to_string()
1245}
1246
1247impl Default for FleetExecConfig {
1248    fn default() -> Self {
1249        Self {
1250            allowed_tools: Vec::new(),
1251            disallowed_tools: Vec::new(),
1252            max_turns: default_fleet_max_turns(),
1253            max_spawn_depth: default_fleet_max_spawn_depth(),
1254            append_system_prompt: String::new(),
1255            output_format: default_fleet_output_format(),
1256        }
1257    }
1258}
1259
1260/// Fleet org-chart profile.
1261///
1262/// A profile is an additive config record for future fleet scheduling policy.
1263/// Loading one must not grant runtime permissions by itself: shell and trust
1264/// escalation default off, and approvals default on.
1265#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1266pub struct FleetProfile {
1267    /// Org-chart slot this profile describes.
1268    #[serde(default)]
1269    pub slot: FleetSlot,
1270    /// Semantic role name and optional instruction overlay.
1271    #[serde(default)]
1272    pub role: FleetRole,
1273    /// Model class / route-role hint. This is data only in this slice.
1274    #[serde(default)]
1275    pub loadout: FleetLoadout,
1276    /// Optional explicit model id for this profile on the active/resolved route.
1277    ///
1278    /// This is not an auth or endpoint selector. Provider-scoped routing still
1279    /// validates the executable provider/model/wire-model decision.
1280    #[serde(default, skip_serializing_if = "Option::is_none")]
1281    pub model: Option<String>,
1282    /// Optional explicit provider id for this profile's model (#4093).
1283    ///
1284    /// Present only when the profile was created against a specific,
1285    /// credential-checked provider (e.g. via the Fleet setup model picker),
1286    /// so a worker can be pinned to a route independent of the parent/current
1287    /// session provider. `None` means "no route pin" (inherit), matching
1288    /// `model: None`; a profile must never carry `provider` without `model`.
1289    ///
1290    /// EPIC #2608 explicit-config-only mandate: this field is the ONLY
1291    /// authority for the profile's provider. It is never inferred by sniffing
1292    /// a substring/prefix out of `model` — callers that need the provider for
1293    /// this profile must read this field, not guess from the model id.
1294    #[serde(default, skip_serializing_if = "Option::is_none")]
1295    pub provider: Option<String>,
1296    /// Optional explicit reasoning/thinking tier for this profile (#4137).
1297    ///
1298    /// This is a safe, non-secret route tuning value. `None` means inherit the
1299    /// operator/session reasoning tier. Concrete values are normalized by the
1300    /// TUI loader before they are used at runtime.
1301    #[serde(default, skip_serializing_if = "Option::is_none")]
1302    pub reasoning_effort: Option<String>,
1303    /// Permission defaults requested by the profile.
1304    #[serde(default)]
1305    pub permissions: FleetProfilePermissions,
1306    /// Delegation hints for future manager policy.
1307    #[serde(default)]
1308    pub delegation: FleetDelegationHints,
1309}
1310
1311/// Semantic role declaration for a fleet profile.
1312///
1313/// TOML may use either `role = "reviewer"` or a role table with `name` and
1314/// `instructions`.
1315#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
1316pub struct FleetRole {
1317    /// Stable role name, e.g. `scout`, `implementer`, or `verifier`.
1318    pub name: String,
1319    /// Optional short description for config UIs and docs.
1320    #[serde(default, skip_serializing_if = "Option::is_none")]
1321    pub description: Option<String>,
1322    /// Optional instruction overlay to apply when the role is later consumed.
1323    #[serde(default, skip_serializing_if = "Option::is_none")]
1324    pub instructions: Option<String>,
1325}
1326
1327impl Default for FleetRole {
1328    fn default() -> Self {
1329        Self {
1330            name: "general".to_string(),
1331            description: None,
1332            instructions: None,
1333        }
1334    }
1335}
1336
1337impl<'de> Deserialize<'de> for FleetRole {
1338    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1339    where
1340        D: serde::Deserializer<'de>,
1341    {
1342        #[derive(Deserialize)]
1343        #[serde(untagged)]
1344        enum FleetRoleWire {
1345            Name(String),
1346            Full {
1347                #[serde(default)]
1348                name: Option<String>,
1349                #[serde(default)]
1350                description: Option<String>,
1351                #[serde(default)]
1352                instructions: Option<String>,
1353            },
1354        }
1355
1356        match FleetRoleWire::deserialize(deserializer)? {
1357            FleetRoleWire::Name(name) => Ok(Self {
1358                name,
1359                ..Self::default()
1360            }),
1361            FleetRoleWire::Full {
1362                name,
1363                description,
1364                instructions,
1365            } => Ok(Self {
1366                name: name.unwrap_or_else(|| Self::default().name),
1367                description,
1368                instructions,
1369            }),
1370        }
1371    }
1372}
1373
1374/// Org-chart slot for grouping fleet profiles.
1375#[derive(Debug, Clone, PartialEq, Eq, Default)]
1376pub enum FleetSlot {
1377    Manager,
1378    Scout,
1379    Implementer,
1380    Reviewer,
1381    Verifier,
1382    Operator,
1383    Summarizer,
1384    #[default]
1385    General,
1386    Custom(String),
1387}
1388
1389impl FleetSlot {
1390    #[must_use]
1391    pub fn as_str(&self) -> &str {
1392        match self {
1393            Self::Manager => "manager",
1394            Self::Scout => "scout",
1395            Self::Implementer => "implementer",
1396            Self::Reviewer => "reviewer",
1397            Self::Verifier => "verifier",
1398            Self::Operator => "operator",
1399            Self::Summarizer => "summarizer",
1400            Self::General => "general",
1401            Self::Custom(value) => value.as_str(),
1402        }
1403    }
1404
1405    #[must_use]
1406    pub fn from_name(value: &str) -> Self {
1407        match value.trim() {
1408            "manager" | "coordinator" => Self::Manager,
1409            "scout" | "research" | "research-worker" => Self::Scout,
1410            "implementer" | "builder" => Self::Implementer,
1411            "reviewer" => Self::Reviewer,
1412            "verifier" | "tester" => Self::Verifier,
1413            "operator" | "incident" | "incident-worker" => Self::Operator,
1414            "summarizer" | "reducer" => Self::Summarizer,
1415            "general" | "" => Self::General,
1416            // Removed slots (e.g. the old "tool-heavy") and unknown names parse
1417            // as Custom, which dispatches on the General surface — identical to
1418            // the behavior the removed variants had.
1419            other => Self::Custom(other.to_string()),
1420        }
1421    }
1422}
1423
1424impl Serialize for FleetSlot {
1425    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1426    where
1427        S: serde::Serializer,
1428    {
1429        serializer.serialize_str(self.as_str())
1430    }
1431}
1432
1433impl<'de> Deserialize<'de> for FleetSlot {
1434    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1435    where
1436        D: serde::Deserializer<'de>,
1437    {
1438        let value = String::deserialize(deserializer)?;
1439        Ok(Self::from_name(&value))
1440    }
1441}
1442
1443/// Model class or route-role hint for a profile.
1444#[derive(Debug, Clone, PartialEq, Eq, Default)]
1445pub enum FleetLoadout {
1446    /// Reuse the active session route (the operator's model). Default.
1447    #[default]
1448    Inherit,
1449    /// Route to the provider's faster/cheaper model class for wide fan-out.
1450    Fast,
1451    /// Unrecognized loadout names parse here (including the retired
1452    /// strong/balanced/deep-reasoning/code/review/tool-heavy tiers, which
1453    /// never routed differently). Treated as auto routing.
1454    Custom(String),
1455}
1456
1457impl FleetLoadout {
1458    #[must_use]
1459    pub fn as_str(&self) -> &str {
1460        match self {
1461            Self::Inherit => "inherit",
1462            Self::Fast => "fast",
1463            Self::Custom(value) => value.as_str(),
1464        }
1465    }
1466
1467    #[must_use]
1468    pub fn from_name(value: &str) -> Self {
1469        match value.trim() {
1470            "inherit" | "default" | "auto" | "" => Self::Inherit,
1471            "fast" => Self::Fast,
1472            // Retired tiers (strong/balanced/deep-reasoning/code/review/
1473            // tool-heavy) and unknown names parse as Custom → auto routing,
1474            // exactly what those tiers resolved to before removal.
1475            other => Self::Custom(other.to_string()),
1476        }
1477    }
1478}
1479
1480impl Serialize for FleetLoadout {
1481    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1482    where
1483        S: serde::Serializer,
1484    {
1485        serializer.serialize_str(self.as_str())
1486    }
1487}
1488
1489impl<'de> Deserialize<'de> for FleetLoadout {
1490    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1491    where
1492        D: serde::Deserializer<'de>,
1493    {
1494        let value = String::deserialize(deserializer)?;
1495        Ok(Self::from_name(&value))
1496    }
1497}
1498
1499/// Safe permission defaults attached to a fleet profile.
1500#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1501pub struct FleetProfilePermissions {
1502    /// Permit shell-capable tools for this profile when later consumed.
1503    #[serde(default)]
1504    pub allow_shell: bool,
1505    /// Permit trusted/elevated execution for this profile when later consumed.
1506    #[serde(default)]
1507    pub trust: bool,
1508    /// Require approval by default. This intentionally defaults on.
1509    #[serde(default = "default_fleet_profile_approval_required")]
1510    pub approval_required: bool,
1511}
1512
1513fn default_fleet_profile_approval_required() -> bool {
1514    true
1515}
1516
1517impl Default for FleetProfilePermissions {
1518    fn default() -> Self {
1519        Self {
1520            allow_shell: false,
1521            trust: false,
1522            approval_required: true,
1523        }
1524    }
1525}
1526
1527/// Delegation hints for future fleet manager scheduling.
1528#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1529pub struct FleetDelegationHints {
1530    /// Optional profile-level child spawn depth. `None` means inherit existing
1531    /// fleet/sub-agent config.
1532    #[serde(default, skip_serializing_if = "Option::is_none")]
1533    pub max_spawn_depth: Option<u32>,
1534    /// Optional profile-level worker concurrency hint.
1535    #[serde(
1536        default,
1537        alias = "concurrency",
1538        skip_serializing_if = "Option::is_none"
1539    )]
1540    pub max_concurrency: Option<usize>,
1541}
1542
1543/// A named role preset that bundles common worker settings.
1544///
1545/// Task specs reference a role name (e.g. `"role": "reviewer"`), and the
1546/// fleet manager fills in any missing fields from the preset. User-defined
1547/// roles in `[fleet.roles]` override built-in defaults with the same name.
1548///
1549/// Token budgets and tool-call limits are task-level decisions — they don't
1550/// belong on role presets. Use `timeout_seconds` as the safety bound.
1551#[derive(Debug, Clone, Serialize, Deserialize)]
1552pub struct FleetRolePreset {
1553    /// Short description of what this role is for.
1554    #[serde(skip_serializing_if = "Option::is_none")]
1555    pub description: Option<String>,
1556    /// Default tool profile (`"read-only"`, `"read-write"`, or `"custom"`).
1557    #[serde(skip_serializing_if = "Option::is_none")]
1558    pub tool_profile: Option<String>,
1559    /// Default set of tool names available to this role.
1560    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1561    pub tools: Vec<String>,
1562    /// Default capability tags (e.g. `"rust"`, `"git"`, `"gh"`).
1563    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1564    pub capabilities: Vec<String>,
1565    /// Default timeout in seconds for tasks using this role.
1566    #[serde(skip_serializing_if = "Option::is_none")]
1567    pub timeout_seconds: Option<u64>,
1568    /// Default trust level override for this role.
1569    #[serde(skip_serializing_if = "Option::is_none")]
1570    pub trust_level: Option<String>,
1571}
1572
1573fn default_fleet_trust_level_str() -> String {
1574    "sandbox".to_string()
1575}
1576
1577fn default_fleet_require_identity() -> bool {
1578    true
1579}
1580
1581fn default_fleet_max_trust_level_str() -> String {
1582    "operator".to_string()
1583}
1584
1585impl Default for FleetConfigToml {
1586    fn default() -> Self {
1587        Self {
1588            default_trust_level: default_fleet_trust_level_str(),
1589            require_identity_verification: default_fleet_require_identity(),
1590            max_trust_level: default_fleet_max_trust_level_str(),
1591            roles: BTreeMap::new(),
1592            profiles: BTreeMap::new(),
1593            exec: FleetExecConfig::default(),
1594        }
1595    }
1596}
1597
1598impl FleetConfigToml {
1599    /// Resolve a role preset by name. Checks user-defined roles first,
1600    /// then falls back to built-in role defaults.
1601    #[must_use]
1602    pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
1603        self.roles
1604            .get(name)
1605            .cloned()
1606            .or_else(|| built_in_role_presets().get(name).cloned())
1607    }
1608}
1609
1610/// On-disk schema for the `[workflow]` table (#4128 / Section 2.11).
1611///
1612/// Automatic Workflow launch, write/approval gates, child/isolation budgets,
1613/// and completed-activity persistence all read from this one model. When the
1614/// table is absent, consumers resolve [`WorkflowConfigToml::default`].
1615/// See `config.example.toml` for documentation.
1616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1617pub struct WorkflowConfigToml {
1618    /// Allow the parent agent to auto-launch Workflow for multi-agent work.
1619    /// Product default is on; set `false` to require explicit `/workflow`.
1620    #[serde(default = "default_workflow_automatic")]
1621    pub automatic: bool,
1622    /// When automatic launch is enabled, start read-only child plans without
1623    /// an approval card. Write/shell/network plans still consult
1624    /// [`Self::require_approval_for_writes`].
1625    #[serde(default = "default_workflow_auto_start_read_only")]
1626    pub auto_start_read_only: bool,
1627    /// Require an operator approval card before launching plans that write,
1628    /// elevate shell/network, or otherwise leave the read-only envelope.
1629    #[serde(default = "default_workflow_require_approval_for_writes")]
1630    pub require_approval_for_writes: bool,
1631    /// Soft upper bound on children admitted by automatic launch. Larger plans
1632    /// should ask the operator or use explicit `/workflow`.
1633    #[serde(default = "default_workflow_auto_start_child_limit")]
1634    pub auto_start_child_limit: u32,
1635    /// Hard ceiling on total children in one Workflow run (product: 1000).
1636    #[serde(default = "default_workflow_max_children")]
1637    pub max_children: u32,
1638    /// Maximum concurrently live agents inside one Workflow run (product: 16).
1639    #[serde(default = "default_workflow_max_concurrent")]
1640    pub max_concurrent: u32,
1641    /// Maximum nested Workflow / child-orchestration depth.
1642    #[serde(default = "default_workflow_max_depth")]
1643    pub max_depth: u32,
1644    /// Default shared token budget for a Workflow run and its children.
1645    #[serde(default = "default_workflow_default_token_budget")]
1646    pub default_token_budget: u64,
1647    /// How many parallel write children may share the parent worktree without
1648    /// isolation. `0` forces worktree isolation for parallel writes.
1649    #[serde(default = "default_workflow_max_parallel_writes_without_worktree")]
1650    pub max_parallel_writes_without_worktree: u32,
1651    /// Keep completed Workflow activity visible in the session activity surface
1652    /// until the next run (or explicit clear).
1653    #[serde(default = "default_workflow_persist_completed_activity")]
1654    pub persist_completed_activity: bool,
1655    /// Persist completed Workflow activity across process restarts via the
1656    /// durable run journal.
1657    #[serde(default = "default_workflow_persist_completed_across_restarts")]
1658    pub persist_completed_across_restarts: bool,
1659}
1660
1661fn default_workflow_automatic() -> bool {
1662    true
1663}
1664
1665fn default_workflow_auto_start_read_only() -> bool {
1666    true
1667}
1668
1669fn default_workflow_require_approval_for_writes() -> bool {
1670    true
1671}
1672
1673fn default_workflow_auto_start_child_limit() -> u32 {
1674    // Soft auto stays small; explicit launches may use the full concurrent cap.
1675    16
1676}
1677
1678fn default_workflow_max_children() -> u32 {
1679    1000
1680}
1681
1682fn default_workflow_max_concurrent() -> u32 {
1683    16
1684}
1685
1686fn default_workflow_max_depth() -> u32 {
1687    2
1688}
1689
1690fn default_workflow_default_token_budget() -> u64 {
1691    120_000
1692}
1693
1694fn default_workflow_max_parallel_writes_without_worktree() -> u32 {
1695    0
1696}
1697
1698fn default_workflow_persist_completed_activity() -> bool {
1699    true
1700}
1701
1702fn default_workflow_persist_completed_across_restarts() -> bool {
1703    true
1704}
1705
1706impl Default for WorkflowConfigToml {
1707    fn default() -> Self {
1708        Self {
1709            automatic: default_workflow_automatic(),
1710            auto_start_read_only: default_workflow_auto_start_read_only(),
1711            require_approval_for_writes: default_workflow_require_approval_for_writes(),
1712            auto_start_child_limit: default_workflow_auto_start_child_limit(),
1713            max_children: default_workflow_max_children(),
1714            max_concurrent: default_workflow_max_concurrent(),
1715            max_depth: default_workflow_max_depth(),
1716            default_token_budget: default_workflow_default_token_budget(),
1717            max_parallel_writes_without_worktree:
1718                default_workflow_max_parallel_writes_without_worktree(),
1719            persist_completed_activity: default_workflow_persist_completed_activity(),
1720            persist_completed_across_restarts: default_workflow_persist_completed_across_restarts(),
1721        }
1722    }
1723}
1724
1725/// Built-in role presets that are always available without config.
1726#[must_use]
1727pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
1728    [
1729        (
1730            "smoke-runner".to_string(),
1731            FleetRolePreset {
1732                description: Some("Lightweight read-only smoke check worker".to_string()),
1733                tool_profile: Some("read-only".to_string()),
1734                tools: vec![],
1735                capabilities: vec![],
1736                timeout_seconds: Some(300),
1737                trust_level: Some("local".to_string()),
1738            },
1739        ),
1740        (
1741            "reviewer".to_string(),
1742            FleetRolePreset {
1743                description: Some("Read-only code and documentation review".to_string()),
1744                tool_profile: Some("read-only".to_string()),
1745                tools: vec![],
1746                capabilities: vec![],
1747                timeout_seconds: Some(600),
1748                trust_level: None,
1749            },
1750        ),
1751        (
1752            "builder".to_string(),
1753            FleetRolePreset {
1754                description: Some(
1755                    "Read-write builder with compilation and test access".to_string(),
1756                ),
1757                tool_profile: Some("read-write".to_string()),
1758                tools: vec![],
1759                capabilities: vec![],
1760                timeout_seconds: Some(1800),
1761                trust_level: Some("local".to_string()),
1762            },
1763        ),
1764        (
1765            "read-only".to_string(),
1766            FleetRolePreset {
1767                description: Some(
1768                    "Minimal read-only observer with no writes or secrets".to_string(),
1769                ),
1770                tool_profile: Some("read-only".to_string()),
1771                tools: vec![],
1772                capabilities: vec![],
1773                timeout_seconds: Some(300),
1774                trust_level: Some("sandbox".to_string()),
1775            },
1776        ),
1777    ]
1778    .into()
1779}
1780
1781/// Verdict policy for the verifier-preview surface (#2093).
1782///
1783/// Only the hunt vocabulary is shipped today. Keeping this typed lets future
1784/// policy additions reject misspellings instead of silently accepting unknown
1785/// strings.
1786#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1787#[serde(rename_all = "snake_case")]
1788pub enum VerifierVerdictPolicy {
1789    #[default]
1790    Hunt,
1791}
1792
1793/// On-disk schema for `[verifier]`.
1794#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1795pub struct VerifierConfigToml {
1796    /// Enable automatic verifier preview when the runtime wires a
1797    /// claim-of-done trigger. Manual `run_verifiers` remains available
1798    /// regardless.
1799    #[serde(default)]
1800    pub enabled: bool,
1801    /// How verifier verdicts map into the goal/hunt system.
1802    #[serde(default)]
1803    pub verdict_policy: VerifierVerdictPolicy,
1804}
1805
1806impl Default for VerifierConfigToml {
1807    fn default() -> Self {
1808        Self {
1809            enabled: false,
1810            verdict_policy: VerifierVerdictPolicy::Hunt,
1811        }
1812    }
1813}
1814
1815/// On-disk schema for the `[network]` table (#135). See `config.example.toml`
1816/// for documentation.
1817#[derive(Debug, Clone, Serialize, Deserialize)]
1818pub struct NetworkPolicyToml {
1819    /// Decision for hosts that are not in `allow` or `deny`. One of
1820    /// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`.
1821    #[serde(default = "default_network_decision")]
1822    pub default: String,
1823    /// Hosts that are always allowed. Subdomain rules: a leading dot
1824    /// (`.example.com`) matches subdomains but not the apex.
1825    #[serde(default)]
1826    pub allow: Vec<String>,
1827    /// Hosts that are always denied. Deny entries win over allow entries.
1828    #[serde(default)]
1829    pub deny: Vec<String>,
1830    /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an
1831    /// explicitly trusted proxy setup. Literal IP URLs remain blocked.
1832    #[serde(default)]
1833    pub proxy: Vec<String>,
1834    /// Whether to record one audit-log line per outbound network call.
1835    #[serde(default = "default_network_audit")]
1836    pub audit: bool,
1837}
1838
1839fn default_network_decision() -> String {
1840    "prompt".to_string()
1841}
1842
1843fn default_network_audit() -> bool {
1844    true
1845}
1846
1847impl Default for NetworkPolicyToml {
1848    fn default() -> Self {
1849        Self {
1850            default: default_network_decision(),
1851            allow: Vec::new(),
1852            deny: Vec::new(),
1853            proxy: Vec::new(),
1854            audit: default_network_audit(),
1855        }
1856    }
1857}
1858
1859/// User-defined LSP server for one file extension (used inside
1860/// [`LspConfigToml::custom`]).
1861#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1862pub struct CustomLspDef {
1863    /// LSP `languageId` value used in `textDocument/didOpen`.
1864    pub language_id: String,
1865    /// Executable to spawn.
1866    pub command: String,
1867    /// Arguments passed to the executable.
1868    #[serde(default)]
1869    pub args: Vec<String>,
1870}
1871
1872/// On-disk schema for the `[lsp]` table (#136). See `config.example.toml`
1873/// for documentation. All fields are optional so the TUI runtime can fall
1874/// back to its own defaults when keys are absent.
1875#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1876pub struct LspConfigToml {
1877    /// Master switch.
1878    pub enabled: Option<bool>,
1879    /// Maximum time to wait for diagnostics after an edit, in milliseconds.
1880    pub poll_after_edit_ms: Option<u64>,
1881    /// Cap on diagnostics surfaced per file.
1882    pub max_diagnostics_per_file: Option<usize>,
1883    /// When `true`, warnings (severity 2) are surfaced in addition to errors.
1884    pub include_warnings: Option<bool>,
1885    /// Optional override for the `language -> [cmd, ...args]` table.
1886    pub servers: Option<BTreeMap<String, Vec<String>>>,
1887    /// User-defined LSP servers for file extensions not in the built-in
1888    /// registry. Keyed by extension (e.g. `"php"`, `"rb"`).
1889    pub custom: Option<BTreeMap<String, CustomLspDef>>,
1890}
1891
1892impl ConfigToml {
1893    /// Merge safe project-level overrides from `$WORKSPACE/.codewhale/config.toml`
1894    /// or legacy `$WORKSPACE/.deepseek/config.toml`.
1895    ///
1896    /// Repo-local config is untrusted input. This helper intentionally ignores
1897    /// credentials, endpoints, provider selection, auth/session values, telemetry,
1898    /// network policy, skill registry, LSP command tables, and unknown extras.
1899    /// Approval and sandbox values may only tighten the existing user/global
1900    /// posture.
1901    pub fn merge_project_overrides(&mut self, project: ConfigToml) {
1902        if project.default_text_model.is_some() {
1903            self.default_text_model = project.default_text_model;
1904        }
1905        if project.model.is_some() {
1906            self.model = project.model;
1907        }
1908        if project.output_mode.is_some() {
1909            self.output_mode = project.output_mode;
1910        }
1911        if project.verbosity.is_some() {
1912            self.verbosity = project.verbosity;
1913        }
1914        if project.log_level.is_some() {
1915            self.log_level = project.log_level;
1916        }
1917        if let Some(policy) = project.approval_policy
1918            && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
1919        {
1920            self.approval_policy = Some(policy);
1921        }
1922        if let Some(mode) = project.sandbox_mode
1923            && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
1924        {
1925            self.sandbox_mode = Some(mode);
1926        }
1927        if project.tools.is_some() {
1928            self.tools = project.tools;
1929        }
1930        for provider in ProviderKind::ALL {
1931            merge_project_provider_config(
1932                self.providers.for_provider_mut(provider),
1933                project.providers.for_provider(provider),
1934            );
1935        }
1936    }
1937
1938    #[must_use]
1939    pub fn get_value(&self, key: &str) -> Option<String> {
1940        if let Some((provider, field)) = parse_provider_config_key(key) {
1941            return get_provider_config_value(self.providers.for_provider(provider), field);
1942        }
1943
1944        match key {
1945            "provider" => Some(self.provider.as_str().to_string()),
1946            "stream_chunk_timeout_secs" | "tui.stream_chunk_timeout_secs" => {
1947                Some(self.stream_chunk_timeout_secs().to_string())
1948            }
1949            "api_key" => self.api_key.clone(),
1950            "base_url" => self.base_url.clone(),
1951            "http_headers" => serialize_http_headers(&self.http_headers),
1952            "default_text_model" => self.default_text_model.clone(),
1953            "model" => self.model.clone(),
1954            "auth.mode" => self.auth_mode.clone(),
1955            "output_mode" => self.output_mode.clone(),
1956            "verbosity" => self.verbosity.clone(),
1957            "log_level" => self.log_level.clone(),
1958            "telemetry" => self.telemetry.map(|v| v.to_string()),
1959            "approval_policy" => self.approval_policy.clone(),
1960            "sandbox_mode" => self.sandbox_mode.clone(),
1961            "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
1962            "hook_sinks.unix_socket_path" => self
1963                .hook_sinks
1964                .as_ref()
1965                .and_then(|sinks| sinks.unix_socket_path.as_ref())
1966                .map(|path| path.display().to_string()),
1967            _ => self.extras.get(key).map(toml::Value::to_string),
1968        }
1969    }
1970
1971    #[must_use]
1972    pub fn get_display_value(&self, key: &str) -> Option<String> {
1973        if let Some((provider, field)) = parse_provider_config_key(key) {
1974            return get_provider_config_display_value(self.providers.for_provider(provider), field);
1975        }
1976
1977        if key == "http_headers" {
1978            return serialize_http_headers_for_display(&self.http_headers);
1979        }
1980
1981        if let Some(value) = self.extras.get(key) {
1982            return Some(redact_toml_value_for_display(key, value));
1983        }
1984
1985        self.get_value(key).map(|value| {
1986            if is_sensitive_config_key(key) {
1987                redact_secret(&value)
1988            } else {
1989                value
1990            }
1991        })
1992    }
1993
1994    #[must_use]
1995    pub fn stream_chunk_timeout_secs(&self) -> u64 {
1996        let raw = self
1997            .extras
1998            .get("tui")
1999            .and_then(toml::Value::as_table)
2000            .and_then(|table| table.get("stream_chunk_timeout_secs"))
2001            .and_then(toml_value_as_u64)
2002            .or_else(|| {
2003                self.extras
2004                    .get("tui.stream_chunk_timeout_secs")
2005                    .and_then(toml_value_as_u64)
2006            })
2007            .or_else(|| {
2008                self.extras
2009                    .get("stream_chunk_timeout_secs")
2010                    .and_then(toml_value_as_u64)
2011            })
2012            .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
2013        if raw == 0 {
2014            DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
2015        } else {
2016            raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
2017        }
2018    }
2019
2020    pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
2021        if let Some((provider, field)) = parse_provider_config_key(key) {
2022            return set_provider_config_value(self, provider, field, value);
2023        }
2024
2025        match key {
2026            "provider" => {
2027                self.provider = ProviderKind::parse(value).with_context(|| {
2028                    format!(
2029                        "unknown provider '{value}': expected {}",
2030                        ProviderKind::names_hint()
2031                    )
2032                })?;
2033            }
2034            "api_key" => self.api_key = Some(value.to_string()),
2035            "base_url" => self.base_url = Some(value.to_string()),
2036            "http_headers" => self.http_headers = parse_http_headers(value)?,
2037            "default_text_model" => self.default_text_model = Some(value.to_string()),
2038            "model" => self.model = Some(value.to_string()),
2039            "auth.mode" => self.auth_mode = Some(value.to_string()),
2040            "output_mode" => self.output_mode = Some(value.to_string()),
2041            "verbosity" => self.verbosity = Some(value.to_string()),
2042            "log_level" => self.log_level = Some(value.to_string()),
2043            "telemetry" => {
2044                self.telemetry = Some(parse_bool(value)?);
2045            }
2046            "approval_policy" => self.approval_policy = Some(value.to_string()),
2047            "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
2048            "hook_sinks.unix_socket_path" => {
2049                self.hook_sinks
2050                    .get_or_insert_with(HookSinksToml::default)
2051                    .unix_socket_path = Some(PathBuf::from(value));
2052            }
2053            _ => {
2054                self.extras
2055                    .insert(key.to_string(), toml::Value::String(value.to_string()));
2056            }
2057        }
2058        Ok(())
2059    }
2060
2061    pub fn unset_value(&mut self, key: &str) -> Result<()> {
2062        if let Some((provider, field)) = parse_provider_config_key(key) {
2063            unset_provider_config_value(self, provider, field);
2064            return Ok(());
2065        }
2066
2067        match key {
2068            "provider" => self.provider = ProviderKind::Deepseek,
2069            "api_key" => self.api_key = None,
2070            "base_url" => self.base_url = None,
2071            "http_headers" => self.http_headers.clear(),
2072            "default_text_model" => self.default_text_model = None,
2073            "model" => self.model = None,
2074            "auth.mode" => self.auth_mode = None,
2075            "output_mode" => self.output_mode = None,
2076            "verbosity" => self.verbosity = None,
2077            "log_level" => self.log_level = None,
2078            "telemetry" => self.telemetry = None,
2079            "approval_policy" => self.approval_policy = None,
2080            "sandbox_mode" => self.sandbox_mode = None,
2081            "hook_sinks.unix_socket_path" => {
2082                if let Some(sinks) = self.hook_sinks.as_mut() {
2083                    sinks.unix_socket_path = None;
2084                }
2085            }
2086            _ => {
2087                self.extras.remove(key);
2088            }
2089        }
2090        Ok(())
2091    }
2092
2093    #[must_use]
2094    pub fn list_values(&self) -> BTreeMap<String, String> {
2095        let mut out = BTreeMap::new();
2096        out.insert("provider".to_string(), self.provider.as_str().to_string());
2097
2098        if let Some(v) = self.api_key.as_ref() {
2099            out.insert("api_key".to_string(), redact_secret(v));
2100        }
2101        if let Some(v) = self.base_url.as_ref() {
2102            out.insert("base_url".to_string(), v.clone());
2103        }
2104        if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
2105            out.insert("http_headers".to_string(), v);
2106        }
2107        if let Some(v) = self.default_text_model.as_ref() {
2108            out.insert("default_text_model".to_string(), v.clone());
2109        }
2110        if let Some(v) = self.model.as_ref() {
2111            out.insert("model".to_string(), v.clone());
2112        }
2113        if let Some(v) = self.auth_mode.as_ref() {
2114            out.insert("auth.mode".to_string(), v.clone());
2115        }
2116        if let Some(v) = self.output_mode.as_ref() {
2117            out.insert("output_mode".to_string(), v.clone());
2118        }
2119        if let Some(v) = self.verbosity.as_ref() {
2120            out.insert("verbosity".to_string(), v.clone());
2121        }
2122        if let Some(v) = self.log_level.as_ref() {
2123            out.insert("log_level".to_string(), v.clone());
2124        }
2125        if let Some(v) = self.telemetry {
2126            out.insert("telemetry".to_string(), v.to_string());
2127        }
2128        if let Some(v) = self.approval_policy.as_ref() {
2129            out.insert("approval_policy".to_string(), v.clone());
2130        }
2131        if let Some(v) = self.sandbox_mode.as_ref() {
2132            out.insert("sandbox_mode".to_string(), v.clone());
2133        }
2134        if let Some(v) = self
2135            .hook_sinks
2136            .as_ref()
2137            .and_then(|sinks| sinks.unix_socket_path.as_ref())
2138        {
2139            out.insert(
2140                "hook_sinks.unix_socket_path".to_string(),
2141                v.display().to_string(),
2142            );
2143        }
2144
2145        for provider in ProviderKind::ALL {
2146            insert_provider_config_values(
2147                &mut out,
2148                provider,
2149                self.providers.for_provider(provider),
2150            );
2151        }
2152
2153        for (k, v) in &self.extras {
2154            out.insert(k.clone(), redact_toml_value_for_display(k, v));
2155        }
2156        out
2157    }
2158
2159    /// Resolve runtime options without touching platform credential stores.
2160    ///
2161    /// This method keeps library callers prompt-free: CLI flag → config file
2162    /// → environment. Call `resolve_runtime_options_with_secrets` when a
2163    /// user-facing dispatcher should recover credentials from the configured
2164    /// secret store.
2165    #[must_use]
2166    pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
2167        let no_keyring = Secrets::new(std::sync::Arc::new(
2168            codewhale_secrets::InMemoryKeyringStore::new(),
2169        ));
2170        self.resolve_runtime_options_with_secrets(cli, &no_keyring)
2171    }
2172
2173    /// Resolve runtime options using an explicit secrets façade.
2174    ///
2175    /// API-key precedence is **CLI flag → config-file → secret store → environment**.
2176    #[must_use]
2177    pub fn resolve_runtime_options_with_secrets(
2178        &self,
2179        cli: &CliRuntimeOverrides,
2180        secrets: &Secrets,
2181    ) -> ResolvedRuntimeOptions {
2182        let env = EnvRuntimeOverrides::load();
2183        let (provider, provider_source) = if let Some(provider) = cli.provider {
2184            (provider, ProviderSource::Cli)
2185        } else if let Some(provider) = env.provider {
2186            (
2187                provider,
2188                ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
2189            )
2190        } else {
2191            (self.provider, ProviderSource::Config)
2192        };
2193
2194        let mut provider_cfg = self.providers.for_provider(provider).clone();
2195        if provider == ProviderKind::SiliconflowCN {
2196            let fb = &self.providers.siliconflow;
2197            if provider_cfg.api_key.is_none() {
2198                provider_cfg.api_key = fb.api_key.clone();
2199            }
2200            if provider_cfg.base_url.is_none() {
2201                provider_cfg.base_url = fb.base_url.clone();
2202            }
2203            if provider_cfg.model.is_none() {
2204                provider_cfg.model = fb.model.clone();
2205            }
2206        }
2207        let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
2208            .then(|| self.api_key.clone())
2209            .flatten();
2210        let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
2211            .then(|| self.base_url.clone())
2212            .flatten();
2213        let root_deepseek_model = (provider == ProviderKind::Deepseek)
2214            .then(|| self.default_text_model.clone())
2215            .flatten();
2216        let auth_mode = cli
2217            .auth_mode
2218            .clone()
2219            .or_else(|| env.auth_mode.clone())
2220            .or_else(|| provider_cfg.auth_mode.clone())
2221            .or_else(|| self.auth_mode.clone());
2222        let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
2223        let cli_base_url = cli.base_url.clone();
2224        let env_base_url = env.base_url_for(provider);
2225        let file_base_url = provider_cfg.base_url.clone().or(root_deepseek_base_url);
2226        let base_url_from_file =
2227            cli_base_url.is_none() && env_base_url.is_none() && file_base_url.is_some();
2228        let configured_base_url = cli_base_url.or(env_base_url).or(file_base_url);
2229        let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
2230            env.xiaomi_mimo_mode
2231                .clone()
2232                .or_else(|| provider_cfg.mode.clone())
2233        } else {
2234            None
2235        };
2236        let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
2237            xiaomi_mimo_env_api_key_for_runtime(
2238                xiaomi_mimo_mode.as_deref(),
2239                configured_base_url.as_deref(),
2240            )
2241        } else {
2242            None
2243        };
2244        let explicit_api_key_for_endpoint = cli
2245            .api_key
2246            .as_deref()
2247            .or(from_file.as_deref())
2248            .or(xiaomi_mimo_env_api_key.as_deref());
2249        let base_url = if provider == ProviderKind::XiaomiMimo {
2250            resolve_xiaomi_mimo_base_url(
2251                configured_base_url,
2252                explicit_api_key_for_endpoint,
2253                xiaomi_mimo_mode.as_deref(),
2254            )
2255        } else {
2256            configured_base_url.unwrap_or_else(|| match provider {
2257                ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
2258                ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string(),
2259                ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
2260                ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
2261                ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
2262                ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
2263                ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
2264                ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
2265                ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
2266                ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
2267                ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
2268                ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
2269                ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
2270                ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
2271                ProviderKind::Moonshot => {
2272                    if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) {
2273                        DEFAULT_KIMI_CODE_BASE_URL.to_string()
2274                    } else {
2275                        DEFAULT_MOONSHOT_BASE_URL.to_string()
2276                    }
2277                }
2278                ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
2279                ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
2280                ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
2281                ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
2282                ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
2283                ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL.to_string(),
2284                ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
2285                ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
2286                ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL.to_string(),
2287                ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
2288                ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
2289                ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
2290                ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string(),
2291                ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
2292                ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL.to_string(),
2293                ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(),
2294                ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(),
2295                ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(),
2296                // The custom provider has no built-in endpoint; fall back to its
2297                // descriptor placeholder so the lookup is total. Real custom
2298                // routes always supply a configured base_url before this point.
2299                ProviderKind::Custom => provider.provider().default_base_url().to_string(),
2300            })
2301        };
2302        // `auth_mode = "none"` is an endpoint contract, so it suppresses every
2303        // credential source (including explicit CLI/config values). Otherwise
2304        // CLI and route-local config win outright. Ambient provider credentials
2305        // are allowed only on the provider's official endpoint family: a saved
2306        // OpenRouter key must never follow `provider = "openrouter"` to an
2307        // unrelated custom gateway merely because the provider id stayed the
2308        // same.
2309        let uses_kimi_oauth = provider == ProviderKind::Moonshot
2310            && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth);
2311        let auth_disabled = auth_mode_disables_api_key(auth_mode.as_deref());
2312        let custom_endpoint = provider_preserves_custom_base_url_model(provider, &base_url);
2313        let (api_key, api_key_source) = if auth_disabled {
2314            (None, None)
2315        } else if let Some(value) = cli.api_key.clone() {
2316            (Some(value), Some(RuntimeApiKeySource::Cli))
2317        } else if uses_kimi_oauth && !custom_endpoint {
2318            (None, None)
2319        } else if (!custom_endpoint || base_url_from_file)
2320            && let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty())
2321        {
2322            (Some(value), Some(RuntimeApiKeySource::ConfigFile))
2323        } else if !custom_endpoint
2324            && let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty())
2325        {
2326            (Some(value), Some(RuntimeApiKeySource::Env))
2327        } else if custom_endpoint {
2328            (None, None)
2329        } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
2330            match env_api_key_for_provider(provider) {
2331                Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2332                None => (None, None),
2333            }
2334        } else {
2335            match secrets.resolve_with_source(provider.as_str()) {
2336                Some((value, source)) => {
2337                    let source = match source {
2338                        SecretSource::Keyring => RuntimeApiKeySource::Keyring,
2339                        SecretSource::Env => RuntimeApiKeySource::Env,
2340                    };
2341                    (Some(value), Some(source))
2342                }
2343                None => match env_api_key_for_provider(provider) {
2344                    Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2345                    None => (None, None),
2346                },
2347            }
2348        };
2349
2350        let env_provider_model = env.model_for(provider, &base_url);
2351        let explicit_model = cli.model.is_some()
2352            || env.model.is_some()
2353            || env_provider_model.is_some()
2354            || provider_cfg.model.is_some()
2355            || root_deepseek_model.is_some()
2356            || self.model.is_some();
2357        let model = cli
2358            .model
2359            .clone()
2360            .or_else(|| env.model.clone())
2361            .or(env_provider_model)
2362            .or_else(|| provider_cfg.model.clone())
2363            .or(root_deepseek_model)
2364            .or_else(|| self.model.clone())
2365            .unwrap_or_else(|| {
2366                if provider == ProviderKind::Moonshot
2367                    && (auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth)
2368                        || moonshot_base_url_uses_kimi_code(&base_url))
2369                {
2370                    DEFAULT_KIMI_CODE_MODEL.to_string()
2371                } else {
2372                    default_model_for_provider(provider).to_string()
2373                }
2374            });
2375        let model =
2376            if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
2377                model.trim().to_string()
2378            } else {
2379                normalize_model_for_provider(provider, &model)
2380            };
2381
2382        let mut http_headers = self.http_headers.clone();
2383        http_headers.extend(provider_cfg.http_headers.clone());
2384        if let Some(env_headers) = env.http_headers {
2385            http_headers.extend(env_headers);
2386        }
2387        http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
2388        if auth_disabled {
2389            http_headers.retain(|name, _| !is_upstream_auth_header(name));
2390        }
2391
2392        let output_mode = cli
2393            .output_mode
2394            .clone()
2395            .or_else(|| env.output_mode.clone())
2396            .or_else(|| self.output_mode.clone());
2397        let log_level = cli
2398            .log_level
2399            .clone()
2400            .or_else(|| env.log_level.clone())
2401            .or_else(|| self.log_level.clone());
2402        let telemetry = cli
2403            .telemetry
2404            .or(env.telemetry)
2405            .or(self.telemetry)
2406            .unwrap_or(false);
2407        let approval_policy = cli
2408            .approval_policy
2409            .clone()
2410            .or_else(|| env.approval_policy.clone())
2411            .or_else(|| self.approval_policy.clone());
2412        let sandbox_mode = cli
2413            .sandbox_mode
2414            .clone()
2415            .or_else(|| env.sandbox_mode.clone())
2416            .or_else(|| self.sandbox_mode.clone());
2417        let yolo = cli.yolo.or(env.yolo);
2418        let verbosity = cli
2419            .verbosity
2420            .clone()
2421            .or_else(|| env.verbosity.clone())
2422            .or_else(|| self.verbosity.clone());
2423
2424        ResolvedRuntimeOptions {
2425            provider,
2426            provider_source,
2427            model,
2428            api_key,
2429            api_key_source,
2430            base_url,
2431            auth_mode,
2432            insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
2433            output_mode,
2434            log_level,
2435            telemetry,
2436            approval_policy,
2437            sandbox_mode,
2438            yolo,
2439            verbosity,
2440            http_headers,
2441        }
2442    }
2443}
2444
2445fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
2446    if source.model.is_some() {
2447        target.model = source.model.clone();
2448    }
2449}
2450
2451#[must_use]
2452pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
2453    let Some(project_rank) = approval_policy_rank(project) else {
2454        return false;
2455    };
2456    match current.and_then(approval_policy_rank) {
2457        Some(current_rank) => project_rank >= current_rank,
2458        None => project_rank >= 2,
2459    }
2460}
2461
2462#[must_use]
2463pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
2464    let normalized_project = project.trim().to_ascii_lowercase();
2465    if normalized_project == "external-sandbox" {
2466        return current
2467            .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
2468            .unwrap_or(false);
2469    }
2470
2471    let Some(project_rank) = sandbox_mode_rank(project) else {
2472        return false;
2473    };
2474    match current.and_then(sandbox_mode_rank) {
2475        Some(current_rank) => project_rank >= current_rank,
2476        None => project_rank >= 2,
2477    }
2478}
2479
2480fn approval_policy_rank(value: &str) -> Option<u8> {
2481    match value.trim().to_ascii_lowercase().as_str() {
2482        "auto" => Some(0),
2483        "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
2484        "never" | "deny" | "denied" => Some(2),
2485        _ => None,
2486    }
2487}
2488
2489fn sandbox_mode_rank(value: &str) -> Option<u8> {
2490    match value.trim().to_ascii_lowercase().as_str() {
2491        "danger-full-access" => Some(0),
2492        "external-sandbox" => Some(0),
2493        "workspace-write" => Some(1),
2494        "read-only" => Some(2),
2495        _ => None,
2496    }
2497}
2498
2499/// Load a project-level config from the workspace.
2500///
2501/// Checks `$WORKSPACE/.codewhale/config.toml` first, falling back to
2502/// `$WORKSPACE/.deepseek/config.toml` for backward compatibility.
2503/// Returns `None` if neither file exists or can't be parsed.
2504pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
2505    for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
2506        let path = workspace.join(dir).join(CONFIG_FILE_NAME);
2507        if !project_config_candidate_exists(&path) {
2508            continue;
2509        }
2510        let raw = match read_checked_config_file(&path) {
2511            Ok(raw) => raw,
2512            Err(e) => {
2513                tracing::warn!("Failed to read project config {}: {e:#}", path.display());
2514                return None;
2515            }
2516        };
2517        match toml::from_str(&raw) {
2518            Ok(config) => return Some(config),
2519            Err(e) => {
2520                tracing::warn!("Failed to parse project config {}: {e}", path.display());
2521                return None;
2522            }
2523        }
2524    }
2525    None
2526}
2527
2528fn project_config_candidate_exists(path: &Path) -> bool {
2529    fs::symlink_metadata(path).is_ok_and(|metadata| {
2530        let file_type = metadata.file_type();
2531        file_type.is_file() || file_type.is_symlink()
2532    })
2533}
2534
2535fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
2536    if matches!(provider, ProviderKind::XiaomiMimo)
2537        && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
2538    {
2539        return canonical.to_string();
2540    }
2541    if matches!(
2542        provider,
2543        ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
2544    ) && let Some(canonical) = canonical_minimax_model_id(model)
2545    {
2546        return canonical.to_string();
2547    }
2548    if matches!(provider, ProviderKind::Zai)
2549        && let Some(canonical) = canonical_zai_model_id(model)
2550    {
2551        return canonical.to_string();
2552    }
2553
2554    if matches!(
2555        provider,
2556        ProviderKind::Atlascloud
2557            | ProviderKind::WanjieArk
2558            | ProviderKind::Volcengine
2559            | ProviderKind::XiaomiMimo
2560            | ProviderKind::Zai
2561            | ProviderKind::Stepfun
2562            | ProviderKind::Minimax
2563            | ProviderKind::MinimaxAnthropic
2564            | ProviderKind::Qianfan
2565            | ProviderKind::Ollama
2566            | ProviderKind::Meta
2567            | ProviderKind::Xai
2568    ) {
2569        return model.to_string();
2570    }
2571
2572    let normalized = model.trim().to_ascii_lowercase();
2573    if provider == ProviderKind::Openrouter
2574        && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
2575    {
2576        return canonical.to_string();
2577    }
2578    match (provider, normalized.as_str()) {
2579        (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
2580            DEFAULT_NVIDIA_NIM_MODEL.to_string()
2581        }
2582        (
2583            ProviderKind::NvidiaNim,
2584            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2585            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2586        ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
2587        (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
2588            DEFAULT_OPENROUTER_MODEL.to_string()
2589        }
2590        (
2591            ProviderKind::Openrouter,
2592            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2593            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2594        ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
2595        (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
2596            DEFAULT_NOVITA_MODEL.to_string()
2597        }
2598        (
2599            ProviderKind::Novita,
2600            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2601            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2602        ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
2603        (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
2604            DEFAULT_FIREWORKS_MODEL.to_string()
2605        }
2606        (
2607            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2608            "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
2609        ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
2610        (
2611            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2612            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
2613        ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
2614        (
2615            ProviderKind::Arcee,
2616            "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
2617        ) => DEFAULT_ARCEE_MODEL.to_string(),
2618        (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
2619            ARCEE_TRINITY_MINI_MODEL.to_string()
2620        }
2621        (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
2622            ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
2623        }
2624        (
2625            ProviderKind::Moonshot,
2626            "kimi"
2627            | "kimi-k2"
2628            | "kimi-k2.7"
2629            | "kimi-k2-7"
2630            | "kimi-k2.7-code"
2631            | "kimi-k2-7-code"
2632            | "kimi-code"
2633            | "moonshot-kimi-k2.7-code",
2634        ) => DEFAULT_MOONSHOT_MODEL.to_string(),
2635        (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
2636            MOONSHOT_KIMI_K2_6_MODEL.to_string()
2637        }
2638        (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
2639            DEFAULT_SGLANG_MODEL.to_string()
2640        }
2641        (
2642            ProviderKind::Sglang,
2643            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2644            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2645        ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
2646        (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
2647            DEFAULT_VLLM_MODEL.to_string()
2648        }
2649        (
2650            ProviderKind::Vllm,
2651            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2652            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2653        ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
2654        (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
2655            DEFAULT_HUGGINGFACE_MODEL.to_string()
2656        }
2657        (
2658            ProviderKind::Huggingface,
2659            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2660            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2661        ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
2662        (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
2663            DEFAULT_TOGETHER_MODEL.to_string()
2664        }
2665        (
2666            ProviderKind::Together,
2667            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2668            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2669        ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
2670        (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
2671            DEFAULT_DEEPINFRA_MODEL.to_string()
2672        }
2673        (
2674            ProviderKind::Deepinfra,
2675            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2676            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2677        ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
2678        _ => model.to_string(),
2679    }
2680}
2681
2682fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
2683    let normalized = model.trim().to_ascii_lowercase();
2684    let normalized = normalized.replace(['_', ' '], "-");
2685    match normalized.as_str() {
2686        "mimo"
2687        | DEFAULT_XIAOMI_MIMO_MODEL
2688        | "mimo-v2-5-pro"
2689        | "xiaomi-mimo-v2.5-pro"
2690        | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
2691        XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL
2692        | "mimo-v2-5-pro-ultraspeed"
2693        | "xiaomi-mimo-v2.5-pro-ultraspeed"
2694        | "xiaomi-mimo-v2-5-pro-ultraspeed"
2695        | "ultraspeed"
2696        | "pro-ultraspeed" => Some(XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL),
2697        "omni"
2698        | "mimo-omni"
2699        | "v2.5-omni"
2700        | "v25-omni"
2701        | "mimo-v2.5"
2702        | "mimo-v25"
2703        | "mimo-v2-5"
2704        | "mimo-v2.5-omni"
2705        | "mimo-v25-omni"
2706        | "mimo-v2-5-omni"
2707        | "xiaomi-mimo-v2.5"
2708        | "xiaomi-mimo-v2-5"
2709        | "xiaomi-mimo-v2.5-omni"
2710        | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
2711        "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
2712            Some(XIAOMI_MIMO_ASR_MODEL)
2713        }
2714        "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
2715            Some(XIAOMI_MIMO_TTS_MODEL)
2716        }
2717        "mimo-tts-voicedesign"
2718        | "mimo-voice-design"
2719        | "mimo-v25-tts-voicedesign"
2720        | "mimo-v2.5-tts-voicedesign"
2721        | "voicedesign"
2722        | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
2723        "mimo-tts-voiceclone"
2724        | "mimo-voice-clone"
2725        | "mimo-v25-tts-voiceclone"
2726        | "mimo-v2.5-tts-voiceclone"
2727        | "voiceclone"
2728        | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
2729        "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
2730        _ => None,
2731    }
2732}
2733
2734fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
2735    let normalized = model.trim().to_ascii_lowercase();
2736    let normalized = normalized.replace(['_', ' '], "-");
2737    match normalized.as_str() {
2738        "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
2739            Some(DEFAULT_MINIMAX_MODEL)
2740        }
2741        "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
2742            Some(MINIMAX_M2_7_MODEL)
2743        }
2744        "minimax-m2.7-highspeed"
2745        | "minimax-m2-7-highspeed"
2746        | "minimax-m-2.7-highspeed"
2747        | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
2748        "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
2749            Some(MINIMAX_M2_5_MODEL)
2750        }
2751        "minimax-m2.5-highspeed"
2752        | "minimax-m2-5-highspeed"
2753        | "minimax-m-2.5-highspeed"
2754        | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
2755        "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
2756            Some(MINIMAX_M2_1_MODEL)
2757        }
2758        "minimax-m2.1-highspeed"
2759        | "minimax-m2-1-highspeed"
2760        | "minimax-m-2.1-highspeed"
2761        | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
2762        "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
2763        _ => None,
2764    }
2765}
2766
2767fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
2768    let normalized = model.trim().to_ascii_lowercase();
2769    let normalized = normalized.replace(['_', ' '], "-");
2770    match normalized.as_str() {
2771        "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
2772        "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
2773        "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
2774        _ => None,
2775    }
2776}
2777
2778fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
2779    let normalized = model.trim().to_ascii_lowercase();
2780    let normalized = normalized.replace(['_', ' '], "-");
2781    match normalized.as_str() {
2782        OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
2783        | "trinity"
2784        | "trinity-large-thinking"
2785        | "arcee-trinity"
2786        | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
2787        OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
2788            Some(OPENROUTER_GEMMA_4_31B_MODEL)
2789        }
2790        OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
2791            Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
2792        }
2793        OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
2794            Some(OPENROUTER_GLM_5_1_MODEL)
2795        }
2796        OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
2797            Some(OPENROUTER_GLM_5_2_MODEL)
2798        }
2799        OPENROUTER_KIMI_K2_7_CODE_MODEL
2800        | "kimi"
2801        | "kimi-k2"
2802        | "kimi-k2.7"
2803        | "kimi-k2-7"
2804        | "kimi-k2.7-code"
2805        | "kimi-k2-7-code"
2806        | "kimi-code"
2807        | "moonshot-kimi-k2.7-code"
2808        | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
2809        OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
2810            Some(OPENROUTER_KIMI_K2_6_MODEL)
2811        }
2812        OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
2813            Some(OPENROUTER_MINIMAX_M3_MODEL)
2814        }
2815        OPENROUTER_MINIMAX_M2_7_MODEL
2816        | "minimax-2.7"
2817        | "minimax-2-7"
2818        | "minimax-m2.7"
2819        | "minimax-m2-7"
2820        | "minimax-m-2.7"
2821        | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_M2_7_MODEL),
2822        OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
2823        | "nemotron-3-nano-omni"
2824        | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
2825        OPENROUTER_QWEN_3_6_35B_A3B_MODEL
2826        | "qwen3.6-35b-a3b"
2827        | "qwen-3.6-35b-a3b"
2828        | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
2829        OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
2830            Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
2831        }
2832        OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
2833        | "qwen3.6-max-preview"
2834        | "qwen-3.6-max-preview"
2835        | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
2836        OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
2837            Some(OPENROUTER_QWEN_3_6_27B_MODEL)
2838        }
2839        OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
2840            Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
2841        }
2842        OPENROUTER_QWEN_3_7_PLUS_MODEL | "qwen3.7-plus" | "qwen-3.7-plus" => {
2843            Some(OPENROUTER_QWEN_3_7_PLUS_MODEL)
2844        }
2845        OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
2846            Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
2847        }
2848        OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
2849            Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
2850        }
2851        OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
2852        | "mimo-v2.5-pro"
2853        | "mimo-v2-5-pro"
2854        | "xiaomi-mimo-v2.5-pro"
2855        | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
2856        OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
2857        | "mimo-v2.5"
2858        | "mimo-v2-5"
2859        | "xiaomi-mimo-v2.5"
2860        | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
2861        _ => None,
2862    }
2863}
2864
2865fn default_model_for_provider(provider: ProviderKind) -> &'static str {
2866    match provider {
2867        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
2868        ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
2869        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
2870        ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
2871        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
2872        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
2873        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
2874        ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
2875        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
2876        ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
2877        ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
2878        ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
2879        ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
2880        ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
2881        ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
2882        ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
2883        ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
2884        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
2885        ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
2886        ProviderKind::Qianfan => DEFAULT_QIANFAN_MODEL,
2887        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
2888        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
2889        ProviderKind::Openmodel => DEFAULT_OPENMODEL_MODEL,
2890        ProviderKind::Zai => DEFAULT_ZAI_MODEL,
2891        ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
2892        ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_MODEL,
2893        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
2894        ProviderKind::Sakana => DEFAULT_SAKANA_MODEL,
2895        ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL,
2896        ProviderKind::Meta => DEFAULT_META_MODEL,
2897        ProviderKind::Xai => DEFAULT_XAI_MODEL,
2898        // No built-in default model; the registry placeholder keeps this total.
2899        ProviderKind::Custom => provider.provider().default_model(),
2900    }
2901}
2902
2903fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
2904    match provider {
2905        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
2906        ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
2907        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
2908        ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
2909        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
2910        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
2911        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
2912        ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
2913        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
2914        ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
2915        ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
2916        ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
2917        ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
2918        ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
2919        ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
2920        ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
2921        ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
2922        ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
2923        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
2924        ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
2925        ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL,
2926        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
2927        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
2928        ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL,
2929        ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
2930        ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
2931        ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
2932        ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
2933        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
2934        ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL,
2935        ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL,
2936        ProviderKind::Meta => DEFAULT_META_BASE_URL,
2937        ProviderKind::Xai => DEFAULT_XAI_BASE_URL,
2938        // No built-in default base URL; the registry placeholder keeps this total.
2939        ProviderKind::Custom => provider.provider().default_base_url(),
2940    }
2941}
2942
2943fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
2944    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2945    normalized == DEFAULT_KIMI_CODE_BASE_URL
2946        || normalized == "https://api.kimi.com/coding"
2947        || normalized.starts_with("https://api.kimi.com/coding/")
2948}
2949
2950fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
2951    let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
2952    if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
2953        return None;
2954    }
2955    Some(match normalized.as_str() {
2956        "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
2957            DEFAULT_XIAOMI_MIMO_BASE_URL
2958        }
2959        "token-plan-cn"
2960        | "token-plan-china"
2961        | "token-plan-mainland"
2962        | "token-plan-mainland-china"
2963        | "cn"
2964        | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
2965        "token-plan-sgp"
2966        | "token-plan-sg"
2967        | "token-plan-singapore"
2968        | "sgp"
2969        | "sg"
2970        | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
2971        "token-plan-ams"
2972        | "token-plan-eu"
2973        | "token-plan-europe"
2974        | "token-plan-amsterdam"
2975        | "ams"
2976        | "eu"
2977        | "europe"
2978        | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
2979        _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
2980    })
2981}
2982
2983fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
2984    matches!(
2985        normalized_mode,
2986        "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
2987    )
2988}
2989
2990fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
2991    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2992    normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
2993        || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
2994        || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
2995}
2996
2997fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
2998    candidates.iter().find_map(|name| {
2999        std::env::var(name)
3000            .ok()
3001            .filter(|value| !value.trim().is_empty())
3002    })
3003}
3004
3005fn xiaomi_mimo_env_api_key_for_runtime(
3006    mode: Option<&str>,
3007    base_url: Option<&str>,
3008) -> Option<String> {
3009    const TOKEN_PLAN_ENV_VARS: &[&str] =
3010        &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
3011    const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
3012
3013    let normalized_mode =
3014        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
3015    let standard_selected = normalized_mode
3016        .as_deref()
3017        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
3018        || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
3019    if standard_selected {
3020        return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
3021    }
3022
3023    let token_plan_selected = normalized_mode
3024        .as_deref()
3025        .and_then(xiaomi_mimo_base_url_for_mode)
3026        .is_some()
3027        || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
3028    if token_plan_selected {
3029        return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
3030    }
3031
3032    xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
3033}
3034
3035fn resolve_xiaomi_mimo_base_url(
3036    configured: Option<String>,
3037    api_key: Option<&str>,
3038    mode: Option<&str>,
3039) -> String {
3040    let normalized_mode =
3041        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
3042    let uses_standard_mode = normalized_mode
3043        .as_deref()
3044        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
3045    let mode_base_url = normalized_mode
3046        .as_deref()
3047        .and_then(xiaomi_mimo_base_url_for_mode);
3048    let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
3049    match configured {
3050        Some(base_url) if uses_standard_mode => base_url,
3051        Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
3052            mode_base_url
3053                .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
3054                .to_string()
3055        }
3056        Some(base_url) => base_url,
3057        None => {
3058            if let Some(base_url) = mode_base_url {
3059                base_url.to_string()
3060            } else if uses_standard_mode {
3061                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
3062            } else if uses_token_plan || api_key.is_none() {
3063                DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
3064            } else {
3065                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
3066            }
3067        }
3068    }
3069}
3070
3071fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
3072    api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
3073}
3074
3075fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
3076    matches!(
3077        base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
3078        "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
3079    )
3080}
3081
3082/// Whether `base_url` belongs to the provider's official endpoint family.
3083///
3084/// Some providers publish multiple stable paths for the same credential and
3085/// model namespace. Keep that family definition centralized so route
3086/// canonicalization and credential scoping cannot disagree.
3087#[must_use]
3088pub fn provider_base_url_is_official(provider: ProviderKind, base_url: &str) -> bool {
3089    let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
3090    match provider {
3091        ProviderKind::Deepseek => matches!(
3092            normalized.as_str(),
3093            "https://api.deepseek.com"
3094                | "https://api.deepseek.com/v1"
3095                | "https://api.deepseek.com/beta"
3096        ),
3097        ProviderKind::DeepseekAnthropic => matches!(
3098            normalized.as_str(),
3099            "https://api.deepseek.com/anthropic" | "https://api.deepseek.com/anthropic/v1"
3100        ),
3101        ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => matches!(
3102            normalized.as_str(),
3103            "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
3104        ),
3105        ProviderKind::Moonshot => {
3106            normalized == DEFAULT_MOONSHOT_BASE_URL || moonshot_base_url_uses_kimi_code(base_url)
3107        }
3108        ProviderKind::XiaomiMimo => {
3109            xiaomi_mimo_base_url_uses_token_plan(base_url)
3110                || xiaomi_mimo_base_url_is_pay_as_you_go(base_url)
3111        }
3112        // Custom routes have no Codewhale-owned official endpoint. The
3113        // descriptor URL is a schema placeholder, never a credential scope.
3114        ProviderKind::Custom => false,
3115        _ => {
3116            normalized
3117                == default_base_url_for_provider(provider)
3118                    .trim()
3119                    .trim_end_matches('/')
3120                    .to_ascii_lowercase()
3121        }
3122    }
3123}
3124
3125fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
3126    !provider_base_url_is_official(provider, base_url)
3127}
3128
3129/// Whether `base_url` is outside the provider's official endpoint family and
3130/// therefore owns its model-id namespace.
3131///
3132/// Custom OpenAI-compatible endpoints must receive the exact model selector
3133/// the user supplied. Official endpoints may safely canonicalize known aliases
3134/// to their provider wire ids.
3135#[must_use]
3136pub fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
3137    base_url_is_custom_for_provider(provider, base_url)
3138}
3139
3140fn should_skip_secret_store_for_provider(
3141    provider: ProviderKind,
3142    base_url: &str,
3143    auth_mode: Option<&str>,
3144) -> bool {
3145    if auth_mode_disables_api_key(auth_mode) {
3146        return true;
3147    }
3148    if base_url_is_custom_for_provider(provider, base_url) {
3149        return true;
3150    }
3151    if auth_mode_requires_api_key(auth_mode) {
3152        return false;
3153    }
3154
3155    matches!(
3156        provider,
3157        ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
3158    ) || base_url_uses_local_host(base_url)
3159}
3160
3161fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
3162    if provider == ProviderKind::Huggingface {
3163        return std::env::var("HUGGINGFACE_API_KEY")
3164            .ok()
3165            .filter(|value| !value.trim().is_empty())
3166            .or_else(|| {
3167                std::env::var("HF_TOKEN")
3168                    .ok()
3169                    .filter(|value| !value.trim().is_empty())
3170            });
3171    }
3172
3173    codewhale_secrets::env_for(provider.as_str())
3174}
3175
3176fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
3177    matches!(
3178        auth_mode
3179            .map(str::trim)
3180            .filter(|value| !value.is_empty())
3181            .map(|value| value.to_ascii_lowercase()),
3182        Some(value)
3183            if matches!(
3184                value.as_str(),
3185                "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
3186            )
3187    )
3188}
3189
3190/// Whether an authentication mode explicitly disables upstream provider auth.
3191#[must_use]
3192pub fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
3193    matches!(
3194        auth_mode
3195            .map(str::trim)
3196            .filter(|value| !value.is_empty())
3197            .map(|value| value.to_ascii_lowercase()),
3198        Some(value)
3199            if matches!(
3200                value.as_str(),
3201                "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
3202            )
3203    )
3204}
3205
3206fn auth_mode_uses_kimi_oauth(auth_mode: &str) -> bool {
3207    matches!(
3208        auth_mode
3209            .trim()
3210            .to_ascii_lowercase()
3211            .replace('-', "_")
3212            .as_str(),
3213        "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
3214    )
3215}
3216
3217fn base_url_uses_local_host(base_url: &str) -> bool {
3218    let Some(host) = base_url_host(base_url) else {
3219        return false;
3220    };
3221    let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
3222    if matches!(host.as_str(), "localhost" | "0.0.0.0") {
3223        return true;
3224    }
3225    host.parse::<std::net::IpAddr>()
3226        .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
3227}
3228
3229fn base_url_host(base_url: &str) -> Option<&str> {
3230    let without_scheme = base_url
3231        .split_once("://")
3232        .map_or(base_url, |(_, rest)| rest);
3233    let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
3234    if let Some(rest) = authority.strip_prefix('[') {
3235        return rest.split_once(']').map(|(host, _)| host);
3236    }
3237    authority.split(':').next().filter(|host| !host.is_empty())
3238}
3239
3240#[derive(Debug, Clone, Default)]
3241pub struct CliRuntimeOverrides {
3242    pub provider: Option<ProviderKind>,
3243    pub model: Option<String>,
3244    pub api_key: Option<String>,
3245    pub base_url: Option<String>,
3246    pub auth_mode: Option<String>,
3247    pub output_mode: Option<String>,
3248    pub log_level: Option<String>,
3249    pub telemetry: Option<bool>,
3250    pub approval_policy: Option<String>,
3251    pub sandbox_mode: Option<String>,
3252    pub yolo: Option<bool>,
3253    pub verbosity: Option<String>,
3254}
3255
3256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3257pub enum RuntimeApiKeySource {
3258    Cli,
3259    ConfigFile,
3260    Keyring,
3261    Env,
3262}
3263
3264impl RuntimeApiKeySource {
3265    #[must_use]
3266    pub fn as_env_value(self) -> &'static str {
3267        match self {
3268            Self::Cli => "cli",
3269            Self::ConfigFile => "config",
3270            Self::Keyring => "keyring",
3271            Self::Env => "env",
3272        }
3273    }
3274}
3275
3276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3277pub enum ProviderSource {
3278    Cli,
3279    Env(&'static str),
3280    Config,
3281}
3282
3283#[derive(Debug, Clone)]
3284pub struct ResolvedRuntimeOptions {
3285    pub provider: ProviderKind,
3286    pub provider_source: ProviderSource,
3287    pub model: String,
3288    pub api_key: Option<String>,
3289    pub api_key_source: Option<RuntimeApiKeySource>,
3290    pub base_url: String,
3291    pub auth_mode: Option<String>,
3292    pub insecure_skip_tls_verify: bool,
3293    pub output_mode: Option<String>,
3294    pub log_level: Option<String>,
3295    pub telemetry: bool,
3296    pub approval_policy: Option<String>,
3297    pub sandbox_mode: Option<String>,
3298    pub yolo: Option<bool>,
3299    pub verbosity: Option<String>,
3300    pub http_headers: BTreeMap<String, String>,
3301}
3302
3303#[derive(Debug, Clone)]
3304pub struct ConfigStore {
3305    path: PathBuf,
3306    pub config: ConfigToml,
3307    permissions: PermissionsToml,
3308    /// Original file text, retained so [`save`](Self::save) can merge
3309    /// comments back after serialisation.
3310    original_raw: Option<String>,
3311}
3312
3313impl ConfigStore {
3314    pub fn load(path: Option<PathBuf>) -> Result<Self> {
3315        let path = resolve_config_path(path)?;
3316        let (config, original_raw) = if checked_path_exists(&path)? {
3317            let raw = read_checked_config_file(&path)?;
3318            let parsed: ConfigToml = toml::from_str(&raw)
3319                .with_context(|| format!("failed to parse config at {}", path.display()))?;
3320            (parsed, Some(raw))
3321        } else {
3322            (ConfigToml::default(), None)
3323        };
3324        let permissions = load_sibling_permissions(&path)?;
3325
3326        Ok(Self {
3327            path,
3328            config,
3329            permissions,
3330            original_raw,
3331        })
3332    }
3333
3334    /// Render the exact body [`save`](Self::save) would write: the serialized
3335    /// config with comments and disabled keys from the originally-loaded file
3336    /// merged back in. Exposed so setup flows can stage this body into a
3337    /// [`persistence::SetupTransaction`] alongside sibling files and keep the
3338    /// comment-preserving write atomic with the rest of the transaction.
3339    pub fn rendered_body(&self) -> Result<String> {
3340        let serialized =
3341            toml::to_string_pretty(&self.config).context("failed to serialize config")?;
3342        if let Some(ref original_raw) = self.original_raw {
3343            Ok(
3344                merge_and_preserve_comments(&serialized, original_raw).unwrap_or_else(|e| {
3345                    tracing::warn!("failed to merge config comments, saving without them: {e:#}");
3346                    serialized
3347                }),
3348            )
3349        } else {
3350            Ok(serialized)
3351        }
3352    }
3353
3354    pub fn save(&self) -> Result<()> {
3355        let path = normalize_config_file_path(self.path.clone())?;
3356        if let Some(parent) = path.parent() {
3357            fs::create_dir_all(parent).with_context(|| {
3358                format!("failed to create config directory {}", parent.display())
3359            })?;
3360        }
3361        let body = self.rendered_body()?;
3362        if checked_path_exists(&path)? {
3363            let existing = read_checked_config_file(&path)?;
3364            if existing == body {
3365                return Ok(());
3366            }
3367            write_one_time_config_backup(&path)?;
3368        }
3369        persistence::atomic_write(&path, body.as_bytes())
3370            .with_context(|| format!("failed to write config at {}", path.display()))?;
3371        Ok(())
3372    }
3373
3374    #[must_use]
3375    pub fn path(&self) -> &Path {
3376        &self.path
3377    }
3378
3379    #[must_use]
3380    pub fn permissions(&self) -> &PermissionsToml {
3381        &self.permissions
3382    }
3383
3384    #[must_use]
3385    pub fn permissions_path(&self) -> PathBuf {
3386        checked_permissions_path_for_config_path(&self.path)
3387            .expect("ConfigStore path is validated before construction")
3388    }
3389
3390    #[must_use]
3391    pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
3392        if self.permissions.is_empty() {
3393            ExecPolicyEngine::new(Vec::new(), Vec::new())
3394        } else {
3395            ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
3396        }
3397    }
3398
3399    /// Atomically append ask-only permission rules to the sibling
3400    /// `permissions.toml` file.
3401    ///
3402    /// Existing comments and formatting are preserved. Exact duplicate rules
3403    /// are ignored, and the in-memory permissions snapshot is refreshed after
3404    /// a successful write.
3405    pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
3406        if rules.is_empty() {
3407            return Ok(0);
3408        }
3409
3410        let path = checked_permissions_path_for_config_path(&self.path)?;
3411        let raw = if checked_path_exists(&path)? {
3412            read_checked_permissions_file(&path)?
3413        } else {
3414            String::new()
3415        };
3416        let mut permissions = if raw.trim().is_empty() {
3417            PermissionsToml::default()
3418        } else {
3419            toml::from_str(&raw)
3420                .with_context(|| format!("failed to parse permissions at {}", path.display()))?
3421        };
3422        let mut document = if raw.trim().is_empty() {
3423            toml_edit::DocumentMut::new()
3424        } else {
3425            raw.parse::<toml_edit::DocumentMut>()
3426                .with_context(|| format!("failed to edit permissions at {}", path.display()))?
3427        };
3428
3429        if !document.contains_key("rules") {
3430            document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
3431        }
3432        let rules_item = document
3433            .get_mut("rules")
3434            .expect("rules entry was inserted above");
3435
3436        let mut added = 0;
3437        for rule in rules {
3438            if permissions.rules.contains(rule) {
3439                continue;
3440            }
3441            append_ask_rule(rules_item, rule)?;
3442            permissions.rules.push(rule.clone());
3443            added += 1;
3444        }
3445        if added == 0 {
3446            self.permissions = permissions;
3447            return Ok(0);
3448        }
3449
3450        let body = document.to_string();
3451        let persisted: PermissionsToml = toml::from_str(&body).with_context(|| {
3452            format!(
3453                "generated invalid permissions document for {}",
3454                path.display()
3455            )
3456        })?;
3457        write_permissions_atomic(&path, body.as_bytes())?;
3458        self.permissions = persisted;
3459        Ok(added)
3460    }
3461}
3462
3463fn config_backup_file_name(path: &Path) -> OsString {
3464    let mut file_name = path
3465        .file_name()
3466        .map(OsString::from)
3467        .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
3468    file_name.push(".bak");
3469    file_name
3470}
3471
3472fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
3473    config_path
3474        .parent()
3475        .unwrap_or_else(|| Path::new("."))
3476        .join(file_name)
3477}
3478
3479fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
3480    let config_path = normalize_config_file_path(config_path.to_path_buf())?;
3481    let parent = config_path
3482        .parent()
3483        .context("config path must include a parent directory")?;
3484    let path = parent.join(file_name);
3485    reject_path_symlink(&path)?;
3486    Ok(path)
3487}
3488
3489#[cfg(test)]
3490fn config_backup_path(path: &Path) -> PathBuf {
3491    config_sibling_path_unchecked(path, &config_backup_file_name(path))
3492}
3493
3494fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
3495    checked_config_sibling_path(path, &config_backup_file_name(path))
3496}
3497
3498/// Remove plaintext `api_key` entries from the one-time config backup, if it
3499/// exists.
3500///
3501/// Credential migration deliberately preserves the rest of `config.toml.bak`
3502/// while ensuring that moving a key into the durable secret store does not
3503/// leave the same credential behind in an older backup.
3504pub fn scrub_plaintext_api_keys_from_config_backup(path: &Path) -> Result<()> {
3505    let backup = checked_config_backup_path(path)?;
3506    if !backup.exists() {
3507        return Ok(());
3508    }
3509
3510    let raw = read_checked_toml_file(&backup, "config backup")?;
3511    let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
3512        format!(
3513            "failed to scrub plaintext API keys from config backup {}",
3514            backup.display()
3515        )
3516    })?;
3517    if scrubbed != raw {
3518        persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
3519            format!(
3520                "failed to write credential-free config backup {}",
3521                backup.display()
3522            )
3523        })?;
3524    }
3525    Ok(())
3526}
3527
3528fn write_one_time_config_backup(path: &Path) -> Result<()> {
3529    let backup = checked_config_backup_path(path)?;
3530    if backup.exists() {
3531        return scrub_plaintext_api_keys_from_config_backup(path);
3532    }
3533
3534    let raw = read_checked_config_file(path)?;
3535    let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
3536        format!(
3537            "failed to scrub plaintext API keys while creating config backup {}",
3538            backup.display()
3539        )
3540    })?;
3541    persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
3542        format!(
3543            "failed to create credential-free config backup {} from {}",
3544            backup.display(),
3545            path.display()
3546        )
3547    })?;
3548    Ok(())
3549}
3550
3551fn config_toml_without_plaintext_api_keys(raw: &str) -> Result<String> {
3552    let mut document = raw
3553        .parse::<toml_edit::DocumentMut>()
3554        .context("failed to parse config TOML while removing plaintext API keys")?;
3555    remove_plaintext_api_keys_recursive(document.as_table_mut());
3556    Ok(document.to_string())
3557}
3558
3559fn remove_plaintext_api_keys_recursive(table: &mut dyn toml_edit::TableLike) {
3560    table.remove("api_key");
3561    for (_, item) in table.iter_mut() {
3562        if let toml_edit::Item::ArrayOfTables(tables) = item {
3563            for nested in tables.iter_mut() {
3564                remove_plaintext_api_keys_recursive(nested);
3565            }
3566        } else if let Some(nested) = item.as_table_like_mut() {
3567            remove_plaintext_api_keys_recursive(nested);
3568        }
3569    }
3570}
3571
3572/// Merge comments and formatting from an original TOML file into a
3573/// freshly serialized document so user annotations (comments, whitespace,
3574/// disabled keys) survive config rewrites.
3575///
3576/// `original_raw` is the raw text of the file before the change; the
3577/// function parses it internally with [`toml_edit`] so callers stay free
3578/// of that dependency.
3579pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
3580    let original = original_raw
3581        .parse::<toml_edit::DocumentMut>()
3582        .context("failed to parse original config for comment merge")?;
3583
3584    let mut new_doc = serialized
3585        .parse::<toml_edit::DocumentMut>()
3586        .context("failed to parse serialized config for comment merge")?;
3587
3588    // Reuse the original document’s trailing text (file-footer comments /
3589    // disabled keys) so they survive the rewrite.
3590    new_doc.set_trailing(original.trailing().clone());
3591
3592    // Copy the top-level table's decor (document-header comments, whitespace
3593    // before the first key) which `toml_edit` stores on the root `Table` itself.
3594    *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
3595
3596    merge_decor_table(new_doc.as_table_mut(), original.as_table());
3597
3598    Ok(new_doc.to_string())
3599}
3600
3601/// Recursively copy `decor` (prefix/suffix comments and whitespace) from
3602/// every key in `source` that also exists in `target`.
3603fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3604    // Collect keys first — the borrow checker won't let us hold
3605    // `get_key_value_mut` while iterating.
3606    let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
3607    for key in &keys {
3608        let Some((source_key, source_item)) = source.get_key_value(key) else {
3609            continue;
3610        };
3611        let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
3612            continue;
3613        };
3614
3615        // Copy the key-level decor (comments before the key itself)
3616        *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
3617
3618        copy_item_decor(target_item, source_item);
3619
3620        if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
3621            merge_decor_table(tt, st);
3622        }
3623
3624        if let (Some(ta), Some(sa)) = (
3625            target_item.as_array_of_tables_mut(),
3626            source_item.as_array_of_tables(),
3627        ) {
3628            for (i, source_table) in sa.iter().enumerate() {
3629                if let Some(target_table) = ta.get_mut(i) {
3630                    copy_item_decor_table(target_table, source_table);
3631                    merge_decor_table(target_table, source_table);
3632                }
3633            }
3634        }
3635    }
3636}
3637
3638/// Copy the decor (comments and surrounding whitespace) from `source` to `target`,
3639/// respecting the concrete item type since [`toml_edit::Item`] has no uniform
3640/// `decor` accessor.
3641fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
3642    match (target, source) {
3643        (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
3644            *tt.decor_mut() = st.decor().clone();
3645        }
3646        (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
3647            *tv.decor_mut() = sv.decor().clone();
3648        }
3649        _ => {}
3650    }
3651}
3652
3653fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3654    *target.decor_mut() = source.decor().clone();
3655}
3656
3657/// Process-wide default [`Secrets`] façade. The first caller wins; the
3658/// lock is exposed so test or CLI code can install an explicit
3659/// backend (e.g. an [`codewhale_secrets::InMemoryKeyringStore`]) before
3660/// any resolver runs.
3661pub fn default_secrets() -> &'static Secrets {
3662    static SECRETS: OnceLock<Secrets> = OnceLock::new();
3663    SECRETS.get_or_init(|| {
3664        // Tests should never poke real platform credential stores. Cargo sets the
3665        // `RUST_TEST_*` family of env vars (and `CARGO_PKG_NAME` is
3666        // always populated), but the `cfg(test)` flag is the canonical
3667        // signal here. See `install_test_secrets` for explicit installs.
3668        #[cfg(test)]
3669        {
3670            Secrets::new(std::sync::Arc::new(
3671                codewhale_secrets::InMemoryKeyringStore::new(),
3672            ))
3673        }
3674        #[cfg(not(test))]
3675        {
3676            Secrets::auto_detect()
3677        }
3678    })
3679}
3680
3681// ── CodeWhale state root (v0.8.44) ──────────────────────────────────
3682//
3683// v0.8.44 migrates product-owned app state from ~/.deepseek/ to
3684// ~/.codewhale/ while keeping ~/.deepseek/ as a compatibility fallback.
3685// New installs write to ~/.codewhale/. Existing installs with only
3686// ~/.deepseek/ continue working without data loss.
3687
3688/// Canonical CodeWhale app directory name under $HOME.
3689pub const CODEWHALE_APP_DIR: &str = ".codewhale";
3690
3691/// Legacy DeepSeek-branded app directory name (compatibility fallback).
3692pub const LEGACY_APP_DIR: &str = ".deepseek";
3693
3694/// Resolve the primary CodeWhale home directory.
3695///
3696/// `$CODEWHALE_HOME` takes precedence when set. Otherwise defaults to
3697/// `$HOME/.codewhale`. This is the write target for new product state.
3698pub fn codewhale_home() -> Result<PathBuf> {
3699    if let Some(path) = codewhale_home_env_override() {
3700        return Ok(path);
3701    }
3702    let home = effective_home_dir().context("failed to resolve home directory")?;
3703    Ok(home.join(CODEWHALE_APP_DIR))
3704}
3705
3706fn codewhale_home_env_override() -> Option<PathBuf> {
3707    let val = std::env::var("CODEWHALE_HOME").ok()?;
3708    let trimmed = val.trim();
3709    if trimmed.is_empty() {
3710        None
3711    } else {
3712        Some(PathBuf::from(trimmed))
3713    }
3714}
3715
3716/// Whether `$CODEWHALE_HOME` is set to a non-empty value.
3717///
3718/// An explicit CodeWhale home is an isolation boundary: state/config resolvers
3719/// must not fall back to ambient legacy `~/.deepseek` data outside that root.
3720pub fn codewhale_home_is_explicit() -> bool {
3721    codewhale_home_env_override().is_some()
3722}
3723
3724/// Resolve the legacy DeepSeek home directory (`$HOME/.deepseek`).
3725///
3726/// Always returns the legacy path regardless of whether it exists.
3727pub fn legacy_deepseek_home() -> Result<PathBuf> {
3728    let home = effective_home_dir().context("failed to resolve home directory")?;
3729    Ok(home.join(LEGACY_APP_DIR))
3730}
3731
3732fn effective_home_dir() -> Option<PathBuf> {
3733    std::env::var_os("HOME")
3734        .filter(|value| !value.is_empty())
3735        .map(PathBuf::from)
3736        .or_else(dirs::home_dir)
3737}
3738
3739/// Reject state subdirs that could escape the state root via path injection.
3740///
3741/// `ensure_state_dir` / `resolve_state_dir` are public APIs taking an arbitrary
3742/// subdir string; every in-tree caller passes a hardcoded single component
3743/// (e.g. `"sessions"`, `"."`). This validates defensively so a future caller
3744/// can never traverse out of the state root via `..` components or an absolute
3745/// path. Nested relative paths such as `"a/b"` are permitted.
3746fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
3747    if subdir.is_empty() {
3748        bail!("state subdir must not be empty");
3749    }
3750    let path = std::path::Path::new(subdir);
3751    if path.is_absolute() {
3752        bail!("state subdir must not be an absolute path: {subdir}");
3753    }
3754    if path.components().any(|c| {
3755        matches!(
3756            c,
3757            std::path::Component::RootDir | std::path::Component::Prefix(_)
3758        )
3759    }) {
3760        bail!("state subdir must not contain a root or prefix: {subdir}");
3761    }
3762    if path
3763        .components()
3764        .any(|c| matches!(c, std::path::Component::ParentDir))
3765    {
3766        bail!("state subdir must not contain parent-dir (..) components: {subdir}");
3767    }
3768    Ok(())
3769}
3770
3771/// Resolve a state subdirectory, preferring the CodeWhale root if
3772/// it already exists, otherwise falling back to the legacy root.
3773///
3774/// This is the read-path resolver: it returns the primary path when
3775/// migration has occurred or on a fresh install, but keeps reading
3776/// from the legacy path for users who haven't migrated yet.
3777pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
3778    ensure_safe_state_subdir(subdir)?;
3779    let explicit_codewhale_home = codewhale_home_env_override().is_some();
3780    let primary = codewhale_home()?.join(subdir);
3781    if explicit_codewhale_home || primary.exists() {
3782        return Ok(primary);
3783    }
3784    let legacy = legacy_deepseek_home()?.join(subdir);
3785    if legacy.exists() {
3786        return Ok(legacy);
3787    }
3788    // Neither exists — return primary for first-write creation.
3789    Ok(primary)
3790}
3791
3792/// Ensure a state subdirectory exists under the primary CodeWhale root,
3793/// creating it if necessary. This is the write-path resolver.
3794///
3795/// On the first creation of a real subdirectory (not the root sentinel `"."`),
3796/// if a legacy `~/.deepseek/<subdir>` exists but the primary
3797/// `~/.codewhale/<subdir>` does not, the legacy directory is relocated into
3798/// the primary location so the user keeps their data and the legacy tree
3799/// stops growing (#3240). After migration, [`resolve_state_dir`] finds the
3800/// data in the primary location; the read resolver itself is unchanged.
3801pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
3802    let (dir, migration) = ensure_state_dir_with_migration(subdir)?;
3803    if let Some(migration) = migration {
3804        eprintln!("{}", migration.user_notice());
3805    }
3806    Ok(dir)
3807}
3808
3809#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3810pub enum StateMigrationKind {
3811    Relocated,
3812    Copied,
3813}
3814
3815#[derive(Debug, Clone, PartialEq, Eq)]
3816pub struct StateMigration {
3817    pub subdir: String,
3818    pub legacy_path: PathBuf,
3819    pub primary_path: PathBuf,
3820    pub kind: StateMigrationKind,
3821}
3822
3823impl StateMigration {
3824    pub fn user_notice(&self) -> String {
3825        let action = match self.kind {
3826            StateMigrationKind::Relocated => "relocated",
3827            StateMigrationKind::Copied => "copied",
3828        };
3829        let legacy_detail = match self.kind {
3830            StateMigrationKind::Relocated => {
3831                "The legacy .deepseek copy for this state path was removed by the move."
3832            }
3833            StateMigrationKind::Copied => {
3834                "The legacy .deepseek copy was left in place because a direct move failed."
3835            }
3836        };
3837
3838        format!(
3839            "Codewhale migrated legacy state ({action}):\n  {} -> {}\nYour data was preserved. Use .codewhale as the canonical state location from now on.\n{legacy_detail}\nIf no other apps use it, you can remove the legacy .deepseek tree after confirming everything looks right.",
3840            self.legacy_path.display(),
3841            self.primary_path.display(),
3842        )
3843    }
3844}
3845
3846/// Variant of [`ensure_state_dir`] that exposes whether a legacy state path was
3847/// migrated. Most callers should use [`ensure_state_dir`]; this is kept for
3848/// tests and future UI surfaces that want to render the notice themselves.
3849pub fn ensure_state_dir_with_migration(subdir: &str) -> Result<(PathBuf, Option<StateMigration>)> {
3850    ensure_safe_state_subdir(subdir)?;
3851    let explicit_codewhale_home = codewhale_home_env_override().is_some();
3852    let dir = codewhale_home()?.join(subdir);
3853    let migration = if !explicit_codewhale_home {
3854        migrate_legacy_state_dir(&dir, subdir)?
3855    } else {
3856        None
3857    };
3858    std::fs::create_dir_all(&dir)
3859        .with_context(|| format!("failed to create {}/", dir.display()))?;
3860    Ok((dir, migration))
3861}
3862
3863/// One-time relocation of a legacy `~/.deepseek/<subdir>` state directory into
3864/// the primary `~/.codewhale/<subdir>` location (#3240). No-op once the primary
3865/// exists, for the root sentinel `"."` (a whole-tree move is owned by the
3866/// config-file migration), or when no legacy directory is present.
3867fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<Option<StateMigration>> {
3868    if primary.exists() || subdir == "." || subdir.is_empty() {
3869        return Ok(None);
3870    }
3871    let legacy = match legacy_deepseek_home() {
3872        Ok(home) => home.join(subdir),
3873        Err(_) => return Ok(None),
3874    };
3875    if !legacy.exists() {
3876        return Ok(None);
3877    }
3878    // The primary's parent (the ~/.codewhale root) must exist for the rename.
3879    if let Some(parent) = primary.parent()
3880        && let Err(err) = std::fs::create_dir_all(parent)
3881    {
3882        tracing::warn!(
3883            target: "config::migration",
3884            "Could not create {} for state migration ({}); writing to primary anyway",
3885            parent.display(),
3886            err
3887        );
3888    }
3889    match std::fs::rename(&legacy, primary) {
3890        Ok(()) => {
3891            tracing::info!(
3892                target: "config::migration",
3893                "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
3894                legacy.display(),
3895                primary.display()
3896            );
3897            return Ok(Some(StateMigration {
3898                subdir: subdir.to_string(),
3899                legacy_path: legacy,
3900                primary_path: primary.to_path_buf(),
3901                kind: StateMigrationKind::Relocated,
3902            }));
3903        }
3904        Err(err) => {
3905            // Cross-device rename or permission issue: fall back to a
3906            // recursive copy so the user keeps their data. The legacy tree is
3907            // left in place; it stops growing because writes now target the
3908            // primary path.
3909            match copy_dir_recursive(&legacy, primary) {
3910                Ok(()) => {
3911                    tracing::info!(
3912                        target: "config::migration",
3913                        "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
3914                         The legacy .deepseek copy was left in place.",
3915                        legacy.display(),
3916                        primary.display()
3917                    );
3918                    return Ok(Some(StateMigration {
3919                        subdir: subdir.to_string(),
3920                        legacy_path: legacy,
3921                        primary_path: primary.to_path_buf(),
3922                        kind: StateMigrationKind::Copied,
3923                    }));
3924                }
3925                Err(copy_err) => {
3926                    tracing::warn!(
3927                        target: "config::migration",
3928                        "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
3929                         New data is written to the primary path; the legacy tree remains untouched.",
3930                        legacy.display(),
3931                        primary.display()
3932                    );
3933                }
3934            }
3935        }
3936    }
3937    Ok(None)
3938}
3939
3940/// Recursively copy a directory tree from `src` to `dst`, creating `dst`.
3941/// Symlinks and other non-file/non-dir entries are skipped (rare in state dirs).
3942fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
3943    std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
3944    for entry in
3945        std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
3946    {
3947        let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
3948        let path = entry.path();
3949        let target = dst.join(entry.file_name());
3950        let file_type = entry
3951            .file_type()
3952            .with_context(|| format!("failed to read file type for {}", path.display()))?;
3953        if file_type.is_dir() {
3954            copy_dir_recursive(&path, &target)?;
3955        } else if file_type.is_file() {
3956            std::fs::copy(&path, &target).with_context(|| {
3957                format!("failed to copy {} -> {}", path.display(), target.display())
3958            })?;
3959        }
3960    }
3961    Ok(())
3962}
3963
3964/// Resolve a project-local state subdirectory, preferring `.codewhale/`
3965/// when it exists, falling back to `.deepseek/` for legacy projects.
3966///
3967/// Returns `(true, path)` when the primary `.codewhale/` path is used,
3968/// `(false, path)` for the legacy fallback. The boolean helps callers
3969/// emit a deprecation notice on legacy paths.
3970pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
3971    ensure_safe_state_subdir(subdir)?;
3972    let workspace = normalize_project_workspace(workspace)?;
3973    let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3974    if primary.exists() {
3975        return Ok((true, primary));
3976    }
3977    let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
3978    Ok((false, legacy))
3979}
3980
3981/// Ensure a project-local state subdirectory exists under `.codewhale/`,
3982/// creating it if necessary. Returns the directory path.
3983pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
3984    ensure_safe_state_subdir(subdir)?;
3985    let workspace = normalize_project_workspace(workspace)?;
3986    let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3987    std::fs::create_dir_all(&dir)
3988        .with_context(|| format!("failed to create {}/", dir.display()))?;
3989    Ok(dir)
3990}
3991
3992pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
3993    if let Some(path) = explicit {
3994        return normalize_config_file_path(path);
3995    }
3996    if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
3997        if let Some(path) = config_path_from_env_value(&path)? {
3998            return Ok(path);
3999        }
4000        return default_config_path();
4001    }
4002    if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
4003        if let Some(path) = config_path_from_env_value(&path)? {
4004            return Ok(path);
4005        }
4006        return default_config_path();
4007    }
4008    default_config_path()
4009}
4010
4011fn config_path_from_env_value(path: &str) -> Result<Option<PathBuf>> {
4012    let trimmed = path.trim();
4013    if trimmed.is_empty() {
4014        Ok(None)
4015    } else {
4016        normalize_config_file_path(PathBuf::from(trimmed)).map(Some)
4017    }
4018}
4019
4020#[must_use]
4021pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
4022    config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
4023}
4024
4025fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
4026    checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
4027}
4028
4029pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
4030    checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
4031}
4032
4033/// Read a resolved `permissions.toml` path using the same checked/no-follow
4034/// path handling as config loading.
4035pub fn read_permissions_file(path: &Path) -> Result<String> {
4036    read_checked_permissions_file(path)
4037}
4038
4039fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
4040    let permissions_path = checked_permissions_path_for_config_path(config_path)?;
4041    if !checked_path_exists(&permissions_path)? {
4042        return Ok(PermissionsToml::default());
4043    }
4044
4045    let raw = read_checked_permissions_file(&permissions_path)?;
4046    toml::from_str(&raw).with_context(|| {
4047        format!(
4048            "failed to parse permissions at {}",
4049            permissions_path.display()
4050        )
4051    })
4052}
4053
4054fn append_ask_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
4055    match item {
4056        toml_edit::Item::ArrayOfTables(rules) => {
4057            rules.push(ask_rule_table(rule));
4058            Ok(())
4059        }
4060        toml_edit::Item::Value(value) => {
4061            let Some(rules) = value.as_array_mut() else {
4062                bail!("`rules` in permissions.toml must be an array");
4063            };
4064            rules.push(toml_edit::Value::InlineTable(ask_rule_inline_table(rule)));
4065            Ok(())
4066        }
4067        _ => bail!("`rules` in permissions.toml must be an array"),
4068    }
4069}
4070
4071fn ask_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
4072    let mut table = toml_edit::Table::new();
4073    table["tool"] = toml_edit::value(rule.tool.clone());
4074    if let Some(command) = rule.command.as_deref() {
4075        table["command"] = toml_edit::value(command);
4076    }
4077    if let Some(path) = rule.path.as_deref() {
4078        table["path"] = toml_edit::value(path);
4079    }
4080    table
4081}
4082
4083fn ask_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
4084    let mut table = toml_edit::InlineTable::new();
4085    table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
4086    if let Some(command) = rule.command.as_deref() {
4087        table.insert("command", toml_edit::Value::from(command));
4088    }
4089    if let Some(path) = rule.path.as_deref() {
4090        table.insert("path", toml_edit::Value::from(path));
4091    }
4092    table
4093}
4094
4095fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
4096    let parent = path.parent().with_context(|| {
4097        format!(
4098            "permissions path has no parent directory: {}",
4099            path.display()
4100        )
4101    })?;
4102    fs::create_dir_all(parent).with_context(|| {
4103        format!(
4104            "failed to create permissions directory {}",
4105            parent.display()
4106        )
4107    })?;
4108
4109    let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
4110        format!(
4111            "failed to create temporary permissions file in {}",
4112            parent.display()
4113        )
4114    })?;
4115    #[cfg(unix)]
4116    temporary
4117        .as_file()
4118        .set_permissions(fs::Permissions::from_mode(0o600))
4119        .with_context(|| {
4120            format!(
4121                "failed to secure temporary permissions file for {}",
4122                path.display()
4123            )
4124        })?;
4125    temporary
4126        .write_all(body)
4127        .with_context(|| format!("failed to write permissions at {}", path.display()))?;
4128    temporary
4129        .as_file()
4130        .sync_all()
4131        .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
4132    temporary
4133        .persist(path)
4134        .map_err(|error| error.error)
4135        .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
4136    Ok(())
4137}
4138
4139pub fn default_config_path() -> Result<PathBuf> {
4140    // Prefer ~/.codewhale/config.toml when it exists (fresh install or
4141    // migrated), otherwise fall back to ~/.deepseek/config.toml.
4142    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
4143    if codewhale_home_is_explicit() || primary.exists() {
4144        return Ok(primary);
4145    }
4146    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
4147    if legacy.exists() {
4148        return Ok(legacy);
4149    }
4150    // Neither exists — return primary so first write creates it there.
4151    Ok(primary)
4152}
4153
4154#[derive(Debug, Clone, PartialEq, Eq)]
4155pub struct ConfigMigration {
4156    pub legacy_path: PathBuf,
4157    pub primary_path: PathBuf,
4158}
4159
4160impl ConfigMigration {
4161    pub fn user_notice(&self) -> String {
4162        format!(
4163            "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
4164            self.legacy_path.display(),
4165            self.primary_path.display()
4166        )
4167    }
4168}
4169
4170/// v0.8.44: one-time migration from `~/.deepseek/config.toml` to
4171/// `~/.codewhale/config.toml`. Called on first launch after the config
4172/// is loaded; copies the legacy file if the primary doesn't exist yet.
4173/// Never overwrites an existing primary config.
4174pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
4175    if codewhale_home_is_explicit() {
4176        return Ok(None);
4177    }
4178    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
4179    if primary.exists() {
4180        return Ok(None);
4181    }
4182    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
4183    if !legacy.exists() {
4184        return Ok(None);
4185    }
4186    // Copy the config to the new home.
4187    if let Some(parent) = primary.parent() {
4188        std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
4189    }
4190    std::fs::copy(&legacy, &primary)
4191        .context("failed to migrate config from deepseek to codewhale home")?;
4192    tracing::info!(
4193        "Migrated config from {} to {}",
4194        legacy.display(),
4195        primary.display()
4196    );
4197    Ok(Some(ConfigMigration {
4198        legacy_path: legacy,
4199        primary_path: primary,
4200    }))
4201}
4202
4203fn parse_bool(raw: &str) -> Result<bool> {
4204    match raw.trim().to_ascii_lowercase().as_str() {
4205        "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
4206        "0" | "false" | "no" | "off" | "disabled" => Ok(false),
4207        _ => bail!("invalid boolean '{raw}'"),
4208    }
4209}
4210
4211fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
4212    let mut headers = BTreeMap::new();
4213    for pair in raw.trim().split(',') {
4214        let pair = pair.trim();
4215        if pair.is_empty() {
4216            continue;
4217        }
4218        let Some((name, value)) = pair.split_once('=') else {
4219            bail!("invalid header pair '{pair}', expected name=value");
4220        };
4221        let name = name.trim();
4222        let value = value.trim();
4223        if name.is_empty() {
4224            bail!("header name cannot be empty");
4225        }
4226        if value.is_empty() {
4227            continue;
4228        }
4229        headers.insert(name.to_string(), value.to_string());
4230    }
4231    Ok(headers)
4232}
4233
4234fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
4235    if headers.is_empty() {
4236        return None;
4237    }
4238    Some(
4239        headers
4240            .iter()
4241            .map(|(name, value)| format!("{name}={value}"))
4242            .collect::<Vec<_>>()
4243            .join(","),
4244    )
4245}
4246
4247fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
4248    if headers.is_empty() {
4249        return None;
4250    }
4251    Some(
4252        headers
4253            .iter()
4254            .map(|(name, value)| {
4255                let display_value = if is_sensitive_config_key(name) {
4256                    redact_secret(value)
4257                } else {
4258                    value.clone()
4259                };
4260                format!("{name}={display_value}")
4261            })
4262            .collect::<Vec<_>>()
4263            .join(","),
4264    )
4265}
4266
4267fn redact_secret(secret: &str) -> String {
4268    let chars: Vec<char> = secret.chars().collect();
4269    if chars.len() <= 16 {
4270        return "********".to_string();
4271    }
4272    let prefix: String = chars.iter().take(4).collect();
4273    let suffix: String = chars
4274        .iter()
4275        .rev()
4276        .take(4)
4277        .collect::<Vec<_>>()
4278        .into_iter()
4279        .rev()
4280        .collect();
4281    format!("{prefix}***{suffix}")
4282}
4283
4284#[must_use]
4285pub fn is_sensitive_config_key(key: &str) -> bool {
4286    let Some(segment) = key.rsplit('.').next() else {
4287        return false;
4288    };
4289    let normalized = segment
4290        .trim()
4291        .trim_matches('"')
4292        .replace('-', "_")
4293        .to_ascii_lowercase();
4294
4295    matches!(
4296        normalized.as_str(),
4297        "api_key"
4298            | "apikey"
4299            | "api_keys"
4300            | "authorization"
4301            | "bearer"
4302            | "client_secret"
4303            | "credential"
4304            | "credentials"
4305            | "id_token"
4306            | "password"
4307            | "passwords"
4308            | "passwd"
4309            | "proxy_authorization"
4310            | "refresh_token"
4311            | "secret"
4312            | "secrets"
4313            | "token"
4314            | "tokens"
4315    ) || normalized.ends_with("_api_key")
4316        || normalized.ends_with("_authorization")
4317        || normalized.ends_with("_password")
4318        || normalized.ends_with("_secret")
4319        || normalized.ends_with("_token")
4320}
4321
4322fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
4323    redact_toml_value_for_display_inner(key, false, value).to_string()
4324}
4325
4326fn toml_value_as_u64(value: &toml::Value) -> Option<u64> {
4327    match value {
4328        toml::Value::Integer(value) => u64::try_from(*value).ok(),
4329        toml::Value::String(value) => value.trim().parse().ok(),
4330        _ => None,
4331    }
4332}
4333
4334fn redact_toml_value_for_display_inner(
4335    key: &str,
4336    sensitive_ancestor: bool,
4337    value: &toml::Value,
4338) -> toml::Value {
4339    let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
4340    match value {
4341        toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
4342        toml::Value::Array(values) => toml::Value::Array(
4343            values
4344                .iter()
4345                .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
4346                .collect(),
4347        ),
4348        toml::Value::Table(table) => {
4349            let mut redacted = toml::map::Map::new();
4350            for (child_key, child_value) in table {
4351                let path = if key.is_empty() {
4352                    child_key.clone()
4353                } else {
4354                    format!("{key}.{child_key}")
4355                };
4356                redacted.insert(
4357                    child_key.clone(),
4358                    redact_toml_value_for_display_inner(&path, sensitive, child_value),
4359                );
4360            }
4361            toml::Value::Table(redacted)
4362        }
4363        _ if sensitive => toml::Value::String("********".to_string()),
4364        _ => value.clone(),
4365    }
4366}
4367
4368fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
4369    if path.as_os_str().is_empty() {
4370        bail!("config path cannot be empty");
4371    }
4372    if path
4373        .components()
4374        .any(|component| matches!(component, Component::ParentDir))
4375    {
4376        bail!("config path cannot contain '..' components");
4377    }
4378    if path.file_name().is_none() {
4379        bail!("config path must include a file name");
4380    }
4381    let absolute = if path.is_absolute() {
4382        path
4383    } else {
4384        std::env::current_dir()
4385            .context("failed to resolve current directory for config path")?
4386            .join(path)
4387    };
4388    let file_name = absolute
4389        .file_name()
4390        .map(OsString::from)
4391        .context("config path must include a file name")?;
4392    let parent = absolute
4393        .parent()
4394        .context("config path must include a parent directory")?;
4395    let parent = match parent.canonicalize() {
4396        Ok(parent) => parent,
4397        Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
4398        Err(err) => {
4399            return Err(err).with_context(|| {
4400                format!("failed to resolve config directory {}", parent.display())
4401            });
4402        }
4403    };
4404    let normalized = parent.join(file_name);
4405    reject_path_symlink(&normalized)?;
4406    Ok(normalized)
4407}
4408
4409fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
4410    if workspace.as_os_str().is_empty() {
4411        bail!("project workspace path cannot be empty");
4412    }
4413    if workspace
4414        .components()
4415        .any(|component| matches!(component, Component::ParentDir))
4416    {
4417        bail!("project workspace path cannot contain '..' components");
4418    }
4419    let absolute = if workspace.is_absolute() {
4420        workspace.to_path_buf()
4421    } else {
4422        std::env::current_dir()
4423            .context("failed to resolve current directory for project workspace")?
4424            .join(workspace)
4425    };
4426    match absolute.canonicalize() {
4427        Ok(path) => Ok(path),
4428        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
4429            Ok(normalize_path_components(&absolute))
4430        }
4431        Err(err) => Err(err).with_context(|| {
4432            format!(
4433                "failed to resolve project workspace {}",
4434                workspace.display()
4435            )
4436        }),
4437    }
4438}
4439
4440fn normalize_path_components(path: &Path) -> PathBuf {
4441    let mut normalized = PathBuf::new();
4442    for component in path.components() {
4443        match component {
4444            Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
4445            Component::CurDir => {}
4446            Component::ParentDir => {
4447                normalized.pop();
4448            }
4449            Component::Normal(part) => normalized.push(part),
4450        }
4451    }
4452    if normalized.as_os_str().is_empty() {
4453        PathBuf::from(".")
4454    } else {
4455        normalized
4456    }
4457}
4458
4459fn checked_path_exists(path: &Path) -> Result<bool> {
4460    let path = normalize_config_file_path(path.to_path_buf())?;
4461    path.try_exists()
4462        .with_context(|| format!("failed to inspect config path {}", path.display()))
4463}
4464
4465fn read_checked_config_file(path: &Path) -> Result<String> {
4466    read_checked_toml_file(path, "config")
4467}
4468
4469fn read_checked_permissions_file(path: &Path) -> Result<String> {
4470    read_checked_toml_file(path, "permissions")
4471}
4472
4473fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
4474    let path = normalize_config_file_path(path.to_path_buf())?;
4475    read_string_no_follow(&path)
4476        .with_context(|| format!("failed to read {label} at {}", path.display()))
4477}
4478
4479#[cfg(unix)]
4480fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
4481    let mut file = fs::OpenOptions::new()
4482        .read(true)
4483        .custom_flags(libc::O_NOFOLLOW)
4484        .open(path)?;
4485    let mut raw = String::new();
4486    file.read_to_string(&mut raw)?;
4487    Ok(raw)
4488}
4489
4490#[cfg(not(unix))]
4491fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
4492    fs::read_to_string(path)
4493}
4494
4495fn reject_path_symlink(path: &Path) -> Result<()> {
4496    let Ok(metadata) = fs::symlink_metadata(path) else {
4497        return Ok(());
4498    };
4499    if metadata.file_type().is_symlink() {
4500        bail!("config path must not be a symlink: {}", path.display());
4501    }
4502    Ok(())
4503}
4504
4505#[derive(Debug, Clone, Default)]
4506struct EnvRuntimeOverrides {
4507    provider: Option<ProviderKind>,
4508    provider_source: Option<&'static str>,
4509    model: Option<String>,
4510    volcengine_model: Option<String>,
4511    wanjie_ark_model: Option<String>,
4512    openrouter_model: Option<String>,
4513    moonshot_model: Option<String>,
4514    xiaomi_mimo_model: Option<String>,
4515    xiaomi_mimo_mode: Option<String>,
4516    novita_model: Option<String>,
4517    fireworks_model: Option<String>,
4518    arcee_model: Option<String>,
4519    output_mode: Option<String>,
4520    auth_mode: Option<String>,
4521    log_level: Option<String>,
4522    telemetry: Option<bool>,
4523    approval_policy: Option<String>,
4524    sandbox_mode: Option<String>,
4525    yolo: Option<bool>,
4526    verbosity: Option<String>,
4527    http_headers: Option<BTreeMap<String, String>>,
4528    deepseek_base_url: Option<String>,
4529    deepseek_anthropic_base_url: Option<String>,
4530    nvidia_base_url: Option<String>,
4531    openai_base_url: Option<String>,
4532    atlascloud_base_url: Option<String>,
4533    volcengine_base_url: Option<String>,
4534    wanjie_ark_base_url: Option<String>,
4535    openrouter_base_url: Option<String>,
4536    xiaomi_mimo_base_url: Option<String>,
4537    novita_base_url: Option<String>,
4538    fireworks_base_url: Option<String>,
4539    siliconflow_base_url: Option<String>,
4540    siliconflow_model: Option<String>,
4541    arcee_base_url: Option<String>,
4542    moonshot_base_url: Option<String>,
4543    sglang_base_url: Option<String>,
4544    vllm_base_url: Option<String>,
4545    ollama_base_url: Option<String>,
4546    huggingface_base_url: Option<String>,
4547    huggingface_model: Option<String>,
4548    together_base_url: Option<String>,
4549    together_model: Option<String>,
4550    qianfan_base_url: Option<String>,
4551    qianfan_model: Option<String>,
4552    openai_codex_base_url: Option<String>,
4553    openai_codex_model: Option<String>,
4554    anthropic_base_url: Option<String>,
4555    anthropic_model: Option<String>,
4556    openmodel_base_url: Option<String>,
4557    openmodel_model: Option<String>,
4558    zai_base_url: Option<String>,
4559    zai_model: Option<String>,
4560    stepfun_base_url: Option<String>,
4561    stepfun_model: Option<String>,
4562    minimax_base_url: Option<String>,
4563    minimax_anthropic_base_url: Option<String>,
4564    minimax_model: Option<String>,
4565    deepinfra_base_url: Option<String>,
4566    deepinfra_model: Option<String>,
4567    sakana_base_url: Option<String>,
4568    sakana_model: Option<String>,
4569    longcat_base_url: Option<String>,
4570    longcat_model: Option<String>,
4571    meta_base_url: Option<String>,
4572    meta_model: Option<String>,
4573    xai_base_url: Option<String>,
4574    xai_model: Option<String>,
4575}
4576
4577impl EnvRuntimeOverrides {
4578    fn load() -> Self {
4579        let (provider, provider_source) = Self::load_provider();
4580        Self {
4581            provider,
4582            provider_source,
4583            model: std::env::var("CODEWHALE_MODEL")
4584                .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
4585                .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
4586                .ok()
4587                .filter(|v| !v.trim().is_empty()),
4588            volcengine_model: std::env::var("VOLCENGINE_MODEL")
4589                .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
4590                .ok()
4591                .filter(|v| !v.trim().is_empty()),
4592            wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
4593                .or_else(|_| std::env::var("WANJIE_MODEL"))
4594                .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
4595                .ok()
4596                .filter(|v| !v.trim().is_empty()),
4597            openrouter_model: std::env::var("OPENROUTER_MODEL")
4598                .ok()
4599                .filter(|v| !v.trim().is_empty()),
4600            moonshot_model: std::env::var("MOONSHOT_MODEL")
4601                .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
4602                .or_else(|_| std::env::var("KIMI_MODEL"))
4603                .ok()
4604                .filter(|v| !v.trim().is_empty()),
4605            xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
4606                .or_else(|_| std::env::var("MIMO_MODEL"))
4607                .ok()
4608                .filter(|v| !v.trim().is_empty()),
4609            xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
4610                .or_else(|_| std::env::var("MIMO_MODE"))
4611                .ok()
4612                .filter(|v| !v.trim().is_empty()),
4613            novita_model: std::env::var("NOVITA_MODEL")
4614                .ok()
4615                .filter(|v| !v.trim().is_empty()),
4616            fireworks_model: std::env::var("FIREWORKS_MODEL")
4617                .ok()
4618                .filter(|v| !v.trim().is_empty()),
4619            arcee_model: std::env::var("ARCEE_MODEL")
4620                .ok()
4621                .filter(|v| !v.trim().is_empty()),
4622            verbosity: std::env::var("CODEWHALE_VERBOSITY")
4623                .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
4624                .ok(),
4625            output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
4626            auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
4627            log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
4628            telemetry: std::env::var("DEEPSEEK_TELEMETRY")
4629                .ok()
4630                .and_then(|v| match parse_bool(&v) {
4631                    Ok(b) => Some(b),
4632                    Err(_) => {
4633                        tracing::warn!("Invalid DEEPSEEK_TELEMETRY value '{v}', expected true/false");
4634                        None
4635                    }
4636                }),
4637            approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
4638            sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
4639            yolo: std::env::var("DEEPSEEK_YOLO")
4640                .ok()
4641                .and_then(|v| match parse_bool(&v) {
4642                    Ok(b) => Some(b),
4643                    Err(_) => {
4644                        tracing::warn!("Invalid DEEPSEEK_YOLO value '{v}', expected true/false");
4645                        None
4646                    }
4647                }),
4648            http_headers: std::env::var("DEEPSEEK_HTTP_HEADERS")
4649                .ok()
4650                .and_then(|value| match parse_http_headers(&value) {
4651                    Ok(h) => Some(h),
4652                    Err(_) => {
4653                        tracing::warn!("Invalid DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
4654                        None
4655                    }
4656                })
4657                .filter(|headers| !headers.is_empty()),
4658            deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
4659                .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
4660                .ok()
4661                .filter(|v| !v.trim().is_empty()),
4662            deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
4663                .or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
4664                .ok()
4665                .filter(|v| !v.trim().is_empty()),
4666            nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
4667                .or_else(|_| std::env::var("NIM_BASE_URL"))
4668                .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
4669                .ok()
4670                .filter(|v| !v.trim().is_empty()),
4671            openai_base_url: std::env::var("OPENAI_BASE_URL")
4672                .ok()
4673                .filter(|v| !v.trim().is_empty()),
4674            atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
4675                .ok()
4676                .filter(|v| !v.trim().is_empty()),
4677            volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
4678                .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
4679                .or_else(|_| std::env::var("ARK_BASE_URL"))
4680                .ok()
4681                .filter(|v| !v.trim().is_empty()),
4682            wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
4683                .or_else(|_| std::env::var("WANJIE_BASE_URL"))
4684                .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
4685                .ok()
4686                .filter(|v| !v.trim().is_empty()),
4687            openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
4688                .ok()
4689                .filter(|v| !v.trim().is_empty()),
4690            xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
4691                .or_else(|_| std::env::var("MIMO_BASE_URL"))
4692                .ok()
4693                .filter(|v| !v.trim().is_empty()),
4694            novita_base_url: std::env::var("NOVITA_BASE_URL")
4695                .ok()
4696                .filter(|v| !v.trim().is_empty()),
4697            fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
4698                .ok()
4699                .filter(|v| !v.trim().is_empty()),
4700            siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
4701                .ok()
4702                .filter(|v| !v.trim().is_empty()),
4703            siliconflow_model: std::env::var("SILICONFLOW_MODEL")
4704                .ok()
4705                .filter(|v| !v.trim().is_empty()),
4706            arcee_base_url: std::env::var("ARCEE_BASE_URL")
4707                .ok()
4708                .filter(|v| !v.trim().is_empty()),
4709            moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
4710                .or_else(|_| std::env::var("KIMI_BASE_URL"))
4711                .ok()
4712                .filter(|v| !v.trim().is_empty()),
4713            sglang_base_url: std::env::var("SGLANG_BASE_URL")
4714                .ok()
4715                .filter(|v| !v.trim().is_empty()),
4716            vllm_base_url: std::env::var("VLLM_BASE_URL")
4717                .ok()
4718                .filter(|v| !v.trim().is_empty()),
4719            ollama_base_url: std::env::var("OLLAMA_BASE_URL")
4720                .ok()
4721                .filter(|v| !v.trim().is_empty()),
4722            huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
4723                .or_else(|_| std::env::var("HF_BASE_URL"))
4724                .ok()
4725                .filter(|v| !v.trim().is_empty()),
4726            huggingface_model: std::env::var("HUGGINGFACE_MODEL")
4727                .or_else(|_| std::env::var("HF_MODEL"))
4728                .ok()
4729                .filter(|v| !v.trim().is_empty()),
4730            together_base_url: std::env::var("TOGETHER_BASE_URL")
4731                .ok()
4732                .filter(|v| !v.trim().is_empty()),
4733            together_model: std::env::var("TOGETHER_MODEL")
4734                .ok()
4735                .filter(|v| !v.trim().is_empty()),
4736            qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
4737                .ok()
4738                .filter(|v| !v.trim().is_empty())
4739                .or_else(|| {
4740                    std::env::var("BAIDU_QIANFAN_BASE_URL")
4741                        .ok()
4742                        .filter(|v| !v.trim().is_empty())
4743                }),
4744            qianfan_model: std::env::var("QIANFAN_MODEL")
4745                .ok()
4746                .filter(|v| !v.trim().is_empty())
4747                .or_else(|| {
4748                    std::env::var("BAIDU_QIANFAN_MODEL")
4749                        .ok()
4750                        .filter(|v| !v.trim().is_empty())
4751                }),
4752            openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
4753                .or_else(|_| std::env::var("CODEX_BASE_URL"))
4754                .ok()
4755                .filter(|v| !v.trim().is_empty()),
4756            openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
4757                .or_else(|_| std::env::var("CODEX_MODEL"))
4758                .ok()
4759                .filter(|v| !v.trim().is_empty()),
4760            anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
4761                .ok()
4762                .filter(|v| !v.trim().is_empty()),
4763            anthropic_model: std::env::var("ANTHROPIC_MODEL")
4764                .ok()
4765                .filter(|v| !v.trim().is_empty()),
4766            openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
4767                .ok()
4768                .filter(|v| !v.trim().is_empty()),
4769            openmodel_model: std::env::var("OPENMODEL_MODEL")
4770                .ok()
4771                .filter(|v| !v.trim().is_empty()),
4772            zai_base_url: std::env::var("ZAI_BASE_URL")
4773                .or_else(|_| std::env::var("Z_AI_BASE_URL"))
4774                .or_else(|_| std::env::var("ZHIPU_BASE_URL"))
4775                .or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
4776                .or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
4777                .ok()
4778                .filter(|v| !v.trim().is_empty()),
4779            zai_model: std::env::var("ZAI_MODEL")
4780                .or_else(|_| std::env::var("Z_AI_MODEL"))
4781                .or_else(|_| std::env::var("ZHIPU_MODEL"))
4782                .or_else(|_| std::env::var("ZHIPUAI_MODEL"))
4783                .or_else(|_| std::env::var("BIGMODEL_MODEL"))
4784                .or_else(|_| std::env::var("GLM_MODEL"))
4785                .ok()
4786                .filter(|v| !v.trim().is_empty()),
4787            stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
4788                .or_else(|_| std::env::var("STEP_BASE_URL"))
4789                .ok()
4790                .filter(|v| !v.trim().is_empty()),
4791            stepfun_model: std::env::var("STEPFUN_MODEL")
4792                .or_else(|_| std::env::var("STEP_MODEL"))
4793                .ok()
4794                .filter(|v| !v.trim().is_empty()),
4795            minimax_base_url: std::env::var("MINIMAX_BASE_URL")
4796                .ok()
4797                .filter(|v| !v.trim().is_empty()),
4798            minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
4799                .ok()
4800                .filter(|v| !v.trim().is_empty()),
4801            minimax_model: std::env::var("MINIMAX_MODEL")
4802                .ok()
4803                .filter(|v| !v.trim().is_empty()),
4804            deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
4805                .ok()
4806                .filter(|v| !v.trim().is_empty()),
4807            deepinfra_model: std::env::var("DEEPINFRA_MODEL")
4808                .ok()
4809                .filter(|v| !v.trim().is_empty()),
4810            sakana_base_url: std::env::var("SAKANA_BASE_URL")
4811                .ok()
4812                .filter(|v| !v.trim().is_empty()),
4813            sakana_model: std::env::var("SAKANA_MODEL")
4814                .ok()
4815                .filter(|v| !v.trim().is_empty()),
4816            longcat_base_url: std::env::var("LONGCAT_BASE_URL")
4817                .ok()
4818                .filter(|v| !v.trim().is_empty()),
4819            longcat_model: std::env::var("LONGCAT_MODEL")
4820                .ok()
4821                .filter(|v| !v.trim().is_empty()),
4822            meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
4823                .ok()
4824                .filter(|v| !v.trim().is_empty())
4825                .or_else(|| {
4826                    std::env::var("MODEL_API_BASE_URL")
4827                        .ok()
4828                        .filter(|v| !v.trim().is_empty())
4829                }),
4830            meta_model: std::env::var("META_MODEL_API_MODEL")
4831                .ok()
4832                .filter(|v| !v.trim().is_empty())
4833                .or_else(|| {
4834                    std::env::var("MODEL_API_MODEL")
4835                        .ok()
4836                        .filter(|v| !v.trim().is_empty())
4837                }),
4838            xai_base_url: std::env::var("XAI_BASE_URL")
4839                .ok()
4840                .filter(|v| !v.trim().is_empty()),
4841            xai_model: std::env::var("XAI_MODEL")
4842                .ok()
4843                .filter(|v| !v.trim().is_empty()),
4844        }
4845    }
4846
4847    fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
4848        if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
4849            let parsed = ProviderKind::parse(&value);
4850            return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
4851        }
4852
4853        if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
4854            let parsed = ProviderKind::parse(&value);
4855            return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
4856        }
4857
4858        (None, None)
4859    }
4860
4861    fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
4862        // Defaults belong in the resolver's final fallback so config-file
4863        // values (`providers.<name>.base_url`) still win when env is unset.
4864        match provider {
4865            ProviderKind::Deepseek => self.deepseek_base_url.clone(),
4866            ProviderKind::DeepseekAnthropic => self.deepseek_anthropic_base_url.clone(),
4867            ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
4868            ProviderKind::Openai => self.openai_base_url.clone(),
4869            ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
4870            ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
4871            ProviderKind::Volcengine => self.volcengine_base_url.clone(),
4872            ProviderKind::Openrouter => self.openrouter_base_url.clone(),
4873            ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
4874            ProviderKind::Novita => self.novita_base_url.clone(),
4875            ProviderKind::Fireworks => self.fireworks_base_url.clone(),
4876            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4877                self.siliconflow_base_url.clone()
4878            }
4879            ProviderKind::Arcee => self.arcee_base_url.clone(),
4880            ProviderKind::Moonshot => self.moonshot_base_url.clone(),
4881            ProviderKind::Sglang => self.sglang_base_url.clone(),
4882            ProviderKind::Vllm => self.vllm_base_url.clone(),
4883            ProviderKind::Ollama => self.ollama_base_url.clone(),
4884            ProviderKind::Huggingface => self.huggingface_base_url.clone(),
4885            ProviderKind::Together => self.together_base_url.clone(),
4886            ProviderKind::Qianfan => self.qianfan_base_url.clone(),
4887            ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
4888            ProviderKind::Anthropic => self.anthropic_base_url.clone(),
4889            ProviderKind::Openmodel => self.openmodel_base_url.clone(),
4890            ProviderKind::Zai => self.zai_base_url.clone(),
4891            ProviderKind::Stepfun => self.stepfun_base_url.clone(),
4892            ProviderKind::Minimax => self.minimax_base_url.clone(),
4893            ProviderKind::MinimaxAnthropic => self.minimax_anthropic_base_url.clone(),
4894            ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
4895            ProviderKind::Sakana => self.sakana_base_url.clone(),
4896            ProviderKind::LongCat => self.longcat_base_url.clone(),
4897            ProviderKind::Meta => self.meta_base_url.clone(),
4898            ProviderKind::Xai => self.xai_base_url.clone(),
4899            // No dedicated CODEWHALE_CUSTOM_BASE_URL env override: a custom
4900            // provider's base URL comes from its `[providers.<name>]` table.
4901            ProviderKind::Custom => None,
4902        }
4903    }
4904
4905    fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
4906        let model = match provider {
4907            ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
4908            ProviderKind::Volcengine => self.volcengine_model.clone(),
4909            ProviderKind::Openrouter => self.openrouter_model.clone(),
4910            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4911                self.siliconflow_model.clone()
4912            }
4913            ProviderKind::Arcee => self.arcee_model.clone(),
4914            ProviderKind::Moonshot => self.moonshot_model.clone(),
4915            ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
4916            ProviderKind::Novita => self.novita_model.clone(),
4917            ProviderKind::Fireworks => self.fireworks_model.clone(),
4918            ProviderKind::Huggingface => self.huggingface_model.clone(),
4919            ProviderKind::Together => self.together_model.clone(),
4920            ProviderKind::Qianfan => self.qianfan_model.clone(),
4921            ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
4922            ProviderKind::Anthropic => self.anthropic_model.clone(),
4923            ProviderKind::Openmodel => self.openmodel_model.clone(),
4924            ProviderKind::Zai => self.zai_model.clone(),
4925            ProviderKind::Stepfun => self.stepfun_model.clone(),
4926            ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => self.minimax_model.clone(),
4927            ProviderKind::Deepinfra => self.deepinfra_model.clone(),
4928            ProviderKind::Sakana => self.sakana_model.clone(),
4929            ProviderKind::LongCat => self.longcat_model.clone(),
4930            ProviderKind::Meta => self.meta_model.clone(),
4931            ProviderKind::Xai => self.xai_model.clone(),
4932            _ => None,
4933        }?;
4934
4935        if provider_preserves_custom_base_url_model(provider, base_url) {
4936            Some(model.trim().to_string())
4937        } else {
4938            Some(normalize_model_for_provider(provider, &model))
4939        }
4940    }
4941}
4942
4943#[cfg(test)]
4944mod tests;