Skip to main content

codewhale_config/
lib.rs

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