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