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 == "telemetry" {
2871            // #5441: telemetry resolves ON by default, so an unset file key
2872            // must not read "key not found" (or as a bare file value) on a
2873            // machine whose batches ship. Show the resolved consent with its
2874            // source — a truth change, not a behaviour change.
2875            let (on, source) = resolved_telemetry_consent(self.telemetry);
2876            return Some(format!(
2877                "{} ({})",
2878                if on { "on" } else { "off" },
2879                source.as_str()
2880            ));
2881        }
2882
2883        if key == "http_headers" {
2884            return serialize_http_headers_for_display(&self.http_headers);
2885        }
2886
2887        if let Some(value) = self.extras.get(key) {
2888            return Some(redact_toml_value_for_display(key, value));
2889        }
2890
2891        self.get_value(key).map(|value| {
2892            if is_sensitive_config_key(key) {
2893                redact_secret(&value)
2894            } else {
2895                value
2896            }
2897        })
2898    }
2899
2900    #[must_use]
2901    pub fn stream_chunk_timeout_secs(&self) -> u64 {
2902        let raw = self
2903            .extras
2904            .get("tui")
2905            .and_then(toml::Value::as_table)
2906            .and_then(|table| table.get("stream_chunk_timeout_secs"))
2907            .and_then(toml_value_as_u64)
2908            .or_else(|| {
2909                self.extras
2910                    .get("tui.stream_chunk_timeout_secs")
2911                    .and_then(toml_value_as_u64)
2912            })
2913            .or_else(|| {
2914                self.extras
2915                    .get("stream_chunk_timeout_secs")
2916                    .and_then(toml_value_as_u64)
2917            })
2918            .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
2919        if raw == 0 {
2920            DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
2921        } else {
2922            raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
2923        }
2924    }
2925
2926    pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
2927        if let Some((provider, field)) = parse_provider_config_key(key) {
2928            return set_provider_config_value(self, provider, field, value);
2929        }
2930        if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2931            return self.set_custom_provider_value(provider_id, field_key, value);
2932        }
2933
2934        match key {
2935            "provider" => {
2936                if let Some(provider) = ProviderKind::parse_config_identity(value) {
2937                    self.provider = provider;
2938                    self.selected_provider_id = None;
2939                } else {
2940                    let provider_id = value.trim();
2941                    self.named_custom_provider_table(provider_id)
2942                        .with_context(|| {
2943                            format!(
2944                                "unknown provider '{value}': expected {} or a configured custom provider",
2945                                ProviderKind::names_hint()
2946                            )
2947                        })?;
2948                    self.provider = ProviderKind::Custom;
2949                    self.selected_provider_id = Some(provider_id.to_string());
2950                }
2951            }
2952            "api_key" => self.api_key = Some(value.to_string()),
2953            "base_url" => self.base_url = Some(value.to_string()),
2954            "http_headers" => self.http_headers = parse_http_headers(value)?,
2955            "default_text_model" => self.default_text_model = Some(value.to_string()),
2956            "model" => self.model = Some(value.to_string()),
2957            "auth.mode" => self.auth_mode = Some(value.to_string()),
2958            "output_mode" => self.output_mode = Some(value.to_string()),
2959            "verbosity" => self.verbosity = Some(value.to_string()),
2960            "log_level" => self.log_level = Some(value.to_string()),
2961            "telemetry" => {
2962                self.telemetry = Some(parse_bool(value)?);
2963            }
2964            // Scheme rules (HTTPS, or loopback HTTP) are enforced where a
2965            // batch would actually be sent, not here: a user must be able to
2966            // stage a value before the machinery that reads it exists.
2967            "telemetry_endpoint" => self.telemetry_endpoint = Some(value.to_string()),
2968            "approval_policy" => self.approval_policy = Some(value.to_string()),
2969            "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
2970            "hook_sinks.unix_socket_path" => {
2971                self.hook_sinks
2972                    .get_or_insert_with(HookSinksToml::default)
2973                    .unix_socket_path = Some(PathBuf::from(value));
2974            }
2975            _ => {
2976                self.extras
2977                    .insert(key.to_string(), toml::Value::String(value.to_string()));
2978            }
2979        }
2980        Ok(())
2981    }
2982
2983    pub fn unset_value(&mut self, key: &str) -> Result<()> {
2984        if let Some((provider, field)) = parse_provider_config_key(key) {
2985            unset_provider_config_value(self, provider, field);
2986            return Ok(());
2987        }
2988        if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2989            self.unset_custom_provider_value(provider_id, field_key);
2990            return Ok(());
2991        }
2992
2993        match key {
2994            "provider" => {
2995                self.provider = ProviderKind::Deepseek;
2996                self.selected_provider_id = None;
2997            }
2998            "api_key" => self.api_key = None,
2999            "base_url" => self.base_url = None,
3000            "http_headers" => self.http_headers.clear(),
3001            "default_text_model" => self.default_text_model = None,
3002            "model" => self.model = None,
3003            "auth.mode" => self.auth_mode = None,
3004            "output_mode" => self.output_mode = None,
3005            "verbosity" => self.verbosity = None,
3006            "log_level" => self.log_level = None,
3007            "telemetry" => self.telemetry = None,
3008            "telemetry_endpoint" => self.telemetry_endpoint = None,
3009            "approval_policy" => self.approval_policy = None,
3010            "sandbox_mode" => self.sandbox_mode = None,
3011            "hook_sinks.unix_socket_path" => {
3012                if let Some(sinks) = self.hook_sinks.as_mut() {
3013                    sinks.unix_socket_path = None;
3014                }
3015            }
3016            _ => {
3017                self.extras.remove(key);
3018            }
3019        }
3020        Ok(())
3021    }
3022
3023    #[must_use]
3024    pub fn list_values(&self) -> BTreeMap<String, String> {
3025        let mut out = BTreeMap::new();
3026        out.insert("provider".to_string(), self.provider_id().to_string());
3027
3028        if let Some(v) = self.api_key.as_ref() {
3029            out.insert("api_key".to_string(), redact_secret(v));
3030        }
3031        if let Some(v) = self.base_url.as_ref() {
3032            out.insert("base_url".to_string(), v.clone());
3033        }
3034        if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
3035            out.insert("http_headers".to_string(), v);
3036        }
3037        if let Some(v) = self.default_text_model.as_ref() {
3038            out.insert("default_text_model".to_string(), v.clone());
3039        }
3040        if let Some(v) = self.model.as_ref() {
3041            out.insert("model".to_string(), v.clone());
3042        }
3043        if let Some(v) = self.auth_mode.as_ref() {
3044            out.insert("auth.mode".to_string(), v.clone());
3045        }
3046        if let Some(v) = self.output_mode.as_ref() {
3047            out.insert("output_mode".to_string(), v.clone());
3048        }
3049        if let Some(v) = self.verbosity.as_ref() {
3050            out.insert("verbosity".to_string(), v.clone());
3051        }
3052        if let Some(v) = self.log_level.as_ref() {
3053            out.insert("log_level".to_string(), v.clone());
3054        }
3055        if let Some(v) = self.telemetry {
3056            out.insert("telemetry".to_string(), v.to_string());
3057        }
3058        if let Some(v) = self.telemetry_endpoint.as_ref() {
3059            out.insert("telemetry_endpoint".to_string(), v.clone());
3060        }
3061        if let Some(v) = self.approval_policy.as_ref() {
3062            out.insert("approval_policy".to_string(), v.clone());
3063        }
3064        if let Some(v) = self.sandbox_mode.as_ref() {
3065            out.insert("sandbox_mode".to_string(), v.clone());
3066        }
3067        if let Some(v) = self
3068            .hook_sinks
3069            .as_ref()
3070            .and_then(|sinks| sinks.unix_socket_path.as_ref())
3071        {
3072            out.insert(
3073                "hook_sinks.unix_socket_path".to_string(),
3074                v.display().to_string(),
3075            );
3076        }
3077
3078        for provider in provider::all_providers().iter().map(|p| p.kind()) {
3079            insert_provider_config_values(
3080                &mut out,
3081                provider,
3082                self.providers.for_provider(provider),
3083            );
3084        }
3085
3086        for (k, v) in &self.extras {
3087            out.insert(k.clone(), redact_toml_value_for_display(k, v));
3088        }
3089        out
3090    }
3091
3092    /// Resolve runtime options without touching platform credential stores.
3093    ///
3094    /// This method keeps library callers prompt-free: CLI flag → config file
3095    /// → environment. Call `resolve_runtime_options_with_secrets` when a
3096    /// user-facing dispatcher should recover credentials from the configured
3097    /// secret store.
3098    #[must_use]
3099    pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
3100        let no_keyring = Secrets::new(std::sync::Arc::new(
3101            codewhale_secrets::InMemoryKeyringStore::new(),
3102        ));
3103        self.resolve_runtime_options_with_secrets(cli, &no_keyring)
3104    }
3105
3106    /// Resolve runtime options using an explicit secrets façade.
3107    ///
3108    /// API-key precedence is **CLI flag → config-file → secret store → environment**.
3109    #[must_use]
3110    pub fn resolve_runtime_options_with_secrets(
3111        &self,
3112        cli: &CliRuntimeOverrides,
3113        secrets: &Secrets,
3114    ) -> ResolvedRuntimeOptions {
3115        let env = EnvRuntimeOverrides::load();
3116        let (provider, provider_source) = if let Some(provider) = cli.provider {
3117            (provider, ProviderSource::Cli)
3118        } else if let Some(provider) = env.provider {
3119            (
3120                provider,
3121                ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
3122            )
3123        } else {
3124            (self.provider, ProviderSource::Config)
3125        };
3126
3127        let mut provider_cfg = if provider == ProviderKind::Custom
3128            && matches!(provider_source, ProviderSource::Config)
3129        {
3130            self.named_custom_provider_config()
3131                .unwrap_or_else(|| self.providers.for_provider(provider).clone())
3132        } else {
3133            self.providers.for_provider(provider).clone()
3134        };
3135        if provider == ProviderKind::SiliconflowCN {
3136            let fb = &self.providers.siliconflow;
3137            if provider_cfg.api_key.is_none() {
3138                provider_cfg.api_key = fb.api_key.clone();
3139            }
3140            if provider_cfg.base_url.is_none() {
3141                provider_cfg.base_url = fb.base_url.clone();
3142            }
3143            if provider_cfg.model.is_none() {
3144                provider_cfg.model = fb.model.clone();
3145            }
3146        }
3147        let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
3148            .then(|| self.api_key.clone())
3149            .flatten();
3150        // Root `base_url` is the legacy DeepSeek field, but Xiaomi MiMo and
3151        // OpenAI Codex also honour it when the per-provider table has no
3152        // endpoint of its own. Silently ignoring a configured root URL while
3153        // also dropping the root model made both routes unusable from a
3154        // minimal top-level config.
3155        let root_base_url = matches!(
3156            provider,
3157            ProviderKind::Deepseek | ProviderKind::XiaomiMimo | ProviderKind::OpenaiCodex
3158        )
3159        .then(|| self.base_url.clone())
3160        .flatten();
3161        let auth_mode = cli
3162            .auth_mode
3163            .clone()
3164            .or_else(|| env.auth_mode.clone())
3165            .or_else(|| provider_cfg.auth_mode.clone())
3166            .or_else(|| self.auth_mode.clone());
3167        let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
3168        let cli_base_url = cli.base_url.clone();
3169        let env_base_url = env.base_url_for(provider);
3170        let file_base_url = provider_cfg.base_url.clone().or(root_base_url);
3171        let base_url_from_file =
3172            cli_base_url.is_none() && env_base_url.is_none() && file_base_url.is_some();
3173        let configured_base_url = cli_base_url.or(env_base_url).or(file_base_url);
3174        let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
3175            env.xiaomi_mimo_mode
3176                .clone()
3177                .or_else(|| provider_cfg.mode.clone())
3178        } else {
3179            None
3180        };
3181        let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
3182            xiaomi_mimo_env_api_key_for_runtime(
3183                xiaomi_mimo_mode.as_deref(),
3184                configured_base_url.as_deref(),
3185            )
3186        } else {
3187            None
3188        };
3189        let explicit_api_key_for_endpoint = cli
3190            .api_key
3191            .as_deref()
3192            .or(from_file.as_deref().filter(|value| {
3193                classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
3194            }))
3195            .or(xiaomi_mimo_env_api_key.as_deref());
3196        let provider_wire = provider_cfg.wire.as_deref();
3197        let base_url = if provider == ProviderKind::XiaomiMimo {
3198            resolve_xiaomi_mimo_base_url(
3199                configured_base_url,
3200                explicit_api_key_for_endpoint,
3201                xiaomi_mimo_mode.as_deref(),
3202            )
3203        } else if is_modelstudio_family(provider) {
3204            resolve_modelstudio_base_url(
3205                configured_base_url,
3206                provider,
3207                provider_cfg.mode.as_deref(),
3208                provider_wire,
3209            )
3210        } else if matches!(
3211            provider,
3212            ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
3213        ) {
3214            resolve_minimax_base_url(configured_base_url, provider, provider_wire)
3215        } else if matches!(
3216            provider,
3217            ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
3218        ) {
3219            resolve_deepseek_base_url(configured_base_url, provider, provider_wire)
3220        } else {
3221            configured_base_url.unwrap_or_else(|| match provider {
3222                ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
3223                ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string(),
3224                ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
3225                ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
3226                ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
3227                ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
3228                ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
3229                ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
3230                ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_BASE_URL.to_string(),
3231                ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
3232                ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
3233                ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
3234                ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
3235                ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
3236                ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
3237                ProviderKind::Moonshot => {
3238                    if auth_mode
3239                        .as_deref()
3240                        .is_some_and(auth_mode_uses_kimi_imported_token)
3241                    {
3242                        DEFAULT_KIMI_CODE_BASE_URL.to_string()
3243                    } else {
3244                        DEFAULT_MOONSHOT_BASE_URL.to_string()
3245                    }
3246                }
3247                ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
3248                ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
3249                ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
3250                ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_BASE_URL.to_string(),
3251                ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
3252                ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
3253                ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL.to_string(),
3254                ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
3255                ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
3256                ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL.to_string(),
3257                ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
3258                ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
3259                ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
3260                ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string(),
3261                ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
3262                ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL.to_string(),
3263                ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(),
3264                ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL.to_string(),
3265                ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL.to_string(),
3266                ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(),
3267                ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(),
3268                ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL.to_string(),
3269                ProviderKind::Google => DEFAULT_GOOGLE_BASE_URL.to_string(),
3270                ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_BASE_URL.to_string(),
3271                ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(),
3272                ProviderKind::Edenai => DEFAULT_EDENAI_BASE_URL.to_string(),
3273                ProviderKind::ModelstudioTokenPlan
3274                | ProviderKind::ModelstudioTokenPlanAnthropic
3275                | ProviderKind::ModelstudioCodingPlan
3276                | ProviderKind::ModelstudioCodingPlanAnthropic => {
3277                    DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL.to_string()
3278                }
3279                // The custom provider has no built-in endpoint; fall back to its
3280                // descriptor placeholder so the lookup is total. Real custom
3281                // routes always supply a configured base_url before this point.
3282                ProviderKind::Custom => provider.provider().default_base_url().to_string(),
3283            })
3284        };
3285        // Released builds represented Ollama Cloud as the local `ollama`
3286        // identity plus one exact hosted base URL. Upgrade only that tuple in
3287        // memory: the parsed config and secret store are never rewritten, and
3288        // neighboring/custom routes retain the local/custom identity.
3289        let legacy_ollama_cloud = provider::migrates_legacy_ollama_cloud_route(provider, &base_url);
3290        let provider = if legacy_ollama_cloud {
3291            ProviderKind::OllamaCloud
3292        } else {
3293            provider
3294        };
3295        // `auth_mode = "none"` is an endpoint contract, so it suppresses every
3296        // credential source (including explicit CLI/config values). Otherwise
3297        // CLI and route-local config win outright. Ambient provider credentials
3298        // are allowed only on the provider's official endpoint family: a saved
3299        // OpenRouter key must never follow `provider = "openrouter"` to an
3300        // unrelated custom gateway merely because the provider id stayed the
3301        // same.
3302        let uses_kimi_imported_token = provider == ProviderKind::Moonshot
3303            && auth_mode
3304                .as_deref()
3305                .is_some_and(auth_mode_uses_kimi_imported_token);
3306        let auth_disabled = auth_mode_disables_api_key(auth_mode.as_deref());
3307        let custom_endpoint = provider_preserves_custom_base_url_model(provider, &base_url);
3308        let (api_key, api_key_source) = if auth_disabled {
3309            (None, None)
3310        } else if let Some(value) = cli.api_key.clone() {
3311            (Some(value), Some(RuntimeApiKeySource::Cli))
3312        } else if uses_kimi_imported_token && !custom_endpoint {
3313            (None, None)
3314        } else if (!custom_endpoint || base_url_from_file)
3315            && let Some(value) = from_file.clone().filter(|value| {
3316                classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
3317            })
3318        {
3319            (Some(value), Some(RuntimeApiKeySource::ConfigFile))
3320        } else if !custom_endpoint
3321            && let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty())
3322        {
3323            (Some(value), Some(RuntimeApiKeySource::Env))
3324        } else if custom_endpoint {
3325            (None, None)
3326        } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
3327            match env_api_key_for_provider(provider) {
3328                Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
3329                None => (None, None),
3330            }
3331        } else {
3332            match stored_api_key_for_provider(secrets, provider, legacy_ollama_cloud) {
3333                Some((value, source)) => {
3334                    let source = match source {
3335                        SecretSource::Keyring => RuntimeApiKeySource::Keyring,
3336                        SecretSource::Env => RuntimeApiKeySource::Env,
3337                    };
3338                    (Some(value), Some(source))
3339                }
3340                None => match env_api_key_for_provider(provider) {
3341                    Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
3342                    None => (None, None),
3343                },
3344            }
3345        };
3346
3347        let env_provider_model = env.model_for(provider, &base_url);
3348        // Root `default_text_model` is the key `codewhale model set` writes and
3349        // the setup wizard writes, for every provider. It used to enter this
3350        // chain only when `provider == Deepseek`, which made this resolver
3351        // disagree with `Config::default_model()` in the TUI — the chain that
3352        // actually builds the request — for every non-DeepSeek provider
3353        // (#4832, #4838). The user's model still shipped; only this resolver,
3354        // and therefore `codewhale model resolve`, reported a provider default.
3355        //
3356        // It is honoured for any provider now, minus the one case the DeepSeek
3357        // gate was accidentally covering: a stale DeepSeek id left behind by a
3358        // provider switch must not be forwarded to an endpoint that cannot
3359        // serve it.
3360        let root_default_model = self
3361            .default_text_model
3362            .clone()
3363            .filter(|model| !root_default_model_is_foreign_to_provider(provider, model, &base_url));
3364        // Derived from the same chain as `model` below so the reported
3365        // provenance cannot drift from the id that is actually used.
3366        let model_source = if cli.model.is_some() {
3367            ModelSource::Cli
3368        } else if env.model.is_some() || env_provider_model.is_some() {
3369            ModelSource::Env
3370        } else if provider_cfg.model.is_some() {
3371            ModelSource::ProviderConfig
3372        } else if root_default_model.is_some() {
3373            ModelSource::RootDefaultTextModel
3374        } else if self.model.is_some() {
3375            ModelSource::RootModel
3376        } else {
3377            ModelSource::ProviderDefault
3378        };
3379        let explicit_model = model_source.is_explicit();
3380        let model = cli
3381            .model
3382            .clone()
3383            .or_else(|| env.model.clone())
3384            .or(env_provider_model)
3385            .or_else(|| provider_cfg.model.clone())
3386            .or(root_default_model)
3387            .or_else(|| self.model.clone())
3388            .unwrap_or_else(|| {
3389                if provider == ProviderKind::Moonshot
3390                    && (auth_mode
3391                        .as_deref()
3392                        .is_some_and(auth_mode_uses_kimi_imported_token)
3393                        || moonshot_base_url_uses_kimi_code(&base_url))
3394                {
3395                    DEFAULT_KIMI_CODE_MODEL.to_string()
3396                } else {
3397                    default_model_for_provider(provider).to_string()
3398                }
3399            });
3400        let model = if provider == ProviderKind::OpencodeGo {
3401            // OpenCode Go's `/models` response also contains models that only
3402            // speak Anthropic Messages. This provider is deliberately bound to
3403            // Chat Completions, so even custom endpoint/env overrides cannot
3404            // promote an incompatible id onto `/chat/completions`.
3405            normalize_model_for_provider(provider, &model)
3406        } else if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
3407            model.trim().to_string()
3408        } else {
3409            normalize_model_for_provider(provider, &model)
3410        };
3411
3412        let mut http_headers = self.http_headers.clone();
3413        http_headers.extend(provider_cfg.http_headers.clone());
3414        if let Some(env_headers) = env.http_headers {
3415            http_headers.extend(env_headers);
3416        }
3417        http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
3418        if auth_disabled {
3419            http_headers.retain(|name, _| !is_upstream_auth_header(name));
3420        }
3421
3422        let output_mode = cli
3423            .output_mode
3424            .clone()
3425            .or_else(|| env.output_mode.clone())
3426            .or_else(|| self.output_mode.clone());
3427        let log_level = cli
3428            .log_level
3429            .clone()
3430            .or_else(|| env.log_level.clone())
3431            .or_else(|| self.log_level.clone());
3432        // Telemetry consent resolves once, in the shared core behind
3433        // [`resolved_telemetry_consent`], so the runtime and the
3434        // doctor/config provenance surfaces cannot disagree about what is
3435        // shipping (#5441). The comments that matter live there: the
3436        // environment/file/default chain, and why every kill switch is a
3437        // floor (`telemetry = false` persisted in the file is the *persistent*
3438        // off switch; an explicit env "off", an unreadable env value, or a
3439        // dispatcher-declared floor forces off regardless of CLI flag or
3440        // config file).
3441        let (telemetry_env_file, telemetry_source_env_file) = telemetry_consent_from_env(
3442            env.telemetry,
3443            env.telemetry_env_invalid,
3444            env.telemetry_floor,
3445            self.telemetry,
3446        );
3447        // The CLI flag is a run-scoped term on top: `--telemetry false` stops
3448        // this run; `--telemetry true` can never climb over a kill switch.
3449        // The source names the CLI only when the CLI term actually decided
3450        // the outcome — a flag that lost to a kill switch is not the provenance.
3451        let telemetry = telemetry_env_file && cli.telemetry != Some(false);
3452        let telemetry_source = if cli.telemetry == Some(false)
3453            || (cli.telemetry == Some(true) && telemetry_env_file)
3454        {
3455            TelemetrySource::Cli
3456        } else {
3457            telemetry_source_env_file
3458        };
3459        let telemetry_persisted_off = self.telemetry == Some(false);
3460        // Only a *persisted* off is an answer. `--telemetry false` and
3461        // `CODEWHALE_TELEMETRY=0` are run-scoped kill switches: they must stop
3462        // this run without deleting the identity and buffered events of a user
3463        // who never revoked consent — the dispatcher forwards a resolved
3464        // `false` on every ordinary run, so treating an environment "off" as an
3465        // answer would also make the default state indistinguishable from a
3466        // revocation.
3467        let telemetry_explicit_off = telemetry_persisted_off;
3468        // The shipped default is [`DEFAULT_TELEMETRY_ENDPOINT`], and it is a
3469        // default rather than a floor: an explicit value in the environment or
3470        // the config file wins outright. An explicit *empty* value is not a
3471        // missing value — it is the local dry-run sink, and it stays reachable
3472        // by resolving to `None` instead of falling through to the default.
3473        //
3474        // None of this changes the user's opt-out. A session only reaches an
3475        // endpoint after `telemetry` above resolved true; every persistent and
3476        // run-scoped kill switch is upstream of this line.
3477        let telemetry_endpoint = match env
3478            .telemetry_endpoint
3479            .clone()
3480            .or_else(|| self.telemetry_endpoint.clone())
3481        {
3482            Some(configured) if configured.trim().is_empty() => None,
3483            Some(configured) => Some(configured),
3484            None => Some(DEFAULT_TELEMETRY_ENDPOINT.to_string()),
3485        };
3486        let approval_policy = cli
3487            .approval_policy
3488            .clone()
3489            .or_else(|| env.approval_policy.clone())
3490            .or_else(|| self.approval_policy.clone());
3491        let sandbox_mode = cli
3492            .sandbox_mode
3493            .clone()
3494            .or_else(|| env.sandbox_mode.clone())
3495            .or_else(|| self.sandbox_mode.clone());
3496        let yolo = cli.yolo.or(env.yolo);
3497        let verbosity = cli
3498            .verbosity
3499            .clone()
3500            .or_else(|| env.verbosity.clone())
3501            .or_else(|| self.verbosity.clone());
3502
3503        ResolvedRuntimeOptions {
3504            provider,
3505            provider_source,
3506            model,
3507            model_source,
3508            api_key,
3509            api_key_source,
3510            base_url,
3511            auth_mode,
3512            insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
3513            output_mode,
3514            log_level,
3515            telemetry,
3516            telemetry_source,
3517            telemetry_explicit_off,
3518            telemetry_endpoint,
3519            approval_policy,
3520            sandbox_mode,
3521            yolo,
3522            verbosity,
3523            http_headers,
3524        }
3525    }
3526}
3527
3528fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
3529    if source.model.is_some() {
3530        target.model = source.model.clone();
3531    }
3532}
3533
3534/// Where an enabled session's batches go when nobody has said otherwise.
3535///
3536/// The first-party ingest service — a Cloudflare Worker that appends to Workers
3537/// Analytics Engine and stores nothing else. See `docs/TELEMETRY.md` for what a
3538/// batch contains and `telemetry-ingest/` for the handler.
3539///
3540/// This is a *default*, not a floor, and it changes nothing about permission:
3541/// `CODEWHALE_TELEMETRY=0`, `telemetry = false`, and a recorded decline all
3542/// stop the session long before an endpoint is read.
3543///
3544/// An explicit value — `CODEWHALE_TELEMETRY_ENDPOINT` or `telemetry_endpoint` in
3545/// the config file — wins outright, and an explicit *empty* value resolves to no
3546/// endpoint at all, which is the local dry-run sink: batches are serialized
3547/// exactly as a server would see them and appended to
3548/// `$CODEWHALE_HOME/telemetry/dryrun.jsonl`, and no HTTP client is constructed.
3549pub const DEFAULT_TELEMETRY_ENDPOINT: &str = "https://telemetry.codewhale.net/v1/telemetry";
3550
3551/// The dispatcher's statement to the TUI child about *why* telemetry is off.
3552///
3553/// Private to the `codewhale` → `codewhale-tui` hop, in the same spirit as
3554/// `DEEPSEEK_API_KEY_SOURCE`. Set to `1`/`0` on every delegated run.
3555pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
3556
3557/// Whether an environment-level kill switch forces telemetry off here.
3558///
3559/// A floor is *not* the same as "telemetry resolved to false": off is the
3560/// default, and the dispatcher forwards a resolved `CODEWHALE_TELEMETRY=false`
3561/// on every ordinary run, so a child reading only that value cannot tell an
3562/// operator's declared kill switch from the shipped default. That distinction
3563/// matters exactly once — the first-run notice must not ask a question whose
3564/// answer this environment overrides — so the dispatcher states it outright in
3565/// [`TELEMETRY_FLOOR_ENV`] and the child believes the statement.
3566///
3567/// With no statement (a directly launched `codewhale-tui`) the raw environment
3568/// is read instead, where an explicit "off" or an unreadable value is a floor.
3569#[must_use]
3570pub fn telemetry_floor_in_force() -> bool {
3571    if let Ok(raw) = std::env::var(TELEMETRY_FLOOR_ENV)
3572        && let Ok(declared) = parse_bool(&raw)
3573    {
3574        return declared;
3575    }
3576    let Ok(raw) =
3577        std::env::var("CODEWHALE_TELEMETRY").or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
3578    else {
3579        return false;
3580    };
3581    !matches!(parse_bool(&raw), Ok(true))
3582}
3583
3584/// Where resolved telemetry consent came from (#5441).
3585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3586pub enum TelemetrySource {
3587    /// `--telemetry` on this run's command line.
3588    Cli,
3589    /// `CODEWHALE_TELEMETRY`/`DEEPSEEK_TELEMETRY`, including the dispatcher's
3590    /// floor statement and every environment kill switch.
3591    Env,
3592    /// `telemetry = …` written to the config file.
3593    Config,
3594    /// Nobody said anything; the shipped default (`on`) applies silently.
3595    Default,
3596}
3597
3598impl TelemetrySource {
3599    /// Stable label for the doctor row and config display.
3600    #[must_use]
3601    pub const fn as_str(self) -> &'static str {
3602        match self {
3603            Self::Cli => "cli",
3604            Self::Env => "env",
3605            Self::Config => "config",
3606            Self::Default => "default",
3607        }
3608    }
3609}
3610
3611/// Read the telemetry environment override, reporting an unreadable value
3612/// instead of swallowing it.
3613///
3614/// Returns `(value, invalid)`. `invalid` is `true` only when the variable
3615/// was set to something [`parse_bool`] rejected; an unset variable is
3616/// simply `(None, false)`. Shared by the runtime resolver and the
3617/// provenance surfaces so they cannot drift.
3618fn read_telemetry_env() -> (Option<bool>, bool) {
3619    let Some(raw) = std::env::var("CODEWHALE_TELEMETRY")
3620        .or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
3621        .ok()
3622    else {
3623        return (None, false);
3624    };
3625    match parse_bool(&raw) {
3626        Ok(value) => (Some(value), false),
3627        Err(_) => {
3628            tracing::warn!(
3629                "Invalid CODEWHALE_TELEMETRY/DEEPSEEK_TELEMETRY value '{raw}'; expected one of \
3630                 1/0, true/false, yes/no, on/off, enabled/disabled. Telemetry is forced off."
3631            );
3632            (None, true)
3633        }
3634    }
3635}
3636
3637/// Resolved telemetry consent with its source, for surfaces that hold the
3638/// config-file value but not the full CLI/env resolution chain — the doctor
3639/// runtime-posture row and the `config get telemetry` display (#5441).
3640///
3641/// This is the same resolution [`ConfigToml::resolve_runtime_options`]
3642/// applies without its CLI term: environment first (an explicit value, an
3643/// unreadable one, or a dispatcher floor), then the file, then the shipped
3644/// default of `on`; a persisted `telemetry = false` is a floor no later
3645/// term can climb over. The runtime resolver calls this directly, so the
3646/// surfaces and the shipped batches can never disagree.
3647#[must_use]
3648pub fn resolved_telemetry_consent(file_telemetry: Option<bool>) -> (bool, TelemetrySource) {
3649    let (env_telemetry, env_invalid) = read_telemetry_env();
3650    telemetry_consent_from_env(
3651        env_telemetry,
3652        env_invalid,
3653        telemetry_floor_in_force(),
3654        file_telemetry,
3655    )
3656}
3657
3658/// The decision core shared by [`resolved_telemetry_consent`] and the runtime
3659/// resolver, which already holds a snapshot of the same environment facts.
3660#[must_use]
3661fn telemetry_consent_from_env(
3662    env_telemetry: Option<bool>,
3663    env_invalid: bool,
3664    floor: bool,
3665    file_telemetry: Option<bool>,
3666) -> (bool, TelemetrySource) {
3667    let persisted_off = file_telemetry == Some(false);
3668    let allowed = env_telemetry.or(file_telemetry).unwrap_or(true);
3669    let on = allowed && env_telemetry != Some(false) && !env_invalid && !floor && !persisted_off;
3670    let source = if !on && (env_telemetry == Some(false) || env_invalid || floor) {
3671        // An environment kill switch decided the outcome.
3672        TelemetrySource::Env
3673    } else if !on && persisted_off {
3674        // The persistent opt-out outranked everything else in play.
3675        TelemetrySource::Config
3676    } else if env_telemetry.is_some() {
3677        TelemetrySource::Env
3678    } else if file_telemetry.is_some() {
3679        TelemetrySource::Config
3680    } else {
3681        TelemetrySource::Default
3682    };
3683    (on, source)
3684}
3685
3686#[must_use]
3687pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
3688    let Some(project_rank) = approval_policy_rank(project) else {
3689        return false;
3690    };
3691    match current.and_then(approval_policy_rank) {
3692        Some(current_rank) => project_rank >= current_rank,
3693        None => project_rank >= 2,
3694    }
3695}
3696
3697#[must_use]
3698pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
3699    let normalized_project = project.trim().to_ascii_lowercase();
3700    if normalized_project == "external-sandbox" {
3701        return current
3702            .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
3703            .unwrap_or(false);
3704    }
3705
3706    let Some(project_rank) = sandbox_mode_rank(project) else {
3707        return false;
3708    };
3709    match current.and_then(sandbox_mode_rank) {
3710        Some(current_rank) => project_rank >= current_rank,
3711        None => project_rank >= 2,
3712    }
3713}
3714
3715fn approval_policy_rank(value: &str) -> Option<u8> {
3716    match value.trim().to_ascii_lowercase().as_str() {
3717        "auto" => Some(0),
3718        "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
3719        "never" | "deny" | "denied" => Some(2),
3720        _ => None,
3721    }
3722}
3723
3724fn sandbox_mode_rank(value: &str) -> Option<u8> {
3725    match value.trim().to_ascii_lowercase().as_str() {
3726        "danger-full-access" => Some(0),
3727        "external-sandbox" => Some(0),
3728        "workspace-write" => Some(1),
3729        "read-only" => Some(2),
3730        _ => None,
3731    }
3732}
3733
3734/// What [`load_project_config_outcome`] found in the workspace.
3735///
3736/// The distinction between "no project config" and "a project config that is
3737/// broken" is security-relevant, so it is in the type rather than in a log
3738/// line. A project config can only *tighten* `approval_policy` /
3739/// `sandbox_mode` beyond the user's baseline; if a typo makes it unparseable
3740/// and that is reported as absence, the project silently loses its
3741/// restrictions and falls back to the user's more permissive baseline.
3742#[derive(Debug, Clone)]
3743pub enum ProjectConfigOutcome {
3744    /// No project config file exists in this workspace.
3745    Missing,
3746    /// A project config was found and parsed.
3747    Loaded(Box<ConfigToml>),
3748    /// A project config file exists but could not be used. Its contents are
3749    /// deliberately not included — a config file holds credentials.
3750    Invalid {
3751        /// The offending file.
3752        path: PathBuf,
3753        /// Why it could not be used, safe to display.
3754        reason: String,
3755    },
3756}
3757
3758impl ProjectConfigOutcome {
3759    /// The parsed config, discarding the reason a broken one was rejected.
3760    #[must_use]
3761    pub fn into_config(self) -> Option<ConfigToml> {
3762        match self {
3763            Self::Loaded(config) => Some(*config),
3764            Self::Missing | Self::Invalid { .. } => None,
3765        }
3766    }
3767
3768    /// The path and reason when a project config exists but is unusable.
3769    #[must_use]
3770    pub fn invalid(&self) -> Option<(&Path, &str)> {
3771        match self {
3772            Self::Invalid { path, reason } => Some((path.as_path(), reason.as_str())),
3773            Self::Missing | Self::Loaded(_) => None,
3774        }
3775    }
3776}
3777
3778/// Load a project-level config from the workspace, reporting why a file that
3779/// exists could not be used.
3780///
3781/// Checks `$WORKSPACE/.codewhale/config.toml` first, falling back to
3782/// `$WORKSPACE/.deepseek/config.toml` for backward compatibility.
3783pub fn load_project_config_outcome(workspace: &Path) -> ProjectConfigOutcome {
3784    for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
3785        let path = workspace.join(dir).join(CONFIG_FILE_NAME);
3786        if !project_config_candidate_exists(&path) {
3787            continue;
3788        }
3789        let raw = match read_checked_config_file(&path) {
3790            Ok(raw) => raw,
3791            Err(e) => {
3792                tracing::warn!("Failed to read project config {}: {e:#}", path.display());
3793                return ProjectConfigOutcome::Invalid {
3794                    path,
3795                    reason: format!("could not be read: {e}"),
3796                };
3797            }
3798        };
3799        match toml::from_str::<ConfigToml>(&raw) {
3800            Ok(config) => {
3801                let raw_provider = toml::from_str::<toml::Value>(&raw)
3802                    .ok()
3803                    .and_then(|document| document.get("provider").cloned())
3804                    .and_then(|provider| provider.as_str().map(str::to_string));
3805                if config.provider == ProviderKind::Custom
3806                    && raw_provider.as_deref() != Some(ProviderKind::Custom.as_str())
3807                {
3808                    // An unrecognized provider name deserializes to `Custom`
3809                    // rather than failing, so a typo would otherwise be
3810                    // accepted as a deliberate custom-provider selection.
3811                    tracing::warn!(
3812                        "Failed to parse project config {}; file contents were omitted",
3813                        quote_os_path(&path)
3814                    );
3815                    return ProjectConfigOutcome::Invalid {
3816                        path,
3817                        reason: match raw_provider {
3818                            Some(name) => format!("unknown provider '{name}'"),
3819                            None => "unknown provider".to_string(),
3820                        },
3821                    };
3822                }
3823                return ProjectConfigOutcome::Loaded(Box::new(config));
3824            }
3825            Err(err) => {
3826                tracing::warn!(
3827                    "Failed to parse project config {}; file contents were omitted",
3828                    quote_os_path(&path)
3829                );
3830                return ProjectConfigOutcome::Invalid {
3831                    path,
3832                    // `toml`'s message names the offending key and span
3833                    // without echoing the file, so it is safe to surface.
3834                    reason: err.message().to_string(),
3835                };
3836            }
3837        }
3838    }
3839    ProjectConfigOutcome::Missing
3840}
3841
3842/// Load a project-level config from the workspace.
3843///
3844/// Returns `None` both when no project config exists and when one exists but
3845/// is unusable. Callers that act on the *absence* of project restrictions —
3846/// anything deciding whether a project tightens `approval_policy` or
3847/// `sandbox_mode` — should use [`load_project_config_outcome`] instead, so a
3848/// broken file is not read as "this project asked for nothing."
3849pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
3850    load_project_config_outcome(workspace).into_config()
3851}
3852
3853fn project_config_candidate_exists(path: &Path) -> bool {
3854    fs::symlink_metadata(path).is_ok_and(|metadata| {
3855        let file_type = metadata.file_type();
3856        file_type.is_file() || file_type.is_symlink()
3857    })
3858}
3859
3860/// Canonical id for a DeepSeek-family model name, or `None` for anything else.
3861///
3862/// Kept behaviourally identical to `normalize_model_name` in
3863/// `crates/tui/src/config.rs`, which is the definition the TUI's own model
3864/// chain uses. It exists here only so this crate can answer "is this root
3865/// default a DeepSeek id?" without depending on the TUI.
3866fn deepseek_family_model_id(model: &str) -> Option<String> {
3867    let trimmed = model.trim();
3868    if trimmed.is_empty() {
3869        return None;
3870    }
3871    match trimmed.to_ascii_lowercase().as_str() {
3872        "pro" | "deepseek-v4pro" => return Some("deepseek-v4-pro".to_string()),
3873        "flash" | "deepseek-v4flash" => return Some("deepseek-v4-flash".to_string()),
3874        _ => {}
3875    }
3876
3877    let normalized = trimmed.to_ascii_lowercase();
3878    if !normalized.starts_with("deepseek") && !normalized.contains("/deepseek") {
3879        return None;
3880    }
3881    if trimmed
3882        .chars()
3883        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))
3884    {
3885        return Some(trimmed.to_string());
3886    }
3887    None
3888}
3889
3890/// Providers whose model id is forwarded verbatim, because the upstream
3891/// service — not this crate — is the authority on what ids it serves.
3892///
3893/// Mirrors `provider_passes_model_through` in `crates/tui/src/config.rs`.
3894fn provider_passes_model_through(provider: ProviderKind) -> bool {
3895    matches!(
3896        provider,
3897        ProviderKind::Openai
3898            | ProviderKind::Atlascloud
3899            | ProviderKind::WanjieArk
3900            | ProviderKind::Volcengine
3901            | ProviderKind::XiaomiMimo
3902            | ProviderKind::Moonshot
3903            | ProviderKind::Qianfan
3904            | ProviderKind::Openmodel
3905            | ProviderKind::Ollama
3906            | ProviderKind::OllamaCloud
3907            | ProviderKind::Huggingface
3908            | ProviderKind::Meta
3909            | ProviderKind::Xai
3910            | ProviderKind::Telecomjs
3911            | ProviderKind::Edenai
3912            | ProviderKind::ModelstudioTokenPlan
3913            | ProviderKind::ModelstudioTokenPlanAnthropic
3914            | ProviderKind::ModelstudioCodingPlan
3915            | ProviderKind::ModelstudioCodingPlanAnthropic
3916            | ProviderKind::Custom
3917    )
3918}
3919
3920/// Whether a root `default_text_model` would be foreign to the active
3921/// provider's endpoint, i.e. honouring it would send an id the endpoint cannot
3922/// serve.
3923///
3924/// This is the narrow case the old `provider == Deepseek` gate was covering by
3925/// accident: a user switches `provider` and leaves a DeepSeek id behind in
3926/// `default_text_model`. Forwarding `deepseek-chat` to Z.ai fails every
3927/// request, so the root default is dropped and the provider default used
3928/// instead — matching the decision `Config::default_model()` makes via
3929/// `root_deepseek_model_is_foreign_to_direct_provider`
3930/// (`crates/tui/src/config.rs`), whose provider lists this mirrors.
3931fn root_default_model_is_foreign_to_provider(
3932    provider: ProviderKind,
3933    model: &str,
3934    base_url: &str,
3935) -> bool {
3936    // Not a DeepSeek id at all: nothing to protect against here. A model the
3937    // provider does not serve for some other reason is the provider's error to
3938    // report, not ours to silently rewrite.
3939    if deepseek_family_model_id(model).is_none() {
3940        return false;
3941    }
3942    // DeepSeek's own endpoints serve DeepSeek ids.
3943    if matches!(
3944        provider,
3945        ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
3946    ) {
3947        return false;
3948    }
3949    // A custom base URL may be any OpenAI-compatible proxy, and a proxy may
3950    // legitimately serve DeepSeek ids (#1519). Full pass-through.
3951    if provider_preserves_custom_base_url_model(provider, base_url) {
3952        return false;
3953    }
3954    // Vendor-locked official endpoints. These pass model ids through, but
3955    // api.x.ai will never answer to `deepseek-v4-pro`, so pass-through does not
3956    // make the id servable — this is the #3227 contamination case.
3957    if matches!(
3958        provider,
3959        ProviderKind::Xai | ProviderKind::Openai | ProviderKind::Moonshot
3960    ) {
3961        return true;
3962    }
3963    // Remaining pass-through providers forward the id verbatim to a service
3964    // that is the authority on its own catalog.
3965    if provider_passes_model_through(provider) {
3966        return false;
3967    }
3968    // Aggregators, local runtimes, and multi-vendor clouds host DeepSeek
3969    // models under their own catalogs, so a DeepSeek id is valid there.
3970    if matches!(
3971        provider,
3972        ProviderKind::NvidiaNim
3973            | ProviderKind::Openrouter
3974            | ProviderKind::Orcarouter
3975            | ProviderKind::Novita
3976            | ProviderKind::Fireworks
3977            | ProviderKind::Siliconflow
3978            | ProviderKind::SiliconflowCN
3979            | ProviderKind::Deepinfra
3980            | ProviderKind::Together
3981            | ProviderKind::Sglang
3982            | ProviderKind::Vllm
3983            | ProviderKind::Volcengine
3984            | ProviderKind::Atlascloud
3985            | ProviderKind::OpencodeGo
3986            | ProviderKind::WanjieArk
3987    ) {
3988        return false;
3989    }
3990    // Everything else is a vendor serving only its own family (Z.ai, Stepfun,
3991    // MiniMax, Anthropic, …): a DeepSeek id there is the stale-config case.
3992    true
3993}
3994
3995/// A provider owner that Codewhale can identify with high confidence when an
3996/// official route is handed a foreign model id.
3997///
3998/// This intentionally reuses the conservative stale-root-model guard instead
3999/// of treating the partial provider catalog as a closed-world allowlist.
4000/// Unknown ids, custom endpoints, local runtimes, and multi-model gateways
4001/// therefore remain provider-authoritative.
4002#[must_use]
4003pub fn known_foreign_model_owner(
4004    provider: ProviderKind,
4005    model: &str,
4006    base_url: &str,
4007) -> Option<ProviderKind> {
4008    root_default_model_is_foreign_to_provider(provider, model, base_url)
4009        .then_some(ProviderKind::Deepseek)
4010}
4011
4012fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
4013    if matches!(provider, ProviderKind::OpencodeGo) {
4014        // Canonicalize known Chat Completions ids. Unknown / Messages-only ids
4015        // must never be rewritten to the provider default — substituting a
4016        // different model is worse than letting the route layer reject the
4017        // request by the name the user actually configured.
4018        return opencode_go_chat_model_id(model)
4019            .map(str::to_string)
4020            .unwrap_or_else(|| model.trim().to_string());
4021    }
4022    if matches!(provider, ProviderKind::XiaomiMimo)
4023        && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
4024    {
4025        return canonical.to_string();
4026    }
4027    if matches!(
4028        provider,
4029        ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
4030    ) && let Some(canonical) = canonical_minimax_model_id(model)
4031    {
4032        return canonical.to_string();
4033    }
4034    if matches!(provider, ProviderKind::Zai)
4035        && let Some(canonical) = canonical_zai_model_id(model)
4036    {
4037        return canonical.to_string();
4038    }
4039
4040    if matches!(
4041        provider,
4042        ProviderKind::Atlascloud
4043            | ProviderKind::WanjieArk
4044            | ProviderKind::Volcengine
4045            | ProviderKind::XiaomiMimo
4046            | ProviderKind::Zai
4047            | ProviderKind::Stepfun
4048            | ProviderKind::Minimax
4049            | ProviderKind::MinimaxAnthropic
4050            | ProviderKind::Qianfan
4051            | ProviderKind::Ollama
4052            | ProviderKind::OllamaCloud
4053            | ProviderKind::Meta
4054            | ProviderKind::Xai
4055    ) {
4056        return model.to_string();
4057    }
4058
4059    let normalized = model.trim().to_ascii_lowercase();
4060    if provider == ProviderKind::Openrouter
4061        && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
4062    {
4063        return canonical.to_string();
4064    }
4065    if provider == ProviderKind::Orcarouter
4066        && let Some(canonical) = canonical_orcarouter_recent_model_id(&normalized)
4067    {
4068        return canonical.to_string();
4069    }
4070    match (provider, normalized.as_str()) {
4071        (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
4072            DEFAULT_NVIDIA_NIM_MODEL.to_string()
4073        }
4074        (
4075            ProviderKind::NvidiaNim,
4076            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4077            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4078        ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
4079        (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
4080            DEFAULT_OPENROUTER_MODEL.to_string()
4081        }
4082        (
4083            ProviderKind::Openrouter,
4084            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4085            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4086        ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
4087        (ProviderKind::Orcarouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
4088            DEFAULT_ORCAROUTER_MODEL.to_string()
4089        }
4090        (
4091            ProviderKind::Orcarouter,
4092            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4093            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4094        ) => DEFAULT_ORCAROUTER_FLASH_MODEL.to_string(),
4095        (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
4096            DEFAULT_NOVITA_MODEL.to_string()
4097        }
4098        (
4099            ProviderKind::Novita,
4100            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4101            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4102        ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
4103        (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
4104            DEFAULT_FIREWORKS_MODEL.to_string()
4105        }
4106        (
4107            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
4108            "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
4109        ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
4110        (
4111            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
4112            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
4113        ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
4114        (
4115            ProviderKind::Arcee,
4116            "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
4117        ) => DEFAULT_ARCEE_MODEL.to_string(),
4118        (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
4119            ARCEE_TRINITY_MINI_MODEL.to_string()
4120        }
4121        (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
4122            ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
4123        }
4124        (
4125            ProviderKind::Moonshot,
4126            "kimi"
4127            | "kimi-k2"
4128            | "kimi-k2.7"
4129            | "kimi-k2-7"
4130            | "kimi-k2.7-code"
4131            | "kimi-k2-7-code"
4132            | "kimi-code"
4133            | "moonshot-kimi-k2.7-code",
4134        ) => DEFAULT_MOONSHOT_MODEL.to_string(),
4135        (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
4136            MOONSHOT_KIMI_K2_6_MODEL.to_string()
4137        }
4138        (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
4139            DEFAULT_SGLANG_MODEL.to_string()
4140        }
4141        (
4142            ProviderKind::Sglang,
4143            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4144            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4145        ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
4146        (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
4147            DEFAULT_VLLM_MODEL.to_string()
4148        }
4149        (
4150            ProviderKind::Vllm,
4151            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4152            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4153        ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
4154        (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
4155            DEFAULT_HUGGINGFACE_MODEL.to_string()
4156        }
4157        (
4158            ProviderKind::Huggingface,
4159            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4160            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4161        ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
4162        (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
4163            DEFAULT_TOGETHER_MODEL.to_string()
4164        }
4165        (
4166            ProviderKind::Together,
4167            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4168            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4169        ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
4170        (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
4171            DEFAULT_DEEPINFRA_MODEL.to_string()
4172        }
4173        (
4174            ProviderKind::Deepinfra,
4175            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4176            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4177        ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
4178        _ => model.to_string(),
4179    }
4180}
4181
4182/// OpenCode Go models documented for its OpenAI Chat Completions endpoint.
4183///
4184/// Keep config validation, picker/catalog projections, and live-roster
4185/// sanitization on this one protocol-scoped contract. The provider's combined
4186/// `/models` roster also contains Anthropic-Messages-only models, which are
4187/// deliberately absent here.
4188///
4189/// `glm-5.3` is also deliberately absent (2026-08-03): OpenCode Go documents no
4190/// glm-5.3 row. The direct Z.ai and OpenRouter glm-5.3 rows inherit their
4191/// metadata from glm-5.2, but that inheritance says nothing about which
4192/// subscription gateways carry the model. Add it here only against an OpenCode
4193/// Go roster listing.
4194pub const OPENCODE_GO_CHAT_MODELS: &[&str] = &[
4195    DEFAULT_OPENCODE_GO_MODEL,
4196    OPENCODE_GO_GROK_4_5_MODEL,
4197    OPENCODE_GO_GLM_5_2_MODEL,
4198    OPENCODE_GO_GLM_5_1_MODEL,
4199    OPENCODE_GO_KIMI_K3_MODEL,
4200    OPENCODE_GO_KIMI_K2_7_CODE_MODEL,
4201    OPENCODE_GO_KIMI_K2_6_MODEL,
4202    OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL,
4203    OPENCODE_GO_MIMO_V2_5_MODEL,
4204    OPENCODE_GO_MIMO_V2_5_PRO_MODEL,
4205];
4206
4207/// Canonicalize an OpenCode Go model that is documented for the OpenAI Chat
4208/// Completions endpoint. The live `/models` roster also contains
4209/// Anthropic-Messages-only models; returning `None` for those is the protocol
4210/// cutline shared by config and the TUI live-catalog paths.
4211#[must_use]
4212pub fn opencode_go_chat_model_id(model: &str) -> Option<&'static str> {
4213    let normalized = model.trim().to_ascii_lowercase().replace(['_', ' '], "-");
4214    let normalized = normalized
4215        .strip_prefix("opencode-go/")
4216        .unwrap_or(&normalized);
4217    let familiar_alias = match normalized {
4218        "grok-4-5" => Some(OPENCODE_GO_GROK_4_5_MODEL),
4219        "glm-5-2" => Some(OPENCODE_GO_GLM_5_2_MODEL),
4220        "glm-5-1" => Some(OPENCODE_GO_GLM_5_1_MODEL),
4221        "kimi-k2-7-code" => Some(OPENCODE_GO_KIMI_K2_7_CODE_MODEL),
4222        "kimi-k2-6" => Some(OPENCODE_GO_KIMI_K2_6_MODEL),
4223        "deepseek-v4pro" => Some(DEFAULT_OPENCODE_GO_MODEL),
4224        "deepseek-v4flash" => Some(OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL),
4225        "mimo-v2-5" => Some(OPENCODE_GO_MIMO_V2_5_MODEL),
4226        "mimo-v2-5-pro" => Some(OPENCODE_GO_MIMO_V2_5_PRO_MODEL),
4227        _ => None,
4228    };
4229    familiar_alias.or_else(|| {
4230        OPENCODE_GO_CHAT_MODELS
4231            .iter()
4232            .copied()
4233            .find(|candidate| *candidate == normalized)
4234    })
4235}
4236
4237fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
4238    let normalized = model.trim().to_ascii_lowercase();
4239    let normalized = normalized.replace(['_', ' '], "-");
4240    match normalized.as_str() {
4241        "mimo"
4242        | DEFAULT_XIAOMI_MIMO_MODEL
4243        | "mimo-v2-5-pro"
4244        | "xiaomi-mimo-v2.5-pro"
4245        | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
4246        XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL
4247        | "mimo-v2-5-pro-ultraspeed"
4248        | "xiaomi-mimo-v2.5-pro-ultraspeed"
4249        | "xiaomi-mimo-v2-5-pro-ultraspeed"
4250        | "ultraspeed"
4251        | "pro-ultraspeed" => Some(XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL),
4252        "omni"
4253        | "mimo-omni"
4254        | "v2.5-omni"
4255        | "v25-omni"
4256        | "mimo-v2.5"
4257        | "mimo-v25"
4258        | "mimo-v2-5"
4259        | "mimo-v2.5-omni"
4260        | "mimo-v25-omni"
4261        | "mimo-v2-5-omni"
4262        | "xiaomi-mimo-v2.5"
4263        | "xiaomi-mimo-v2-5"
4264        | "xiaomi-mimo-v2.5-omni"
4265        | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
4266        "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
4267            Some(XIAOMI_MIMO_ASR_MODEL)
4268        }
4269        "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
4270            Some(XIAOMI_MIMO_TTS_MODEL)
4271        }
4272        "mimo-tts-voicedesign"
4273        | "mimo-voice-design"
4274        | "mimo-v25-tts-voicedesign"
4275        | "mimo-v2.5-tts-voicedesign"
4276        | "voicedesign"
4277        | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
4278        "mimo-tts-voiceclone"
4279        | "mimo-voice-clone"
4280        | "mimo-v25-tts-voiceclone"
4281        | "mimo-v2.5-tts-voiceclone"
4282        | "voiceclone"
4283        | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
4284        "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
4285        _ => None,
4286    }
4287}
4288
4289fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
4290    let normalized = model.trim().to_ascii_lowercase();
4291    let normalized = normalized.replace(['_', ' '], "-");
4292    match normalized.as_str() {
4293        "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
4294            Some(DEFAULT_MINIMAX_MODEL)
4295        }
4296        "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
4297            Some(MINIMAX_M2_7_MODEL)
4298        }
4299        "minimax-m2.7-highspeed"
4300        | "minimax-m2-7-highspeed"
4301        | "minimax-m-2.7-highspeed"
4302        | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
4303        "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
4304            Some(MINIMAX_M2_5_MODEL)
4305        }
4306        "minimax-m2.5-highspeed"
4307        | "minimax-m2-5-highspeed"
4308        | "minimax-m-2.5-highspeed"
4309        | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
4310        "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
4311            Some(MINIMAX_M2_1_MODEL)
4312        }
4313        "minimax-m2.1-highspeed"
4314        | "minimax-m2-1-highspeed"
4315        | "minimax-m-2.1-highspeed"
4316        | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
4317        "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
4318        _ => None,
4319    }
4320}
4321
4322fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
4323    let normalized = model.trim().to_ascii_lowercase();
4324    let normalized = normalized.replace(['_', ' '], "-");
4325    match normalized.as_str() {
4326        "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
4327        // Every alias resolves to its own id, never through DEFAULT_ZAI_MODEL:
4328        // moving the default (now GLM-5.3) must not silently re-point an
4329        // explicit GLM-5.2 route.
4330        "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(ZAI_GLM_5_2_MODEL),
4331        "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => Some(ZAI_GLM_5_3_MODEL),
4332        "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
4333        _ => None,
4334    }
4335}
4336
4337fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
4338    let normalized = model.trim().to_ascii_lowercase();
4339    let normalized = normalized.replace(['_', ' '], "-");
4340    match normalized.as_str() {
4341        OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
4342        | "trinity"
4343        | "trinity-large-thinking"
4344        | "arcee-trinity"
4345        | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
4346        OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
4347            Some(OPENROUTER_GEMMA_4_31B_MODEL)
4348        }
4349        OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
4350            Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
4351        }
4352        OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
4353            Some(OPENROUTER_GLM_5_1_MODEL)
4354        }
4355        OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
4356            Some(OPENROUTER_GLM_5_2_MODEL)
4357        }
4358        OPENROUTER_GLM_5_3_MODEL | "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => {
4359            Some(OPENROUTER_GLM_5_3_MODEL)
4360        }
4361        OPENROUTER_KIMI_K2_7_CODE_MODEL
4362        | "kimi"
4363        | "kimi-k2"
4364        | "kimi-k2.7"
4365        | "kimi-k2-7"
4366        | "kimi-k2.7-code"
4367        | "kimi-k2-7-code"
4368        | "kimi-code"
4369        | "moonshot-kimi-k2.7-code"
4370        | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
4371        OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
4372            Some(OPENROUTER_KIMI_K2_6_MODEL)
4373        }
4374        OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
4375            Some(OPENROUTER_MINIMAX_M3_MODEL)
4376        }
4377        OPENROUTER_MINIMAX_M2_7_MODEL
4378        | "minimax-2.7"
4379        | "minimax-2-7"
4380        | "minimax-m2.7"
4381        | "minimax-m2-7"
4382        | "minimax-m-2.7"
4383        | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_M2_7_MODEL),
4384        OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
4385        | "nemotron-3-nano-omni"
4386        | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
4387        OPENROUTER_QWEN_3_6_35B_A3B_MODEL
4388        | "qwen3.6-35b-a3b"
4389        | "qwen-3.6-35b-a3b"
4390        | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
4391        OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
4392            Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
4393        }
4394        OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
4395        | "qwen3.6-max-preview"
4396        | "qwen-3.6-max-preview"
4397        | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
4398        OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
4399            Some(OPENROUTER_QWEN_3_6_27B_MODEL)
4400        }
4401        OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
4402            Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
4403        }
4404        OPENROUTER_QWEN_3_7_PLUS_MODEL | "qwen3.7-plus" | "qwen-3.7-plus" => {
4405            Some(OPENROUTER_QWEN_3_7_PLUS_MODEL)
4406        }
4407        OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
4408            Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
4409        }
4410        OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
4411            Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
4412        }
4413        OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
4414        | "mimo-v2.5-pro"
4415        | "mimo-v2-5-pro"
4416        | "xiaomi-mimo-v2.5-pro"
4417        | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
4418        OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
4419        | "mimo-v2.5"
4420        | "mimo-v2-5"
4421        | "xiaomi-mimo-v2.5"
4422        | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
4423        _ => None,
4424    }
4425}
4426
4427/// Canonical id resolution for OrcaRouter's own auto-routing model.
4428///
4429/// OrcaRouter is an aggregator whose upstream catalog uses the same
4430/// namespaced ids as OpenRouter, so those ids pass through verbatim. The one
4431/// OrcaRouter-specific alias worth normalizing is its `orcarouter/auto`
4432/// router, which is not an upstream model and needs the bare `auto` spelling
4433/// (as users naturally type it) to resolve to the namespaced wire id.
4434fn canonical_orcarouter_recent_model_id(model: &str) -> Option<&'static str> {
4435    let normalized = model.trim().to_ascii_lowercase();
4436    let normalized = normalized.replace(['_', ' '], "-");
4437    match normalized.as_str() {
4438        ORCAROUTER_AUTO_MODEL | "auto" | "orcarouter-auto" | "orca-auto" => {
4439            Some(ORCAROUTER_AUTO_MODEL)
4440        }
4441        _ => None,
4442    }
4443}
4444
4445fn default_model_for_provider(provider: ProviderKind) -> &'static str {
4446    match provider {
4447        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
4448        ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
4449        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
4450        ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
4451        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
4452        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
4453        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
4454        ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
4455        ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_MODEL,
4456        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
4457        ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
4458        ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
4459        ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
4460        ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
4461        ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
4462        ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
4463        ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
4464        ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
4465        ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_MODEL,
4466        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
4467        ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
4468        ProviderKind::Qianfan => DEFAULT_QIANFAN_MODEL,
4469        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
4470        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
4471        ProviderKind::Openmodel => DEFAULT_OPENMODEL_MODEL,
4472        ProviderKind::Zai => DEFAULT_ZAI_MODEL,
4473        ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
4474        ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_MODEL,
4475        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
4476        ProviderKind::Sakana => DEFAULT_SAKANA_MODEL,
4477        ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL,
4478        ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL,
4479        ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_MODEL,
4480        ProviderKind::Meta => DEFAULT_META_MODEL,
4481        ProviderKind::Xai => DEFAULT_XAI_MODEL,
4482        ProviderKind::Mistral => DEFAULT_MISTRAL_MODEL,
4483        ProviderKind::Google => DEFAULT_GOOGLE_MODEL,
4484        ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_MODEL,
4485        ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL,
4486        ProviderKind::Edenai => DEFAULT_EDENAI_MODEL,
4487        ProviderKind::ModelstudioTokenPlan
4488        | ProviderKind::ModelstudioTokenPlanAnthropic
4489        | ProviderKind::ModelstudioCodingPlan
4490        | ProviderKind::ModelstudioCodingPlanAnthropic => DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
4491        // No built-in default model; the registry placeholder keeps this total.
4492        ProviderKind::Custom => provider.provider().default_model(),
4493    }
4494}
4495
4496fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
4497    match provider {
4498        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
4499        ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
4500        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
4501        ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
4502        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
4503        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
4504        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
4505        ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
4506        ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_BASE_URL,
4507        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
4508        ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
4509        ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
4510        ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
4511        ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
4512        ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
4513        ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
4514        ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
4515        ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
4516        ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
4517        ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_BASE_URL,
4518        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
4519        ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
4520        ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL,
4521        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
4522        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
4523        ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL,
4524        ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
4525        ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
4526        ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
4527        ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
4528        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
4529        ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL,
4530        ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL,
4531        ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL,
4532        ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL,
4533        ProviderKind::Meta => DEFAULT_META_BASE_URL,
4534        ProviderKind::Xai => DEFAULT_XAI_BASE_URL,
4535        ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL,
4536        ProviderKind::Google => DEFAULT_GOOGLE_BASE_URL,
4537        ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_BASE_URL,
4538        ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
4539        ProviderKind::Edenai => DEFAULT_EDENAI_BASE_URL,
4540        ProviderKind::ModelstudioTokenPlan => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
4541        ProviderKind::ModelstudioTokenPlanAnthropic => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
4542        ProviderKind::ModelstudioCodingPlan => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
4543        ProviderKind::ModelstudioCodingPlanAnthropic => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL,
4544        // No built-in default base URL; the registry placeholder keeps this total.
4545        ProviderKind::Custom => provider.provider().default_base_url(),
4546    }
4547}
4548
4549fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
4550    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
4551    normalized == DEFAULT_KIMI_CODE_BASE_URL
4552        || normalized == "https://api.kimi.com/coding"
4553        || normalized.starts_with("https://api.kimi.com/coding/")
4554}
4555
4556/// Dual-wire vendors: dialect is config (`wire`), not a separate ProviderKind.
4557fn wire_prefers_anthropic(kind: ProviderKind, wire: Option<&str>) -> bool {
4558    if matches!(
4559        kind,
4560        ProviderKind::DeepseekAnthropic
4561            | ProviderKind::MinimaxAnthropic
4562            | ProviderKind::ModelstudioTokenPlanAnthropic
4563            | ProviderKind::ModelstudioCodingPlanAnthropic
4564    ) {
4565        return true;
4566    }
4567    let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else {
4568        return false;
4569    };
4570    let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
4571    matches!(
4572        normalized.as_str(),
4573        "anthropic"
4574            | "anthropic-messages"
4575            | "messages"
4576            | "claude"
4577            | "anthropic-compatible"
4578            | "anthropic-compat"
4579    )
4580}
4581
4582fn modelstudio_mode_is_coding_plan(kind: ProviderKind, mode: Option<&str>) -> bool {
4583    if matches!(
4584        kind,
4585        ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic
4586    ) {
4587        return true;
4588    }
4589    let Some(raw) = mode.map(str::trim).filter(|value| !value.is_empty()) else {
4590        return false;
4591    };
4592    let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
4593    matches!(
4594        normalized.as_str(),
4595        "coding-plan" | "coding" | "codingplan" | "dashscope-coding" | "code"
4596    )
4597}
4598
4599fn is_modelstudio_family(kind: ProviderKind) -> bool {
4600    matches!(
4601        kind,
4602        ProviderKind::ModelstudioTokenPlan
4603            | ProviderKind::ModelstudioTokenPlanAnthropic
4604            | ProviderKind::ModelstudioCodingPlan
4605            | ProviderKind::ModelstudioCodingPlanAnthropic
4606    )
4607}
4608
4609fn resolve_modelstudio_base_url(
4610    configured: Option<String>,
4611    kind: ProviderKind,
4612    mode: Option<&str>,
4613    wire: Option<&str>,
4614) -> String {
4615    if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4616        return url;
4617    }
4618    let coding = modelstudio_mode_is_coding_plan(kind, mode);
4619    let anthropic = wire_prefers_anthropic(kind, wire);
4620    match (coding, anthropic) {
4621        (true, true) => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL.to_string(),
4622        (true, false) => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL.to_string(),
4623        (false, true) => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL.to_string(),
4624        (false, false) => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL.to_string(),
4625    }
4626}
4627
4628fn resolve_minimax_base_url(
4629    configured: Option<String>,
4630    kind: ProviderKind,
4631    wire: Option<&str>,
4632) -> String {
4633    if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4634        return url;
4635    }
4636    if wire_prefers_anthropic(kind, wire) {
4637        DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string()
4638    } else {
4639        DEFAULT_MINIMAX_BASE_URL.to_string()
4640    }
4641}
4642
4643fn resolve_deepseek_base_url(
4644    configured: Option<String>,
4645    kind: ProviderKind,
4646    wire: Option<&str>,
4647) -> String {
4648    if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4649        return url;
4650    }
4651    if wire_prefers_anthropic(kind, wire) {
4652        DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string()
4653    } else {
4654        DEFAULT_DEEPSEEK_BASE_URL.to_string()
4655    }
4656}
4657
4658fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
4659    let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
4660    if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
4661        return None;
4662    }
4663    Some(match normalized.as_str() {
4664        "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
4665            DEFAULT_XIAOMI_MIMO_BASE_URL
4666        }
4667        "token-plan-cn"
4668        | "token-plan-china"
4669        | "token-plan-mainland"
4670        | "token-plan-mainland-china"
4671        | "cn"
4672        | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
4673        "token-plan-sgp"
4674        | "token-plan-sg"
4675        | "token-plan-singapore"
4676        | "sgp"
4677        | "sg"
4678        | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
4679        "token-plan-ams"
4680        | "token-plan-eu"
4681        | "token-plan-europe"
4682        | "token-plan-amsterdam"
4683        | "ams"
4684        | "eu"
4685        | "europe"
4686        | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
4687        _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
4688    })
4689}
4690
4691fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
4692    matches!(
4693        normalized_mode,
4694        "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
4695    )
4696}
4697
4698fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
4699    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
4700    normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
4701        || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
4702        || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
4703}
4704
4705fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
4706    candidates.iter().find_map(|name| {
4707        std::env::var(name)
4708            .ok()
4709            .filter(|value| !value.trim().is_empty())
4710    })
4711}
4712
4713fn xiaomi_mimo_env_api_key_for_runtime(
4714    mode: Option<&str>,
4715    base_url: Option<&str>,
4716) -> Option<String> {
4717    const TOKEN_PLAN_ENV_VARS: &[&str] =
4718        &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
4719    const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
4720
4721    let normalized_mode =
4722        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
4723    let standard_selected = normalized_mode
4724        .as_deref()
4725        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
4726        || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
4727    if standard_selected {
4728        return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
4729    }
4730
4731    let token_plan_selected = normalized_mode
4732        .as_deref()
4733        .and_then(xiaomi_mimo_base_url_for_mode)
4734        .is_some()
4735        || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
4736    if token_plan_selected {
4737        return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
4738    }
4739
4740    xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
4741}
4742
4743fn resolve_xiaomi_mimo_base_url(
4744    configured: Option<String>,
4745    api_key: Option<&str>,
4746    mode: Option<&str>,
4747) -> String {
4748    let normalized_mode =
4749        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
4750    let uses_standard_mode = normalized_mode
4751        .as_deref()
4752        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
4753    let mode_base_url = normalized_mode
4754        .as_deref()
4755        .and_then(xiaomi_mimo_base_url_for_mode);
4756    let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
4757    match configured {
4758        Some(base_url) if uses_standard_mode => base_url,
4759        Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
4760            mode_base_url
4761                .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
4762                .to_string()
4763        }
4764        Some(base_url) => base_url,
4765        None => {
4766            if let Some(base_url) = mode_base_url {
4767                base_url.to_string()
4768            } else if uses_standard_mode {
4769                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
4770            } else if uses_token_plan || api_key.is_none() {
4771                DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
4772            } else {
4773                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
4774            }
4775        }
4776    }
4777}
4778
4779fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
4780    api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
4781}
4782
4783fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
4784    matches!(
4785        base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
4786        "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
4787    )
4788}
4789
4790/// Whether `base_url` belongs to the provider's official endpoint family.
4791///
4792/// Some providers publish multiple stable paths for the same credential and
4793/// model namespace. Keep that family definition centralized so route
4794/// canonicalization and credential scoping cannot disagree.
4795#[must_use]
4796pub fn provider_base_url_is_official(provider: ProviderKind, base_url: &str) -> bool {
4797    let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
4798    match provider {
4799        ProviderKind::Deepseek => matches!(
4800            normalized.as_str(),
4801            "https://api.deepseek.com"
4802                | "https://api.deepseek.com/v1"
4803                | "https://api.deepseek.com/beta"
4804        ),
4805        ProviderKind::DeepseekAnthropic => matches!(
4806            normalized.as_str(),
4807            "https://api.deepseek.com/anthropic" | "https://api.deepseek.com/anthropic/v1"
4808        ),
4809        ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => matches!(
4810            normalized.as_str(),
4811            "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
4812        ),
4813        ProviderKind::Moonshot => {
4814            normalized == DEFAULT_MOONSHOT_BASE_URL || moonshot_base_url_uses_kimi_code(base_url)
4815        }
4816        ProviderKind::XiaomiMimo => {
4817            xiaomi_mimo_base_url_uses_token_plan(base_url)
4818                || xiaomi_mimo_base_url_is_pay_as_you_go(base_url)
4819        }
4820        ProviderKind::Ollama => {
4821            normalized == DEFAULT_OLLAMA_BASE_URL
4822                || provider::is_exact_ollama_cloud_route(provider, base_url)
4823        }
4824        ProviderKind::OllamaCloud => provider::is_exact_ollama_cloud_route(provider, base_url),
4825        ProviderKind::Edenai => matches!(
4826            normalized.as_str(),
4827            "https://api.edenai.run/v3" | "https://api.eu.edenai.run/v3"
4828        ),
4829        // Custom routes have no Codewhale-owned official endpoint. The
4830        // descriptor URL is a schema placeholder, never a credential scope.
4831        ProviderKind::Custom => false,
4832        _ => {
4833            normalized
4834                == default_base_url_for_provider(provider)
4835                    .trim()
4836                    .trim_end_matches('/')
4837                    .to_ascii_lowercase()
4838        }
4839    }
4840}
4841
4842fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
4843    !provider_base_url_is_official(provider, base_url)
4844}
4845
4846/// Whether `base_url` is outside the provider's official endpoint family and
4847/// therefore owns its model-id namespace.
4848///
4849/// Custom OpenAI-compatible endpoints must receive the exact model selector
4850/// the user supplied. Official endpoints may safely canonicalize known aliases
4851/// to their provider wire ids.
4852#[must_use]
4853pub fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
4854    base_url_is_custom_for_provider(provider, base_url)
4855}
4856
4857fn should_skip_secret_store_for_provider(
4858    provider: ProviderKind,
4859    base_url: &str,
4860    auth_mode: Option<&str>,
4861) -> bool {
4862    if auth_mode_disables_api_key(auth_mode) {
4863        return true;
4864    }
4865    if base_url_is_custom_for_provider(provider, base_url) {
4866        return true;
4867    }
4868    if auth_mode_requires_api_key(auth_mode) {
4869        return false;
4870    }
4871
4872    matches!(provider, ProviderKind::Sglang | ProviderKind::Vllm)
4873        || (provider == ProviderKind::Ollama
4874            && !provider::is_exact_ollama_cloud_route(provider, base_url))
4875        || base_url_uses_local_host(base_url)
4876}
4877
4878/// Read the durable provider slot without allowing environment fallback to
4879/// jump ahead of the bounded legacy slot. The old `ollama` slot is consulted
4880/// only for the exact route tuple migrated above; selecting `ollama-cloud`
4881/// directly never consumes a local provider credential.
4882fn stored_api_key_for_provider(
4883    secrets: &Secrets,
4884    provider: ProviderKind,
4885    legacy_ollama_cloud: bool,
4886) -> Option<(String, SecretSource)> {
4887    let mut slots = vec![provider.secret_store_slot()];
4888    if provider == ProviderKind::OllamaCloud && legacy_ollama_cloud {
4889        slots.push(ProviderKind::Ollama.secret_store_slot());
4890    }
4891    slots.into_iter().find_map(|slot| {
4892        secrets
4893            .get(slot)
4894            .ok()
4895            .flatten()
4896            .filter(|value| !value.trim().is_empty())
4897            .map(|value| (value, SecretSource::Keyring))
4898    })
4899}
4900
4901fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
4902    if provider == ProviderKind::Huggingface {
4903        return std::env::var("HUGGINGFACE_API_KEY")
4904            .ok()
4905            .filter(|value| !value.trim().is_empty())
4906            .or_else(|| {
4907                std::env::var("HF_TOKEN")
4908                    .ok()
4909                    .filter(|value| !value.trim().is_empty())
4910            });
4911    }
4912
4913    codewhale_secrets::env_for(provider.as_str())
4914}
4915
4916/// Whether an authentication mode requires API-key material.
4917#[must_use]
4918pub fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
4919    matches!(
4920        auth_mode
4921            .map(str::trim)
4922            .filter(|value| !value.is_empty())
4923            .map(|value| value.to_ascii_lowercase()),
4924        Some(value)
4925            if matches!(
4926                value.as_str(),
4927                "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
4928            )
4929    )
4930}
4931
4932/// Whether an authentication mode explicitly disables upstream provider auth.
4933#[must_use]
4934pub fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
4935    matches!(
4936        auth_mode
4937            .map(str::trim)
4938            .filter(|value| !value.is_empty())
4939            .map(|value| value.to_ascii_lowercase()),
4940        Some(value)
4941            if matches!(
4942                value.as_str(),
4943                "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
4944            )
4945    )
4946}
4947
4948/// Whether an authentication mode selects Kimi's imported bearer token.
4949#[must_use]
4950pub fn auth_mode_uses_kimi_imported_token(auth_mode: &str) -> bool {
4951    matches!(
4952        auth_mode
4953            .trim()
4954            .to_ascii_lowercase()
4955            .replace('-', "_")
4956            .as_str(),
4957        "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
4958    )
4959}
4960
4961fn base_url_uses_local_host(base_url: &str) -> bool {
4962    let Some(host) = base_url_host(base_url) else {
4963        return false;
4964    };
4965    let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
4966    if matches!(host.as_str(), "localhost" | "0.0.0.0") {
4967        return true;
4968    }
4969    host.parse::<std::net::IpAddr>()
4970        .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
4971}
4972
4973fn base_url_host(base_url: &str) -> Option<&str> {
4974    let without_scheme = base_url
4975        .split_once("://")
4976        .map_or(base_url, |(_, rest)| rest);
4977    let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
4978    if let Some(rest) = authority.strip_prefix('[') {
4979        return rest.split_once(']').map(|(host, _)| host);
4980    }
4981    authority.split(':').next().filter(|host| !host.is_empty())
4982}
4983
4984#[derive(Debug, Clone, Default)]
4985pub struct CliRuntimeOverrides {
4986    pub provider: Option<ProviderKind>,
4987    pub model: Option<String>,
4988    pub api_key: Option<String>,
4989    pub base_url: Option<String>,
4990    pub auth_mode: Option<String>,
4991    pub output_mode: Option<String>,
4992    pub log_level: Option<String>,
4993    pub telemetry: Option<bool>,
4994    pub approval_policy: Option<String>,
4995    pub sandbox_mode: Option<String>,
4996    pub yolo: Option<bool>,
4997    pub verbosity: Option<String>,
4998}
4999
5000#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5001pub enum RuntimeApiKeySource {
5002    Cli,
5003    ConfigFile,
5004    Keyring,
5005    Env,
5006}
5007
5008impl RuntimeApiKeySource {
5009    #[must_use]
5010    pub fn as_env_value(self) -> &'static str {
5011        match self {
5012            Self::Cli => "cli",
5013            Self::ConfigFile => "config",
5014            Self::Keyring => "keyring",
5015            Self::Env => "env",
5016        }
5017    }
5018}
5019
5020#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5021pub enum ProviderSource {
5022    Cli,
5023    Env(&'static str),
5024    Config,
5025}
5026
5027/// Where the resolved runtime model id came from.
5028///
5029/// This mirrors the precedence chain in
5030/// [`ConfigToml::resolve_runtime_options_with_secrets`] so diagnostics can say
5031/// *why* a model was chosen instead of presenting a built-in default as if the
5032/// user had asked for it. [`Self::ProviderDefault`] is the only variant that
5033/// means "nothing was configured".
5034#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5035pub enum ModelSource {
5036    /// `--model` on the command line.
5037    Cli,
5038    /// A `CODEWHALE_*` environment variable.
5039    Env,
5040    /// `[providers.<name>].model`.
5041    ProviderConfig,
5042    /// The root `default_text_model` key, which is DeepSeek-scoped.
5043    RootDefaultTextModel,
5044    /// The provider-neutral root `model` key.
5045    RootModel,
5046    /// Nothing was configured; this is the built-in default for the provider.
5047    ProviderDefault,
5048}
5049
5050impl ModelSource {
5051    /// Whether the id was chosen by the user rather than substituted by us.
5052    #[must_use]
5053    pub fn is_explicit(self) -> bool {
5054        !matches!(self, Self::ProviderDefault)
5055    }
5056
5057    #[must_use]
5058    pub fn as_str(self) -> &'static str {
5059        match self {
5060            Self::Cli => "--model",
5061            Self::Env => "environment",
5062            Self::ProviderConfig => "config [providers.*].model",
5063            Self::RootDefaultTextModel => "config default_text_model",
5064            Self::RootModel => "config model",
5065            Self::ProviderDefault => "provider default",
5066        }
5067    }
5068}
5069
5070#[derive(Debug, Clone)]
5071pub struct ResolvedRuntimeOptions {
5072    pub provider: ProviderKind,
5073    pub provider_source: ProviderSource,
5074    pub model: String,
5075    pub model_source: ModelSource,
5076    pub api_key: Option<String>,
5077    pub api_key_source: Option<RuntimeApiKeySource>,
5078    pub base_url: String,
5079    pub auth_mode: Option<String>,
5080    pub insecure_skip_tls_verify: bool,
5081    pub output_mode: Option<String>,
5082    pub log_level: Option<String>,
5083    pub telemetry: bool,
5084    /// Where the resolved telemetry consent came from (cli | env | config |
5085    /// default), so doctor and config displays can state the truth about a
5086    /// machine that never opted in (#5441).
5087    pub telemetry_source: TelemetrySource,
5088    /// A human wrote `telemetry = false` into the config file.
5089    ///
5090    /// This is the *persistent* opt-out, and it is deliberately narrower than
5091    /// "telemetry resolved to false". A run-scoped kill switch also resolves
5092    /// false; treating that as a revocation would destroy the identity and
5093    /// buffered events of a user who merely set `CODEWHALE_TELEMETRY=0` for one
5094    /// command. Run-scoped kill switches
5095    /// (`--telemetry false`, the environment variable) stop the run and leave
5096    /// every byte on disk alone; only this flag authorizes the wipe.
5097    pub telemetry_explicit_off: bool,
5098    /// Where a telemetry batch would be sent, if telemetry were on.
5099    ///
5100    /// Already resolved: [`DEFAULT_TELEMETRY_ENDPOINT`] when nobody configured
5101    /// one, the configured value when somebody did, and `None` when somebody
5102    /// configured an empty one — which means the dry-run sink, not "unset".
5103    /// Which schemes are actually contactable is decided where a batch would be
5104    /// sent, not here — a user must be able to stage a value.
5105    pub telemetry_endpoint: Option<String>,
5106    pub approval_policy: Option<String>,
5107    pub sandbox_mode: Option<String>,
5108    pub yolo: Option<bool>,
5109    pub verbosity: Option<String>,
5110    pub http_headers: BTreeMap<String, String>,
5111}
5112
5113#[derive(Debug, Clone)]
5114pub struct ConfigStore {
5115    path: PathBuf,
5116    pub config: ConfigToml,
5117    permissions: PermissionsToml,
5118    /// Original file text, retained so [`save`](Self::save) can merge
5119    /// comments back after serialisation.
5120    original_raw: Option<String>,
5121}
5122
5123impl ConfigStore {
5124    pub fn load(path: Option<PathBuf>) -> Result<Self> {
5125        let path = resolve_config_path(path)?;
5126        let (config, original_raw) = if checked_path_exists(&path)? {
5127            let raw = read_checked_config_file(&path)?;
5128            let mut parsed: ConfigToml = toml::from_str(&raw).map_err(|_| {
5129                anyhow::anyhow!(
5130                    "failed to parse config at {}; file contents were omitted",
5131                    quote_os_path(&path)
5132                )
5133            })?;
5134            let raw_document: toml::Value = toml::from_str(&raw).map_err(|_| {
5135                anyhow::anyhow!(
5136                    "failed to parse config at {}; file contents were omitted",
5137                    quote_os_path(&path)
5138                )
5139            })?;
5140            if let Some(provider_id) = raw_document.get("provider").and_then(toml::Value::as_str) {
5141                parsed
5142                    .bind_persisted_provider_id(provider_id)
5143                    .with_context(|| {
5144                        format!("failed to parse config at {}", quote_os_path(&path))
5145                    })?;
5146            }
5147            (parsed, Some(raw))
5148        } else {
5149            (ConfigToml::default(), None)
5150        };
5151        let permissions = load_sibling_permissions(&path)?;
5152
5153        Ok(Self {
5154            path,
5155            config,
5156            permissions,
5157            original_raw,
5158        })
5159    }
5160
5161    /// Render the exact body [`save`](Self::save) would write: the serialized
5162    /// config with comments and disabled keys from the originally-loaded file
5163    /// merged back in. Exposed so setup flows can stage this body into a
5164    /// [`persistence::SetupTransaction`] alongside sibling files and keep the
5165    /// comment-preserving write atomic with the rest of the transaction.
5166    pub fn rendered_body(&self) -> Result<String> {
5167        let mut serialized =
5168            toml::to_string_pretty(&self.config).context("failed to serialize config")?;
5169        if let Some(provider_id) = self.config.named_custom_provider_id() {
5170            let mut document = serialized
5171                .parse::<toml_edit::DocumentMut>()
5172                .context("failed to edit serialized config")?;
5173            document["provider"] = toml_edit::value(provider_id);
5174            serialized = document.to_string();
5175        }
5176        if let Some(ref original_raw) = self.original_raw {
5177            merge_and_preserve_comments(&serialized, original_raw).with_context(|| {
5178                format!(
5179                    "cannot safely preserve config at {}; reload it and retry instead of replacing an unmergeable snapshot",
5180                    quote_os_path(&self.path)
5181                )
5182            })
5183        } else {
5184            Ok(serialized)
5185        }
5186    }
5187
5188    pub fn save(&mut self) -> Result<()> {
5189        let path = normalize_config_file_path(self.path.clone())?;
5190        let body = self.rendered_body()?;
5191        replace_config_document_if_unchanged(&path, self.original_raw.as_deref(), &body)?;
5192        self.original_raw = Some(body);
5193        Ok(())
5194    }
5195
5196    /// Refresh the typed value and byte snapshot after a targeted writer used
5197    /// the shared config lock. This keeps a long-lived command process from
5198    /// treating its own successful mutation as an external stale conflict.
5199    pub fn reload(&mut self) -> Result<()> {
5200        *self = Self::load(Some(self.path.clone()))?;
5201        Ok(())
5202    }
5203
5204    #[must_use]
5205    pub fn path(&self) -> &Path {
5206        &self.path
5207    }
5208
5209    #[must_use]
5210    pub fn permissions(&self) -> &PermissionsToml {
5211        &self.permissions
5212    }
5213
5214    #[must_use]
5215    pub fn permissions_path(&self) -> PathBuf {
5216        checked_permissions_path_for_config_path(&self.path)
5217            .expect("ConfigStore path is validated before construction")
5218    }
5219
5220    #[must_use]
5221    pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
5222        if self.permissions.is_empty() {
5223            ExecPolicyEngine::new(Vec::new(), Vec::new())
5224        } else {
5225            ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
5226        }
5227    }
5228
5229    /// Atomically append ask-only permission rules to the sibling
5230    /// `permissions.toml` file.
5231    ///
5232    /// Existing comments and formatting are preserved. Exact duplicate rules
5233    /// are ignored, and the in-memory permissions snapshot is refreshed after
5234    /// a successful write.
5235    pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
5236        self.append_permission_rules(rules, PermissionAction::Ask)
5237    }
5238
5239    /// Atomically append exact, repo-scoped allow rules to the sibling
5240    /// `permissions.toml` file.
5241    ///
5242    /// The caller is responsible for deciding which tool calls are eligible;
5243    /// this boundary rejects broad or incorrectly typed records so a UI bug
5244    /// cannot persist an unscoped allow grant.
5245    pub fn append_allow_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
5246        for rule in rules {
5247            if rule.action != PermissionAction::Allow {
5248                bail!("append_allow_rules only accepts action = \"allow\"");
5249            }
5250            let Some(workspace) = rule
5251                .workspace
5252                .as_deref()
5253                .and_then(codewhale_execpolicy::normalize_workspace_scope)
5254            else {
5255                bail!("persistent allow rules must be scoped to a workspace");
5256            };
5257            if rule.command.is_some() && !rule.command_exact {
5258                bail!("persistent command allow rules must use exact matching");
5259            }
5260            if rule.command.is_none() && rule.path.is_none() {
5261                bail!("persistent allow rules must match an exact command or path");
5262            }
5263            if let Some(command) = rule.command.as_deref()
5264                && command.trim().is_empty()
5265            {
5266                bail!("persistent command allow rules must not be empty");
5267            }
5268            if let Some(path) = rule.path.as_deref()
5269                && codewhale_execpolicy::normalize_workspace_relative_path(path, &workspace)
5270                    .is_none_or(|path| path.is_empty())
5271            {
5272                bail!("persistent path allow rules must stay within the workspace");
5273            }
5274        }
5275        self.append_permission_rules(rules, PermissionAction::Allow)
5276    }
5277
5278    fn append_permission_rules(
5279        &mut self,
5280        rules: &[ToolAskRule],
5281        expected_action: PermissionAction,
5282    ) -> Result<usize> {
5283        if rules.is_empty() {
5284            return Ok(0);
5285        }
5286        if rules.iter().any(|rule| rule.action != expected_action) {
5287            bail!(
5288                "permission rule action does not match requested {:?} persistence",
5289                expected_action
5290            );
5291        }
5292
5293        let path = checked_permissions_path_for_config_path(&self.path)?;
5294        let (added, persisted) = config_document::with_config_write_lock(&path, |path| {
5295            let (_, raw, mut permissions) = read_permissions_state(path)?;
5296            let mut document = parse_permissions_document(path, &raw)?;
5297
5298            if !document.contains_key("rules") {
5299                document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
5300            }
5301            let rules_item = document
5302                .get_mut("rules")
5303                .expect("rules entry was inserted above");
5304
5305            let mut added = 0;
5306            for rule in rules {
5307                if permissions.rules.contains(rule) {
5308                    continue;
5309                }
5310                append_permission_rule(rules_item, rule)?;
5311                permissions.rules.push(rule.clone());
5312                added += 1;
5313            }
5314            if added == 0 {
5315                return Ok((0, permissions));
5316            }
5317
5318            let body = document.to_string();
5319            let persisted = parse_generated_permissions(path, &body)?;
5320            write_permissions_atomic(path, body.as_bytes())?;
5321            Ok((added, persisted))
5322        })?;
5323        self.permissions = persisted;
5324        Ok(added)
5325    }
5326}
5327
5328fn config_backup_file_name(path: &Path) -> OsString {
5329    let mut file_name = path
5330        .file_name()
5331        .map(OsString::from)
5332        .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
5333    file_name.push(".bak");
5334    file_name
5335}
5336
5337fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
5338    config_path
5339        .parent()
5340        .unwrap_or_else(|| Path::new("."))
5341        .join(file_name)
5342}
5343
5344fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
5345    let config_path = normalize_config_file_path(config_path.to_path_buf())?;
5346    let parent = config_path
5347        .parent()
5348        .context("config path must include a parent directory")?;
5349    let path = parent.join(file_name);
5350    reject_path_symlink(&path)?;
5351    Ok(path)
5352}
5353
5354#[cfg(test)]
5355fn config_backup_path(path: &Path) -> PathBuf {
5356    config_sibling_path_unchecked(path, &config_backup_file_name(path))
5357}
5358
5359fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
5360    checked_config_sibling_path(path, &config_backup_file_name(path))
5361}
5362
5363/// Remove plaintext `api_key` entries from the one-time config backup, if it
5364/// exists.
5365///
5366/// Credential migration deliberately preserves the rest of `config.toml.bak`
5367/// while ensuring that moving a key into the durable secret store does not
5368/// leave the same credential behind in an older backup.
5369pub fn scrub_plaintext_api_keys_from_config_backup(path: &Path) -> Result<()> {
5370    let backup = checked_config_backup_path(path)?;
5371    if !backup.exists() {
5372        return Ok(());
5373    }
5374
5375    let raw = read_checked_toml_file(&backup, "config backup")?;
5376    let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
5377        format!(
5378            "failed to scrub plaintext API keys from config backup {}",
5379            backup.display()
5380        )
5381    })?;
5382    if scrubbed != raw {
5383        persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
5384            format!(
5385                "failed to write credential-free config backup {}",
5386                backup.display()
5387            )
5388        })?;
5389    }
5390    Ok(())
5391}
5392
5393fn write_one_time_config_backup(path: &Path) -> Result<()> {
5394    let backup = checked_config_backup_path(path)?;
5395    if backup.exists() {
5396        return scrub_plaintext_api_keys_from_config_backup(path);
5397    }
5398
5399    let raw = read_checked_config_file(path)?;
5400    let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
5401        format!(
5402            "failed to scrub plaintext API keys while creating config backup {}",
5403            backup.display()
5404        )
5405    })?;
5406    persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
5407        format!(
5408            "failed to create credential-free config backup {} from {}",
5409            backup.display(),
5410            path.display()
5411        )
5412    })?;
5413    Ok(())
5414}
5415
5416fn config_toml_without_plaintext_api_keys(raw: &str) -> Result<String> {
5417    let mut document = raw
5418        .parse::<toml_edit::DocumentMut>()
5419        .map_err(|_| {
5420            anyhow::anyhow!(
5421                "failed to parse config TOML while removing plaintext API keys; file contents were omitted"
5422            )
5423        })?;
5424    remove_plaintext_api_keys_recursive(document.as_table_mut());
5425    Ok(document.to_string())
5426}
5427
5428fn remove_plaintext_api_keys_recursive(table: &mut dyn toml_edit::TableLike) {
5429    table.remove("api_key");
5430    for (_, item) in table.iter_mut() {
5431        if let toml_edit::Item::ArrayOfTables(tables) = item {
5432            for nested in tables.iter_mut() {
5433                remove_plaintext_api_keys_recursive(nested);
5434            }
5435        } else if let Some(nested) = item.as_table_like_mut() {
5436            remove_plaintext_api_keys_recursive(nested);
5437        }
5438    }
5439}
5440
5441/// Merge comments and formatting from an original TOML file into a
5442/// freshly serialized document so user annotations (comments, whitespace,
5443/// disabled keys) survive config rewrites.
5444///
5445/// `original_raw` is the raw text of the file before the change; the
5446/// function parses it internally with [`toml_edit`] so callers stay free
5447/// of that dependency.
5448pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
5449    let original = original_raw
5450        .parse::<toml_edit::DocumentMut>()
5451        .map_err(|_| {
5452            anyhow::anyhow!(
5453                "failed to parse original config for comment merge; file contents were omitted"
5454            )
5455        })?;
5456
5457    let mut new_doc = serialized.parse::<toml_edit::DocumentMut>().map_err(|_| {
5458        anyhow::anyhow!(
5459            "failed to parse serialized config for comment merge; file contents were omitted"
5460        )
5461    })?;
5462
5463    // Reuse the original document’s trailing text (file-footer comments /
5464    // disabled keys) so they survive the rewrite.
5465    new_doc.set_trailing(original.trailing().clone());
5466
5467    // Copy the top-level table's decor (document-header comments, whitespace
5468    // before the first key) which `toml_edit` stores on the root `Table` itself.
5469    *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
5470
5471    merge_decor_table(new_doc.as_table_mut(), original.as_table());
5472
5473    Ok(new_doc.to_string())
5474}
5475
5476/// Recursively copy `decor` (prefix/suffix comments and whitespace) from
5477/// every key in `source` that also exists in `target`.
5478fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
5479    // Collect keys first — the borrow checker won't let us hold
5480    // `get_key_value_mut` while iterating.
5481    let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
5482    for key in &keys {
5483        let Some((source_key, source_item)) = source.get_key_value(key) else {
5484            continue;
5485        };
5486        let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
5487            continue;
5488        };
5489
5490        // Copy the key-level decor (comments before the key itself)
5491        *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
5492
5493        copy_item_decor(target_item, source_item);
5494
5495        if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
5496            merge_decor_table(tt, st);
5497        }
5498
5499        if let (Some(ta), Some(sa)) = (
5500            target_item.as_array_of_tables_mut(),
5501            source_item.as_array_of_tables(),
5502        ) {
5503            for (i, source_table) in sa.iter().enumerate() {
5504                if let Some(target_table) = ta.get_mut(i) {
5505                    copy_item_decor_table(target_table, source_table);
5506                    merge_decor_table(target_table, source_table);
5507                }
5508            }
5509        }
5510    }
5511}
5512
5513/// Copy the decor (comments and surrounding whitespace) from `source` to `target`,
5514/// respecting the concrete item type since [`toml_edit::Item`] has no uniform
5515/// `decor` accessor.
5516fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
5517    match (target, source) {
5518        (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
5519            *tt.decor_mut() = st.decor().clone();
5520        }
5521        (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
5522            *tv.decor_mut() = sv.decor().clone();
5523        }
5524        _ => {}
5525    }
5526}
5527
5528fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
5529    *target.decor_mut() = source.decor().clone();
5530}
5531
5532/// Process-wide default [`Secrets`] façade. The first caller wins; the
5533/// lock is exposed so test or CLI code can install an explicit
5534/// backend (e.g. an [`codewhale_secrets::InMemoryKeyringStore`]) before
5535/// any resolver runs.
5536pub fn default_secrets() -> &'static Secrets {
5537    static SECRETS: OnceLock<Secrets> = OnceLock::new();
5538    SECRETS.get_or_init(|| {
5539        // Tests should never poke real platform credential stores. Cargo sets the
5540        // `RUST_TEST_*` family of env vars (and `CARGO_PKG_NAME` is
5541        // always populated), but the `cfg(test)` flag is the canonical
5542        // signal here. See `install_test_secrets` for explicit installs.
5543        #[cfg(test)]
5544        {
5545            Secrets::new(std::sync::Arc::new(
5546                codewhale_secrets::InMemoryKeyringStore::new(),
5547            ))
5548        }
5549        #[cfg(not(test))]
5550        {
5551            Secrets::auto_detect()
5552        }
5553    })
5554}
5555
5556// ── CodeWhale state root (v0.8.44) ──────────────────────────────────
5557//
5558// v0.8.44 migrates product-owned app state from ~/.deepseek/ to
5559// ~/.codewhale/ while keeping ~/.deepseek/ as a compatibility fallback.
5560// New installs write to ~/.codewhale/. Existing installs with only
5561// ~/.deepseek/ continue working without data loss.
5562
5563pub use codewhale_paths::{CODEWHALE_APP_DIR, LEGACY_APP_DIR};
5564
5565/// Resolve the primary CodeWhale home directory.
5566///
5567/// `$CODEWHALE_HOME` takes precedence when set. Otherwise defaults to
5568/// `$HOME/.codewhale`. This is the write target for new product state.
5569pub fn codewhale_home() -> Result<PathBuf> {
5570    codewhale_paths::codewhale_home()
5571        .map_err(anyhow::Error::new)?
5572        .context("failed to resolve home directory")
5573}
5574
5575/// Whether `$CODEWHALE_HOME` is set to a non-empty value.
5576///
5577/// An explicit CodeWhale home is an isolation boundary: state/config resolvers
5578/// must not fall back to ambient legacy `~/.deepseek` data outside that root.
5579pub fn codewhale_home_is_explicit() -> bool {
5580    codewhale_paths::codewhale_home_is_explicit()
5581}
5582
5583/// Resolve the legacy DeepSeek home directory (`$HOME/.deepseek`).
5584///
5585/// Always returns the legacy path regardless of whether it exists.
5586pub fn legacy_deepseek_home() -> Result<PathBuf> {
5587    codewhale_paths::legacy_deepseek_home().context("failed to resolve home directory")
5588}
5589
5590/// Reject state subdirs that could escape the state root via path injection.
5591///
5592/// `ensure_state_dir` / `resolve_state_dir` are public APIs taking an arbitrary
5593/// subdir string; every in-tree caller passes a hardcoded single component
5594/// (e.g. `"sessions"`, `"."`). This validates defensively so a future caller
5595/// can never traverse out of the state root via `..` components or an absolute
5596/// path. Nested relative paths such as `"a/b"` are permitted.
5597fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
5598    if subdir.is_empty() {
5599        bail!("state subdir must not be empty");
5600    }
5601    let path = std::path::Path::new(subdir);
5602    if path.is_absolute() {
5603        bail!("state subdir must not be an absolute path: {subdir}");
5604    }
5605    if path.components().any(|c| {
5606        matches!(
5607            c,
5608            std::path::Component::RootDir | std::path::Component::Prefix(_)
5609        )
5610    }) {
5611        bail!("state subdir must not contain a root or prefix: {subdir}");
5612    }
5613    if path
5614        .components()
5615        .any(|c| matches!(c, std::path::Component::ParentDir))
5616    {
5617        bail!("state subdir must not contain parent-dir (..) components: {subdir}");
5618    }
5619    Ok(())
5620}
5621
5622/// Resolve a state subdirectory, preferring the CodeWhale root if
5623/// it already exists, otherwise falling back to the legacy root.
5624///
5625/// This is the read-path resolver: it returns the primary path when
5626/// migration has occurred or on a fresh install, but keeps reading
5627/// from the legacy path for users who haven't migrated yet.
5628pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
5629    ensure_safe_state_subdir(subdir)?;
5630    let explicit_codewhale_home = codewhale_home_is_explicit();
5631    let primary = codewhale_home()?.join(subdir);
5632    if explicit_codewhale_home || primary.exists() {
5633        return Ok(primary);
5634    }
5635    let legacy = legacy_deepseek_home()?.join(subdir);
5636    if legacy.exists() {
5637        return Ok(legacy);
5638    }
5639    // Neither exists — return primary for first-write creation.
5640    Ok(primary)
5641}
5642
5643/// Ensure a state subdirectory exists under the primary CodeWhale root,
5644/// creating it if necessary. This is the write-path resolver.
5645///
5646/// On the first creation of a real subdirectory (not the root sentinel `"."`),
5647/// if a legacy `~/.deepseek/<subdir>` exists but the primary
5648/// `~/.codewhale/<subdir>` does not, the legacy directory is relocated into
5649/// the primary location so the user keeps their data and the legacy tree
5650/// stops growing (#3240). After migration, [`resolve_state_dir`] finds the
5651/// data in the primary location; the read resolver itself is unchanged.
5652pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
5653    let (dir, migration) = ensure_state_dir_with_migration(subdir)?;
5654    if let Some(migration) = migration {
5655        eprintln!("{}", migration.user_notice());
5656    }
5657    Ok(dir)
5658}
5659
5660#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5661pub enum StateMigrationKind {
5662    Relocated,
5663    Copied,
5664}
5665
5666#[derive(Debug, Clone, PartialEq, Eq)]
5667pub struct StateMigration {
5668    pub subdir: String,
5669    pub legacy_path: PathBuf,
5670    pub primary_path: PathBuf,
5671    pub kind: StateMigrationKind,
5672}
5673
5674impl StateMigration {
5675    pub fn user_notice(&self) -> String {
5676        let action = match self.kind {
5677            StateMigrationKind::Relocated => "relocated",
5678            StateMigrationKind::Copied => "copied",
5679        };
5680        let legacy_detail = match self.kind {
5681            StateMigrationKind::Relocated => {
5682                "The legacy .deepseek copy for this state path was removed by the move."
5683            }
5684            StateMigrationKind::Copied => {
5685                "The legacy .deepseek copy was left in place because a direct move failed."
5686            }
5687        };
5688
5689        format!(
5690            "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.",
5691            self.legacy_path.display(),
5692            self.primary_path.display(),
5693        )
5694    }
5695}
5696
5697/// Variant of [`ensure_state_dir`] that exposes whether a legacy state path was
5698/// migrated. Most callers should use [`ensure_state_dir`]; this is kept for
5699/// tests and future UI surfaces that want to render the notice themselves.
5700pub fn ensure_state_dir_with_migration(subdir: &str) -> Result<(PathBuf, Option<StateMigration>)> {
5701    ensure_safe_state_subdir(subdir)?;
5702    let explicit_codewhale_home = codewhale_home_is_explicit();
5703    let dir = codewhale_home()?.join(subdir);
5704    let migration = if !explicit_codewhale_home {
5705        migrate_legacy_state_dir(&dir, subdir)?
5706    } else {
5707        None
5708    };
5709    std::fs::create_dir_all(&dir)
5710        .with_context(|| format!("failed to create {}/", dir.display()))?;
5711    Ok((dir, migration))
5712}
5713
5714/// One-time relocation of a legacy `~/.deepseek/<subdir>` state directory into
5715/// the primary `~/.codewhale/<subdir>` location (#3240). No-op once the primary
5716/// exists, for the root sentinel `"."` (a whole-tree move is owned by the
5717/// config-file migration), or when no legacy directory is present.
5718fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<Option<StateMigration>> {
5719    if primary.exists() || subdir == "." || subdir.is_empty() {
5720        return Ok(None);
5721    }
5722    let legacy = match legacy_deepseek_home() {
5723        Ok(home) => home.join(subdir),
5724        Err(_) => return Ok(None),
5725    };
5726    if !legacy.exists() {
5727        return Ok(None);
5728    }
5729    // The primary's parent (the ~/.codewhale root) must exist for the rename.
5730    if let Some(parent) = primary.parent()
5731        && let Err(err) = std::fs::create_dir_all(parent)
5732    {
5733        tracing::warn!(
5734            target: "config::migration",
5735            "Could not create {} for state migration ({}); writing to primary anyway",
5736            parent.display(),
5737            err
5738        );
5739    }
5740    match std::fs::rename(&legacy, primary) {
5741        Ok(()) => {
5742            tracing::info!(
5743                target: "config::migration",
5744                "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
5745                legacy.display(),
5746                primary.display()
5747            );
5748            return Ok(Some(StateMigration {
5749                subdir: subdir.to_string(),
5750                legacy_path: legacy,
5751                primary_path: primary.to_path_buf(),
5752                kind: StateMigrationKind::Relocated,
5753            }));
5754        }
5755        Err(err) => {
5756            // Cross-device rename or permission issue: fall back to a
5757            // recursive copy so the user keeps their data. The legacy tree is
5758            // left in place; it stops growing because writes now target the
5759            // primary path.
5760            match copy_dir_recursive(&legacy, primary) {
5761                Ok(()) => {
5762                    tracing::info!(
5763                        target: "config::migration",
5764                        "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
5765                         The legacy .deepseek copy was left in place.",
5766                        legacy.display(),
5767                        primary.display()
5768                    );
5769                    return Ok(Some(StateMigration {
5770                        subdir: subdir.to_string(),
5771                        legacy_path: legacy,
5772                        primary_path: primary.to_path_buf(),
5773                        kind: StateMigrationKind::Copied,
5774                    }));
5775                }
5776                Err(copy_err) => {
5777                    tracing::warn!(
5778                        target: "config::migration",
5779                        "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
5780                         New data is written to the primary path; the legacy tree remains untouched.",
5781                        legacy.display(),
5782                        primary.display()
5783                    );
5784                }
5785            }
5786        }
5787    }
5788    Ok(None)
5789}
5790
5791/// Recursively copy a directory tree from `src` to `dst`, creating `dst`.
5792/// Symlinks and other non-file/non-dir entries are skipped (rare in state dirs).
5793fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
5794    std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
5795    for entry in
5796        std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
5797    {
5798        let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
5799        let path = entry.path();
5800        let target = dst.join(entry.file_name());
5801        let file_type = entry
5802            .file_type()
5803            .with_context(|| format!("failed to read file type for {}", path.display()))?;
5804        if file_type.is_dir() {
5805            copy_dir_recursive(&path, &target)?;
5806        } else if file_type.is_file() {
5807            std::fs::copy(&path, &target).with_context(|| {
5808                format!("failed to copy {} -> {}", path.display(), target.display())
5809            })?;
5810        }
5811    }
5812    Ok(())
5813}
5814
5815/// Resolve a project-local state subdirectory, preferring `.codewhale/`
5816/// when it exists, falling back to `.deepseek/` for legacy projects.
5817///
5818/// Returns `(true, path)` when the primary `.codewhale/` path is used,
5819/// `(false, path)` for the legacy fallback. The boolean helps callers
5820/// emit a deprecation notice on legacy paths.
5821pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
5822    ensure_safe_state_subdir(subdir)?;
5823    let workspace = normalize_project_workspace(workspace)?;
5824    let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
5825    if primary.exists() {
5826        return Ok((true, primary));
5827    }
5828    let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
5829    Ok((false, legacy))
5830}
5831
5832/// Ensure a project-local state subdirectory exists under `.codewhale/`,
5833/// creating it if necessary. Returns the directory path.
5834pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
5835    ensure_safe_state_subdir(subdir)?;
5836    let workspace = normalize_project_workspace(workspace)?;
5837    let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
5838    std::fs::create_dir_all(&dir)
5839        .with_context(|| format!("failed to create {}/", dir.display()))?;
5840    Ok(dir)
5841}
5842
5843pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
5844    if let Some(path) = explicit {
5845        return normalize_config_file_path(path);
5846    }
5847    if let Some(path) = codewhale_paths::config_path_override().map_err(anyhow::Error::new)? {
5848        return normalize_config_file_path(path);
5849    }
5850    default_config_path()
5851}
5852
5853/// Whether `path` names a workspace-scoped config document —
5854/// `<repo>/.codewhale/config.toml` (or the legacy `.deepseek` layout) inside a
5855/// checkout — rather than a user-global config file.
5856///
5857/// Credential writes (api_key values, `auth_mode` markers, oauth/external
5858/// credential pointers) must never target such a document: a key saved while
5859/// working in one repo would be invisible from every other repo, and the repo
5860/// file stores it in plaintext where it is easy to commit by accident (#5045,
5861/// #5193).
5862///
5863/// A path is classified workspace-scoped only when its parent directory is a
5864/// `.codewhale`/`.deepseek` app dir outside the user's home AND the document
5865/// belongs to a workspace: it is relative (resolves against the process cwd),
5866/// its base directory contains the process cwd, or its base directory is a
5867/// checkout (has a `.git` entry). An explicit `$CODEWHALE_HOME` config is
5868/// user-global wherever that home points, even when the directory itself
5869/// happens to be named `.codewhale`; other custom locations (for example
5870/// `CODEWHALE_CONFIG_PATH=~/team.toml` or an isolated test directory) stay
5871/// honored as deliberate user-scoped choices.
5872#[must_use]
5873pub fn config_path_is_workspace_scoped(path: &Path) -> bool {
5874    config_path_is_workspace_scoped_with_context(
5875        path,
5876        codewhale_paths::codewhale_home_override()
5877            .ok()
5878            .flatten()
5879            .as_deref(),
5880        codewhale_paths::user_home().as_deref(),
5881        std::env::current_dir().ok().as_deref(),
5882    )
5883}
5884
5885/// Environment-free core of [`config_path_is_workspace_scoped`], split out so
5886/// scope classification is testable without mutating process-global state.
5887fn config_path_is_workspace_scoped_with_context(
5888    path: &Path,
5889    explicit_codewhale_home: Option<&Path>,
5890    user_home: Option<&Path>,
5891    current_dir: Option<&Path>,
5892) -> bool {
5893    if let Some(home) = explicit_codewhale_home
5894        && same_lexical_or_canonical_path(path, &home.join(CONFIG_FILE_NAME))
5895    {
5896        return false;
5897    }
5898    let Some(parent) = path.parent() else {
5899        return false;
5900    };
5901    let parent_is_app_dir = parent
5902        .file_name()
5903        .and_then(OsStr::to_str)
5904        .is_some_and(|name| name == CODEWHALE_APP_DIR || name == LEGACY_APP_DIR);
5905    if !parent_is_app_dir {
5906        return false;
5907    }
5908    let Some(base) = parent.parent() else {
5909        return true;
5910    };
5911    if let Some(home) = user_home
5912        && same_lexical_or_canonical_path(base, home)
5913    {
5914        return false;
5915    }
5916    if path.is_relative() {
5917        // Resolves against the process cwd: repo-scoped by construction.
5918        return true;
5919    }
5920    // The document belongs to the workspace the process is sitting in…
5921    if let Some(cwd) = current_dir
5922        && canonicalize_or_keep(cwd).starts_with(canonicalize_or_keep(base))
5923    {
5924        return true;
5925    }
5926    // …or to some other checkout (a `.git` entry beside the app dir).
5927    base.join(".git").exists()
5928}
5929
5930/// Lexical equality first, canonical equality as a fallback so an existing
5931/// path still matches through symlinked parents (e.g. `/tmp` on macOS).
5932fn same_lexical_or_canonical_path(a: &Path, b: &Path) -> bool {
5933    a == b || canonicalize_or_keep(a) == canonicalize_or_keep(b)
5934}
5935
5936fn canonicalize_or_keep(path: &Path) -> PathBuf {
5937    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
5938}
5939
5940#[cfg(test)]
5941mod credential_scope_tests {
5942    use super::config_path_is_workspace_scoped_with_context;
5943    use std::path::Path;
5944
5945    #[test]
5946    fn config_inside_current_workspace_is_workspace_scoped() {
5947        let temp = tempfile::tempdir().expect("tempdir");
5948        let repo = temp.path().join("repo");
5949        let cwd = repo.join("nested/dir");
5950        for app_dir in [".codewhale", ".deepseek"] {
5951            let config = repo.join(app_dir).join("config.toml");
5952            assert!(
5953                config_path_is_workspace_scoped_with_context(
5954                    &config,
5955                    None,
5956                    Some(Path::new("/home/user")),
5957                    Some(&cwd),
5958                ),
5959                "{} should be workspace-scoped when cwd sits inside the repo",
5960                config.display()
5961            );
5962        }
5963    }
5964
5965    #[test]
5966    fn relative_app_dir_config_is_workspace_scoped() {
5967        assert!(config_path_is_workspace_scoped_with_context(
5968            Path::new(".codewhale/config.toml"),
5969            None,
5970            Some(Path::new("/home/user")),
5971            Some(Path::new("/somewhere/else")),
5972        ));
5973    }
5974
5975    #[test]
5976    fn checkout_config_outside_cwd_is_workspace_scoped_via_git_marker() {
5977        let temp = tempfile::tempdir().expect("tempdir");
5978        let repo = temp.path().join("repo");
5979        std::fs::create_dir_all(repo.join(".git")).expect("git marker");
5980        std::fs::create_dir_all(repo.join(".codewhale")).expect("app dir");
5981        assert!(config_path_is_workspace_scoped_with_context(
5982            &repo.join(".codewhale/config.toml"),
5983            None,
5984            Some(Path::new("/home/user")),
5985            Some(Path::new("/somewhere/else")),
5986        ));
5987    }
5988
5989    #[test]
5990    fn user_global_and_custom_locations_are_not_workspace_scoped() {
5991        let home = Path::new("/home/user");
5992        let elsewhere = Some(Path::new("/somewhere/else"));
5993        for global_config in [
5994            "/home/user/.codewhale/config.toml",
5995            "/home/user/.deepseek/config.toml",
5996            "/home/user/team-config.toml",
5997            "/etc/codewhale/config.toml",
5998        ] {
5999            assert!(
6000                !config_path_is_workspace_scoped_with_context(
6001                    Path::new(global_config),
6002                    None,
6003                    Some(home),
6004                    elsewhere,
6005                ),
6006                "{global_config} should stay user-global"
6007            );
6008        }
6009        // An isolated app-dir-shaped location with no workspace relationship
6010        // (no cwd ancestry, no checkout marker) stays honored: test harnesses
6011        // and deliberate overrides point there.
6012        let temp = tempfile::tempdir().expect("tempdir");
6013        assert!(!config_path_is_workspace_scoped_with_context(
6014            &temp.path().join(".codewhale/config.toml"),
6015            None,
6016            Some(home),
6017            elsewhere,
6018        ));
6019    }
6020
6021    #[test]
6022    fn explicit_codewhale_home_config_is_user_global_even_when_dir_is_app_named() {
6023        let temp = tempfile::tempdir().expect("tempdir");
6024        let repo = temp.path().join("repo");
6025        let explicit = repo.join(".codewhale");
6026        // Even with cwd inside the repo, the explicit CODEWHALE_HOME config is
6027        // the user-global scope by definition.
6028        assert!(!config_path_is_workspace_scoped_with_context(
6029            &explicit.join("config.toml"),
6030            Some(&explicit),
6031            Some(Path::new("/home/user")),
6032            Some(&repo),
6033        ));
6034        // A different repo-scoped document is still workspace-scoped.
6035        assert!(config_path_is_workspace_scoped_with_context(
6036            &repo.join("other/.codewhale/config.toml"),
6037            Some(&explicit),
6038            Some(Path::new("/home/user")),
6039            Some(&repo.join("other")),
6040        ));
6041    }
6042}
6043
6044#[must_use]
6045pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
6046    config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
6047}
6048
6049fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
6050    checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
6051}
6052
6053pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
6054    checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
6055}
6056
6057/// Load the active sibling permission rules with confirmation tokens suitable
6058/// for a later compare-and-remove operation.
6059pub fn load_permissions_snapshot(config_path: Option<PathBuf>) -> Result<PermissionsSnapshot> {
6060    let path = resolve_permissions_path(config_path)?;
6061    let (file_exists, raw, permissions) = read_permissions_state(&path)?;
6062    let file_state = if !file_exists {
6063        PermissionsFileState::Missing
6064    } else if raw.is_empty() {
6065        PermissionsFileState::Empty
6066    } else {
6067        PermissionsFileState::Present
6068    };
6069    let removal_tokens = (0..permissions.rules.len())
6070        .map(|index| permission_removal_token(&path, &raw, index))
6071        .collect();
6072    Ok(PermissionsSnapshot {
6073        path,
6074        file_state,
6075        permissions,
6076        removal_tokens,
6077    })
6078}
6079
6080/// Remove one zero-based permission rule if `expected_token` still describes
6081/// that exact index in the current file.
6082///
6083/// The file is re-read only after acquiring the same adjacent lock used by
6084/// append operations. This makes the token check and atomic replacement one
6085/// transaction, preventing stale list views from deleting a different rule.
6086pub fn remove_permission_rule(
6087    config_path: Option<PathBuf>,
6088    index: usize,
6089    expected_token: &str,
6090) -> Result<ToolAskRule> {
6091    let path = resolve_permissions_path(config_path)?;
6092    config_document::with_config_write_lock(&path, |path| {
6093        let (file_exists, raw, permissions) = read_permissions_state(path)?;
6094        if !file_exists {
6095            bail!(
6096                "permissions changed after they were listed; reload {} and retry",
6097                quote_os_path(path)
6098            );
6099        }
6100        let rule = permissions.rules.get(index).cloned().with_context(|| {
6101            format!(
6102                "permission rule {} no longer exists in {}; list rules again",
6103                index + 1,
6104                quote_os_path(path)
6105            )
6106        })?;
6107        let current_token = permission_removal_token(path, &raw, index);
6108        if current_token != expected_token {
6109            bail!(
6110                "permissions changed after they were listed; reload {} and retry",
6111                quote_os_path(path)
6112            );
6113        }
6114
6115        let mut document = parse_permissions_document(path, &raw)?;
6116        let rules_item = document.get_mut("rules").with_context(|| {
6117            format!(
6118                "permissions at {} no longer contain a rules array",
6119                quote_os_path(path)
6120            )
6121        })?;
6122        let orphaned_header = remove_permission_rule_item(rules_item, index)?;
6123        if let Some(header) = orphaned_header {
6124            let trailing = format!(
6125                "{header}{}",
6126                document.trailing().as_str().unwrap_or_default()
6127            );
6128            document.set_trailing(trailing);
6129        }
6130        let body = document.to_string();
6131        let persisted = parse_generated_permissions(path, &body)?;
6132        if persisted.rules.len() + 1 != permissions.rules.len() {
6133            bail!(
6134                "refusing inconsistent permission removal at {}",
6135                quote_os_path(path)
6136            );
6137        }
6138        write_permissions_atomic(path, body.as_bytes())?;
6139        Ok(rule)
6140    })
6141}
6142
6143/// Read a resolved `permissions.toml` path using the same checked/no-follow
6144/// path handling as config loading.
6145pub fn read_permissions_file(path: &Path) -> Result<String> {
6146    read_checked_permissions_file(path)
6147}
6148
6149fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
6150    let permissions_path = checked_permissions_path_for_config_path(config_path)?;
6151    let (_, _, permissions) = read_permissions_state(&permissions_path)?;
6152    Ok(permissions)
6153}
6154
6155fn read_permissions_state(path: &Path) -> Result<(bool, String, PermissionsToml)> {
6156    let file_exists = checked_path_exists(path)?;
6157    let raw = if file_exists {
6158        read_checked_permissions_file(path)?
6159    } else {
6160        String::new()
6161    };
6162    let permissions = if raw.trim().is_empty() {
6163        PermissionsToml::default()
6164    } else {
6165        toml::from_str(&raw).map_err(|_| {
6166            anyhow::anyhow!(
6167                "failed to parse permissions at {}; file contents were omitted",
6168                quote_os_path(path)
6169            )
6170        })?
6171    };
6172    Ok((file_exists, raw, permissions))
6173}
6174
6175fn parse_permissions_document(path: &Path, raw: &str) -> Result<toml_edit::DocumentMut> {
6176    if raw.trim().is_empty() {
6177        Ok(toml_edit::DocumentMut::new())
6178    } else {
6179        raw.parse::<toml_edit::DocumentMut>().map_err(|_| {
6180            anyhow::anyhow!(
6181                "failed to edit permissions at {}; file contents were omitted",
6182                quote_os_path(path)
6183            )
6184        })
6185    }
6186}
6187
6188fn parse_generated_permissions(path: &Path, body: &str) -> Result<PermissionsToml> {
6189    toml::from_str(body).map_err(|_| {
6190        anyhow::anyhow!(
6191            "generated invalid permissions document for {}; file contents were omitted",
6192            quote_os_path(path)
6193        )
6194    })
6195}
6196
6197fn permission_removal_token(path: &Path, raw: &str, index: usize) -> String {
6198    let mut hasher = Sha256::new();
6199    hasher.update(b"codewhale-permission-removal-v1\0");
6200    hasher.update(quote_os_path(path).as_bytes());
6201    hasher.update(b"\0");
6202    hasher.update(index.to_le_bytes());
6203    hasher.update(b"\0");
6204    hasher.update(raw.as_bytes());
6205    let digest = hasher.finalize();
6206    let mut token = String::with_capacity(24);
6207    for byte in &digest[..12] {
6208        use std::fmt::Write as _;
6209        let _ = write!(&mut token, "{byte:02x}");
6210    }
6211    token
6212}
6213
6214fn append_permission_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
6215    match item {
6216        toml_edit::Item::ArrayOfTables(rules) => {
6217            rules.push(permission_rule_table(rule));
6218            Ok(())
6219        }
6220        toml_edit::Item::Value(value) => {
6221            let Some(rules) = value.as_array_mut() else {
6222                bail!("`rules` in permissions.toml must be an array");
6223            };
6224            rules.push(toml_edit::Value::InlineTable(permission_rule_inline_table(
6225                rule,
6226            )));
6227            Ok(())
6228        }
6229        _ => bail!("`rules` in permissions.toml must be an array"),
6230    }
6231}
6232
6233fn remove_permission_rule_item(item: &mut toml_edit::Item, index: usize) -> Result<Option<String>> {
6234    match item {
6235        toml_edit::Item::ArrayOfTables(rules) => {
6236            if index >= rules.len() {
6237                bail!("permission rule index changed before removal");
6238            }
6239            let file_header = if index == 0 {
6240                rules
6241                    .get(index)
6242                    .and_then(|rule| rule.decor().prefix())
6243                    .and_then(toml_edit::RawString::as_str)
6244                    .map(str::to_owned)
6245            } else {
6246                None
6247            };
6248            rules.remove(index);
6249            if let Some(header) = file_header.as_deref()
6250                && let Some(next_rule) = rules.get_mut(0)
6251            {
6252                let next_prefix = next_rule
6253                    .decor()
6254                    .prefix()
6255                    .and_then(toml_edit::RawString::as_str)
6256                    .unwrap_or_default()
6257                    .to_owned();
6258                next_rule
6259                    .decor_mut()
6260                    .set_prefix(format!("{header}{next_prefix}"));
6261                return Ok(None);
6262            }
6263            Ok(file_header)
6264        }
6265        toml_edit::Item::Value(value) => {
6266            let Some(rules) = value.as_array_mut() else {
6267                bail!("`rules` in permissions.toml must be an array");
6268            };
6269            if index >= rules.len() {
6270                bail!("permission rule index changed before removal");
6271            }
6272            rules.remove(index);
6273            Ok(None)
6274        }
6275        _ => bail!("`rules` in permissions.toml must be an array"),
6276    }
6277}
6278
6279fn permission_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
6280    let mut table = toml_edit::Table::new();
6281    table["tool"] = toml_edit::value(rule.tool.clone());
6282    if let Some(command) = rule.command.as_deref() {
6283        table["command"] = toml_edit::value(command);
6284    }
6285    if rule.command_exact {
6286        table["command_exact"] = toml_edit::value(true);
6287    }
6288    if let Some(path) = rule.path.as_deref() {
6289        table["path"] = toml_edit::value(path);
6290    }
6291    if let Some(workspace) = rule.workspace.as_deref() {
6292        table["workspace"] = toml_edit::value(workspace);
6293    }
6294    if rule.action != PermissionAction::Ask {
6295        table["action"] = toml_edit::value(match rule.action {
6296            PermissionAction::Allow => "allow",
6297            PermissionAction::Ask => "ask",
6298            PermissionAction::Deny => "deny",
6299        });
6300    }
6301    table
6302}
6303
6304fn permission_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
6305    let mut table = toml_edit::InlineTable::new();
6306    table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
6307    if let Some(command) = rule.command.as_deref() {
6308        table.insert("command", toml_edit::Value::from(command));
6309    }
6310    if rule.command_exact {
6311        table.insert("command_exact", toml_edit::Value::from(true));
6312    }
6313    if let Some(path) = rule.path.as_deref() {
6314        table.insert("path", toml_edit::Value::from(path));
6315    }
6316    if let Some(workspace) = rule.workspace.as_deref() {
6317        table.insert("workspace", toml_edit::Value::from(workspace));
6318    }
6319    if rule.action != PermissionAction::Ask {
6320        table.insert(
6321            "action",
6322            toml_edit::Value::from(match rule.action {
6323                PermissionAction::Allow => "allow",
6324                PermissionAction::Ask => "ask",
6325                PermissionAction::Deny => "deny",
6326            }),
6327        );
6328    }
6329    table
6330}
6331
6332fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
6333    let parent = path.parent().with_context(|| {
6334        format!(
6335            "permissions path has no parent directory: {}",
6336            path.display()
6337        )
6338    })?;
6339    fs::create_dir_all(parent).with_context(|| {
6340        format!(
6341            "failed to create permissions directory {}",
6342            parent.display()
6343        )
6344    })?;
6345
6346    let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
6347        format!(
6348            "failed to create temporary permissions file in {}",
6349            parent.display()
6350        )
6351    })?;
6352    #[cfg(unix)]
6353    temporary
6354        .as_file()
6355        .set_permissions(fs::Permissions::from_mode(0o600))
6356        .with_context(|| {
6357            format!(
6358                "failed to secure temporary permissions file for {}",
6359                path.display()
6360            )
6361        })?;
6362    temporary
6363        .write_all(body)
6364        .with_context(|| format!("failed to write permissions at {}", path.display()))?;
6365    temporary
6366        .as_file()
6367        .sync_all()
6368        .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
6369    temporary
6370        .persist(path)
6371        .map_err(|error| error.error)
6372        .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
6373    Ok(())
6374}
6375
6376pub fn default_config_path() -> Result<PathBuf> {
6377    // Prefer ~/.codewhale/config.toml when it exists (fresh install or
6378    // migrated), otherwise fall back to ~/.deepseek/config.toml.
6379    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
6380    if codewhale_home_is_explicit() || primary.exists() {
6381        return Ok(primary);
6382    }
6383    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
6384    if legacy.exists() {
6385        return Ok(legacy);
6386    }
6387    // Neither exists — return primary so first write creates it there.
6388    Ok(primary)
6389}
6390
6391#[derive(Debug, Clone, PartialEq, Eq)]
6392pub struct ConfigMigration {
6393    pub legacy_path: PathBuf,
6394    pub primary_path: PathBuf,
6395}
6396
6397impl ConfigMigration {
6398    pub fn user_notice(&self) -> String {
6399        format!(
6400            "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
6401            self.legacy_path.display(),
6402            self.primary_path.display()
6403        )
6404    }
6405}
6406
6407/// v0.8.44: one-time migration from `~/.deepseek/config.toml` to
6408/// `~/.codewhale/config.toml`. Called on first launch after the config
6409/// is loaded; copies the legacy file if the primary doesn't exist yet.
6410/// Never overwrites an existing primary config.
6411pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
6412    if codewhale_home_is_explicit() {
6413        return Ok(None);
6414    }
6415    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
6416    if primary.exists() {
6417        return Ok(None);
6418    }
6419    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
6420    if !legacy.exists() {
6421        return Ok(None);
6422    }
6423    // Copy the config to the new home.
6424    if let Some(parent) = primary.parent() {
6425        std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
6426    }
6427    std::fs::copy(&legacy, &primary)
6428        .context("failed to migrate config from deepseek to codewhale home")?;
6429    tracing::info!(
6430        "Migrated config from {} to {}",
6431        legacy.display(),
6432        primary.display()
6433    );
6434    Ok(Some(ConfigMigration {
6435        legacy_path: legacy,
6436        primary_path: primary,
6437    }))
6438}
6439
6440fn parse_bool(raw: &str) -> Result<bool> {
6441    match raw.trim().to_ascii_lowercase().as_str() {
6442        "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
6443        "0" | "false" | "no" | "off" | "disabled" => Ok(false),
6444        _ => bail!("invalid boolean '{raw}'"),
6445    }
6446}
6447
6448fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
6449    let mut headers = BTreeMap::new();
6450    for pair in raw.trim().split(',') {
6451        let pair = pair.trim();
6452        if pair.is_empty() {
6453            continue;
6454        }
6455        let Some((name, value)) = pair.split_once('=') else {
6456            bail!("invalid header pair '{pair}', expected name=value");
6457        };
6458        let name = name.trim();
6459        let value = value.trim();
6460        if name.is_empty() {
6461            bail!("header name cannot be empty");
6462        }
6463        if value.is_empty() {
6464            continue;
6465        }
6466        headers.insert(name.to_string(), value.to_string());
6467    }
6468    Ok(headers)
6469}
6470
6471fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
6472    if headers.is_empty() {
6473        return None;
6474    }
6475    Some(
6476        headers
6477            .iter()
6478            .map(|(name, value)| format!("{name}={value}"))
6479            .collect::<Vec<_>>()
6480            .join(","),
6481    )
6482}
6483
6484fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
6485    if headers.is_empty() {
6486        return None;
6487    }
6488    Some(
6489        headers
6490            .iter()
6491            .map(|(name, value)| {
6492                let display_value = if is_sensitive_config_key(name) {
6493                    redact_secret(value)
6494                } else {
6495                    value.clone()
6496                };
6497                format!("{name}={display_value}")
6498            })
6499            .collect::<Vec<_>>()
6500            .join(","),
6501    )
6502}
6503
6504fn redact_secret(secret: &str) -> String {
6505    let chars: Vec<char> = secret.chars().collect();
6506    if chars.len() <= 16 {
6507        return "********".to_string();
6508    }
6509    let prefix: String = chars.iter().take(4).collect();
6510    let suffix: String = chars
6511        .iter()
6512        .rev()
6513        .take(4)
6514        .collect::<Vec<_>>()
6515        .into_iter()
6516        .rev()
6517        .collect();
6518    format!("{prefix}***{suffix}")
6519}
6520
6521#[must_use]
6522pub fn is_sensitive_config_key(key: &str) -> bool {
6523    let Some(segment) = key.rsplit('.').next() else {
6524        return false;
6525    };
6526    let normalized = segment
6527        .trim()
6528        .trim_matches('"')
6529        .replace('-', "_")
6530        .to_ascii_lowercase();
6531
6532    matches!(
6533        normalized.as_str(),
6534        "api_key"
6535            | "apikey"
6536            | "api_keys"
6537            | "authorization"
6538            | "bearer"
6539            | "client_secret"
6540            | "credential"
6541            | "credentials"
6542            | "id_token"
6543            | "password"
6544            | "passwords"
6545            | "passwd"
6546            | "proxy_authorization"
6547            | "refresh_token"
6548            | "secret"
6549            | "secrets"
6550            | "token"
6551            | "tokens"
6552    ) || normalized.ends_with("_api_key")
6553        || normalized.ends_with("_authorization")
6554        || normalized.ends_with("_password")
6555        || normalized.ends_with("_secret")
6556        || normalized.ends_with("_token")
6557}
6558
6559fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
6560    redact_toml_value_for_display_inner(key, false, value).to_string()
6561}
6562
6563fn toml_value_as_u64(value: &toml::Value) -> Option<u64> {
6564    match value {
6565        toml::Value::Integer(value) => u64::try_from(*value).ok(),
6566        toml::Value::String(value) => value.trim().parse().ok(),
6567        _ => None,
6568    }
6569}
6570
6571fn redact_toml_value_for_display_inner(
6572    key: &str,
6573    sensitive_ancestor: bool,
6574    value: &toml::Value,
6575) -> toml::Value {
6576    let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
6577    match value {
6578        toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
6579        toml::Value::Array(values) => toml::Value::Array(
6580            values
6581                .iter()
6582                .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
6583                .collect(),
6584        ),
6585        toml::Value::Table(table) => {
6586            let mut redacted = toml::map::Map::new();
6587            for (child_key, child_value) in table {
6588                let path = if key.is_empty() {
6589                    child_key.clone()
6590                } else {
6591                    format!("{key}.{child_key}")
6592                };
6593                redacted.insert(
6594                    child_key.clone(),
6595                    redact_toml_value_for_display_inner(&path, sensitive, child_value),
6596                );
6597            }
6598            toml::Value::Table(redacted)
6599        }
6600        _ if sensitive => toml::Value::String("********".to_string()),
6601        _ => value.clone(),
6602    }
6603}
6604
6605fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
6606    if path.as_os_str().is_empty() {
6607        bail!("config path cannot be empty");
6608    }
6609    if path
6610        .components()
6611        .any(|component| matches!(component, Component::ParentDir))
6612    {
6613        bail!("config path cannot contain '..' components");
6614    }
6615    if path.file_name().is_none() {
6616        bail!("config path must include a file name");
6617    }
6618    let absolute = if path.is_absolute() {
6619        path
6620    } else {
6621        std::env::current_dir()
6622            .context("failed to resolve current directory for config path")?
6623            .join(path)
6624    };
6625    let file_name = absolute
6626        .file_name()
6627        .map(OsString::from)
6628        .context("config path must include a file name")?;
6629    let parent = absolute
6630        .parent()
6631        .context("config path must include a parent directory")?;
6632    let parent = match parent.canonicalize() {
6633        Ok(parent) => parent,
6634        Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
6635        Err(err) => {
6636            return Err(err).with_context(|| {
6637                format!("failed to resolve config directory {}", parent.display())
6638            });
6639        }
6640    };
6641    let normalized = parent.join(file_name);
6642    reject_path_symlink(&normalized)?;
6643    Ok(normalized)
6644}
6645
6646fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
6647    if workspace.as_os_str().is_empty() {
6648        bail!("project workspace path cannot be empty");
6649    }
6650    if workspace
6651        .components()
6652        .any(|component| matches!(component, Component::ParentDir))
6653    {
6654        bail!("project workspace path cannot contain '..' components");
6655    }
6656    let absolute = if workspace.is_absolute() {
6657        workspace.to_path_buf()
6658    } else {
6659        std::env::current_dir()
6660            .context("failed to resolve current directory for project workspace")?
6661            .join(workspace)
6662    };
6663    match absolute.canonicalize() {
6664        Ok(path) => Ok(path),
6665        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
6666            Ok(normalize_path_components(&absolute))
6667        }
6668        Err(err) => Err(err).with_context(|| {
6669            format!(
6670                "failed to resolve project workspace {}",
6671                workspace.display()
6672            )
6673        }),
6674    }
6675}
6676
6677fn normalize_path_components(path: &Path) -> PathBuf {
6678    let mut normalized = PathBuf::new();
6679    for component in path.components() {
6680        match component {
6681            Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
6682            Component::CurDir => {}
6683            Component::ParentDir => {
6684                normalized.pop();
6685            }
6686            Component::Normal(part) => normalized.push(part),
6687        }
6688    }
6689    if normalized.as_os_str().is_empty() {
6690        PathBuf::from(".")
6691    } else {
6692        normalized
6693    }
6694}
6695
6696fn checked_path_exists(path: &Path) -> Result<bool> {
6697    let path = normalize_config_file_path(path.to_path_buf())?;
6698    path.try_exists()
6699        .with_context(|| format!("failed to inspect config path {}", path.display()))
6700}
6701
6702fn read_checked_config_file(path: &Path) -> Result<String> {
6703    read_checked_toml_file(path, "config")
6704}
6705
6706fn read_checked_permissions_file(path: &Path) -> Result<String> {
6707    read_checked_toml_file(path, "permissions")
6708}
6709
6710fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
6711    let path = normalize_config_file_path(path.to_path_buf())?;
6712    read_string_no_follow(&path)
6713        .with_context(|| format!("failed to read {label} at {}", path.display()))
6714}
6715
6716#[cfg(unix)]
6717fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
6718    let mut file = fs::OpenOptions::new()
6719        .read(true)
6720        .custom_flags(libc::O_NOFOLLOW)
6721        .open(path)?;
6722    let mut raw = String::new();
6723    file.read_to_string(&mut raw)?;
6724    Ok(raw)
6725}
6726
6727#[cfg(not(unix))]
6728fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
6729    fs::read_to_string(path)
6730}
6731
6732fn reject_path_symlink(path: &Path) -> Result<()> {
6733    let Ok(metadata) = fs::symlink_metadata(path) else {
6734        return Ok(());
6735    };
6736    if metadata.file_type().is_symlink() {
6737        bail!("config path must not be a symlink: {}", path.display());
6738    }
6739    Ok(())
6740}
6741
6742#[derive(Debug, Clone, Default)]
6743struct EnvRuntimeOverrides {
6744    provider: Option<ProviderKind>,
6745    provider_source: Option<&'static str>,
6746    model: Option<String>,
6747    volcengine_model: Option<String>,
6748    wanjie_ark_model: Option<String>,
6749    openrouter_model: Option<String>,
6750    orcarouter_model: Option<String>,
6751    moonshot_model: Option<String>,
6752    xiaomi_mimo_model: Option<String>,
6753    xiaomi_mimo_mode: Option<String>,
6754    novita_model: Option<String>,
6755    fireworks_model: Option<String>,
6756    arcee_model: Option<String>,
6757    output_mode: Option<String>,
6758    auth_mode: Option<String>,
6759    log_level: Option<String>,
6760    telemetry: Option<bool>,
6761    /// `CODEWHALE_TELEMETRY`/`DEEPSEEK_TELEMETRY` was set to something
6762    /// [`parse_bool`] could not read. A typo in a kill switch must never
6763    /// resolve to "on", so this forces telemetry off the same way an explicit
6764    /// `false` does.
6765    telemetry_env_invalid: bool,
6766    /// An environment-level kill switch is in force for this process.
6767    ///
6768    /// See [`telemetry_floor_in_force`] for what sets it and why the dispatcher
6769    /// has to state it rather than let the child infer it.
6770    telemetry_floor: bool,
6771    /// `CODEWHALE_TELEMETRY_ENDPOINT`/`DEEPSEEK_TELEMETRY_ENDPOINT`. Overrides
6772    /// the config file. A workspace `.env` cannot reach this — the dotenv
6773    /// allowlist admits only built-in provider credential names.
6774    telemetry_endpoint: Option<String>,
6775    approval_policy: Option<String>,
6776    sandbox_mode: Option<String>,
6777    yolo: Option<bool>,
6778    verbosity: Option<String>,
6779    http_headers: Option<BTreeMap<String, String>>,
6780    deepseek_base_url: Option<String>,
6781    deepseek_anthropic_base_url: Option<String>,
6782    nvidia_base_url: Option<String>,
6783    openai_base_url: Option<String>,
6784    atlascloud_base_url: Option<String>,
6785    volcengine_base_url: Option<String>,
6786    wanjie_ark_base_url: Option<String>,
6787    openrouter_base_url: Option<String>,
6788    orcarouter_base_url: Option<String>,
6789    xiaomi_mimo_base_url: Option<String>,
6790    novita_base_url: Option<String>,
6791    fireworks_base_url: Option<String>,
6792    siliconflow_base_url: Option<String>,
6793    siliconflow_model: Option<String>,
6794    arcee_base_url: Option<String>,
6795    moonshot_base_url: Option<String>,
6796    sglang_base_url: Option<String>,
6797    vllm_base_url: Option<String>,
6798    ollama_base_url: Option<String>,
6799    ollama_cloud_base_url: Option<String>,
6800    ollama_cloud_model: Option<String>,
6801    huggingface_base_url: Option<String>,
6802    huggingface_model: Option<String>,
6803    together_base_url: Option<String>,
6804    together_model: Option<String>,
6805    qianfan_base_url: Option<String>,
6806    qianfan_model: Option<String>,
6807    openai_codex_base_url: Option<String>,
6808    openai_codex_model: Option<String>,
6809    anthropic_base_url: Option<String>,
6810    anthropic_model: Option<String>,
6811    openmodel_base_url: Option<String>,
6812    openmodel_model: Option<String>,
6813    zai_base_url: Option<String>,
6814    zai_model: Option<String>,
6815    stepfun_base_url: Option<String>,
6816    stepfun_model: Option<String>,
6817    minimax_base_url: Option<String>,
6818    minimax_anthropic_base_url: Option<String>,
6819    minimax_model: Option<String>,
6820    deepinfra_base_url: Option<String>,
6821    deepinfra_model: Option<String>,
6822    sakana_base_url: Option<String>,
6823    sakana_model: Option<String>,
6824    longcat_base_url: Option<String>,
6825    longcat_model: Option<String>,
6826    opencode_go_base_url: Option<String>,
6827    opencode_go_model: Option<String>,
6828    opencode_zen_base_url: Option<String>,
6829    opencode_zen_model: Option<String>,
6830    meta_base_url: Option<String>,
6831    meta_model: Option<String>,
6832    xai_base_url: Option<String>,
6833    xai_model: Option<String>,
6834    mistral_base_url: Option<String>,
6835    mistral_model: Option<String>,
6836    google_base_url: Option<String>,
6837    google_model: Option<String>,
6838    antigravity_base_url: Option<String>,
6839    antigravity_model: Option<String>,
6840    telecomjs_base_url: Option<String>,
6841    telecomjs_model: Option<String>,
6842    edenai_base_url: Option<String>,
6843    edenai_model: Option<String>,
6844    modelstudio_token_plan_base_url: Option<String>,
6845    modelstudio_token_plan_model: Option<String>,
6846    modelstudio_coding_plan_base_url: Option<String>,
6847    modelstudio_coding_plan_model: Option<String>,
6848}
6849
6850impl EnvRuntimeOverrides {
6851    fn load() -> Self {
6852        let (provider, provider_source) = Self::load_provider();
6853        let (telemetry, telemetry_env_invalid) = Self::load_telemetry();
6854        let telemetry_floor = telemetry_floor_in_force();
6855        Self {
6856            provider,
6857            provider_source,
6858            model: std::env::var("CODEWHALE_MODEL")
6859                .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
6860                .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
6861                .ok()
6862                .filter(|v| !v.trim().is_empty()),
6863            volcengine_model: std::env::var("VOLCENGINE_MODEL")
6864                .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
6865                .ok()
6866                .filter(|v| !v.trim().is_empty()),
6867            wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
6868                .or_else(|_| std::env::var("WANJIE_MODEL"))
6869                .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
6870                .ok()
6871                .filter(|v| !v.trim().is_empty()),
6872            openrouter_model: std::env::var("OPENROUTER_MODEL")
6873                .ok()
6874                .filter(|v| !v.trim().is_empty()),
6875            orcarouter_model: std::env::var("ORCAROUTER_MODEL")
6876                .ok()
6877                .filter(|v| !v.trim().is_empty()),
6878            moonshot_model: std::env::var("MOONSHOT_MODEL")
6879                .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
6880                .or_else(|_| std::env::var("KIMI_MODEL"))
6881                .ok()
6882                .filter(|v| !v.trim().is_empty()),
6883            xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
6884                .or_else(|_| std::env::var("MIMO_MODEL"))
6885                .ok()
6886                .filter(|v| !v.trim().is_empty()),
6887            xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
6888                .or_else(|_| std::env::var("MIMO_MODE"))
6889                .ok()
6890                .filter(|v| !v.trim().is_empty()),
6891            novita_model: std::env::var("NOVITA_MODEL")
6892                .ok()
6893                .filter(|v| !v.trim().is_empty()),
6894            fireworks_model: std::env::var("FIREWORKS_MODEL")
6895                .ok()
6896                .filter(|v| !v.trim().is_empty()),
6897            arcee_model: std::env::var("ARCEE_MODEL")
6898                .ok()
6899                .filter(|v| !v.trim().is_empty()),
6900            verbosity: std::env::var("CODEWHALE_VERBOSITY")
6901                .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
6902                .ok(),
6903            output_mode: std::env::var("CODEWHALE_OUTPUT_MODE")
6904                .or_else(|_| std::env::var("DEEPSEEK_OUTPUT_MODE"))
6905                .ok(),
6906            auth_mode: std::env::var("CODEWHALE_AUTH_MODE")
6907                .or_else(|_| std::env::var("DEEPSEEK_AUTH_MODE"))
6908                .ok(),
6909            log_level: std::env::var("CODEWHALE_LOG_LEVEL")
6910                .or_else(|_| std::env::var("DEEPSEEK_LOG_LEVEL"))
6911                .ok(),
6912            telemetry,
6913            telemetry_env_invalid,
6914            telemetry_floor,
6915            // Empty is kept, not discarded. Since the config file's *absent*
6916            // endpoint now resolves to `DEFAULT_TELEMETRY_ENDPOINT`, dropping
6917            // an explicitly emptied variable here would make
6918            // `CODEWHALE_TELEMETRY_ENDPOINT=` select the shipped endpoint —
6919            // the opposite of what anyone typing it means. Resolution reads an
6920            // empty override as "contact nobody, write the dry-run file".
6921            telemetry_endpoint: std::env::var("CODEWHALE_TELEMETRY_ENDPOINT")
6922                .or_else(|_| std::env::var("DEEPSEEK_TELEMETRY_ENDPOINT"))
6923                .ok(),
6924            approval_policy: std::env::var("CODEWHALE_APPROVAL_POLICY")
6925                .or_else(|_| std::env::var("DEEPSEEK_APPROVAL_POLICY"))
6926                .ok(),
6927            sandbox_mode: std::env::var("CODEWHALE_SANDBOX_MODE")
6928                .or_else(|_| std::env::var("DEEPSEEK_SANDBOX_MODE"))
6929                .ok(),
6930            yolo: std::env::var("CODEWHALE_YOLO")
6931                .or_else(|_| std::env::var("DEEPSEEK_YOLO"))
6932                .ok()
6933                .and_then(|v| match parse_bool(&v) {
6934                    Ok(b) => Some(b),
6935                    Err(_) => {
6936                        tracing::warn!("Invalid CODEWHALE_YOLO/DEEPSEEK_YOLO value '{v}', expected true/false");
6937                        None
6938                    }
6939                }),
6940            http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
6941                .or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
6942                .ok()
6943                .and_then(|value| match parse_http_headers(&value) {
6944                    Ok(h) => Some(h),
6945                    Err(_) => {
6946                        tracing::warn!("Invalid CODEWHALE_HTTP_HEADERS/DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
6947                        None
6948                    }
6949                })
6950                .filter(|headers| !headers.is_empty()),
6951            deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
6952                .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
6953                .ok()
6954                .filter(|v| !v.trim().is_empty()),
6955            deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
6956                .or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
6957                .ok()
6958                .filter(|v| !v.trim().is_empty()),
6959            nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
6960                .or_else(|_| std::env::var("NIM_BASE_URL"))
6961                .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
6962                .ok()
6963                .filter(|v| !v.trim().is_empty()),
6964            openai_base_url: std::env::var("OPENAI_BASE_URL")
6965                .ok()
6966                .filter(|v| !v.trim().is_empty()),
6967            atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
6968                .ok()
6969                .filter(|v| !v.trim().is_empty()),
6970            volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
6971                .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
6972                .or_else(|_| std::env::var("ARK_BASE_URL"))
6973                .ok()
6974                .filter(|v| !v.trim().is_empty()),
6975            wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
6976                .or_else(|_| std::env::var("WANJIE_BASE_URL"))
6977                .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
6978                .ok()
6979                .filter(|v| !v.trim().is_empty()),
6980            openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
6981                .ok()
6982                .filter(|v| !v.trim().is_empty()),
6983            orcarouter_base_url: std::env::var("ORCAROUTER_BASE_URL")
6984                .ok()
6985                .filter(|v| !v.trim().is_empty()),
6986            xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
6987                .or_else(|_| std::env::var("MIMO_BASE_URL"))
6988                .ok()
6989                .filter(|v| !v.trim().is_empty()),
6990            novita_base_url: std::env::var("NOVITA_BASE_URL")
6991                .ok()
6992                .filter(|v| !v.trim().is_empty()),
6993            fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
6994                .ok()
6995                .filter(|v| !v.trim().is_empty()),
6996            siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
6997                .ok()
6998                .filter(|v| !v.trim().is_empty()),
6999            siliconflow_model: std::env::var("SILICONFLOW_MODEL")
7000                .ok()
7001                .filter(|v| !v.trim().is_empty()),
7002            arcee_base_url: std::env::var("ARCEE_BASE_URL")
7003                .ok()
7004                .filter(|v| !v.trim().is_empty()),
7005            moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
7006                .or_else(|_| std::env::var("KIMI_BASE_URL"))
7007                .ok()
7008                .filter(|v| !v.trim().is_empty()),
7009            sglang_base_url: std::env::var("SGLANG_BASE_URL")
7010                .ok()
7011                .filter(|v| !v.trim().is_empty()),
7012            vllm_base_url: std::env::var("VLLM_BASE_URL")
7013                .ok()
7014                .filter(|v| !v.trim().is_empty()),
7015            ollama_base_url: std::env::var("OLLAMA_BASE_URL")
7016                .ok()
7017                .filter(|v| !v.trim().is_empty()),
7018            ollama_cloud_base_url: std::env::var("OLLAMA_CLOUD_BASE_URL")
7019                .ok()
7020                .filter(|v| !v.trim().is_empty()),
7021            ollama_cloud_model: std::env::var("OLLAMA_CLOUD_MODEL")
7022                .ok()
7023                .filter(|v| !v.trim().is_empty()),
7024            huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
7025                .or_else(|_| std::env::var("HF_BASE_URL"))
7026                .ok()
7027                .filter(|v| !v.trim().is_empty()),
7028            huggingface_model: std::env::var("HUGGINGFACE_MODEL")
7029                .or_else(|_| std::env::var("HF_MODEL"))
7030                .ok()
7031                .filter(|v| !v.trim().is_empty()),
7032            together_base_url: std::env::var("TOGETHER_BASE_URL")
7033                .ok()
7034                .filter(|v| !v.trim().is_empty()),
7035            together_model: std::env::var("TOGETHER_MODEL")
7036                .ok()
7037                .filter(|v| !v.trim().is_empty()),
7038            qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
7039                .ok()
7040                .filter(|v| !v.trim().is_empty())
7041                .or_else(|| {
7042                    std::env::var("BAIDU_QIANFAN_BASE_URL")
7043                        .ok()
7044                        .filter(|v| !v.trim().is_empty())
7045                }),
7046            qianfan_model: std::env::var("QIANFAN_MODEL")
7047                .ok()
7048                .filter(|v| !v.trim().is_empty())
7049                .or_else(|| {
7050                    std::env::var("BAIDU_QIANFAN_MODEL")
7051                        .ok()
7052                        .filter(|v| !v.trim().is_empty())
7053                }),
7054            openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
7055                .or_else(|_| std::env::var("CODEX_BASE_URL"))
7056                .ok()
7057                .filter(|v| !v.trim().is_empty()),
7058            openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
7059                .or_else(|_| std::env::var("CODEX_MODEL"))
7060                .ok()
7061                .filter(|v| !v.trim().is_empty()),
7062            anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
7063                .ok()
7064                .filter(|v| !v.trim().is_empty()),
7065            anthropic_model: std::env::var("ANTHROPIC_MODEL")
7066                .ok()
7067                .filter(|v| !v.trim().is_empty()),
7068            openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
7069                .ok()
7070                .filter(|v| !v.trim().is_empty()),
7071            openmodel_model: std::env::var("OPENMODEL_MODEL")
7072                .ok()
7073                .filter(|v| !v.trim().is_empty()),
7074            zai_base_url: std::env::var("ZAI_BASE_URL")
7075                .or_else(|_| std::env::var("Z_AI_BASE_URL"))
7076                .or_else(|_| std::env::var("ZHIPU_BASE_URL"))
7077                .or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
7078                .or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
7079                .ok()
7080                .filter(|v| !v.trim().is_empty()),
7081            zai_model: std::env::var("ZAI_MODEL")
7082                .or_else(|_| std::env::var("Z_AI_MODEL"))
7083                .or_else(|_| std::env::var("ZHIPU_MODEL"))
7084                .or_else(|_| std::env::var("ZHIPUAI_MODEL"))
7085                .or_else(|_| std::env::var("BIGMODEL_MODEL"))
7086                .or_else(|_| std::env::var("GLM_MODEL"))
7087                .ok()
7088                .filter(|v| !v.trim().is_empty()),
7089            stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
7090                .or_else(|_| std::env::var("STEP_BASE_URL"))
7091                .ok()
7092                .filter(|v| !v.trim().is_empty()),
7093            stepfun_model: std::env::var("STEPFUN_MODEL")
7094                .or_else(|_| std::env::var("STEP_MODEL"))
7095                .ok()
7096                .filter(|v| !v.trim().is_empty()),
7097            minimax_base_url: std::env::var("MINIMAX_BASE_URL")
7098                .ok()
7099                .filter(|v| !v.trim().is_empty()),
7100            minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
7101                .ok()
7102                .filter(|v| !v.trim().is_empty()),
7103            minimax_model: std::env::var("MINIMAX_MODEL")
7104                .ok()
7105                .filter(|v| !v.trim().is_empty()),
7106            deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
7107                .ok()
7108                .filter(|v| !v.trim().is_empty()),
7109            deepinfra_model: std::env::var("DEEPINFRA_MODEL")
7110                .ok()
7111                .filter(|v| !v.trim().is_empty()),
7112            sakana_base_url: std::env::var("SAKANA_BASE_URL")
7113                .ok()
7114                .filter(|v| !v.trim().is_empty()),
7115            sakana_model: std::env::var("SAKANA_MODEL")
7116                .ok()
7117                .filter(|v| !v.trim().is_empty()),
7118            longcat_base_url: std::env::var("LONGCAT_BASE_URL")
7119                .ok()
7120                .filter(|v| !v.trim().is_empty()),
7121            longcat_model: std::env::var("LONGCAT_MODEL")
7122                .ok()
7123                .filter(|v| !v.trim().is_empty()),
7124            opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
7125                .ok()
7126                .filter(|v| !v.trim().is_empty()),
7127            opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
7128                .ok()
7129                .filter(|v| !v.trim().is_empty()),
7130            opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
7131                .ok()
7132                .filter(|v| !v.trim().is_empty()),
7133            opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
7134                .ok()
7135                .filter(|v| !v.trim().is_empty()),
7136            meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
7137                .ok()
7138                .filter(|v| !v.trim().is_empty())
7139                .or_else(|| {
7140                    std::env::var("MODEL_API_BASE_URL")
7141                        .ok()
7142                        .filter(|v| !v.trim().is_empty())
7143                }),
7144            meta_model: std::env::var("META_MODEL_API_MODEL")
7145                .ok()
7146                .filter(|v| !v.trim().is_empty())
7147                .or_else(|| {
7148                    std::env::var("MODEL_API_MODEL")
7149                        .ok()
7150                        .filter(|v| !v.trim().is_empty())
7151                }),
7152            xai_base_url: std::env::var("XAI_BASE_URL")
7153                .ok()
7154                .filter(|v| !v.trim().is_empty()),
7155            xai_model: std::env::var("XAI_MODEL")
7156                .ok()
7157                .filter(|v| !v.trim().is_empty()),
7158            antigravity_base_url: std::env::var("ANTIGRAVITY_BASE_URL")
7159                .ok()
7160                .filter(|v| !v.trim().is_empty()),
7161            antigravity_model: std::env::var("ANTIGRAVITY_MODEL")
7162                .ok()
7163                .filter(|v| !v.trim().is_empty()),
7164            google_base_url: std::env::var("GOOGLE_BASE_URL")
7165                .ok()
7166                .filter(|v| !v.trim().is_empty())
7167                .or_else(|| {
7168                    std::env::var("GEMINI_BASE_URL")
7169                        .ok()
7170                        .filter(|v| !v.trim().is_empty())
7171                }),
7172            google_model: std::env::var("GOOGLE_MODEL")
7173                .ok()
7174                .filter(|v| !v.trim().is_empty())
7175                .or_else(|| {
7176                    std::env::var("GEMINI_MODEL")
7177                        .ok()
7178                        .filter(|v| !v.trim().is_empty())
7179                }),
7180            mistral_base_url: std::env::var("MISTRAL_BASE_URL")
7181                .ok()
7182                .filter(|v| !v.trim().is_empty()),
7183            mistral_model: std::env::var("MISTRAL_MODEL")
7184                .ok()
7185                .filter(|v| !v.trim().is_empty()),
7186            telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
7187                .ok()
7188                .filter(|v| !v.trim().is_empty()),
7189            telecomjs_model: std::env::var("TELECOMJS_MODEL")
7190                .ok()
7191                .filter(|v| !v.trim().is_empty()),
7192            edenai_base_url: std::env::var("EDENAI_BASE_URL")
7193                .ok()
7194                .filter(|v| !v.trim().is_empty()),
7195            edenai_model: std::env::var("EDENAI_MODEL")
7196                .ok()
7197                .filter(|v| !v.trim().is_empty()),
7198            modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
7199                .ok()
7200                .filter(|v| !v.trim().is_empty()),
7201            modelstudio_token_plan_model: std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
7202                .ok()
7203                .filter(|v| !v.trim().is_empty()),
7204            modelstudio_coding_plan_base_url: std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
7205                .ok()
7206                .filter(|v| !v.trim().is_empty()),
7207            modelstudio_coding_plan_model: std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
7208                .ok()
7209                .filter(|v| !v.trim().is_empty()),
7210        }
7211    }
7212
7213    fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
7214        if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
7215            let parsed = ProviderKind::parse_config_identity(&value);
7216            return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
7217        }
7218
7219        if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
7220            let parsed = ProviderKind::parse_config_identity(&value);
7221            return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
7222        }
7223
7224        (None, None)
7225    }
7226
7227    /// Read the telemetry kill switch, reporting an unreadable value instead of
7228    /// swallowing it. See [`read_telemetry_env`].
7229    fn load_telemetry() -> (Option<bool>, bool) {
7230        read_telemetry_env()
7231    }
7232
7233    fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
7234        // Defaults belong in the resolver's final fallback so config-file
7235        // values (`providers.<name>.base_url`) still win when env is unset.
7236        match provider {
7237            ProviderKind::Deepseek => self.deepseek_base_url.clone(),
7238            ProviderKind::DeepseekAnthropic => self.deepseek_anthropic_base_url.clone(),
7239            ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
7240            ProviderKind::Openai => self.openai_base_url.clone(),
7241            ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
7242            ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
7243            ProviderKind::Volcengine => self.volcengine_base_url.clone(),
7244            ProviderKind::Openrouter => self.openrouter_base_url.clone(),
7245            ProviderKind::Orcarouter => self.orcarouter_base_url.clone(),
7246            ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
7247            ProviderKind::Novita => self.novita_base_url.clone(),
7248            ProviderKind::Fireworks => self.fireworks_base_url.clone(),
7249            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
7250                self.siliconflow_base_url.clone()
7251            }
7252            ProviderKind::Arcee => self.arcee_base_url.clone(),
7253            ProviderKind::Moonshot => self.moonshot_base_url.clone(),
7254            ProviderKind::Sglang => self.sglang_base_url.clone(),
7255            ProviderKind::Vllm => self.vllm_base_url.clone(),
7256            ProviderKind::Ollama => self.ollama_base_url.clone(),
7257            ProviderKind::OllamaCloud => self.ollama_cloud_base_url.clone(),
7258            ProviderKind::Huggingface => self.huggingface_base_url.clone(),
7259            ProviderKind::Together => self.together_base_url.clone(),
7260            ProviderKind::Qianfan => self.qianfan_base_url.clone(),
7261            ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
7262            ProviderKind::Anthropic => self.anthropic_base_url.clone(),
7263            ProviderKind::Openmodel => self.openmodel_base_url.clone(),
7264            ProviderKind::Zai => self.zai_base_url.clone(),
7265            ProviderKind::Stepfun => self.stepfun_base_url.clone(),
7266            ProviderKind::Minimax => self.minimax_base_url.clone(),
7267            ProviderKind::MinimaxAnthropic => self.minimax_anthropic_base_url.clone(),
7268            ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
7269            ProviderKind::Sakana => self.sakana_base_url.clone(),
7270            ProviderKind::LongCat => self.longcat_base_url.clone(),
7271            ProviderKind::OpencodeGo => self.opencode_go_base_url.clone(),
7272            ProviderKind::OpencodeZen => self.opencode_zen_base_url.clone(),
7273            ProviderKind::Meta => self.meta_base_url.clone(),
7274            ProviderKind::Xai => self.xai_base_url.clone(),
7275            ProviderKind::Mistral => self.mistral_base_url.clone(),
7276            ProviderKind::Google => self.google_base_url.clone(),
7277            ProviderKind::Antigravity => self.antigravity_base_url.clone(),
7278            ProviderKind::Telecomjs => self.telecomjs_base_url.clone(),
7279            ProviderKind::Edenai => self.edenai_base_url.clone(),
7280            ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
7281                self.modelstudio_token_plan_base_url.clone()
7282            }
7283            ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic => {
7284                self.modelstudio_coding_plan_base_url.clone()
7285            }
7286            // No dedicated CODEWHALE_CUSTOM_BASE_URL env override: a custom
7287            // provider's base URL comes from its `[providers.<name>]` table.
7288            ProviderKind::Custom => None,
7289        }
7290    }
7291
7292    fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
7293        let model = match provider {
7294            ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
7295            ProviderKind::Volcengine => self.volcengine_model.clone(),
7296            ProviderKind::Openrouter => self.openrouter_model.clone(),
7297            ProviderKind::Orcarouter => self.orcarouter_model.clone(),
7298            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
7299                self.siliconflow_model.clone()
7300            }
7301            ProviderKind::Arcee => self.arcee_model.clone(),
7302            ProviderKind::Moonshot => self.moonshot_model.clone(),
7303            ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
7304            ProviderKind::Novita => self.novita_model.clone(),
7305            ProviderKind::Fireworks => self.fireworks_model.clone(),
7306            ProviderKind::Huggingface => self.huggingface_model.clone(),
7307            ProviderKind::Together => self.together_model.clone(),
7308            ProviderKind::Qianfan => self.qianfan_model.clone(),
7309            ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
7310            ProviderKind::Anthropic => self.anthropic_model.clone(),
7311            ProviderKind::Openmodel => self.openmodel_model.clone(),
7312            ProviderKind::Zai => self.zai_model.clone(),
7313            ProviderKind::Stepfun => self.stepfun_model.clone(),
7314            ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => self.minimax_model.clone(),
7315            ProviderKind::Deepinfra => self.deepinfra_model.clone(),
7316            ProviderKind::Sakana => self.sakana_model.clone(),
7317            ProviderKind::LongCat => self.longcat_model.clone(),
7318            ProviderKind::OpencodeGo => self.opencode_go_model.clone(),
7319            ProviderKind::OpencodeZen => self.opencode_zen_model.clone(),
7320            ProviderKind::Meta => self.meta_model.clone(),
7321            ProviderKind::Xai => self.xai_model.clone(),
7322            ProviderKind::Mistral => self.mistral_model.clone(),
7323            ProviderKind::Google => self.google_model.clone(),
7324            ProviderKind::Antigravity => self.antigravity_model.clone(),
7325            ProviderKind::Telecomjs => self.telecomjs_model.clone(),
7326            ProviderKind::Edenai => self.edenai_model.clone(),
7327            ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
7328                self.modelstudio_token_plan_model.clone()
7329            }
7330            ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic => {
7331                self.modelstudio_coding_plan_model.clone()
7332            }
7333            ProviderKind::OllamaCloud => self.ollama_cloud_model.clone(),
7334            _ => None,
7335        }?;
7336
7337        if provider_preserves_custom_base_url_model(provider, base_url) {
7338            Some(model.trim().to_string())
7339        } else {
7340            Some(normalize_model_for_provider(provider, &model))
7341        }
7342    }
7343}
7344
7345#[cfg(test)]
7346mod tests;