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