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