Skip to main content

codewhale_config/
lib.rs

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