返回 CodeWhale
tests.rs
根目录 / crates / config / src / tests.rs
1 use super::*;
2 use std::env;
3 use std::ffi::OsString;
4 use std::sync::Arc;
5 use std::sync::{Mutex, OnceLock};
6
7 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
8 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
9 LOCK.get_or_init(|| Mutex::new(()))
10 .lock()
11 .unwrap_or_else(std::sync::PoisonError::into_inner)
12 }
13
14 #[test]
15 fn network_policy_toml_deserializes_proxy_hosts() {
16 let policy: NetworkPolicyToml = toml::from_str(
17 r#"
18 default = "allow"
19 proxy = ["github.com", ".githubusercontent.com"]
20 proxy_fake_ip_cidrs = ["198.18.0.0/15"]
21 "#,
22 )
23 .expect("network policy toml");
24
25 assert_eq!(policy.default, "allow");
26 assert_eq!(policy.proxy, ["github.com", ".githubusercontent.com"]);
27 assert_eq!(policy.proxy_fake_ip_cidrs, ["198.18.0.0/15"]);
28 assert!(policy.audit);
29 }
30
31 #[test]
32 fn retired_verifier_verdict_policy_is_accepted_and_dropped_on_load() {
33 // `verdict_policy` was a single-value setting retired with the
34 // verifier-preview schema cleanup. Old configs that still carry it must
35 // keep loading; the key is read-only dropped (never written back).
36 let config: ConfigToml = toml::from_str(
37 r#"
38 [verifier]
39 enabled = true
40 verdict_policy = "hunt"
41
42 [verifier.unknown_extra]
43 key = "value"
44 "#,
45 )
46 .expect("a legacy verifier table must still parse");
47
48 let verifier = config.verifier.expect("verifier table");
49 assert!(verifier.enabled);
50 }
51
52 #[test]
53 fn lifecycle_outbox_toml_is_off_by_default_and_parses_when_configured() {
54 // Unset = feature OFF: the table is absent and the field is None.
55 let absent: ConfigToml = toml::from_str("model = \"demo\"\n").expect("minimal config");
56 assert!(
57 absent.lifecycle_outbox.is_none(),
58 "unset [lifecycle_outbox] must leave the feature off"
59 );
60
61 // An empty table is also off: no path means no outbox file.
62 let empty: ConfigToml =
63 toml::from_str("[lifecycle_outbox]\n").expect("empty lifecycle_outbox table");
64 let outbox = empty.lifecycle_outbox.expect("table should parse");
65 assert!(outbox.path.is_none());
66 assert!(outbox.webhook_url.is_none());
67 assert!(outbox.webhook_token.is_none());
68
69 // Full configuration: path plus optional webhook url and token.
70 let full: ConfigToml = toml::from_str(
71 r#"
72 [lifecycle_outbox]
73 path = "~/.codewhale/notifications/outbox.jsonl"
74 webhook_url = "https://example.com/hooks/codewhale"
75 webhook_token = "secret-token"
76 "#,
77 )
78 .expect("full lifecycle_outbox table");
79 let outbox = full.lifecycle_outbox.expect("table should parse");
80 assert_eq!(
81 outbox.path,
82 Some(PathBuf::from("~/.codewhale/notifications/outbox.jsonl"))
83 );
84 assert_eq!(
85 outbox.webhook_url.as_deref(),
86 Some("https://example.com/hooks/codewhale")
87 );
88 assert_eq!(outbox.webhook_token.as_deref(), Some("secret-token"));
89 }
90
91 #[test]
92 fn lifecycle_outbox_toml_webhook_is_optional() {
93 // `path` alone enables the file outbox without any webhook.
94 let file_only: ConfigToml = toml::from_str(
95 r#"
96 [lifecycle_outbox]
97 path = "/tmp/outbox.jsonl"
98 "#,
99 )
100 .expect("file-only lifecycle_outbox table");
101 let outbox = file_only.lifecycle_outbox.expect("table should parse");
102 assert_eq!(outbox.path, Some(PathBuf::from("/tmp/outbox.jsonl")));
103 assert!(outbox.webhook_url.is_none());
104
105 // A webhook url without a path does not enable a file outbox; the
106 // consumer decides whether webhook-only delivery is meaningful, but the
107 // parse must stay lossless either way.
108 let webhook_only: ConfigToml = toml::from_str(
109 r#"
110 [lifecycle_outbox]
111 webhook_url = "https://example.com/hooks/codewhale"
112 "#,
113 )
114 .expect("webhook-only lifecycle_outbox table");
115 let outbox = webhook_only.lifecycle_outbox.expect("table should parse");
116 assert!(outbox.path.is_none());
117 assert_eq!(
118 outbox.webhook_url.as_deref(),
119 Some("https://example.com/hooks/codewhale")
120 );
121 assert!(outbox.webhook_token.is_none());
122 }
123
124 #[test]
125 fn control_socket_toml_is_off_by_default_and_parses_when_enabled() {
126 // Unset = feature OFF: the table is absent and the field is None.
127 let absent: ConfigToml = toml::from_str("model = \"demo\"\n").expect("minimal config");
128 assert!(
129 absent.control_socket.is_none(),
130 "unset [control_socket] must leave the feature off"
131 );
132
133 // An empty table is also off: enabled defaults to false.
134 let empty: ConfigToml =
135 toml::from_str("[control_socket]\n").expect("empty control_socket table");
136 let socket = empty.control_socket.expect("table should parse");
137 assert!(!socket.enabled, "empty table must leave the socket off");
138
139 // Explicit enable.
140 let enabled: ConfigToml = toml::from_str(
141 r#"
142 [control_socket]
143 enabled = true
144 "#,
145 )
146 .expect("enabled control_socket table");
147 assert!(
148 enabled.control_socket.expect("table should parse").enabled,
149 "enabled = true must turn the socket on"
150 );
151
152 // Explicit disable stays off.
153 let disabled: ConfigToml = toml::from_str(
154 r#"
155 [control_socket]
156 enabled = false
157 "#,
158 )
159 .expect("disabled control_socket table");
160 assert!(
161 !disabled.control_socket.expect("table should parse").enabled,
162 "enabled = false must keep the socket off"
163 );
164 }
165
166 #[test]
167 fn permissions_toml_deserializes_typed_ask_rules() {
168 let permissions: PermissionsToml = toml::from_str(
169 r#"
170 [[rules]]
171 tool = "exec_shell"
172 command = "cargo test"
173
174 [[rules]]
175 tool = "read_file"
176 path = "secrets/api_key.txt"
177 "#,
178 )
179 .expect("permissions toml");
180
181 assert_eq!(
182 permissions.rules,
183 vec![
184 ToolAskRule::exec_shell("cargo test"),
185 ToolAskRule::file_path("read_file", "secrets/api_key.txt"),
186 ]
187 );
188 }
189
190 #[test]
191 fn permissions_toml_rejects_unknown_decision_field() {
192 // `decision` is NOT a valid field — `deny_unknown_fields` still active.
193 let err = toml::from_str::<PermissionsToml>(
194 r#"
195 [[rules]]
196 tool = "exec_shell"
197 decision = "allow"
198 command = "cargo test"
199 "#,
200 )
201 .expect_err("permissions.toml should reject unknown 'decision' field");
202
203 assert!(err.message().contains("unknown field"));
204 }
205
206 #[test]
207 fn permissions_toml_deserializes_action_deny_and_allow() {
208 let permissions: PermissionsToml = toml::from_str(
209 r#"
210 [[rules]]
211 tool = "exec_shell"
212 command = "sed"
213 action = "deny"
214
215 [[rules]]
216 tool = "exec_shell"
217 command = "git status"
218 action = "allow"
219
220 [[rules]]
221 tool = "exec_shell"
222 command = "cargo test"
223 "#,
224 )
225 .expect("permissions toml with actions");
226
227 assert_eq!(permissions.rules.len(), 3);
228 assert_eq!(
229 permissions.rules[0].action,
230 codewhale_execpolicy::PermissionAction::Deny
231 );
232 assert_eq!(
233 permissions.rules[1].action,
234 codewhale_execpolicy::PermissionAction::Allow
235 );
236 assert_eq!(
237 permissions.rules[2].action,
238 codewhale_execpolicy::PermissionAction::Ask
239 ); // default
240 }
241
242 #[test]
243 fn permissions_ruleset_populates_denied_and_trusted_prefixes() {
244 let permissions: PermissionsToml = toml::from_str(
245 r#"
246 [[rules]]
247 tool = "exec_shell"
248 command = "sed"
249 action = "deny"
250
251 [[rules]]
252 tool = "exec_shell"
253 command = "awk"
254 action = "deny"
255
256 [[rules]]
257 tool = "exec_shell"
258 command = "git status"
259 action = "allow"
260
261 [[rules]]
262 tool = "exec_shell"
263 command = "cargo test"
264 action = "ask"
265 "#,
266 )
267 .unwrap();
268
269 let ruleset = permissions.ruleset();
270
271 // All four rules kept as ask_rules for path-based / tool-only matching
272 assert_eq!(ruleset.ask_rules.len(), 4);
273 // deny rules promoted to denied_prefixes
274 assert!(ruleset.denied_prefixes.contains(&"sed".to_string()));
275 assert!(ruleset.denied_prefixes.contains(&"awk".to_string()));
276 // allow rule promoted to trusted_prefixes
277 assert!(ruleset.trusted_prefixes.contains(&"git status".to_string()));
278 // ask rule NOT in trusted/denied prefixes
279 assert!(!ruleset.trusted_prefixes.contains(&"cargo test".to_string()));
280 assert!(!ruleset.denied_prefixes.contains(&"cargo test".to_string()));
281 }
282
283 #[test]
284 fn exact_workspace_command_allow_is_not_promoted_to_global_prefix() {
285 let rule =
286 ToolAskRule::exec_shell("cargo test").into_exact_workspace_allow("/workspace/project");
287 let permissions = PermissionsToml {
288 rules: vec![rule.clone()],
289 };
290
291 let ruleset = permissions.ruleset();
292
293 assert!(
294 ruleset.trusted_prefixes.is_empty(),
295 "an exact repo grant must not become a global trusted prefix"
296 );
297 assert_eq!(ruleset.ask_rules, vec![rule]);
298 }
299
300 #[test]
301 fn permissions_ruleset_deny_without_command_stays_in_ask_rules() {
302 // Tool-only deny (no command) can't be promoted to denied_prefixes.
303 let permissions: PermissionsToml = toml::from_str(
304 r#"
305 [[rules]]
306 tool = "exec_shell"
307 action = "deny"
308 "#,
309 )
310 .unwrap();
311
312 let ruleset = permissions.ruleset();
313 assert_eq!(ruleset.ask_rules.len(), 1);
314 assert_eq!(
315 ruleset.ask_rules[0].action,
316 codewhale_execpolicy::PermissionAction::Deny
317 );
318 // No command → nothing to promote to denied_prefixes
319 assert!(ruleset.denied_prefixes.is_empty());
320 }
321
322 #[test]
323 fn permissions_ruleset_empty_rules_produces_empty_ruleset() {
324 let permissions = PermissionsToml::default();
325 let ruleset = permissions.ruleset();
326 assert!(ruleset.trusted_prefixes.is_empty());
327 assert!(ruleset.denied_prefixes.is_empty());
328 assert!(ruleset.ask_rules.is_empty());
329 }
330
331 #[test]
332 fn permissions_ruleset_mixed_actions_all_coexist() {
333 let permissions: PermissionsToml = toml::from_str(
334 r#"
335 [[rules]]
336 tool = "exec_shell"
337 command = "rm -rf"
338 action = "deny"
339
340 [[rules]]
341 tool = "exec_shell"
342 command = "git status"
343 action = "allow"
344
345 [[rules]]
346 tool = "exec_shell"
347 command = "npm test"
348 action = "ask"
349
350 [[rules]]
351 tool = "read_file"
352 path = "Cargo.toml"
353 action = "allow"
354
355 [[rules]]
356 tool = "write_file"
357 path = "src/secrets.rs"
358 action = "deny"
359 "#,
360 )
361 .unwrap();
362
363 let ruleset = permissions.ruleset();
364
365 // All 5 rules in ask_rules
366 assert_eq!(ruleset.ask_rules.len(), 5);
367
368 // Command-based deny → denied_prefixes
369 assert!(ruleset.denied_prefixes.contains(&"rm -rf".to_string()));
370 assert_eq!(ruleset.denied_prefixes.len(), 1); // only rm -rf has a command
371
372 // Command-based allow → trusted_prefixes
373 assert!(ruleset.trusted_prefixes.contains(&"git status".to_string()));
374 assert_eq!(ruleset.trusted_prefixes.len(), 1); // only git status has a command
375
376 // Path-based rules stay in ask_rules but not in prefixes
377 let path_deny = ruleset
378 .ask_rules
379 .iter()
380 .find(|r| r.path.as_deref() == Some("src/secrets.rs"))
381 .unwrap();
382 assert_eq!(
383 path_deny.action,
384 codewhale_execpolicy::PermissionAction::Deny
385 );
386
387 let path_allow = ruleset
388 .ask_rules
389 .iter()
390 .find(|r| r.path.as_deref() == Some("Cargo.toml"))
391 .unwrap();
392 assert_eq!(
393 path_allow.action,
394 codewhale_execpolicy::PermissionAction::Allow
395 );
396 }
397
398 #[test]
399 fn provider_command_auth_source_deserializes() {
400 let config: ConfigToml = toml::from_str(
401 r#"
402 [providers.deepseek.auth]
403 source = "command"
404 command = ["keepassxc-cli", "show", "CodeWhale/DeepSeek", "--attribute", "password"]
405 timeout_ms = 2000
406 "#,
407 )
408 .expect("config toml");
409
410 let auth = config
411 .providers
412 .deepseek
413 .auth
414 .expect("provider auth source");
415 assert_eq!(auth.source, AuthSourceKind::Command);
416 assert_eq!(auth.source_class(), "command");
417 assert_eq!(auth.command[0], "keepassxc-cli");
418 assert_eq!(auth.timeout_ms, Some(2000));
419 auth.validate().expect("valid command auth source");
420 }
421
422 #[test]
423 fn provider_secret_auth_source_deserializes() {
424 let config: ConfigToml = toml::from_str(
425 r#"
426 [providers.openai.auth]
427 source = "secret"
428 secret_id = "codewhale/openai"
429 "#,
430 )
431 .expect("config toml");
432
433 let auth = config.providers.openai.auth.expect("provider auth source");
434 assert_eq!(auth.source, AuthSourceKind::Secret);
435 assert_eq!(auth.source_class(), "secret");
436 assert_eq!(auth.secret_id.as_deref(), Some("codewhale/openai"));
437 auth.validate().expect("valid secret auth source");
438 }
439
440 #[test]
441 fn provider_auth_source_rejects_empty_command() {
442 let config: ConfigToml = toml::from_str(
443 r#"
444 [providers.deepseek.auth]
445 source = "command"
446 command = []
447 "#,
448 )
449 .expect("config toml");
450
451 let auth = config
452 .providers
453 .deepseek
454 .auth
455 .expect("provider auth source");
456 let err = auth.validate().expect_err("empty command must be invalid");
457 assert!(err.to_string().contains("command must include"));
458 }
459
460 #[test]
461 fn hotbar_hidden_when_config_is_absent() {
462 // #3807: an absent `hotbar` key resolves to no bindings, so the Hotbar is
463 // hidden until the user opts in. The default slots are still available
464 // explicitly via `default_hotbar_bindings_toml()` (what `/hotbar on` writes).
465 let config = ConfigToml::default();
466
467 let resolved = config.resolve_hotbar_bindings(&DEFAULT_HOTBAR_ACTIONS);
468
469 assert_eq!(resolved.warnings, Vec::new());
470 assert!(
471 resolved.bindings.is_empty(),
472 "fresh config must resolve to no hotbar bindings: {:?}",
473 resolved.bindings
474 );
475
476 // The explicit default set still expands to the eight recommended slots.
477 let explicit = ConfigToml {
478 hotbar: Some(default_hotbar_bindings_toml()),
479 ..ConfigToml::default()
480 };
481 assert_eq!(
482 explicit
483 .resolve_hotbar_bindings(&DEFAULT_HOTBAR_ACTIONS)
484 .bindings,
485 default_hotbar_bindings(),
486 "an explicit default-bindings config still shows all eight slots"
487 );
488 }
489
490 #[test]
491 fn hotbar_empty_array_disables_default_slots() {
492 let config: ConfigToml = toml::from_str("hotbar = []\n").expect("parse empty hotbar array");
493
494 let resolved = config.resolve_hotbar_bindings(&DEFAULT_HOTBAR_ACTIONS);
495
496 assert_eq!(resolved.warnings, Vec::new());
497 assert_eq!(resolved.bindings, Vec::new());
498
499 let serialized = toml::to_string_pretty(&config).expect("serialize config");
500 let round_tripped: ConfigToml =
501 toml::from_str(&serialized).expect("deserialize serialized config");
502 assert_eq!(round_tripped.hotbar, Some(Vec::new()));
503 }
504
505 #[test]
506 fn hotbar_tables_parse_and_round_trip() {
507 let config: ConfigToml = toml::from_str(
508 r#"
509 [[hotbar]]
510 slot = 1
511 label = "Plan"
512 action = "mode.plan"
513
514 [[hotbar]]
515 slot = 2
516 action = "session.compact"
517 "#,
518 )
519 .expect("parse hotbar tables");
520
521 let resolved = config.resolve_hotbar_bindings(&["mode.plan", "session.compact"]);
522
523 assert_eq!(
524 resolved.bindings,
525 vec![
526 HotbarBinding {
527 slot: 1,
528 action: "mode.plan".to_string(),
529 label: Some("Plan".to_string()),
530 },
531 HotbarBinding {
532 slot: 2,
533 action: "session.compact".to_string(),
534 label: None,
535 },
536 ]
537 );
538 assert_eq!(resolved.warnings, Vec::new());
539
540 let serialized = toml::to_string_pretty(&config).expect("serialize config");
541 let round_tripped: ConfigToml =
542 toml::from_str(&serialized).expect("deserialize serialized config");
543 assert_eq!(round_tripped.hotbar, config.hotbar);
544 }
545
546 #[test]
547 fn hotbar_validation_warns_without_dropping_unknown_actions() {
548 let config: ConfigToml = toml::from_str(
549 r#"
550 [[hotbar]]
551 slot = 0
552 action = "mode.plan"
553
554 [[hotbar]]
555 slot = 2
556 action = "mode.plan"
557
558 [[hotbar]]
559 slot = 2
560 action = "custom.action"
561
562 [[hotbar]]
563 slot = 9
564 action = "mode.agent"
565 "#,
566 )
567 .expect("parse hotbar tables");
568
569 let resolved = config.resolve_hotbar_bindings(&["mode.plan", "mode.agent"]);
570
571 assert_eq!(
572 resolved.bindings,
573 vec![HotbarBinding {
574 slot: 2,
575 action: "custom.action".to_string(),
576 label: None,
577 }]
578 );
579 assert_eq!(
580 resolved.warnings,
581 vec![
582 HotbarConfigWarning::SlotOutOfRange {
583 slot: 0,
584 action: "mode.plan".to_string(),
585 },
586 HotbarConfigWarning::UnknownAction {
587 slot: 2,
588 action: "custom.action".to_string(),
589 },
590 HotbarConfigWarning::DuplicateSlot {
591 slot: 2,
592 previous_action: "mode.plan".to_string(),
593 replacement_action: "custom.action".to_string(),
594 },
595 HotbarConfigWarning::SlotOutOfRange {
596 slot: 9,
597 action: "mode.agent".to_string(),
598 },
599 ]
600 );
601 assert!(resolved.warnings[1].to_string().contains("keeping binding"));
602 }
603
604 #[test]
605 fn config_store_loads_sibling_permissions_toml() {
606 use std::time::{SystemTime, UNIX_EPOCH};
607
608 let unique = SystemTime::now()
609 .duration_since(UNIX_EPOCH)
610 .expect("clock")
611 .as_nanos();
612 let dir = std::env::temp_dir().join(format!(
613 "codewhale-permissions-schema-{}-{unique}",
614 std::process::id()
615 ));
616 fs::create_dir_all(&dir).expect("mkdir");
617 let config_path = dir.join(CONFIG_FILE_NAME);
618 let permissions_path = dir.join(PERMISSIONS_FILE_NAME);
619 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
620 fs::write(
621 &permissions_path,
622 r#"
623 [[rules]]
624 tool = "exec_shell"
625 command = "cargo test"
626
627 [[rules]]
628 tool = "read_file"
629 path = "secrets/api_key.txt"
630 "#,
631 )
632 .expect("write permissions");
633
634 let store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
635
636 assert_eq!(store.config.model.as_deref(), Some("deepseek-v4-flash"));
637 assert_eq!(
638 store.permissions().rules.as_slice(),
639 &[
640 ToolAskRule::exec_shell("cargo test"),
641 ToolAskRule::file_path("read_file", "secrets/api_key.txt"),
642 ]
643 );
644 assert_eq!(
645 store
646 .permissions_path()
647 .canonicalize()
648 .expect("store perms"),
649 permissions_path.canonicalize().expect("expected perms")
650 );
651
652 let _ = fs::remove_dir_all(dir);
653 }
654
655 #[test]
656 fn config_store_loads_permissions_even_when_config_is_absent() {
657 use std::time::{SystemTime, UNIX_EPOCH};
658
659 let unique = SystemTime::now()
660 .duration_since(UNIX_EPOCH)
661 .expect("clock")
662 .as_nanos();
663 let dir = std::env::temp_dir().join(format!(
664 "codewhale-permissions-only-{}-{unique}",
665 std::process::id()
666 ));
667 fs::create_dir_all(&dir).expect("mkdir");
668 let config_path = dir.join(CONFIG_FILE_NAME);
669 fs::write(
670 dir.join(PERMISSIONS_FILE_NAME),
671 r#"
672 [[rules]]
673 tool = "exec_shell"
674 command = "cargo check"
675 "#,
676 )
677 .expect("write permissions");
678
679 let store = ConfigStore::load(Some(config_path)).expect("load config store");
680
681 assert!(store.config.model.is_none());
682 assert_eq!(
683 store.permissions().rules.as_slice(),
684 &[ToolAskRule::exec_shell("cargo check")]
685 );
686
687 let _ = fs::remove_dir_all(dir);
688 }
689
690 #[test]
691 fn config_store_exec_policy_engine_uses_sibling_permissions() {
692 use std::time::{SystemTime, UNIX_EPOCH};
693
694 let unique = SystemTime::now()
695 .duration_since(UNIX_EPOCH)
696 .expect("clock")
697 .as_nanos();
698 let dir = std::env::temp_dir().join(format!(
699 "codewhale-permissions-engine-{}-{unique}",
700 std::process::id()
701 ));
702 fs::create_dir_all(&dir).expect("mkdir");
703 let config_path = dir.join(CONFIG_FILE_NAME);
704 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
705 fs::write(
706 dir.join(PERMISSIONS_FILE_NAME),
707 r#"
708 [[rules]]
709 tool = "exec_shell"
710 command = "cargo test"
711 "#,
712 )
713 .expect("write permissions");
714
715 let store = ConfigStore::load(Some(config_path)).expect("load config store");
716 let decision = store
717 .exec_policy_engine()
718 .check(codewhale_execpolicy::ExecPolicyContext {
719 command: "cargo test --workspace",
720 cwd: "/workspace",
721 tool: Some("exec_shell"),
722 path: None,
723 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
724 sandbox_mode: Some("workspace-write"),
725 })
726 .expect("policy check");
727
728 assert!(decision.allow);
729 assert!(decision.requires_approval);
730 assert_eq!(
731 decision.matched_rule.as_deref(),
732 Some("tool=exec_shell command=cargo test")
733 );
734
735 let _ = fs::remove_dir_all(dir);
736 }
737
738 #[test]
739 fn config_store_appends_ask_rules_without_losing_comments_or_duplicates() {
740 let dir = tempfile::tempdir().expect("tempdir");
741 let config_path = dir.path().join(CONFIG_FILE_NAME);
742 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
743 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
744 fs::write(
745 &permissions_path,
746 r#"# keep this permission note
747 [[rules]]
748 tool = "exec_shell"
749 command = "cargo check"
750 "#,
751 )
752 .expect("write permissions");
753
754 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
755 let existing = ToolAskRule::exec_shell("cargo check");
756 let added_rule = ToolAskRule::file_path("read_file", "docs/README.md");
757 let added = store
758 .append_ask_rules(&[existing, added_rule.clone(), added_rule.clone()])
759 .expect("append ask rules");
760
761 assert_eq!(added, 1);
762 assert_eq!(
763 store.permissions().rules,
764 vec![ToolAskRule::exec_shell("cargo check"), added_rule.clone(),]
765 );
766 let body = fs::read_to_string(&permissions_path).expect("read permissions");
767 assert!(body.contains("# keep this permission note"));
768 assert_eq!(body.matches("docs/README.md").count(), 1);
769 assert!(!body.contains("decision"));
770
771 let before_duplicate_append = body;
772 assert_eq!(
773 store
774 .append_ask_rules(&[added_rule])
775 .expect("dedupe ask rule"),
776 0
777 );
778 assert_eq!(
779 fs::read_to_string(&permissions_path).expect("read unchanged permissions"),
780 before_duplicate_append
781 );
782
783 let reloaded =
784 ConfigStore::load(Some(dir.path().join(CONFIG_FILE_NAME))).expect("reload config store");
785 assert_eq!(reloaded.permissions(), store.permissions());
786 }
787
788 #[test]
789 fn config_store_appends_ask_rule_to_inline_rules_array() {
790 let dir = tempfile::tempdir().expect("tempdir");
791 let config_path = dir.path().join(CONFIG_FILE_NAME);
792 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
793 fs::write(
794 &permissions_path,
795 "# inline rules stay valid\nrules = [{ tool = \"exec_shell\", command = \"cargo check\" }]\n",
796 )
797 .expect("write permissions");
798
799 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
800 assert_eq!(
801 store
802 .append_ask_rules(&[ToolAskRule::file_path("read_file", "README.md")])
803 .expect("append inline ask rule"),
804 1
805 );
806
807 let body = fs::read_to_string(&permissions_path).expect("read permissions");
808 assert!(body.contains("# inline rules stay valid"));
809 let parsed: PermissionsToml = toml::from_str(&body).expect("parse persisted permissions");
810 assert_eq!(
811 parsed.rules,
812 vec![
813 ToolAskRule::exec_shell("cargo check"),
814 ToolAskRule::file_path("read_file", "README.md"),
815 ]
816 );
817 }
818
819 #[test]
820 fn config_store_appends_exact_workspace_allow_rules() {
821 let dir = tempfile::tempdir().expect("tempdir");
822 let config_path = dir.path().join(CONFIG_FILE_NAME);
823 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
824 fs::write(&permissions_path, "# remembered grants stay user-owned\n")
825 .expect("write permissions");
826 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
827 let rule = ToolAskRule::exec_shell("cargo test")
828 .into_exact_workspace_allow(dir.path().to_string_lossy());
829
830 assert_eq!(
831 store
832 .append_allow_rules(&[rule.clone(), rule.clone()])
833 .expect("append allow rule"),
834 1
835 );
836
837 let body = fs::read_to_string(&permissions_path).expect("read permissions");
838 assert!(body.contains("# remembered grants stay user-owned"));
839 assert!(body.contains("action = \"allow\""));
840 assert!(body.contains("command_exact = true"));
841 // Windows paths may make toml_edit choose a different valid quoting style
842 // than Rust's Debug output. Check the rendered field shape here and its
843 // exact value through the typed parse below.
844 assert!(body.lines().any(|line| line.starts_with("workspace = ")));
845 let parsed: PermissionsToml = toml::from_str(&body).expect("parse permissions");
846 assert_eq!(parsed.rules, vec![rule.clone()]);
847
848 let exact = store
849 .exec_policy_engine()
850 .check(codewhale_execpolicy::ExecPolicyContext {
851 command: "cargo test",
852 cwd: dir.path().to_string_lossy().as_ref(),
853 tool: Some("exec_shell"),
854 path: None,
855 ask_for_approval: codewhale_execpolicy::AskForApproval::OnRequest,
856 sandbox_mode: Some("workspace-write"),
857 })
858 .expect("check exact grant");
859 assert_eq!(
860 exact.matched_action,
861 Some(codewhale_execpolicy::PermissionAction::Allow)
862 );
863 assert!(!exact.requires_approval);
864
865 let extra_args = store
866 .exec_policy_engine()
867 .check(codewhale_execpolicy::ExecPolicyContext {
868 command: "cargo test --workspace",
869 cwd: dir.path().to_string_lossy().as_ref(),
870 tool: Some("exec_shell"),
871 path: None,
872 ask_for_approval: codewhale_execpolicy::AskForApproval::OnRequest,
873 sandbox_mode: Some("workspace-write"),
874 })
875 .expect("check extra args");
876 assert!(extra_args.requires_approval);
877
878 let reloaded = ConfigStore::load(Some(config_path)).expect("reload config store");
879 assert_eq!(reloaded.permissions().rules, vec![rule]);
880 }
881
882 #[test]
883 fn config_store_rejects_broad_or_unscoped_allow_rules() {
884 let dir = tempfile::tempdir().expect("tempdir");
885 let config_path = dir.path().join(CONFIG_FILE_NAME);
886 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
887
888 let mut unscoped = ToolAskRule::exec_shell("cargo test");
889 unscoped.action = codewhale_execpolicy::PermissionAction::Allow;
890 assert!(
891 store
892 .append_allow_rules(&[unscoped])
893 .expect_err("unscoped allow must fail")
894 .to_string()
895 .contains("scoped to a workspace")
896 );
897
898 let mut prefix = ToolAskRule::exec_shell("cargo test");
899 prefix.action = codewhale_execpolicy::PermissionAction::Allow;
900 prefix.workspace = Some(dir.path().to_string_lossy().into_owned());
901 assert!(
902 store
903 .append_allow_rules(&[prefix])
904 .expect_err("prefix allow must fail")
905 .to_string()
906 .contains("exact matching")
907 );
908
909 assert!(
910 store
911 .append_allow_rules(&[ToolAskRule::new("exec_shell")
912 .into_exact_workspace_allow(dir.path().to_string_lossy())])
913 .expect_err("tool-wide allow must fail")
914 .to_string()
915 .contains("exact command or path")
916 );
917 }
918
919 #[test]
920 fn config_store_does_not_overwrite_invalid_permissions_file() {
921 let dir = tempfile::tempdir().expect("tempdir");
922 let config_path = dir.path().join(CONFIG_FILE_NAME);
923 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
924 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
925 let invalid = "rules = \"not-an-array\"\n";
926 fs::write(&permissions_path, invalid).expect("write invalid permissions");
927
928 let error = store
929 .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")])
930 .expect_err("invalid permissions should fail");
931
932 assert!(error.to_string().contains("failed to parse permissions"));
933 assert_eq!(
934 fs::read_to_string(&permissions_path).expect("read invalid permissions"),
935 invalid
936 );
937 assert!(store.permissions().is_empty());
938 }
939
940 #[test]
941 fn duplicate_append_refreshes_permissions_changed_on_disk() {
942 let dir = tempfile::tempdir().expect("tempdir");
943 let config_path = dir.path().join(CONFIG_FILE_NAME);
944 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
945 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
946 fs::write(
947 permissions_path,
948 "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo check\"\n",
949 )
950 .expect("write external permissions update");
951
952 assert_eq!(
953 store
954 .append_ask_rules(&[ToolAskRule::exec_shell("cargo check")])
955 .expect("dedupe external ask rule"),
956 0
957 );
958 assert_eq!(
959 store.permissions().rules,
960 vec![ToolAskRule::exec_shell("cargo check")]
961 );
962 }
963
964 #[cfg(unix)]
965 #[test]
966 fn config_store_secures_persisted_permissions_file() {
967 let dir = tempfile::tempdir().expect("tempdir");
968 let config_path = dir.path().join(CONFIG_FILE_NAME);
969 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
970 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
971
972 store
973 .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")])
974 .expect("append ask rule");
975
976 let mode = fs::metadata(permissions_path)
977 .expect("permissions metadata")
978 .permissions()
979 .mode()
980 & 0o777;
981 assert_eq!(mode, 0o600);
982 }
983
984 #[test]
985 fn permission_snapshot_removes_array_table_rule_without_reformatting_neighbors() {
986 let dir = tempfile::tempdir().expect("tempdir");
987 let config_path = dir.path().join(CONFIG_FILE_NAME);
988 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
989 fs::write(
990 &permissions_path,
991 r#"# operator-owned header
992 [[rules]]
993 tool = "exec_shell"
994 command = "cargo check"
995
996 # remove only this record
997 [[rules]]
998 tool = "exec_shell"
999 command = "cargo test"
1000 action = "deny"
1001
1002 # keep this record and comment
1003 [[rules]]
1004 tool = "edit_file"
1005 path = "src/lib.rs"
1006 action = "allow"
1007 "#,
1008 )
1009 .expect("write permissions");
1010
1011 let snapshot =
1012 load_permissions_snapshot(Some(config_path.clone())).expect("load permission snapshot");
1013 assert!(snapshot.file_exists());
1014 assert_eq!(
1015 snapshot.path().canonicalize().expect("snapshot path"),
1016 permissions_path.canonicalize().expect("permissions path")
1017 );
1018 assert_eq!(snapshot.rules().len(), 3);
1019 let token = snapshot
1020 .removal_token(1)
1021 .expect("middle rule token")
1022 .to_string();
1023
1024 let removed =
1025 remove_permission_rule(Some(config_path.clone()), 1, &token).expect("remove middle rule");
1026 assert_eq!(removed.command.as_deref(), Some("cargo test"));
1027 assert_eq!(removed.action, PermissionAction::Deny);
1028
1029 let body = fs::read_to_string(&permissions_path).expect("read permissions");
1030 assert!(body.contains("# operator-owned header"));
1031 assert!(body.contains("# keep this record and comment"));
1032 assert!(!body.contains("cargo test"));
1033 assert!(body.contains("command = \"cargo check\""));
1034 assert!(body.contains("path = \"src/lib.rs\""));
1035 let parsed: PermissionsToml = toml::from_str(&body).expect("parse edited permissions");
1036 assert_eq!(
1037 parsed.rules,
1038 vec![ToolAskRule::exec_shell("cargo check"), {
1039 let mut rule = ToolAskRule::file_path("edit_file", "src/lib.rs");
1040 rule.action = PermissionAction::Allow;
1041 rule
1042 },]
1043 );
1044
1045 let reloaded =
1046 load_permissions_snapshot(Some(config_path)).expect("reload permission snapshot");
1047 assert_eq!(reloaded.rules(), parsed.rules);
1048 }
1049
1050 #[test]
1051 fn permission_snapshot_removes_inline_array_rule() {
1052 let dir = tempfile::tempdir().expect("tempdir");
1053 let config_path = dir.path().join(CONFIG_FILE_NAME);
1054 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
1055 fs::write(
1056 &permissions_path,
1057 "# inline style stays inline\nrules = [\n { tool = \"exec_shell\", command = \"cargo check\" },\n { tool = \"write_file\", path = \"README.md\", action = \"allow\" },\n]\n",
1058 )
1059 .expect("write permissions");
1060
1061 let snapshot =
1062 load_permissions_snapshot(Some(config_path.clone())).expect("load permission snapshot");
1063 let token = snapshot
1064 .removal_token(0)
1065 .expect("first rule token")
1066 .to_string();
1067 let removed = remove_permission_rule(Some(config_path), 0, &token).expect("remove inline rule");
1068
1069 assert_eq!(removed.command.as_deref(), Some("cargo check"));
1070 let body = fs::read_to_string(&permissions_path).expect("read permissions");
1071 assert!(body.contains("# inline style stays inline"));
1072 assert!(body.contains("rules = ["));
1073 assert!(!body.contains("cargo check"));
1074 let parsed: PermissionsToml = toml::from_str(&body).expect("parse inline permissions");
1075 assert_eq!(parsed.rules.len(), 1);
1076 assert_eq!(parsed.rules[0].path.as_deref(), Some("README.md"));
1077 }
1078
1079 #[test]
1080 fn permission_removal_preserves_file_header_when_first_array_table_is_removed() {
1081 let dir = tempfile::tempdir().expect("tempdir");
1082 let config_path = dir.path().join(CONFIG_FILE_NAME);
1083 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
1084 fs::write(
1085 &permissions_path,
1086 concat!(
1087 "# keep this file-level explanation\n",
1088 "[[rules]]\n",
1089 "tool = \"exec_shell\"\n",
1090 "command = \"cargo check\"\n\n",
1091 "[[rules]]\n",
1092 "tool = \"exec_shell\"\n",
1093 "command = \"cargo test\"\n",
1094 ),
1095 )
1096 .expect("write permissions");
1097 let snapshot =
1098 load_permissions_snapshot(Some(config_path.clone())).expect("load permission snapshot");
1099 let token = snapshot
1100 .removal_token(0)
1101 .expect("first rule token")
1102 .to_string();
1103
1104 remove_permission_rule(Some(config_path), 0, &token).expect("remove first rule");
1105
1106 let body = fs::read_to_string(&permissions_path).expect("read permissions");
1107 assert!(body.contains("# keep this file-level explanation"));
1108 assert!(!body.contains("cargo check"));
1109 assert!(body.contains("cargo test"));
1110 }
1111
1112 #[test]
1113 fn permission_removal_rejects_stale_snapshot_without_writing() {
1114 let dir = tempfile::tempdir().expect("tempdir");
1115 let config_path = dir.path().join(CONFIG_FILE_NAME);
1116 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
1117 fs::write(
1118 &permissions_path,
1119 "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo check\"\n",
1120 )
1121 .expect("write permissions");
1122
1123 let snapshot =
1124 load_permissions_snapshot(Some(config_path.clone())).expect("load permission snapshot");
1125 let token = snapshot
1126 .removal_token(0)
1127 .expect("first rule token")
1128 .to_string();
1129 let changed = concat!(
1130 "[[rules]]\n",
1131 "tool = \"read_file\"\n",
1132 "path = \"README.md\"\n\n",
1133 "[[rules]]\n",
1134 "tool = \"exec_shell\"\n",
1135 "command = \"cargo check\"\n",
1136 );
1137 fs::write(&permissions_path, changed).expect("write concurrent permissions update");
1138
1139 let error =
1140 remove_permission_rule(Some(config_path), 0, &token).expect_err("stale removal must fail");
1141
1142 assert!(
1143 error
1144 .to_string()
1145 .contains("permissions changed after they were listed")
1146 );
1147 assert_eq!(
1148 fs::read_to_string(&permissions_path).expect("read unchanged permissions"),
1149 changed
1150 );
1151 }
1152
1153 struct EnvGuard {
1154 active_route_api_key: Option<OsString>,
1155 active_route_base_url: Option<OsString>,
1156 deepseek_anthropic_base_url: Option<OsString>,
1157 deepseek_claude_base_url: Option<OsString>,
1158 deepseek_http_headers: Option<OsString>,
1159 deepseek_model: Option<OsString>,
1160 deepseek_default_text_model: Option<OsString>,
1161 deepseek_provider: Option<OsString>,
1162 deepseek_auth_mode: Option<OsString>,
1163 nvidia_api_key: Option<OsString>,
1164 nvidia_nim_api_key: Option<OsString>,
1165 nim_base_url: Option<OsString>,
1166 nvidia_base_url: Option<OsString>,
1167 nvidia_nim_base_url: Option<OsString>,
1168 openrouter_api_key: Option<OsString>,
1169 openrouter_base_url: Option<OsString>,
1170 openrouter_model: Option<OsString>,
1171 openmodel_api_key: Option<OsString>,
1172 openmodel_base_url: Option<OsString>,
1173 openmodel_model: Option<OsString>,
1174 xiaomi_mimo_token_plan_api_key: Option<OsString>,
1175 mimo_token_plan_api_key: Option<OsString>,
1176 xiaomi_mimo_api_key: Option<OsString>,
1177 xiaomi_api_key: Option<OsString>,
1178 mimo_api_key: Option<OsString>,
1179 xiaomi_mimo_base_url: Option<OsString>,
1180 mimo_base_url: Option<OsString>,
1181 xiaomi_mimo_model: Option<OsString>,
1182 mimo_model: Option<OsString>,
1183 xiaomi_mimo_mode: Option<OsString>,
1184 mimo_mode: Option<OsString>,
1185 wanjie_ark_api_key: Option<OsString>,
1186 volcengine_api_key: Option<OsString>,
1187 volcengine_ark_api_key: Option<OsString>,
1188 ark_api_key: Option<OsString>,
1189 volcengine_base_url: Option<OsString>,
1190 volcengine_ark_base_url: Option<OsString>,
1191 ark_base_url: Option<OsString>,
1192 wanjie_ark_base_url: Option<OsString>,
1193 wanjie_base_url: Option<OsString>,
1194 wanjie_maas_base_url: Option<OsString>,
1195 volcengine_model: Option<OsString>,
1196 volcengine_ark_model: Option<OsString>,
1197 wanjie_ark_model: Option<OsString>,
1198 wanjie_model: Option<OsString>,
1199 wanjie_maas_model: Option<OsString>,
1200 novita_api_key: Option<OsString>,
1201 novita_base_url: Option<OsString>,
1202 novita_model: Option<OsString>,
1203 fireworks_api_key: Option<OsString>,
1204 fireworks_base_url: Option<OsString>,
1205 fireworks_model: Option<OsString>,
1206 siliconflow_api_key: Option<OsString>,
1207 siliconflow_base_url: Option<OsString>,
1208 siliconflow_model: Option<OsString>,
1209 arcee_api_key: Option<OsString>,
1210 arcee_base_url: Option<OsString>,
1211 arcee_model: Option<OsString>,
1212 moonshot_api_key: Option<OsString>,
1213 moonshot_base_url: Option<OsString>,
1214 moonshot_model: Option<OsString>,
1215 kimi_api_key: Option<OsString>,
1216 kimi_base_url: Option<OsString>,
1217 kimi_model: Option<OsString>,
1218 kimi_model_name: Option<OsString>,
1219 zai_api_key: Option<OsString>,
1220 z_ai_api_key: Option<OsString>,
1221 zhipu_api_key: Option<OsString>,
1222 glm_api_key: Option<OsString>,
1223 zai_base_url: Option<OsString>,
1224 z_ai_base_url: Option<OsString>,
1225 zhipu_base_url: Option<OsString>,
1226 zhipuai_base_url: Option<OsString>,
1227 bigmodel_base_url: Option<OsString>,
1228 zai_model: Option<OsString>,
1229 z_ai_model: Option<OsString>,
1230 zhipu_model: Option<OsString>,
1231 zhipuai_model: Option<OsString>,
1232 bigmodel_model: Option<OsString>,
1233 glm_model: Option<OsString>,
1234 stepfun_api_key: Option<OsString>,
1235 step_api_key: Option<OsString>,
1236 stepfun_base_url: Option<OsString>,
1237 stepfun_model: Option<OsString>,
1238 minimax_api_key: Option<OsString>,
1239 minimax_base_url: Option<OsString>,
1240 minimax_anthropic_base_url: Option<OsString>,
1241 minimax_model: Option<OsString>,
1242 sakana_api_key: Option<OsString>,
1243 fugu_api_key: Option<OsString>,
1244 sakana_base_url: Option<OsString>,
1245 sakana_model: Option<OsString>,
1246 sglang_api_key: Option<OsString>,
1247 sglang_base_url: Option<OsString>,
1248 vllm_api_key: Option<OsString>,
1249 vllm_base_url: Option<OsString>,
1250 ollama_cloud_api_key: Option<OsString>,
1251 ollama_cloud_base_url: Option<OsString>,
1252 ollama_cloud_model: Option<OsString>,
1253 ollama_api_key: Option<OsString>,
1254 ollama_base_url: Option<OsString>,
1255 huggingface_api_key: Option<OsString>,
1256 huggingface_token: Option<OsString>,
1257 huggingface_base_url: Option<OsString>,
1258 hf_base_url: Option<OsString>,
1259 huggingface_model: Option<OsString>,
1260 hf_model: Option<OsString>,
1261 codewhale_provider: Option<OsString>,
1262 codewhale_model: Option<OsString>,
1263 codewhale_base_url: Option<OsString>,
1264 xai_api_key: Option<OsString>,
1265 xai_base_url: Option<OsString>,
1266 xai_model: Option<OsString>,
1267 mistral_api_key: Option<OsString>,
1268 mistral_base_url: Option<OsString>,
1269 mistral_model: Option<OsString>,
1270 telecomjs_api_key: Option<OsString>,
1271 telecomjs_base_url: Option<OsString>,
1272 telecomjs_model: Option<OsString>,
1273 edenai_api_key: Option<OsString>,
1274 edenai_base_url: Option<OsString>,
1275 edenai_model: Option<OsString>,
1276 concentrate_api_key: Option<OsString>,
1277 concentrate_base_url: Option<OsString>,
1278 concentrate_model: Option<OsString>,
1279 opencode_go_api_key: Option<OsString>,
1280 opencode_go_base_url: Option<OsString>,
1281 opencode_go_model: Option<OsString>,
1282 opencode_zen_api_key: Option<OsString>,
1283 opencode_api_key: Option<OsString>,
1284 opencode_zen_base_url: Option<OsString>,
1285 opencode_zen_model: Option<OsString>,
1286 meta_model_api_key: Option<OsString>,
1287 model_api_key: Option<OsString>,
1288 meta_model_api_base_url: Option<OsString>,
1289 model_api_base_url: Option<OsString>,
1290 meta_model_api_model: Option<OsString>,
1291 model_api_model: Option<OsString>,
1292 }
1293
1294 impl EnvGuard {
1295 fn without_deepseek_runtime_overrides() -> Self {
1296 let guard = Self {
1297 active_route_api_key: env::var_os("DEEPSEEK_API_KEY"),
1298 active_route_base_url: env::var_os("DEEPSEEK_BASE_URL"),
1299 deepseek_anthropic_base_url: env::var_os("DEEPSEEK_ANTHROPIC_BASE_URL"),
1300 deepseek_claude_base_url: env::var_os("DEEPSEEK_CLAUDE_BASE_URL"),
1301 deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"),
1302 deepseek_model: env::var_os("DEEPSEEK_MODEL"),
1303 deepseek_default_text_model: env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"),
1304 deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"),
1305 deepseek_auth_mode: env::var_os("DEEPSEEK_AUTH_MODE"),
1306 codewhale_provider: env::var_os("CODEWHALE_PROVIDER"),
1307 codewhale_model: env::var_os("CODEWHALE_MODEL"),
1308 codewhale_base_url: env::var_os("CODEWHALE_BASE_URL"),
1309 xai_api_key: env::var_os("XAI_API_KEY"),
1310 xai_base_url: env::var_os("XAI_BASE_URL"),
1311 xai_model: env::var_os("XAI_MODEL"),
1312 mistral_api_key: env::var_os("MISTRAL_API_KEY"),
1313 mistral_base_url: env::var_os("MISTRAL_BASE_URL"),
1314 mistral_model: env::var_os("MISTRAL_MODEL"),
1315 telecomjs_api_key: env::var_os("TELECOMJS_API_KEY"),
1316 telecomjs_base_url: env::var_os("TELECOMJS_BASE_URL"),
1317 telecomjs_model: env::var_os("TELECOMJS_MODEL"),
1318 edenai_api_key: env::var_os("EDENAI_API_KEY"),
1319 edenai_base_url: env::var_os("EDENAI_BASE_URL"),
1320 edenai_model: env::var_os("EDENAI_MODEL"),
1321 concentrate_api_key: env::var_os("CONCENTRATE_API_KEY"),
1322 concentrate_base_url: env::var_os("CONCENTRATE_BASE_URL"),
1323 concentrate_model: env::var_os("CONCENTRATE_MODEL"),
1324 opencode_go_api_key: env::var_os("OPENCODE_GO_API_KEY"),
1325 opencode_go_base_url: env::var_os("OPENCODE_GO_BASE_URL"),
1326 opencode_go_model: env::var_os("OPENCODE_GO_MODEL"),
1327 opencode_zen_api_key: env::var_os("OPENCODE_ZEN_API_KEY"),
1328 opencode_api_key: env::var_os("OPENCODE_API_KEY"),
1329 opencode_zen_base_url: env::var_os("OPENCODE_ZEN_BASE_URL"),
1330 opencode_zen_model: env::var_os("OPENCODE_ZEN_MODEL"),
1331 meta_model_api_key: env::var_os("META_MODEL_API_KEY"),
1332 model_api_key: env::var_os("MODEL_API_KEY"),
1333 meta_model_api_base_url: env::var_os("META_MODEL_API_BASE_URL"),
1334 model_api_base_url: env::var_os("MODEL_API_BASE_URL"),
1335 meta_model_api_model: env::var_os("META_MODEL_API_MODEL"),
1336 model_api_model: env::var_os("MODEL_API_MODEL"),
1337 nvidia_api_key: env::var_os("NVIDIA_API_KEY"),
1338 nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"),
1339 nim_base_url: env::var_os("NIM_BASE_URL"),
1340 nvidia_base_url: env::var_os("NVIDIA_BASE_URL"),
1341 nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"),
1342 openrouter_api_key: env::var_os("OPENROUTER_API_KEY"),
1343 openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"),
1344 openrouter_model: env::var_os("OPENROUTER_MODEL"),
1345 openmodel_api_key: env::var_os("OPENMODEL_API_KEY"),
1346 openmodel_base_url: env::var_os("OPENMODEL_BASE_URL"),
1347 openmodel_model: env::var_os("OPENMODEL_MODEL"),
1348 xiaomi_mimo_token_plan_api_key: env::var_os("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"),
1349 mimo_token_plan_api_key: env::var_os("MIMO_TOKEN_PLAN_API_KEY"),
1350 xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"),
1351 xiaomi_api_key: env::var_os("XIAOMI_API_KEY"),
1352 mimo_api_key: env::var_os("MIMO_API_KEY"),
1353 xiaomi_mimo_base_url: env::var_os("XIAOMI_MIMO_BASE_URL"),
1354 mimo_base_url: env::var_os("MIMO_BASE_URL"),
1355 xiaomi_mimo_model: env::var_os("XIAOMI_MIMO_MODEL"),
1356 mimo_model: env::var_os("MIMO_MODEL"),
1357 xiaomi_mimo_mode: env::var_os("XIAOMI_MIMO_MODE"),
1358 mimo_mode: env::var_os("MIMO_MODE"),
1359 wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"),
1360 volcengine_api_key: env::var_os("VOLCENGINE_API_KEY"),
1361 volcengine_ark_api_key: env::var_os("VOLCENGINE_ARK_API_KEY"),
1362 ark_api_key: env::var_os("ARK_API_KEY"),
1363 volcengine_base_url: env::var_os("VOLCENGINE_BASE_URL"),
1364 volcengine_ark_base_url: env::var_os("VOLCENGINE_ARK_BASE_URL"),
1365 ark_base_url: env::var_os("ARK_BASE_URL"),
1366 wanjie_ark_base_url: env::var_os("WANJIE_ARK_BASE_URL"),
1367 wanjie_base_url: env::var_os("WANJIE_BASE_URL"),
1368 wanjie_maas_base_url: env::var_os("WANJIE_MAAS_BASE_URL"),
1369 volcengine_model: env::var_os("VOLCENGINE_MODEL"),
1370 volcengine_ark_model: env::var_os("VOLCENGINE_ARK_MODEL"),
1371 wanjie_ark_model: env::var_os("WANJIE_ARK_MODEL"),
1372 wanjie_model: env::var_os("WANJIE_MODEL"),
1373 wanjie_maas_model: env::var_os("WANJIE_MAAS_MODEL"),
1374 novita_api_key: env::var_os("NOVITA_API_KEY"),
1375 novita_base_url: env::var_os("NOVITA_BASE_URL"),
1376 novita_model: env::var_os("NOVITA_MODEL"),
1377 fireworks_api_key: env::var_os("FIREWORKS_API_KEY"),
1378 fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"),
1379 fireworks_model: env::var_os("FIREWORKS_MODEL"),
1380 siliconflow_api_key: env::var_os("SILICONFLOW_API_KEY"),
1381 siliconflow_base_url: env::var_os("SILICONFLOW_BASE_URL"),
1382 siliconflow_model: env::var_os("SILICONFLOW_MODEL"),
1383 arcee_api_key: env::var_os("ARCEE_API_KEY"),
1384 arcee_base_url: env::var_os("ARCEE_BASE_URL"),
1385 arcee_model: env::var_os("ARCEE_MODEL"),
1386 moonshot_api_key: env::var_os("MOONSHOT_API_KEY"),
1387 moonshot_base_url: env::var_os("MOONSHOT_BASE_URL"),
1388 moonshot_model: env::var_os("MOONSHOT_MODEL"),
1389 kimi_api_key: env::var_os("KIMI_API_KEY"),
1390 kimi_base_url: env::var_os("KIMI_BASE_URL"),
1391 kimi_model: env::var_os("KIMI_MODEL"),
1392 kimi_model_name: env::var_os("KIMI_MODEL_NAME"),
1393 zai_api_key: env::var_os("ZAI_API_KEY"),
1394 z_ai_api_key: env::var_os("Z_AI_API_KEY"),
1395 zhipu_api_key: env::var_os("ZHIPU_API_KEY"),
1396 glm_api_key: env::var_os("GLM_API_KEY"),
1397 zai_base_url: env::var_os("ZAI_BASE_URL"),
1398 z_ai_base_url: env::var_os("Z_AI_BASE_URL"),
1399 zhipu_base_url: env::var_os("ZHIPU_BASE_URL"),
1400 zhipuai_base_url: env::var_os("ZHIPUAI_BASE_URL"),
1401 bigmodel_base_url: env::var_os("BIGMODEL_BASE_URL"),
1402 zai_model: env::var_os("ZAI_MODEL"),
1403 z_ai_model: env::var_os("Z_AI_MODEL"),
1404 zhipu_model: env::var_os("ZHIPU_MODEL"),
1405 zhipuai_model: env::var_os("ZHIPUAI_MODEL"),
1406 bigmodel_model: env::var_os("BIGMODEL_MODEL"),
1407 glm_model: env::var_os("GLM_MODEL"),
1408 stepfun_api_key: env::var_os("STEPFUN_API_KEY"),
1409 step_api_key: env::var_os("STEP_API_KEY"),
1410 stepfun_base_url: env::var_os("STEPFUN_BASE_URL"),
1411 stepfun_model: env::var_os("STEPFUN_MODEL"),
1412 minimax_api_key: env::var_os("MINIMAX_API_KEY"),
1413 minimax_base_url: env::var_os("MINIMAX_BASE_URL"),
1414 minimax_anthropic_base_url: env::var_os("MINIMAX_ANTHROPIC_BASE_URL"),
1415 minimax_model: env::var_os("MINIMAX_MODEL"),
1416 sakana_api_key: env::var_os("SAKANA_API_KEY"),
1417 fugu_api_key: env::var_os("FUGU_API_KEY"),
1418 sakana_base_url: env::var_os("SAKANA_BASE_URL"),
1419 sakana_model: env::var_os("SAKANA_MODEL"),
1420 sglang_api_key: env::var_os("SGLANG_API_KEY"),
1421 sglang_base_url: env::var_os("SGLANG_BASE_URL"),
1422 vllm_api_key: env::var_os("VLLM_API_KEY"),
1423 vllm_base_url: env::var_os("VLLM_BASE_URL"),
1424 ollama_cloud_api_key: env::var_os("OLLAMA_CLOUD_API_KEY"),
1425 ollama_cloud_base_url: env::var_os("OLLAMA_CLOUD_BASE_URL"),
1426 ollama_cloud_model: env::var_os("OLLAMA_CLOUD_MODEL"),
1427 ollama_api_key: env::var_os("OLLAMA_API_KEY"),
1428 ollama_base_url: env::var_os("OLLAMA_BASE_URL"),
1429 huggingface_api_key: env::var_os("HUGGINGFACE_API_KEY"),
1430 huggingface_token: env::var_os("HF_TOKEN"),
1431 huggingface_base_url: env::var_os("HUGGINGFACE_BASE_URL"),
1432 hf_base_url: env::var_os("HF_BASE_URL"),
1433 huggingface_model: env::var_os("HUGGINGFACE_MODEL"),
1434 hf_model: env::var_os("HF_MODEL"),
1435 };
1436 // Safety: test-only environment mutation guarded by a module mutex.
1437 unsafe {
1438 env::remove_var("DEEPSEEK_API_KEY");
1439 env::remove_var("DEEPSEEK_BASE_URL");
1440 env::remove_var("DEEPSEEK_ANTHROPIC_BASE_URL");
1441 env::remove_var("DEEPSEEK_CLAUDE_BASE_URL");
1442 env::remove_var("DEEPSEEK_HTTP_HEADERS");
1443 env::remove_var("DEEPSEEK_MODEL");
1444 env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL");
1445 env::remove_var("DEEPSEEK_PROVIDER");
1446 env::remove_var("DEEPSEEK_AUTH_MODE");
1447 env::remove_var("CODEWHALE_PROVIDER");
1448 env::remove_var("CODEWHALE_MODEL");
1449 env::remove_var("CODEWHALE_BASE_URL");
1450 env::remove_var("XAI_API_KEY");
1451 env::remove_var("XAI_BASE_URL");
1452 env::remove_var("XAI_MODEL");
1453 env::remove_var("MISTRAL_API_KEY");
1454 env::remove_var("MISTRAL_BASE_URL");
1455 env::remove_var("MISTRAL_MODEL");
1456 env::remove_var("TELECOMJS_API_KEY");
1457 env::remove_var("TELECOMJS_BASE_URL");
1458 env::remove_var("TELECOMJS_MODEL");
1459 env::remove_var("EDENAI_API_KEY");
1460 env::remove_var("EDENAI_BASE_URL");
1461 env::remove_var("EDENAI_MODEL");
1462 env::remove_var("OPENCODE_GO_API_KEY");
1463 env::remove_var("OPENCODE_GO_BASE_URL");
1464 env::remove_var("OPENCODE_GO_MODEL");
1465 env::remove_var("OPENCODE_ZEN_API_KEY");
1466 env::remove_var("OPENCODE_API_KEY");
1467 env::remove_var("OPENCODE_ZEN_BASE_URL");
1468 env::remove_var("OPENCODE_ZEN_MODEL");
1469 env::remove_var("META_MODEL_API_KEY");
1470 env::remove_var("MODEL_API_KEY");
1471 env::remove_var("META_MODEL_API_BASE_URL");
1472 env::remove_var("MODEL_API_BASE_URL");
1473 env::remove_var("META_MODEL_API_MODEL");
1474 env::remove_var("MODEL_API_MODEL");
1475 env::remove_var("NVIDIA_API_KEY");
1476 env::remove_var("NVIDIA_NIM_API_KEY");
1477 env::remove_var("NIM_BASE_URL");
1478 env::remove_var("NVIDIA_BASE_URL");
1479 env::remove_var("NVIDIA_NIM_BASE_URL");
1480 env::remove_var("OPENROUTER_API_KEY");
1481 env::remove_var("OPENROUTER_BASE_URL");
1482 env::remove_var("OPENROUTER_MODEL");
1483 env::remove_var("OPENMODEL_API_KEY");
1484 env::remove_var("OPENMODEL_BASE_URL");
1485 env::remove_var("OPENMODEL_MODEL");
1486 env::remove_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY");
1487 env::remove_var("MIMO_TOKEN_PLAN_API_KEY");
1488 env::remove_var("XIAOMI_MIMO_API_KEY");
1489 env::remove_var("XIAOMI_API_KEY");
1490 env::remove_var("MIMO_API_KEY");
1491 env::remove_var("XIAOMI_MIMO_BASE_URL");
1492 env::remove_var("MIMO_BASE_URL");
1493 env::remove_var("XIAOMI_MIMO_MODEL");
1494 env::remove_var("MIMO_MODEL");
1495 env::remove_var("XIAOMI_MIMO_MODE");
1496 env::remove_var("MIMO_MODE");
1497 env::remove_var("WANJIE_ARK_API_KEY");
1498 env::remove_var("VOLCENGINE_API_KEY");
1499 env::remove_var("VOLCENGINE_ARK_API_KEY");
1500 env::remove_var("ARK_API_KEY");
1501 env::remove_var("VOLCENGINE_BASE_URL");
1502 env::remove_var("VOLCENGINE_ARK_BASE_URL");
1503 env::remove_var("ARK_BASE_URL");
1504 env::remove_var("WANJIE_ARK_BASE_URL");
1505 env::remove_var("WANJIE_BASE_URL");
1506 env::remove_var("WANJIE_MAAS_BASE_URL");
1507 env::remove_var("VOLCENGINE_MODEL");
1508 env::remove_var("VOLCENGINE_ARK_MODEL");
1509 env::remove_var("WANJIE_ARK_MODEL");
1510 env::remove_var("WANJIE_MODEL");
1511 env::remove_var("WANJIE_MAAS_MODEL");
1512 env::remove_var("NOVITA_API_KEY");
1513 env::remove_var("NOVITA_BASE_URL");
1514 env::remove_var("NOVITA_MODEL");
1515 env::remove_var("FIREWORKS_API_KEY");
1516 env::remove_var("FIREWORKS_BASE_URL");
1517 env::remove_var("FIREWORKS_MODEL");
1518 env::remove_var("SILICONFLOW_API_KEY");
1519 env::remove_var("SILICONFLOW_BASE_URL");
1520 env::remove_var("SILICONFLOW_MODEL");
1521 env::remove_var("ARCEE_API_KEY");
1522 env::remove_var("ARCEE_BASE_URL");
1523 env::remove_var("ARCEE_MODEL");
1524 env::remove_var("MOONSHOT_API_KEY");
1525 env::remove_var("MOONSHOT_BASE_URL");
1526 env::remove_var("MOONSHOT_MODEL");
1527 env::remove_var("KIMI_API_KEY");
1528 env::remove_var("KIMI_BASE_URL");
1529 env::remove_var("KIMI_MODEL");
1530 env::remove_var("KIMI_MODEL_NAME");
1531 env::remove_var("ZAI_API_KEY");
1532 env::remove_var("Z_AI_API_KEY");
1533 env::remove_var("ZHIPU_API_KEY");
1534 env::remove_var("GLM_API_KEY");
1535 env::remove_var("ZAI_BASE_URL");
1536 env::remove_var("Z_AI_BASE_URL");
1537 env::remove_var("ZHIPU_BASE_URL");
1538 env::remove_var("ZHIPUAI_BASE_URL");
1539 env::remove_var("BIGMODEL_BASE_URL");
1540 env::remove_var("ZAI_MODEL");
1541 env::remove_var("Z_AI_MODEL");
1542 env::remove_var("ZHIPU_MODEL");
1543 env::remove_var("ZHIPUAI_MODEL");
1544 env::remove_var("BIGMODEL_MODEL");
1545 env::remove_var("GLM_MODEL");
1546 env::remove_var("STEPFUN_API_KEY");
1547 env::remove_var("STEP_API_KEY");
1548 env::remove_var("STEPFUN_BASE_URL");
1549 env::remove_var("STEPFUN_MODEL");
1550 env::remove_var("MINIMAX_API_KEY");
1551 env::remove_var("MINIMAX_BASE_URL");
1552 env::remove_var("MINIMAX_ANTHROPIC_BASE_URL");
1553 env::remove_var("MINIMAX_MODEL");
1554 env::remove_var("SAKANA_API_KEY");
1555 env::remove_var("FUGU_API_KEY");
1556 env::remove_var("SAKANA_BASE_URL");
1557 env::remove_var("SAKANA_MODEL");
1558 env::remove_var("SGLANG_API_KEY");
1559 env::remove_var("SGLANG_BASE_URL");
1560 env::remove_var("VLLM_API_KEY");
1561 env::remove_var("VLLM_BASE_URL");
1562 env::remove_var("OLLAMA_CLOUD_API_KEY");
1563 env::remove_var("OLLAMA_CLOUD_BASE_URL");
1564 env::remove_var("OLLAMA_CLOUD_MODEL");
1565 env::remove_var("OLLAMA_API_KEY");
1566 env::remove_var("OLLAMA_BASE_URL");
1567 env::remove_var("HUGGINGFACE_API_KEY");
1568 env::remove_var("HF_TOKEN");
1569 env::remove_var("HUGGINGFACE_BASE_URL");
1570 env::remove_var("HF_BASE_URL");
1571 env::remove_var("HUGGINGFACE_MODEL");
1572 env::remove_var("HF_MODEL");
1573 }
1574 guard
1575 }
1576
1577 unsafe fn restore_var(key: &str, value: Option<OsString>) {
1578 if let Some(value) = value {
1579 unsafe { env::set_var(key, value) };
1580 } else {
1581 unsafe { env::remove_var(key) };
1582 }
1583 }
1584 }
1585
1586 impl Drop for EnvGuard {
1587 fn drop(&mut self) {
1588 // Safety: test-only environment mutation guarded by a module mutex.
1589 unsafe {
1590 Self::restore_var("DEEPSEEK_API_KEY", self.active_route_api_key.take());
1591 Self::restore_var("DEEPSEEK_BASE_URL", self.active_route_base_url.take());
1592 Self::restore_var(
1593 "DEEPSEEK_ANTHROPIC_BASE_URL",
1594 self.deepseek_anthropic_base_url.take(),
1595 );
1596 Self::restore_var(
1597 "DEEPSEEK_CLAUDE_BASE_URL",
1598 self.deepseek_claude_base_url.take(),
1599 );
1600 Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take());
1601 Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
1602 Self::restore_var(
1603 "DEEPSEEK_DEFAULT_TEXT_MODEL",
1604 self.deepseek_default_text_model.take(),
1605 );
1606 Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
1607 Self::restore_var("DEEPSEEK_AUTH_MODE", self.deepseek_auth_mode.take());
1608 Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take());
1609 Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take());
1610 Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take());
1611 Self::restore_var("XAI_API_KEY", self.xai_api_key.take());
1612 Self::restore_var("XAI_BASE_URL", self.xai_base_url.take());
1613 Self::restore_var("XAI_MODEL", self.xai_model.take());
1614 Self::restore_var("MISTRAL_API_KEY", self.mistral_api_key.take());
1615 Self::restore_var("MISTRAL_BASE_URL", self.mistral_base_url.take());
1616 Self::restore_var("MISTRAL_MODEL", self.mistral_model.take());
1617 Self::restore_var("TELECOMJS_API_KEY", self.telecomjs_api_key.take());
1618 Self::restore_var("TELECOMJS_BASE_URL", self.telecomjs_base_url.take());
1619 Self::restore_var("TELECOMJS_MODEL", self.telecomjs_model.take());
1620 Self::restore_var("EDENAI_API_KEY", self.edenai_api_key.take());
1621 Self::restore_var("EDENAI_BASE_URL", self.edenai_base_url.take());
1622 Self::restore_var("EDENAI_MODEL", self.edenai_model.take());
1623 Self::restore_var("CONCENTRATE_API_KEY", self.concentrate_api_key.take());
1624 Self::restore_var("CONCENTRATE_BASE_URL", self.concentrate_base_url.take());
1625 Self::restore_var("CONCENTRATE_MODEL", self.concentrate_model.take());
1626 Self::restore_var("OPENCODE_GO_API_KEY", self.opencode_go_api_key.take());
1627 Self::restore_var("OPENCODE_GO_BASE_URL", self.opencode_go_base_url.take());
1628 Self::restore_var("OPENCODE_GO_MODEL", self.opencode_go_model.take());
1629 Self::restore_var("OPENCODE_ZEN_API_KEY", self.opencode_zen_api_key.take());
1630 Self::restore_var("OPENCODE_API_KEY", self.opencode_api_key.take());
1631 Self::restore_var("OPENCODE_ZEN_BASE_URL", self.opencode_zen_base_url.take());
1632 Self::restore_var("OPENCODE_ZEN_MODEL", self.opencode_zen_model.take());
1633 Self::restore_var("META_MODEL_API_KEY", self.meta_model_api_key.take());
1634 Self::restore_var("MODEL_API_KEY", self.model_api_key.take());
1635 Self::restore_var(
1636 "META_MODEL_API_BASE_URL",
1637 self.meta_model_api_base_url.take(),
1638 );
1639 Self::restore_var("MODEL_API_BASE_URL", self.model_api_base_url.take());
1640 Self::restore_var("META_MODEL_API_MODEL", self.meta_model_api_model.take());
1641 Self::restore_var("MODEL_API_MODEL", self.model_api_model.take());
1642 Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
1643 Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
1644 Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
1645 Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
1646 Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
1647 Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take());
1648 Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take());
1649 Self::restore_var("OPENROUTER_MODEL", self.openrouter_model.take());
1650 Self::restore_var("OPENMODEL_API_KEY", self.openmodel_api_key.take());
1651 Self::restore_var("OPENMODEL_BASE_URL", self.openmodel_base_url.take());
1652 Self::restore_var("OPENMODEL_MODEL", self.openmodel_model.take());
1653 Self::restore_var(
1654 "XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
1655 self.xiaomi_mimo_token_plan_api_key.take(),
1656 );
1657 Self::restore_var(
1658 "MIMO_TOKEN_PLAN_API_KEY",
1659 self.mimo_token_plan_api_key.take(),
1660 );
1661 Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take());
1662 Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take());
1663 Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take());
1664 Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take());
1665 Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take());
1666 Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take());
1667 Self::restore_var("MIMO_MODEL", self.mimo_model.take());
1668 Self::restore_var("XIAOMI_MIMO_MODE", self.xiaomi_mimo_mode.take());
1669 Self::restore_var("MIMO_MODE", self.mimo_mode.take());
1670 Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take());
1671 Self::restore_var("VOLCENGINE_API_KEY", self.volcengine_api_key.take());
1672 Self::restore_var("VOLCENGINE_ARK_API_KEY", self.volcengine_ark_api_key.take());
1673 Self::restore_var("ARK_API_KEY", self.ark_api_key.take());
1674 Self::restore_var("VOLCENGINE_BASE_URL", self.volcengine_base_url.take());
1675 Self::restore_var(
1676 "VOLCENGINE_ARK_BASE_URL",
1677 self.volcengine_ark_base_url.take(),
1678 );
1679 Self::restore_var("ARK_BASE_URL", self.ark_base_url.take());
1680 Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take());
1681 Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take());
1682 Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take());
1683 Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take());
1684 Self::restore_var("VOLCENGINE_ARK_MODEL", self.volcengine_ark_model.take());
1685 Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take());
1686 Self::restore_var("WANJIE_MODEL", self.wanjie_model.take());
1687 Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take());
1688 Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take());
1689 Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take());
1690 Self::restore_var("NOVITA_MODEL", self.novita_model.take());
1691 Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take());
1692 Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take());
1693 Self::restore_var("FIREWORKS_MODEL", self.fireworks_model.take());
1694 Self::restore_var("SILICONFLOW_API_KEY", self.siliconflow_api_key.take());
1695 Self::restore_var("SILICONFLOW_BASE_URL", self.siliconflow_base_url.take());
1696 Self::restore_var("SILICONFLOW_MODEL", self.siliconflow_model.take());
1697 Self::restore_var("ARCEE_API_KEY", self.arcee_api_key.take());
1698 Self::restore_var("ARCEE_BASE_URL", self.arcee_base_url.take());
1699 Self::restore_var("ARCEE_MODEL", self.arcee_model.take());
1700 Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take());
1701 Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take());
1702 Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take());
1703 Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take());
1704 Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take());
1705 Self::restore_var("KIMI_MODEL", self.kimi_model.take());
1706 Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take());
1707 Self::restore_var("ZAI_API_KEY", self.zai_api_key.take());
1708 Self::restore_var("Z_AI_API_KEY", self.z_ai_api_key.take());
1709 Self::restore_var("ZHIPU_API_KEY", self.zhipu_api_key.take());
1710 Self::restore_var("GLM_API_KEY", self.glm_api_key.take());
1711 Self::restore_var("ZAI_BASE_URL", self.zai_base_url.take());
1712 Self::restore_var("Z_AI_BASE_URL", self.z_ai_base_url.take());
1713 Self::restore_var("ZHIPU_BASE_URL", self.zhipu_base_url.take());
1714 Self::restore_var("ZHIPUAI_BASE_URL", self.zhipuai_base_url.take());
1715 Self::restore_var("BIGMODEL_BASE_URL", self.bigmodel_base_url.take());
1716 Self::restore_var("ZAI_MODEL", self.zai_model.take());
1717 Self::restore_var("Z_AI_MODEL", self.z_ai_model.take());
1718 Self::restore_var("ZHIPU_MODEL", self.zhipu_model.take());
1719 Self::restore_var("ZHIPUAI_MODEL", self.zhipuai_model.take());
1720 Self::restore_var("BIGMODEL_MODEL", self.bigmodel_model.take());
1721 Self::restore_var("GLM_MODEL", self.glm_model.take());
1722 Self::restore_var("STEPFUN_API_KEY", self.stepfun_api_key.take());
1723 Self::restore_var("STEP_API_KEY", self.step_api_key.take());
1724 Self::restore_var("STEPFUN_BASE_URL", self.stepfun_base_url.take());
1725 Self::restore_var("STEPFUN_MODEL", self.stepfun_model.take());
1726 Self::restore_var("MINIMAX_API_KEY", self.minimax_api_key.take());
1727 Self::restore_var("MINIMAX_BASE_URL", self.minimax_base_url.take());
1728 Self::restore_var(
1729 "MINIMAX_ANTHROPIC_BASE_URL",
1730 self.minimax_anthropic_base_url.take(),
1731 );
1732 Self::restore_var("MINIMAX_MODEL", self.minimax_model.take());
1733 Self::restore_var("SAKANA_API_KEY", self.sakana_api_key.take());
1734 Self::restore_var("FUGU_API_KEY", self.fugu_api_key.take());
1735 Self::restore_var("SAKANA_BASE_URL", self.sakana_base_url.take());
1736 Self::restore_var("SAKANA_MODEL", self.sakana_model.take());
1737 Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take());
1738 Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take());
1739 Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take());
1740 Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take());
1741 Self::restore_var("OLLAMA_CLOUD_API_KEY", self.ollama_cloud_api_key.take());
1742 Self::restore_var("OLLAMA_CLOUD_BASE_URL", self.ollama_cloud_base_url.take());
1743 Self::restore_var("OLLAMA_CLOUD_MODEL", self.ollama_cloud_model.take());
1744 Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take());
1745 Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take());
1746 Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take());
1747 Self::restore_var("HF_TOKEN", self.huggingface_token.take());
1748 Self::restore_var("HUGGINGFACE_BASE_URL", self.huggingface_base_url.take());
1749 Self::restore_var("HF_BASE_URL", self.hf_base_url.take());
1750 Self::restore_var("HUGGINGFACE_MODEL", self.huggingface_model.take());
1751 Self::restore_var("HF_MODEL", self.hf_model.take());
1752 }
1753 }
1754 }
1755
1756 struct RecordingSecretsStore {
1757 gets: Mutex<Vec<String>>,
1758 sets: Mutex<Vec<String>>,
1759 deletes: Mutex<Vec<String>>,
1760 value: Option<String>,
1761 values: std::collections::HashMap<String, String>,
1762 }
1763
1764 impl RecordingSecretsStore {
1765 fn with_value(value: &str) -> Self {
1766 Self {
1767 gets: Mutex::new(Vec::new()),
1768 sets: Mutex::new(Vec::new()),
1769 deletes: Mutex::new(Vec::new()),
1770 value: Some(value.to_string()),
1771 values: std::collections::HashMap::new(),
1772 }
1773 }
1774
1775 fn with_entries(entries: &[(&str, &str)]) -> Self {
1776 Self {
1777 gets: Mutex::new(Vec::new()),
1778 sets: Mutex::new(Vec::new()),
1779 deletes: Mutex::new(Vec::new()),
1780 value: None,
1781 values: entries
1782 .iter()
1783 .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
1784 .collect(),
1785 }
1786 }
1787
1788 fn empty() -> Self {
1789 Self::with_entries(&[])
1790 }
1791 }
1792
1793 impl codewhale_secrets::KeyringStore for RecordingSecretsStore {
1794 fn get(&self, key: &str) -> Result<Option<String>, codewhale_secrets::SecretsError> {
1795 self.gets.lock().unwrap().push(key.to_string());
1796 Ok(self.values.get(key).cloned().or_else(|| self.value.clone()))
1797 }
1798
1799 fn set(&self, key: &str, _value: &str) -> Result<(), codewhale_secrets::SecretsError> {
1800 self.sets.lock().unwrap().push(key.to_string());
1801 Ok(())
1802 }
1803
1804 fn delete(&self, key: &str) -> Result<(), codewhale_secrets::SecretsError> {
1805 self.deletes.lock().unwrap().push(key.to_string());
1806 Ok(())
1807 }
1808
1809 fn backend_name(&self) -> &'static str {
1810 "recording"
1811 }
1812 }
1813
1814 #[test]
1815 fn root_deepseek_fields_are_runtime_fallbacks() {
1816 let _lock = env_lock();
1817 let _env = EnvGuard::without_deepseek_runtime_overrides();
1818 // The legacy top-level shape (#6394): parsing moves both keys into
1819 // `[providers.deepseek]`, so the runtime resolves them from there.
1820 let config = crate::parse_config_toml(
1821 "api_key = \"root-key\"\nbase_url = \"https://api.deepseek.com\"\ndefault_text_model = \"deepseek-v4-pro\"\n",
1822 )
1823 .expect("legacy config parses");
1824 assert_eq!(
1825 config.providers.deepseek.api_key.as_deref(),
1826 Some("root-key")
1827 );
1828 assert!(!config.extras.contains_key("api_key") && !config.extras.contains_key("base_url"));
1829
1830 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1831
1832 assert_eq!(resolved.provider, ProviderKind::Deepseek);
1833 assert_eq!(resolved.api_key.as_deref(), Some("root-key"));
1834 assert_eq!(resolved.base_url, "https://api.deepseek.com");
1835 assert_eq!(resolved.model, "deepseek-v4-pro");
1836 }
1837
1838 #[test]
1839 fn deepseek_runtime_defaults_to_beta_endpoint() {
1840 let _lock = env_lock();
1841 let _env = EnvGuard::without_deepseek_runtime_overrides();
1842 let config = ConfigToml::default();
1843
1844 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1845
1846 assert_eq!(resolved.provider, ProviderKind::Deepseek);
1847 assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_BASE_URL);
1848 assert_eq!(resolved.model, DEFAULT_DEEPSEEK_MODEL);
1849 }
1850
1851 #[test]
1852 fn conflicting_top_level_and_table_values_resolve_like_the_tui() {
1853 let _lock = env_lock();
1854 let _env = EnvGuard::without_deepseek_runtime_overrides();
1855 // Both shapes at once (#6394): the table's endpoint wins, and the
1856 // top-level key wins because that is the key the TUI always sent. The
1857 // dispatcher used to report the table key instead.
1858 let config = crate::parse_config_toml(
1859 r#"
1860 api_key = "root-key"
1861 base_url = "https://api.deepseek.com"
1862 default_text_model = "deepseek-v4-pro"
1863
1864 [providers.deepseek]
1865 api_key = "provider-key"
1866 base_url = "https://gateway.example/v1"
1867 model = "deepseek-v4-flash"
1868 "#,
1869 )
1870 .expect("legacy config parses");
1871
1872 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1873
1874 assert_eq!(resolved.api_key.as_deref(), Some("root-key"));
1875 assert_eq!(resolved.base_url, "https://gateway.example/v1");
1876 assert_eq!(resolved.model, "deepseek-v4-flash");
1877 }
1878
1879 #[test]
1880 fn provider_http_headers_override_root_headers() {
1881 let _lock = env_lock();
1882 let _env = EnvGuard::without_deepseek_runtime_overrides();
1883 let mut config = ConfigToml {
1884 default_text_model: Some("deepseek-v4-pro".to_string()),
1885 ..ConfigToml::default()
1886 };
1887 config.providers.deepseek.api_key = Some("provider-key".to_string());
1888 config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string());
1889 config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
1890 config
1891 .http_headers
1892 .insert("X-Shared".to_string(), "root".to_string());
1893 config
1894 .providers
1895 .deepseek
1896 .http_headers
1897 .insert("X-Model-Provider-Id".to_string(), "tongyi".to_string());
1898 config
1899 .providers
1900 .deepseek
1901 .http_headers
1902 .insert("X-Shared".to_string(), "provider".to_string());
1903
1904 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1905
1906 assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
1907 assert_eq!(resolved.base_url, "https://gateway.example/v1");
1908 assert_eq!(resolved.model, "deepseek-v4-flash");
1909 assert_eq!(
1910 resolved
1911 .http_headers
1912 .get("X-Model-Provider-Id")
1913 .map(String::as_str),
1914 Some("tongyi")
1915 );
1916 assert_eq!(
1917 resolved.http_headers.get("X-Shared").map(String::as_str),
1918 Some("provider")
1919 );
1920 }
1921
1922 #[test]
1923 fn insecure_skip_tls_verify_resolves_only_for_active_provider() {
1924 let _lock = env_lock();
1925 let _env = EnvGuard::without_deepseek_runtime_overrides();
1926 let mut config = ConfigToml {
1927 provider: ProviderKind::Openai,
1928 ..ConfigToml::default()
1929 };
1930 config.providers.deepseek.insecure_skip_tls_verify = Some(true);
1931
1932 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1933
1934 assert_eq!(resolved.provider, ProviderKind::Openai);
1935 assert!(!resolved.insecure_skip_tls_verify);
1936
1937 config.providers.openai.insecure_skip_tls_verify = Some(true);
1938 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1939
1940 assert_eq!(resolved.provider, ProviderKind::Openai);
1941 assert!(resolved.insecure_skip_tls_verify);
1942 }
1943
1944 /// #5991: `allow_insecure_http` is a per-provider key again — parsed from the
1945 /// `[providers.<name>]` table, settable/unsettable through `config set`, and
1946 /// listed in the custom-provider field hint. It must stay independent of
1947 /// `insecure_skip_tls_verify` (which only relaxes TLS verification).
1948 #[test]
1949 fn allow_insecure_http_round_trips_and_stays_distinct_from_tls_verify() {
1950 let _lock = env_lock();
1951 let _env = EnvGuard::without_deepseek_runtime_overrides();
1952
1953 let raw = r#"
1954 provider = "openai"
1955 [providers.openai]
1956 base_url = "http://192.168.0.110:8000/v1"
1957 allow_insecure_http = true
1958 insecure_skip_tls_verify = false
1959 "#;
1960 let config: ConfigToml = toml::from_str(raw).expect("parses");
1961 assert_eq!(config.providers.openai.allow_insecure_http, Some(true));
1962 assert_eq!(
1963 config.providers.openai.insecure_skip_tls_verify,
1964 Some(false)
1965 );
1966
1967 // `config set providers.openai.allow_insecure_http true` lands in the
1968 // same field and `config get` reads it back.
1969 let mut set_target = ConfigToml::default();
1970 set_provider_config_value(
1971 &mut set_target,
1972 ProviderKind::Openai,
1973 ProviderConfigField::AllowInsecureHttp,
1974 "true",
1975 )
1976 .expect("set accepts the key");
1977 assert_eq!(set_target.providers.openai.allow_insecure_http, Some(true));
1978 assert!(
1979 set_target
1980 .providers
1981 .openai
1982 .insecure_skip_tls_verify
1983 .is_none()
1984 );
1985 assert_eq!(
1986 get_provider_config_value(
1987 &set_target.providers.openai,
1988 ProviderConfigField::AllowInsecureHttp
1989 ),
1990 Some("true".to_string())
1991 );
1992
1993 unset_provider_config_value(
1994 &mut set_target,
1995 ProviderKind::Openai,
1996 ProviderConfigField::AllowInsecureHttp,
1997 );
1998 assert_eq!(set_target.providers.openai.allow_insecure_http, None);
1999
2000 assert!(CUSTOM_PROVIDER_FIELD_HINT.contains("allow_insecure_http"));
2001 }
2002
2003 #[test]
2004 fn openai_provider_accepts_dashscope_bailian_base_url_and_model() {
2005 let _lock = env_lock();
2006 let _env = EnvGuard::without_deepseek_runtime_overrides();
2007 let mut config = ConfigToml {
2008 provider: ProviderKind::Openai,
2009 ..ConfigToml::default()
2010 };
2011 config.providers.openai.api_key = Some("dashscope-table-key".to_string());
2012 config.providers.openai.base_url =
2013 Some("https://dashscope-intl.aliyuncs.com/compatible-mode/v1".to_string());
2014 config.providers.openai.model = Some("qwen-plus".to_string());
2015
2016 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2017
2018 assert_eq!(resolved.provider, ProviderKind::Openai);
2019 assert_eq!(resolved.api_key.as_deref(), Some("dashscope-table-key"));
2020 assert_eq!(
2021 resolved.base_url,
2022 "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
2023 );
2024 assert_eq!(resolved.model, "qwen-plus");
2025 }
2026
2027 #[test]
2028 fn http_headers_env_overrides_config() {
2029 let _lock = env_lock();
2030 let _env = EnvGuard::without_deepseek_runtime_overrides();
2031 let mut config = ConfigToml::default();
2032 config
2033 .http_headers
2034 .insert("X-Model-Provider-Id".to_string(), "from-file".to_string());
2035 // Safety: test-only environment mutation guarded by a module mutex.
2036 unsafe {
2037 env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env");
2038 }
2039
2040 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2041
2042 assert_eq!(
2043 resolved
2044 .http_headers
2045 .get("X-Model-Provider-Id")
2046 .map(String::as_str),
2047 Some("from-env")
2048 );
2049 }
2050
2051 #[test]
2052 fn yolo_env_var_prefers_codewhale_and_keeps_deepseek_alias() {
2053 let _lock = env_lock();
2054 let _env = EnvGuard::without_deepseek_runtime_overrides();
2055 let codewhale_prev = env::var_os("CODEWHALE_YOLO");
2056 let deepseek_prev = env::var_os("DEEPSEEK_YOLO");
2057 let config = ConfigToml::default();
2058
2059 // Only the canonical name is set.
2060 unsafe {
2061 env::set_var("CODEWHALE_YOLO", "true");
2062 env::remove_var("DEEPSEEK_YOLO");
2063 }
2064 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2065 assert_eq!(
2066 resolved.yolo,
2067 Some(true),
2068 "CODEWHALE_YOLO=true must enable the yolo posture"
2069 );
2070
2071 // Only the deprecated alias is set: it must keep working through 0.9.x.
2072 unsafe {
2073 env::remove_var("CODEWHALE_YOLO");
2074 env::set_var("DEEPSEEK_YOLO", "true");
2075 }
2076 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2077 assert_eq!(
2078 resolved.yolo,
2079 Some(true),
2080 "DEEPSEEK_YOLO remains a read-only deprecated alias until 0.10 (#5443)"
2081 );
2082
2083 // Both set: the canonical name wins.
2084 unsafe { env::set_var("CODEWHALE_YOLO", "false") };
2085 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2086 assert_eq!(
2087 resolved.yolo,
2088 Some(false),
2089 "CODEWHALE_YOLO must win over the deprecated DEEPSEEK_YOLO alias"
2090 );
2091
2092 unsafe {
2093 EnvGuard::restore_var("CODEWHALE_YOLO", codewhale_prev);
2094 EnvGuard::restore_var("DEEPSEEK_YOLO", deepseek_prev);
2095 }
2096 }
2097
2098 #[test]
2099 fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() {
2100 let _lock = env_lock();
2101 let _env = EnvGuard::without_deepseek_runtime_overrides();
2102 let config = ConfigToml {
2103 provider: ProviderKind::NvidiaNim,
2104 ..ConfigToml::default()
2105 };
2106
2107 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2108
2109 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2110 assert_eq!(resolved.base_url, DEFAULT_NVIDIA_NIM_BASE_URL);
2111 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
2112 }
2113
2114 #[test]
2115 fn nvidia_nim_provider_uses_provider_specific_credentials() {
2116 let _lock = env_lock();
2117 let _env = EnvGuard::without_deepseek_runtime_overrides();
2118 let mut config = ConfigToml {
2119 provider: ProviderKind::NvidiaNim,
2120 ..ConfigToml::default()
2121 };
2122 config.providers.nvidia_nim.api_key = Some("nim-key".to_string());
2123 config.providers.nvidia_nim.base_url = Some("https://nim.example/v1".to_string());
2124 config.providers.nvidia_nim.model = Some("deepseek-ai/deepseek-v4-pro".to_string());
2125
2126 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2127
2128 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2129 assert_eq!(resolved.api_key.as_deref(), Some("nim-key"));
2130 assert_eq!(resolved.base_url, "https://nim.example/v1");
2131 assert_eq!(resolved.model, "deepseek-ai/deepseek-v4-pro");
2132 }
2133
2134 #[test]
2135 fn multiword_provider_sections_accept_the_kebab_canonical_id() {
2136 // The canonical provider ids are kebab (`nvidia-nim`, `wanjie-ark`,
2137 // `xiaomi-mimo`) everywhere users see them; before 2026-08-04 the TOML
2138 // fields carried only the snake_case name, so a `[providers.nvidia-nim]`
2139 // section parsed into nothing and the override was silently lost.
2140 let toml_src = "\
2141 [providers.nvidia-nim]
2142 model = \"nim-kebab-model\"
2143
2144 [providers.wanjie-ark]
2145 model = \"wanjie-kebab-model\"
2146
2147 [providers.xiaomi-mimo]
2148 model = \"mimo-kebab-model\"
2149 ";
2150 let parsed: ConfigToml = toml::from_str(toml_src).expect("kebab provider sections parse");
2151 assert_eq!(
2152 parsed.providers.nvidia_nim.model.as_deref(),
2153 Some("nim-kebab-model")
2154 );
2155 assert_eq!(
2156 parsed.providers.wanjie_ark.model.as_deref(),
2157 Some("wanjie-kebab-model")
2158 );
2159 assert_eq!(
2160 parsed.providers.xiaomi_mimo.model.as_deref(),
2161 Some("mimo-kebab-model")
2162 );
2163 }
2164
2165 #[test]
2166 fn nvidia_nim_provider_normalizes_flash_aliases() {
2167 let _lock = env_lock();
2168 let _env = EnvGuard::without_deepseek_runtime_overrides();
2169 let cli = CliRuntimeOverrides {
2170 provider: Some(ProviderKind::NvidiaNim),
2171 model: Some("deepseek-v4-flash".to_string()),
2172 ..CliRuntimeOverrides::default()
2173 };
2174
2175 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
2176
2177 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2178 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_FLASH_MODEL);
2179 }
2180
2181 #[test]
2182 fn nvidia_nim_custom_env_url_does_not_inherit_ambient_credentials() {
2183 let _lock = env_lock();
2184 let _env = EnvGuard::without_deepseek_runtime_overrides();
2185 // Safety: test-only environment mutation guarded by a module mutex.
2186 unsafe {
2187 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
2188 env::set_var("NVIDIA_API_KEY", "nim-env-key");
2189 env::set_var("NVIDIA_NIM_BASE_URL", "https://nim-env.example/v1");
2190 }
2191
2192 let config = ConfigToml::default();
2193 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2194
2195 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2196 assert_eq!(resolved.api_key, None);
2197 assert_eq!(resolved.api_key_source, None);
2198 assert_eq!(resolved.base_url, "https://nim-env.example/v1");
2199 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
2200 }
2201
2202 #[test]
2203 fn nvidia_nim_provider_accepts_short_nim_base_url_alias() {
2204 let _lock = env_lock();
2205 let _env = EnvGuard::without_deepseek_runtime_overrides();
2206 // Safety: test-only environment mutation guarded by a module mutex.
2207 unsafe {
2208 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
2209 env::set_var("NVIDIA_API_KEY", "nim-env-key");
2210 env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
2211 }
2212
2213 let config = ConfigToml::default();
2214 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2215
2216 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2217 assert_eq!(resolved.base_url, "https://short-nim.example/v1");
2218 }
2219
2220 #[test]
2221 fn nvidia_nim_provider_does_not_fallback_to_deepseek_api_key_env() {
2222 let _lock = env_lock();
2223 let _env = EnvGuard::without_deepseek_runtime_overrides();
2224 // Safety: test-only environment mutation guarded by a module mutex.
2225 unsafe {
2226 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
2227 env::set_var("DEEPSEEK_API_KEY", "deepseek-compat-key");
2228 }
2229
2230 let config = ConfigToml::default();
2231 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2232
2233 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2234 assert_eq!(resolved.api_key, None);
2235 assert_eq!(resolved.api_key_source, None);
2236 }
2237
2238 #[test]
2239 fn list_values_redacts_root_api_key() {
2240 let config = crate::parse_config_toml("api_key = \"sk-deepseek-secret\"\n").unwrap();
2241
2242 let values = config.list_values();
2243
2244 assert!(!values.contains_key("api_key"));
2245 assert_eq!(
2246 values.get("providers.deepseek.api_key").map(String::as_str),
2247 Some("sk-d***cret")
2248 );
2249 }
2250
2251 #[test]
2252 fn list_values_fully_redacts_short_api_key() {
2253 let mut config = ConfigToml::default();
2254 config.providers.deepseek.api_key = Some("short-key".to_string());
2255
2256 let values = config.list_values();
2257
2258 assert_eq!(
2259 values.get("providers.deepseek.api_key").map(String::as_str),
2260 Some("********")
2261 );
2262 }
2263
2264 #[test]
2265 fn redacted_toml_value_keeps_shape_but_not_secret_bytes() {
2266 let mut config = ConfigToml {
2267 model: Some("deepseek-v4-pro".to_string()),
2268 ..ConfigToml::default()
2269 };
2270 config.providers.deepseek.api_key = Some("sk-deepseek-secret-value".to_string());
2271 config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string());
2272
2273 let value = config.redacted_toml_value();
2274 let table = value.as_table().expect("dump renders a table");
2275 let api_key = table["providers"]["deepseek"]
2276 .get("api_key")
2277 .and_then(toml::Value::as_str)
2278 .expect("api_key keeps its slot");
2279 assert!(!api_key.contains("secret"), "{api_key}");
2280 let rendered = toml::to_string_pretty(&value).expect("dump serializes");
2281 assert!(!rendered.contains("secret-value"), "{rendered}");
2282 assert!(rendered.contains("deepseek-v4-pro"), "{rendered}");
2283 }
2284
2285 #[test]
2286 fn get_display_value_redacts_sensitive_keys() {
2287 let mut config = ConfigToml::default();
2288 config.providers.deepseek.api_key = Some("sk-deepseek-secret".to_string());
2289 config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string());
2290 config.model = Some("deepseek-v4-pro".to_string());
2291
2292 // `api_key` at the top level names the active provider's table (#6394).
2293 assert_eq!(
2294 config.get_display_value("api_key").as_deref(),
2295 Some("sk-d***cret")
2296 );
2297 assert_eq!(
2298 config
2299 .get_display_value("providers.openrouter.api_key")
2300 .as_deref(),
2301 Some("open***alue")
2302 );
2303 assert_eq!(
2304 config.get_display_value("model").as_deref(),
2305 Some("deepseek-v4-pro")
2306 );
2307 }
2308
2309 #[test]
2310 fn stream_chunk_timeout_display_defaults_to_900_for_flat_key() {
2311 let config = ConfigToml::default();
2312
2313 assert_eq!(
2314 config
2315 .get_display_value("stream_chunk_timeout_secs")
2316 .as_deref(),
2317 Some("900")
2318 );
2319 }
2320
2321 #[test]
2322 fn stream_chunk_timeout_display_reads_tui_table_for_flat_key() {
2323 let config: ConfigToml = toml::from_str(
2324 r#"
2325 [tui]
2326 stream_chunk_timeout_secs = 1200
2327 "#,
2328 )
2329 .expect("config toml");
2330
2331 assert_eq!(
2332 config
2333 .get_display_value("stream_chunk_timeout_secs")
2334 .as_deref(),
2335 Some("1200")
2336 );
2337 }
2338
2339 #[test]
2340 fn stream_chunk_timeout_display_supports_dotted_tui_key() {
2341 let config: ConfigToml = toml::from_str(
2342 r#"
2343 [tui]
2344 stream_chunk_timeout_secs = 1200
2345 "#,
2346 )
2347 .expect("config toml");
2348
2349 assert_eq!(
2350 config
2351 .get_display_value("tui.stream_chunk_timeout_secs")
2352 .as_deref(),
2353 Some("1200")
2354 );
2355 }
2356
2357 #[test]
2358 fn stream_chunk_timeout_display_zero_maps_to_default_and_clamps() {
2359 let zero: ConfigToml = toml::from_str(
2360 r#"
2361 [tui]
2362 stream_chunk_timeout_secs = 0
2363 "#,
2364 )
2365 .expect("zero config toml");
2366 assert_eq!(
2367 zero.get_display_value("stream_chunk_timeout_secs")
2368 .as_deref(),
2369 Some("900")
2370 );
2371
2372 let high: ConfigToml = toml::from_str(
2373 r#"
2374 [tui]
2375 stream_chunk_timeout_secs = 9999
2376 "#,
2377 )
2378 .expect("high config toml");
2379 assert_eq!(
2380 high.get_display_value("stream_chunk_timeout_secs")
2381 .as_deref(),
2382 Some("3600")
2383 );
2384 }
2385
2386 #[test]
2387 fn config_display_redacts_nested_extra_secrets() {
2388 let mut config = ConfigToml::default();
2389 let mut profile = toml::map::Map::new();
2390 profile.insert(
2391 "chatgpt_access_token".to_string(),
2392 toml::Value::String("raw-chatgpt-access-token-value".to_string()),
2393 );
2394 profile.insert(
2395 "safe_label".to_string(),
2396 toml::Value::String("visible".to_string()),
2397 );
2398
2399 let mut nested = toml::map::Map::new();
2400 nested.insert(
2401 "refresh_token".to_string(),
2402 toml::Value::String("raw-refresh-token-value".to_string()),
2403 );
2404 nested.insert("expires_at".to_string(), toml::Value::Integer(1234));
2405 profile.insert("session".to_string(), toml::Value::Table(nested));
2406
2407 config
2408 .extras
2409 .insert("extras".to_string(), toml::Value::Table(profile));
2410
2411 let listed = config.list_values();
2412 let rendered = listed.get("extras").expect("extras are listed");
2413
2414 assert!(rendered.contains("chatgpt_access_token"));
2415 assert!(rendered.contains("refresh_token"));
2416 assert!(rendered.contains("safe_label = \"visible\""));
2417 assert!(!rendered.contains("raw-chatgpt-access-token-value"));
2418 assert!(!rendered.contains("raw-refresh-token-value"));
2419
2420 let display = config
2421 .get_display_value("extras")
2422 .expect("extras display value");
2423 assert!(!display.contains("raw-chatgpt-access-token-value"));
2424 assert!(!display.contains("raw-refresh-token-value"));
2425 }
2426
2427 #[test]
2428 fn config_display_redacts_sensitive_extra_leaf_keys_and_headers() {
2429 let mut config = ConfigToml::default();
2430 config.extras.insert(
2431 "chatgpt_access_token".to_string(),
2432 toml::Value::String("raw-chatgpt-token-value".to_string()),
2433 );
2434 config.http_headers.insert(
2435 "Authorization".to_string(),
2436 "Bearer raw-header-token".to_string(),
2437 );
2438 config
2439 .http_headers
2440 .insert("X-Test".to_string(), "ok".to_string());
2441
2442 assert_eq!(
2443 config.get_display_value("chatgpt_access_token").as_deref(),
2444 Some("\"raw-***alue\"")
2445 );
2446
2447 let headers = config
2448 .list_values()
2449 .get("http_headers")
2450 .expect("headers are listed")
2451 .clone();
2452 assert!(headers.contains("Authorization=Bear***oken"));
2453 assert!(headers.contains("X-Test=ok"));
2454 assert!(!headers.contains("raw-header-token"));
2455 }
2456
2457 #[test]
2458 fn hook_sinks_config_uses_separate_table_from_lifecycle_hooks() -> Result<()> {
2459 let raw = r#"
2460 [hooks]
2461 enabled = true
2462 default_timeout_secs = 20
2463
2464 [[hooks.hooks]]
2465 event = "message_submit"
2466 command = "echo ok"
2467
2468 [hook_sinks]
2469 unix_socket_path = "/tmp/cw-hooks.sock"
2470 "#;
2471
2472 let config: ConfigToml = toml::from_str(raw)?;
2473
2474 assert_eq!(
2475 config.get_value("hook_sinks.unix_socket_path").as_deref(),
2476 Some("/tmp/cw-hooks.sock")
2477 );
2478 assert!(
2479 config.extras.contains_key("hooks"),
2480 "legacy lifecycle hooks table must remain an opaque extra"
2481 );
2482
2483 let serialized = toml::to_string_pretty(&config)?;
2484 let round_tripped: ConfigToml = toml::from_str(&serialized)?;
2485 let hooks = round_tripped
2486 .extras
2487 .get("hooks")
2488 .and_then(toml::Value::as_table)
2489 .expect("hooks table preserved");
2490
2491 assert_eq!(
2492 hooks.get("enabled").and_then(toml::Value::as_bool),
2493 Some(true)
2494 );
2495 assert_eq!(
2496 hooks
2497 .get("default_timeout_secs")
2498 .and_then(toml::Value::as_integer),
2499 Some(20)
2500 );
2501 assert!(
2502 hooks.get("hooks").and_then(toml::Value::as_array).is_some(),
2503 "nested lifecycle hooks array must survive config rewrites"
2504 );
2505 assert_eq!(
2506 round_tripped
2507 .get_value("hook_sinks.unix_socket_path")
2508 .as_deref(),
2509 Some("/tmp/cw-hooks.sock")
2510 );
2511
2512 Ok(())
2513 }
2514
2515 #[test]
2516 fn hook_sinks_unix_socket_path_round_trips_through_key_value_api() -> Result<()> {
2517 let mut config = ConfigToml::default();
2518
2519 config.set_value("hook_sinks.unix_socket_path", "/tmp/cw-events.sock")?;
2520
2521 assert_eq!(
2522 config.get_value("hook_sinks.unix_socket_path").as_deref(),
2523 Some("/tmp/cw-events.sock")
2524 );
2525 assert_eq!(
2526 config
2527 .list_values()
2528 .get("hook_sinks.unix_socket_path")
2529 .map(String::as_str),
2530 Some("/tmp/cw-events.sock")
2531 );
2532
2533 config.unset_value("hook_sinks.unix_socket_path")?;
2534 assert_eq!(config.get_value("hook_sinks.unix_socket_path"), None);
2535
2536 Ok(())
2537 }
2538
2539 /// End-to-end smoke for the preferred Kimi Code setup path:
2540 /// 1. Start from a fresh root config that uses DeepSeek defaults.
2541 /// 2. Mutate it through the same key-value setters the
2542 /// `codewhale config set providers.moonshot.*` CLI invokes.
2543 /// 3. Switch the active provider through `CODEWHALE_PROVIDER` —
2544 /// the public env alias — without ever touching the legacy
2545 /// `DEEPSEEK_PROVIDER` name.
2546 /// 4. Resolve the runtime and confirm the doctor/runtime values.
2547 ///
2548 /// No real API key is required; the `api_key` here is just a
2549 /// non-empty placeholder.
2550 #[test]
2551 fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> {
2552 let _lock = env_lock();
2553 let _env = EnvGuard::without_deepseek_runtime_overrides();
2554
2555 let mut config = ConfigToml {
2556 provider: ProviderKind::Deepseek,
2557 default_text_model: Some("deepseek-v4-pro".to_string()),
2558 ..ConfigToml::default()
2559 };
2560
2561 // Same key paths a user would run via `codewhale config set`.
2562 config.set_value("providers.moonshot.api_key", "kimi-code-key-placeholder")?;
2563 config.set_value("providers.moonshot.auth_mode", "api_key")?;
2564 config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
2565 config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
2566
2567 // Public env alias for the active-provider switch.
2568 // Safety: test-only env mutation guarded by env_lock().
2569 unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") };
2570
2571 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2572
2573 assert_eq!(resolved.provider, ProviderKind::Moonshot);
2574 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
2575 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
2576 assert_eq!(resolved.auth_mode.as_deref(), Some("api_key"));
2577 assert_eq!(
2578 resolved.api_key.as_deref(),
2579 Some("kimi-code-key-placeholder")
2580 );
2581 assert_eq!(
2582 resolved.api_key_source,
2583 Some(RuntimeApiKeySource::ConfigFile)
2584 );
2585 Ok(())
2586 }
2587
2588 #[test]
2589 fn moonshot_provider_config_values_round_trip() -> Result<()> {
2590 let mut config = ConfigToml::default();
2591
2592 config.set_value("providers.moonshot.api_key", "moonshot-secret-value")?;
2593 config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
2594 config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
2595 config.set_value("providers.moonshot.auth_mode", "api_key")?;
2596 config.set_value("providers.moonshot.http_headers", "X-Test=ok")?;
2597
2598 assert_eq!(
2599 config
2600 .get_display_value("providers.moonshot.api_key")
2601 .as_deref(),
2602 Some("moon***alue")
2603 );
2604 assert_eq!(
2605 config.get_value("providers.moonshot.base_url").as_deref(),
2606 Some(DEFAULT_KIMI_CODE_BASE_URL)
2607 );
2608 assert_eq!(
2609 config.get_value("providers.moonshot.model").as_deref(),
2610 Some(DEFAULT_KIMI_CODE_MODEL)
2611 );
2612 assert_eq!(
2613 config.get_value("providers.moonshot.auth_mode").as_deref(),
2614 Some("api_key")
2615 );
2616 assert_eq!(
2617 config
2618 .list_values()
2619 .get("providers.moonshot.api_key")
2620 .map(String::as_str),
2621 Some("moon***alue")
2622 );
2623
2624 config.unset_value("providers.moonshot.auth_mode")?;
2625 config.unset_value("providers.moonshot.base_url")?;
2626 config.unset_value("providers.moonshot.model")?;
2627
2628 assert_eq!(config.get_value("providers.moonshot.auth_mode"), None);
2629 assert_eq!(config.get_value("providers.moonshot.base_url"), None);
2630 assert_eq!(config.get_value("providers.moonshot.model"), None);
2631 Ok(())
2632 }
2633
2634 #[test]
2635 fn custom_provider_config_set_get_unset_round_trip() -> Result<()> {
2636 let mut config = ConfigToml::default();
2637
2638 // The owner flow from #5174: build an Alibaba Model Studio custom
2639 // provider entirely through `config set providers.<id>.<field>`.
2640 config.set_value("providers.alibaba_studio.kind", "openai-compatible")?;
2641 config.set_value(
2642 "providers.alibaba_studio.base_url",
2643 "https://coding.dashscope.aliyuncs.com/v1",
2644 )?;
2645 config.set_value("providers.alibaba_studio.model", "qwen3-coder-plus")?;
2646 config.set_value("providers.alibaba_studio.api_key", "sk-studio-secret")?;
2647 config.set_value("providers.alibaba_studio.context_window", "256000")?;
2648
2649 assert_eq!(
2650 config.get_value("providers.alibaba_studio.kind").as_deref(),
2651 Some("openai-compatible")
2652 );
2653 assert_eq!(
2654 config
2655 .get_value("providers.alibaba_studio.base_url")
2656 .as_deref(),
2657 Some("https://coding.dashscope.aliyuncs.com/v1")
2658 );
2659 assert_eq!(
2660 config
2661 .get_value("providers.alibaba_studio.model")
2662 .as_deref(),
2663 Some("qwen3-coder-plus")
2664 );
2665 assert_eq!(
2666 config
2667 .get_value("providers.alibaba_studio.context_window")
2668 .as_deref(),
2669 Some("256000")
2670 );
2671 assert_eq!(
2672 config
2673 .get_display_value("providers.alibaba_studio.api_key")
2674 .as_deref(),
2675 Some("********")
2676 );
2677
2678 // The set must land in a real `[providers.alibaba_studio]` table, never
2679 // in a literal top-level extras key (#5167).
2680 let serialized = toml::to_string(&config)?;
2681 assert!(
2682 serialized.contains("[providers.alibaba_studio]"),
2683 "custom provider legs must serialize as a providers table, got:\n{serialized}"
2684 );
2685 assert!(
2686 config
2687 .extras
2688 .keys()
2689 .all(|key| !key.starts_with("providers.")),
2690 "no literal 'providers.*' extras key may round-trip: {:?}",
2691 config.extras.keys().collect::<Vec<_>>()
2692 );
2693
2694 // The table must satisfy the runtime binding contract for named custom
2695 // providers.
2696 config.set_value("provider", "alibaba_studio")?;
2697 assert_eq!(config.provider_id(), "alibaba_studio");
2698
2699 config.unset_value("providers.alibaba_studio.api_key")?;
2700 assert_eq!(config.get_value("providers.alibaba_studio.api_key"), None);
2701 assert_eq!(
2702 config
2703 .get_value("providers.alibaba_studio.model")
2704 .as_deref(),
2705 Some("qwen3-coder-plus")
2706 );
2707 Ok(())
2708 }
2709
2710 #[test]
2711 fn custom_provider_set_rejects_unknown_field_with_corrective_error() {
2712 let mut config = ConfigToml::default();
2713
2714 let err = config
2715 .set_value("providers.alibaba_studio.bogus", "x")
2716 .expect_err("unknown custom provider fields must not fall into extras");
2717 let message = format!("{err:#}");
2718 assert!(
2719 message.contains("bogus") && message.contains("base_url"),
2720 "error must name the bad leg and the valid shape, got: {message}"
2721 );
2722 assert!(config.providers.extras.is_empty());
2723 }
2724
2725 #[test]
2726 fn model_context_windows_set_get_unset_round_trip() -> Result<()> {
2727 let mut config = ConfigToml::default();
2728
2729 // Built-in provider, bare wire id — plus slash and dotted spellings,
2730 // which stay intact because the model leg is the whole remainder.
2731 config.set_value("providers.moonshot.model_context_windows.k3", "262144")?;
2732 config.set_value(
2733 "providers.moonshot.model_context_windows.MiniMaxAI/MiniMax-M2.5",
2734 "204800",
2735 )?;
2736 config.set_value(
2737 "providers.openrouter.model_context_windows.qwen3.5-flash",
2738 "131072",
2739 )?;
2740
2741 assert_eq!(
2742 config
2743 .get_value("providers.moonshot.model_context_windows.k3")
2744 .as_deref(),
2745 Some("262144")
2746 );
2747 assert_eq!(
2748 config
2749 .get_value("providers.moonshot.model_context_windows.MiniMaxAI/MiniMax-M2.5")
2750 .as_deref(),
2751 Some("204800")
2752 );
2753 assert_eq!(
2754 config
2755 .get_value("providers.openrouter.model_context_windows.qwen3.5-flash")
2756 .as_deref(),
2757 Some("131072")
2758 );
2759
2760 // Per-provider isolation: another provider's table never answers.
2761 assert_eq!(
2762 config.get_value("providers.openai.model_context_windows.k3"),
2763 None
2764 );
2765
2766 // The typed field round-trips through TOML as a real subtable.
2767 let serialized = toml::to_string(&config)?;
2768 assert!(
2769 serialized.contains("model_context_windows"),
2770 "per-model windows must serialize as a providers subtable, got:\n{serialized}"
2771 );
2772 let reparsed: ConfigToml = toml::from_str(&serialized)?;
2773 assert_eq!(
2774 reparsed
2775 .get_value("providers.moonshot.model_context_windows.k3")
2776 .as_deref(),
2777 Some("262144")
2778 );
2779
2780 config.unset_value("providers.moonshot.model_context_windows.k3")?;
2781 assert_eq!(
2782 config.get_value("providers.moonshot.model_context_windows.k3"),
2783 None
2784 );
2785 assert_eq!(
2786 config
2787 .get_value("providers.moonshot.model_context_windows.MiniMaxAI/MiniMax-M2.5")
2788 .as_deref(),
2789 Some("204800")
2790 );
2791 Ok(())
2792 }
2793
2794 #[test]
2795 fn model_context_windows_custom_gateway_round_trip() -> Result<()> {
2796 let mut config = ConfigToml::default();
2797
2798 config.set_value("providers.command_code.kind", "openai-compatible")?;
2799 config.set_value(
2800 "providers.command_code.base_url",
2801 "https://gateway.example/v1",
2802 )?;
2803 config.set_value(
2804 "providers.command_code.model_context_windows.google/gemini-3.1-flash-lite",
2805 "1000000",
2806 )?;
2807
2808 assert_eq!(
2809 config
2810 .get_value("providers.command_code.model_context_windows.google/gemini-3.1-flash-lite")
2811 .as_deref(),
2812 Some("1000000")
2813 );
2814
2815 let serialized = toml::to_string(&config)?;
2816 assert!(
2817 serialized.contains("[providers.command_code.model_context_windows]")
2818 || serialized.contains("model_context_windows"),
2819 "custom provider windows must serialize under the providers table, got:\n{serialized}"
2820 );
2821
2822 config
2823 .unset_value("providers.command_code.model_context_windows.google/gemini-3.1-flash-lite")?;
2824 assert_eq!(
2825 config
2826 .get_value("providers.command_code.model_context_windows.google/gemini-3.1-flash-lite"),
2827 None
2828 );
2829 Ok(())
2830 }
2831
2832 #[test]
2833 fn model_context_windows_rejects_zero_and_bad_model_ids() {
2834 let mut config = ConfigToml::default();
2835
2836 let err = config
2837 .set_value("providers.moonshot.model_context_windows.k3", "0")
2838 .expect_err("zero per-model window must be rejected");
2839 assert!(
2840 format!("{err:#}").contains("greater than 0"),
2841 "unexpected error: {err:#}"
2842 );
2843
2844 let err = config
2845 .set_value("providers.moonshot.model_context_windows.auto", "204800")
2846 .expect_err("`auto` is a selector, not a wire model id");
2847 assert!(
2848 format!("{err:#}").contains("model_context_windows"),
2849 "unexpected error: {err:#}"
2850 );
2851
2852 let err = config
2853 .set_value(
2854 "providers.moonshot.model_context_windows.bad model",
2855 "204800",
2856 )
2857 .expect_err("whitespace model ids must be rejected");
2858 assert!(
2859 format!("{err:#}").contains("model_context_windows"),
2860 "unexpected error: {err:#}"
2861 );
2862
2863 // Rejected writes leave no residue on the typed field.
2864 assert!(
2865 config
2866 .providers
2867 .for_provider(ProviderKind::Moonshot)
2868 .model_context_windows
2869 .is_empty()
2870 );
2871 }
2872
2873 #[test]
2874 fn builtin_provider_set_rejects_unknown_field_with_corrective_error() {
2875 let mut config = ConfigToml::default();
2876
2877 let err = config
2878 .set_value("providers.deepseek.bogus", "x")
2879 .expect_err("unknown built-in provider fields must not fall into extras");
2880 let message = format!("{err:#}");
2881 assert!(
2882 message.contains("bogus") && message.contains("deepseek"),
2883 "error must name the bad leg and provider, got: {message}"
2884 );
2885 assert!(config.providers.extras.is_empty());
2886 }
2887
2888 #[test]
2889 fn siliconflow_cn_provider_config_values_round_trip() -> Result<()> {
2890 let mut config = ConfigToml::default();
2891
2892 config.set_value("providers.siliconflow_cn.api_key", "sf-cn-secret-value")?;
2893 config.set_value(
2894 "providers.siliconflow_cn.base_url",
2895 DEFAULT_SILICONFLOW_CN_BASE_URL,
2896 )?;
2897 config.set_value("providers.siliconflow_cn.model", DEFAULT_SILICONFLOW_MODEL)?;
2898 config.set_value("providers.siliconflow_cn.http_headers", "X-Test=ok")?;
2899
2900 assert_eq!(
2901 config
2902 .get_display_value("providers.siliconflow_cn.api_key")
2903 .as_deref(),
2904 Some("sf-c***alue")
2905 );
2906 assert_eq!(
2907 config
2908 .get_value("providers.siliconflow_cn.base_url")
2909 .as_deref(),
2910 Some(DEFAULT_SILICONFLOW_CN_BASE_URL)
2911 );
2912 assert_eq!(
2913 config
2914 .get_value("providers.siliconflow_cn.model")
2915 .as_deref(),
2916 Some(DEFAULT_SILICONFLOW_MODEL)
2917 );
2918 assert_eq!(
2919 config
2920 .list_values()
2921 .get("providers.siliconflow_cn.api_key")
2922 .map(String::as_str),
2923 Some("sf-c***alue")
2924 );
2925
2926 config.unset_value("providers.siliconflow_cn.api_key")?;
2927 config.unset_value("providers.siliconflow_cn.base_url")?;
2928 config.unset_value("providers.siliconflow_cn.model")?;
2929 config.unset_value("providers.siliconflow_cn.http_headers")?;
2930
2931 assert_eq!(config.get_value("providers.siliconflow_cn.api_key"), None);
2932 assert_eq!(config.get_value("providers.siliconflow_cn.base_url"), None);
2933 assert_eq!(config.get_value("providers.siliconflow_cn.model"), None);
2934 assert_eq!(
2935 config.get_value("providers.siliconflow_cn.http_headers"),
2936 None
2937 );
2938 Ok(())
2939 }
2940
2941 #[test]
2942 fn volcengine_provider_config_values_round_trip() -> Result<()> {
2943 let mut config = ConfigToml::default();
2944
2945 config.set_value("providers.volcengine.api_key", "volcengine-secret-value")?;
2946 config.set_value("providers.volcengine.base_url", DEFAULT_VOLCENGINE_BASE_URL)?;
2947 config.set_value("providers.volcengine.model", DEFAULT_VOLCENGINE_MODEL)?;
2948 config.set_value("providers.volcengine.http_headers", "X-Test=ok")?;
2949
2950 assert_eq!(
2951 config
2952 .get_display_value("providers.volcengine.api_key")
2953 .as_deref(),
2954 Some("volc***alue")
2955 );
2956 assert_eq!(
2957 config.get_value("providers.volcengine.base_url").as_deref(),
2958 Some(DEFAULT_VOLCENGINE_BASE_URL)
2959 );
2960 assert_eq!(
2961 config.get_value("providers.volcengine.model").as_deref(),
2962 Some(DEFAULT_VOLCENGINE_MODEL)
2963 );
2964 assert_eq!(
2965 config
2966 .get_value("providers.volcengine.http_headers")
2967 .as_deref(),
2968 Some("X-Test=ok")
2969 );
2970 assert_eq!(
2971 config
2972 .list_values()
2973 .get("providers.volcengine.http_headers")
2974 .map(String::as_str),
2975 Some("X-Test=ok")
2976 );
2977
2978 config.unset_value("providers.volcengine.http_headers")?;
2979 assert_eq!(config.get_value("providers.volcengine.http_headers"), None);
2980 Ok(())
2981 }
2982
2983 #[test]
2984 fn provider_key_value_api_covers_all_provider_metadata_entries() -> Result<()> {
2985 for provider in ProviderKind::ALL {
2986 let table = provider.provider().provider_config_key();
2987 let mut config = ConfigToml::default();
2988 let api_key = format!("secret-value-for-{table}-123456");
2989 let api_key_path = format!("providers.{table}.api_key");
2990 let base_url_path = format!("providers.{table}.base_url");
2991 let model_path = format!("providers.{table}.model");
2992 let context_window_path = format!("providers.{table}.context_window");
2993 let headers_path = format!("providers.{table}.http_headers");
2994 let mode_path = format!("providers.{table}.mode");
2995 let auth_mode_path = format!("providers.{table}.auth_mode");
2996 let insecure_path = format!("providers.{table}.insecure_skip_tls_verify");
2997 let path_suffix_path = format!("providers.{table}.path_suffix");
2998
2999 config.set_value(&api_key_path, &api_key)?;
3000 config.set_value(&base_url_path, "https://gateway.example/v1")?;
3001 config.set_value(&model_path, "provider-test-model")?;
3002 config.set_value(&context_window_path, "1000000")?;
3003 config.set_value(&headers_path, "X-Test=ok")?;
3004 config.set_value(&mode_path, "concise")?;
3005 config.set_value(&auth_mode_path, "api_key")?;
3006 config.set_value(&insecure_path, "true")?;
3007 config.set_value(&path_suffix_path, "/chat/completions")?;
3008
3009 assert_eq!(
3010 config.get_value(&api_key_path).as_deref(),
3011 Some(api_key.as_str())
3012 );
3013 assert_eq!(
3014 config.get_value(&base_url_path).as_deref(),
3015 Some("https://gateway.example/v1")
3016 );
3017 assert_eq!(
3018 config.get_value(&model_path).as_deref(),
3019 Some("provider-test-model")
3020 );
3021 assert_eq!(
3022 config.get_value(&context_window_path).as_deref(),
3023 Some("1000000")
3024 );
3025 assert_eq!(
3026 config.get_value(&headers_path).as_deref(),
3027 Some("X-Test=ok")
3028 );
3029 assert_eq!(config.get_value(&mode_path).as_deref(), Some("concise"));
3030 assert_eq!(
3031 config.get_value(&auth_mode_path).as_deref(),
3032 Some("api_key")
3033 );
3034 assert_eq!(config.get_value(&insecure_path).as_deref(), Some("true"));
3035 assert_eq!(
3036 config.get_value(&path_suffix_path).as_deref(),
3037 Some("/chat/completions")
3038 );
3039
3040 let listed = config.list_values();
3041 let listed_api_key = listed
3042 .get(&api_key_path)
3043 .expect("provider API key is listed");
3044 assert!(listed_api_key.contains("***"));
3045 assert_ne!(listed_api_key, &api_key);
3046 assert_eq!(
3047 listed.get(&headers_path).map(String::as_str),
3048 Some("X-Test=ok")
3049 );
3050 assert_eq!(
3051 listed.get(&context_window_path).map(String::as_str),
3052 Some("1000000")
3053 );
3054 assert_eq!(listed.get(&insecure_path).map(String::as_str), Some("true"));
3055
3056 config.unset_value(&api_key_path)?;
3057 config.unset_value(&base_url_path)?;
3058 config.unset_value(&model_path)?;
3059 config.unset_value(&context_window_path)?;
3060 config.unset_value(&headers_path)?;
3061 config.unset_value(&mode_path)?;
3062 config.unset_value(&auth_mode_path)?;
3063 config.unset_value(&insecure_path)?;
3064 config.unset_value(&path_suffix_path)?;
3065
3066 assert_eq!(config.get_value(&api_key_path), None);
3067 assert_eq!(config.get_value(&base_url_path), None);
3068 assert_eq!(config.get_value(&model_path), None);
3069 assert_eq!(config.get_value(&context_window_path), None);
3070 assert_eq!(config.get_value(&headers_path), None);
3071 assert_eq!(config.get_value(&mode_path), None);
3072 assert_eq!(config.get_value(&auth_mode_path), None);
3073 assert_eq!(config.get_value(&insecure_path), None);
3074 assert_eq!(config.get_value(&path_suffix_path), None);
3075
3076 if provider == ProviderKind::Deepseek {
3077 assert_eq!(config.default_text_model, None);
3078 assert!(config.http_headers.is_empty());
3079 }
3080 }
3081
3082 Ok(())
3083 }
3084
3085 #[test]
3086 fn provider_context_window_rejects_zero() {
3087 let mut config = ConfigToml::default();
3088 let err = config
3089 .set_value("providers.openai.context_window", "0")
3090 .expect_err("zero context window should be rejected");
3091
3092 assert!(err.to_string().contains("greater than 0"));
3093 }
3094
3095 #[test]
3096 fn list_values_redacts_unicode_api_key_without_byte_slicing() {
3097 let mut config = ConfigToml::default();
3098 config.providers.deepseek.api_key = Some("密钥密钥密钥密钥123456789".to_string());
3099
3100 let values = config.list_values();
3101
3102 assert_eq!(
3103 values.get("providers.deepseek.api_key").map(String::as_str),
3104 Some("密钥密钥***6789")
3105 );
3106 }
3107
3108 #[test]
3109 fn app_homes_prefer_home_env_before_platform_home_fallback() {
3110 let _lock = env_lock();
3111 let home =
3112 std::env::temp_dir().join(format!("codewhale-config-home-env-{}", std::process::id()));
3113 let userprofile = std::env::temp_dir().join(format!(
3114 "codewhale-config-userprofile-{}",
3115 std::process::id()
3116 ));
3117 let _env = StateEnvRestore {
3118 home: env::var_os("HOME"),
3119 userprofile: env::var_os("USERPROFILE"),
3120 codewhale_home: env::var_os("CODEWHALE_HOME"),
3121 };
3122 // Safety: test-only environment mutation is serialized by env_lock().
3123 unsafe {
3124 env::set_var("HOME", &home);
3125 env::set_var("USERPROFILE", &userprofile);
3126 env::remove_var("CODEWHALE_HOME");
3127 }
3128
3129 assert_eq!(
3130 codewhale_home().expect("codewhale home"),
3131 home.join(CODEWHALE_APP_DIR)
3132 );
3133 assert_eq!(
3134 legacy_deepseek_home().expect("legacy home"),
3135 home.join(LEGACY_APP_DIR)
3136 );
3137
3138 let explicit = std::env::temp_dir().join(format!(
3139 "codewhale-config-explicit-home-{}",
3140 std::process::id()
3141 ));
3142 // Safety: test-only environment mutation is serialized by env_lock().
3143 unsafe {
3144 env::set_var("CODEWHALE_HOME", &explicit);
3145 }
3146 assert_eq!(codewhale_home().expect("explicit home"), explicit);
3147 }
3148
3149 #[test]
3150 fn relative_codewhale_home_is_a_hard_error() {
3151 let _lock = env_lock();
3152 let _env = StateEnvRestore {
3153 home: env::var_os("HOME"),
3154 userprofile: env::var_os("USERPROFILE"),
3155 codewhale_home: env::var_os("CODEWHALE_HOME"),
3156 };
3157 // Safety: test-only environment mutation is serialized by env_lock().
3158 unsafe {
3159 env::set_var("CODEWHALE_HOME", ".codewhale");
3160 }
3161
3162 let error = codewhale_home().expect_err("relative global home must fail closed");
3163 let message = format!("{error:#}");
3164 assert!(message.contains("CODEWHALE_HOME"), "{message}");
3165 assert!(message.contains("absolute"), "{message}");
3166 }
3167
3168 /// Audit R04-05: a relative `HOME` must not relocate global state into the
3169 /// working directory.
3170 #[test]
3171 fn relative_user_home_is_a_hard_error() {
3172 let _lock = env_lock();
3173 let _env = StateEnvRestore {
3174 home: env::var_os("HOME"),
3175 userprofile: env::var_os("USERPROFILE"),
3176 codewhale_home: env::var_os("CODEWHALE_HOME"),
3177 };
3178 // Safety: test-only environment mutation is serialized by env_lock().
3179 unsafe {
3180 env::set_var("HOME", "relative-home");
3181 env::remove_var("USERPROFILE");
3182 env::remove_var("CODEWHALE_HOME");
3183 }
3184
3185 assert_eq!(codewhale_paths::user_home(), None);
3186 let error = codewhale_home().expect_err("relative HOME must fail closed");
3187 let message = format!("{error:#}");
3188 assert!(message.contains("HOME"), "{message}");
3189 assert!(message.contains("absolute"), "{message}");
3190 }
3191
3192 #[test]
3193 fn migrate_config_reports_copied_legacy_path() {
3194 let _lock = env_lock();
3195 struct LegacyConfigGuard {
3196 path: PathBuf,
3197 original: Option<Vec<u8>>,
3198 }
3199
3200 impl LegacyConfigGuard {
3201 fn install(path: PathBuf, contents: &[u8]) -> Self {
3202 let original = fs::read(&path).ok();
3203 fs::create_dir_all(path.parent().expect("legacy config parent")).expect("legacy dir");
3204 fs::write(&path, contents).expect("legacy config");
3205 Self { path, original }
3206 }
3207 }
3208
3209 impl Drop for LegacyConfigGuard {
3210 fn drop(&mut self) {
3211 if let Some(original) = self.original.take() {
3212 let _ = fs::write(&self.path, original);
3213 } else {
3214 let _ = fs::remove_file(&self.path);
3215 if let Some(parent) = self.path.parent() {
3216 let _ = fs::remove_dir(parent);
3217 }
3218 }
3219 }
3220 }
3221
3222 let unique = std::time::SystemTime::now()
3223 .duration_since(std::time::UNIX_EPOCH)
3224 .expect("clock")
3225 .as_nanos();
3226 let home = std::env::temp_dir().join(format!(
3227 "codewhale-config-migration-{}-{unique}",
3228 std::process::id()
3229 ));
3230 let legacy_dir = home.join(LEGACY_APP_DIR);
3231 let primary_dir = home.join(CODEWHALE_APP_DIR);
3232 let legacy_config = legacy_dir.join(CONFIG_FILE_NAME);
3233 let _legacy = LegacyConfigGuard::install(legacy_config.clone(), b"provider = \"deepseek\"\n");
3234
3235 let _env = StateEnvRestore {
3236 home: env::var_os("HOME"),
3237 userprofile: env::var_os("USERPROFILE"),
3238 codewhale_home: env::var_os("CODEWHALE_HOME"),
3239 };
3240 // Safety: test-only environment mutation is serialized by env_lock().
3241 unsafe {
3242 env::set_var("HOME", &home);
3243 env::set_var("USERPROFILE", &home);
3244 env::remove_var("CODEWHALE_HOME");
3245 }
3246
3247 let migration = migrate_config_if_needed()
3248 .expect("migration")
3249 .expect("legacy config should be copied");
3250
3251 assert_eq!(migration.legacy_path, legacy_config);
3252 assert_eq!(migration.primary_path, primary_dir.join(CONFIG_FILE_NAME));
3253 let notice = migration.user_notice();
3254 assert!(notice.contains(&legacy_dir.join(CONFIG_FILE_NAME).display().to_string()));
3255 assert!(notice.contains(&primary_dir.join(CONFIG_FILE_NAME).display().to_string()));
3256 assert!(notice.contains(".codewhale path for future edits"));
3257 assert!(notice.contains(".deepseek file remains only as a compatibility fallback"));
3258 assert_eq!(
3259 fs::read_to_string(primary_dir.join(CONFIG_FILE_NAME)).expect("primary config"),
3260 "provider = \"deepseek\"\n"
3261 );
3262
3263 let _ = fs::remove_dir_all(home);
3264 }
3265
3266 #[cfg(unix)]
3267 #[test]
3268 fn migrate_config_refuses_a_dangling_link_at_the_primary_path() {
3269 let _lock = env_lock();
3270 let unique = std::time::SystemTime::now()
3271 .duration_since(std::time::UNIX_EPOCH)
3272 .expect("clock")
3273 .as_nanos();
3274 let home = std::env::temp_dir().join(format!(
3275 "codewhale-config-migration-link-{}-{unique}",
3276 std::process::id()
3277 ));
3278 let legacy_config = home.join(LEGACY_APP_DIR).join(CONFIG_FILE_NAME);
3279 fs::create_dir_all(legacy_config.parent().expect("legacy parent")).expect("legacy dir");
3280 fs::write(&legacy_config, b"provider = \"deepseek\"\n").expect("legacy config");
3281 let primary = home.join(CODEWHALE_APP_DIR).join(CONFIG_FILE_NAME);
3282 fs::create_dir_all(primary.parent().expect("primary parent")).expect("primary dir");
3283 let target = home.join("elsewhere").join("created-through-the-link");
3284 fs::create_dir_all(target.parent().expect("target parent")).expect("target dir");
3285 std::os::unix::fs::symlink(&target, &primary).expect("plant dangling link");
3286
3287 let _env = StateEnvRestore {
3288 home: env::var_os("HOME"),
3289 userprofile: env::var_os("USERPROFILE"),
3290 codewhale_home: env::var_os("CODEWHALE_HOME"),
3291 };
3292 // Safety: test-only environment mutation is serialized by env_lock().
3293 unsafe {
3294 env::set_var("HOME", &home);
3295 env::set_var("USERPROFILE", &home);
3296 env::remove_var("CODEWHALE_HOME");
3297 }
3298
3299 let error = migrate_config_if_needed().expect_err("a linked primary path is refused");
3300 assert!(format!("{error:#}").contains("symlink"), "{error:#}");
3301 assert!(!target.exists(), "the link target must not be created");
3302 assert!(
3303 fs::symlink_metadata(&primary)
3304 .unwrap()
3305 .file_type()
3306 .is_symlink(),
3307 "the planted link is left as it was"
3308 );
3309
3310 let _ = fs::remove_dir_all(home);
3311 }
3312
3313 #[test]
3314 fn explicit_codewhale_home_bypasses_legacy_config_fallback_and_migration() {
3315 let _lock = env_lock();
3316 let unique = std::time::SystemTime::now()
3317 .duration_since(std::time::UNIX_EPOCH)
3318 .expect("clock")
3319 .as_nanos();
3320 let home = std::env::temp_dir().join(format!(
3321 "codewhale-config-explicit-isolation-{}-{unique}",
3322 std::process::id()
3323 ));
3324 let legacy_config = home.join(LEGACY_APP_DIR).join(CONFIG_FILE_NAME);
3325 fs::create_dir_all(legacy_config.parent().expect("legacy config parent")).expect("legacy dir");
3326 fs::write(&legacy_config, b"provider = \"deepseek\"\n").expect("legacy config");
3327
3328 let explicit_home = home.join("isolated-codewhale");
3329 let _env = StateEnvRestore {
3330 home: env::var_os("HOME"),
3331 userprofile: env::var_os("USERPROFILE"),
3332 codewhale_home: env::var_os("CODEWHALE_HOME"),
3333 };
3334 // Safety: test-only environment mutation is serialized by env_lock().
3335 unsafe {
3336 env::set_var("HOME", &home);
3337 env::set_var("USERPROFILE", &home);
3338 env::set_var("CODEWHALE_HOME", &explicit_home);
3339 }
3340
3341 assert_eq!(
3342 default_config_path().expect("default config path"),
3343 explicit_home.join(CONFIG_FILE_NAME),
3344 "explicit CODEWHALE_HOME must not read ambient legacy config"
3345 );
3346 assert!(
3347 migrate_config_if_needed()
3348 .expect("migration check")
3349 .is_none(),
3350 "explicit CODEWHALE_HOME must not migrate ambient legacy config"
3351 );
3352 assert!(
3353 !explicit_home.join(CONFIG_FILE_NAME).exists(),
3354 "legacy config must not be copied into explicit CODEWHALE_HOME"
3355 );
3356
3357 let _ = fs::remove_dir_all(home);
3358 }
3359
3360 // ── ensure_state_dir legacy migration (#3240) ───────────────────────
3361
3362 /// Saves and restores the env vars that the state-resolvers read.
3363 struct StateEnvRestore {
3364 home: Option<OsString>,
3365 userprofile: Option<OsString>,
3366 codewhale_home: Option<OsString>,
3367 }
3368
3369 impl Drop for StateEnvRestore {
3370 fn drop(&mut self) {
3371 // Safety: test-only environment mutation is serialized by env_lock().
3372 unsafe {
3373 match self.home.take() {
3374 Some(value) => env::set_var("HOME", value),
3375 None => env::remove_var("HOME"),
3376 }
3377 match self.userprofile.take() {
3378 Some(value) => env::set_var("USERPROFILE", value),
3379 None => env::remove_var("USERPROFILE"),
3380 }
3381 match self.codewhale_home.take() {
3382 Some(value) => env::set_var("CODEWHALE_HOME", value),
3383 None => env::remove_var("CODEWHALE_HOME"),
3384 }
3385 }
3386 }
3387 }
3388
3389 /// Points `HOME`/`USERPROFILE` at a fresh temp tree and clears
3390 /// `CODEWHALE_HOME` so `codewhale_home()` -> `<home>/.codewhale` and
3391 /// `legacy_deepseek_home()` -> `<home>/.deepseek`. Env is restored on drop.
3392 struct StateDirEnv {
3393 home: PathBuf,
3394 _restore: StateEnvRestore,
3395 }
3396
3397 impl StateDirEnv {
3398 fn install(unique: u128) -> Self {
3399 let home = std::env::temp_dir().join(format!(
3400 "codewhale-state-migration-{}-{unique}",
3401 std::process::id()
3402 ));
3403 let restore = StateEnvRestore {
3404 home: env::var_os("HOME"),
3405 userprofile: env::var_os("USERPROFILE"),
3406 codewhale_home: env::var_os("CODEWHALE_HOME"),
3407 };
3408 // Safety: test-only environment mutation is serialized by env_lock().
3409 unsafe {
3410 env::set_var("HOME", &home);
3411 env::set_var("USERPROFILE", &home);
3412 env::remove_var("CODEWHALE_HOME");
3413 }
3414 Self {
3415 home,
3416 _restore: restore,
3417 }
3418 }
3419 fn legacy(&self, sub: &str) -> PathBuf {
3420 self.home.join(LEGACY_APP_DIR).join(sub)
3421 }
3422 fn primary(&self, sub: &str) -> PathBuf {
3423 self.home.join(CODEWHALE_APP_DIR).join(sub)
3424 }
3425 }
3426
3427 #[test]
3428 fn ensure_state_dir_relocates_legacy_subdir_on_first_write() {
3429 let _lock = env_lock();
3430 let unique = std::time::SystemTime::now()
3431 .duration_since(std::time::UNIX_EPOCH)
3432 .expect("clock")
3433 .as_nanos();
3434 let state_env = StateDirEnv::install(unique);
3435 // Seed a legacy subdir; primary must not exist yet.
3436 fs::create_dir_all(state_env.legacy("slop_ledger")).expect("legacy dir");
3437 fs::write(
3438 state_env.legacy("slop_ledger").join("slop_ledger.json"),
3439 b"legacy",
3440 )
3441 .expect("legacy file");
3442 assert!(!state_env.primary("slop_ledger").exists());
3443
3444 let (dir, migration) =
3445 ensure_state_dir_with_migration("slop_ledger").expect("ensure_state_dir");
3446 assert_eq!(dir, state_env.primary("slop_ledger"));
3447 let migration = migration.expect("legacy migration should be reported");
3448 assert_eq!(migration.kind, StateMigrationKind::Relocated);
3449 assert_eq!(migration.subdir, "slop_ledger");
3450 assert_eq!(migration.legacy_path, state_env.legacy("slop_ledger"));
3451 assert_eq!(migration.primary_path, state_env.primary("slop_ledger"));
3452 // Legacy contents relocated into primary.
3453 assert_eq!(
3454 fs::read_to_string(state_env.primary("slop_ledger").join("slop_ledger.json"))
3455 .expect("migrated file"),
3456 "legacy"
3457 );
3458 // The legacy subdir was relocated (moved), so .deepseek stops growing.
3459 assert!(
3460 !state_env.legacy("slop_ledger").exists(),
3461 "legacy subdir should be removed after relocation"
3462 );
3463 // Idempotent: a second call is a no-op now that primary exists.
3464 let (_, repeated_migration) =
3465 ensure_state_dir_with_migration("slop_ledger").expect("idempotent ensure");
3466 assert!(repeated_migration.is_none());
3467 let _ = fs::remove_dir_all(&state_env.home);
3468 }
3469
3470 #[test]
3471 fn state_migration_notice_explains_preserved_data_and_canonical_root() {
3472 let migration = StateMigration {
3473 subdir: "sessions".to_string(),
3474 legacy_path: PathBuf::from("/home/alice/.deepseek/sessions"),
3475 primary_path: PathBuf::from("/home/alice/.codewhale/sessions"),
3476 kind: StateMigrationKind::Relocated,
3477 };
3478
3479 let notice = migration.user_notice();
3480
3481 assert!(notice.contains("Codewhale migrated legacy state"));
3482 assert!(notice.contains("/home/alice/.deepseek/sessions"));
3483 assert!(notice.contains("/home/alice/.codewhale/sessions"));
3484 assert!(notice.contains("Your data was preserved"));
3485 assert!(notice.contains("Use .codewhale as the canonical state location"));
3486 assert!(notice.contains("remove the legacy .deepseek tree"));
3487 }
3488
3489 #[test]
3490 fn copied_state_migration_notice_says_legacy_copy_remains() {
3491 let migration = StateMigration {
3492 subdir: "catalog".to_string(),
3493 legacy_path: PathBuf::from("/home/alice/.deepseek/catalog"),
3494 primary_path: PathBuf::from("/home/alice/.codewhale/catalog"),
3495 kind: StateMigrationKind::Copied,
3496 };
3497
3498 let notice = migration.user_notice();
3499
3500 assert!(notice.contains("copied"));
3501 assert!(notice.contains("legacy .deepseek copy was left in place"));
3502 }
3503
3504 #[test]
3505 fn ensure_state_dir_writes_to_primary_when_both_exist() {
3506 let _lock = env_lock();
3507 let unique = std::time::SystemTime::now()
3508 .duration_since(std::time::UNIX_EPOCH)
3509 .expect("clock")
3510 .as_nanos();
3511 let state_env = StateDirEnv::install(unique);
3512 // Migrated user: primary already exists; a legacy orphan also remains.
3513 fs::create_dir_all(state_env.primary("sessions")).expect("primary dir");
3514 fs::write(state_env.primary("sessions").join("a.json"), b"primary").expect("primary file");
3515 fs::create_dir_all(state_env.legacy("sessions")).expect("legacy dir");
3516 fs::write(state_env.legacy("sessions").join("old.json"), b"legacy").expect("legacy file");
3517
3518 let (dir, migration) = ensure_state_dir_with_migration("sessions").expect("ensure_state_dir");
3519 assert_eq!(dir, state_env.primary("sessions"));
3520 assert!(
3521 migration.is_none(),
3522 "existing primary must not emit a migration event"
3523 );
3524 // Primary untouched; legacy orphan left as-is (not migrated, not deleted).
3525 assert_eq!(
3526 fs::read_to_string(state_env.primary("sessions").join("a.json")).expect("primary"),
3527 "primary"
3528 );
3529 assert!(
3530 state_env.legacy("sessions").exists(),
3531 "existing legacy orphan must not be deleted when primary exists"
3532 );
3533 let _ = fs::remove_dir_all(&state_env.home);
3534 }
3535
3536 #[test]
3537 fn resolve_state_dir_still_finds_legacy_for_backfill() {
3538 let _lock = env_lock();
3539 let unique = std::time::SystemTime::now()
3540 .duration_since(std::time::UNIX_EPOCH)
3541 .expect("clock")
3542 .as_nanos();
3543 let state_env = StateDirEnv::install(unique);
3544 // Only legacy exists -> read resolver returns legacy (backfill).
3545 fs::create_dir_all(state_env.legacy("catalog")).expect("legacy dir");
3546 assert_eq!(
3547 resolve_state_dir("catalog").expect("resolve"),
3548 state_env.legacy("catalog")
3549 );
3550 // After the primary is created (e.g. via a write), the read resolver
3551 // returns primary — legacy is reachable only while primary is absent.
3552 ensure_state_dir("catalog").expect("ensure");
3553 assert_eq!(
3554 resolve_state_dir("catalog").expect("resolve after migrate"),
3555 state_env.primary("catalog")
3556 );
3557 let _ = fs::remove_dir_all(&state_env.home);
3558 }
3559
3560 #[test]
3561 fn explicit_codewhale_home_bypasses_legacy_state_fallback_and_migration() {
3562 let _lock = env_lock();
3563 let unique = std::time::SystemTime::now()
3564 .duration_since(std::time::UNIX_EPOCH)
3565 .expect("clock")
3566 .as_nanos();
3567 let state_env = StateDirEnv::install(unique);
3568 let explicit_home = state_env.home.join("isolated-codewhale");
3569 // Safety: test-only environment mutation is serialized by env_lock().
3570 unsafe {
3571 env::set_var("CODEWHALE_HOME", &explicit_home);
3572 }
3573 fs::create_dir_all(state_env.legacy("catalog")).expect("legacy dir");
3574 fs::write(state_env.legacy("catalog").join("legacy.json"), b"legacy").expect("legacy file");
3575
3576 let primary = explicit_home.join("catalog");
3577 assert_eq!(
3578 resolve_state_dir("catalog").expect("resolve"),
3579 primary,
3580 "explicit CODEWHALE_HOME must not read ambient legacy state"
3581 );
3582
3583 let ensured = ensure_state_dir("catalog").expect("ensure");
3584 assert_eq!(ensured, primary);
3585 assert!(
3586 state_env.legacy("catalog").join("legacy.json").exists(),
3587 "explicit CODEWHALE_HOME must not migrate ambient legacy state"
3588 );
3589 assert!(
3590 !primary.join("legacy.json").exists(),
3591 "legacy contents must not be copied into an explicit CODEWHALE_HOME"
3592 );
3593 let _ = fs::remove_dir_all(&state_env.home);
3594 }
3595
3596 #[test]
3597 fn state_resolvers_reject_path_traversal_subdirs() {
3598 // Defense against path injection (#3240 hardening): the public state
3599 // resolvers must refuse subdirs that could escape the state root.
3600 for bad in ["..", "../secret", "/etc", "a/../../b"] {
3601 let err = ensure_state_dir(bad)
3602 .err()
3603 .unwrap_or_else(|| panic!("expected {bad:?} to be rejected"));
3604 assert!(
3605 format!("{err:#}").contains("state subdir"),
3606 "expected rejection of {bad:?}, got {err:#}"
3607 );
3608 assert!(
3609 resolve_state_dir(bad).is_err(),
3610 "read resolver must also reject {bad:?}"
3611 );
3612 }
3613 // Safe values are accepted (including the root sentinel ".").
3614 assert!(ensure_safe_state_subdir(".").is_ok());
3615 assert!(ensure_safe_state_subdir("sessions").is_ok());
3616 assert!(ensure_safe_state_subdir("a/b").is_ok());
3617 assert!(ensure_safe_state_subdir("").is_err());
3618 }
3619
3620 #[test]
3621 fn project_state_resolvers_reject_path_traversal_subdirs() {
3622 let dir = tempfile::tempdir().expect("tempdir");
3623 let workspace = dir.path().join("workspace");
3624 fs::create_dir_all(&workspace).expect("workspace");
3625
3626 for bad in ["..", "../secret", "/etc", "a/../../b"] {
3627 let err = resolve_project_state_dir(&workspace, bad)
3628 .err()
3629 .unwrap_or_else(|| panic!("expected {bad:?} to be rejected"));
3630 assert!(
3631 format!("{err:#}").contains("state subdir"),
3632 "expected rejection of {bad:?}, got {err:#}"
3633 );
3634 assert!(
3635 ensure_project_state_dir(&workspace, bad).is_err(),
3636 "write resolver must also reject {bad:?}"
3637 );
3638 }
3639
3640 let canonical_workspace = workspace.canonicalize().expect("canonical workspace");
3641 let safe = resolve_project_state_dir(&workspace, "notes.md")
3642 .expect("safe project state subdir should resolve")
3643 .1;
3644 assert_eq!(
3645 safe,
3646 canonical_workspace.join(LEGACY_APP_DIR).join("notes.md")
3647 );
3648 let created =
3649 ensure_project_state_dir(&workspace, "a/b").expect("safe nested project state dir");
3650 assert_eq!(
3651 created,
3652 canonical_workspace.join(CODEWHALE_APP_DIR).join("a/b")
3653 );
3654 }
3655
3656 #[test]
3657 fn project_state_resolvers_reject_workspace_traversal() {
3658 let dir = tempfile::tempdir().expect("tempdir");
3659 let workspace = dir.path().join("workspace");
3660 fs::create_dir_all(&workspace).expect("workspace");
3661 let bad_workspace = workspace.join("..").join("outside");
3662
3663 let err = resolve_project_state_dir(&bad_workspace, "notes.md")
3664 .expect_err("workspace traversal should fail");
3665 assert!(format!("{err:#}").contains("project workspace path"));
3666 assert!(ensure_project_state_dir(&bad_workspace, "state").is_err());
3667 }
3668
3669 #[test]
3670 fn normalize_config_file_path_rejects_traversal() {
3671 let err = normalize_config_file_path(PathBuf::from("../config.toml"))
3672 .expect_err("traversal path should fail");
3673 assert!(format!("{err:#}").contains("cannot contain '..'"));
3674 }
3675
3676 #[test]
3677 fn config_store_save_revalidates_path_before_parent_creation() {
3678 let dir = tempfile::tempdir().expect("tempdir");
3679 let outside_dir = dir.path().join("outside");
3680 let traversal_path = dir
3681 .path()
3682 .join("allowed")
3683 .join("..")
3684 .join("outside")
3685 .join(CONFIG_FILE_NAME);
3686 let mut store = ConfigStore {
3687 path: traversal_path,
3688 config: ConfigToml::default(),
3689 permissions: PermissionsToml::default(),
3690 original_raw: None,
3691 legacy_root: Default::default(),
3692 };
3693
3694 let err = store
3695 .save()
3696 .expect_err("save should reject traversal before creating parents");
3697
3698 assert!(format!("{err:#}").contains("cannot contain '..'"));
3699 assert!(
3700 !outside_dir.exists(),
3701 "save must not create directories from an unvalidated path"
3702 );
3703 }
3704
3705 #[test]
3706 fn resolve_config_path_rejects_relative_env_path_before_cwd_resolution() {
3707 let _lock = env_lock();
3708 struct ConfigPathEnvGuard {
3709 codewhale: Option<OsString>,
3710 deepseek: Option<OsString>,
3711 }
3712 impl Drop for ConfigPathEnvGuard {
3713 fn drop(&mut self) {
3714 // Safety: test-only environment mutation is serialized by env_lock().
3715 unsafe {
3716 match self.codewhale.as_ref() {
3717 Some(value) => env::set_var("CODEWHALE_CONFIG_PATH", value),
3718 None => env::remove_var("CODEWHALE_CONFIG_PATH"),
3719 }
3720 match self.deepseek.as_ref() {
3721 Some(value) => env::set_var("DEEPSEEK_CONFIG_PATH", value),
3722 None => env::remove_var("DEEPSEEK_CONFIG_PATH"),
3723 }
3724 }
3725 }
3726 }
3727 let _guard = ConfigPathEnvGuard {
3728 codewhale: env::var_os("CODEWHALE_CONFIG_PATH"),
3729 deepseek: env::var_os("DEEPSEEK_CONFIG_PATH"),
3730 };
3731
3732 // Safety: test-only environment mutation is serialized by env_lock().
3733 unsafe {
3734 env::set_var("CODEWHALE_CONFIG_PATH", "../config.toml");
3735 env::remove_var("DEEPSEEK_CONFIG_PATH");
3736 }
3737
3738 let err = resolve_config_path(None).expect_err("relative env path should fail");
3739 let message = format!("{err:#}");
3740 assert!(message.contains("CODEWHALE_CONFIG_PATH"), "{message}");
3741 assert!(message.contains("absolute"), "{message}");
3742 }
3743
3744 #[cfg(unix)]
3745 #[test]
3746 fn normalize_config_file_path_rejects_symlink_file() {
3747 let dir = tempfile::tempdir().expect("tempdir");
3748 let target = dir.path().join("target.toml");
3749 let link = dir.path().join(CONFIG_FILE_NAME);
3750 fs::write(&target, "model = \"deepseek-v4-flash\"\n").expect("write target");
3751 std::os::unix::fs::symlink(&target, &link).expect("symlink config");
3752
3753 let err = normalize_config_file_path(link).expect_err("symlink config should fail");
3754 assert!(format!("{err:#}").contains("must not be a symlink"));
3755 }
3756
3757 #[cfg(unix)]
3758 #[test]
3759 fn load_project_config_rejects_symlinked_primary_config() {
3760 let workspace = tempfile::tempdir().expect("workspace tempdir");
3761 let outside = tempfile::tempdir().expect("outside tempdir");
3762 let primary_dir = workspace.path().join(CODEWHALE_APP_DIR);
3763 let legacy_dir = workspace.path().join(LEGACY_APP_DIR);
3764 fs::create_dir_all(&primary_dir).expect("mkdir primary");
3765 fs::create_dir_all(&legacy_dir).expect("mkdir legacy");
3766 let outside_config = outside.path().join(CONFIG_FILE_NAME);
3767 fs::write(&outside_config, "model = \"outside-model\"\n").expect("write outside config");
3768 fs::write(
3769 legacy_dir.join(CONFIG_FILE_NAME),
3770 "model = \"legacy-model\"\n",
3771 )
3772 .expect("write legacy config");
3773 std::os::unix::fs::symlink(&outside_config, primary_dir.join(CONFIG_FILE_NAME))
3774 .expect("symlink project config");
3775
3776 let loaded = load_project_config(workspace.path());
3777
3778 assert!(
3779 loaded.is_none(),
3780 "symlinked primary project config should stop the project overlay"
3781 );
3782 }
3783
3784 #[test]
3785 fn load_project_config_keeps_unknown_provider_names_strict() {
3786 let workspace = tempfile::tempdir().expect("workspace tempdir");
3787 let config_dir = workspace.path().join(CODEWHALE_APP_DIR);
3788 fs::create_dir_all(&config_dir).expect("mkdir project config");
3789 fs::write(
3790 config_dir.join(CONFIG_FILE_NAME),
3791 r#"provider = "acme_zen_gateway"
3792 model = "must-not-apply"
3793
3794 [providers.acme_zen_gateway]
3795 kind = "openai-compatible"
3796 base_url = "https://acme.example/v1"
3797 "#,
3798 )
3799 .expect("write project config");
3800
3801 assert!(
3802 load_project_config(workspace.path()).is_none(),
3803 "project overlays must not gain named-provider authority"
3804 );
3805 // ...and the reason is available, not just the absence.
3806 let (path, reason) = load_project_config_outcome(workspace.path())
3807 .invalid()
3808 .map(|(path, reason)| (path.to_path_buf(), reason.to_string()))
3809 .expect("unknown provider must report why the config was rejected");
3810 assert!(path.ends_with(CONFIG_FILE_NAME), "{path:?}");
3811 assert!(reason.contains("acme_zen_gateway"), "{reason}");
3812 }
3813
3814 #[test]
3815 fn project_config_unknown_provider_reason_never_echoes_a_pasted_key() {
3816 let workspace = tempfile::tempdir().expect("workspace tempdir");
3817 let config_dir = workspace.path().join(CODEWHALE_APP_DIR);
3818 fs::create_dir_all(&config_dir).expect("mkdir project config");
3819 let key = ["sk-live-", "Z7qX4mNb2Vc9Lk3PwR8t"].concat();
3820 fs::write(
3821 config_dir.join(CONFIG_FILE_NAME),
3822 format!("provider = \"{key}\"\n"),
3823 )
3824 .expect("write project config");
3825
3826 let reason = load_project_config_outcome(workspace.path())
3827 .invalid()
3828 .map(|(_, reason)| reason.to_string())
3829 .expect("unknown provider is rejected");
3830 assert!(reason.starts_with("unknown provider"), "{reason}");
3831 assert!(!reason.contains(&key), "{reason}");
3832 }
3833
3834 #[test]
3835 fn malformed_project_config_is_distinguishable_from_a_missing_one() {
3836 // #4733: the loader returned `None` for both cases. A project config can
3837 // only *tighten* approval/sandbox posture, so reporting a broken file as
3838 // "no project config" silently drops those restrictions and falls back to
3839 // the user's more permissive baseline.
3840 let workspace = tempfile::tempdir().expect("workspace tempdir");
3841 assert!(
3842 matches!(
3843 load_project_config_outcome(workspace.path()),
3844 ProjectConfigOutcome::Missing
3845 ),
3846 "a workspace with no project config must report Missing"
3847 );
3848
3849 let config_dir = workspace.path().join(CODEWHALE_APP_DIR);
3850 fs::create_dir_all(&config_dir).expect("mkdir project config");
3851 fs::write(
3852 config_dir.join(CONFIG_FILE_NAME),
3853 "approval_policy = \"unless-trusted\"\nthis is not valid toml\n",
3854 )
3855 .expect("write malformed project config");
3856
3857 let outcome = load_project_config_outcome(workspace.path());
3858 let (path, reason) = outcome
3859 .invalid()
3860 .expect("a malformed project config must not read as Missing");
3861 assert!(path.ends_with(CONFIG_FILE_NAME), "{path:?}");
3862 assert!(!reason.is_empty(), "the rejection must carry a reason");
3863 // The reason must not leak the file's contents — config files hold
3864 // credentials.
3865 assert!(
3866 !reason.contains("approval_policy"),
3867 "reason must not echo file contents: {reason}"
3868 );
3869
3870 // The lossy convenience wrapper still behaves as before for callers that
3871 // only want the config.
3872 assert!(load_project_config(workspace.path()).is_none());
3873 }
3874
3875 #[test]
3876 fn well_formed_project_config_still_loads() {
3877 let workspace = tempfile::tempdir().expect("workspace tempdir");
3878 let config_dir = workspace.path().join(CODEWHALE_APP_DIR);
3879 fs::create_dir_all(&config_dir).expect("mkdir project config");
3880 fs::write(
3881 config_dir.join(CONFIG_FILE_NAME),
3882 "approval_policy = \"unless-trusted\"\n",
3883 )
3884 .expect("write project config");
3885
3886 let outcome = load_project_config_outcome(workspace.path());
3887 assert!(outcome.invalid().is_none(), "{outcome:?}");
3888 assert_eq!(
3889 outcome
3890 .into_config()
3891 .and_then(|config| config.approval_policy)
3892 .as_deref(),
3893 Some("unless-trusted"),
3894 );
3895 }
3896
3897 #[cfg(unix)]
3898 #[test]
3899 fn load_sibling_permissions_rejects_symlink_file() {
3900 let dir = tempfile::tempdir().expect("tempdir");
3901 let config_path = dir.path().join(CONFIG_FILE_NAME);
3902 let outside = dir.path().join("outside-permissions.toml");
3903 let permissions_link = dir.path().join(PERMISSIONS_FILE_NAME);
3904 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
3905 fs::write(&outside, "").expect("write outside permissions");
3906 std::os::unix::fs::symlink(&outside, &permissions_link).expect("symlink permissions");
3907
3908 let err = load_sibling_permissions(&config_path).expect_err("symlink permissions should fail");
3909 assert!(format!("{err:#}").contains("must not be a symlink"));
3910 }
3911
3912 #[cfg(unix)]
3913 #[test]
3914 fn append_ask_rules_rejects_symlinked_permissions_file() {
3915 let dir = tempfile::tempdir().expect("tempdir");
3916 let config_path = dir.path().join(CONFIG_FILE_NAME);
3917 let outside = dir.path().join("outside-permissions.toml");
3918 let permissions_link = dir.path().join(PERMISSIONS_FILE_NAME);
3919 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
3920 fs::write(&outside, "").expect("write outside permissions");
3921 let mut store = ConfigStore::load(Some(config_path)).expect("load store before link");
3922 std::os::unix::fs::symlink(&outside, &permissions_link).expect("symlink permissions");
3923
3924 let err = store
3925 .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")])
3926 .expect_err("symlink permissions should fail");
3927
3928 assert!(format!("{err:#}").contains("must not be a symlink"));
3929 assert_eq!(
3930 fs::read_to_string(&outside).expect("read outside permissions"),
3931 ""
3932 );
3933 }
3934
3935 #[cfg(unix)]
3936 #[test]
3937 fn remove_permission_rule_rejects_symlinked_permissions_file() {
3938 let dir = tempfile::tempdir().expect("tempdir");
3939 let config_path = dir.path().join(CONFIG_FILE_NAME);
3940 let outside = dir.path().join("outside-permissions.toml");
3941 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
3942 fs::write(
3943 &permissions_path,
3944 "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo test\"\n",
3945 )
3946 .expect("write permissions");
3947 let snapshot =
3948 load_permissions_snapshot(Some(config_path.clone())).expect("load permission snapshot");
3949 let token = snapshot
3950 .removal_token(0)
3951 .expect("first rule token")
3952 .to_string();
3953 fs::rename(&permissions_path, &outside).expect("move permissions outside");
3954 std::os::unix::fs::symlink(&outside, &permissions_path).expect("symlink permissions");
3955
3956 let err = remove_permission_rule(Some(config_path), 0, &token)
3957 .expect_err("symlink permissions should fail");
3958
3959 assert!(format!("{err:#}").contains("must not be a symlink"));
3960 assert!(
3961 fs::read_to_string(&outside)
3962 .expect("read outside permissions")
3963 .contains("cargo test")
3964 );
3965 }
3966
3967 #[cfg(unix)]
3968 #[test]
3969 fn write_config_backup_rejects_symlink_file() {
3970 let dir = tempfile::tempdir().expect("tempdir");
3971 let config_path = dir.path().join(CONFIG_FILE_NAME);
3972 let outside = dir.path().join("outside-backup.toml");
3973 let backup_link = config_backup_path(&config_path);
3974 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
3975 fs::write(&outside, "").expect("write outside backup");
3976 std::os::unix::fs::symlink(&outside, &backup_link).expect("symlink backup");
3977
3978 let err = write_one_time_config_backup(&config_path).expect_err("symlink backup should fail");
3979 assert!(format!("{err:#}").contains("must not be a symlink"));
3980 }
3981
3982 #[cfg(unix)]
3983 #[test]
3984 fn save_clamps_existing_config_permissions() {
3985 use std::time::{SystemTime, UNIX_EPOCH};
3986
3987 let unique = SystemTime::now()
3988 .duration_since(UNIX_EPOCH)
3989 .expect("clock")
3990 .as_nanos();
3991 let dir = std::env::temp_dir().join(format!(
3992 "deepseek-config-perms-{}-{unique}",
3993 std::process::id()
3994 ));
3995 fs::create_dir_all(&dir).expect("mkdir");
3996 let path = dir.join(CONFIG_FILE_NAME);
3997 fs::write(&path, "api_key = \"old\"\n").expect("seed config");
3998 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod seed");
3999
4000 let mut store = ConfigStore {
4001 path: path.clone(),
4002 config: {
4003 let mut config = ConfigToml::default();
4004 config.providers.deepseek.api_key = Some("new-secret".to_string());
4005 config
4006 },
4007 permissions: PermissionsToml::default(),
4008 original_raw: Some("api_key = \"old\"\n".to_string()),
4009 legacy_root: Default::default(),
4010 };
4011 store.save().expect("save");
4012
4013 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
4014 assert_eq!(mode, 0o600);
4015
4016 let _ = fs::remove_dir_all(dir);
4017 }
4018
4019 #[test]
4020 fn config_store_save_skips_identical_serialized_body() {
4021 use std::time::{SystemTime, UNIX_EPOCH};
4022
4023 let unique = SystemTime::now()
4024 .duration_since(UNIX_EPOCH)
4025 .expect("clock")
4026 .as_nanos();
4027 let dir = std::env::temp_dir().join(format!(
4028 "codewhale-config-noop-save-{}-{unique}",
4029 std::process::id()
4030 ));
4031 fs::create_dir_all(&dir).expect("mkdir");
4032 let path = dir.join(CONFIG_FILE_NAME);
4033 let config = ConfigToml {
4034 model: Some("deepseek-v4-flash".to_string()),
4035 ..ConfigToml::default()
4036 };
4037 let body = toml::to_string_pretty(&config).expect("serialize");
4038 fs::write(&path, &body).expect("seed config");
4039 #[cfg(unix)]
4040 fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("chmod seed");
4041
4042 let mut store = ConfigStore {
4043 path: path.clone(),
4044 config,
4045 permissions: PermissionsToml::default(),
4046 original_raw: Some(body.clone()),
4047 legacy_root: Default::default(),
4048 };
4049 store.save().expect("identical save should not rewrite");
4050
4051 #[cfg(unix)]
4052 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("chmod restore");
4053 assert_eq!(fs::read_to_string(&path).expect("read config"), body);
4054 assert!(
4055 !config_backup_path(&path).exists(),
4056 "no-op save must not create a migration backup"
4057 );
4058
4059 let _ = fs::remove_dir_all(dir);
4060 }
4061
4062 #[test]
4063 fn config_store_save_creates_one_time_backup_before_changed_write() {
4064 use std::time::{SystemTime, UNIX_EPOCH};
4065
4066 let unique = SystemTime::now()
4067 .duration_since(UNIX_EPOCH)
4068 .expect("clock")
4069 .as_nanos();
4070 let dir = std::env::temp_dir().join(format!(
4071 "codewhale-config-backup-save-{}-{unique}",
4072 std::process::id()
4073 ));
4074 fs::create_dir_all(&dir).expect("mkdir");
4075 let path = dir.join(CONFIG_FILE_NAME);
4076 let original = "model = \"deepseek-v4-flash\"\n";
4077 fs::write(&path, original).expect("seed config");
4078
4079 let mut store = ConfigStore {
4080 path: path.clone(),
4081 config: ConfigToml {
4082 model: Some("deepseek-v4-pro".to_string()),
4083 ..ConfigToml::default()
4084 },
4085 permissions: PermissionsToml::default(),
4086 original_raw: Some(original.to_string()),
4087 legacy_root: Default::default(),
4088 };
4089 store.save().expect("changed save");
4090
4091 let backup_path = config_backup_path(&path);
4092 assert_eq!(
4093 fs::read_to_string(&backup_path).expect("read backup"),
4094 original
4095 );
4096 let updated = fs::read_to_string(&path).expect("read updated config");
4097 assert!(updated.contains("model = \"deepseek-v4-pro\""));
4098
4099 let _ = fs::remove_dir_all(dir);
4100 }
4101
4102 #[test]
4103 fn config_backup_strips_plaintext_api_keys_but_preserves_non_secret_auth_metadata() {
4104 let dir = tempfile::tempdir().expect("tempdir");
4105 let path = dir.path().join(CONFIG_FILE_NAME);
4106 let original = r#"api_key = "root-test-credential"
4107 default_text_model = "deepseek-v4-pro"
4108
4109 [providers.openrouter]
4110 api_key = "provider-test-credential"
4111 api_key_env = "OPENROUTER_API_KEY"
4112 auth_mode = "api_key"
4113 "#;
4114 fs::write(&path, original).expect("seed config");
4115
4116 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
4117 store.config.default_text_model = Some("deepseek-v4-flash".to_string());
4118 store.save().expect("changed save");
4119
4120 let backup = fs::read_to_string(config_backup_path(&path)).expect("read backup");
4121 assert!(!backup.contains("root-test-credential"), "{backup}");
4122 assert!(!backup.contains("provider-test-credential"), "{backup}");
4123 assert!(
4124 !backup
4125 .lines()
4126 .any(|line| line.trim_start().starts_with("api_key ="))
4127 );
4128 assert!(backup.contains("api_key_env = \"OPENROUTER_API_KEY\""));
4129 assert!(backup.contains("auth_mode = \"api_key\""));
4130 assert!(backup.contains("default_text_model = \"deepseek-v4-pro\""));
4131 }
4132
4133 #[test]
4134 fn config_backup_scrub_repairs_an_existing_plaintext_backup() {
4135 let dir = tempfile::tempdir().expect("tempdir");
4136 let path = dir.path().join(CONFIG_FILE_NAME);
4137 fs::write(&path, "model = \"new-model\"\n").expect("seed config");
4138 let backup_path = config_backup_path(&path);
4139 fs::write(
4140 &backup_path,
4141 "api_key = \"old-test-credential\"\nmodel = \"old-model\"\n",
4142 )
4143 .expect("seed backup");
4144
4145 scrub_plaintext_api_keys_from_config_backup(&path).expect("scrub backup");
4146
4147 let backup = fs::read_to_string(backup_path).expect("read backup");
4148 assert!(!backup.contains("old-test-credential"), "{backup}");
4149 assert!(!backup.contains("api_key"), "{backup}");
4150 assert!(backup.contains("model = \"old-model\""));
4151 }
4152
4153 #[test]
4154 fn config_store_save_preserves_comments() {
4155 let dir = tempfile::tempdir().expect("tempdir");
4156 let config_path = dir.path().join(CONFIG_FILE_NAME);
4157 let original = "# my model\nmodel = \"deepseek-v4-flash\"\n# end comment\n";
4158 fs::write(&config_path, original).expect("write config");
4159
4160 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
4161 store.config.model = Some("deepseek-v4-pro".to_string());
4162 store.save().expect("save");
4163
4164 let body = fs::read_to_string(&config_path).expect("read config");
4165 assert!(body.contains("# my model"), "prefix comment preserved");
4166 assert!(body.contains("# end comment"), "suffix comment preserved");
4167 assert!(body.contains("model = \"deepseek-v4-pro\""));
4168 }
4169
4170 #[test]
4171 fn config_store_save_preserves_disabled_keys() {
4172 let dir = tempfile::tempdir().expect("tempdir");
4173 let config_path = dir.path().join(CONFIG_FILE_NAME);
4174 fs::write(
4175 &config_path,
4176 "# my note\nmodel = \"deepseek-v4-flash\"\n# base_url = \"http://localhost:11434/v1\"\n",
4177 )
4178 .expect("write config");
4179
4180 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
4181 store.config.model = Some("deepseek-v4-pro".to_string());
4182 store.save().expect("save");
4183
4184 let body = fs::read_to_string(&config_path).expect("read config");
4185 assert!(
4186 body.contains("# base_url = \"http://localhost:11434/v1\""),
4187 "disabled key preserved as comment"
4188 );
4189 assert!(body.contains("model = \"deepseek-v4-pro\""));
4190 }
4191
4192 #[test]
4193 fn config_store_save_preserves_comments_with_other_keys() {
4194 // Realistic scenario: user already has api_key + model, adds a comment,
4195 // then changes model via `codewhale config set model`.
4196 let dir = tempfile::tempdir().expect("tempdir");
4197 let config_path = dir.path().join(CONFIG_FILE_NAME);
4198 fs::write(
4199 &config_path,
4200 "# my deepseek key\napi_key = \"sk-1234\"\n\n# my current model\nmodel = \"deepseek-v4-flash\"\n",
4201 )
4202 .expect("write config");
4203
4204 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
4205 store.config.model = Some("deepseek-v4-pro".to_string());
4206 store.save().expect("save");
4207
4208 let body = fs::read_to_string(&config_path).expect("read config");
4209 assert!(body.contains("# my deepseek key"), "api_key comment lost");
4210 assert!(body.contains("# my current model"), "model comment lost");
4211 assert!(
4212 body.contains("model = \"deepseek-v4-pro\""),
4213 "new model not written"
4214 );
4215 assert!(body.contains("api_key = \"sk-1234\""), "api_key lost");
4216 }
4217
4218 #[test]
4219 fn setup_transaction_applies_config_store_body_preserving_comments() {
4220 // #3410: the comment-preserving ConfigStore write must compose with
4221 // SetupTransaction so a setup step can update config.toml atomically
4222 // alongside sibling setup files.
4223 let dir = tempfile::tempdir().expect("tempdir");
4224 let config_path = dir.path().join(CONFIG_FILE_NAME);
4225 let state_path = dir.path().join(crate::setup_state::SETUP_STATE_FILE_NAME);
4226 fs::write(
4227 &config_path,
4228 "# my model\nmodel = \"deepseek-v4-flash\"\n# end comment\n",
4229 )
4230 .expect("write config");
4231
4232 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
4233 store.config.model = Some("deepseek-v4-pro".to_string());
4234
4235 let mut transaction = persistence::SetupTransaction::new();
4236 transaction.stage(
4237 &config_path,
4238 store.rendered_body().expect("rendered body").into_bytes(),
4239 );
4240 transaction
4241 .stage_json(&state_path, &SetupState::default())
4242 .expect("stage setup state");
4243 transaction.commit().expect("commit");
4244
4245 let body = fs::read_to_string(&config_path).expect("read config");
4246 assert!(body.contains("# my model"), "prefix comment preserved");
4247 assert!(body.contains("# end comment"), "suffix comment preserved");
4248 assert!(body.contains("model = \"deepseek-v4-pro\""));
4249 assert!(state_path.exists(), "sibling setup state written");
4250 }
4251
4252 #[test]
4253 fn setup_transaction_rolls_back_config_store_body_on_sibling_failure() {
4254 // #3410 rollback expectation: when a sibling stage fails to apply, the
4255 // already-written config.toml is restored byte-for-byte, comments and
4256 // all — no half-applied setup.
4257 let dir = tempfile::tempdir().expect("tempdir");
4258 let config_path = dir.path().join(CONFIG_FILE_NAME);
4259 let original = "# my model\nmodel = \"deepseek-v4-flash\"\n# end comment\n";
4260 fs::write(&config_path, original).expect("write config");
4261 // A parent that is a regular file makes the second stage unwritable.
4262 let blocker = dir.path().join("blocker");
4263 fs::write(&blocker, b"file, not a directory").expect("write blocker");
4264
4265 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
4266 store.config.model = Some("deepseek-v4-pro".to_string());
4267
4268 let mut transaction = persistence::SetupTransaction::new();
4269 transaction.stage(
4270 &config_path,
4271 store.rendered_body().expect("rendered body").into_bytes(),
4272 );
4273 transaction.stage(blocker.join("nested.json"), b"{}".to_vec());
4274 transaction
4275 .commit()
4276 .expect_err("commit must fail on unwritable sibling");
4277
4278 let body = fs::read_to_string(&config_path).expect("read config");
4279 assert_eq!(body, original, "config restored byte-for-byte on rollback");
4280 }
4281
4282 #[test]
4283 fn config_store_load_fails_on_malformed_config_without_touching_file() {
4284 // #3410 malformed-config posture: repair is explicit, never implicit.
4285 // Loading a malformed config surfaces a parse error naming the path and
4286 // leaves the file bytes untouched for the user (or doctor) to repair.
4287 let dir = tempfile::tempdir().expect("tempdir");
4288 let config_path = dir.path().join(CONFIG_FILE_NAME);
4289 let secret = "cw-secret-store-load-4507";
4290 let malformed =
4291 format!("# half-edited config\n[providers.xai]\napi_key = \"{secret}\" trailing-junk\n");
4292 fs::write(&config_path, &malformed).expect("write config");
4293
4294 let err = ConfigStore::load(Some(config_path.clone())).expect_err("malformed must not parse");
4295 let diagnostic = format!("{err:#}");
4296
4297 assert!(
4298 diagnostic.contains("failed to parse config"),
4299 "error should name the parse failure: {err:#}"
4300 );
4301 assert!(!diagnostic.contains(secret), "{diagnostic}");
4302 assert!(!diagnostic.contains("api_key"), "{diagnostic}");
4303 assert!(diagnostic.contains("file contents were omitted"));
4304 let body = fs::read_to_string(&config_path).expect("read config");
4305 assert_eq!(body, malformed, "malformed config left untouched");
4306 }
4307
4308 #[test]
4309 fn env_api_key_lookup_reads_exactly_each_providers_env_vars() {
4310 // `auth print-api-key` and the dispatcher resolve through this lookup; it
4311 // used to read a second hand-kept table in the secrets crate that had no
4312 // entry for mistral, minimax, zai, orcarouter, google, stepfun, qianfan,
4313 // or the Anthropic-dialect routes, so their exported keys were ignored.
4314 let _lock = env_lock();
4315 let all_vars: std::collections::BTreeSet<&str> = crate::provider::all_providers()
4316 .iter()
4317 .flat_map(|provider| provider.env_vars().iter().copied())
4318 .collect();
4319 let saved: Vec<_> = all_vars
4320 .iter()
4321 .map(|var| (*var, std::env::var_os(var)))
4322 .collect();
4323 for var in &all_vars {
4324 unsafe { std::env::remove_var(var) };
4325 }
4326 for provider in crate::provider::all_providers() {
4327 let kind = provider.kind();
4328 let mut reads = Vec::new();
4329 for var in &all_vars {
4330 unsafe { std::env::set_var(var, "probe-key") };
4331 if super::env_api_key_for_provider(kind).as_deref() == Some("probe-key") {
4332 reads.push(*var);
4333 }
4334 unsafe { std::env::remove_var(var) };
4335 }
4336 // Xiaomi MiMo's token-plan variables are read only by the mode-aware
4337 // lookup, never by this generic fallback.
4338 let mut declared = if kind == ProviderKind::XiaomiMimo {
4339 super::XIAOMI_MIMO_STANDARD_ENV_VARS.to_vec()
4340 } else {
4341 provider.env_vars().to_vec()
4342 };
4343 declared.sort_unstable();
4344 assert_eq!(reads, declared, "{}", kind.as_str());
4345 }
4346 for (var, value) in saved {
4347 if let Some(value) = value {
4348 unsafe { std::env::set_var(var, value) };
4349 }
4350 }
4351 }
4352
4353 #[test]
4354 fn toml_errors_name_line_and_column_but_never_the_value() {
4355 // A type error's message quotes the string (`invalid type: string "…"`)
4356 // and a syntax error's snippet quotes the whole line; both can carry a
4357 // credential, so every layer reports only where the error is.
4358 let canary = "LEAKCANARY0123456789";
4359 let secret = format!("sk-live-{canary}");
4360 let cases = [
4361 (
4362 format!("model = \"x\"\ntelemetry = \"{secret}\"\n"),
4363 "line 2, column 13, in `telemetry`",
4364 ),
4365 (
4366 format!("model = \"x\"\n\n[providers.xai]\napi_key = \"{secret}\" junk\n"),
4367 "line 4, column",
4368 ),
4369 ];
4370 for (body, location) in &cases {
4371 let dir = tempfile::tempdir().expect("tempdir");
4372 let config_path = dir.path().join(CONFIG_FILE_NAME);
4373 fs::write(&config_path, body).expect("write config");
4374 let error = format!(
4375 "{:#}",
4376 ConfigStore::load(Some(config_path)).expect_err("invalid config")
4377 );
4378 assert!(!error.contains(canary), "{error}");
4379 assert!(error.contains(location), "{error}");
4380
4381 let workspace = tempfile::tempdir().expect("workspace");
4382 let project_dir = workspace.path().join(CODEWHALE_APP_DIR);
4383 fs::create_dir_all(&project_dir).expect("project dir");
4384 fs::write(project_dir.join(CONFIG_FILE_NAME), body).expect("write project config");
4385 let outcome = load_project_config_outcome(workspace.path());
4386 let (_, reason) = outcome.invalid().expect("invalid project config");
4387 assert!(!reason.contains(canary), "{reason}");
4388 assert!(reason.contains(location), "{reason}");
4389 }
4390
4391 let dir = tempfile::tempdir().expect("tempdir");
4392 let config_path = dir.path().join(CONFIG_FILE_NAME);
4393 fs::write(&config_path, "model = \"x\"\n").expect("write config");
4394 fs::write(
4395 dir.path().join(PERMISSIONS_FILE_NAME),
4396 format!("[[rules]]\ntool = \"{secret}\" junk\n"),
4397 )
4398 .expect("write permissions");
4399 let error = format!(
4400 "{:#}",
4401 load_permissions_snapshot(Some(config_path)).expect_err("invalid permissions")
4402 );
4403 assert!(!error.contains(canary), "{error}");
4404 assert!(error.contains("line 2, column"), "{error}");
4405 }
4406
4407 #[test]
4408 fn config_store_rendered_body_preserves_comments_at_legacy_deepseek_path() {
4409 // #3410 legacy case: a config still living under `.deepseek/` keeps its
4410 // comments when written back through a transaction at the same path.
4411 let dir = tempfile::tempdir().expect("tempdir");
4412 let legacy_dir = dir.path().join(".deepseek");
4413 fs::create_dir_all(&legacy_dir).expect("legacy dir");
4414 let config_path = legacy_dir.join(CONFIG_FILE_NAME);
4415 fs::write(
4416 &config_path,
4417 "# legacy home config\nmodel = \"deepseek-v4-flash\"\n",
4418 )
4419 .expect("write config");
4420
4421 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
4422 store.config.model = Some("deepseek-v4-pro".to_string());
4423
4424 let mut transaction = persistence::SetupTransaction::new();
4425 transaction.stage(
4426 &config_path,
4427 store.rendered_body().expect("rendered body").into_bytes(),
4428 );
4429 transaction.commit().expect("commit");
4430
4431 let body = fs::read_to_string(&config_path).expect("read config");
4432 assert!(body.contains("# legacy home config"), "comment preserved");
4433 assert!(body.contains("model = \"deepseek-v4-pro\""));
4434 }
4435
4436 #[test]
4437 fn merge_and_preserve_comments_returns_err_on_invalid_serialized() {
4438 let secret = "sentinel";
4439 let err = merge_and_preserve_comments(
4440 &format!("api_key = \"{secret}\" trailing-junk\n"),
4441 "model = 1\n",
4442 )
4443 .expect_err("invalid serialized should fail");
4444 let diagnostic = format!("{err:#}");
4445 assert!(
4446 diagnostic.contains("failed to parse serialized"),
4447 "unexpected error: {err:#}"
4448 );
4449 assert!(!diagnostic.contains(secret), "{diagnostic}");
4450 assert!(!diagnostic.contains("api_key"), "{diagnostic}");
4451 }
4452
4453 #[test]
4454 fn merge_and_preserve_comments_returns_err_on_invalid_original() {
4455 let secret = "cw-secret-original-4507";
4456 let err = merge_and_preserve_comments(
4457 "model = 1\n",
4458 &format!("api_key = \"{secret}\" trailing-junk\n"),
4459 )
4460 .expect_err("invalid original should fail");
4461 let diagnostic = format!("{err:#}");
4462 assert!(
4463 diagnostic.contains("failed to parse original"),
4464 "unexpected error: {err:#}"
4465 );
4466 assert!(!diagnostic.contains(secret), "{diagnostic}");
4467 assert!(!diagnostic.contains("api_key"), "{diagnostic}");
4468 }
4469
4470 #[test]
4471 fn config_store_save_rejects_a_stale_or_corrupt_original_snapshot() {
4472 let dir = tempfile::tempdir().expect("tempdir");
4473 let config_path = dir.path().join(CONFIG_FILE_NAME);
4474 // A full typed writer must never overwrite bytes that differ from its
4475 // original snapshot, even when that snapshot is corrupt.
4476 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
4477
4478 // Bypass ConfigStore::load to inject a deliberately broken original_raw.
4479 let mut store = ConfigStore {
4480 path: config_path.clone(),
4481 config: ConfigToml {
4482 model: Some("deepseek-v4-pro".to_string()),
4483 ..ConfigToml::default()
4484 },
4485 permissions: PermissionsToml::default(),
4486 original_raw: Some("{ broken".to_string()),
4487 legacy_root: Default::default(),
4488 };
4489 let error = store
4490 .save()
4491 .expect_err("stale original bytes must fail instead of overwriting");
4492 assert!(
4493 error.to_string().contains("reload") && error.to_string().contains("retry"),
4494 "{error:#}"
4495 );
4496
4497 let body = fs::read_to_string(&config_path).expect("read config");
4498 assert_eq!(body, "model = \"deepseek-v4-flash\"\n");
4499 }
4500
4501 #[test]
4502 fn sequential_config_stores_cannot_resurrect_a_concurrent_change() {
4503 let dir = tempfile::tempdir().expect("tempdir");
4504 let config_path = dir.path().join(CONFIG_FILE_NAME);
4505 fs::write(
4506 &config_path,
4507 "model = \"before\"\n[providers.xai.external_credentials]\naccess = \"read_only\"\nprovider = \"xai\"\nsource = \"grok_cli\"\npath = \"/external/auth.json\"\nconsent_version = 1\n",
4508 )
4509 .expect("seed config");
4510 let mut stale = ConfigStore::load(Some(config_path.clone())).expect("load stale store");
4511
4512 mutate_config_document(&config_path, |document| {
4513 unset_config_document_value(document, &["providers", "xai", "external_credentials"])?;
4514 set_config_document_value(document, &["tui", "low_motion"], true)
4515 })
4516 .expect("concurrent targeted update");
4517 stale.config.model = Some("stale-writer".to_string());
4518 let error = stale
4519 .save()
4520 .expect_err("stale typed snapshot must not overwrite revocation");
4521 assert!(error.to_string().contains("config changed"), "{error:#}");
4522
4523 let saved = fs::read_to_string(config_path).expect("read final config");
4524 assert!(!saved.contains("external_credentials"), "{saved}");
4525 assert!(saved.contains("low_motion = true"), "{saved}");
4526 assert!(!saved.contains("stale-writer"), "{saved}");
4527 }
4528
4529 #[test]
4530 fn provider_kind_parses_openrouter_and_novita_aliases() {
4531 assert_eq!(
4532 ProviderKind::parse("openrouter"),
4533 Some(ProviderKind::Openrouter)
4534 );
4535 assert_eq!(
4536 ProviderKind::parse("OPEN_ROUTER"),
4537 Some(ProviderKind::Openrouter)
4538 );
4539 assert_eq!(
4540 ProviderKind::parse("xiaomi-mimo"),
4541 Some(ProviderKind::XiaomiMimo)
4542 );
4543 assert_eq!(
4544 ProviderKind::parse("xiaomi"),
4545 Some(ProviderKind::XiaomiMimo)
4546 );
4547 assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita));
4548 assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita));
4549 assert_eq!(
4550 ProviderKind::parse("fireworks-ai"),
4551 Some(ProviderKind::Fireworks)
4552 );
4553 assert_eq!(
4554 ProviderKind::parse("silicon-flow"),
4555 Some(ProviderKind::Siliconflow)
4556 );
4557 assert_eq!(
4558 ProviderKind::parse("silicon_flow"),
4559 Some(ProviderKind::Siliconflow)
4560 );
4561 assert_eq!(ProviderKind::parse("kimi"), Some(ProviderKind::Moonshot));
4562 assert_eq!(
4563 ProviderKind::parse("moonshot-ai"),
4564 Some(ProviderKind::Moonshot)
4565 );
4566 assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang));
4567 assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm));
4568 assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm));
4569 assert_eq!(ProviderKind::parse("ollama"), Some(ProviderKind::Ollama));
4570 assert_eq!(
4571 ProviderKind::parse("ollama-local"),
4572 Some(ProviderKind::Ollama)
4573 );
4574 for alias in ["ollama-cloud", "ollama_cloud"] {
4575 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::OllamaCloud));
4576 let parsed: ConfigToml =
4577 toml::from_str(&format!("provider = \"{alias}\"")).expect("ollama cloud alias");
4578 assert_eq!(parsed.provider, ProviderKind::OllamaCloud);
4579 }
4580 assert_eq!(
4581 ProviderKind::parse("wanjie-ark"),
4582 Some(ProviderKind::WanjieArk)
4583 );
4584 assert_eq!(
4585 ProviderKind::parse("ark_wanjie"),
4586 Some(ProviderKind::WanjieArk)
4587 );
4588 for alias in ["huggingface", "hugging-face", "hugging_face", "hf"] {
4589 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Huggingface));
4590
4591 let parsed: ConfigToml =
4592 toml::from_str(&format!("provider = \"{alias}\"")).expect("huggingface alias");
4593 assert_eq!(parsed.provider, ProviderKind::Huggingface);
4594 }
4595
4596 for alias in ["modelscope", "modelscope-cn"] {
4597 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Modelscope));
4598
4599 let parsed: ConfigToml =
4600 toml::from_str(&format!("provider = \"{alias}\"")).expect("modelscope alias");
4601 assert_eq!(parsed.provider, ProviderKind::Modelscope);
4602 }
4603
4604 for alias in ["deepinfra", "deep-infra", "deep_infra"] {
4605 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepinfra));
4606
4607 let parsed: ConfigToml =
4608 toml::from_str(&format!("provider = \"{alias}\"")).expect("deepinfra alias");
4609 assert_eq!(parsed.provider, ProviderKind::Deepinfra);
4610 }
4611
4612 for alias in ["sakana", "sakana-ai", "sakana_ai", "fugu"] {
4613 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Sakana));
4614
4615 let parsed: ConfigToml =
4616 toml::from_str(&format!("provider = \"{alias}\"")).expect("sakana alias");
4617 assert_eq!(parsed.provider, ProviderKind::Sakana);
4618 }
4619
4620 for alias in [
4621 "mistral",
4622 "mistral-ai",
4623 "mistral_ai",
4624 "mistralai",
4625 "la-plateforme",
4626 "la_plateforme",
4627 ] {
4628 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Mistral));
4629
4630 let parsed: ConfigToml =
4631 toml::from_str(&format!("provider = \"{alias}\"")).expect("mistral alias");
4632 assert_eq!(parsed.provider, ProviderKind::Mistral);
4633 }
4634
4635 for alias in ["qianfan", "baidu-qianfan", "baidu_qianfan", "baidu"] {
4636 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Qianfan));
4637
4638 let parsed: ConfigToml =
4639 toml::from_str(&format!("provider = \"{alias}\"")).expect("qianfan alias");
4640 assert_eq!(parsed.provider, ProviderKind::Qianfan);
4641 }
4642
4643 let parsed: ConfigToml =
4644 toml::from_str("provider = \"ark-wanjie\"").expect("wanjie provider alias");
4645 assert_eq!(parsed.provider, ProviderKind::WanjieArk);
4646
4647 let parsed: ConfigToml =
4648 toml::from_str("provider = \"silicon-flow\"").expect("siliconflow provider alias");
4649 assert_eq!(parsed.provider, ProviderKind::Siliconflow);
4650 }
4651
4652 /// Models.dev publishes provider ids that do not always match CodeWhale's
4653 /// canonical id (`fireworks-ai`, `togetherai`, `novita-ai`, `moonshotai`).
4654 /// These MUST normalize onto the right [`ProviderKind`] via
4655 /// [`ProviderKind::parse`], which is the seam `ModelReferenceCard::from_offering`
4656 /// uses to label a live-catalog row's provider kind. A miss here means
4657 /// Fireworks/Together/Novita/Moonshot models from the live Models.dev catalog
4658 /// land under an `unknown` kind (Refs #4186).
4659 #[test]
4660 fn provider_kind_normalizes_models_dev_provider_ids() {
4661 let cases = [
4662 ("fireworks-ai", ProviderKind::Fireworks),
4663 ("togetherai", ProviderKind::Together),
4664 ("together-ai", ProviderKind::Together),
4665 ("together_ai", ProviderKind::Together),
4666 ("novita-ai", ProviderKind::Novita),
4667 ("novita_ai", ProviderKind::Novita),
4668 // Live Models.dev key for Moonshot/Kimi (verified 2026-07-08).
4669 ("moonshotai", ProviderKind::Moonshot),
4670 ("moonshot-ai", ProviderKind::Moonshot),
4671 ("moonshot_ai", ProviderKind::Moonshot),
4672 ("nvidia", ProviderKind::NvidiaNim),
4673 ("xiaomi", ProviderKind::XiaomiMimo),
4674 ("deepinfra", ProviderKind::Deepinfra),
4675 ("siliconflow", ProviderKind::Siliconflow),
4676 // Models.dev spells the China endpoint `siliconflow-cn`; CodeWhale's
4677 // canonical id is `siliconflow-CN` and `parse` is case-insensitive.
4678 ("siliconflow-cn", ProviderKind::SiliconflowCN),
4679 ("openrouter", ProviderKind::Openrouter),
4680 ("longcat", ProviderKind::LongCat),
4681 ("xai", ProviderKind::Xai),
4682 ("x-ai", ProviderKind::Xai),
4683 ("x_ai", ProviderKind::Xai),
4684 ("grok", ProviderKind::Xai),
4685 ];
4686 for (models_dev_id, expected) in cases {
4687 assert_eq!(
4688 ProviderKind::parse(models_dev_id),
4689 Some(expected),
4690 "Models.dev id {models_dev_id:?} must normalize onto {expected:?}"
4691 );
4692 }
4693
4694 // The separator-free Models.dev ids must also deserialize from config TOML,
4695 // so a `provider = "togetherai"` / `"novita-ai"` / `"moonshotai"` line
4696 // resolves identically.
4697 for (alias, expected) in [
4698 ("togetherai", ProviderKind::Together),
4699 ("novita-ai", ProviderKind::Novita),
4700 ("fireworks-ai", ProviderKind::Fireworks),
4701 ("moonshotai", ProviderKind::Moonshot),
4702 ("grok", ProviderKind::Xai),
4703 ] {
4704 let parsed: ConfigToml =
4705 toml::from_str(&format!("provider = \"{alias}\"")).expect("models.dev id alias");
4706 assert_eq!(parsed.provider, expected, "toml provider = {alias:?}");
4707 }
4708 }
4709
4710 /// Pin the Fireworks and Together transport metadata against the real provider
4711 /// APIs: the OpenAI-compatible base URL and the canonical API-key env var. These
4712 /// are the two primary providers this audit targets, so a regression to a wrong
4713 /// base URL or env var name fails here.
4714 #[test]
4715 fn fireworks_and_together_base_url_and_auth_metadata() {
4716 let fireworks = provider::provider_for_kind(ProviderKind::Fireworks);
4717 assert_eq!(fireworks.id(), "fireworks");
4718 assert_eq!(
4719 fireworks.default_base_url(),
4720 "https://api.fireworks.ai/inference/v1"
4721 );
4722 assert_eq!(fireworks.default_base_url(), DEFAULT_FIREWORKS_BASE_URL);
4723 assert_eq!(fireworks.env_vars(), &["FIREWORKS_API_KEY"]);
4724 // Fireworks wire model ids are namespaced `accounts/fireworks/models/<name>`.
4725 assert!(
4726 fireworks
4727 .default_model()
4728 .starts_with("accounts/fireworks/models/"),
4729 "Fireworks default model must use the accounts/fireworks/models/ prefix, got {:?}",
4730 fireworks.default_model()
4731 );
4732
4733 let together = provider::provider_for_kind(ProviderKind::Together);
4734 assert_eq!(together.id(), "together");
4735 assert_eq!(together.default_base_url(), "https://api.together.xyz/v1");
4736 assert_eq!(together.default_base_url(), DEFAULT_TOGETHER_BASE_URL);
4737 assert_eq!(together.env_vars(), &["TOGETHER_API_KEY"]);
4738 // Together wire model ids are `<org>/<Model>` (a slash-namespaced id).
4739 assert!(
4740 together.default_model().contains('/'),
4741 "Together default model must be an <org>/<Model> id, got {:?}",
4742 together.default_model()
4743 );
4744
4745 // The env-based key resolver (secrets crate) must recognize both providers
4746 // by canonical id; a shell-exported key would otherwise be ignored.
4747 let _lock = env_lock();
4748 unsafe {
4749 std::env::set_var("FIREWORKS_API_KEY", "fw-test-key");
4750 std::env::set_var("TOGETHER_API_KEY", "tg-test-key");
4751 }
4752 assert_eq!(
4753 super::env_api_key_for_provider(ProviderKind::Fireworks).as_deref(),
4754 Some("fw-test-key")
4755 );
4756 assert_eq!(
4757 super::env_api_key_for_provider(ProviderKind::Together).as_deref(),
4758 Some("tg-test-key")
4759 );
4760 unsafe {
4761 std::env::remove_var("FIREWORKS_API_KEY");
4762 std::env::remove_var("TOGETHER_API_KEY");
4763 }
4764 }
4765
4766 #[test]
4767 fn unknown_provider_error_lists_huggingface() {
4768 let mut config = ConfigToml::default();
4769 let err = config
4770 .set_value("provider", "not-a-provider")
4771 .expect_err("unknown provider should fail");
4772 let message = err.to_string();
4773 assert!(message.contains("unknown provider 'not-a-provider'"));
4774 assert!(message.contains("huggingface"));
4775 }
4776
4777 #[test]
4778 fn config_store_preserves_named_custom_provider_identity_across_typed_dispatch_reads() {
4779 let _lock = env_lock();
4780 let _env = EnvGuard::without_deepseek_runtime_overrides();
4781 let dir = tempfile::tempdir().expect("tempdir");
4782 let path = dir.path().join("config.toml");
4783 fs::write(
4784 &path,
4785 r#"# written by the TUI custom-provider flow
4786 provider = "acme_zen_gateway"
4787
4788 [providers.acme_zen_gateway]
4789 kind = "openai-compatible"
4790 base_url = "https://acme.example/v1"
4791 model = "deepseek-v4-flash-free"
4792 api_key_env = "ACME_ZEN_GATEWAY_API_KEY"
4793 "#,
4794 )
4795 .expect("custom provider fixture");
4796
4797 let mut store = ConfigStore::load(Some(path.clone())).expect("dispatcher config should load");
4798 assert_eq!(store.config.provider, ProviderKind::Custom);
4799 assert_eq!(store.config.provider_id(), "acme_zen_gateway");
4800 assert_eq!(
4801 store.config.get_value("provider").as_deref(),
4802 Some("acme_zen_gateway")
4803 );
4804 assert_eq!(
4805 store
4806 .config
4807 .list_values()
4808 .get("provider")
4809 .map(String::as_str),
4810 Some("acme_zen_gateway")
4811 );
4812
4813 let resolved = store
4814 .config
4815 .resolve_runtime_options(&CliRuntimeOverrides::default());
4816 assert_eq!(resolved.provider, ProviderKind::Custom);
4817 assert_eq!(resolved.provider_source, ProviderSource::Config);
4818 assert_eq!(resolved.base_url, "https://acme.example/v1");
4819 assert_eq!(resolved.model, "deepseek-v4-flash-free");
4820
4821 store
4822 .config
4823 .set_value("telemetry", "false")
4824 .expect("unrelated typed mutation");
4825 let rendered = store
4826 .rendered_body()
4827 .expect("render custom provider config");
4828 assert!(
4829 rendered.contains("provider = \"acme_zen_gateway\""),
4830 "{rendered}"
4831 );
4832 assert!(!rendered.contains("provider = \"custom\""), "{rendered}");
4833 assert!(!rendered.contains("[providers.custom]"), "{rendered}");
4834
4835 store.save().expect("save custom provider config");
4836 let reloaded = ConfigStore::load(Some(path)).expect("reload custom provider config");
4837 assert_eq!(reloaded.config.provider_id(), "acme_zen_gateway");
4838 }
4839
4840 #[test]
4841 fn config_store_preserves_builtin_shadowing_custom_and_regional_selectors() {
4842 let _lock = env_lock();
4843 let _env = EnvGuard::without_deepseek_runtime_overrides();
4844 let dir = tempfile::tempdir().expect("tempdir");
4845 let path = dir.path().join("config.toml");
4846 for (selector, kind, table) in [
4847 (
4848 "OpenAI",
4849 ProviderKind::Custom,
4850 "[providers.OpenAI]\nkind = 'openai-compatible'\nbase_url = 'https://gateway.example/v1'\nmodel = 'Exact-Model'\n",
4851 ),
4852 (
4853 "deepseek-cn",
4854 ProviderKind::Deepseek,
4855 "[providers.deepseek_cn]\nbase_url = 'https://api.deepseek.cn'\nmodel = 'deepseek-v4-flash'\n",
4856 ),
4857 ] {
4858 fs::write(&path, format!("provider = '{selector}'\n{table}")).unwrap();
4859 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
4860 assert_eq!(store.config.provider, kind);
4861 assert_eq!(store.config.provider_id(), selector);
4862 if kind == ProviderKind::Custom {
4863 let route = store
4864 .config
4865 .resolve_runtime_options(&CliRuntimeOverrides::default());
4866 assert_eq!(route.base_url, "https://gateway.example/v1");
4867 assert_eq!(route.model, "Exact-Model");
4868 }
4869 store.config.set_value("verbosity", "concise").unwrap();
4870 store.save().unwrap();
4871 let saved: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
4872 assert_eq!(saved["provider"].as_str(), Some(selector));
4873 let mut reloaded = ConfigStore::load(Some(path.clone())).unwrap();
4874 assert_eq!(reloaded.config.provider, kind);
4875 assert_eq!(reloaded.config.provider_id(), selector);
4876 reloaded.config.set_value("provider", selector).unwrap();
4877 assert_eq!(reloaded.config.provider, kind);
4878 assert_eq!(reloaded.config.provider_id(), selector);
4879 // Direct typed callers that change kind cannot retain an old alias.
4880 reloaded.config.provider = if kind == ProviderKind::Custom {
4881 ProviderKind::Openai
4882 } else {
4883 ProviderKind::Custom
4884 };
4885 assert_eq!(
4886 reloaded.config.provider_id(),
4887 reloaded.config.provider.as_str()
4888 );
4889 assert!(reloaded.config.named_custom_provider_id().is_none());
4890 reloaded.save().unwrap();
4891 let rebound = ConfigStore::load(Some(path.clone())).unwrap();
4892 assert_eq!(rebound.config.provider, reloaded.config.provider);
4893 reloaded.config.provider = ProviderKind::Zai;
4894 assert_eq!(reloaded.config.provider_id(), "zai");
4895 }
4896 }
4897
4898 #[test]
4899 fn named_custom_root_provider_requires_a_matching_openai_compatible_table() {
4900 for body in [
4901 "provider = \"acme_zen_gateway\"\n",
4902 r#"provider = "acme_zen_gateway"
4903
4904 [providers.acme_zen_gateway]
4905 kind = "anthropic-messages"
4906 base_url = "https://acme.example/v1"
4907 "#,
4908 ] {
4909 let dir = tempfile::tempdir().expect("tempdir");
4910 let path = dir.path().join("config.toml");
4911 fs::write(&path, body).expect("invalid custom provider fixture");
4912 let err = ConfigStore::load(Some(path)).expect_err("invalid custom route should fail");
4913 let message = format!("{err:#}");
4914 assert!(message.contains("acme_zen_gateway"), "{message}");
4915 assert!(message.contains("openai-compatible") || message.contains("matching"));
4916 }
4917 }
4918
4919 #[test]
4920 fn kindless_table_mirroring_a_builtin_alias_keeps_the_builtin_route() {
4921 let _lock = env_lock();
4922 let _env = EnvGuard::without_deepseek_runtime_overrides();
4923 let dir = tempfile::tempdir().expect("tempdir");
4924 let path = dir.path().join("config.toml");
4925 // A kindless `[providers.deepseek-cn]` table merely mirrors the regional
4926 // selector spelling. It is not an openai-compatible custom provider, so it
4927 // must stay inert: the selector still binds the built-in DeepSeek kind and
4928 // the load must not fail.
4929 fs::write(
4930 &path,
4931 "provider = 'deepseek-cn'\n[providers.deepseek-cn]\nmodel = 'deepseek-v4-flash'\n",
4932 )
4933 .expect("kindless alias fixture");
4934 let mut store = ConfigStore::load(Some(path.clone())).expect("kindless alias table must load");
4935 assert_eq!(store.config.provider, ProviderKind::Deepseek);
4936 assert_eq!(store.config.provider_id(), "deepseek-cn");
4937 assert!(store.config.named_custom_provider_id().is_none());
4938 // An unrelated typed save leaves the inert extras table untouched.
4939 store.config.set_value("verbosity", "concise").unwrap();
4940 store.save().unwrap();
4941 let saved: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
4942 assert_eq!(saved["provider"].as_str(), Some("deepseek-cn"));
4943 assert_eq!(
4944 saved["providers"]["deepseek-cn"]["model"].as_str(),
4945 Some("deepseek-v4-flash")
4946 );
4947 let reloaded = ConfigStore::load(Some(path.clone())).expect("reload kindless alias config");
4948 assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
4949 assert_eq!(reloaded.config.provider_id(), "deepseek-cn");
4950
4951 // A table that does validate as openai-compatible still takes precedence
4952 // over the built-in alias.
4953 fs::write(
4954 &path,
4955 "provider = 'deepseek-cn'\n[providers.deepseek-cn]\nkind = 'openai-compatible'\nbase_url = 'https://gateway.example/v1'\nmodel = 'Exact-CN'\n",
4956 )
4957 .expect("valid custom table fixture");
4958 let store = ConfigStore::load(Some(path)).expect("valid custom table takes precedence");
4959 assert_eq!(store.config.provider, ProviderKind::Custom);
4960 assert_eq!(store.config.provider_id(), "deepseek-cn");
4961 assert_eq!(store.config.named_custom_provider_id(), Some("deepseek-cn"));
4962 }
4963
4964 #[test]
4965 fn invalid_custom_kind_never_falls_back_to_a_builtin_alias() {
4966 for kind in ["'unsupported'", "42", "false"] {
4967 let mut config: ConfigToml = toml::from_str(&format!(
4968 "provider = 'deepseek-cn'\n[providers.deepseek-cn]\nkind = {kind}\n"
4969 ))
4970 .unwrap();
4971 assert!(config.bind_persisted_provider_id("deepseek-cn").is_err());
4972 }
4973 }
4974
4975 #[test]
4976 fn provider_kind_accepts_legacy_deepseek_cn_aliases() {
4977 for alias in [
4978 "deepseek-cn",
4979 "deepseek_china",
4980 "deepseekcn",
4981 "deepseek-china",
4982 ] {
4983 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepseek));
4984
4985 let parsed: ConfigToml =
4986 toml::from_str(&format!("provider = \"{alias}\"")).expect("legacy provider alias");
4987 assert_eq!(parsed.provider, ProviderKind::Deepseek);
4988 }
4989 }
4990
4991 #[test]
4992 fn deepseek_anthropic_aliases_collapse_onto_primary_with_wire_toggle() {
4993 let _lock = env_lock();
4994 let _env = EnvGuard::without_deepseek_runtime_overrides();
4995 // Dialect is not a catalog identity — aliases resolve to DeepSeek primary.
4996 for alias in [
4997 "deepseek-anthropic",
4998 "deepseek_anthropic",
4999 "deepseek-claude",
5000 "deepseek_claude",
5001 ] {
5002 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepseek));
5003 }
5004
5005 let provider = provider::resolve_provider("deepseek-anthropic")
5006 .expect("deepseek anthropic alias resolves to primary");
5007 assert_eq!(provider.kind(), ProviderKind::Deepseek);
5008 assert_eq!(provider.id(), "deepseek");
5009
5010 // wire=anthropic selects the Messages endpoint without a second provider.
5011 let config: ConfigToml = toml::from_str(
5012 r#"
5013 provider = "deepseek"
5014
5015 [providers.deepseek]
5016 wire = "anthropic"
5017 "#,
5018 )
5019 .expect("deepseek wire config");
5020 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5021 assert_eq!(resolved.provider, ProviderKind::Deepseek);
5022 assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL);
5023 // Legacy serde kind still resolves the anthropic endpoint.
5024 let legacy = ConfigToml {
5025 provider: ProviderKind::DeepseekAnthropic,
5026 ..ConfigToml::default()
5027 };
5028 let legacy_resolved = legacy.resolve_runtime_options(&CliRuntimeOverrides::default());
5029 assert_eq!(
5030 legacy_resolved.base_url,
5031 DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
5032 );
5033 }
5034
5035 #[test]
5036 fn openmodel_route_defaults_to_messages_endpoint() {
5037 let _lock = env_lock();
5038 let _env = EnvGuard::without_deepseek_runtime_overrides();
5039 for alias in ["openmodel", "open-model", "open_model"] {
5040 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Openmodel));
5041
5042 let parsed: ConfigToml =
5043 toml::from_str(&format!("provider = \"{alias}\"")).expect("openmodel alias");
5044 assert_eq!(parsed.provider, ProviderKind::Openmodel);
5045 }
5046
5047 let provider = provider::resolve_provider("openmodel").expect("openmodel metadata resolves");
5048 assert_eq!(provider.kind(), ProviderKind::Openmodel);
5049 assert_eq!(provider.provider_config_key(), "openmodel");
5050 assert_eq!(provider.default_model(), DEFAULT_OPENMODEL_MODEL);
5051 assert_eq!(provider.default_base_url(), DEFAULT_OPENMODEL_BASE_URL);
5052 assert_eq!(provider.env_vars(), &["OPENMODEL_API_KEY"]);
5053 assert_eq!(
5054 provider.wire_policy().fixed(),
5055 Some(provider::WireFormat::AnthropicMessages)
5056 );
5057
5058 let config = ConfigToml {
5059 provider: ProviderKind::Openmodel,
5060 ..ConfigToml::default()
5061 };
5062 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5063
5064 assert_eq!(resolved.provider, ProviderKind::Openmodel);
5065 assert_eq!(resolved.base_url, DEFAULT_OPENMODEL_BASE_URL);
5066 assert_eq!(resolved.model, DEFAULT_OPENMODEL_MODEL);
5067
5068 unsafe {
5069 std::env::set_var("OPENMODEL_BASE_URL", "https://gateway.example.test");
5070 std::env::set_var("OPENMODEL_MODEL", "claude-sonnet-4-20250514");
5071 }
5072 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5073 assert_eq!(resolved.base_url, "https://gateway.example.test");
5074 assert_eq!(resolved.model, "claude-sonnet-4-20250514");
5075 unsafe {
5076 std::env::remove_var("OPENMODEL_BASE_URL");
5077 std::env::remove_var("OPENMODEL_MODEL");
5078 }
5079 }
5080
5081 #[test]
5082 fn xai_api_key_provider_resolves_defaults_and_scopes_env_credentials() {
5083 let _lock = env_lock();
5084 let _env = EnvGuard::without_deepseek_runtime_overrides();
5085
5086 let config = ConfigToml {
5087 provider: ProviderKind::Xai,
5088 ..ConfigToml::default()
5089 };
5090 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5091
5092 assert_eq!(resolved.provider, ProviderKind::Xai);
5093 assert_eq!(resolved.base_url, DEFAULT_XAI_BASE_URL);
5094 assert_eq!(resolved.model, DEFAULT_XAI_MODEL);
5095 assert_eq!(resolved.api_key, None);
5096
5097 unsafe {
5098 std::env::set_var("XAI_API_KEY", "xai-env-key");
5099 std::env::set_var("XAI_BASE_URL", "https://xai-gateway.example/v1");
5100 std::env::set_var("XAI_MODEL", "grok-4.3");
5101 }
5102
5103 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5104 assert_eq!(resolved.api_key, None);
5105 assert_eq!(resolved.api_key_source, None);
5106 assert_eq!(resolved.base_url, "https://xai-gateway.example/v1");
5107 assert_eq!(resolved.model, "grok-4.3");
5108 }
5109
5110 #[test]
5111 fn mistral_provider_resolves_defaults_and_metadata() {
5112 let _lock = env_lock();
5113 let _env = EnvGuard::without_deepseek_runtime_overrides();
5114
5115 let metadata = provider::resolve_provider("mistral").expect("mistral provider metadata");
5116 assert_eq!(metadata.id(), "mistral");
5117 assert_eq!(metadata.kind(), ProviderKind::Mistral);
5118 assert_eq!(metadata.display_name(), "Mistral AI");
5119 assert_eq!(metadata.provider_config_key(), "mistral");
5120 assert_eq!(metadata.default_base_url(), "https://api.mistral.ai/v1");
5121 assert_eq!(metadata.default_model(), "mistral-code-latest");
5122 assert_eq!(metadata.env_vars(), &["MISTRAL_API_KEY"]);
5123 assert_eq!(
5124 metadata.wire_policy().fixed(),
5125 Some(provider::WireFormat::ChatCompletions)
5126 );
5127
5128 let help = metadata.credential_help();
5129 assert_eq!(
5130 help.acquisition,
5131 provider::CredentialAcquisition::ApiKey,
5132 "Mistral is a hosted API-key provider"
5133 );
5134 assert_eq!(
5135 help.credential_url,
5136 Some("https://console.mistral.ai/api-keys")
5137 );
5138
5139 let config: ConfigToml = toml::from_str(
5140 r#"
5141 provider = "mistral-ai"
5142
5143 [providers.mistral]
5144 api_key = "mistral-config-key"
5145 model = "mistral-large-latest"
5146 "#,
5147 )
5148 .expect("mistral provider table");
5149 assert_eq!(config.provider, ProviderKind::Mistral);
5150 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5151 assert_eq!(resolved.provider, ProviderKind::Mistral);
5152 assert_eq!(resolved.base_url, "https://api.mistral.ai/v1");
5153 assert_eq!(resolved.model, "mistral-large-latest");
5154 assert_eq!(resolved.api_key.as_deref(), Some("mistral-config-key"));
5155
5156 unsafe {
5157 std::env::set_var("MISTRAL_BASE_URL", "https://api.eu.mistral.ai/v1");
5158 std::env::set_var("MISTRAL_MODEL", "mistral-medium-latest");
5159 }
5160 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5161 assert_eq!(resolved.base_url, "https://api.eu.mistral.ai/v1");
5162 assert_eq!(resolved.model, "mistral-medium-latest");
5163 }
5164
5165 #[test]
5166 fn opencode_go_resolves_model_aware_route() {
5167 let _lock = env_lock();
5168 let _env = EnvGuard::without_deepseek_runtime_overrides();
5169
5170 for alias in ["opencode-go", "opencode_go", "opencodego"] {
5171 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::OpencodeGo));
5172
5173 let parsed: ConfigToml =
5174 toml::from_str(&format!("provider = \"{alias}\"")).expect("OpenCode Go alias");
5175 assert_eq!(parsed.provider, ProviderKind::OpencodeGo);
5176 }
5177
5178 let metadata = provider::resolve_provider("opencode_go").expect("provider metadata");
5179 assert_eq!(metadata.id(), "opencode-go");
5180 assert_eq!(metadata.display_name(), "OpenCode Go");
5181 assert_eq!(metadata.provider_config_key(), "opencode_go");
5182 assert_eq!(metadata.default_base_url(), DEFAULT_OPENCODE_GO_BASE_URL);
5183 assert_eq!(metadata.default_model(), DEFAULT_OPENCODE_GO_MODEL);
5184 assert_eq!(metadata.env_vars(), &["OPENCODE_GO_API_KEY"]);
5185 assert_eq!(metadata.wire_policy(), provider::WirePolicy::ModelAware);
5186
5187 let config: ConfigToml = toml::from_str(
5188 r#"
5189 provider = "opencode-go"
5190
5191 [providers.opencode_go]
5192 api_key = "go-config-key"
5193 model = "opencode-go/glm-5.2"
5194 "#,
5195 )
5196 .expect("OpenCode Go provider table");
5197 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5198 assert_eq!(resolved.provider, ProviderKind::OpencodeGo);
5199 assert_eq!(resolved.base_url, DEFAULT_OPENCODE_GO_BASE_URL);
5200 assert_eq!(resolved.model, OPENCODE_GO_GLM_5_2_MODEL);
5201 assert_eq!(resolved.api_key.as_deref(), Some("go-config-key"));
5202 assert_eq!(
5203 resolved.api_key_source,
5204 Some(RuntimeApiKeySource::ConfigFile)
5205 );
5206
5207 // Provider-specific environment overrides remain available, but model ids
5208 // stay inside the documented protocol roster.
5209 unsafe {
5210 std::env::set_var("OPENCODE_GO_API_KEY", "go-env-key");
5211 std::env::set_var("OPENCODE_GO_MODEL", "opencode-go/mimo-v2.5-pro");
5212 }
5213 assert_eq!(
5214 super::env_api_key_for_provider(ProviderKind::OpencodeGo).as_deref(),
5215 Some("go-env-key")
5216 );
5217 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5218 assert_eq!(resolved.base_url, DEFAULT_OPENCODE_GO_BASE_URL);
5219 assert_eq!(resolved.model, OPENCODE_GO_MIMO_V2_5_PRO_MODEL);
5220
5221 for model in [OPENCODE_GO_GROK_4_5_MODEL, OPENCODE_GO_KIMI_K3_MODEL] {
5222 assert_eq!(opencode_go_model_id(model), Some(model));
5223 assert_eq!(
5224 opencode_go_model_id(&format!("opencode-go/{model}")),
5225 Some(model)
5226 );
5227 }
5228
5229 // A custom endpoint preserves the configured Messages model; no fallback.
5230 unsafe {
5231 std::env::set_var("OPENCODE_GO_BASE_URL", "https://go-gateway.example/v1");
5232 std::env::set_var("OPENCODE_GO_MODEL", "minimax-m3");
5233 }
5234 assert_eq!(opencode_go_model_id("minimax-m3"), Some("minimax-m3"));
5235 assert_eq!(opencode_go_model_id("qwen3.7-max"), Some("qwen3.7-max"));
5236 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5237 assert_eq!(resolved.base_url, "https://go-gateway.example/v1");
5238 assert_eq!(resolved.model, "minimax-m3");
5239 assert_eq!(resolved.api_key, None);
5240 }
5241
5242 #[test]
5243 fn telecomjs_resolves_key_scoped_chat_completions_route() {
5244 let _lock = env_lock();
5245 let _env = EnvGuard::without_deepseek_runtime_overrides();
5246
5247 for alias in [
5248 "telecomjs",
5249 "telecom-js",
5250 "telecom_js",
5251 "telecomjs-cn",
5252 "tokenhub",
5253 ] {
5254 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Telecomjs));
5255
5256 let parsed: ConfigToml =
5257 toml::from_str(&format!("provider = \"{alias}\"")).expect("TelecomJS alias");
5258 assert_eq!(parsed.provider, ProviderKind::Telecomjs);
5259 }
5260
5261 let metadata = provider::resolve_provider("tokenhub").expect("provider metadata");
5262 assert_eq!(metadata.id(), "telecomjs");
5263 assert_eq!(metadata.display_name(), "TelecomJS TokenHub");
5264 assert_eq!(metadata.provider_config_key(), "telecomjs");
5265 assert_eq!(metadata.default_base_url(), DEFAULT_TELECOMJS_BASE_URL);
5266 assert_eq!(metadata.default_model(), DEFAULT_TELECOMJS_MODEL);
5267 assert_eq!(metadata.env_vars(), &["TELECOMJS_API_KEY"]);
5268 assert_eq!(
5269 metadata.wire_policy(),
5270 provider::WirePolicy::Fixed(provider::WireFormat::ChatCompletions)
5271 );
5272
5273 let config: ConfigToml = toml::from_str(
5274 r#"
5275 provider = "telecomjs"
5276
5277 [providers.telecomjs]
5278 api_key = "telecom-config-key"
5279 model = "glm-5.2"
5280 "#,
5281 )
5282 .expect("TelecomJS provider table");
5283 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5284 assert_eq!(resolved.provider, ProviderKind::Telecomjs);
5285 assert_eq!(resolved.base_url, DEFAULT_TELECOMJS_BASE_URL);
5286 assert_eq!(resolved.model, "glm-5.2");
5287 assert_eq!(resolved.api_key.as_deref(), Some("telecom-config-key"));
5288 assert_eq!(
5289 resolved.api_key_source,
5290 Some(RuntimeApiKeySource::ConfigFile)
5291 );
5292
5293 unsafe {
5294 std::env::set_var("TELECOMJS_API_KEY", "telecom-env-key");
5295 std::env::set_var("TELECOMJS_MODEL", "kimi-k2.5");
5296 }
5297 assert_eq!(
5298 super::env_api_key_for_provider(ProviderKind::Telecomjs).as_deref(),
5299 Some("telecom-env-key")
5300 );
5301
5302 let env_config = ConfigToml {
5303 provider: ProviderKind::Telecomjs,
5304 ..ConfigToml::default()
5305 };
5306 let resolved = env_config.resolve_runtime_options(&CliRuntimeOverrides::default());
5307 assert_eq!(resolved.base_url, DEFAULT_TELECOMJS_BASE_URL);
5308 assert_eq!(resolved.model, "kimi-k2.5");
5309 assert_eq!(resolved.api_key.as_deref(), Some("telecom-env-key"));
5310 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
5311 }
5312
5313 #[test]
5314 fn edenai_resolves_named_chat_gateway_and_environment_overrides() {
5315 let _lock = env_lock();
5316 let _env = EnvGuard::without_deepseek_runtime_overrides();
5317
5318 for alias in ["edenai", "eden-ai", "eden_ai"] {
5319 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Edenai));
5320 let parsed: ConfigToml =
5321 toml::from_str(&format!("provider = \"{alias}\"")).expect("Eden AI alias");
5322 assert_eq!(parsed.provider, ProviderKind::Edenai);
5323 }
5324
5325 let metadata = provider::resolve_provider("eden-ai").expect("Eden AI metadata");
5326 assert_eq!(metadata.id(), "edenai");
5327 assert_eq!(metadata.display_name(), "Eden AI");
5328 assert_eq!(metadata.provider_config_key(), "edenai");
5329 assert_eq!(metadata.default_base_url(), DEFAULT_EDENAI_BASE_URL);
5330 assert_eq!(metadata.default_model(), DEFAULT_EDENAI_MODEL);
5331 assert_eq!(metadata.env_vars(), &["EDENAI_API_KEY"]);
5332 assert_eq!(
5333 metadata.wire_policy(),
5334 provider::WirePolicy::Fixed(provider::WireFormat::ChatCompletions)
5335 );
5336
5337 let config: ConfigToml = toml::from_str(
5338 r#"
5339 provider = "edenai"
5340
5341 [providers.edenai]
5342 api_key = "eden-config-key"
5343 model = "anthropic/claude-sonnet-4-5"
5344 "#,
5345 )
5346 .expect("Eden AI provider table");
5347 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5348 assert_eq!(resolved.provider, ProviderKind::Edenai);
5349 assert_eq!(resolved.base_url, DEFAULT_EDENAI_BASE_URL);
5350 assert_eq!(resolved.model, "anthropic/claude-sonnet-4-5");
5351 assert_eq!(resolved.api_key.as_deref(), Some("eden-config-key"));
5352 assert_eq!(
5353 resolved.api_key_source,
5354 Some(RuntimeApiKeySource::ConfigFile)
5355 );
5356
5357 unsafe {
5358 std::env::set_var("EDENAI_API_KEY", "eden-env-key");
5359 std::env::set_var("EDENAI_BASE_URL", "https://api.eu.edenai.run/v3");
5360 std::env::set_var("EDENAI_MODEL", "deepseek/deepseek-v4-flash");
5361 }
5362 let env_config = ConfigToml {
5363 provider: ProviderKind::Edenai,
5364 ..ConfigToml::default()
5365 };
5366 let resolved = env_config.resolve_runtime_options(&CliRuntimeOverrides::default());
5367 assert_eq!(resolved.base_url, "https://api.eu.edenai.run/v3");
5368 assert_eq!(resolved.model, "deepseek/deepseek-v4-flash");
5369 assert_eq!(resolved.api_key.as_deref(), Some("eden-env-key"));
5370 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
5371 }
5372
5373 #[test]
5374 fn zenmux_resolves_named_chat_gateway_and_environment_overrides() {
5375 let _lock = env_lock();
5376 let _env = EnvGuard::without_deepseek_runtime_overrides();
5377
5378 for alias in ["zenmux", "zen-mux", "zen_mux"] {
5379 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Zenmux));
5380 let parsed: ConfigToml =
5381 toml::from_str(&format!("provider = \"{alias}\"")).expect("ZenMux alias");
5382 assert_eq!(parsed.provider, ProviderKind::Zenmux);
5383 }
5384
5385 let metadata = provider::resolve_provider("zen-mux").expect("ZenMux metadata");
5386 assert_eq!(metadata.id(), "zenmux");
5387 assert_eq!(metadata.display_name(), "ZenMux");
5388 assert_eq!(metadata.provider_config_key(), "zenmux");
5389 assert_eq!(metadata.default_base_url(), DEFAULT_ZENMUX_BASE_URL);
5390 assert_eq!(metadata.default_model(), DEFAULT_ZENMUX_MODEL);
5391 assert_eq!(metadata.env_vars(), &["ZENMUX_API_KEY"]);
5392 assert_eq!(
5393 metadata.wire_policy(),
5394 provider::WirePolicy::Fixed(provider::WireFormat::ChatCompletions)
5395 );
5396
5397 let config: ConfigToml = toml::from_str(
5398 r#"
5399 provider = "zenmux"
5400
5401 [providers.zenmux]
5402 api_key = "zen-config-key"
5403 model = "z-ai/glm-5.3"
5404 "#,
5405 )
5406 .expect("ZenMux provider table");
5407 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5408 assert_eq!(resolved.provider, ProviderKind::Zenmux);
5409 assert_eq!(resolved.base_url, DEFAULT_ZENMUX_BASE_URL);
5410 assert_eq!(resolved.model, "z-ai/glm-5.3");
5411 assert_eq!(resolved.api_key.as_deref(), Some("zen-config-key"));
5412 assert_eq!(
5413 resolved.api_key_source,
5414 Some(RuntimeApiKeySource::ConfigFile)
5415 );
5416
5417 unsafe {
5418 std::env::set_var("ZENMUX_API_KEY", "zen-env-key");
5419 std::env::set_var("ZENMUX_BASE_URL", "https://zenmux.ai/api/v1");
5420 std::env::set_var("ZENMUX_MODEL", "deepseek/deepseek-v4.1-flash");
5421 }
5422 let env_config = ConfigToml {
5423 provider: ProviderKind::Zenmux,
5424 ..ConfigToml::default()
5425 };
5426 let resolved = env_config.resolve_runtime_options(&CliRuntimeOverrides::default());
5427 assert_eq!(resolved.base_url, "https://zenmux.ai/api/v1");
5428 assert_eq!(resolved.model, "deepseek/deepseek-v4.1-flash");
5429 assert_eq!(resolved.api_key.as_deref(), Some("zen-env-key"));
5430 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
5431 }
5432
5433 /// CSDN 星图 (Starmap) is an OpenAI-compatible hosted platform whose default
5434 /// route is the Coding Plan model `glm_for_coding`: aliases collapse onto one
5435 /// catalog identity, the metadata names the official base URL and a distinct
5436 /// `CSDN_API_KEY` slot, the wire policy is fixed on Chat Completions, and
5437 /// env/config overrides resolve exactly like every other provider table.
5438 #[test]
5439 fn csdn_resolves_named_chat_provider_and_environment_overrides() {
5440 let _lock = env_lock();
5441 let _env = EnvGuard::without_deepseek_runtime_overrides();
5442
5443 for alias in [
5444 "csdn",
5445 "csdn-ai",
5446 "csdn_ai",
5447 "csdn-coding-plan",
5448 "csdn_coding_plan",
5449 "starmap",
5450 ] {
5451 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Csdn));
5452 let parsed: ConfigToml =
5453 toml::from_str(&format!("provider = \"{alias}\"")).expect("CSDN alias");
5454 assert_eq!(parsed.provider, ProviderKind::Csdn);
5455 }
5456
5457 let metadata = provider::resolve_provider("starmap").expect("CSDN metadata");
5458 assert_eq!(metadata.id(), "csdn");
5459 assert_eq!(metadata.display_name(), "CSDN");
5460 assert_eq!(metadata.provider_config_key(), "csdn");
5461 assert_eq!(metadata.default_base_url(), DEFAULT_CSDN_BASE_URL);
5462 assert_eq!(metadata.default_model(), DEFAULT_CSDN_MODEL);
5463 assert_eq!(metadata.env_vars(), &["CSDN_API_KEY"]);
5464 assert_eq!(
5465 metadata.wire_policy(),
5466 provider::WirePolicy::Fixed(provider::WireFormat::ChatCompletions)
5467 );
5468
5469 let config: ConfigToml = toml::from_str(
5470 r#"
5471 provider = "csdn"
5472
5473 [providers.csdn]
5474 api_key = "csdn-config-key"
5475 model = "glm_for_coding"
5476 "#,
5477 )
5478 .expect("CSDN provider table");
5479 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5480 assert_eq!(resolved.provider, ProviderKind::Csdn);
5481 assert_eq!(resolved.base_url, DEFAULT_CSDN_BASE_URL);
5482 assert_eq!(resolved.model, "glm_for_coding");
5483 assert_eq!(resolved.api_key.as_deref(), Some("csdn-config-key"));
5484 assert_eq!(
5485 resolved.api_key_source,
5486 Some(RuntimeApiKeySource::ConfigFile)
5487 );
5488
5489 unsafe {
5490 std::env::set_var("CSDN_API_KEY", "csdn-env-key");
5491 std::env::set_var("CSDN_BASE_URL", "https://ai.csdn.net/api/model/v1");
5492 std::env::set_var("CSDN_MODEL", "deepseek-v3.2");
5493 }
5494 let env_config = ConfigToml {
5495 provider: ProviderKind::Csdn,
5496 ..ConfigToml::default()
5497 };
5498 let resolved = env_config.resolve_runtime_options(&CliRuntimeOverrides::default());
5499 assert_eq!(resolved.base_url, "https://ai.csdn.net/api/model/v1");
5500 assert_eq!(resolved.model, "deepseek-v3.2");
5501 assert_eq!(resolved.api_key.as_deref(), Some("csdn-env-key"));
5502 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
5503 }
5504
5505 /// Concentrate is an opt-in, BYOK, Responses-wire gateway: aliases collapse
5506 /// onto one catalog identity, the metadata names the official base URL and a
5507 /// distinct `CONCENTRATE_API_KEY` slot, the wire policy is fixed on the
5508 /// Responses API, and env/config overrides resolve exactly like every other
5509 /// provider table.
5510 #[test]
5511 fn concentrate_resolves_named_responses_gateway_and_environment_overrides() {
5512 let _lock = env_lock();
5513 let _env = EnvGuard::without_deepseek_runtime_overrides();
5514
5515 for alias in [
5516 "concentrate",
5517 "concentrate-ai",
5518 "concentrate_ai",
5519 "concentrateai",
5520 ] {
5521 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Concentrate));
5522 let parsed: ConfigToml =
5523 toml::from_str(&format!("provider = \"{alias}\"")).expect("Concentrate alias");
5524 assert_eq!(parsed.provider, ProviderKind::Concentrate);
5525 }
5526
5527 let metadata = provider::resolve_provider("concentrate-ai").expect("Concentrate metadata");
5528 assert_eq!(metadata.id(), "concentrate");
5529 assert_eq!(metadata.display_name(), "Concentrate");
5530 assert_eq!(metadata.provider_config_key(), "concentrate");
5531 assert_eq!(metadata.default_base_url(), "https://api.concentrate.ai/v1");
5532 assert_eq!(metadata.default_base_url(), DEFAULT_CONCENTRATE_BASE_URL);
5533 assert_eq!(metadata.default_model(), DEFAULT_CONCENTRATE_MODEL);
5534 assert_eq!(metadata.env_vars(), &["CONCENTRATE_API_KEY"]);
5535 assert_eq!(
5536 metadata.wire_policy(),
5537 provider::WirePolicy::Fixed(provider::WireFormat::Responses)
5538 );
5539 assert_eq!(
5540 ProviderKind::Concentrate.secret_store_slot(),
5541 "concentrate",
5542 "the gateway key never shares a slot with another provider"
5543 );
5544 let help = metadata.credential_help();
5545 assert_eq!(help.acquisition, provider::CredentialAcquisition::ApiKey);
5546 assert_eq!(
5547 help.docs_url,
5548 Some("https://concentrate.ai/docs/api-reference/introduction")
5549 );
5550
5551 let config: ConfigToml = toml::from_str(
5552 r#"
5553 provider = "concentrate"
5554
5555 [providers.concentrate]
5556 api_key = "concentrate-config-key"
5557 model = "openai/gpt-5.6-sol"
5558 "#,
5559 )
5560 .expect("Concentrate provider table");
5561 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5562 assert_eq!(resolved.provider, ProviderKind::Concentrate);
5563 assert_eq!(resolved.base_url, DEFAULT_CONCENTRATE_BASE_URL);
5564 let alias_table: ConfigToml = toml::from_str(
5565 r#"
5566 provider = "concentrate"
5567
5568 [providers.concentrateai]
5569 api_key = "concentrate-alias-table-key"
5570 model = "openai/gpt-5.6-sol"
5571 "#,
5572 )
5573 .expect("[providers.concentrateai] must deserialize onto the concentrate table");
5574 let alias_resolved = alias_table.resolve_runtime_options(&CliRuntimeOverrides::default());
5575 assert_eq!(alias_resolved.provider, ProviderKind::Concentrate);
5576 assert_eq!(
5577 alias_resolved.api_key.as_deref(),
5578 Some("concentrate-alias-table-key"),
5579 "[providers.concentrateai] must not be silently ignored"
5580 );
5581 assert_eq!(
5582 resolved.model, "openai/gpt-5.6-sol",
5583 "provider/model ids pass through verbatim"
5584 );
5585 assert_eq!(resolved.api_key.as_deref(), Some("concentrate-config-key"));
5586 assert_eq!(
5587 resolved.api_key_source,
5588 Some(RuntimeApiKeySource::ConfigFile)
5589 );
5590
5591 unsafe {
5592 std::env::set_var("CONCENTRATE_API_KEY", "concentrate-env-key");
5593 std::env::set_var("CONCENTRATE_MODEL", "claude-fable-5");
5594 }
5595 let env_config = ConfigToml {
5596 provider: ProviderKind::Concentrate,
5597 ..ConfigToml::default()
5598 };
5599 let resolved = env_config.resolve_runtime_options(&CliRuntimeOverrides::default());
5600 assert_eq!(resolved.base_url, DEFAULT_CONCENTRATE_BASE_URL);
5601 assert_eq!(resolved.model, "claude-fable-5");
5602 assert_eq!(resolved.api_key.as_deref(), Some("concentrate-env-key"));
5603 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
5604
5605 // Credential scope: a saved or environment Concentrate key is bound to the
5606 // official gateway. Pointing the same identity at any other base URL is
5607 // an arbitrary endpoint, and the key must NOT follow it (a local stub or
5608 // proxy gets the key only when the user writes both into the config).
5609 unsafe {
5610 std::env::set_var("CONCENTRATE_BASE_URL", "http://127.0.0.1:8790/v1");
5611 }
5612 let resolved = env_config.resolve_runtime_options(&CliRuntimeOverrides::default());
5613 assert_eq!(resolved.base_url, "http://127.0.0.1:8790/v1");
5614 assert_eq!(
5615 resolved.api_key, None,
5616 "an env key must never be sent to a non-official Concentrate base URL"
5617 );
5618 unsafe {
5619 std::env::remove_var("CONCENTRATE_BASE_URL");
5620 std::env::remove_var("CONCENTRATE_MODEL");
5621 std::env::remove_var("CONCENTRATE_API_KEY");
5622 }
5623 let custom: ConfigToml = toml::from_str(
5624 r#"
5625 provider = "concentrate"
5626
5627 [providers.concentrate]
5628 base_url = "http://127.0.0.1:8790/v1"
5629 api_key = "stub-key"
5630 model = "concentrate/auto"
5631 "#,
5632 )
5633 .expect("Concentrate custom endpoint table");
5634 let resolved = custom.resolve_runtime_options(&CliRuntimeOverrides::default());
5635 assert_eq!(resolved.base_url, "http://127.0.0.1:8790/v1");
5636 assert_eq!(resolved.api_key.as_deref(), Some("stub-key"));
5637 assert_eq!(
5638 resolved.api_key_source,
5639 Some(RuntimeApiKeySource::ConfigFile)
5640 );
5641 assert_eq!(resolved.model, "concentrate/auto");
5642 }
5643
5644 #[test]
5645 fn opencode_zen_configures_model_aware_provider_with_catalog_proof() {
5646 let _lock = env_lock();
5647 let _env = EnvGuard::without_deepseek_runtime_overrides();
5648
5649 for alias in [
5650 "opencode-zen",
5651 "opencode_zen",
5652 "opencodezen",
5653 "zen",
5654 "opencode",
5655 ] {
5656 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::OpencodeZen));
5657 }
5658
5659 let metadata = provider::resolve_provider("opencode_zen").expect("Zen provider metadata");
5660 assert_eq!(metadata.id(), "opencode-zen");
5661 assert_eq!(metadata.display_name(), "OpenCode Zen");
5662 assert_eq!(metadata.provider_config_key(), "opencode_zen");
5663 assert_eq!(metadata.default_base_url(), DEFAULT_OPENCODE_ZEN_BASE_URL);
5664 assert_eq!(metadata.default_model(), DEFAULT_OPENCODE_ZEN_MODEL);
5665 assert_eq!(
5666 metadata.env_vars(),
5667 &["OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"]
5668 );
5669 assert_eq!(metadata.wire_policy(), provider::WirePolicy::ModelAware);
5670
5671 let config: ConfigToml = toml::from_str(
5672 r#"
5673 provider = "opencode-zen"
5674
5675 [providers.opencode_zen]
5676 api_key = "zen-config-key"
5677 base_url = "https://zen-gateway.example/v1"
5678 model = "gpt-5.5"
5679 "#,
5680 )
5681 .expect("OpenCode Zen provider table");
5682 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5683 assert_eq!(resolved.provider, ProviderKind::OpencodeZen);
5684 assert_eq!(resolved.base_url, "https://zen-gateway.example/v1");
5685 assert_eq!(resolved.model, "gpt-5.5");
5686 assert_eq!(resolved.api_key.as_deref(), Some("zen-config-key"));
5687
5688 let resolver = crate::route::RouteResolver::new();
5689 let route = resolver
5690 .resolve(&crate::route::RouteRequest {
5691 explicit_provider: Some(ProviderKind::OpencodeZen),
5692 model_selector: Some(crate::route::LogicalModelRef::from("gpt-5.5")),
5693 saved_provider_model: None,
5694 base_url_override: None,
5695 limit_overrides: Vec::new(),
5696 })
5697 .expect("documented Zen model must resolve");
5698 assert_eq!(route.protocol(), crate::route::RequestProtocol::Responses);
5699 assert_eq!(route.endpoint().endpoint_key, "responses");
5700 }
5701
5702 #[test]
5703 fn meta_model_api_scopes_both_documented_key_names_to_official_endpoint() {
5704 let _lock = env_lock();
5705 let _env = EnvGuard::without_deepseek_runtime_overrides();
5706
5707 let config = ConfigToml {
5708 provider: ProviderKind::Meta,
5709 ..ConfigToml::default()
5710 };
5711 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5712
5713 assert_eq!(resolved.provider, ProviderKind::Meta);
5714 assert_eq!(resolved.base_url, DEFAULT_META_BASE_URL);
5715 assert_eq!(resolved.model, DEFAULT_META_MODEL);
5716 assert_eq!(resolved.api_key, None);
5717
5718 unsafe {
5719 std::env::set_var("MODEL_API_KEY", "meta-official-key");
5720 std::env::set_var("MODEL_API_BASE_URL", "https://meta-gateway.example/v1");
5721 std::env::set_var("MODEL_API_MODEL", "muse-spark-canary");
5722 }
5723 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5724 assert_eq!(resolved.api_key, None);
5725 assert_eq!(resolved.api_key_source, None);
5726 assert_eq!(resolved.base_url, "https://meta-gateway.example/v1");
5727 assert_eq!(resolved.model, "muse-spark-canary");
5728
5729 unsafe {
5730 std::env::set_var("META_MODEL_API_KEY", "meta-models-dev-key");
5731 std::env::set_var("META_MODEL_API_BASE_URL", "https://meta-primary.example/v1");
5732 std::env::set_var("META_MODEL_API_MODEL", "muse-spark-1.1");
5733 }
5734 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5735 assert_eq!(resolved.api_key, None);
5736 assert_eq!(resolved.api_key_source, None);
5737 assert_eq!(resolved.base_url, "https://meta-primary.example/v1");
5738 assert_eq!(resolved.model, "muse-spark-1.1");
5739 }
5740
5741 #[test]
5742 fn provider_metadata_registry_covers_every_provider_kind_once() {
5743 let providers = provider::all_providers();
5744 // Full registry keeps legacy dialect/plan kinds for provider_for_kind.
5745 assert_eq!(providers.len(), 52);
5746 // Catalog surface is one identity per vendor (no dual-wire / plan rows),
5747 // and never a retired tombstone: Antigravity stays in the full registry
5748 // so old config parses and can be cleared, but it left `ALL` when it
5749 // stopped being selectable (PRD §4.4 PROD-002).
5750 assert_eq!(ProviderKind::ALL.len(), 46);
5751 assert!(
5752 !ProviderKind::ALL.contains(&ProviderKind::Antigravity),
5753 "a tombstone must never be offered as a selectable provider"
5754 );
5755 assert!(ProviderKind::ALL.len() < providers.len());
5756
5757 let mut ids = std::collections::BTreeSet::new();
5758 for provider in providers {
5759 assert!(ids.insert(provider.id()), "duplicate provider id");
5760 assert_eq!(provider.id(), provider.kind().as_str());
5761 assert_eq!(provider.kind().provider().id(), provider.id());
5762 }
5763 // Catalog entries are a subset of the full registry.
5764 for kind in ProviderKind::ALL {
5765 assert!(
5766 providers.iter().any(|p| p.kind() == kind),
5767 "catalog kind {kind:?} missing from full registry"
5768 );
5769 }
5770 }
5771
5772 #[test]
5773 fn provider_metadata_lookup_does_not_fall_back_to_deepseek() {
5774 assert!(provider::lookup_provider("not-a-provider").is_none());
5775 assert!(provider::resolve_provider("not-a-provider").is_none());
5776 assert!(provider::lookup_provider("deepseek-cn").is_none());
5777 assert_eq!(
5778 provider::resolve_provider("deepseek-cn")
5779 .expect("legacy alias resolves")
5780 .kind(),
5781 ProviderKind::Deepseek
5782 );
5783 }
5784
5785 #[test]
5786 fn provider_metadata_preserves_alias_and_config_key_semantics() {
5787 assert_eq!(
5788 provider::resolve_provider("open_router")
5789 .expect("openrouter alias")
5790 .kind(),
5791 ProviderKind::Openrouter
5792 );
5793 assert_eq!(
5794 provider::resolve_provider("xiaomi")
5795 .expect("xiaomi alias")
5796 .kind(),
5797 ProviderKind::XiaomiMimo
5798 );
5799 assert_eq!(
5800 provider::resolve_provider("kimi")
5801 .expect("kimi alias")
5802 .kind(),
5803 ProviderKind::Moonshot
5804 );
5805 assert_eq!(
5806 provider::resolve_provider("hf")
5807 .expect("huggingface alias")
5808 .kind(),
5809 ProviderKind::Huggingface
5810 );
5811 assert_eq!(
5812 provider::resolve_provider("grok")
5813 .expect("xAI grok alias")
5814 .kind(),
5815 ProviderKind::Xai
5816 );
5817 assert_eq!(
5818 provider::resolve_provider("muse-spark")
5819 .expect("Meta Muse Spark alias")
5820 .kind(),
5821 ProviderKind::Meta
5822 );
5823
5824 let siliconflow_cn =
5825 provider::resolve_provider("siliconflow-cn").expect("siliconflow-cn alias resolves");
5826 assert_eq!(siliconflow_cn.kind(), ProviderKind::SiliconflowCN);
5827 assert_eq!(siliconflow_cn.id(), "siliconflow-CN");
5828 assert_eq!(siliconflow_cn.provider_config_key(), "siliconflow_cn");
5829
5830 let config = ProvidersToml::default();
5831 let shared_table = config.for_provider(ProviderKind::SiliconflowCN);
5832 assert!(!std::ptr::eq(
5833 shared_table,
5834 config.for_provider(ProviderKind::Siliconflow)
5835 ));
5836 }
5837
5838 #[test]
5839 fn provider_metadata_defaults_match_runtime_helpers() {
5840 for kind in ProviderKind::ALL {
5841 let provider = kind.provider();
5842 assert_eq!(provider.default_model(), default_model_for_provider(kind));
5843 assert_eq!(
5844 provider.default_base_url(),
5845 default_base_url_for_provider(kind)
5846 );
5847 assert!(!provider.display_name().trim().is_empty());
5848 // The dynamic custom provider (#1519) intentionally declares no
5849 // built-in auth env var: the key env var name is supplied per entry via
5850 // `[providers.<name>] api_key_env = "..."`. Every built-in provider
5851 // still must declare at least one.
5852 if kind != ProviderKind::Custom {
5853 assert!(!provider.env_vars().is_empty());
5854 }
5855 // OpenAI Codex (ChatGPT) speaks the Responses API; DeepSeek,
5856 // OpenCode Zen, OpenCode Go, and the Codewhale API select a protocol per exact
5857 // model offering; Anthropic
5858 // and the Anthropic-compatible routes speak native Messages; every
5859 // other built-in provider is OpenAI-compatible Chat Completions.
5860 let expected_wire = match kind {
5861 ProviderKind::Deepseek
5862 | ProviderKind::OpencodeZen
5863 | ProviderKind::OpencodeGo
5864 | ProviderKind::Codewhale => None,
5865 ProviderKind::OpenaiCodex | ProviderKind::Concentrate => {
5866 Some(provider::WireFormat::Responses)
5867 }
5868 ProviderKind::Anthropic
5869 | ProviderKind::DeepseekAnthropic
5870 | ProviderKind::MinimaxAnthropic
5871 | ProviderKind::Openmodel
5872 | ProviderKind::ModelstudioTokenPlanAnthropic
5873 | ProviderKind::ModelstudioCodingPlanAnthropic => {
5874 Some(provider::WireFormat::AnthropicMessages)
5875 }
5876 _ => Some(provider::WireFormat::ChatCompletions),
5877 };
5878 assert_eq!(provider.wire_policy().fixed(), expected_wire);
5879 }
5880 }
5881
5882 #[test]
5883 fn openrouter_provider_defaults_to_canonical_endpoint_and_model() {
5884 let _lock = env_lock();
5885 let _env = EnvGuard::without_deepseek_runtime_overrides();
5886 let config = ConfigToml {
5887 provider: ProviderKind::Openrouter,
5888 ..ConfigToml::default()
5889 };
5890
5891 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5892
5893 assert_eq!(resolved.provider, ProviderKind::Openrouter);
5894 assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
5895 assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL);
5896 }
5897
5898 #[test]
5899 fn orcarouter_provider_defaults_to_canonical_endpoint_and_model() {
5900 let _lock = env_lock();
5901 let _env = EnvGuard::without_deepseek_runtime_overrides();
5902 let config = ConfigToml {
5903 provider: ProviderKind::Orcarouter,
5904 ..ConfigToml::default()
5905 };
5906
5907 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5908
5909 assert_eq!(resolved.provider, ProviderKind::Orcarouter);
5910 assert_eq!(resolved.base_url, DEFAULT_ORCAROUTER_BASE_URL);
5911 assert_eq!(resolved.model, DEFAULT_ORCAROUTER_MODEL);
5912 }
5913
5914 #[test]
5915 fn orcarouter_provider_normalizes_deepseek_aliases() {
5916 let _lock = env_lock();
5917 let _env = EnvGuard::without_deepseek_runtime_overrides();
5918 let config = ConfigToml {
5919 provider: ProviderKind::Orcarouter,
5920 ..ConfigToml::default()
5921 };
5922
5923 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides {
5924 model: Some("deepseek-v4-flash".to_string()),
5925 ..CliRuntimeOverrides::default()
5926 });
5927
5928 assert_eq!(resolved.provider, ProviderKind::Orcarouter);
5929 assert_eq!(resolved.model, DEFAULT_ORCAROUTER_FLASH_MODEL);
5930 }
5931
5932 #[test]
5933 fn xiaomi_mimo_provider_defaults_to_canonical_endpoint_and_model() {
5934 let _lock = env_lock();
5935 let _env = EnvGuard::without_deepseek_runtime_overrides();
5936 let config = ConfigToml {
5937 provider: ProviderKind::XiaomiMimo,
5938 ..ConfigToml::default()
5939 };
5940
5941 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5942
5943 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
5944 assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
5945 assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
5946 }
5947
5948 #[test]
5949 fn xiaomi_provider_alias_table_maps_to_mimo_runtime_config() {
5950 let _lock = env_lock();
5951 let _env = EnvGuard::without_deepseek_runtime_overrides();
5952 let config: ConfigToml = toml::from_str(
5953 r#"
5954 provider = "xiaomi-mimo"
5955 default_text_model = "deepseek/deepseek-v4-pro"
5956
5957 [providers.xiaomi]
5958 api_key = "mimo-table-key"
5959 base_url = "https://token-plan-sgp.xiaomimimo.com/v1"
5960 model = "mimo-v2.5-pro"
5961 "#,
5962 )
5963 .expect("xiaomi provider alias config");
5964
5965 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5966
5967 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
5968 assert_eq!(resolved.api_key.as_deref(), Some("mimo-table-key"));
5969 assert_eq!(
5970 resolved.base_url,
5971 "https://token-plan-sgp.xiaomimimo.com/v1"
5972 );
5973 assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
5974 }
5975
5976 #[test]
5977 fn xiaomi_token_plan_key_rewrites_saved_pay_as_you_go_base_url() {
5978 let _lock = env_lock();
5979 let _env = EnvGuard::without_deepseek_runtime_overrides();
5980 let config: ConfigToml = toml::from_str(
5981 r#"
5982 provider = "xiaomi-mimo"
5983
5984 [providers.xiaomi_mimo]
5985 api_key = "tp-test-token-plan-key"
5986 base_url = "https://api.xiaomimimo.com/v1"
5987 model = "mimo-v2.5-pro"
5988 "#,
5989 )
5990 .expect("xiaomi token-plan config");
5991
5992 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5993
5994 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
5995 assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
5996 assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
5997 }
5998
5999 #[test]
6000 fn xiaomi_mimo_token_plan_mode_accepts_region_aliases() {
6001 let _lock = env_lock();
6002 let _env = EnvGuard::without_deepseek_runtime_overrides();
6003 let config: ConfigToml = toml::from_str(
6004 r#"
6005 provider = "mimo"
6006
6007 [providers.mimo]
6008 mode = "token-plan-ams"
6009 "#,
6010 )
6011 .expect("xiaomi token-plan region config");
6012
6013 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6014
6015 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6016 assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL);
6017 }
6018
6019 #[test]
6020 fn xiaomi_mimo_unknown_mode_stays_on_token_plan_endpoint() {
6021 let _lock = env_lock();
6022 let _env = EnvGuard::without_deepseek_runtime_overrides();
6023 let config: ConfigToml = toml::from_str(
6024 r#"
6025 provider = "mimo"
6026
6027 [providers.mimo]
6028 mode = "token-plan-usa"
6029 "#,
6030 )
6031 .expect("xiaomi token-plan unknown mode config");
6032
6033 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6034
6035 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6036 assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
6037 }
6038
6039 #[test]
6040 fn xiaomi_mimo_aliases_resolve_to_canonical_models() {
6041 assert_eq!(
6042 normalize_model_for_provider(ProviderKind::XiaomiMimo, "omni"),
6043 "mimo-v2.5"
6044 );
6045 assert_eq!(
6046 normalize_model_for_provider(ProviderKind::XiaomiMimo, "pro-ultraspeed"),
6047 "mimo-v2.5-pro-ultraspeed"
6048 );
6049 assert_eq!(
6050 normalize_model_for_provider(ProviderKind::XiaomiMimo, "tts"),
6051 "mimo-v2.5-tts"
6052 );
6053 assert_eq!(
6054 normalize_model_for_provider(ProviderKind::XiaomiMimo, "voice-design"),
6055 "mimo-v2.5-tts-voicedesign"
6056 );
6057 assert_eq!(
6058 normalize_model_for_provider(ProviderKind::XiaomiMimo, "voiceclone"),
6059 "mimo-v2.5-tts-voiceclone"
6060 );
6061 assert_eq!(
6062 normalize_model_for_provider(ProviderKind::XiaomiMimo, "custom-mimo-model"),
6063 "custom-mimo-model"
6064 );
6065 }
6066
6067 #[test]
6068 fn zai_aliases_resolve_to_canonical_models() {
6069 // GLM-5.3 is the default; the glm-5.1 alias must still resolve to 5.1
6070 // (not to the default), and GLM-5-Turbo resolves to its own id.
6071 assert_eq!(
6072 normalize_model_for_provider(ProviderKind::Zai, "glm-5.1"),
6073 ZAI_GLM_5_1_MODEL
6074 );
6075 assert_eq!(DEFAULT_ZAI_MODEL, "GLM-5.3");
6076 assert_eq!(DEFAULT_ZAI_MODEL, ZAI_GLM_5_3_MODEL);
6077 for alias in ["glm-5.3", "glm-5-3", "zai-glm-5.3", "zai-glm-5-3"] {
6078 assert_eq!(
6079 normalize_model_for_provider(ProviderKind::Zai, alias),
6080 ZAI_GLM_5_3_MODEL,
6081 "{alias} must canonicalize to GLM-5.3"
6082 );
6083 }
6084 // GLM-5.2 is a peer, no longer the default: an explicit 5.2 selection
6085 // must keep its own id and must never fold into DEFAULT_ZAI_MODEL.
6086 for alias in ["glm-5.2", "glm-5-2", "zai-glm-5.2", "zai-glm-5-2"] {
6087 assert_eq!(
6088 normalize_model_for_provider(ProviderKind::Zai, alias),
6089 ZAI_GLM_5_2_MODEL,
6090 "{alias} must canonicalize to GLM-5.2"
6091 );
6092 assert_ne!(
6093 normalize_model_for_provider(ProviderKind::Zai, alias),
6094 DEFAULT_ZAI_MODEL,
6095 "{alias} must not resolve to the Z.ai default"
6096 );
6097 }
6098 for alias in [
6099 "glm-5.3-flash",
6100 "glm-5-3-flash",
6101 "zai-glm-5.3-flash",
6102 "GLM-5.3-Flash",
6103 ] {
6104 assert_eq!(
6105 normalize_model_for_provider(ProviderKind::Zai, alias),
6106 ZAI_GLM_5_3_FLASH_MODEL,
6107 "{alias} must canonicalize to GLM-5.3-Flash"
6108 );
6109 assert_ne!(
6110 normalize_model_for_provider(ProviderKind::Zai, alias),
6111 ZAI_GLM_5_3_MODEL,
6112 "{alias} must not collapse onto GLM-5.3"
6113 );
6114 }
6115 assert_eq!(
6116 normalize_model_for_provider(ProviderKind::Zai, "glm-5-turbo"),
6117 ZAI_GLM_5_TURBO_MODEL
6118 );
6119 assert_eq!(
6120 normalize_model_for_provider(ProviderKind::Zai, "custom-glm-preview"),
6121 "custom-glm-preview"
6122 );
6123 }
6124
6125 #[test]
6126 fn zhipu_aliases_fold_into_zai_provider() {
6127 // Zhipu AI and Z.ai are the same vendor; `zhipu`/`zhipuai`/`bigmodel`
6128 // resolve to the single Zai provider rather than a separate one.
6129 assert_eq!(ProviderKind::parse("zhipu"), Some(ProviderKind::Zai));
6130 assert_eq!(ProviderKind::parse("zhipuai"), Some(ProviderKind::Zai));
6131 assert_eq!(ProviderKind::parse("bigmodel"), Some(ProviderKind::Zai));
6132 assert_eq!(ProviderKind::parse("big-model"), Some(ProviderKind::Zai));
6133
6134 // A `[providers.zhipu]` table (BigModel China endpoint) merges into the Zai
6135 // provider config through the serde alias.
6136 let parsed: ConfigToml = toml::from_str(
6137 r#"
6138 [providers.zhipu]
6139 api_key = "$ZHIPU_API_KEY"
6140 base_url = "https://open.bigmodel.cn/api/paas/v4/"
6141 model = "glm-5-2"
6142 "#,
6143 )
6144 .expect("zhipu provider table parses");
6145
6146 let provider = parsed.providers.for_provider(ProviderKind::Zai);
6147 assert_eq!(provider.api_key.as_deref(), Some("$ZHIPU_API_KEY"));
6148 assert_eq!(
6149 provider.base_url.as_deref(),
6150 Some("https://open.bigmodel.cn/api/paas/v4/")
6151 );
6152 assert_eq!(provider.model.as_deref(), Some("glm-5-2"));
6153
6154 // GLM aliases canonicalize under the Zai umbrella, to their own ids.
6155 assert_eq!(
6156 normalize_model_for_provider(ProviderKind::Zai, "glm-5-2"),
6157 ZAI_GLM_5_2_MODEL
6158 );
6159 }
6160
6161 #[test]
6162 fn stepfun_step_plan_hosts_are_official_so_the_catalog_is_not_withheld() {
6163 // A Step Plan subscriber's base URL is StepFun's own documented host.
6164 // Treating it as custom made `catalog_models_for_route` return nothing,
6165 // which the picker showed as `0 bundled`: no model list, and a guessed
6166 // context window instead of the 1M `step-5-preview` actually has.
6167 for official in [
6168 "https://api.stepfun.ai/v1",
6169 "https://api.stepfun.ai/step_plan/v1",
6170 "https://api.stepfun.com/v1",
6171 "https://api.stepfun.com/step_plan/v1",
6172 "https://api.stepfun.ai/step_plan/v1/",
6173 ] {
6174 assert!(
6175 provider_base_url_is_official(ProviderKind::Stepfun, official),
6176 "{official} is a StepFun-owned endpoint"
6177 );
6178 }
6179 // A host StepFun does not own stays custom: this predicate also scopes
6180 // credentials, so it must not widen to arbitrary look-alikes.
6181 for foreign in [
6182 "https://api.stepfun.evil.com/v1",
6183 "https://api.deepseek.com",
6184 "https://stepfun.ai.attacker.test/step_plan/v1",
6185 ] {
6186 assert!(
6187 !provider_base_url_is_official(ProviderKind::Stepfun, foreign),
6188 "{foreign} must stay custom and keyless"
6189 );
6190 }
6191 }
6192
6193 #[test]
6194 fn zai_official_endpoint_family_includes_zhipu_general_api() {
6195 let _lock = env_lock();
6196 let _env = EnvGuard::without_deepseek_runtime_overrides();
6197
6198 for base_url in [
6199 "https://api.z.ai/api/coding/paas/v4",
6200 "https://api.z.ai/api/paas/v4/",
6201 "https://open.bigmodel.cn/api/paas/v4",
6202 ] {
6203 assert!(provider_base_url_is_official(ProviderKind::Zai, base_url));
6204 assert!(!provider_preserves_custom_base_url_model(
6205 ProviderKind::Zai,
6206 base_url
6207 ));
6208 }
6209 assert!(!provider_base_url_is_official(
6210 ProviderKind::Zai,
6211 "https://open.bigmodel.cn/api/paas/v4/preview"
6212 ));
6213 }
6214
6215 #[test]
6216 fn novita_provider_defaults_to_canonical_endpoint_and_model() {
6217 let _lock = env_lock();
6218 let _env = EnvGuard::without_deepseek_runtime_overrides();
6219 let config = ConfigToml {
6220 provider: ProviderKind::Novita,
6221 ..ConfigToml::default()
6222 };
6223
6224 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6225
6226 assert_eq!(resolved.provider, ProviderKind::Novita);
6227 assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
6228 assert_eq!(resolved.model, DEFAULT_NOVITA_MODEL);
6229 }
6230
6231 #[test]
6232 fn fireworks_provider_defaults_to_canonical_endpoint_and_model() {
6233 let _lock = env_lock();
6234 let _env = EnvGuard::without_deepseek_runtime_overrides();
6235 let config = ConfigToml {
6236 provider: ProviderKind::Fireworks,
6237 ..ConfigToml::default()
6238 };
6239
6240 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6241
6242 assert_eq!(resolved.provider, ProviderKind::Fireworks);
6243 assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
6244 assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL);
6245 }
6246
6247 #[test]
6248 fn siliconflow_provider_defaults_to_canonical_endpoint_and_model() {
6249 let _lock = env_lock();
6250 let _env = EnvGuard::without_deepseek_runtime_overrides();
6251 let config = ConfigToml {
6252 provider: ProviderKind::Siliconflow,
6253 ..ConfigToml::default()
6254 };
6255
6256 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6257
6258 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
6259 assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_BASE_URL);
6260 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
6261 }
6262
6263 #[test]
6264 fn siliconflow_cn_config_falls_back_to_shared_table_when_unset() {
6265 let _lock = env_lock();
6266 let _env = EnvGuard::without_deepseek_runtime_overrides();
6267 let mut config = ConfigToml {
6268 provider: ProviderKind::SiliconflowCN,
6269 ..ConfigToml::default()
6270 };
6271 config.providers.siliconflow.api_key = Some("sf-shared-key".to_string());
6272 config.providers.siliconflow.base_url = Some(DEFAULT_SILICONFLOW_BASE_URL.to_string());
6273 config.providers.siliconflow.model = Some("deepseek-chat".to_string());
6274 config.providers.siliconflow_cn.base_url = Some(DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
6275
6276 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6277
6278 assert_eq!(resolved.provider, ProviderKind::SiliconflowCN);
6279 assert_eq!(resolved.api_key.as_deref(), Some("sf-shared-key"));
6280 assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_CN_BASE_URL);
6281 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL);
6282 }
6283
6284 #[test]
6285 fn siliconflow_cn_first_class_config_preserves_provider_scoped_route() {
6286 let _lock = env_lock();
6287 let _env = EnvGuard::without_deepseek_runtime_overrides();
6288 let mut config = ConfigToml {
6289 provider: ProviderKind::SiliconflowCN,
6290 ..ConfigToml::default()
6291 };
6292 config.providers.siliconflow_cn.api_key = Some("sf-cn-file-key".to_string());
6293 config.providers.siliconflow_cn.base_url = Some(DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
6294 config.providers.siliconflow_cn.model = Some(DEFAULT_SILICONFLOW_MODEL.to_string());
6295
6296 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6297
6298 assert_eq!(resolved.provider, ProviderKind::SiliconflowCN);
6299 assert_eq!(resolved.api_key.as_deref(), Some("sf-cn-file-key"));
6300 assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_CN_BASE_URL);
6301 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
6302 }
6303
6304 #[test]
6305 fn moonshot_provider_defaults_to_kimi_k27_code() {
6306 let _lock = env_lock();
6307 let _env = EnvGuard::without_deepseek_runtime_overrides();
6308 let config = ConfigToml {
6309 provider: ProviderKind::Moonshot,
6310 ..ConfigToml::default()
6311 };
6312
6313 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6314
6315 assert_eq!(resolved.provider, ProviderKind::Moonshot);
6316 assert_eq!(resolved.base_url, DEFAULT_MOONSHOT_BASE_URL);
6317 assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
6318 }
6319
6320 #[test]
6321 fn zai_stepfun_minimax_and_sakana_default_to_first_party_routes() {
6322 let _lock = env_lock();
6323 let _env = EnvGuard::without_deepseek_runtime_overrides();
6324
6325 for (provider, expected_base_url, expected_model) in [
6326 (ProviderKind::Zai, DEFAULT_ZAI_BASE_URL, DEFAULT_ZAI_MODEL),
6327 (
6328 ProviderKind::Stepfun,
6329 DEFAULT_STEPFUN_BASE_URL,
6330 DEFAULT_STEPFUN_MODEL,
6331 ),
6332 (
6333 ProviderKind::Minimax,
6334 DEFAULT_MINIMAX_BASE_URL,
6335 DEFAULT_MINIMAX_MODEL,
6336 ),
6337 (
6338 ProviderKind::MinimaxAnthropic,
6339 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
6340 DEFAULT_MINIMAX_MODEL,
6341 ),
6342 (
6343 ProviderKind::Sakana,
6344 DEFAULT_SAKANA_BASE_URL,
6345 DEFAULT_SAKANA_MODEL,
6346 ),
6347 ] {
6348 let config = ConfigToml {
6349 provider,
6350 ..ConfigToml::default()
6351 };
6352 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6353
6354 assert_eq!(resolved.provider, provider);
6355 assert_eq!(resolved.base_url, expected_base_url);
6356 assert_eq!(resolved.model, expected_model);
6357 }
6358 }
6359
6360 #[test]
6361 fn qianfan_provider_defaults_to_openai_compatible_endpoint_and_model() {
6362 let _lock = env_lock();
6363 let _env = EnvGuard::without_deepseek_runtime_overrides();
6364 let config = ConfigToml {
6365 provider: ProviderKind::Qianfan,
6366 ..ConfigToml::default()
6367 };
6368
6369 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6370
6371 assert_eq!(resolved.provider, ProviderKind::Qianfan);
6372 assert_eq!(resolved.base_url, DEFAULT_QIANFAN_BASE_URL);
6373 assert_eq!(resolved.model, DEFAULT_QIANFAN_MODEL);
6374 }
6375
6376 #[test]
6377 fn qianfan_provider_preserves_configured_base_url_and_model() {
6378 let _lock = env_lock();
6379 let _env = EnvGuard::without_deepseek_runtime_overrides();
6380 let mut config = ConfigToml {
6381 provider: ProviderKind::Qianfan,
6382 ..ConfigToml::default()
6383 };
6384 config.providers.qianfan.api_key = Some("qianfan-table-key".to_string());
6385 config.providers.qianfan.base_url = Some("https://qianfan.baidubce.com/v2".to_string());
6386 config.providers.qianfan.model = Some("custom-qianfan-service-id".to_string());
6387
6388 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6389
6390 assert_eq!(resolved.provider, ProviderKind::Qianfan);
6391 assert_eq!(resolved.api_key.as_deref(), Some("qianfan-table-key"));
6392 assert_eq!(resolved.base_url, "https://qianfan.baidubce.com/v2");
6393 assert_eq!(resolved.model, "custom-qianfan-service-id");
6394 }
6395
6396 #[test]
6397 fn first_party_provider_env_model_overrides_pass_through() {
6398 let _lock = env_lock();
6399 let _env = EnvGuard::without_deepseek_runtime_overrides();
6400 unsafe {
6401 env::set_var("CODEWHALE_PROVIDER", "minimax");
6402 env::set_var("MINIMAX_MODEL", "MiniMax-M2.7-highspeed");
6403 env::set_var("MINIMAX_BASE_URL", "https://minimax.example/v1");
6404 }
6405
6406 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6407
6408 assert_eq!(resolved.provider, ProviderKind::Minimax);
6409 assert_eq!(resolved.base_url, "https://minimax.example/v1");
6410 assert_eq!(resolved.model, "MiniMax-M2.7-highspeed");
6411 }
6412
6413 #[test]
6414 fn minimax_env_model_override_canonicalizes_known_aliases() {
6415 let _lock = env_lock();
6416 let _env = EnvGuard::without_deepseek_runtime_overrides();
6417 unsafe {
6418 env::set_var("CODEWHALE_PROVIDER", "minimax");
6419 env::set_var("MINIMAX_MODEL", "minimax-m2-5-highspeed");
6420 }
6421
6422 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6423
6424 assert_eq!(resolved.provider, ProviderKind::Minimax);
6425 assert_eq!(resolved.model, "MiniMax-M2.5-highspeed");
6426 }
6427
6428 #[test]
6429 fn minimax_wire_anthropic_selects_messages_endpoint() {
6430 let _lock = env_lock();
6431 let _env = EnvGuard::without_deepseek_runtime_overrides();
6432 // minimax-anthropic is an alias of MiniMax; dialect is wire config.
6433 assert_eq!(
6434 ProviderKind::parse("minimax-anthropic"),
6435 Some(ProviderKind::Minimax)
6436 );
6437
6438 let config: ConfigToml = toml::from_str(
6439 r#"
6440 provider = "minimax"
6441
6442 [providers.minimax]
6443 wire = "anthropic"
6444 model = "MiniMax-M2.7"
6445 "#,
6446 )
6447 .expect("minimax wire config");
6448 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6449
6450 assert_eq!(resolved.provider, ProviderKind::Minimax);
6451 assert_eq!(resolved.base_url, DEFAULT_MINIMAX_ANTHROPIC_BASE_URL);
6452 assert_eq!(resolved.model, "MiniMax-M2.7");
6453 }
6454
6455 #[test]
6456 fn parse_config_identity_preserves_legacy_table_kinds() {
6457 // Legacy dual-wire spellings name the user's own [providers.*] table;
6458 // config-table identity must not collapse them onto the vendor primary
6459 // the way catalog `parse` does.
6460 assert_eq!(
6461 ProviderKind::parse_config_identity("deepseek-anthropic"),
6462 Some(ProviderKind::DeepseekAnthropic)
6463 );
6464 assert_eq!(
6465 ProviderKind::parse_config_identity("deepseek_anthropic"),
6466 Some(ProviderKind::DeepseekAnthropic)
6467 );
6468 assert_eq!(
6469 ProviderKind::parse_config_identity("minimax-anthropic"),
6470 Some(ProviderKind::MinimaxAnthropic)
6471 );
6472 assert_eq!(
6473 ProviderKind::parse_config_identity("MINIMAX-ANTHROPIC"),
6474 Some(ProviderKind::MinimaxAnthropic)
6475 );
6476 // Primary spellings and aliases resolve exactly as catalog `parse`.
6477 assert_eq!(
6478 ProviderKind::parse_config_identity("deepseek-cn"),
6479 Some(ProviderKind::Deepseek)
6480 );
6481 assert_eq!(
6482 ProviderKind::parse_config_identity("minimax"),
6483 Some(ProviderKind::Minimax)
6484 );
6485 assert_eq!(ProviderKind::parse_config_identity("nope"), None);
6486 }
6487
6488 #[test]
6489 fn legacy_dual_wire_toml_table_supplies_credentials_and_endpoint() {
6490 let _lock = env_lock();
6491 let _env = EnvGuard::without_deepseek_runtime_overrides();
6492 // TOML serde keeps the legacy kind, so the user's own named table is
6493 // read for credentials and the Messages endpoint is selected.
6494 let config: ConfigToml = toml::from_str(
6495 r#"
6496 provider = "deepseek-anthropic"
6497
6498 [providers.deepseek-anthropic]
6499 api_key = "sk-legacy-deepseek-table"
6500 "#,
6501 )
6502 .expect("legacy deepseek-anthropic config");
6503 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6504
6505 assert_eq!(resolved.provider, ProviderKind::DeepseekAnthropic);
6506 assert_eq!(
6507 resolved.api_key.as_deref(),
6508 Some("sk-legacy-deepseek-table")
6509 );
6510 assert_eq!(
6511 resolved.api_key_source,
6512 Some(RuntimeApiKeySource::ConfigFile)
6513 );
6514 assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL);
6515 }
6516
6517 #[test]
6518 fn legacy_dual_wire_env_provider_preserves_named_table_credentials() {
6519 let _lock = env_lock();
6520 let _env = EnvGuard::without_deepseek_runtime_overrides();
6521 // CODEWHALE_PROVIDER must resolve legacy spellings to the same kind TOML
6522 // serde produces; catalog collapse would orphan the user's own table and
6523 // drop the configured key.
6524 for (slug, kind, table, base_url) in [
6525 (
6526 "deepseek-anthropic",
6527 ProviderKind::DeepseekAnthropic,
6528 "deepseek-anthropic",
6529 DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
6530 ),
6531 (
6532 "minimax-anthropic",
6533 ProviderKind::MinimaxAnthropic,
6534 "minimax-anthropic",
6535 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
6536 ),
6537 (
6538 "minimax_anthropic",
6539 ProviderKind::MinimaxAnthropic,
6540 "minimax-anthropic",
6541 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
6542 ),
6543 ] {
6544 unsafe {
6545 env::set_var("CODEWHALE_PROVIDER", slug);
6546 }
6547 let config: ConfigToml = toml::from_str(&format!(
6548 "[providers.{table}]\napi_key = \"sk-legacy-{table}\"\n"
6549 ))
6550 .expect("legacy table config");
6551 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6552
6553 assert_eq!(resolved.provider, kind, "env slug {slug}");
6554 assert_eq!(
6555 resolved.api_key.as_deref(),
6556 Some(format!("sk-legacy-{table}").as_str()),
6557 "env slug {slug} must read the user's own named table"
6558 );
6559 assert_eq!(
6560 resolved.api_key_source,
6561 Some(RuntimeApiKeySource::ConfigFile)
6562 );
6563 assert_eq!(resolved.base_url, base_url, "env slug {slug}");
6564 unsafe {
6565 env::remove_var("CODEWHALE_PROVIDER");
6566 }
6567 }
6568 }
6569
6570 #[test]
6571 fn config_set_legacy_dual_wire_slug_keeps_named_table_identity() {
6572 let _lock = env_lock();
6573 let _env = EnvGuard::without_deepseek_runtime_overrides();
6574 // `config set provider <legacy-slug>` must keep the table-owning kind —
6575 // collapsing to the primary would rewrite the config to a provider whose
6576 // table the user never configured.
6577 let mut config = ConfigToml::default();
6578 config
6579 .set_value("provider", "minimax-anthropic")
6580 .expect("legacy slug provider set");
6581 assert_eq!(config.provider, ProviderKind::MinimaxAnthropic);
6582
6583 // Flat `providers.<legacy>.<field>` keys target the legacy table, not a
6584 // synthetic custom provider.
6585 config
6586 .set_value("providers.minimax_anthropic.api_key", "sk-minimax-legacy")
6587 .expect("legacy table api_key set");
6588 assert_eq!(
6589 config.providers.minimax_anthropic.api_key.as_deref(),
6590 Some("sk-minimax-legacy")
6591 );
6592 assert!(config.providers.extras.is_empty());
6593 assert_eq!(
6594 config
6595 .get_value("providers.minimax_anthropic.api_key")
6596 .as_deref(),
6597 Some("sk-minimax-legacy")
6598 );
6599 config
6600 .unset_value("providers.minimax_anthropic.api_key")
6601 .expect("legacy table api_key unset");
6602 assert_eq!(config.providers.minimax_anthropic.api_key, None);
6603 }
6604
6605 #[test]
6606 fn sakana_env_overrides_resolve_fugu_route() {
6607 let _lock = env_lock();
6608 let _env = EnvGuard::without_deepseek_runtime_overrides();
6609 unsafe {
6610 env::set_var("CODEWHALE_PROVIDER", "sakana");
6611 env::set_var("SAKANA_BASE_URL", "https://sakana.example/v1");
6612 env::set_var("SAKANA_MODEL", "fugu-ultra-20260615");
6613 }
6614
6615 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6616
6617 assert_eq!(resolved.provider, ProviderKind::Sakana);
6618 assert_eq!(resolved.base_url, "https://sakana.example/v1");
6619 assert_eq!(resolved.model, "fugu-ultra-20260615");
6620 }
6621
6622 #[test]
6623 fn moonshot_provider_preserves_explicit_kimi_k26() {
6624 let _lock = env_lock();
6625 let _env = EnvGuard::without_deepseek_runtime_overrides();
6626 let mut config = ConfigToml {
6627 provider: ProviderKind::Moonshot,
6628 ..ConfigToml::default()
6629 };
6630 config.providers.moonshot.model = Some("kimi-k2.6".to_string());
6631
6632 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6633
6634 assert_eq!(resolved.provider, ProviderKind::Moonshot);
6635 assert_eq!(resolved.model, MOONSHOT_KIMI_K2_6_MODEL);
6636 }
6637
6638 #[test]
6639 fn moonshot_legacy_kimi_import_uses_kimi_code_endpoint_and_model() {
6640 let _lock = env_lock();
6641 let _env = EnvGuard::without_deepseek_runtime_overrides();
6642 let mut config = ConfigToml {
6643 provider: ProviderKind::Moonshot,
6644 ..ConfigToml::default()
6645 };
6646 config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string());
6647
6648 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6649
6650 assert_eq!(resolved.provider, ProviderKind::Moonshot);
6651 assert_eq!(resolved.auth_mode.as_deref(), Some("kimi_oauth"));
6652 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
6653 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
6654 assert_eq!(resolved.api_key, None);
6655 assert_eq!(resolved.api_key_source, None);
6656 }
6657
6658 #[test]
6659 fn moonshot_kimi_code_api_key_endpoint_defaults_to_kimi_for_coding() {
6660 let _lock = env_lock();
6661 let _env = EnvGuard::without_deepseek_runtime_overrides();
6662 let mut config = ConfigToml {
6663 provider: ProviderKind::Moonshot,
6664 ..ConfigToml::default()
6665 };
6666 config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
6667 config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
6668
6669 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6670
6671 assert_eq!(resolved.provider, ProviderKind::Moonshot);
6672 assert_eq!(resolved.auth_mode, None);
6673 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
6674 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
6675 assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
6676 assert_eq!(
6677 resolved.api_key_source,
6678 Some(RuntimeApiKeySource::ConfigFile)
6679 );
6680 }
6681
6682 /// `CODEWHALE_PROVIDER` is the user-facing env alias for switching the
6683 /// active provider. It must be honored by the runtime resolver and win
6684 /// over a root `provider = "deepseek"` config entry.
6685 #[test]
6686 fn codewhale_provider_env_switches_active_provider() {
6687 let _lock = env_lock();
6688 let _env = EnvGuard::without_deepseek_runtime_overrides();
6689 // Safety: test-only env mutation guarded by env_lock().
6690 unsafe {
6691 env::set_var("CODEWHALE_PROVIDER", "moonshot");
6692 }
6693 let mut config = ConfigToml {
6694 provider: ProviderKind::Deepseek,
6695 ..ConfigToml::default()
6696 };
6697 config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
6698 config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
6699
6700 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6701
6702 assert_eq!(resolved.provider, ProviderKind::Moonshot);
6703 assert_eq!(
6704 resolved.provider_source,
6705 ProviderSource::Env("CODEWHALE_PROVIDER")
6706 );
6707 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
6708 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
6709 assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
6710 }
6711
6712 /// When both `CODEWHALE_PROVIDER` and the legacy `DEEPSEEK_PROVIDER`
6713 /// are set, the public alias wins — a user adopting `CODEWHALE_*` in a
6714 /// fresh shell config is not tripped up by a stale legacy export still
6715 /// living in their dotfiles.
6716 #[test]
6717 fn codewhale_provider_env_wins_over_deepseek_provider_env() {
6718 let _lock = env_lock();
6719 let _env = EnvGuard::without_deepseek_runtime_overrides();
6720 // Safety: test-only env mutation guarded by env_lock().
6721 unsafe {
6722 env::set_var("CODEWHALE_PROVIDER", "moonshot");
6723 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
6724 }
6725 let config = ConfigToml {
6726 provider: ProviderKind::Deepseek,
6727 ..ConfigToml::default()
6728 };
6729
6730 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6731
6732 assert_eq!(resolved.provider, ProviderKind::Moonshot);
6733 assert_eq!(
6734 resolved.provider_source,
6735 ProviderSource::Env("CODEWHALE_PROVIDER")
6736 );
6737 }
6738
6739 #[test]
6740 fn legacy_deepseek_provider_env_records_provider_source() {
6741 let _lock = env_lock();
6742 let _env = EnvGuard::without_deepseek_runtime_overrides();
6743 // Safety: test-only env mutation guarded by env_lock().
6744 unsafe {
6745 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
6746 }
6747 let config = ConfigToml {
6748 provider: ProviderKind::Deepseek,
6749 ..ConfigToml::default()
6750 };
6751
6752 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6753
6754 assert_eq!(resolved.provider, ProviderKind::Openrouter);
6755 assert_eq!(
6756 resolved.provider_source,
6757 ProviderSource::Env("DEEPSEEK_PROVIDER")
6758 );
6759 }
6760
6761 #[test]
6762 fn cli_provider_records_provider_source() {
6763 let _lock = env_lock();
6764 let _env = EnvGuard::without_deepseek_runtime_overrides();
6765 // Safety: test-only env mutation guarded by env_lock().
6766 unsafe {
6767 env::set_var("CODEWHALE_PROVIDER", "moonshot");
6768 }
6769 let cli = CliRuntimeOverrides {
6770 provider: Some(ProviderKind::Openai),
6771 ..CliRuntimeOverrides::default()
6772 };
6773 let config = ConfigToml {
6774 provider: ProviderKind::Deepseek,
6775 ..ConfigToml::default()
6776 };
6777
6778 let resolved = config.resolve_runtime_options(&cli);
6779
6780 assert_eq!(resolved.provider, ProviderKind::Openai);
6781 assert_eq!(resolved.provider_source, ProviderSource::Cli);
6782 }
6783
6784 #[test]
6785 fn config_provider_records_provider_source() {
6786 let _lock = env_lock();
6787 let _env = EnvGuard::without_deepseek_runtime_overrides();
6788 let config = ConfigToml {
6789 provider: ProviderKind::Moonshot,
6790 ..ConfigToml::default()
6791 };
6792
6793 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6794
6795 assert_eq!(resolved.provider, ProviderKind::Moonshot);
6796 assert_eq!(resolved.provider_source, ProviderSource::Config);
6797 }
6798
6799 /// `CODEWHALE_MODEL` is the user-facing env alias for picking a model
6800 /// against the active provider. It must be honored by the runtime
6801 /// resolver in place of `DEEPSEEK_MODEL`.
6802 #[test]
6803 fn codewhale_model_env_alias_overrides_default_for_active_provider() {
6804 let _lock = env_lock();
6805 let _env = EnvGuard::without_deepseek_runtime_overrides();
6806 // Safety: test-only env mutation guarded by env_lock().
6807 unsafe {
6808 env::set_var("CODEWHALE_PROVIDER", "moonshot");
6809 env::set_var("CODEWHALE_MODEL", "custom-kimi-test-model");
6810 }
6811 let config = ConfigToml::default();
6812
6813 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6814
6815 assert_eq!(resolved.provider, ProviderKind::Moonshot);
6816 assert_eq!(resolved.model, "custom-kimi-test-model");
6817 }
6818
6819 #[test]
6820 fn blank_codewhale_model_env_alias_does_not_override_default_for_active_provider() {
6821 let _lock = env_lock();
6822 let _env = EnvGuard::without_deepseek_runtime_overrides();
6823 // Safety: test-only env mutation guarded by env_lock().
6824 unsafe {
6825 env::set_var("CODEWHALE_PROVIDER", "moonshot");
6826 env::set_var("CODEWHALE_MODEL", " ");
6827 }
6828 let config = ConfigToml::default();
6829
6830 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6831
6832 assert_eq!(resolved.provider, ProviderKind::Moonshot);
6833 assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
6834 }
6835
6836 #[test]
6837 fn deepseek_default_text_model_legacy_alias_still_overrides_active_provider_model() {
6838 let _lock = env_lock();
6839 let _env = EnvGuard::without_deepseek_runtime_overrides();
6840 // Safety: test-only env mutation guarded by env_lock().
6841 unsafe {
6842 env::set_var("CODEWHALE_PROVIDER", "moonshot");
6843 env::set_var("DEEPSEEK_DEFAULT_TEXT_MODEL", "legacy-env-model");
6844 }
6845 let config = ConfigToml::default();
6846
6847 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6848
6849 assert_eq!(resolved.provider, ProviderKind::Moonshot);
6850 assert_eq!(resolved.model, "legacy-env-model");
6851 }
6852
6853 #[test]
6854 fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() {
6855 let _lock = env_lock();
6856 let _env = EnvGuard::without_deepseek_runtime_overrides();
6857 let config = ConfigToml {
6858 provider: ProviderKind::WanjieArk,
6859 ..ConfigToml::default()
6860 };
6861
6862 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6863
6864 assert_eq!(resolved.provider, ProviderKind::WanjieArk);
6865 assert_eq!(resolved.base_url, DEFAULT_WANJIE_ARK_BASE_URL);
6866 assert_eq!(resolved.model, DEFAULT_WANJIE_ARK_MODEL);
6867 }
6868
6869 #[test]
6870 fn sglang_provider_defaults_to_local_endpoint_and_model() {
6871 let _lock = env_lock();
6872 let _env = EnvGuard::without_deepseek_runtime_overrides();
6873 let config = ConfigToml {
6874 provider: ProviderKind::Sglang,
6875 ..ConfigToml::default()
6876 };
6877
6878 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6879
6880 assert_eq!(resolved.provider, ProviderKind::Sglang);
6881 assert_eq!(resolved.base_url, DEFAULT_SGLANG_BASE_URL);
6882 assert_eq!(resolved.model, DEFAULT_SGLANG_MODEL);
6883 }
6884
6885 #[test]
6886 fn vllm_provider_defaults_to_local_endpoint_and_model() {
6887 let _lock = env_lock();
6888 let _env = EnvGuard::without_deepseek_runtime_overrides();
6889 let config = ConfigToml {
6890 provider: ProviderKind::Vllm,
6891 ..ConfigToml::default()
6892 };
6893
6894 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6895
6896 assert_eq!(resolved.provider, ProviderKind::Vllm);
6897 assert_eq!(resolved.base_url, DEFAULT_VLLM_BASE_URL);
6898 assert_eq!(resolved.model, DEFAULT_VLLM_MODEL);
6899 }
6900
6901 #[test]
6902 fn ollama_provider_defaults_to_local_endpoint_and_unresolved_model() {
6903 let _lock = env_lock();
6904 let _env = EnvGuard::without_deepseek_runtime_overrides();
6905 let config = ConfigToml {
6906 provider: ProviderKind::Ollama,
6907 ..ConfigToml::default()
6908 };
6909
6910 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6911
6912 assert_eq!(resolved.provider, ProviderKind::Ollama);
6913 assert_eq!(resolved.base_url, DEFAULT_OLLAMA_BASE_URL);
6914 // Pre-refresh window: do not invent a hosted DeepSeek id. A live
6915 // `GET /v1/models` (same tags as `/api/tags`) must replace this marker.
6916 assert_eq!(resolved.model, "unknown");
6917 assert_eq!(resolved.model, DEFAULT_OLLAMA_MODEL);
6918 assert_eq!(resolved.api_key, None);
6919 }
6920
6921 #[test]
6922 fn ollama_cloud_endpoint_is_official_but_neighboring_routes_are_custom() {
6923 let _lock = env_lock();
6924 let _env = EnvGuard::without_deepseek_runtime_overrides();
6925
6926 assert!(provider_base_url_is_official(
6927 ProviderKind::Ollama,
6928 DEFAULT_OLLAMA_BASE_URL
6929 ));
6930 for base_url in [
6931 provider::OLLAMA_CLOUD_BASE_URL,
6932 "https://ollama.com/v1/",
6933 " HTTPS://OLLAMA.COM/v1/ ",
6934 ] {
6935 for provider in [ProviderKind::Ollama, ProviderKind::OllamaCloud] {
6936 assert!(provider_base_url_is_official(provider, base_url));
6937 assert!(!provider_preserves_custom_base_url_model(
6938 provider, base_url
6939 ));
6940 }
6941 }
6942
6943 for base_url in [
6944 "http://ollama.com/v1",
6945 "https://ollama.com/api",
6946 "https://ollama.com/v1/preview",
6947 "https://ollama.com.evil.example/v1",
6948 "https://ollama-gateway.example/v1",
6949 ] {
6950 for provider in [ProviderKind::Ollama, ProviderKind::OllamaCloud] {
6951 assert!(!provider_base_url_is_official(provider, base_url));
6952 assert!(provider_preserves_custom_base_url_model(provider, base_url));
6953 }
6954 }
6955 }
6956
6957 #[test]
6958 fn explicit_ollama_cloud_defaults_to_hosted_route_and_is_not_keyless() {
6959 let _lock = env_lock();
6960 let _env = EnvGuard::without_deepseek_runtime_overrides();
6961 let config = ConfigToml {
6962 provider: ProviderKind::OllamaCloud,
6963 ..ConfigToml::default()
6964 };
6965
6966 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6967
6968 assert_eq!(resolved.provider, ProviderKind::OllamaCloud);
6969 assert_eq!(resolved.base_url, DEFAULT_OLLAMA_CLOUD_BASE_URL);
6970 assert_eq!(resolved.model, DEFAULT_OLLAMA_CLOUD_MODEL);
6971 assert_eq!(resolved.api_key, None);
6972 }
6973
6974 #[test]
6975 fn ollama_cloud_preserves_provider_authoritative_model_ids() {
6976 let _lock = env_lock();
6977 let _env = EnvGuard::without_deepseek_runtime_overrides();
6978
6979 let mut configured = ConfigToml {
6980 provider: ProviderKind::OllamaCloud,
6981 ..ConfigToml::default()
6982 };
6983 configured.providers.ollama_cloud.model = Some("vendor/model:tag".to_string());
6984 let resolved = configured.resolve_runtime_options(&CliRuntimeOverrides::default());
6985 assert_eq!(resolved.model, "vendor/model:tag");
6986
6987 let persisted_root = ConfigToml {
6988 provider: ProviderKind::OllamaCloud,
6989 default_text_model: Some("deepseek-v4-flash:0731".to_string()),
6990 ..ConfigToml::default()
6991 };
6992 let resolved = persisted_root.resolve_runtime_options(&CliRuntimeOverrides::default());
6993 assert_eq!(resolved.model, "deepseek-v4-flash:0731");
6994 }
6995
6996 #[test]
6997 fn ollama_cloud_env_prefers_pi_compatible_name_then_official_name() {
6998 let _lock = env_lock();
6999 let _env = EnvGuard::without_deepseek_runtime_overrides();
7000 // Safety: test-only environment mutation guarded by a module mutex.
7001 unsafe {
7002 env::set_var("DEEPSEEK_PROVIDER", "ollama-cloud");
7003 env::set_var("OLLAMA_CLOUD_API_KEY", "pi-compatible-key");
7004 env::set_var("OLLAMA_API_KEY", "official-fallback-key");
7005 }
7006
7007 let preferred = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7008 assert_eq!(preferred.provider, ProviderKind::OllamaCloud);
7009 assert_eq!(preferred.api_key.as_deref(), Some("pi-compatible-key"));
7010 assert_eq!(preferred.api_key_source, Some(RuntimeApiKeySource::Env));
7011
7012 // Safety: same serialized test restores both values through EnvGuard.
7013 unsafe { env::remove_var("OLLAMA_CLOUD_API_KEY") };
7014 let fallback = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7015 assert_eq!(fallback.api_key.as_deref(), Some("official-fallback-key"));
7016 assert_eq!(fallback.api_key_source, Some(RuntimeApiKeySource::Env));
7017 }
7018
7019 #[test]
7020 fn local_ollama_never_consumes_the_cloud_specific_environment_key() {
7021 let _lock = env_lock();
7022 let _env = EnvGuard::without_deepseek_runtime_overrides();
7023 // Safety: test-only environment mutation guarded by a module mutex.
7024 unsafe { env::set_var("OLLAMA_CLOUD_API_KEY", "must-not-reach-local-ollama") };
7025 let config = ConfigToml {
7026 provider: ProviderKind::Ollama,
7027 ..ConfigToml::default()
7028 };
7029
7030 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7031
7032 assert_eq!(resolved.provider, ProviderKind::Ollama);
7033 assert_eq!(resolved.base_url, DEFAULT_OLLAMA_BASE_URL);
7034 assert_eq!(resolved.api_key, None);
7035 }
7036
7037 #[test]
7038 fn exact_legacy_ollama_cloud_tuple_migrates_in_memory_without_writes() {
7039 let _lock = env_lock();
7040 let _env = EnvGuard::without_deepseek_runtime_overrides();
7041 let store = Arc::new(RecordingSecretsStore::with_entries(&[(
7042 "ollama",
7043 "legacy-cloud-key",
7044 )]));
7045 let secrets = Secrets::new(store.clone());
7046 let mut config = ConfigToml {
7047 provider: ProviderKind::Ollama,
7048 ..ConfigToml::default()
7049 };
7050 config.providers.ollama.base_url = Some(provider::OLLAMA_CLOUD_BASE_URL.to_string());
7051 config.providers.ollama.model = Some("legacy-cloud-model".to_string());
7052 let before = toml::to_string(&config).expect("serialize pre-migration config");
7053
7054 let resolved =
7055 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7056
7057 assert_eq!(resolved.provider, ProviderKind::OllamaCloud);
7058 assert_eq!(resolved.base_url, provider::OLLAMA_CLOUD_BASE_URL);
7059 assert_eq!(resolved.model, "legacy-cloud-model");
7060 assert_eq!(resolved.api_key.as_deref(), Some("legacy-cloud-key"));
7061 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
7062 assert_eq!(
7063 store.gets.lock().unwrap().as_slice(),
7064 ["ollama-cloud", "ollama"]
7065 );
7066 assert!(store.sets.lock().unwrap().is_empty());
7067 assert!(store.deletes.lock().unwrap().is_empty());
7068 assert_eq!(
7069 toml::to_string(&config).expect("serialize post-migration config"),
7070 before,
7071 "runtime migration must not rewrite the parsed config"
7072 );
7073 }
7074
7075 #[test]
7076 fn explicit_ollama_cloud_uses_only_its_new_secret_slot() {
7077 let _lock = env_lock();
7078 let _env = EnvGuard::without_deepseek_runtime_overrides();
7079 let store = Arc::new(RecordingSecretsStore::with_entries(&[
7080 ("ollama-cloud", "cloud-key"),
7081 ("ollama", "must-not-be-consumed"),
7082 ]));
7083 let secrets = Secrets::new(store.clone());
7084 let mut config = ConfigToml {
7085 provider: ProviderKind::OllamaCloud,
7086 ..ConfigToml::default()
7087 };
7088 config.providers.ollama.base_url = Some(provider::OLLAMA_CLOUD_BASE_URL.to_string());
7089
7090 let resolved =
7091 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7092
7093 assert_eq!(resolved.provider, ProviderKind::OllamaCloud);
7094 assert_eq!(resolved.api_key.as_deref(), Some("cloud-key"));
7095 assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama-cloud"]);
7096 assert!(store.sets.lock().unwrap().is_empty());
7097 assert!(store.deletes.lock().unwrap().is_empty());
7098 }
7099
7100 #[test]
7101 fn explicit_ollama_cloud_never_falls_back_to_local_secret_slot() {
7102 let _lock = env_lock();
7103 let _env = EnvGuard::without_deepseek_runtime_overrides();
7104 let store = Arc::new(RecordingSecretsStore::with_entries(&[(
7105 "ollama",
7106 "must-not-be-consumed",
7107 )]));
7108 let secrets = Secrets::new(store.clone());
7109 let config = ConfigToml {
7110 provider: ProviderKind::OllamaCloud,
7111 ..ConfigToml::default()
7112 };
7113
7114 let resolved =
7115 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7116
7117 assert_eq!(resolved.api_key, None);
7118 assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama-cloud"]);
7119 }
7120
7121 #[test]
7122 fn neighboring_legacy_ollama_routes_do_not_migrate_or_probe_cloud_secrets() {
7123 let _lock = env_lock();
7124 let _env = EnvGuard::without_deepseek_runtime_overrides();
7125
7126 for base_url in [
7127 "http://ollama.com/v1",
7128 "https://ollama.com/api",
7129 "https://ollama.com/v1/preview",
7130 "https://ollama.com.evil.example/v1",
7131 "https://ollama-gateway.example/v1",
7132 ] {
7133 let store = Arc::new(RecordingSecretsStore::with_entries(&[
7134 ("ollama-cloud", "cloud-key"),
7135 ("ollama", "legacy-key"),
7136 ]));
7137 let secrets = Secrets::new(store.clone());
7138 let mut config = ConfigToml {
7139 provider: ProviderKind::Ollama,
7140 ..ConfigToml::default()
7141 };
7142 config.providers.ollama.base_url = Some(base_url.to_string());
7143
7144 let resolved =
7145 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7146
7147 assert_eq!(resolved.provider, ProviderKind::Ollama, "{base_url}");
7148 assert_eq!(resolved.api_key, None, "{base_url}");
7149 assert!(store.gets.lock().unwrap().is_empty(), "{base_url}");
7150 }
7151 }
7152
7153 #[test]
7154 fn self_hosted_providers_do_not_probe_secret_store_by_default() {
7155 let _lock = env_lock();
7156 let _env = EnvGuard::without_deepseek_runtime_overrides();
7157 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
7158 let secrets = Secrets::new(store.clone());
7159
7160 for provider in [
7161 ProviderKind::Sglang,
7162 ProviderKind::Vllm,
7163 ProviderKind::Ollama,
7164 ] {
7165 let config = ConfigToml {
7166 provider,
7167 ..ConfigToml::default()
7168 };
7169
7170 let resolved =
7171 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7172
7173 assert_eq!(resolved.provider, provider);
7174 assert_eq!(resolved.api_key, None);
7175 }
7176
7177 assert!(
7178 store.gets.lock().unwrap().is_empty(),
7179 "self-hosted providers should not read the secret store by default"
7180 );
7181 }
7182
7183 #[test]
7184 fn self_hosted_api_key_auth_can_use_secret_store_when_requested() {
7185 let _lock = env_lock();
7186 let _env = EnvGuard::without_deepseek_runtime_overrides();
7187 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
7188 let secrets = Secrets::new(store.clone());
7189 let config = ConfigToml {
7190 provider: ProviderKind::Ollama,
7191 auth_mode: Some("api_key".to_string()),
7192 ..ConfigToml::default()
7193 };
7194
7195 let resolved =
7196 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7197
7198 assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
7199 assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]);
7200 }
7201
7202 #[test]
7203 fn moonshot_api_key_mode_can_use_secret_store_by_default() {
7204 let _lock = env_lock();
7205 let _env = EnvGuard::without_deepseek_runtime_overrides();
7206 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
7207 let secrets = Secrets::new(store.clone());
7208 let config = ConfigToml {
7209 provider: ProviderKind::Moonshot,
7210 ..ConfigToml::default()
7211 };
7212
7213 let resolved =
7214 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7215
7216 assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
7217 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
7218 assert_eq!(store.gets.lock().unwrap().as_slice(), ["moonshot"]);
7219 }
7220
7221 #[test]
7222 fn modelstudio_variants_resolve_one_shared_secret_store_slot() {
7223 let _lock = env_lock();
7224 let _env = EnvGuard::without_deepseek_runtime_overrides();
7225 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
7226 let secrets = Secrets::new(store.clone());
7227
7228 // One Alibaba Cloud Model Studio account authenticates every plan/dialect
7229 // variant, so all four resolve the family's single canonical slot.
7230 for provider in [
7231 ProviderKind::ModelstudioTokenPlan,
7232 ProviderKind::ModelstudioTokenPlanAnthropic,
7233 ProviderKind::ModelstudioCodingPlan,
7234 ProviderKind::ModelstudioCodingPlanAnthropic,
7235 ] {
7236 let config = ConfigToml {
7237 provider,
7238 ..ConfigToml::default()
7239 };
7240
7241 let resolved =
7242 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7243
7244 assert_eq!(resolved.provider, provider);
7245 assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
7246 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
7247 }
7248
7249 assert_eq!(
7250 store.gets.lock().unwrap().as_slice(),
7251 [
7252 "modelstudio-token-plan",
7253 "modelstudio-token-plan",
7254 "modelstudio-token-plan",
7255 "modelstudio-token-plan"
7256 ]
7257 );
7258 }
7259
7260 #[test]
7261 fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() {
7262 let _lock = env_lock();
7263 let _env = EnvGuard::without_deepseek_runtime_overrides();
7264 let store = Arc::new(RecordingSecretsStore::with_value("stale-deepseek-key"));
7265 let secrets = Secrets::new(store.clone());
7266 let mut config = ConfigToml::default();
7267 config.providers.deepseek.base_url = Some("http://127.0.0.1:8000/v1".to_string());
7268
7269 let resolved =
7270 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7271
7272 assert_eq!(resolved.provider, ProviderKind::Deepseek);
7273 assert_eq!(resolved.base_url, "http://127.0.0.1:8000/v1");
7274 assert_eq!(resolved.api_key, None);
7275 assert!(
7276 store.gets.lock().unwrap().is_empty(),
7277 "loopback custom endpoints should not read macOS Keychain or any secret store"
7278 );
7279 }
7280
7281 #[test]
7282 fn remote_custom_provider_endpoint_does_not_reuse_ambient_official_credentials() {
7283 let _lock = env_lock();
7284 let _env = EnvGuard::without_deepseek_runtime_overrides();
7285 // Safety: test-only env mutation guarded by env_lock().
7286 unsafe { env::set_var("OPENROUTER_API_KEY", "ambient-official-key") };
7287 let store = Arc::new(RecordingSecretsStore::with_value("saved-official-key"));
7288 let secrets = Secrets::new(store.clone());
7289 let mut config = ConfigToml {
7290 provider: ProviderKind::Openrouter,
7291 ..ConfigToml::default()
7292 };
7293 config.providers.openrouter.base_url = Some("https://gateway.example/v1".to_string());
7294
7295 let resolved =
7296 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7297
7298 assert_eq!(resolved.base_url, "https://gateway.example/v1");
7299 assert_eq!(resolved.api_key, None);
7300 assert_eq!(resolved.api_key_source, None);
7301 assert!(
7302 store.gets.lock().unwrap().is_empty(),
7303 "a custom endpoint must not read the provider's global secret slot"
7304 );
7305 }
7306
7307 #[test]
7308 fn env_custom_provider_endpoint_does_not_reuse_ambient_official_credentials() {
7309 let _lock = env_lock();
7310 let _env = EnvGuard::without_deepseek_runtime_overrides();
7311 // A provider-specific base URL and API key are independent ambient
7312 // exports; the resolver cannot safely assume the key belongs to a remote
7313 // custom gateway. Custom routes must bind their credential explicitly.
7314 // Safety: test-only env mutation guarded by env_lock().
7315 unsafe {
7316 env::set_var("OPENROUTER_BASE_URL", "https://gateway.example/v1");
7317 env::set_var("OPENROUTER_API_KEY", "ambient-official-key");
7318 }
7319 let store = Arc::new(RecordingSecretsStore::with_value("saved-official-key"));
7320 let secrets = Secrets::new(store.clone());
7321 let config = ConfigToml {
7322 provider: ProviderKind::Openrouter,
7323 ..ConfigToml::default()
7324 };
7325
7326 let resolved =
7327 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7328
7329 assert_eq!(resolved.base_url, "https://gateway.example/v1");
7330 assert_eq!(resolved.api_key, None);
7331 assert_eq!(resolved.api_key_source, None);
7332 assert!(
7333 store.gets.lock().unwrap().is_empty(),
7334 "an env-supplied custom endpoint must not read the provider's global secret slot"
7335 );
7336 }
7337
7338 #[test]
7339 fn remote_custom_provider_endpoint_accepts_only_explicitly_bound_credentials() {
7340 let _lock = env_lock();
7341 let _env = EnvGuard::without_deepseek_runtime_overrides();
7342 // Safety: test-only env mutation guarded by env_lock().
7343 unsafe { env::set_var("OPENROUTER_API_KEY", "ambient-official-key") };
7344 let store = Arc::new(RecordingSecretsStore::with_value("saved-official-key"));
7345 let secrets = Secrets::new(store.clone());
7346 let mut config = ConfigToml {
7347 provider: ProviderKind::Openrouter,
7348 ..ConfigToml::default()
7349 };
7350 config.providers.openrouter.base_url = Some("https://gateway.example/v1".to_string());
7351 config.providers.openrouter.api_key = Some("route-bound-key".to_string());
7352
7353 let resolved =
7354 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7355
7356 assert_eq!(resolved.api_key.as_deref(), Some("route-bound-key"));
7357 assert_eq!(
7358 resolved.api_key_source,
7359 Some(RuntimeApiKeySource::ConfigFile)
7360 );
7361 assert!(store.gets.lock().unwrap().is_empty());
7362 }
7363
7364 #[test]
7365 fn auth_mode_none_suppresses_every_runtime_credential_source() {
7366 let _lock = env_lock();
7367 let _env = EnvGuard::without_deepseek_runtime_overrides();
7368 // Safety: test-only env mutation guarded by env_lock().
7369 unsafe { env::set_var("OPENROUTER_API_KEY", "ambient-key") };
7370 let store = Arc::new(RecordingSecretsStore::with_value("saved-key"));
7371 let secrets = Secrets::new(store.clone());
7372 let mut config = ConfigToml {
7373 provider: ProviderKind::Openrouter,
7374 auth_mode: Some("none".to_string()),
7375 ..ConfigToml::default()
7376 };
7377 config.providers.openrouter.api_key = Some("configured-key".to_string());
7378 config.http_headers.insert(
7379 "aUtHoRiZaTiOn".to_string(),
7380 "Bearer configured-header-secret".to_string(),
7381 );
7382 config.providers.openrouter.http_headers.insert(
7383 "X-API-Key".to_string(),
7384 "configured-x-api-key-secret".to_string(),
7385 );
7386 config.providers.openrouter.http_headers.insert(
7387 "Api-Key".to_string(),
7388 "configured-api-key-secret".to_string(),
7389 );
7390 config.providers.openrouter.http_headers.extend([
7391 (
7392 "Proxy-Authorization".to_string(),
7393 "Basic configured-proxy-secret".to_string(),
7394 ),
7395 (
7396 "X-Auth-Token".to_string(),
7397 "configured-auth-token".to_string(),
7398 ),
7399 (
7400 "X-Access-Token".to_string(),
7401 "configured-access-token".to_string(),
7402 ),
7403 (
7404 "X-Goog-Api-Key".to_string(),
7405 "configured-google-key".to_string(),
7406 ),
7407 ("Cookie".to_string(), "session=secret".to_string()),
7408 ]);
7409 config
7410 .providers
7411 .openrouter
7412 .http_headers
7413 .insert("X-Route-Metadata".to_string(), "safe".to_string());
7414 let cli = CliRuntimeOverrides {
7415 api_key: Some("explicit-cli-key".to_string()),
7416 ..CliRuntimeOverrides::default()
7417 };
7418
7419 let resolved = config.resolve_runtime_options_with_secrets(&cli, &secrets);
7420
7421 assert_eq!(resolved.api_key, None);
7422 assert_eq!(resolved.api_key_source, None);
7423 for name in [
7424 "authorization",
7425 "x-api-key",
7426 "api-key",
7427 "proxy-authorization",
7428 "x-auth-token",
7429 "x-access-token",
7430 "x-goog-api-key",
7431 "cookie",
7432 ] {
7433 assert!(
7434 !resolved
7435 .http_headers
7436 .keys()
7437 .any(|candidate| candidate.eq_ignore_ascii_case(name)),
7438 "disabled auth leaked {name}: {:?}",
7439 resolved.http_headers
7440 );
7441 }
7442 assert_eq!(
7443 resolved
7444 .http_headers
7445 .get("X-Route-Metadata")
7446 .map(String::as_str),
7447 Some("safe")
7448 );
7449 assert!(
7450 store.gets.lock().unwrap().is_empty(),
7451 "disabled auth must not probe any durable credential source"
7452 );
7453 }
7454
7455 #[test]
7456 fn deepseek_official_endpoint_family_shares_canonical_model_namespace() {
7457 let _lock = env_lock();
7458 let _env = EnvGuard::without_deepseek_runtime_overrides();
7459
7460 for base_url in [
7461 "https://api.deepseek.com",
7462 "https://api.deepseek.com/v1/",
7463 "https://api.deepseek.com/beta",
7464 ] {
7465 assert!(provider_base_url_is_official(
7466 ProviderKind::Deepseek,
7467 base_url
7468 ));
7469 assert!(!provider_preserves_custom_base_url_model(
7470 ProviderKind::Deepseek,
7471 base_url
7472 ));
7473 }
7474 assert!(!provider_base_url_is_official(
7475 ProviderKind::Deepseek,
7476 "https://api.deepseek.com.evil.example/v1"
7477 ));
7478 }
7479
7480 #[test]
7481 fn ollama_provider_preserves_model_tags() {
7482 let _lock = env_lock();
7483 let _env = EnvGuard::without_deepseek_runtime_overrides();
7484 let cli = CliRuntimeOverrides {
7485 provider: Some(ProviderKind::Ollama),
7486 model: Some("deepseek-coder-v2:16b".to_string()),
7487 ..CliRuntimeOverrides::default()
7488 };
7489
7490 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7491
7492 assert_eq!(resolved.provider, ProviderKind::Ollama);
7493 assert_eq!(resolved.model, "deepseek-coder-v2:16b");
7494 }
7495
7496 #[test]
7497 fn ollama_custom_remote_does_not_inherit_ambient_or_saved_official_key() {
7498 let _lock = env_lock();
7499 let _env = EnvGuard::without_deepseek_runtime_overrides();
7500 // Safety: test-only environment mutation guarded by a module mutex.
7501 unsafe {
7502 env::set_var("DEEPSEEK_PROVIDER", "ollama-local");
7503 env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1");
7504 env::set_var("OLLAMA_API_KEY", "ollama-env-key");
7505 }
7506
7507 let store = Arc::new(RecordingSecretsStore::with_value("ollama-saved-key"));
7508 let secrets = Secrets::new(store.clone());
7509
7510 let resolved = ConfigToml::default()
7511 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7512
7513 assert_eq!(resolved.provider, ProviderKind::Ollama);
7514 assert_eq!(resolved.base_url, "http://ollama.example/v1");
7515 assert_eq!(resolved.api_key, None);
7516 assert_eq!(resolved.api_key_source, None);
7517 assert!(
7518 store.gets.lock().unwrap().is_empty(),
7519 "a custom Ollama endpoint must not read the official ollama secret slot"
7520 );
7521 }
7522
7523 #[test]
7524 fn openrouter_env_overrides_key_and_model_when_config_missing() {
7525 let _lock = env_lock();
7526 let _env = EnvGuard::without_deepseek_runtime_overrides();
7527 // Safety: test-only environment mutation guarded by a module mutex.
7528 unsafe {
7529 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
7530 env::set_var("OPENROUTER_API_KEY", "or-env-key");
7531 env::set_var("OPENROUTER_MODEL", "deepseek-v4-flash");
7532 }
7533
7534 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7535
7536 assert_eq!(resolved.provider, ProviderKind::Openrouter);
7537 assert_eq!(resolved.api_key.as_deref(), Some("or-env-key"));
7538 assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
7539 assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL);
7540 }
7541
7542 #[test]
7543 fn xiaomi_mimo_custom_env_url_does_not_inherit_ambient_key() {
7544 let _lock = env_lock();
7545 let _env = EnvGuard::without_deepseek_runtime_overrides();
7546 // Safety: test-only environment mutation guarded by a module mutex.
7547 unsafe {
7548 env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7549 env::set_var("MIMO_API_KEY", "mimo-env-key");
7550 env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1");
7551 env::set_var("MIMO_MODEL", "mimo-v2.5");
7552 }
7553
7554 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7555
7556 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
7557 assert_eq!(resolved.api_key, None);
7558 assert_eq!(resolved.api_key_source, None);
7559 assert_eq!(resolved.base_url, "https://mimo-gateway.example/v1");
7560 assert_eq!(resolved.model, "mimo-v2.5");
7561 }
7562
7563 #[test]
7564 fn xiaomi_mimo_env_token_plan_mode_uses_token_plan_key_and_endpoint() {
7565 let _lock = env_lock();
7566 let _env = EnvGuard::without_deepseek_runtime_overrides();
7567 // Safety: test-only environment mutation guarded by a module mutex.
7568 unsafe {
7569 env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7570 env::set_var("XIAOMI_MIMO_MODE", "token-plan-cn");
7571 env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key");
7572 env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key");
7573 }
7574
7575 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7576
7577 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
7578 assert_eq!(resolved.api_key.as_deref(), Some("tp-env-key"));
7579 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
7580 assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL);
7581 }
7582
7583 #[test]
7584 fn xiaomi_mimo_env_pay_as_you_go_mode_prefers_standard_key() {
7585 let _lock = env_lock();
7586 let _env = EnvGuard::without_deepseek_runtime_overrides();
7587 // Safety: test-only environment mutation guarded by a module mutex.
7588 unsafe {
7589 env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7590 env::set_var("XIAOMI_MIMO_MODE", "pay-as-you-go");
7591 env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key");
7592 env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key");
7593 }
7594
7595 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7596
7597 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
7598 assert_eq!(resolved.api_key.as_deref(), Some("sk-env-key"));
7599 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
7600 assert_eq!(resolved.base_url, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL);
7601 }
7602
7603 #[test]
7604 fn xiaomi_mimo_pay_as_you_go_mode_never_falls_back_to_a_token_plan_key() {
7605 let _lock = env_lock();
7606 let _env = EnvGuard::without_deepseek_runtime_overrides();
7607 // Safety: test-only environment mutation guarded by a module mutex.
7608 unsafe {
7609 env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7610 env::set_var("XIAOMI_MIMO_MODE", "pay-as-you-go");
7611 env::set_var("MIMO_TOKEN_PLAN_API_KEY", "tp-env-key");
7612 }
7613
7614 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7615
7616 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
7617 assert_eq!(resolved.base_url, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL);
7618 assert_eq!(resolved.api_key, None);
7619 assert_eq!(resolved.api_key_source, None);
7620 }
7621
7622 #[test]
7623 fn novita_env_overrides_key_and_model_when_config_missing() {
7624 let _lock = env_lock();
7625 let _env = EnvGuard::without_deepseek_runtime_overrides();
7626 // Safety: test-only environment mutation guarded by a module mutex.
7627 unsafe {
7628 env::set_var("DEEPSEEK_PROVIDER", "novita");
7629 env::set_var("NOVITA_API_KEY", "novita-env-key");
7630 env::set_var("NOVITA_MODEL", "deepseek-v4-flash");
7631 }
7632
7633 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7634
7635 assert_eq!(resolved.provider, ProviderKind::Novita);
7636 assert_eq!(resolved.api_key.as_deref(), Some("novita-env-key"));
7637 assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
7638 assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
7639 }
7640
7641 #[test]
7642 fn fireworks_env_overrides_key_and_model_when_config_missing() {
7643 let _lock = env_lock();
7644 let _env = EnvGuard::without_deepseek_runtime_overrides();
7645 // Safety: test-only environment mutation guarded by a module mutex.
7646 unsafe {
7647 env::set_var("DEEPSEEK_PROVIDER", "fireworks");
7648 env::set_var("FIREWORKS_API_KEY", "fw-env-key");
7649 env::set_var(
7650 "FIREWORKS_MODEL",
7651 "accounts/fireworks/models/account-specific-model",
7652 );
7653 }
7654
7655 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7656
7657 assert_eq!(resolved.provider, ProviderKind::Fireworks);
7658 assert_eq!(resolved.api_key.as_deref(), Some("fw-env-key"));
7659 assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
7660 assert_eq!(
7661 resolved.model,
7662 "accounts/fireworks/models/account-specific-model"
7663 );
7664 }
7665
7666 #[test]
7667 fn siliconflow_custom_env_url_does_not_inherit_ambient_key() {
7668 let _lock = env_lock();
7669 let _env = EnvGuard::without_deepseek_runtime_overrides();
7670 // Safety: test-only environment mutation guarded by a module mutex.
7671 unsafe {
7672 env::set_var("CODEWHALE_PROVIDER", "siliconflow");
7673 env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
7674 env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1");
7675 env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash");
7676 }
7677
7678 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7679
7680 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7681 assert_eq!(resolved.api_key, None);
7682 assert_eq!(resolved.api_key_source, None);
7683 assert_eq!(resolved.base_url, "https://sf-mirror.example/v1");
7684 assert_eq!(resolved.model, "deepseek-v4-flash");
7685 }
7686
7687 #[test]
7688 fn arcee_provider_defaults_to_direct_api_endpoint_and_model() {
7689 let _lock = env_lock();
7690 let _env = EnvGuard::without_deepseek_runtime_overrides();
7691 let config = ConfigToml {
7692 provider: ProviderKind::Arcee,
7693 ..ConfigToml::default()
7694 };
7695
7696 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7697
7698 assert_eq!(resolved.provider, ProviderKind::Arcee);
7699 assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL);
7700 assert_eq!(resolved.model, DEFAULT_ARCEE_MODEL);
7701 }
7702
7703 #[test]
7704 fn arcee_custom_env_url_does_not_inherit_ambient_key() {
7705 let _lock = env_lock();
7706 let _env = EnvGuard::without_deepseek_runtime_overrides();
7707 // Safety: test-only environment mutation guarded by a module mutex.
7708 unsafe {
7709 env::set_var("CODEWHALE_PROVIDER", "arcee");
7710 env::set_var("ARCEE_API_KEY", "arcee-env-key");
7711 env::set_var("ARCEE_BASE_URL", "https://arcee-mirror.example/api/v1");
7712 env::set_var("ARCEE_MODEL", "trinity-large-preview");
7713 }
7714
7715 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7716
7717 assert_eq!(resolved.provider, ProviderKind::Arcee);
7718 assert_eq!(resolved.api_key, None);
7719 assert_eq!(resolved.api_key_source, None);
7720 assert_eq!(resolved.base_url, "https://arcee-mirror.example/api/v1");
7721 assert_eq!(resolved.model, "trinity-large-preview");
7722 }
7723
7724 #[test]
7725 fn arcee_provider_config_overrides_runtime_defaults() {
7726 let _lock = env_lock();
7727 let _env = EnvGuard::without_deepseek_runtime_overrides();
7728 let mut config = ConfigToml {
7729 provider: ProviderKind::Arcee,
7730 ..ConfigToml::default()
7731 };
7732 config.providers.arcee.api_key = Some("arcee-file-key".to_string());
7733 config.providers.arcee.base_url = Some(DEFAULT_ARCEE_BASE_URL.to_string());
7734 config.providers.arcee.model = Some("arcee-trinity-large-preview".to_string());
7735
7736 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7737
7738 assert_eq!(resolved.provider, ProviderKind::Arcee);
7739 assert_eq!(resolved.api_key.as_deref(), Some("arcee-file-key"));
7740 assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL);
7741 assert_eq!(resolved.model, ARCEE_TRINITY_LARGE_PREVIEW_MODEL);
7742 }
7743
7744 #[test]
7745 fn huggingface_custom_env_url_does_not_inherit_documented_key_names() {
7746 let _lock = env_lock();
7747 let _env = EnvGuard::without_deepseek_runtime_overrides();
7748 // Safety: test-only environment mutation guarded by a module mutex.
7749 unsafe {
7750 env::set_var("CODEWHALE_PROVIDER", "hf");
7751 env::set_var("HUGGINGFACE_API_KEY", "hf-full-key");
7752 env::set_var("HF_TOKEN", "hf-token-fallback");
7753 env::set_var("HUGGINGFACE_BASE_URL", "https://hf-full.example/v1");
7754 env::set_var("HF_BASE_URL", "https://hf-short.example/v1");
7755 env::set_var("HUGGINGFACE_MODEL", "org/full-model");
7756 env::set_var("HF_MODEL", "org/short-model");
7757 }
7758
7759 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7760
7761 assert_eq!(resolved.provider, ProviderKind::Huggingface);
7762 assert_eq!(resolved.api_key, None);
7763 assert_eq!(resolved.api_key_source, None);
7764 assert_eq!(resolved.base_url, "https://hf-full.example/v1");
7765 assert_eq!(resolved.model, "org/full-model");
7766 }
7767
7768 #[test]
7769 fn huggingface_short_custom_env_url_does_not_inherit_ambient_token() {
7770 let _lock = env_lock();
7771 let _env = EnvGuard::without_deepseek_runtime_overrides();
7772 // Safety: test-only environment mutation guarded by a module mutex.
7773 unsafe {
7774 env::set_var("CODEWHALE_PROVIDER", "huggingface");
7775 env::set_var("HF_TOKEN", "hf-token-fallback");
7776 env::set_var("HF_BASE_URL", "https://hf-short.example/v1");
7777 env::set_var("HF_MODEL", "org/short-model");
7778 }
7779
7780 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7781
7782 assert_eq!(resolved.provider, ProviderKind::Huggingface);
7783 assert_eq!(resolved.api_key, None);
7784 assert_eq!(resolved.api_key_source, None);
7785 assert_eq!(resolved.base_url, "https://hf-short.example/v1");
7786 assert_eq!(resolved.model, "org/short-model");
7787 }
7788
7789 #[test]
7790 fn huggingface_token_fallback_resolves_when_primary_api_key_is_blank() {
7791 let _lock = env_lock();
7792 let _env = EnvGuard::without_deepseek_runtime_overrides();
7793 // Safety: test-only environment mutation guarded by a module mutex.
7794 unsafe {
7795 env::set_var("CODEWHALE_PROVIDER", "huggingface");
7796 env::set_var("HUGGINGFACE_API_KEY", " ");
7797 env::set_var("HF_TOKEN", "hf-token-fallback");
7798 }
7799
7800 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7801
7802 assert_eq!(resolved.provider, ProviderKind::Huggingface);
7803 assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback"));
7804 }
7805
7806 #[test]
7807 fn siliconflow_cn_base_url_env_normalizes_model_aliases() {
7808 let _lock = env_lock();
7809 let _env = EnvGuard::without_deepseek_runtime_overrides();
7810 // Safety: test-only environment mutation guarded by a module mutex.
7811 unsafe {
7812 env::set_var("CODEWHALE_PROVIDER", "siliconflow");
7813 env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
7814 env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1");
7815 }
7816
7817 for (alias, expected) in [
7818 ("deepseek-v4-flash", DEFAULT_SILICONFLOW_FLASH_MODEL),
7819 ("deepseek-reasoner", DEFAULT_SILICONFLOW_MODEL),
7820 ] {
7821 // Safety: test-only environment mutation guarded by a module mutex.
7822 unsafe {
7823 env::set_var("SILICONFLOW_MODEL", alias);
7824 }
7825
7826 let resolved =
7827 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7828
7829 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7830 assert_eq!(resolved.base_url, "https://api.siliconflow.cn/v1");
7831 assert_eq!(resolved.model, expected);
7832 }
7833 }
7834
7835 #[test]
7836 fn wanjie_ark_custom_env_url_does_not_inherit_ambient_key() {
7837 let _lock = env_lock();
7838 let _env = EnvGuard::without_deepseek_runtime_overrides();
7839 // Safety: test-only environment mutation guarded by a module mutex.
7840 unsafe {
7841 env::set_var("DEEPSEEK_PROVIDER", "wanjie-ark");
7842 env::set_var("WANJIE_ARK_API_KEY", "wanjie-env-key");
7843 env::set_var("WANJIE_ARK_BASE_URL", "https://wanjie.example/api/v1");
7844 env::set_var("WANJIE_ARK_MODEL", "account-model-id");
7845 }
7846
7847 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7848
7849 assert_eq!(resolved.provider, ProviderKind::WanjieArk);
7850 assert_eq!(resolved.api_key, None);
7851 assert_eq!(resolved.api_key_source, None);
7852 assert_eq!(resolved.base_url, "https://wanjie.example/api/v1");
7853 assert_eq!(resolved.model, "account-model-id");
7854 }
7855
7856 #[test]
7857 fn volcengine_custom_env_alias_url_does_not_inherit_ambient_key() {
7858 let _lock = env_lock();
7859 let _env = EnvGuard::without_deepseek_runtime_overrides();
7860 // Safety: test-only environment mutation guarded by a module mutex.
7861 unsafe {
7862 env::set_var("DEEPSEEK_PROVIDER", "volcengine");
7863 env::set_var("ARK_API_KEY", "volcengine-env-key");
7864 env::set_var("ARK_BASE_URL", "https://volcengine.example/api/coding/v3");
7865 env::set_var("VOLCENGINE_ARK_MODEL", "DeepSeek-V4-Flash");
7866 }
7867
7868 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7869
7870 assert_eq!(resolved.provider, ProviderKind::Volcengine);
7871 assert_eq!(resolved.api_key, None);
7872 assert_eq!(resolved.api_key_source, None);
7873 assert_eq!(
7874 resolved.base_url,
7875 "https://volcengine.example/api/coding/v3"
7876 );
7877 assert_eq!(resolved.model, "DeepSeek-V4-Flash");
7878 }
7879
7880 #[test]
7881 fn openrouter_provider_normalizes_flash_aliases() {
7882 let _lock = env_lock();
7883 let _env = EnvGuard::without_deepseek_runtime_overrides();
7884 let cli = CliRuntimeOverrides {
7885 provider: Some(ProviderKind::Openrouter),
7886 model: Some("deepseek-v4-flash".to_string()),
7887 ..CliRuntimeOverrides::default()
7888 };
7889
7890 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7891
7892 assert_eq!(resolved.provider, ProviderKind::Openrouter);
7893 assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL);
7894 }
7895
7896 #[test]
7897 fn qwen3_6_plus_resolves_to_canonical_on_openrouter() {
7898 let _lock = env_lock();
7899 let _env = EnvGuard::without_deepseek_runtime_overrides();
7900 let config = ConfigToml {
7901 provider: ProviderKind::Openrouter,
7902 ..ConfigToml::default()
7903 };
7904
7905 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides {
7906 model: Some("qwen3.6-plus".to_string()),
7907 ..CliRuntimeOverrides::default()
7908 });
7909
7910 assert_eq!(resolved.provider, ProviderKind::Openrouter);
7911 assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL);
7912 }
7913
7914 #[test]
7915 fn qwen3_6_plus_alias_qwen_dash_resolves() {
7916 let _lock = env_lock();
7917 let _env = EnvGuard::without_deepseek_runtime_overrides();
7918 let config = ConfigToml {
7919 provider: ProviderKind::Openrouter,
7920 ..ConfigToml::default()
7921 };
7922
7923 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides {
7924 model: Some("qwen-3.6-plus".to_string()),
7925 ..CliRuntimeOverrides::default()
7926 });
7927
7928 assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL);
7929 }
7930
7931 #[test]
7932 fn openrouter_provider_normalizes_recent_large_model_aliases() {
7933 let _lock = env_lock();
7934 let _env = EnvGuard::without_deepseek_runtime_overrides();
7935
7936 for (alias, expected) in [
7937 (
7938 "trinity-large-thinking",
7939 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL,
7940 ),
7941 ("qwen3.6-flash", OPENROUTER_QWEN_3_6_FLASH_MODEL),
7942 ("qwen3.6-35b-a3b", OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
7943 ("qwen3.6-max-preview", OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
7944 ("qwen3.6-plus", OPENROUTER_QWEN_3_6_PLUS_MODEL),
7945 ("qwen3.7-plus", OPENROUTER_QWEN_3_7_PLUS_MODEL),
7946 ("qwen-3.7-plus", OPENROUTER_QWEN_3_7_PLUS_MODEL),
7947 ("mimo-v2.5-pro", OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
7948 ("kimi-k2.7-code", OPENROUTER_KIMI_K2_7_CODE_MODEL),
7949 ("kimi", OPENROUTER_KIMI_K2_7_CODE_MODEL),
7950 ("kimi-k2.6", OPENROUTER_KIMI_K2_6_MODEL),
7951 ("minimax-m3", OPENROUTER_MINIMAX_M3_MODEL),
7952 ("minimax-2.7", OPENROUTER_MINIMAX_M2_7_MODEL),
7953 ("gemma-4-31b-it", OPENROUTER_GEMMA_4_31B_MODEL),
7954 ("glm-5.1", OPENROUTER_GLM_5_1_MODEL),
7955 ("glm-5.2", OPENROUTER_GLM_5_2_MODEL),
7956 ("glm-5.3", OPENROUTER_GLM_5_3_MODEL),
7957 ("glm-5.3-flash", OPENROUTER_GLM_5_3_FLASH_MODEL),
7958 ] {
7959 let cli = CliRuntimeOverrides {
7960 provider: Some(ProviderKind::Openrouter),
7961 model: Some(alias.to_string()),
7962 ..CliRuntimeOverrides::default()
7963 };
7964
7965 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7966
7967 assert_eq!(resolved.provider, ProviderKind::Openrouter);
7968 assert_eq!(resolved.model, expected);
7969 }
7970 }
7971
7972 #[test]
7973 fn novita_provider_normalizes_flash_aliases() {
7974 let _lock = env_lock();
7975 let _env = EnvGuard::without_deepseek_runtime_overrides();
7976 let cli = CliRuntimeOverrides {
7977 provider: Some(ProviderKind::Novita),
7978 model: Some("deepseek-v4-flash".to_string()),
7979 ..CliRuntimeOverrides::default()
7980 };
7981
7982 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7983
7984 assert_eq!(resolved.provider, ProviderKind::Novita);
7985 assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
7986 }
7987
7988 #[test]
7989 fn siliconflow_provider_normalizes_flash_aliases() {
7990 let _lock = env_lock();
7991 let _env = EnvGuard::without_deepseek_runtime_overrides();
7992 let cli = CliRuntimeOverrides {
7993 provider: Some(ProviderKind::Siliconflow),
7994 model: Some("deepseek-v4-flash".to_string()),
7995 ..CliRuntimeOverrides::default()
7996 };
7997
7998 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7999
8000 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
8001 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL);
8002 }
8003
8004 #[test]
8005 fn siliconflow_provider_normalizes_reasoning_aliases_to_pro() {
8006 let _lock = env_lock();
8007 let _env = EnvGuard::without_deepseek_runtime_overrides();
8008
8009 for alias in ["deepseek-reasoner", "deepseek-r1"] {
8010 let cli = CliRuntimeOverrides {
8011 provider: Some(ProviderKind::Siliconflow),
8012 model: Some(alias.to_string()),
8013 ..CliRuntimeOverrides::default()
8014 };
8015
8016 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
8017
8018 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
8019 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
8020 }
8021 }
8022
8023 #[test]
8024 fn siliconflow_provider_preserves_deepseek_v3_2_alias() {
8025 let _lock = env_lock();
8026 let _env = EnvGuard::without_deepseek_runtime_overrides();
8027 let cli = CliRuntimeOverrides {
8028 provider: Some(ProviderKind::Siliconflow),
8029 model: Some("deepseek-v3.2".to_string()),
8030 ..CliRuntimeOverrides::default()
8031 };
8032
8033 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
8034
8035 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
8036 assert_eq!(resolved.model, "deepseek-v3.2");
8037 }
8038
8039 #[test]
8040 fn sglang_provider_normalizes_flash_aliases() {
8041 let _lock = env_lock();
8042 let _env = EnvGuard::without_deepseek_runtime_overrides();
8043 let cli = CliRuntimeOverrides {
8044 provider: Some(ProviderKind::Sglang),
8045 model: Some("deepseek-v4-flash".to_string()),
8046 ..CliRuntimeOverrides::default()
8047 };
8048
8049 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
8050
8051 assert_eq!(resolved.provider, ProviderKind::Sglang);
8052 assert_eq!(resolved.model, DEFAULT_SGLANG_FLASH_MODEL);
8053 }
8054
8055 #[test]
8056 fn vllm_provider_normalizes_flash_aliases() {
8057 let _lock = env_lock();
8058 let _env = EnvGuard::without_deepseek_runtime_overrides();
8059 let cli = CliRuntimeOverrides {
8060 provider: Some(ProviderKind::Vllm),
8061 model: Some("deepseek-v4-flash".to_string()),
8062 ..CliRuntimeOverrides::default()
8063 };
8064
8065 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
8066
8067 assert_eq!(resolved.provider, ProviderKind::Vllm);
8068 assert_eq!(resolved.model, DEFAULT_VLLM_FLASH_MODEL);
8069 }
8070
8071 #[test]
8072 fn openrouter_provider_specific_config_overrides_env() {
8073 let _lock = env_lock();
8074 let _env = EnvGuard::without_deepseek_runtime_overrides();
8075 let mut config = ConfigToml {
8076 provider: ProviderKind::Openrouter,
8077 ..ConfigToml::default()
8078 };
8079 config.providers.openrouter.api_key = Some("file-key".to_string());
8080 config.providers.openrouter.base_url = Some("https://or-mirror.example/v1".to_string());
8081
8082 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8083
8084 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
8085 assert_eq!(resolved.base_url, "https://or-mirror.example/v1");
8086 }
8087
8088 #[test]
8089 fn openrouter_custom_base_url_preserves_provider_model() {
8090 let _lock = env_lock();
8091 let _env = EnvGuard::without_deepseek_runtime_overrides();
8092 let mut config = ConfigToml {
8093 provider: ProviderKind::Openrouter,
8094 ..ConfigToml::default()
8095 };
8096 config.providers.openrouter.base_url = Some("https://gateway.example.com/v1".to_string());
8097 config.providers.openrouter.model = Some("DeepSeek-V4-Pro".to_string());
8098
8099 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8100
8101 assert_eq!(resolved.provider, ProviderKind::Openrouter);
8102 assert_eq!(resolved.base_url, "https://gateway.example.com/v1");
8103 assert_eq!(resolved.model, "DeepSeek-V4-Pro");
8104 }
8105
8106 #[test]
8107 fn openai_compatible_tokenhub_route_preserves_provider_scope() {
8108 let _lock = env_lock();
8109 let _env = EnvGuard::without_deepseek_runtime_overrides();
8110 let mut config = ConfigToml {
8111 provider: ProviderKind::Openai,
8112 ..ConfigToml::default()
8113 };
8114 config.providers.openai.api_key = Some("tokenhub-file-key".to_string());
8115 config.providers.openai.base_url = Some("https://tokenhub.tencentmaas.com/v1".to_string());
8116 config.providers.openai.model = Some("deepseek-ai/DeepSeek-V4-Pro".to_string());
8117
8118 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8119
8120 assert_eq!(resolved.provider, ProviderKind::Openai);
8121 assert_eq!(resolved.api_key.as_deref(), Some("tokenhub-file-key"));
8122 assert_eq!(resolved.base_url, "https://tokenhub.tencentmaas.com/v1");
8123 assert_eq!(resolved.model, "deepseek-ai/DeepSeek-V4-Pro");
8124 }
8125
8126 #[test]
8127 fn openrouter_compatible_base_url_preserves_namespaced_wire_model() {
8128 let _lock = env_lock();
8129 let _env = EnvGuard::without_deepseek_runtime_overrides();
8130 let mut config = ConfigToml {
8131 provider: ProviderKind::Openrouter,
8132 ..ConfigToml::default()
8133 };
8134 config.providers.openrouter.base_url = Some("https://openrouter-compatible.example/v1".into());
8135 config.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".into());
8136
8137 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8138
8139 assert_eq!(resolved.provider, ProviderKind::Openrouter);
8140 assert_eq!(resolved.provider_source, ProviderSource::Config);
8141 assert_eq!(
8142 resolved.base_url,
8143 "https://openrouter-compatible.example/v1"
8144 );
8145 assert_eq!(resolved.model, "deepseek/deepseek-v4-pro");
8146 }
8147
8148 #[test]
8149 fn fireworks_custom_base_url_preserves_provider_model() {
8150 let _lock = env_lock();
8151 let _env = EnvGuard::without_deepseek_runtime_overrides();
8152 let mut config = ConfigToml {
8153 provider: ProviderKind::Fireworks,
8154 ..ConfigToml::default()
8155 };
8156 config.providers.fireworks.base_url = Some("https://my-gateway.example/v1".to_string());
8157 config.providers.fireworks.model = Some("DeepSeek-V4-Pro".to_string());
8158
8159 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8160
8161 assert_eq!(resolved.provider, ProviderKind::Fireworks);
8162 assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
8163 // Custom base URL skips provider-specific model prefixing.
8164 assert_eq!(resolved.model, "DeepSeek-V4-Pro");
8165 }
8166
8167 #[test]
8168 fn siliconflow_custom_base_url_preserves_provider_model() {
8169 let _lock = env_lock();
8170 let _env = EnvGuard::without_deepseek_runtime_overrides();
8171 let mut config = ConfigToml {
8172 provider: ProviderKind::Siliconflow,
8173 ..ConfigToml::default()
8174 };
8175 config.providers.siliconflow.base_url = Some("https://my-gateway.example/v1".to_string());
8176 config.providers.siliconflow.model = Some("DeepSeek-V4-Pro".to_string());
8177
8178 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8179
8180 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
8181 assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
8182 assert_eq!(resolved.model, "DeepSeek-V4-Pro");
8183 }
8184
8185 #[test]
8186 fn sentinel_config_values_fall_through_without_becoming_runtime_keys() {
8187 let _lock = env_lock();
8188 let _env = EnvGuard::without_deepseek_runtime_overrides();
8189
8190 for sentinel in [API_KEYRING_SENTINEL, " __KEYRING__ "] {
8191 let store = Arc::new(RecordingSecretsStore::with_value("stored-key"));
8192 let secrets = Secrets::new(store.clone());
8193 let mut official = ConfigToml::default();
8194 official.providers.deepseek.api_key = Some(sentinel.to_string());
8195 let resolved = official
8196 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
8197 assert_eq!(resolved.api_key.as_deref(), Some("stored-key"));
8198 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
8199
8200 let custom_store = Arc::new(RecordingSecretsStore::with_value("must-not-be-read"));
8201 let custom_secrets = Secrets::new(custom_store.clone());
8202 let mut custom = ConfigToml {
8203 provider: ProviderKind::Openrouter,
8204 ..ConfigToml::default()
8205 };
8206 custom.providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
8207 custom.providers.openrouter.api_key = Some(sentinel.to_string());
8208 let resolved = custom
8209 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &custom_secrets);
8210 assert_eq!(resolved.api_key, None);
8211 assert_eq!(resolved.api_key_source, None);
8212 assert!(custom_store.gets.lock().unwrap().is_empty());
8213
8214 let empty_store = Arc::new(RecordingSecretsStore::empty());
8215 let empty_secrets = Secrets::new(empty_store);
8216 let mut xiaomi = ConfigToml {
8217 provider: ProviderKind::XiaomiMimo,
8218 ..ConfigToml::default()
8219 };
8220 xiaomi.providers.xiaomi_mimo.api_key = Some(sentinel.to_string());
8221 let resolved = xiaomi
8222 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &empty_secrets);
8223 assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
8224 assert_eq!(resolved.api_key, None);
8225 assert_eq!(resolved.api_key_source, None);
8226 }
8227 }
8228
8229 #[test]
8230 fn config_file_resolves_above_env_and_keyring() {
8231 use codewhale_secrets::KeyringStore;
8232 let _lock = env_lock();
8233 let _env = EnvGuard::without_deepseek_runtime_overrides();
8234 // Safety: env mutation guarded by env_lock().
8235 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
8236
8237 let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
8238 store.set("deepseek", "ring-key").unwrap();
8239 let secrets = Secrets::new(store);
8240
8241 let mut config = ConfigToml::default();
8242 config.providers.deepseek.api_key = Some("file-key".to_string());
8243
8244 let resolved =
8245 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
8246 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
8247 assert_eq!(
8248 resolved.api_key_source,
8249 Some(RuntimeApiKeySource::ConfigFile)
8250 );
8251
8252 // Safety: env mutation guarded by env_lock().
8253 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
8254 }
8255
8256 #[test]
8257 fn env_resolves_when_config_file_and_keyring_empty() {
8258 let _lock = env_lock();
8259 let _env = EnvGuard::without_deepseek_runtime_overrides();
8260 // Safety: env mutation guarded by env_lock().
8261 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
8262
8263 let secrets = Secrets::new(std::sync::Arc::new(
8264 codewhale_secrets::InMemoryKeyringStore::new(),
8265 ));
8266 let config = ConfigToml::default();
8267
8268 let resolved =
8269 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
8270 assert_eq!(resolved.api_key.as_deref(), Some("env-key"));
8271 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
8272
8273 // Safety: env mutation guarded by env_lock().
8274 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
8275 }
8276
8277 #[test]
8278 fn config_file_resolves_when_keyring_and_env_empty() {
8279 let _lock = env_lock();
8280 let _env = EnvGuard::without_deepseek_runtime_overrides();
8281
8282 let secrets = Secrets::new(std::sync::Arc::new(
8283 codewhale_secrets::InMemoryKeyringStore::new(),
8284 ));
8285 let mut config = ConfigToml::default();
8286 config.providers.deepseek.api_key = Some("file-key".to_string());
8287
8288 let resolved =
8289 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
8290 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
8291 assert_eq!(
8292 resolved.api_key_source,
8293 Some(RuntimeApiKeySource::ConfigFile)
8294 );
8295 }
8296
8297 #[test]
8298 fn keyring_resolves_when_config_file_empty_even_if_env_is_set() {
8299 use codewhale_secrets::KeyringStore;
8300 let _lock = env_lock();
8301 let _env = EnvGuard::without_deepseek_runtime_overrides();
8302 // Safety: env mutation guarded by env_lock().
8303 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key") };
8304
8305 let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
8306 store.set("deepseek", "ring-key").unwrap();
8307 let secrets = Secrets::new(store);
8308
8309 let resolved = ConfigToml::default()
8310 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
8311 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
8312 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
8313
8314 // Safety: env mutation guarded by env_lock().
8315 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
8316 }
8317
8318 #[test]
8319 fn cli_flag_still_overrides_keyring() {
8320 use codewhale_secrets::KeyringStore;
8321 let _lock = env_lock();
8322 let _env = EnvGuard::without_deepseek_runtime_overrides();
8323
8324 let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
8325 store.set("deepseek", "ring-key").unwrap();
8326 let secrets = Secrets::new(store);
8327
8328 let cli = CliRuntimeOverrides {
8329 api_key: Some("cli-key".to_string()),
8330 ..CliRuntimeOverrides::default()
8331 };
8332 let resolved = ConfigToml::default().resolve_runtime_options_with_secrets(&cli, &secrets);
8333 assert_eq!(resolved.api_key.as_deref(), Some("cli-key"));
8334 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli));
8335 }
8336
8337 #[test]
8338 fn provider_chain_initial_current_is_active() {
8339 let chain = ProviderChain::new(
8340 ProviderKind::NvidiaNim,
8341 &[ProviderKind::Deepseek, ProviderKind::Openrouter],
8342 );
8343
8344 assert_eq!(chain.current(), ProviderKind::NvidiaNim);
8345 assert_eq!(chain.position(), 0);
8346 assert_eq!(
8347 chain.providers(),
8348 &[
8349 ProviderKind::NvidiaNim,
8350 ProviderKind::Deepseek,
8351 ProviderKind::Openrouter,
8352 ]
8353 );
8354 assert!(!chain.is_fallback_active());
8355 }
8356
8357 #[test]
8358 fn provider_chain_advance_switches_to_fallback() {
8359 let mut chain = ProviderChain::new(
8360 ProviderKind::NvidiaNim,
8361 &[ProviderKind::Deepseek, ProviderKind::Openrouter],
8362 );
8363
8364 assert!(chain.has_next());
8365 assert_eq!(chain.advance(), Some(ProviderKind::Deepseek));
8366 assert_eq!(chain.current(), ProviderKind::Deepseek);
8367 assert!(chain.is_fallback_active());
8368 }
8369
8370 #[test]
8371 fn provider_chain_exhausts_returns_none() {
8372 let mut chain = ProviderChain::new(ProviderKind::Deepseek, &[ProviderKind::Openrouter]);
8373
8374 assert_eq!(chain.advance(), Some(ProviderKind::Openrouter));
8375 assert!(!chain.has_next());
8376 assert_eq!(chain.advance(), None);
8377 }
8378
8379 #[test]
8380 fn provider_chain_skips_duplicates() {
8381 let chain = ProviderChain::new(
8382 ProviderKind::Deepseek,
8383 &[
8384 ProviderKind::Deepseek,
8385 ProviderKind::NvidiaNim,
8386 ProviderKind::Deepseek,
8387 ],
8388 );
8389
8390 assert_eq!(
8391 chain.providers(),
8392 &[ProviderKind::Deepseek, ProviderKind::NvidiaNim]
8393 );
8394 }
8395
8396 #[test]
8397 fn provider_chain_remaining_counts_current_and_untried_entries() {
8398 let mut chain = ProviderChain::new(
8399 ProviderKind::Deepseek,
8400 &[ProviderKind::NvidiaNim, ProviderKind::Openrouter],
8401 );
8402
8403 assert_eq!(chain.remaining(), 3);
8404 assert_eq!(chain.advance(), Some(ProviderKind::NvidiaNim));
8405 assert_eq!(chain.remaining(), 2);
8406 }
8407
8408 #[test]
8409 fn config_toml_parses_fallback_providers() {
8410 let config: ConfigToml = toml::from_str(
8411 r#"
8412 provider = "nvidia-nim"
8413 fallback_providers = ["deepseek", "openrouter"]
8414 "#,
8415 )
8416 .expect("fallback providers config");
8417
8418 assert_eq!(config.provider, ProviderKind::NvidiaNim);
8419 assert_eq!(
8420 config.fallback_providers,
8421 [ProviderKind::Deepseek, ProviderKind::Openrouter]
8422 );
8423 }
8424
8425 #[test]
8426 fn empty_fallback_providers_do_not_serialize() {
8427 let serialized = toml::to_string_pretty(&ConfigToml::default()).expect("config serializes");
8428
8429 assert!(!serialized.contains("fallback_providers"));
8430 }
8431
8432 #[test]
8433 fn empty_provider_header_tables_do_not_survive_round_trip() {
8434 let polluted = r#"
8435 [http_headers]
8436
8437 [providers.anthropic.http_headers]
8438 " " = "ignored"
8439 "X-Blank" = " "
8440
8441 [providers.openrouter.http_headers]
8442
8443 [providers.xai]
8444 model = " "
8445 "#;
8446 let config: ConfigToml = toml::from_str(polluted).expect("polluted config parses");
8447 let serialized = toml::to_string_pretty(&config).expect("config serializes");
8448
8449 assert!(
8450 !serialized.contains("[http_headers]"),
8451 "empty root headers must not be serialized:\n{serialized}"
8452 );
8453 assert!(
8454 !serialized.contains("[providers.anthropic"),
8455 "empty Anthropic provider state must not be serialized:\n{serialized}"
8456 );
8457 assert!(
8458 !serialized.contains("[providers.openrouter"),
8459 "empty OpenRouter provider state must not be serialized:\n{serialized}"
8460 );
8461 assert!(
8462 !serialized.contains("[providers.xai"),
8463 "blank provider fields must not be serialized:\n{serialized}"
8464 );
8465
8466 let round_tripped: ConfigToml = toml::from_str(&serialized).expect("canonical config parses");
8467 assert!(round_tripped.http_headers.is_empty());
8468 assert!(round_tripped.providers.anthropic.http_headers.is_empty());
8469 assert!(round_tripped.providers.openrouter.http_headers.is_empty());
8470 }
8471
8472 #[test]
8473 fn workflow_config_defaults_match_product_surface() {
8474 // #4128 / Section 2.11: omitted `[workflow]` keys resolve to the
8475 // documented product defaults so launch/approval/persist share one model.
8476 let defaults = WorkflowConfigToml::default();
8477 assert!(defaults.automatic);
8478 assert!(defaults.auto_start_read_only);
8479 assert!(defaults.require_approval_for_writes);
8480 assert_eq!(defaults.max_children, 1000);
8481 assert_eq!(defaults.max_concurrent, 16);
8482 assert_eq!(defaults.max_depth, 5);
8483 // 0 = no shared cap; budgets are opt-in, matching the parent turn loop's
8484 // advisory policy (#6189).
8485 assert_eq!(defaults.default_token_budget, 0);
8486 }
8487
8488 #[test]
8489 fn workflow_config_absent_table_stays_none_empty_table_fills_defaults() {
8490 let absent: ConfigToml = toml::from_str("").expect("empty config parses");
8491 assert!(absent.workflow.is_none());
8492
8493 let empty_table: ConfigToml = toml::from_str(
8494 r#"
8495 [workflow]
8496 "#,
8497 )
8498 .expect("empty workflow table should parse");
8499 assert_eq!(
8500 empty_table.workflow.expect("workflow table present"),
8501 WorkflowConfigToml::default()
8502 );
8503 }
8504
8505 #[test]
8506 fn workflow_config_partial_override_and_round_trip() {
8507 let config: ConfigToml = toml::from_str(
8508 r#"
8509 [workflow]
8510 automatic = false
8511 max_children = 16
8512 default_token_budget = 50000
8513 "#,
8514 )
8515 .expect("workflow overrides should parse");
8516
8517 let workflow = config.workflow.expect("workflow table");
8518 assert!(!workflow.automatic);
8519 assert_eq!(workflow.max_children, 16);
8520 assert_eq!(workflow.default_token_budget, 50_000);
8521 // Unset keys keep product defaults.
8522 assert!(workflow.auto_start_read_only);
8523 assert!(workflow.require_approval_for_writes);
8524 assert_eq!(workflow.max_concurrent, 16);
8525 assert_eq!(workflow.max_depth, 5);
8526
8527 let serialized = toml::to_string_pretty(&workflow).expect("workflow serializes");
8528 let round_tripped: WorkflowConfigToml =
8529 toml::from_str(&serialized).expect("serialized workflow parses");
8530 assert_eq!(round_tripped, workflow);
8531 }
8532
8533 #[test]
8534 fn fleet_exec_config_default_matches_subagent_depth() {
8535 // Fleet workers and standalone sub-agents share one recursion axis:
8536 // the fleet default equals DEFAULT_SPAWN_DEPTH (3) and affords >=3
8537 // nested delegation levels out of the box.
8538 assert_eq!(
8539 FleetExecConfig::default().max_spawn_depth,
8540 DEFAULT_SPAWN_DEPTH
8541 );
8542 assert_eq!(FleetExecConfig::default().max_spawn_depth, 3);
8543 const { assert!(DEFAULT_SPAWN_DEPTH <= MAX_SPAWN_DEPTH_CEILING) };
8544 }
8545
8546 #[test]
8547 fn fleet_exec_model_turns_are_unbounded_by_default() {
8548 assert_eq!(FleetExecConfig::default().max_turns, 0);
8549 }
8550
8551 #[test]
8552 fn fleet_exec_config_parses_max_spawn_depth() {
8553 let config: ConfigToml = toml::from_str(
8554 r#"
8555 [fleet.exec]
8556 max_spawn_depth = 2
8557 "#,
8558 )
8559 .expect("fleet exec config should parse");
8560
8561 assert_eq!(config.fleet.expect("fleet config").exec.max_spawn_depth, 2);
8562 }
8563
8564 /// Retired tables/keys are ignored, never a parse failure: a pre-0.9.14
8565 /// config with inline `[fleets.*]`, legacy trust keys, or dead workflow
8566 /// knobs must still load (named fleets live in `fleets/*.toml` files).
8567 #[test]
8568 fn retired_inline_fleet_and_workflow_keys_are_ignored_not_rejected() {
8569 let config: ConfigToml = toml::from_str(
8570 r#"
8571 [fleet]
8572 default_trust_level = "local"
8573
8574 [fleets.alice-team]
8575 operator = "alice"
8576 default_trust_level = "local"
8577
8578 [workflow]
8579 auto_start_child_limit = 4
8580 max_parallel_writes_without_worktree = 1
8581 persist_completed_activity = false
8582 "#,
8583 )
8584 .expect("retired keys must still parse");
8585 assert!(config.fleet.is_some());
8586 assert!(config.workflow.is_some());
8587 }
8588
8589 #[test]
8590 fn fleet_profile_defaults_round_trip_through_config() {
8591 let config: ConfigToml = toml::from_str(
8592 r#"
8593 [fleet.profiles.default]
8594 "#,
8595 )
8596 .expect("fleet profile config should parse");
8597
8598 let profile = config
8599 .fleet
8600 .expect("fleet config")
8601 .profiles
8602 .get("default")
8603 .expect("default profile")
8604 .clone();
8605
8606 assert_eq!(profile, FleetProfile::default());
8607 assert!(!profile.permissions.allow_shell);
8608 assert!(!profile.permissions.trust);
8609 assert!(profile.permissions.approval_required);
8610
8611 let serialized = toml::to_string_pretty(&profile).expect("profile serializes");
8612 let round_tripped: FleetProfile =
8613 toml::from_str(&serialized).expect("serialized profile parses");
8614 assert_eq!(round_tripped, profile);
8615 }
8616
8617 #[test]
8618 fn fleet_profile_explicit_config_parses_legacy_permissions_as_ignored_input() {
8619 let config: ConfigToml = toml::from_str(
8620 r#"
8621 [fleet.profiles.verifier]
8622 slot = "verifier"
8623 loadout = "review"
8624 model = "deepseek-v4-pro"
8625
8626 [fleet.profiles.verifier.role]
8627 name = "verifier"
8628 description = "Read-only verification worker"
8629 instructions = "Check the patch and report evidence."
8630
8631 [fleet.profiles.verifier.permissions]
8632 allow_shell = false
8633 trust = false
8634 approval_required = true
8635
8636 [fleet.profiles.verifier.delegation]
8637 max_spawn_depth = 0
8638 concurrency = 3
8639 "#,
8640 )
8641 .expect("fleet profile config should parse");
8642
8643 let profile = config
8644 .fleet
8645 .expect("fleet config")
8646 .profiles
8647 .get("verifier")
8648 .expect("verifier profile")
8649 .clone();
8650
8651 assert_eq!(profile.slot, FleetSlot::Verifier);
8652 assert_eq!(profile.role.name, "verifier");
8653 assert_eq!(
8654 profile.role.description.as_deref(),
8655 Some("Read-only verification worker")
8656 );
8657 assert_eq!(
8658 profile.role.instructions.as_deref(),
8659 Some("Check the patch and report evidence.")
8660 );
8661 // "review" was a retired decorative tier: it parses as Custom and keeps
8662 // the same auto routing it always had.
8663 assert_eq!(profile.loadout, FleetLoadout::Custom("review".to_string()));
8664 assert_eq!(profile.model.as_deref(), Some("deepseek-v4-pro"));
8665 assert!(!profile.permissions.allow_shell);
8666 assert!(!profile.permissions.trust);
8667 assert!(profile.permissions.approval_required);
8668 assert_eq!(profile.delegation.max_spawn_depth, Some(0));
8669 assert_eq!(profile.delegation.max_concurrency, Some(3));
8670 let serialized = toml::to_string_pretty(&profile).expect("profile serializes");
8671 assert!(!serialized.contains("permissions"));
8672 }
8673
8674 #[test]
8675 fn fleet_loadout_accepts_default_model_classes() {
8676 assert_eq!(FleetLoadout::from_name("fast"), FleetLoadout::Fast);
8677 assert_eq!(FleetLoadout::from_name("inherit"), FleetLoadout::Inherit);
8678 assert_eq!(FleetLoadout::from_name(""), FleetLoadout::Inherit);
8679 assert_eq!(FleetLoadout::Fast.as_str(), "fast");
8680 // Retired tiers stay parseable as Custom so old configs keep loading
8681 // with identical (auto) routing.
8682 assert_eq!(
8683 FleetLoadout::from_name("strong"),
8684 FleetLoadout::Custom("strong".to_string())
8685 );
8686 assert_eq!(
8687 FleetLoadout::from_name("tool-heavy"),
8688 FleetLoadout::Custom("tool-heavy".to_string())
8689 );
8690 assert_eq!(
8691 FleetLoadout::Custom("strong".to_string()).as_str(),
8692 "strong"
8693 );
8694 }
8695
8696 #[test]
8697 fn fallback_providers_do_not_change_runtime_resolution() {
8698 let _lock = env_lock();
8699 let _env = EnvGuard::without_deepseek_runtime_overrides();
8700 let config = ConfigToml {
8701 provider: ProviderKind::NvidiaNim,
8702 fallback_providers: vec![ProviderKind::Deepseek],
8703 ..ConfigToml::default()
8704 };
8705
8706 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8707
8708 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
8709 }
8710
8711 #[test]
8712 fn test_verbosity_resolution() {
8713 let _lock = env_lock();
8714 // Test TOML parsing
8715 let toml_str = r#"
8716 verbosity = "concise"
8717 "#;
8718 let config: ConfigToml = toml::from_str(toml_str).unwrap();
8719 assert_eq!(config.verbosity, Some("concise".to_string()));
8720
8721 // Test Env overrides
8722 let _env = EnvGuard::without_deepseek_runtime_overrides();
8723 unsafe {
8724 std::env::set_var("CODEWHALE_VERBOSITY", "normal");
8725 }
8726 let env_overrides = EnvRuntimeOverrides::load();
8727 assert_eq!(env_overrides.verbosity, Some("normal".to_string()));
8728 unsafe {
8729 std::env::remove_var("CODEWHALE_VERBOSITY");
8730 }
8731
8732 // Test fallback to DEEPSEEK_VERBOSITY
8733 unsafe {
8734 std::env::set_var("DEEPSEEK_VERBOSITY", "concise");
8735 }
8736 let env_overrides = EnvRuntimeOverrides::load();
8737 assert_eq!(env_overrides.verbosity, Some("concise".to_string()));
8738 unsafe {
8739 std::env::remove_var("DEEPSEEK_VERBOSITY");
8740 }
8741 }
8742
8743 /// Save and restore the telemetry env vars around a test that mutates them.
8744 ///
8745 /// Held together with [`env_lock`]: the process environment is global, so
8746 /// every telemetry test serialises on the same mutex the provider tests use.
8747 struct TelemetryEnvGuard {
8748 codewhale: Option<OsString>,
8749 deepseek: Option<OsString>,
8750 floor: Option<OsString>,
8751 codewhale_endpoint: Option<OsString>,
8752 deepseek_endpoint: Option<OsString>,
8753 _lock: std::sync::MutexGuard<'static, ()>,
8754 }
8755
8756 impl TelemetryEnvGuard {
8757 fn take() -> Self {
8758 let lock = env_lock();
8759 let guard = Self {
8760 codewhale: env::var_os("CODEWHALE_TELEMETRY"),
8761 deepseek: env::var_os("DEEPSEEK_TELEMETRY"),
8762 floor: env::var_os(TELEMETRY_FLOOR_ENV),
8763 // The endpoint variables are cleared too. Resolution now has a
8764 // shipped default, so an ambient endpoint in the developer's or
8765 // CI's environment is the difference between pinning the default
8766 // and pinning whatever that machine happened to export.
8767 codewhale_endpoint: env::var_os("CODEWHALE_TELEMETRY_ENDPOINT"),
8768 deepseek_endpoint: env::var_os("DEEPSEEK_TELEMETRY_ENDPOINT"),
8769 _lock: lock,
8770 };
8771 // Safety: test-only environment mutation guarded by the module mutex.
8772 unsafe {
8773 env::remove_var("CODEWHALE_TELEMETRY");
8774 env::remove_var("DEEPSEEK_TELEMETRY");
8775 env::remove_var(TELEMETRY_FLOOR_ENV);
8776 env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT");
8777 env::remove_var("DEEPSEEK_TELEMETRY_ENDPOINT");
8778 }
8779 guard
8780 }
8781
8782 fn set(&self, value: &str) {
8783 // Safety: test-only environment mutation guarded by the module mutex.
8784 unsafe {
8785 env::set_var("CODEWHALE_TELEMETRY", value);
8786 }
8787 }
8788
8789 fn set_endpoint(&self, value: &str) {
8790 // Safety: test-only environment mutation guarded by the module mutex.
8791 unsafe {
8792 env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", value);
8793 }
8794 }
8795
8796 fn set_floor(&self, value: &str) {
8797 // Safety: test-only environment mutation guarded by the module mutex.
8798 unsafe {
8799 env::set_var(TELEMETRY_FLOOR_ENV, value);
8800 }
8801 }
8802
8803 fn clear(&self) {
8804 // Safety: test-only environment mutation guarded by the module mutex.
8805 unsafe {
8806 env::remove_var("CODEWHALE_TELEMETRY");
8807 env::remove_var("DEEPSEEK_TELEMETRY");
8808 env::remove_var(TELEMETRY_FLOOR_ENV);
8809 }
8810 }
8811 }
8812
8813 impl Drop for TelemetryEnvGuard {
8814 fn drop(&mut self) {
8815 // Safety: test-only environment mutation guarded by the module mutex.
8816 unsafe {
8817 match self.codewhale.take() {
8818 Some(value) => env::set_var("CODEWHALE_TELEMETRY", value),
8819 None => env::remove_var("CODEWHALE_TELEMETRY"),
8820 }
8821 match self.deepseek.take() {
8822 Some(value) => env::set_var("DEEPSEEK_TELEMETRY", value),
8823 None => env::remove_var("DEEPSEEK_TELEMETRY"),
8824 }
8825 match self.floor.take() {
8826 Some(value) => env::set_var(TELEMETRY_FLOOR_ENV, value),
8827 None => env::remove_var(TELEMETRY_FLOOR_ENV),
8828 }
8829 match self.codewhale_endpoint.take() {
8830 Some(value) => env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", value),
8831 None => env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT"),
8832 }
8833 match self.deepseek_endpoint.take() {
8834 Some(value) => env::set_var("DEEPSEEK_TELEMETRY_ENDPOINT", value),
8835 None => env::remove_var("DEEPSEEK_TELEMETRY_ENDPOINT"),
8836 }
8837 }
8838 }
8839 }
8840
8841 #[test]
8842 fn env_telemetry_off_is_a_floor_over_cli_on() {
8843 let guard = TelemetryEnvGuard::take();
8844 guard.set("0");
8845
8846 let config = ConfigToml {
8847 telemetry: Some(true),
8848 ..ConfigToml::default()
8849 };
8850 let cli = CliRuntimeOverrides {
8851 telemetry: Some(true),
8852 ..CliRuntimeOverrides::default()
8853 };
8854
8855 let resolved = config.resolve_runtime_options(&cli);
8856
8857 // `--telemetry true` must not be able to climb back over an explicit
8858 // `CODEWHALE_TELEMETRY=0`. Off is a floor, not one more precedence rung.
8859 assert!(!resolved.telemetry);
8860 // …but the environment is a run-scoped switch, not a revocation. See
8861 // `a_run_scoped_off_is_a_kill_switch_and_not_a_revocation`.
8862 assert!(!resolved.telemetry_explicit_off);
8863 }
8864
8865 #[test]
8866 fn persisted_telemetry_off_is_a_floor_over_cli_on() {
8867 // Regression: `--telemetry true` used to beat `telemetry = false` in the
8868 // config file, and the dispatcher then forwarded the resolved `true` as
8869 // `CODEWHALE_TELEMETRY=true`, which also outranked the child's own copy of
8870 // that file. Any wrapper script, alias, or agent harness passing the flag
8871 // silently re-enabled a user who had turned telemetry off through the one
8872 // switch the first-run notice advertises as permanent.
8873 let guard = TelemetryEnvGuard::take();
8874
8875 let config = ConfigToml {
8876 telemetry: Some(false),
8877 ..ConfigToml::default()
8878 };
8879 let cli = CliRuntimeOverrides {
8880 telemetry: Some(true),
8881 ..CliRuntimeOverrides::default()
8882 };
8883
8884 let resolved = config.resolve_runtime_options(&cli);
8885 assert!(!resolved.telemetry);
8886 // And it stays an answer, so the run re-asserts the tombstone.
8887 assert!(resolved.telemetry_explicit_off);
8888
8889 // An environment "on" loses to it as well: re-enabling is writing the
8890 // durable register the off was written in.
8891 guard.set("1");
8892 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8893 assert!(!resolved.telemetry);
8894 assert!(resolved.telemetry_explicit_off);
8895 }
8896
8897 #[test]
8898 fn a_run_scoped_off_is_a_kill_switch_and_not_a_revocation() {
8899 // Regression: an explicit `CODEWHALE_TELEMETRY=0` marked the run as an
8900 // *answer*, so the telemetry crate took its destructive opt-out branch —
8901 // deleting the install id and truncating the user's own dry-run records —
8902 // on a recipe the runtime docs prescribe for one command. Worse, the
8903 // dispatcher forwards a resolved `false` on every ordinary run, so the
8904 // shipped default was indistinguishable from a revocation.
8905 let guard = TelemetryEnvGuard::take();
8906
8907 for value in ["0", "false", "off", "disabled", "no"] {
8908 guard.set(value);
8909 let config = ConfigToml {
8910 telemetry: Some(true),
8911 ..ConfigToml::default()
8912 };
8913 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8914 assert!(!resolved.telemetry, "{value} must stop the run");
8915 assert!(
8916 !resolved.telemetry_explicit_off,
8917 "{value} must not read as a revocation"
8918 );
8919 }
8920
8921 // The same for the per-run flag, with the environment back to silent.
8922 guard.clear();
8923 let cli = CliRuntimeOverrides {
8924 telemetry: Some(false),
8925 ..CliRuntimeOverrides::default()
8926 };
8927 let resolved = ConfigToml {
8928 telemetry: Some(true),
8929 ..ConfigToml::default()
8930 }
8931 .resolve_runtime_options(&cli);
8932 assert!(!resolved.telemetry);
8933 assert!(!resolved.telemetry_explicit_off);
8934 }
8935
8936 /// #5441: the resolved consent must name its source, because "telemetry: on"
8937 /// with no provenance hides the one default users most need to see.
8938 #[test]
8939 fn telemetry_consent_names_its_source() {
8940 let guard = TelemetryEnvGuard::take();
8941
8942 // Nobody said anything: on, by default.
8943 let (on, source) = resolved_telemetry_consent(None);
8944 assert!(on);
8945 assert_eq!(source, TelemetrySource::Default);
8946
8947 // The config file owns the answer.
8948 let (on, source) = resolved_telemetry_consent(Some(true));
8949 assert!(on);
8950 assert_eq!(source, TelemetrySource::Config);
8951
8952 // A persisted off is a floor and is named as the decision.
8953 let (on, source) = resolved_telemetry_consent(Some(false));
8954 assert!(!on);
8955 assert_eq!(source, TelemetrySource::Config);
8956
8957 // An explicit environment "on" loses to the persisted off: re-enabling
8958 // is writing the durable register the off was written in.
8959 guard.set("1");
8960 let (on, source) = resolved_telemetry_consent(Some(false));
8961 assert!(!on);
8962 assert_eq!(source, TelemetrySource::Config);
8963
8964 // An environment kill switch decides and is named.
8965 guard.set("0");
8966 let (on, source) = resolved_telemetry_consent(Some(true));
8967 assert!(!on);
8968 assert_eq!(source, TelemetrySource::Env);
8969
8970 // An unreadable environment value is a kill switch, never "on".
8971 guard.set("yes-please");
8972 let (on, source) = resolved_telemetry_consent(Some(true));
8973 assert!(!on);
8974 assert_eq!(source, TelemetrySource::Env);
8975
8976 // A clean environment "on" with nothing in the file is env-owned.
8977 guard.set("1");
8978 let (on, source) = resolved_telemetry_consent(None);
8979 assert!(on);
8980 assert_eq!(source, TelemetrySource::Env);
8981 }
8982
8983 #[test]
8984 fn resolved_runtime_options_mints_a_route_candidate() {
8985 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
8986 let route = resolved
8987 .route
8988 .as_ref()
8989 .expect("RouteResolver is the runtime path");
8990 assert_eq!(route.provider_kind(), resolved.provider);
8991 assert_eq!(route.endpoint().base_url, resolved.base_url);
8992 }
8993
8994 /// #5441: the runtime receipt carries the same source the surfaces print.
8995 #[test]
8996 fn resolved_runtime_options_reports_telemetry_source() {
8997 let guard = TelemetryEnvGuard::take();
8998
8999 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
9000 assert!(resolved.telemetry);
9001 assert_eq!(resolved.telemetry_source, TelemetrySource::Default);
9002
9003 // The CLI flag owns the answer for this run, off or on.
9004 let cli = CliRuntimeOverrides {
9005 telemetry: Some(false),
9006 ..CliRuntimeOverrides::default()
9007 };
9008 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
9009 assert!(!resolved.telemetry);
9010 assert_eq!(resolved.telemetry_source, TelemetrySource::Cli);
9011
9012 // A kill switch still beats `--telemetry true`, and the source says so.
9013 guard.set("0");
9014 let cli = CliRuntimeOverrides {
9015 telemetry: Some(true),
9016 ..CliRuntimeOverrides::default()
9017 };
9018 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
9019 assert!(!resolved.telemetry);
9020 assert_eq!(resolved.telemetry_source, TelemetrySource::Env);
9021 }
9022
9023 /// #5441: `config get telemetry` reports the resolved consent with its
9024 /// source instead of "key not found" on a machine whose batches ship.
9025 #[test]
9026 fn config_display_for_telemetry_reports_resolved_consent_with_source() {
9027 let guard = TelemetryEnvGuard::take();
9028
9029 let config = ConfigToml::default();
9030 assert_eq!(
9031 config.get_display_value("telemetry").as_deref(),
9032 Some("on (default)")
9033 );
9034
9035 let config = ConfigToml {
9036 telemetry: Some(false),
9037 ..ConfigToml::default()
9038 };
9039 assert_eq!(
9040 config.get_display_value("telemetry").as_deref(),
9041 Some("off (config)")
9042 );
9043
9044 guard.set("0");
9045 let config = ConfigToml {
9046 telemetry: Some(true),
9047 ..ConfigToml::default()
9048 };
9049 assert_eq!(
9050 config.get_display_value("telemetry").as_deref(),
9051 Some("off (env)")
9052 );
9053 }
9054
9055 #[test]
9056 fn the_dispatcher_states_the_floor_rather_than_letting_the_child_infer_it() {
9057 // The child cannot tell an operator's declared kill switch from the
9058 // shipped default: both arrive as `CODEWHALE_TELEMETRY=false`. So the
9059 // dispatcher states it, and the statement outranks the inference — which
9060 // is what lets the first-run notice refuse to ask under a real floor while
9061 // still asking on an ordinary first run.
9062 let guard = TelemetryEnvGuard::take();
9063 assert!(!telemetry_floor_in_force());
9064
9065 guard.set("0");
9066 assert!(telemetry_floor_in_force());
9067
9068 // A forwarded resolved `false` with the dispatcher saying "no floor" is
9069 // the ordinary first run.
9070 guard.set("false");
9071 guard.set_floor("0");
9072 assert!(!telemetry_floor_in_force());
9073
9074 // A declared floor holds even where the value alone would not show it.
9075 guard.set("true");
9076 guard.set_floor("1");
9077 assert!(telemetry_floor_in_force());
9078 let resolved = ConfigToml {
9079 telemetry: Some(true),
9080 ..ConfigToml::default()
9081 }
9082 .resolve_runtime_options(&CliRuntimeOverrides::default());
9083 assert!(!resolved.telemetry);
9084
9085 // An unreadable value is a floor: a typo in a kill switch never resolves
9086 // to "on".
9087 guard.clear();
9088 guard.set("maybe");
9089 assert!(telemetry_floor_in_force());
9090 }
9091
9092 #[test]
9093 fn unparseable_telemetry_env_fails_closed() {
9094 let guard = TelemetryEnvGuard::take();
9095 guard.set("maybe");
9096
9097 let config = ConfigToml {
9098 telemetry: Some(true),
9099 ..ConfigToml::default()
9100 };
9101 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
9102
9103 // A typo must not fall through to the config file's `true`.
9104 assert!(!resolved.telemetry);
9105 // …but it is also not the user answering "no", so it is not an explicit
9106 // opt-out either.
9107 assert!(!resolved.telemetry_explicit_off);
9108 }
9109
9110 #[test]
9111 fn telemetry_env_invalid_is_recorded_rather_than_swallowed() {
9112 let guard = TelemetryEnvGuard::take();
9113 guard.set("sure why not");
9114
9115 let env = EnvRuntimeOverrides::load();
9116 assert_eq!(env.telemetry, None);
9117 assert!(env.telemetry_env_invalid);
9118
9119 guard.set("disabled");
9120 let env = EnvRuntimeOverrides::load();
9121 assert_eq!(env.telemetry, Some(false));
9122 assert!(!env.telemetry_env_invalid);
9123
9124 guard.set("enabled");
9125 let env = EnvRuntimeOverrides::load();
9126 assert_eq!(env.telemetry, Some(true));
9127 assert!(!env.telemetry_env_invalid);
9128 }
9129
9130 #[test]
9131 fn telemetry_explicit_off_distinguishes_an_answer_from_the_default() {
9132 let _guard = TelemetryEnvGuard::take();
9133
9134 // Nobody said anything: default on, with no explicit opt-out.
9135 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
9136 assert!(resolved.telemetry);
9137 assert!(!resolved.telemetry_explicit_off);
9138
9139 // The config file says no.
9140 let config = ConfigToml {
9141 telemetry: Some(false),
9142 ..ConfigToml::default()
9143 };
9144 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
9145 assert!(!resolved.telemetry);
9146 assert!(resolved.telemetry_explicit_off);
9147
9148 // A CLI `--telemetry true` does not override the file's `false`: the
9149 // persistent switch is a floor, and the answer stands.
9150 let cli = CliRuntimeOverrides {
9151 telemetry: Some(true),
9152 ..CliRuntimeOverrides::default()
9153 };
9154 let resolved = config.resolve_runtime_options(&cli);
9155 assert!(!resolved.telemetry);
9156 assert!(resolved.telemetry_explicit_off);
9157
9158 // A CLI `--telemetry false` stops the run without being an answer: it is
9159 // scoped to the run, and a run-scoped switch must not delete state.
9160 let cli = CliRuntimeOverrides {
9161 telemetry: Some(false),
9162 ..CliRuntimeOverrides::default()
9163 };
9164 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
9165 assert!(!resolved.telemetry);
9166 assert!(!resolved.telemetry_explicit_off);
9167 }
9168
9169 /// The shipped default endpoint, pinned by value.
9170 ///
9171 /// A default nobody asserts is a default that drifts, and this one decides
9172 /// which host an enabled session contacts. The literal is repeated here on
9173 /// purpose: comparing the constant to itself would pass against any edit.
9174 #[test]
9175 fn an_unconfigured_endpoint_resolves_to_the_shipped_default() {
9176 let _guard = TelemetryEnvGuard::take();
9177
9178 assert_eq!(
9179 DEFAULT_TELEMETRY_ENDPOINT,
9180 "https://telemetry.codewhale.net/v1/telemetry"
9181 );
9182 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
9183 assert_eq!(
9184 resolved.telemetry_endpoint.as_deref(),
9185 Some(DEFAULT_TELEMETRY_ENDPOINT),
9186 "an unconfigured endpoint must resolve to the shipped default"
9187 );
9188
9189 // The shipped usage preference is on without an acceptance prerequisite.
9190 assert!(resolved.telemetry);
9191 assert!(!resolved.telemetry_explicit_off);
9192 }
9193
9194 /// A user-set endpoint beats the shipped default, from either source, and an
9195 /// explicitly *empty* one is the dry-run sink rather than a missing value.
9196 #[test]
9197 fn a_configured_endpoint_beats_the_shipped_default() {
9198 let guard = TelemetryEnvGuard::take();
9199
9200 let config = ConfigToml {
9201 telemetry_endpoint: Some("https://collector.internal/v1/batch".to_string()),
9202 ..ConfigToml::default()
9203 };
9204 assert_eq!(
9205 config
9206 .resolve_runtime_options(&CliRuntimeOverrides::default())
9207 .telemetry_endpoint
9208 .as_deref(),
9209 Some("https://collector.internal/v1/batch"),
9210 "the config file must outrank the shipped default"
9211 );
9212
9213 // The environment outranks the file, which outranks the default.
9214 guard.set_endpoint("https://collector.env.internal/v1/batch");
9215 assert_eq!(
9216 config
9217 .resolve_runtime_options(&CliRuntimeOverrides::default())
9218 .telemetry_endpoint
9219 .as_deref(),
9220 Some("https://collector.env.internal/v1/batch")
9221 );
9222 assert_eq!(
9223 ConfigToml::default()
9224 .resolve_runtime_options(&CliRuntimeOverrides::default())
9225 .telemetry_endpoint
9226 .as_deref(),
9227 Some("https://collector.env.internal/v1/batch")
9228 );
9229 }
9230
9231 /// An empty endpoint means "contact nobody", not "use the default".
9232 ///
9233 /// `None` at the resolved layer is what `codewhale-telemetry`'s client reads as
9234 /// the dry-run sink: batches are serialized exactly as a server would see them
9235 /// and appended to `dryrun.jsonl`, and no HTTP client is constructed. With a
9236 /// shipped default in place that path is only reachable through an explicit
9237 /// empty value, so both ways of writing one are pinned here.
9238 #[test]
9239 fn an_empty_endpoint_keeps_the_dry_run_sink_reachable() {
9240 let guard = TelemetryEnvGuard::take();
9241
9242 let config = ConfigToml {
9243 telemetry_endpoint: Some(String::new()),
9244 ..ConfigToml::default()
9245 };
9246 assert_eq!(
9247 config
9248 .resolve_runtime_options(&CliRuntimeOverrides::default())
9249 .telemetry_endpoint,
9250 None,
9251 "`telemetry_endpoint = \"\"` must resolve to the dry-run sink"
9252 );
9253
9254 // Whitespace is the same statement typed less carefully.
9255 let config = ConfigToml {
9256 telemetry_endpoint: Some(" ".to_string()),
9257 ..ConfigToml::default()
9258 };
9259 assert_eq!(
9260 config
9261 .resolve_runtime_options(&CliRuntimeOverrides::default())
9262 .telemetry_endpoint,
9263 None
9264 );
9265
9266 // An emptied environment variable says it too, and it says it over a
9267 // config file that names a real host — otherwise the documented one-shot
9268 // `CODEWHALE_TELEMETRY_ENDPOINT= codewhale …` would silently keep sending.
9269 let config = ConfigToml {
9270 telemetry_endpoint: Some("https://collector.internal/v1/batch".to_string()),
9271 ..ConfigToml::default()
9272 };
9273 guard.set_endpoint("");
9274 assert_eq!(
9275 config
9276 .resolve_runtime_options(&CliRuntimeOverrides::default())
9277 .telemetry_endpoint,
9278 None
9279 );
9280 assert_eq!(
9281 ConfigToml::default()
9282 .resolve_runtime_options(&CliRuntimeOverrides::default())
9283 .telemetry_endpoint,
9284 None,
9285 "an emptied environment variable must not fall through to the default"
9286 );
9287 }
9288
9289 #[test]
9290 fn telemetry_endpoint_round_trips_through_all_four_verbs() {
9291 let mut config = ConfigToml::default();
9292 assert_eq!(config.get_value("telemetry_endpoint"), None);
9293 assert!(!config.list_values().contains_key("telemetry_endpoint"));
9294
9295 config
9296 .set_value("telemetry_endpoint", "https://collector.example/ingest")
9297 .expect("set telemetry_endpoint");
9298 assert_eq!(
9299 config.get_value("telemetry_endpoint").as_deref(),
9300 Some("https://collector.example/ingest")
9301 );
9302 assert_eq!(
9303 config
9304 .list_values()
9305 .get("telemetry_endpoint")
9306 .map(String::as_str),
9307 Some("https://collector.example/ingest")
9308 );
9309
9310 // Scheme rules belong at send time, not at set time: staging a value the
9311 // client will later refuse must still be possible.
9312 config
9313 .set_value("telemetry_endpoint", "http://collector.example/ingest")
9314 .expect("staging an http endpoint is not a set-time error");
9315 assert_eq!(
9316 config.get_value("telemetry_endpoint").as_deref(),
9317 Some("http://collector.example/ingest")
9318 );
9319
9320 config
9321 .unset_value("telemetry_endpoint")
9322 .expect("unset telemetry_endpoint");
9323 assert_eq!(config.get_value("telemetry_endpoint"), None);
9324 assert!(!config.list_values().contains_key("telemetry_endpoint"));
9325
9326 // The key must land as a typed field, never in `extras` — an extras key
9327 // would serialize after the section tables and break the file.
9328 assert!(!config.extras.contains_key("telemetry_endpoint"));
9329 }
9330
9331 #[test]
9332 fn telemetry_endpoint_stays_a_scalar_sibling_of_telemetry() {
9333 // `telemetry` is a root scalar and every section table is declared after
9334 // it, so the endpoint must serialize as a scalar too. A `[telemetry]`
9335 // table would be a hard parse failure on load, and a scalar emitted after
9336 // a table is a TOML `ValueAfterTable` error on save.
9337 let config = ConfigToml {
9338 telemetry: Some(true),
9339 telemetry_endpoint: Some("https://collector.example/ingest".to_string()),
9340 ..ConfigToml::default()
9341 };
9342 let rendered = toml::to_string_pretty(&config).expect("serialize config");
9343 assert!(
9344 rendered.contains("telemetry_endpoint = \"https://collector.example/ingest\""),
9345 "{rendered}"
9346 );
9347 assert!(!rendered.contains("[telemetry]"), "{rendered}");
9348
9349 let endpoint_at = rendered
9350 .find("telemetry_endpoint =")
9351 .expect("endpoint present");
9352 if let Some(first_table_at) = rendered.find("\n[") {
9353 assert!(
9354 endpoint_at < first_table_at,
9355 "telemetry_endpoint must precede every section table:\n{rendered}"
9356 );
9357 }
9358
9359 let round_tripped: ConfigToml = toml::from_str(&rendered).expect("round trips");
9360 assert_eq!(
9361 round_tripped.telemetry_endpoint.as_deref(),
9362 Some("https://collector.example/ingest")
9363 );
9364 assert_eq!(round_tripped.telemetry, Some(true));
9365 }
9366
9367 #[test]
9368 fn a_telemetry_table_is_a_hard_load_failure_and_stays_unbuildable() {
9369 // Documents *why* the endpoint is a sibling scalar rather than
9370 // `[telemetry] endpoint = …`: `telemetry` is already `Option<bool>`, so a
9371 // table of that name cannot deserialize at all.
9372 let err = toml::from_str::<ConfigToml>("[telemetry]\nenabled = true\n")
9373 .expect_err("a [telemetry] table must not deserialize");
9374 let _ = err;
9375 }
9376
9377 #[test]
9378 fn telemetry_notice_is_owed_until_it_is_answered_out_loud() {
9379 let mut state = SetupState::default();
9380
9381 // A fresh record owes the notice, and "owed" is not an answer either way.
9382 assert!(state.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION));
9383 assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
9384 assert!(!state.telemetry_declined(TELEMETRY_NOTICE_VERSION));
9385
9386 state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, false);
9387 assert!(!state.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION));
9388 assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
9389 assert!(state.telemetry_declined(TELEMETRY_NOTICE_VERSION));
9390
9391 state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, true);
9392 assert!(state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
9393 assert!(!state.telemetry_declined(TELEMETRY_NOTICE_VERSION));
9394
9395 // A decision recorded against different notice content is stale: the
9396 // notice is owed again. The stale `true` still means the user did not opt
9397 // out; this helper only describes whether the current wording was shown.
9398 state.record_telemetry_notice("0", true);
9399 assert!(state.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION));
9400 assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
9401 assert!(!state.telemetry_declined(TELEMETRY_NOTICE_VERSION));
9402 }
9403
9404 #[test]
9405 fn deferring_the_constitution_checkpoint_does_not_answer_the_telemetry_notice() {
9406 // `complete_constitution_checkpoint(_, Deferred)` persists a completed
9407 // checkpoint without showing the user anything — reached from the
9408 // skip-onboarding path. The telemetry notice must not mirror that.
9409 let mut state = SetupState::default();
9410 state.complete_constitution_checkpoint("0.9.4", ConstitutionChoice::Deferred);
9411
9412 assert_eq!(
9413 state.constitution_checkpoint_completed_for.as_deref(),
9414 Some("0.9.4")
9415 );
9416 assert_eq!(state.telemetry_notice_decided_for, None);
9417 assert!(state.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION));
9418 assert!(!state.telemetry_opt_in);
9419 }
9420
9421 #[test]
9422 fn inherited_setup_state_never_carries_a_telemetry_decision() {
9423 // Upgrading users with no `setup_state.json` get a derived record. It must
9424 // not manufacture an answer they never gave.
9425 let state = SetupState::derive_inherited(&InheritedConfigFacts {
9426 has_provider_route: true,
9427 has_credentials_or_local_runtime: true,
9428 trust_chosen: true,
9429 language: Some("en".to_string()),
9430 ..InheritedConfigFacts::default()
9431 });
9432 assert_eq!(state.telemetry_notice_decided_for, None);
9433 assert!(!state.telemetry_opt_in);
9434 assert!(state.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION));
9435 }
9436
9437 #[test]
9438 fn telemetry_notice_fields_round_trip_and_stay_absent_when_unanswered() {
9439 let mut state = SetupState::default();
9440 let rendered = serde_json::to_string(&state).expect("serialize setup state");
9441 assert!(
9442 !rendered.contains("telemetry_notice_decided_for"),
9443 "an unanswered notice must not write a field: {rendered}"
9444 );
9445 assert!(
9446 !rendered.contains("telemetry_opt_in"),
9447 "the default false compatibility field must not be serialized: {rendered}"
9448 );
9449
9450 state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, true);
9451 let rendered = serde_json::to_string(&state).expect("serialize setup state");
9452 let round_tripped: SetupState = serde_json::from_str(&rendered).expect("round trips");
9453 assert_eq!(round_tripped, state);
9454
9455 // Records written before these fields existed load as "notice owed".
9456 let legacy: SetupState =
9457 serde_json::from_str(r#"{"schema_version":1}"#).expect("legacy record loads");
9458 assert_eq!(legacy.telemetry_notice_decided_for, None);
9459 assert!(!legacy.telemetry_opt_in);
9460 }
9461
9462 #[test]
9463 fn telemetry_disclosure_records_presentation_without_acceptance_or_erasing_old_declines() {
9464 for version in [None, Some("1"), Some("4")] {
9465 for enabled in [false, true] {
9466 let dir = tempfile::tempdir().unwrap();
9467 let path = dir.path().join("setup_state.json");
9468 let mut state = SetupState {
9469 constitution_preview_version: 12,
9470 ..Default::default()
9471 };
9472 if let Some(version) = version {
9473 state.record_telemetry_notice(version, enabled);
9474 }
9475 state.save_to(&path).unwrap();
9476 SetupState::update_at(&path, |latest| {
9477 latest.record_telemetry_notice_shown(TELEMETRY_NOTICE_VERSION);
9478 })
9479 .unwrap();
9480 let shown = SetupState::load_from(&path).unwrap();
9481 assert!(!shown.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION));
9482 assert!(!shown.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
9483 assert!(!shown.telemetry_declined(TELEMETRY_NOTICE_VERSION));
9484 assert_eq!(shown.telemetry_opted_out(), version.is_some() && !enabled);
9485 assert_eq!(
9486 shown.telemetry_notice_decided_for,
9487 state.telemetry_notice_decided_for
9488 );
9489 assert_eq!(shown.telemetry_opt_in, state.telemetry_opt_in);
9490 assert_eq!(shown.constitution_preview_version, 12);
9491 }
9492 }
9493 }
9494
9495 #[test]
9496 fn telemetry_metadata_update_refuses_corrupt_or_busy_state_and_reloads_the_saved_decline() {
9497 let dir = tempfile::tempdir().unwrap();
9498 let path = dir.path().join("setup_state.json");
9499 std::fs::write(&path, "not-json").unwrap();
9500 assert!(SetupState::update_at(&path, |_| {}).is_err());
9501 assert_eq!(std::fs::read_to_string(&path).unwrap(), "not-json");
9502 SetupState::default().save_to(&path).unwrap();
9503 let file = std::fs::OpenOptions::new()
9504 .read(true)
9505 .write(true)
9506 .open(path.with_extension("telemetry.lock"))
9507 .unwrap();
9508 let mut lock = fd_lock::RwLock::new(file);
9509 let guard = lock.write().unwrap();
9510 assert!(
9511 SetupState::update_at(&path, |state| {
9512 state.record_telemetry_notice_shown(TELEMETRY_NOTICE_VERSION);
9513 })
9514 .is_err(),
9515 "display bookkeeping must never block startup"
9516 );
9517 drop(guard);
9518 SetupState::update_at(&path, |state| {
9519 state.record_telemetry_notice("4", false);
9520 })
9521 .unwrap();
9522 SetupState::update_at(&path, |state| {
9523 state.record_telemetry_notice_shown(TELEMETRY_NOTICE_VERSION);
9524 })
9525 .unwrap();
9526 assert!(SetupState::load_from(&path).unwrap().telemetry_opted_out());
9527 }
9528
9529 #[test]
9530 fn openrouter_vendor_config_round_trip_and_trust_boundary() -> Result<()> {
9531 let key = "providers.openrouter.vendor";
9532 let mut config = ConfigToml::default();
9533 config.set_value(key, "deepinfra/turbo")?;
9534 let serialized = toml::to_string(&config)?;
9535 let mut reloaded: ConfigToml = toml::from_str(&serialized)?;
9536 assert_eq!(reloaded.get_value(key).as_deref(), Some("deepinfra/turbo"));
9537 assert_eq!(
9538 reloaded.list_values().get(key).map(String::as_str),
9539 Some("deepinfra/turbo")
9540 );
9541 assert_eq!(reloaded.get_value(key).as_deref(), Some("deepinfra/turbo"));
9542 for invalid in ["deep infra", "deepinfra\n/turbo", " deepinfra"] {
9543 assert!(reloaded.set_value(key, invalid).is_err());
9544 }
9545 assert!(
9546 reloaded
9547 .set_value("providers.openai.vendor", "deepinfra")
9548 .is_err()
9549 );
9550 assert!(
9551 reloaded
9552 .set_value("providers.my-gateway.vendor", "deepinfra")
9553 .is_err()
9554 );
9555 reloaded.set_value(key, "")?;
9556 let cleared: ConfigToml = toml::from_str(&toml::to_string(&reloaded)?)?;
9557 assert_eq!(cleared.get_value(key).as_deref(), Some(""));
9558 reloaded.unset_value(key)?;
9559 assert_eq!(reloaded.get_value(key), None);
9560 Ok(())
9561 }
9562
9563 #[test]
9564 fn notifications_nested_edits_keep_toml_types_siblings_and_future_fields() {
9565 let mut config: ConfigToml = toml::from_str(
9566 r#"
9567 [notifications]
9568 quiet = false
9569 future_delivery = "keep"
9570 [notifications.events]
9571 input-needed = false
9572 "#,
9573 )
9574 .unwrap();
9575 config.set_value("notifications.quiet", "true").unwrap();
9576 config
9577 .set_value("notifications.threshold_secs", "42")
9578 .unwrap();
9579 config
9580 .set_value("notifications.events.approval-needed", "false")
9581 .unwrap();
9582 config
9583 .set_value(
9584 "notifications.event_sound.events",
9585 r#"["input-needed", "model-notify"]"#,
9586 )
9587 .unwrap();
9588 config.set_value("notifications.sound", "whale").unwrap();
9589 let encoded = toml::to_string(&config).unwrap();
9590 let raw: toml::Value = toml::from_str(&encoded).unwrap();
9591 let notifications = &raw["notifications"];
9592 assert_eq!(notifications["quiet"].as_bool(), Some(true));
9593 assert_eq!(notifications["threshold_secs"].as_integer(), Some(42));
9594 assert_eq!(
9595 notifications["events"]["approval-needed"].as_bool(),
9596 Some(false)
9597 );
9598 assert_eq!(
9599 notifications["events"]["input-needed"].as_bool(),
9600 Some(false)
9601 );
9602 assert_eq!(notifications["future_delivery"].as_str(), Some("keep"));
9603 assert_eq!(
9604 notifications["event_sound"]["events"]
9605 .as_array()
9606 .unwrap()
9607 .len(),
9608 2
9609 );
9610 assert!(!raw.as_table().unwrap().contains_key("notifications.quiet"));
9611 assert_eq!(
9612 config.get_display_value("notifications.sound").as_deref(),
9613 Some("whale")
9614 );
9615 }
9616
9617 #[test]
9618 fn notifications_invalid_edits_are_atomic_even_with_public_update_values() {
9619 use notifications::{NotificationConfigUpdate as Update, NotificationSetting as Key};
9620 let mut config = ConfigToml::default();
9621 config.set_value("notifications.quiet", "true").unwrap();
9622 let before = toml::to_string(&config).unwrap();
9623 for (key, value) in [
9624 ("notifications", "false"),
9625 ("notifications.quiet", "maybe"),
9626 ("notifications.threshold_secs", "18446744073709551615"),
9627 ("notifications.event_sound.events", r#"["bogus"]"#),
9628 ("notifications.events.unknown", "true"),
9629 ("notifications.sound_file", ""),
9630 ] {
9631 assert!(config.set_value(key, value).is_err(), "{key}");
9632 assert_eq!(toml::to_string(&config).unwrap(), before);
9633 }
9634 let dir = tempfile::tempdir().unwrap();
9635 let path = dir.path().join("config.toml");
9636 std::fs::write(&path, &before).unwrap();
9637 for update in [
9638 Update::ThresholdSecs(u64::MAX),
9639 Update::SoundFile(PathBuf::new()),
9640 Update::EventSoundEvents(vec!["bogus".into()]),
9641 ] {
9642 let mut live = notifications::NotificationsConfig::default();
9643 let prior = live.clone();
9644 assert!(update.persist(&path).is_err());
9645 assert!(live.apply_update(update).is_err());
9646 assert_eq!(live, prior);
9647 assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
9648 }
9649 assert!(
9650 notifications::edit_extras(
9651 &mut config.extras,
9652 Key::Quiet,
9653 Some(toml::Value::String("true".into()))
9654 )
9655 .is_err()
9656 );
9657 assert_eq!(toml::to_string(&config).unwrap(), before);
9658 }
9659
9660 #[test]
9661 fn notifications_targeted_persistence_preserves_comments_and_leaf_unset() {
9662 use notifications::{NotificationConfigUpdate as Update, NotificationSetting as Key};
9663 let dir = tempfile::tempdir().unwrap();
9664 let path = dir.path().join("config.toml");
9665 std::fs::write(&path, "# operator note\n\"notifications.quiet\" = \"false\"\n[notifications]\nquiet = false # retained\nfuture = 7\n[notifications.events]\ninput-needed = false\n").unwrap();
9666 Update::parse(Key::Quiet, "true")
9667 .unwrap()
9668 .persist(&path)
9669 .unwrap();
9670 Update::parse(Key::Sound, "whale")
9671 .unwrap()
9672 .persist(&path)
9673 .unwrap();
9674 Key::Quiet.unset(&path).unwrap();
9675 let saved = std::fs::read_to_string(&path).unwrap();
9676 assert!(saved.contains("# operator note"));
9677 let raw: toml::Value = toml::from_str(&saved).unwrap();
9678 assert!(!raw.as_table().unwrap().contains_key("notifications.quiet"));
9679 assert!(raw["notifications"].get("quiet").is_none());
9680 assert_eq!(raw["notifications"]["sound"].as_str(), Some("whale"));
9681 assert_eq!(raw["notifications"]["future"].as_integer(), Some(7));
9682 assert_eq!(
9683 raw["notifications"]["events"]["input-needed"].as_bool(),
9684 Some(false)
9685 );
9686 }
9687
9688 #[test]
9689 fn notifications_legacy_condition_is_fallback_and_explicit_sound_off_wins() {
9690 let mut config: ConfigToml = toml::from_str(
9691 "[tui]\nnotification_condition = \"never\"\n[notifications]\ncompletion_sound = \"bell\"\n",
9692 )
9693 .unwrap();
9694 assert_eq!(
9695 config.get_value("notifications.condition").as_deref(),
9696 Some("never")
9697 );
9698 assert_eq!(
9699 config.get_value("notifications.sound").as_deref(),
9700 Some("legacy")
9701 );
9702 config
9703 .set_value("notifications.condition", "always")
9704 .unwrap();
9705 config.set_value("notifications.sound", "off").unwrap();
9706 assert_eq!(
9707 config.get_value("notifications.condition").as_deref(),
9708 Some("always")
9709 );
9710 config.unset_value("notifications.condition").unwrap();
9711 assert_eq!(
9712 config.get_value("notifications.condition").as_deref(),
9713 Some("never")
9714 );
9715 assert_eq!(
9716 config.get_value("notifications.sound").as_deref(),
9717 Some("off")
9718 );
9719 }
9720
9721 #[test]
9722 fn notifications_path_whitespace_and_quotes_round_trip_without_reparsing() {
9723 let mut config = ConfigToml::default();
9724 for path in [
9725 " sound with spaces.wav ",
9726 "\"quoted-name.wav",
9727 "folder/normal.wav",
9728 ] {
9729 let raw = toml::Value::String(path.into()).to_string();
9730 config.set_value("notifications.sound_file", &raw).unwrap();
9731 assert_eq!(
9732 config.get_value("notifications.sound_file").as_deref(),
9733 Some(path)
9734 );
9735 }
9736 }
9737
9738 #[test]
9739 fn notifications_malformed_parent_and_unknown_root_cannot_erase_config() {
9740 let dir = tempfile::tempdir().unwrap();
9741 let path = dir.path().join("config.toml");
9742 let original = "[notifications]\nevents = false\nquiet = true\n";
9743 std::fs::write(&path, original).unwrap();
9744 let update = notifications::NotificationConfigUpdate::parse(
9745 notifications::NotificationSetting::Event(notifications::NotificationEvent::InputNeeded),
9746 "false",
9747 )
9748 .unwrap();
9749 assert!(update.persist(&path).is_err());
9750 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
9751 let mut config: ConfigToml = toml::from_str(original).unwrap();
9752 assert!(config.unset_value("notifications").is_err());
9753 let before = toml::to_string(&config).unwrap();
9754 assert!(
9755 config
9756 .set_value("notifications.events.input-needed", "false")
9757 .is_err()
9758 );
9759 assert_eq!(toml::to_string(&config).unwrap(), before);
9760 }
9761
9762 #[test]
9763 fn config_table_and_nested_reads_share_redacted_document() {
9764 let config: ConfigToml = toml::from_str(
9765 r#"
9766 [tools]
9767 user_input_timeout_seconds = 7
9768 [hooks]
9769 enabled = true
9770 [credentials.service]
9771 value = "fixture-secret-never-display"
9772 [providers.openai]
9773 api_key = "fixture-provider-secret"
9774 "#,
9775 )
9776 .unwrap();
9777 assert!(
9778 config
9779 .get_value("tools")
9780 .unwrap()
9781 .contains("user_input_timeout_seconds = 7")
9782 );
9783 assert_eq!(
9784 config
9785 .get_value("tools.user_input_timeout_seconds")
9786 .as_deref(),
9787 Some("7")
9788 );
9789 assert_eq!(
9790 config.get_display_value("hooks.enabled").as_deref(),
9791 Some("true")
9792 );
9793 for key in [
9794 "credentials",
9795 "credentials.service",
9796 "credentials.service.value",
9797 "providers",
9798 ] {
9799 let shown = config.get_display_value(key).expect(key);
9800 assert!(!shown.contains("fixture-secret-never-display"), "{key}");
9801 assert!(!shown.contains("fixture-provider-secret"), "{key}");
9802 }
9803 }
9804
9805 #[test]
9806 fn unsupported_nested_config_write_fails_without_mutation() {
9807 let mut config: ConfigToml =
9808 toml::from_str("[tools]\nuser_input_timeout_seconds = 7\n").unwrap();
9809 let before = toml::to_string(&config).unwrap();
9810 let err = config
9811 .set_value("tools.user_input_timeout_seconds", "0")
9812 .unwrap_err();
9813 assert!(err.to_string().contains("[tools]"));
9814 assert!(err.to_string().contains("user_input_timeout_seconds"));
9815 assert_eq!(toml::to_string(&config).unwrap(), before);
9816 }
9817
9818 /// #6516: `output_mode` had no reader and was removed from the typed schema. A
9819 /// config that still carries it must keep loading, and a typed save must keep
9820 /// the user's line rather than silently dropping it or failing on it.
9821 #[test]
9822 fn retired_output_mode_key_still_loads_and_survives_a_typed_save() {
9823 let dir = tempfile::tempdir().expect("tempdir");
9824 let config_path = dir.path().join(CONFIG_FILE_NAME);
9825 fs::write(
9826 &config_path,
9827 "output_mode = \"plain\"\nverbosity = \"quiet\"\n\n[providers.deepseek]\nmodel = \"deepseek-v4-flash\"\n",
9828 )
9829 .expect("write config");
9830
9831 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
9832 assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
9833 assert_eq!(
9834 store.config.extras.get("output_mode"),
9835 Some(&toml::Value::String("plain".to_string()))
9836 );
9837
9838 store.config.verbosity = Some("concise".to_string());
9839 store.save().expect("typed save");
9840 let saved = fs::read_to_string(&config_path).expect("read saved config");
9841 let reparsed: ConfigToml = toml::from_str(&saved).expect("saved config parses");
9842 assert_eq!(reparsed.verbosity.as_deref(), Some("concise"));
9843 assert!(saved.contains("output_mode = \"plain\""), "{saved}");
9844 }
9845
9846 #[test]
9847 fn closed_choice_writes_validate_before_mutation_and_redact_pasted_credentials() {
9848 let mut config = ConfigToml::default();
9849 let token = ["sk-live-", "Z7qX4mNb2Vc9Lk3PwR8t"].concat();
9850 for key in ["approval_policy", "sandbox_mode", "verbosity"] {
9851 for choice in config_toml_choices(key).unwrap() {
9852 let value = format!(" {} ", choice.to_ascii_uppercase());
9853 config
9854 .set_value(key, &value)
9855 .expect("reader accepts normalized choice");
9856 assert_eq!(config.get_value(key).as_deref(), Some(value.as_str()));
9857 }
9858 let before = toml::to_string(&config).unwrap();
9859 for value in ["misspelled-choice", token.as_str()] {
9860 let error = config.set_value(key, value).expect_err("invalid choice");
9861 let message = error.to_string();
9862 assert!(message.contains(key), "{message}");
9863 assert!(message.contains("fix: codewhale config set"), "{message}");
9864 assert!(!message.contains(&token), "{message}");
9865 assert!(message.contains(if value == token { "[redacted]" } else { value }));
9866 assert_eq!(
9867 toml::to_string(&config).unwrap(),
9868 before,
9869 "{key} changed on refusal"
9870 );
9871 }
9872 }
9873 }
9874
9875 #[test]
9876 fn declared_setting_writes_keep_schema_type_and_refuse_bad_values() {
9877 let mut config = ConfigToml::default();
9878 config.set_value("allow_shell", "off").unwrap();
9879 assert_eq!(config.extras["allow_shell"], toml::Value::Boolean(false));
9880 config.set_value("max_history", " 250 ").unwrap();
9881 assert_eq!(config.extras["max_history"], toml::Value::Integer(250));
9882 config
9883 .set_value("auto_compact_threshold_percent", "72.5")
9884 .unwrap();
9885 assert_eq!(
9886 config.extras["auto_compact_threshold_percent"],
9887 toml::Value::Float(72.5)
9888 );
9889 config.set_value("tool_collapse", "Expanded").unwrap();
9890 assert_eq!(
9891 config.extras["tool_collapse"],
9892 toml::Value::String("expanded".into())
9893 );
9894
9895 let before = toml::to_string(&config).unwrap();
9896 for (key, value, needle) in [
9897 ("allow_shell", "flase", "invalid value for 'allow_shell'"),
9898 ("max_history", "lots", "expected an integer"),
9899 ("auto_compact_threshold_percent", "NaN", "expected a number"),
9900 (
9901 "tool_collapse",
9902 "sideways",
9903 "expected one of compact, expanded, calm",
9904 ),
9905 ] {
9906 let err = config.set_value(key, value).unwrap_err();
9907 assert!(format!("{err:#}").contains(needle), "{key}: {err:#}");
9908 }
9909 assert_eq!(
9910 toml::to_string(&config).unwrap(),
9911 before,
9912 "refusals change nothing"
9913 );
9914
9915 // `reasoning_effort` keeps its reader's aliases, which the schema's
9916 // option list does not name; the TUI reader validates them.
9917 for alias in ["none", "mid", "maximum", "minimum"] {
9918 config.set_value("reasoning_effort", alias).unwrap();
9919 assert_eq!(
9920 config.extras["reasoning_effort"],
9921 toml::Value::String(alias.into())
9922 );
9923 }
9924
9925 // Undeclared keys keep the string fallthrough; `config set` refuses the
9926 // ones nothing reads before calling here.
9927 config.set_value("skills_dir", "/tmp/skills").unwrap();
9928 assert_eq!(
9929 config.extras["skills_dir"],
9930 toml::Value::String("/tmp/skills".into())
9931 );
9932 }
9933
9934 // ── #6394: legacy top-level `base_url` / `api_key` ────────────────────────
9935
9936 mod legacy_root_upgrade {
9937 use super::*;
9938
9939 const V0_10_0_EXAMPLE: &str =
9940 include_str!("../tests/fixtures/legacy_root/v0_10_0_example.toml");
9941 const V0_9_9_AUTH_SET: &str =
9942 include_str!("../tests/fixtures/legacy_root/v0_9_9_auth_set.toml");
9943 const BASE_URL_SAVE: &str =
9944 include_str!("../tests/fixtures/legacy_root/config_base_url_save.toml");
9945 const LITERAL_CUSTOM: &str = include_str!("../tests/fixtures/legacy_root/literal_custom.toml");
9946 const URL_GUESSED_NIM: &str =
9947 include_str!("../tests/fixtures/legacy_root/url_guessed_nim.toml");
9948
9949 const CONFLICT: &str = r#"# keep me
9950 provider = "deepseek"
9951 base_url = "https://root.example.test/v1"
9952 api_key = "sk-root"
9953 verbosity = "normal"
9954
9955 [providers.deepseek]
9956 base_url = "https://table.example.test/v1"
9957 api_key = "sk-table"
9958 "#;
9959
9960 fn resolve(config: &ConfigToml) -> ResolvedRuntimeOptions {
9961 config.resolve_runtime_options(&CliRuntimeOverrides::default())
9962 }
9963
9964 fn parse(body: &str) -> ConfigToml {
9965 crate::parse_config_toml(body).expect("fixture parses")
9966 }
9967
9968 fn raw_table(path: &Path) -> toml::Table {
9969 toml::from_str(&fs::read_to_string(path).expect("read config")).expect("config parses")
9970 }
9971
9972 fn migrated(body: &str) -> String {
9973 let mut doc: toml_edit::DocumentMut = body.parse().expect("document");
9974 crate::legacy_root::apply_to_document(&mut doc, None);
9975 doc.to_string()
9976 }
9977
9978 /// Each fixture is real writer output from an older release. It resolves
9979 /// the same provider, endpoint and key before and after the file moves.
9980 #[test]
9981 fn upgrade_fixtures_resolve_the_same_before_and_after_migrate() {
9982 let _lock = env_lock();
9983 let _env = EnvGuard::without_deepseek_runtime_overrides();
9984 for (name, body, provider, base_url, api_key) in [
9985 (
9986 "v0.10.0 example",
9987 V0_10_0_EXAMPLE,
9988 ProviderKind::Deepseek,
9989 "https://api.deepseek.com/beta",
9990 Some("YOUR_DEEPSEEK_API_KEY"),
9991 ),
9992 (
9993 "v0.9.9 auth set",
9994 V0_9_9_AUTH_SET,
9995 ProviderKind::Deepseek,
9996 DEFAULT_DEEPSEEK_BASE_URL,
9997 Some("sk-legacy-auth-set"),
9998 ),
9999 (
10000 "/config base_url --save",
10001 BASE_URL_SAVE,
10002 ProviderKind::Deepseek,
10003 "https://proxy.example.test/v1",
10004 Some("sk-proxy-key"),
10005 ),
10006 (
10007 "literal custom",
10008 LITERAL_CUSTOM,
10009 ProviderKind::Custom,
10010 "http://127.0.0.1:18181/v1",
10011 Some("sk-literal-custom"),
10012 ),
10013 (
10014 "URL-guessed NIM",
10015 URL_GUESSED_NIM,
10016 ProviderKind::NvidiaNim,
10017 "https://integrate.api.nvidia.com/v1",
10018 // The top-level key was never NIM's (the TUI only sent it to
10019 // DeepSeek); it stays DeepSeek's.
10020 None,
10021 ),
10022 ] {
10023 let before = parse(body);
10024 for key in ["base_url", "api_key", "baseUrl", "apiKey"] {
10025 assert!(
10026 !before.extras.contains_key(key),
10027 "{name}: {key} leaked into extras"
10028 );
10029 }
10030 let after_body = migrated(body);
10031 let after = parse(&after_body);
10032 assert!(
10033 !crate::legacy_root::has_legacy_root_keys(
10034 &toml::from_str(&after_body).expect("migrated file parses")
10035 ),
10036 "{name}: legacy keys left after migrate"
10037 );
10038 for config in [&before, &after] {
10039 let resolved = resolve(config);
10040 assert_eq!(resolved.provider, provider, "{name}");
10041 assert_eq!(resolved.base_url.trim_end_matches('/'), base_url, "{name}");
10042 assert_eq!(resolved.api_key.as_deref(), api_key, "{name}");
10043 }
10044 }
10045 }
10046
10047 #[test]
10048 fn canonicalizing_keeps_value_types_in_unrelated_keys() {
10049 let config = parse(
10050 "base_url = \"https://proxy.example.test/v1\"\nstarted_at = 1979-05-27T07:32:00Z\n",
10051 );
10052 assert!(
10053 matches!(
10054 config.extras.get("started_at"),
10055 Some(toml::Value::Datetime(_))
10056 ),
10057 "{:?}",
10058 config.extras
10059 );
10060 }
10061
10062 #[test]
10063 fn the_literal_custom_route_keeps_its_model() {
10064 let _lock = env_lock();
10065 let _env = EnvGuard::without_deepseek_runtime_overrides();
10066 assert_eq!(resolve(&parse(LITERAL_CUSTOM)).model, "my-local-model");
10067 }
10068
10069 #[test]
10070 fn the_shipped_nim_profile_key_now_reaches_nim() {
10071 let root = {
10072 let mut table: toml::Table = toml::from_str(V0_10_0_EXAMPLE).unwrap();
10073 crate::legacy_root::apply_to_table(&mut table);
10074 table
10075 };
10076 let profile = root["profiles"]["nvidia-nim"].as_table().unwrap();
10077 assert_eq!(
10078 profile["providers"]["nvidia_nim"]["api_key"].as_str(),
10079 Some("YOUR_NVIDIA_API_KEY")
10080 );
10081 assert_eq!(
10082 root["profiles"]["work"]["providers"]["deepseek"]["base_url"].as_str(),
10083 Some("https://api.deepseek.com/beta")
10084 );
10085 }
10086
10087 #[test]
10088 fn migrate_keeps_comments_and_puts_simple_keys_before_tables() {
10089 let after = migrated(V0_9_9_AUTH_SET);
10090 assert!(after.starts_with("# codewhale Configuration\n"), "{after}");
10091 assert!(after.contains("# Thinking mode"), "{after}");
10092 assert!(after.contains("reasoning_effort = \"max\""), "{after}");
10093 let table_at = after.find("[providers.deepseek]").expect("table written");
10094 let scalar_at = after.find("reasoning_effort").unwrap();
10095 assert!(scalar_at < table_at, "{after}");
10096 assert!(parse(&after).providers.deepseek.api_key.is_some());
10097 }
10098
10099 #[test]
10100 fn a_save_moves_the_keys_once_with_a_credential_free_backup_and_one_notice() {
10101 let dir = tempfile::tempdir().expect("tempdir");
10102 let path = dir.path().join(CONFIG_FILE_NAME);
10103 fs::write(&path, V0_9_9_AUTH_SET).unwrap();
10104
10105 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
10106 assert!(store.legacy_root_migration().has_pending_moves());
10107 store.config.set_value("verbosity", "normal").unwrap();
10108 store.save().unwrap();
10109
10110 let raw = raw_table(&path);
10111 assert!(!crate::legacy_root::has_legacy_root_keys(&raw));
10112 assert_eq!(
10113 raw["providers"]["deepseek"]["api_key"].as_str(),
10114 Some("sk-legacy-auth-set")
10115 );
10116 let backup = crate::legacy_root_backup_path(&path).unwrap();
10117 let backup_body = fs::read_to_string(&backup).expect("backup written");
10118 assert!(
10119 backup_body.contains("# codewhale Configuration"),
10120 "{backup_body}"
10121 );
10122 assert!(
10123 !backup_body.contains("sk-legacy-auth-set"),
10124 "backup is credential-free"
10125 );
10126 let backup_path = backup.display().to_string();
10127 let ours: Vec<String> = crate::legacy_root::take_notices()
10128 .into_iter()
10129 .filter(|notice| notice.contains(&backup_path))
10130 .collect();
10131 assert_eq!(ours.len(), 1, "{ours:?}");
10132 assert!(ours[0].contains("[providers.deepseek]"), "{ours:?}");
10133
10134 // A second save changes nothing and says nothing.
10135 let body = fs::read_to_string(&path).unwrap();
10136 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
10137 assert!(store.legacy_root_migration().is_empty());
10138 store.save().unwrap();
10139 assert_eq!(fs::read_to_string(&path).unwrap(), body);
10140 assert!(
10141 !crate::legacy_root::take_notices()
10142 .iter()
10143 .any(|notice| notice.contains(&backup_path))
10144 );
10145 }
10146
10147 #[test]
10148 fn a_conflict_survives_an_unrelated_save_and_ends_on_an_explicit_write() {
10149 let dir = tempfile::tempdir().expect("tempdir");
10150 let path = dir.path().join(CONFIG_FILE_NAME);
10151 fs::write(&path, CONFLICT).unwrap();
10152
10153 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
10154 assert_eq!(
10155 store.legacy_root_migration().unresolved_conflicts().count(),
10156 2
10157 );
10158 store.config.set_value("verbosity", "normal").unwrap();
10159 store.save().unwrap();
10160 let raw = raw_table(&path);
10161 assert_eq!(
10162 raw["base_url"].as_str(),
10163 Some("https://root.example.test/v1")
10164 );
10165 assert_eq!(raw["api_key"].as_str(), Some("sk-root"));
10166 assert_eq!(
10167 raw["providers"]["deepseek"]["base_url"].as_str(),
10168 Some("https://table.example.test/v1")
10169 );
10170 assert_eq!(
10171 raw["providers"]["deepseek"]["api_key"].as_str(),
10172 Some("sk-table")
10173 );
10174 assert!(
10175 fs::read_to_string(&path)
10176 .unwrap()
10177 .starts_with("# keep me\n")
10178 );
10179
10180 // `auth set` style: the user writes the key; that ends the key
10181 // conflict and leaves the endpoint pair alone.
10182 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
10183 store
10184 .config
10185 .set_value("providers.deepseek.api_key", "sk-chosen")
10186 .unwrap();
10187 store.save().unwrap();
10188 let raw = raw_table(&path);
10189 assert!(raw.get("api_key").is_none());
10190 assert_eq!(
10191 raw["providers"]["deepseek"]["api_key"].as_str(),
10192 Some("sk-chosen")
10193 );
10194 assert_eq!(
10195 raw["base_url"].as_str(),
10196 Some("https://root.example.test/v1")
10197 );
10198 }
10199
10200 #[test]
10201 fn a_targeted_write_moves_keys_and_keeps_conflicts() {
10202 let dir = tempfile::tempdir().expect("tempdir");
10203 let path = dir.path().join(CONFIG_FILE_NAME);
10204 fs::write(&path, CONFLICT.replace("verbosity", "log_level")).unwrap();
10205 crate::mutate_config_document(&path, |doc| {
10206 crate::set_config_document_value(doc, &["verbosity"], "high")
10207 })
10208 .unwrap();
10209 let raw = raw_table(&path);
10210 assert_eq!(
10211 raw["base_url"].as_str(),
10212 Some("https://root.example.test/v1")
10213 );
10214 assert_eq!(raw["api_key"].as_str(), Some("sk-root"));
10215
10216 fs::write(&path, BASE_URL_SAVE).unwrap();
10217 crate::mutate_config_document(&path, |doc| {
10218 crate::set_config_document_value(doc, &["verbosity"], "high")
10219 })
10220 .unwrap();
10221 let raw = raw_table(&path);
10222 assert!(!crate::legacy_root::has_legacy_root_keys(&raw));
10223 assert_eq!(
10224 raw["providers"]["deepseek"]["base_url"].as_str(),
10225 Some("https://proxy.example.test/v1")
10226 );
10227 assert_eq!(
10228 raw["providers"]["openrouter"]["api_key"].as_str(),
10229 Some("sk-or-other-vendor")
10230 );
10231 }
10232
10233 #[test]
10234 fn config_migrate_dry_run_writes_nothing_and_a_second_run_is_empty() {
10235 let dir = tempfile::tempdir().expect("tempdir");
10236 let path = dir.path().join(CONFIG_FILE_NAME);
10237 fs::write(&path, CONFLICT).unwrap();
10238
10239 let preview = crate::preview_legacy_root_config(&path, None).unwrap();
10240 assert_eq!(preview.unresolved_conflicts().count(), 2);
10241 assert_eq!(fs::read_to_string(&path).unwrap(), CONFLICT);
10242
10243 // No `--prefer`: a conflict-only file is left exactly as it is.
10244 let (receipt, backup) = crate::migrate_legacy_root_config(&path, None).unwrap();
10245 assert!(!receipt.changes_file());
10246 assert!(backup.is_none());
10247 assert_eq!(fs::read_to_string(&path).unwrap(), CONFLICT);
10248
10249 let (receipt, backup) = crate::migrate_legacy_root_config(
10250 &path,
10251 Some(crate::legacy_root::LegacyRootPrefer::Table),
10252 )
10253 .unwrap();
10254 assert!(receipt.changes_file());
10255 let backup = backup.expect("backup before resolving");
10256 assert!(!fs::read_to_string(backup).unwrap().contains("sk-"));
10257 let raw = raw_table(&path);
10258 assert!(!crate::legacy_root::has_legacy_root_keys(&raw));
10259 assert_eq!(
10260 raw["providers"]["deepseek"]["api_key"].as_str(),
10261 Some("sk-table")
10262 );
10263
10264 let (receipt, _) = crate::migrate_legacy_root_config(&path, None).unwrap();
10265 assert!(receipt.is_empty());
10266 }
10267
10268 #[test]
10269 fn config_set_base_url_writes_the_active_providers_table() {
10270 let mut config = parse("provider = \"openai\"\n");
10271 config
10272 .set_value("base_url", "https://gateway.example.test/v1")
10273 .unwrap();
10274 assert_eq!(
10275 config.providers.openai.base_url.as_deref(),
10276 Some("https://gateway.example.test/v1")
10277 );
10278 assert!(!config.extras.contains_key("base_url"));
10279 assert_eq!(
10280 config.get_value("base_url").as_deref(),
10281 Some("https://gateway.example.test/v1")
10282 );
10283 assert_eq!(
10284 config.root_alias_key("base_url").as_deref(),
10285 Some("providers.openai.base_url")
10286 );
10287 config.unset_value("base_url").unwrap();
10288 assert!(config.providers.openai.base_url.is_none());
10289 }
10290
10291 #[test]
10292 fn provider_field_writers_no_longer_write_top_level_twins() {
10293 let mut config = ConfigToml::default();
10294 config
10295 .set_value(
10296 "providers.deepseek.base_url",
10297 "https://proxy.example.test/v1",
10298 )
10299 .unwrap();
10300 config
10301 .set_value("providers.deepseek.api_key", "sk-deepseek")
10302 .unwrap();
10303 let rendered = toml::to_string(&config).unwrap();
10304 let table: toml::Table = toml::from_str(&rendered).unwrap();
10305 assert!(
10306 !crate::legacy_root::has_legacy_root_keys(&table),
10307 "{rendered}"
10308 );
10309 }
10310
10311 #[test]
10312 fn project_config_base_url_never_reaches_the_runtime() {
10313 let dir = tempfile::tempdir().expect("tempdir");
10314 let project = dir.path().join(CODEWHALE_APP_DIR);
10315 fs::create_dir_all(&project).unwrap();
10316 fs::write(
10317 project.join(CONFIG_FILE_NAME),
10318 "approval_policy = \"never\"\nbase_url = \"https://attacker.example.test/v1\"\n",
10319 )
10320 .unwrap();
10321 let project_config = crate::load_project_config(dir.path()).expect("project config");
10322 assert!(!project_config.extras.contains_key("base_url"));
10323 // Project config is read for approval/sandbox posture only; its moved
10324 // endpoint sits in a table nothing merges into the user's routing.
10325 assert_eq!(project_config.approval_policy.as_deref(), Some("never"));
10326 }
10327 }
10328
10329 #[test]
10330 fn typed_save_round_trips_every_builtin_provider_selector() {
10331 let dir = tempfile::tempdir().expect("tempdir");
10332 let path = dir.path().join(CONFIG_FILE_NAME);
10333 let mismatched: Vec<String> = provider::all_providers()
10334 .iter()
10335 .map(|entry| entry.kind())
10336 .filter_map(|kind| {
10337 let serialized = toml::Value::try_from(kind).expect("serialize provider kind");
10338 (serialized.as_str() != Some(kind.as_str()))
10339 .then(|| format!("{} -> {serialized}", kind.as_str()))
10340 })
10341 .collect();
10342 assert!(
10343 mismatched.is_empty(),
10344 "serde spelling must be the canonical id: {mismatched:?}"
10345 );
10346 for entry in provider::all_providers() {
10347 let kind = entry.kind();
10348 fs::write(&path, format!("provider = \"{}\"\n", entry.id())).expect("write config");
10349 let Ok(mut store) = ConfigStore::load(Some(path.clone())) else {
10350 // A retired tombstone may refuse to load; it must not be written.
10351 continue;
10352 };
10353 store
10354 .config
10355 .set_value("verbosity", "concise")
10356 .expect("set verbosity");
10357 store.save().expect("typed save");
10358 let reloaded = ConfigStore::load(Some(path.clone()))
10359 .unwrap_or_else(|err| panic!("reload after saving {}: {err:#}", entry.id()));
10360 assert_eq!(reloaded.config.provider, kind, "{}", entry.id());
10361 }
10362 }
10363
10364 #[test]
10365 fn legacy_siliconflow_cn_spelling_loads_and_is_repaired_on_save() {
10366 let dir = tempfile::tempdir().expect("tempdir");
10367 let path = dir.path().join(CONFIG_FILE_NAME);
10368 fs::write(&path, "provider = \"siliconflow-c-n\"\n").expect("write config");
10369 let mut store = ConfigStore::load(Some(path.clone())).expect("load legacy spelling");
10370 assert_eq!(store.config.provider, ProviderKind::SiliconflowCN);
10371 store
10372 .config
10373 .set_value("verbosity", "concise")
10374 .expect("set verbosity");
10375 store.save().expect("typed save");
10376 let body = fs::read_to_string(&path).expect("read config");
10377 assert!(body.contains("provider = \"siliconflow-CN\""), "{body}");
10378 }
10379
10380 #[test]
10381 fn typed_save_keeps_a_providers_section_holding_only_a_legacy_kind_table() {
10382 let dir = tempfile::tempdir().expect("tempdir");
10383 let path = dir.path().join(CONFIG_FILE_NAME);
10384 for entry in provider::all_providers() {
10385 let key = entry.provider_config_key();
10386 fs::write(&path, format!("[providers.{key}]\nmodel = \"m-x\"\n")).expect("write config");
10387 let Ok(mut store) = ConfigStore::load(Some(path.clone())) else {
10388 continue;
10389 };
10390 store
10391 .config
10392 .set_value("verbosity", "concise")
10393 .expect("set verbosity");
10394 store.save().expect("typed save");
10395 let body = fs::read_to_string(&path).expect("read config");
10396 assert!(
10397 body.contains("m-x"),
10398 "[providers.{key}] was dropped by a typed save:\n{body}"
10399 );
10400 }
10401 }
10402
10403 #[test]
10404 fn typed_save_keeps_runtime_owned_keys_in_typed_sub_tables() {
10405 let dir = tempfile::tempdir().expect("tempdir");
10406 let path = dir.path().join(CONFIG_FILE_NAME);
10407 fs::write(
10408 &path,
10409 "[snapshots]\nmax_workspace_gb = 8\n\n[skills]\nscan_codewhale_only = true\n\n\
10410 [network]\nfuture_network_key = \"kept\"\n\n[lsp]\nfuture_lsp_key = 3\n",
10411 )
10412 .expect("write config");
10413 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
10414 store
10415 .config
10416 .set_value("verbosity", "concise")
10417 .expect("set verbosity");
10418 store.save().expect("typed save");
10419 let saved: toml::Table = toml::from_str(&fs::read_to_string(&path).expect("read config"))
10420 .expect("parse saved config");
10421 assert_eq!(
10422 saved["snapshots"].get("max_workspace_gb"),
10423 Some(&toml::Value::Integer(8))
10424 );
10425 assert_eq!(
10426 saved["skills"].get("scan_codewhale_only"),
10427 Some(&toml::Value::Boolean(true))
10428 );
10429 assert_eq!(
10430 saved["network"].get("future_network_key"),
10431 Some(&toml::Value::String("kept".to_string()))
10432 );
10433 assert_eq!(
10434 saved["lsp"].get("future_lsp_key"),
10435 Some(&toml::Value::Integer(3))
10436 );
10437 }
10438
10439 const NAMED_CUSTOM_WITH_LEGACY_CUSTOM: &str = r#"provider = "acme"
10440
10441 [providers.acme]
10442 kind = "openai-compatible"
10443 base_url = "https://acme.example/v1"
10444 model = "acme-model"
10445 context_window = CONTEXT_WINDOW
10446
10447 [providers.custom]
10448 base_url = "https://legacy-custom.example/v1"
10449 model = "legacy-model"
10450 api_key = "legacy-custom-key-1234567890"
10451 "#;
10452
10453 #[test]
10454 fn named_custom_provider_with_invalid_table_is_rejected_on_load() {
10455 let dir = tempfile::tempdir().expect("tempdir");
10456 let path = dir.path().join(CONFIG_FILE_NAME);
10457 fs::write(
10458 &path,
10459 NAMED_CUSTOM_WITH_LEGACY_CUSTOM.replace("CONTEXT_WINDOW", "\"big-secret-value\""),
10460 )
10461 .expect("write config");
10462 let err = ConfigStore::load(Some(path)).expect_err("invalid named table must not load");
10463 let message = format!("{err:#}");
10464 assert!(message.contains("[providers.acme]"), "{message}");
10465 assert!(!message.contains("big-secret-value"), "{message}");
10466 }
10467
10468 #[test]
10469 fn named_custom_provider_never_resolves_to_legacy_custom_table() {
10470 let _lock = env_lock();
10471 let _env = EnvGuard::without_deepseek_runtime_overrides();
10472 let dir = tempfile::tempdir().expect("tempdir");
10473 let path = dir.path().join(CONFIG_FILE_NAME);
10474 fs::write(
10475 &path,
10476 NAMED_CUSTOM_WITH_LEGACY_CUSTOM.replace("CONTEXT_WINDOW", "128000"),
10477 )
10478 .expect("write config");
10479 let mut store = ConfigStore::load(Some(path)).expect("load valid named table");
10480 let resolved = store
10481 .config
10482 .resolve_runtime_options(&CliRuntimeOverrides::default());
10483 assert_eq!(resolved.base_url, "https://acme.example/v1");
10484
10485 // The table turns invalid after binding: resolution fails closed instead
10486 // of reading `[providers.custom]`.
10487 store
10488 .config
10489 .providers
10490 .extras
10491 .get_mut("acme")
10492 .and_then(toml::Value::as_table_mut)
10493 .expect("acme table")
10494 .insert("context_window".to_string(), toml::Value::from("big"));
10495 let resolved = store
10496 .config
10497 .resolve_runtime_options(&CliRuntimeOverrides::default());
10498 assert!(
10499 !resolved.base_url.contains("legacy-custom"),
10500 "{}",
10501 resolved.base_url
10502 );
10503 assert_ne!(resolved.model, "legacy-model");
10504 assert_ne!(
10505 resolved.api_key.as_deref(),
10506 Some("legacy-custom-key-1234567890")
10507 );
10508 }
10509
10510 #[test]
10511 fn deepseek_scoped_headers_and_model_stay_out_of_root_keys() -> Result<()> {
10512 let _lock = env_lock();
10513 let _env = EnvGuard::without_deepseek_runtime_overrides();
10514 let mut config = ConfigToml::default();
10515 config.set_value("providers.deepseek.http_headers", "X-Gateway-Key=ds-only")?;
10516 config.set_value("providers.deepseek.model", "deepseek-v4-pro")?;
10517 assert!(config.http_headers.is_empty());
10518 assert_eq!(config.default_text_model, None);
10519
10520 config.set_value("provider", "openrouter")?;
10521 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
10522 assert!(
10523 !resolved.http_headers.contains_key("X-Gateway-Key"),
10524 "{:?}",
10525 resolved.http_headers
10526 );
10527
10528 // Root values the user set apart survive unsetting the provider leg,
10529 // while a mirrored copy left by an earlier release is cleared with it.
10530 config.set_value("http_headers", "X-Everywhere=root")?;
10531 config.set_value("default_text_model", "root-model")?;
10532 config.unset_value("providers.deepseek.http_headers")?;
10533 config.unset_value("providers.deepseek.model")?;
10534 assert_eq!(
10535 config.http_headers.get("X-Everywhere").map(String::as_str),
10536 Some("root")
10537 );
10538 assert_eq!(config.default_text_model.as_deref(), Some("root-model"));
10539
10540 config.set_value("providers.deepseek.http_headers", "X-Everywhere=root")?;
10541 config.unset_value("providers.deepseek.http_headers")?;
10542 assert!(config.http_headers.is_empty());
10543 Ok(())
10544 }
10545
10546 #[test]
10547 fn key_and_cookie_names_are_classified_as_sensitive() {
10548 for name in [
10549 "Ocp-Apim-Subscription-Key",
10550 "Cookie",
10551 "Set-Cookie",
10552 "secret_key",
10553 "access_key",
10554 "private_key",
10555 "providers.acme.secret_key",
10556 "X-Api-Key",
10557 "api_key",
10558 ] {
10559 assert!(is_sensitive_config_key(name), "{name}");
10560 assert!(is_upstream_auth_header(name), "{name}");
10561 }
10562 for name in [
10563 "api_key_env",
10564 "public_key",
10565 "base_url",
10566 "model",
10567 "X-Model-Provider-Id",
10568 ] {
10569 assert!(!is_sensitive_config_key(name), "{name}");
10570 }
10571
10572 let mut config = ConfigToml::default();
10573 config.http_headers.insert(
10574 "Ocp-Apim-Subscription-Key".to_string(),
10575 "apim-value-0123456789abcdef".to_string(),
10576 );
10577 let listed = config.list_values();
10578 let shown = listed.get("http_headers").expect("headers listed");
10579 assert!(!shown.contains("apim-value-0123456789abcdef"), "{shown}");
10580 }
10581
10582 #[test]
10583 fn config_backup_strips_every_credential_named_key() {
10584 let dir = tempfile::tempdir().expect("tempdir");
10585 let path = dir.path().join(CONFIG_FILE_NAME);
10586 let original = r#"chatgpt_access_token = "root-access-token-value"
10587 model = "deepseek-v4-pro"
10588
10589 [lifecycle_outbox]
10590 path = "/tmp/outbox.jsonl"
10591 webhook_token = "webhook-token-value"
10592
10593 [providers.openrouter]
10594 api_key = "provider-api-key-value"
10595 auth_mode = "api_key"
10596
10597 [providers.openrouter.http_headers]
10598 Authorization = "Bearer header-bearer-value"
10599 X-Title = "kept-title"
10600
10601 [future_section]
10602 profiles = [{ secret_key = "inline-array-secret-value", label = "kept-label" }]
10603 "#;
10604 fs::write(&path, original).expect("seed config");
10605
10606 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
10607 store.config.model = Some("deepseek-v4-flash".to_string());
10608 store.save().expect("changed save");
10609
10610 let backup = fs::read_to_string(config_backup_path(&path)).expect("read backup");
10611 for secret in [
10612 "root-access-token-value",
10613 "webhook-token-value",
10614 "provider-api-key-value",
10615 "header-bearer-value",
10616 "inline-array-secret-value",
10617 ] {
10618 assert!(
10619 !backup.contains(secret),
10620 "{secret} left in backup:\n{backup}"
10621 );
10622 }
10623 for kept in [
10624 "auth_mode = \"api_key\"",
10625 "kept-title",
10626 "kept-label",
10627 "model = \"deepseek-v4-pro\"",
10628 ] {
10629 assert!(
10630 backup.contains(kept),
10631 "{kept} missing from backup:\n{backup}"
10632 );
10633 }
10634 }
10635
10636 #[test]
10637 fn blank_or_unrecognized_env_overrides_do_not_shadow_config_or_legacy_vars() {
10638 let _lock = env_lock();
10639 let _env = EnvGuard::without_deepseek_runtime_overrides();
10640 let names = [
10641 "CODEWHALE_AUTH_MODE",
10642 "DEEPSEEK_AUTH_MODE",
10643 "CODEWHALE_SANDBOX_MODE",
10644 "DEEPSEEK_SANDBOX_MODE",
10645 ];
10646 let saved: Vec<(&str, Option<OsString>)> = names
10647 .iter()
10648 .map(|name| (*name, env::var_os(name)))
10649 .collect();
10650 // SAFETY: env mutation is serialized by `env_lock` and restored below.
10651 unsafe {
10652 env::set_var("CODEWHALE_AUTH_MODE", "");
10653 env::remove_var("DEEPSEEK_AUTH_MODE");
10654 env::set_var("CODEWHALE_SANDBOX_MODE", " ");
10655 env::set_var("DEEPSEEK_SANDBOX_MODE", "workspace-write");
10656 env::set_var("CODEWHALE_PROVIDER", "openroutr");
10657 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
10658 }
10659
10660 let mut config = ConfigToml {
10661 auth_mode: Some("api_key".to_string()),
10662 ..ConfigToml::default()
10663 };
10664 config.provider = ProviderKind::Deepseek;
10665 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
10666 let env_overrides = EnvRuntimeOverrides::load();
10667
10668 unsafe {
10669 for (name, value) in saved {
10670 match value {
10671 Some(value) => env::set_var(name, value),
10672 None => env::remove_var(name),
10673 }
10674 }
10675 }
10676
10677 assert_eq!(resolved.auth_mode.as_deref(), Some("api_key"));
10678 assert_eq!(
10679 env_overrides.sandbox_mode.as_deref(),
10680 Some("workspace-write")
10681 );
10682 assert_eq!(resolved.provider, ProviderKind::Openrouter);
10683 assert!(matches!(
10684 resolved.provider_source,
10685 ProviderSource::Env("DEEPSEEK_PROVIDER")
10686 ));
10687 }
10688
10689 #[cfg(unix)]
10690 #[test]
10691 fn failed_legacy_state_copy_leaves_no_partial_primary() {
10692 use std::os::unix::fs::PermissionsExt;
10693 let dir = tempfile::tempdir().expect("tempdir");
10694 let legacy = dir.path().join("legacy-sessions");
10695 let primary = dir.path().join("primary").join("sessions");
10696 fs::create_dir_all(legacy.join("nested")).expect("legacy dir");
10697 fs::create_dir_all(primary.parent().expect("parent")).expect("primary root");
10698 fs::write(legacy.join("a.json"), b"a").expect("file a");
10699 let unreadable = legacy.join("nested").join("b.json");
10700 fs::write(&unreadable, b"b").expect("file b");
10701 fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o000)).expect("chmod");
10702 let readable_anyway = fs::read(&unreadable).is_ok();
10703
10704 let result = copy_dir_into_place(&legacy, &primary);
10705 fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o644)).expect("chmod back");
10706 if readable_anyway {
10707 // Running as root: permissions cannot force the failure.
10708 return;
10709 }
10710 assert!(result.is_err());
10711 assert!(
10712 !primary.exists(),
10713 "a failed copy must not create the primary"
10714 );
10715 assert_eq!(
10716 fs::read_dir(primary.parent().unwrap()).unwrap().count(),
10717 0,
10718 "a failed copy removes only its owned staging directory"
10719 );
10720
10721 copy_dir_into_place(&legacy, &primary).expect("retry succeeds");
10722 assert_eq!(fs::read(primary.join("a.json")).expect("a"), b"a");
10723 assert_eq!(
10724 fs::read(primary.join("nested").join("b.json")).expect("b"),
10725 b"b"
10726 );
10727 }
10728
10729 #[test]
10730 fn legacy_state_copy_preserves_another_attempts_staging_directory() {
10731 let root = tempfile::tempdir().expect("tempdir");
10732 let legacy = root.path().join("legacy");
10733 let primary = root.path().join("sessions");
10734 let other_staging = root.path().join(".sessions.migrating");
10735 fs::create_dir_all(&legacy).expect("legacy");
10736 fs::create_dir_all(&other_staging).expect("other attempt");
10737 fs::write(legacy.join("session.json"), b"source").expect("source");
10738 fs::write(other_staging.join("in-flight.json"), b"other attempt").expect("marker");
10739
10740 copy_dir_into_place(&legacy, &primary).expect("copy");
10741 assert_eq!(fs::read(primary.join("session.json")).unwrap(), b"source");
10742 assert_eq!(
10743 fs::read(other_staging.join("in-flight.json")).unwrap(),
10744 b"other attempt"
10745 );
10746 assert!(legacy.join("session.json").exists());
10747 let siblings: Vec<_> = fs::read_dir(root.path())
10748 .unwrap()
10749 .map(|entry| entry.unwrap().file_name())
10750 .collect();
10751 assert_eq!(siblings.len(), 3, "only this attempt's staging is removed");
10752 }
10753
10754 #[test]
10755 fn legacy_state_copy_preserves_a_completed_primary() {
10756 let root = tempfile::tempdir().expect("tempdir");
10757 let legacy = root.path().join("legacy");
10758 let primary = root.path().join("sessions");
10759 fs::create_dir_all(&legacy).expect("legacy");
10760 fs::create_dir_all(&primary).expect("completed primary");
10761 fs::write(legacy.join("session.json"), b"legacy").expect("source");
10762 fs::write(primary.join("session.json"), b"newer").expect("primary");
10763
10764 assert!(copy_dir_into_place(&legacy, &primary).is_err());
10765 assert_eq!(fs::read(primary.join("session.json")).unwrap(), b"newer");
10766 assert_eq!(fs::read(legacy.join("session.json")).unwrap(), b"legacy");
10767 assert_eq!(fs::read_dir(root.path()).unwrap().count(), 2);
10768 }
10769
10770 #[cfg(unix)]
10771 #[test]
10772 fn ensure_state_dir_keeps_legacy_authoritative_until_migration_succeeds() {
10773 use std::os::unix::fs::PermissionsExt;
10774 let _lock = env_lock();
10775 let unique = std::time::SystemTime::now()
10776 .duration_since(std::time::UNIX_EPOCH)
10777 .expect("clock")
10778 .as_nanos();
10779 let state_env = StateDirEnv::install(unique);
10780 fs::create_dir_all(state_env.legacy("sessions")).expect("legacy dir");
10781 fs::write(state_env.legacy("sessions").join("old.json"), b"legacy").expect("legacy file");
10782 let root = state_env.home.join(CODEWHALE_APP_DIR);
10783 fs::create_dir_all(&root).expect("codewhale root");
10784 fs::set_permissions(&root, fs::Permissions::from_mode(0o555)).expect("chmod root");
10785 let root_writable = fs::write(root.join("probe"), b"").is_ok();
10786
10787 let first = ensure_state_dir_with_migration("sessions");
10788 fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).expect("chmod back");
10789 if !root_writable {
10790 let (dir, migration) = first.expect("ensure_state_dir falls back to legacy");
10791 assert_eq!(dir, state_env.legacy("sessions"));
10792 assert!(migration.is_none());
10793 assert!(!state_env.primary("sessions").exists());
10794 assert_eq!(
10795 resolve_state_dir("sessions").expect("resolve"),
10796 state_env.legacy("sessions")
10797 );
10798
10799 // Once the primary root is writable again the migration retries.
10800 let (dir, migration) = ensure_state_dir_with_migration("sessions").expect("retry");
10801 assert_eq!(dir, state_env.primary("sessions"));
10802 assert!(migration.is_some());
10803 assert_eq!(
10804 fs::read(state_env.primary("sessions").join("old.json")).expect("migrated"),
10805 b"legacy"
10806 );
10807 }
10808 let _ = fs::remove_dir_all(&state_env.home);
10809 }
10810
10811 #[test]
10812 fn stream_settings_are_typed_nested_and_fail_without_mutation() {
10813 let mut config = ConfigToml::default();
10814 for (key, value) in [
10815 ("stream.open_timeout_secs", "120"),
10816 ("stream.force_http1", "on"),
10817 ("stream.tcp_keepalive_secs", "0"),
10818 ] {
10819 config.set_value(key, value).unwrap();
10820 }
10821 assert_eq!(
10822 config.extras["stream"]["open_timeout_secs"].as_integer(),
10823 Some(120)
10824 );
10825 assert_eq!(config.extras["stream"]["force_http1"].as_bool(), Some(true));
10826 let before = toml::to_string(&config).unwrap();
10827 for (key, value) in [
10828 ("stream.max_resumes", "-1"),
10829 ("stream.max_resumes", "4294967296"),
10830 ("stream.force_http1", "flase"),
10831 ("stream.tcp_keepalive_secs", "1.5"),
10832 ("stream.open_timout_secs", "45"),
10833 ] {
10834 assert!(config.set_value(key, value).is_err(), "{key}");
10835 assert_eq!(toml::to_string(&config).unwrap(), before);
10836 }
10837 let mut reloaded: ConfigToml = toml::from_str(&before).unwrap();
10838 reloaded.unset_value("stream.force_http1").unwrap();
10839 assert!(reloaded.extras["stream"].get("force_http1").is_none());
10840 assert_eq!(
10841 reloaded.extras["stream"]["open_timeout_secs"].as_integer(),
10842 Some(120)
10843 );
10844 assert_eq!(
10845 reloaded.extras["stream"]["tcp_keepalive_secs"].as_integer(),
10846 Some(0)
10847 );
10848 }
10849
10850 #[test]
10851 fn config_backup_shared_vocabulary_preserves_safe_values_and_comments_on_disk() {
10852 let dir = tempfile::tempdir().expect("tempdir");
10853 let path = dir.path().join(CONFIG_FILE_NAME);
10854 let canonical = "model = \"current-model\"\n";
10855 fs::write(&path, canonical).expect("canonical config");
10856 let raw = r#"# ordinary header stays
10857 # privateKey = "comment-s10-synthetic"
10858 model = "fixture-model" # clientSecret=inline-s10-synthetic
10859 max_tokens = 4096 # ordinary inline stays
10860 public_key = "public-value"
10861 endpoint_key = "endpoint-label"
10862 base_url = "https://api.example.com"
10863 # ordinary URL https://api.example.com
10864 [providers.fixture] # cookie=table-inline-s10-synthetic
10865 privateKey = "private-s10-synthetic"
10866 clientSecret = "client-s10-synthetic"
10867 api_key_env = "SYNTHETIC_KEY_ENV"
10868 auth_mode = "api_key"
10869 headers = { "Set-Cookie" = "cookie-s10-synthetic", "X-Trace" = "kept-trace" }
10870 [future_section]
10871 profiles = [{ accessToken = "access-s10-synthetic", label = "kept-profile" }]
10872 args = ["--flag", "clientSecret=array-s10-synthetic", "https://api.example.com"]
10873 webhook_url = "https://hooks.example.com/?sas=url-s10-synthetic"
10874 # trailing ordinary comment stays
10875 "#;
10876 let backup_path = config_backup_path(&path);
10877 fs::write(&backup_path, raw).expect("seed historical backup");
10878 scrub_plaintext_api_keys_from_config_backup(&path).expect("scrub persisted backup");
10879 let backup = fs::read_to_string(&backup_path).expect("persisted backup");
10880 for marker in [
10881 "comment-s10-synthetic",
10882 "inline-s10-synthetic",
10883 "table-inline-s10-synthetic",
10884 "private-s10-synthetic",
10885 "client-s10-synthetic",
10886 "cookie-s10-synthetic",
10887 "access-s10-synthetic",
10888 "array-s10-synthetic",
10889 "url-s10-synthetic",
10890 ] {
10891 assert!(
10892 !backup.contains(marker),
10893 "credential survived in persisted backup"
10894 );
10895 }
10896 for kept in [
10897 "ordinary header stays",
10898 "ordinary inline stays",
10899 "trailing ordinary comment stays",
10900 "max_tokens = 4096",
10901 "public-value",
10902 "endpoint-label",
10903 "https://api.example.com",
10904 "ordinary URL",
10905 "SYNTHETIC_KEY_ENV",
10906 "auth_mode",
10907 "kept-trace",
10908 "kept-profile",
10909 "--flag",
10910 ] {
10911 assert!(backup.contains(kept), "ordinary backup data lost: {kept}");
10912 }
10913 backup
10914 .parse::<toml_edit::DocumentMut>()
10915 .expect("still valid TOML");
10916 assert_eq!(fs::read_to_string(&path).unwrap(), canonical);
10917 }
10918
10919 #[test]
10920 fn config_dump_shared_vocabulary_redacts_camel_case() {
10921 for key in [
10922 "accessToken",
10923 "clientSecret",
10924 "privateKey",
10925 "refreshToken",
10926 "Set-Cookie",
10927 "sas",
10928 "Ocp-Apim-Subscription-Key",
10929 ] {
10930 assert!(is_sensitive_config_key(key), "{key}");
10931 assert!(
10932 is_sensitive_config_key(&format!("providers.fixture.{key}")),
10933 "{key}"
10934 );
10935 }
10936 for key in [
10937 "max_tokens",
10938 "token_budget",
10939 "api_key_source",
10940 "authMode",
10941 "publicKey",
10942 "endpoint_key",
10943 ] {
10944 assert!(!is_sensitive_config_key(key), "{key}");
10945 }
10946 let value: toml::Value = toml::from_str(
10947 r#"privateKey = "private-s10-synthetic"
10948 clientSecret = "client-s10-synthetic"
10949 model = "fixture-model"
10950 "#,
10951 )
10952 .unwrap();
10953 let shown = redact_toml_value_for_display("providers.fixture", &value);
10954 assert!(!shown.contains("private-s10-synthetic"));
10955 assert!(!shown.contains("client-s10-synthetic"));
10956 assert!(shown.contains("fixture-model"));
10957 }
10958
10958 lines RUST