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