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