Skip to main content

codewhale_config/
lib.rs

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