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