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