返回 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 verifier_config_defaults_to_hunt_verdict_policy() {
33 let config: ConfigToml = toml::from_str(
34 r#"
35 [verifier]
36 enabled = true
37 "#,
38 )
39 .expect("verifier config toml");
40
41 let verifier = config.verifier.expect("verifier table");
42 assert!(verifier.enabled);
43 assert_eq!(verifier.verdict_policy, VerifierVerdictPolicy::Hunt);
44 }
45
46 #[test]
47 fn verifier_config_rejects_unknown_verdict_policy() {
48 let err = toml::from_str::<ConfigToml>(
49 r#"
50 [verifier]
51 verdict_policy = "strict"
52 "#,
53 )
54 .expect_err("only the shipped hunt policy should parse");
55
56 assert!(
57 err.message().contains("unknown variant"),
58 "unexpected error: {err}"
59 );
60 }
61
62 #[test]
63 fn permissions_toml_deserializes_typed_ask_rules() {
64 let permissions: PermissionsToml = toml::from_str(
65 r#"
66 [[rules]]
67 tool = "exec_shell"
68 command = "cargo test"
69
70 [[rules]]
71 tool = "read_file"
72 path = "secrets/api_key.txt"
73 "#,
74 )
75 .expect("permissions toml");
76
77 assert_eq!(
78 permissions.rules,
79 vec![
80 ToolAskRule::exec_shell("cargo test"),
81 ToolAskRule::file_path("read_file", "secrets/api_key.txt"),
82 ]
83 );
84 }
85
86 #[test]
87 fn permissions_toml_rejects_unknown_decision_field() {
88 // `decision` is NOT a valid field — `deny_unknown_fields` still active.
89 let err = toml::from_str::<PermissionsToml>(
90 r#"
91 [[rules]]
92 tool = "exec_shell"
93 decision = "allow"
94 command = "cargo test"
95 "#,
96 )
97 .expect_err("permissions.toml should reject unknown 'decision' field");
98
99 assert!(err.message().contains("unknown field"));
100 }
101
102 #[test]
103 fn permissions_toml_deserializes_action_deny_and_allow() {
104 let permissions: PermissionsToml = toml::from_str(
105 r#"
106 [[rules]]
107 tool = "exec_shell"
108 command = "sed"
109 action = "deny"
110
111 [[rules]]
112 tool = "exec_shell"
113 command = "git status"
114 action = "allow"
115
116 [[rules]]
117 tool = "exec_shell"
118 command = "cargo test"
119 "#,
120 )
121 .expect("permissions toml with actions");
122
123 assert_eq!(permissions.rules.len(), 3);
124 assert_eq!(
125 permissions.rules[0].action,
126 codewhale_execpolicy::PermissionAction::Deny
127 );
128 assert_eq!(
129 permissions.rules[1].action,
130 codewhale_execpolicy::PermissionAction::Allow
131 );
132 assert_eq!(
133 permissions.rules[2].action,
134 codewhale_execpolicy::PermissionAction::Ask
135 ); // default
136 }
137
138 #[test]
139 fn permissions_ruleset_populates_denied_and_trusted_prefixes() {
140 let permissions: PermissionsToml = toml::from_str(
141 r#"
142 [[rules]]
143 tool = "exec_shell"
144 command = "sed"
145 action = "deny"
146
147 [[rules]]
148 tool = "exec_shell"
149 command = "awk"
150 action = "deny"
151
152 [[rules]]
153 tool = "exec_shell"
154 command = "git status"
155 action = "allow"
156
157 [[rules]]
158 tool = "exec_shell"
159 command = "cargo test"
160 action = "ask"
161 "#,
162 )
163 .unwrap();
164
165 let ruleset = permissions.ruleset();
166
167 // All four rules kept as ask_rules for path-based / tool-only matching
168 assert_eq!(ruleset.ask_rules.len(), 4);
169 // deny rules promoted to denied_prefixes
170 assert!(ruleset.denied_prefixes.contains(&"sed".to_string()));
171 assert!(ruleset.denied_prefixes.contains(&"awk".to_string()));
172 // allow rule promoted to trusted_prefixes
173 assert!(ruleset.trusted_prefixes.contains(&"git status".to_string()));
174 // ask rule NOT in trusted/denied prefixes
175 assert!(!ruleset.trusted_prefixes.contains(&"cargo test".to_string()));
176 assert!(!ruleset.denied_prefixes.contains(&"cargo test".to_string()));
177 }
178
179 #[test]
180 fn exact_workspace_command_allow_is_not_promoted_to_global_prefix() {
181 let rule =
182 ToolAskRule::exec_shell("cargo test").into_exact_workspace_allow("/workspace/project");
183 let permissions = PermissionsToml {
184 rules: vec![rule.clone()],
185 };
186
187 let ruleset = permissions.ruleset();
188
189 assert!(
190 ruleset.trusted_prefixes.is_empty(),
191 "an exact repo grant must not become a global trusted prefix"
192 );
193 assert_eq!(ruleset.ask_rules, vec![rule]);
194 }
195
196 #[test]
197 fn permissions_ruleset_deny_without_command_stays_in_ask_rules() {
198 // Tool-only deny (no command) can't be promoted to denied_prefixes.
199 let permissions: PermissionsToml = toml::from_str(
200 r#"
201 [[rules]]
202 tool = "exec_shell"
203 action = "deny"
204 "#,
205 )
206 .unwrap();
207
208 let ruleset = permissions.ruleset();
209 assert_eq!(ruleset.ask_rules.len(), 1);
210 assert_eq!(
211 ruleset.ask_rules[0].action,
212 codewhale_execpolicy::PermissionAction::Deny
213 );
214 // No command → nothing to promote to denied_prefixes
215 assert!(ruleset.denied_prefixes.is_empty());
216 }
217
218 #[test]
219 fn permissions_ruleset_empty_rules_produces_empty_ruleset() {
220 let permissions = PermissionsToml::default();
221 let ruleset = permissions.ruleset();
222 assert!(ruleset.trusted_prefixes.is_empty());
223 assert!(ruleset.denied_prefixes.is_empty());
224 assert!(ruleset.ask_rules.is_empty());
225 }
226
227 #[test]
228 fn permissions_ruleset_mixed_actions_all_coexist() {
229 let permissions: PermissionsToml = toml::from_str(
230 r#"
231 [[rules]]
232 tool = "exec_shell"
233 command = "rm -rf"
234 action = "deny"
235
236 [[rules]]
237 tool = "exec_shell"
238 command = "git status"
239 action = "allow"
240
241 [[rules]]
242 tool = "exec_shell"
243 command = "npm test"
244 action = "ask"
245
246 [[rules]]
247 tool = "read_file"
248 path = "Cargo.toml"
249 action = "allow"
250
251 [[rules]]
252 tool = "write_file"
253 path = "src/secrets.rs"
254 action = "deny"
255 "#,
256 )
257 .unwrap();
258
259 let ruleset = permissions.ruleset();
260
261 // All 5 rules in ask_rules
262 assert_eq!(ruleset.ask_rules.len(), 5);
263
264 // Command-based deny → denied_prefixes
265 assert!(ruleset.denied_prefixes.contains(&"rm -rf".to_string()));
266 assert_eq!(ruleset.denied_prefixes.len(), 1); // only rm -rf has a command
267
268 // Command-based allow → trusted_prefixes
269 assert!(ruleset.trusted_prefixes.contains(&"git status".to_string()));
270 assert_eq!(ruleset.trusted_prefixes.len(), 1); // only git status has a command
271
272 // Path-based rules stay in ask_rules but not in prefixes
273 let path_deny = ruleset
274 .ask_rules
275 .iter()
276 .find(|r| r.path.as_deref() == Some("src/secrets.rs"))
277 .unwrap();
278 assert_eq!(
279 path_deny.action,
280 codewhale_execpolicy::PermissionAction::Deny
281 );
282
283 let path_allow = ruleset
284 .ask_rules
285 .iter()
286 .find(|r| r.path.as_deref() == Some("Cargo.toml"))
287 .unwrap();
288 assert_eq!(
289 path_allow.action,
290 codewhale_execpolicy::PermissionAction::Allow
291 );
292 }
293
294 #[test]
295 fn provider_command_auth_source_deserializes() {
296 let config: ConfigToml = toml::from_str(
297 r#"
298 [providers.deepseek.auth]
299 source = "command"
300 command = ["keepassxc-cli", "show", "CodeWhale/DeepSeek", "--attribute", "password"]
301 timeout_ms = 2000
302 "#,
303 )
304 .expect("config toml");
305
306 let auth = config
307 .providers
308 .deepseek
309 .auth
310 .expect("provider auth source");
311 assert_eq!(auth.source, AuthSourceKind::Command);
312 assert_eq!(auth.source_class(), "command");
313 assert_eq!(auth.command[0], "keepassxc-cli");
314 assert_eq!(auth.timeout_ms, Some(2000));
315 auth.validate().expect("valid command auth source");
316 }
317
318 #[test]
319 fn provider_secret_auth_source_deserializes() {
320 let config: ConfigToml = toml::from_str(
321 r#"
322 [providers.openai.auth]
323 source = "secret"
324 secret_id = "codewhale/openai"
325 "#,
326 )
327 .expect("config toml");
328
329 let auth = config.providers.openai.auth.expect("provider auth source");
330 assert_eq!(auth.source, AuthSourceKind::Secret);
331 assert_eq!(auth.source_class(), "secret");
332 assert_eq!(auth.secret_id.as_deref(), Some("codewhale/openai"));
333 auth.validate().expect("valid secret auth source");
334 }
335
336 #[test]
337 fn provider_auth_source_rejects_empty_command() {
338 let config: ConfigToml = toml::from_str(
339 r#"
340 [providers.deepseek.auth]
341 source = "command"
342 command = []
343 "#,
344 )
345 .expect("config toml");
346
347 let auth = config
348 .providers
349 .deepseek
350 .auth
351 .expect("provider auth source");
352 let err = auth.validate().expect_err("empty command must be invalid");
353 assert!(err.to_string().contains("command must include"));
354 }
355
356 #[test]
357 fn hotbar_hidden_when_config_is_absent() {
358 // #3807: an absent `hotbar` key resolves to no bindings, so the Hotbar is
359 // hidden until the user opts in. The default slots are still available
360 // explicitly via `default_hotbar_bindings_toml()` (what `/hotbar on` writes).
361 let config = ConfigToml::default();
362
363 let resolved = config.resolve_hotbar_bindings(&DEFAULT_HOTBAR_ACTIONS);
364
365 assert_eq!(resolved.warnings, Vec::new());
366 assert!(
367 resolved.bindings.is_empty(),
368 "fresh config must resolve to no hotbar bindings: {:?}",
369 resolved.bindings
370 );
371
372 // The explicit default set still expands to the eight recommended slots.
373 let explicit = ConfigToml {
374 hotbar: Some(default_hotbar_bindings_toml()),
375 ..ConfigToml::default()
376 };
377 assert_eq!(
378 explicit
379 .resolve_hotbar_bindings(&DEFAULT_HOTBAR_ACTIONS)
380 .bindings,
381 default_hotbar_bindings(),
382 "an explicit default-bindings config still shows all eight slots"
383 );
384 }
385
386 #[test]
387 fn hotbar_empty_array_disables_default_slots() {
388 let config: ConfigToml = toml::from_str("hotbar = []\n").expect("parse empty hotbar array");
389
390 let resolved = config.resolve_hotbar_bindings(&DEFAULT_HOTBAR_ACTIONS);
391
392 assert_eq!(resolved.warnings, Vec::new());
393 assert_eq!(resolved.bindings, Vec::new());
394
395 let serialized = toml::to_string_pretty(&config).expect("serialize config");
396 let round_tripped: ConfigToml =
397 toml::from_str(&serialized).expect("deserialize serialized config");
398 assert_eq!(round_tripped.hotbar, Some(Vec::new()));
399 }
400
401 #[test]
402 fn hotbar_tables_parse_and_round_trip() {
403 let config: ConfigToml = toml::from_str(
404 r#"
405 [[hotbar]]
406 slot = 1
407 label = "Plan"
408 action = "mode.plan"
409
410 [[hotbar]]
411 slot = 2
412 action = "session.compact"
413 "#,
414 )
415 .expect("parse hotbar tables");
416
417 let resolved = config.resolve_hotbar_bindings(&["mode.plan", "session.compact"]);
418
419 assert_eq!(
420 resolved.bindings,
421 vec![
422 HotbarBinding {
423 slot: 1,
424 action: "mode.plan".to_string(),
425 label: Some("Plan".to_string()),
426 },
427 HotbarBinding {
428 slot: 2,
429 action: "session.compact".to_string(),
430 label: None,
431 },
432 ]
433 );
434 assert_eq!(resolved.warnings, Vec::new());
435
436 let serialized = toml::to_string_pretty(&config).expect("serialize config");
437 let round_tripped: ConfigToml =
438 toml::from_str(&serialized).expect("deserialize serialized config");
439 assert_eq!(round_tripped.hotbar, config.hotbar);
440 }
441
442 #[test]
443 fn hotbar_validation_warns_without_dropping_unknown_actions() {
444 let config: ConfigToml = toml::from_str(
445 r#"
446 [[hotbar]]
447 slot = 0
448 action = "mode.plan"
449
450 [[hotbar]]
451 slot = 2
452 action = "mode.plan"
453
454 [[hotbar]]
455 slot = 2
456 action = "custom.action"
457
458 [[hotbar]]
459 slot = 9
460 action = "mode.agent"
461 "#,
462 )
463 .expect("parse hotbar tables");
464
465 let resolved = config.resolve_hotbar_bindings(&["mode.plan", "mode.agent"]);
466
467 assert_eq!(
468 resolved.bindings,
469 vec![HotbarBinding {
470 slot: 2,
471 action: "custom.action".to_string(),
472 label: None,
473 }]
474 );
475 assert_eq!(
476 resolved.warnings,
477 vec![
478 HotbarConfigWarning::SlotOutOfRange {
479 slot: 0,
480 action: "mode.plan".to_string(),
481 },
482 HotbarConfigWarning::UnknownAction {
483 slot: 2,
484 action: "custom.action".to_string(),
485 },
486 HotbarConfigWarning::DuplicateSlot {
487 slot: 2,
488 previous_action: "mode.plan".to_string(),
489 replacement_action: "custom.action".to_string(),
490 },
491 HotbarConfigWarning::SlotOutOfRange {
492 slot: 9,
493 action: "mode.agent".to_string(),
494 },
495 ]
496 );
497 assert!(resolved.warnings[1].to_string().contains("keeping binding"));
498 }
499
500 #[test]
501 fn config_store_loads_sibling_permissions_toml() {
502 use std::time::{SystemTime, UNIX_EPOCH};
503
504 let unique = SystemTime::now()
505 .duration_since(UNIX_EPOCH)
506 .expect("clock")
507 .as_nanos();
508 let dir = std::env::temp_dir().join(format!(
509 "codewhale-permissions-schema-{}-{unique}",
510 std::process::id()
511 ));
512 fs::create_dir_all(&dir).expect("mkdir");
513 let config_path = dir.join(CONFIG_FILE_NAME);
514 let permissions_path = dir.join(PERMISSIONS_FILE_NAME);
515 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
516 fs::write(
517 &permissions_path,
518 r#"
519 [[rules]]
520 tool = "exec_shell"
521 command = "cargo test"
522
523 [[rules]]
524 tool = "read_file"
525 path = "secrets/api_key.txt"
526 "#,
527 )
528 .expect("write permissions");
529
530 let store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
531
532 assert_eq!(store.config.model.as_deref(), Some("deepseek-v4-flash"));
533 assert_eq!(
534 store.permissions().rules.as_slice(),
535 &[
536 ToolAskRule::exec_shell("cargo test"),
537 ToolAskRule::file_path("read_file", "secrets/api_key.txt"),
538 ]
539 );
540 assert_eq!(
541 store
542 .permissions_path()
543 .canonicalize()
544 .expect("store perms"),
545 permissions_path.canonicalize().expect("expected perms")
546 );
547
548 let _ = fs::remove_dir_all(dir);
549 }
550
551 #[test]
552 fn config_store_loads_permissions_even_when_config_is_absent() {
553 use std::time::{SystemTime, UNIX_EPOCH};
554
555 let unique = SystemTime::now()
556 .duration_since(UNIX_EPOCH)
557 .expect("clock")
558 .as_nanos();
559 let dir = std::env::temp_dir().join(format!(
560 "codewhale-permissions-only-{}-{unique}",
561 std::process::id()
562 ));
563 fs::create_dir_all(&dir).expect("mkdir");
564 let config_path = dir.join(CONFIG_FILE_NAME);
565 fs::write(
566 dir.join(PERMISSIONS_FILE_NAME),
567 r#"
568 [[rules]]
569 tool = "exec_shell"
570 command = "cargo check"
571 "#,
572 )
573 .expect("write permissions");
574
575 let store = ConfigStore::load(Some(config_path)).expect("load config store");
576
577 assert!(store.config.model.is_none());
578 assert_eq!(
579 store.permissions().rules.as_slice(),
580 &[ToolAskRule::exec_shell("cargo check")]
581 );
582
583 let _ = fs::remove_dir_all(dir);
584 }
585
586 #[test]
587 fn config_store_exec_policy_engine_uses_sibling_permissions() {
588 use std::time::{SystemTime, UNIX_EPOCH};
589
590 let unique = SystemTime::now()
591 .duration_since(UNIX_EPOCH)
592 .expect("clock")
593 .as_nanos();
594 let dir = std::env::temp_dir().join(format!(
595 "codewhale-permissions-engine-{}-{unique}",
596 std::process::id()
597 ));
598 fs::create_dir_all(&dir).expect("mkdir");
599 let config_path = dir.join(CONFIG_FILE_NAME);
600 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
601 fs::write(
602 dir.join(PERMISSIONS_FILE_NAME),
603 r#"
604 [[rules]]
605 tool = "exec_shell"
606 command = "cargo test"
607 "#,
608 )
609 .expect("write permissions");
610
611 let store = ConfigStore::load(Some(config_path)).expect("load config store");
612 let decision = store
613 .exec_policy_engine()
614 .check(codewhale_execpolicy::ExecPolicyContext {
615 command: "cargo test --workspace",
616 cwd: "/workspace",
617 tool: Some("exec_shell"),
618 path: None,
619 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
620 sandbox_mode: Some("workspace-write"),
621 })
622 .expect("policy check");
623
624 assert!(decision.allow);
625 assert!(decision.requires_approval);
626 assert_eq!(
627 decision.matched_rule.as_deref(),
628 Some("tool=exec_shell command=cargo test")
629 );
630
631 let _ = fs::remove_dir_all(dir);
632 }
633
634 #[test]
635 fn config_store_appends_ask_rules_without_losing_comments_or_duplicates() {
636 let dir = tempfile::tempdir().expect("tempdir");
637 let config_path = dir.path().join(CONFIG_FILE_NAME);
638 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
639 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
640 fs::write(
641 &permissions_path,
642 r#"# keep this permission note
643 [[rules]]
644 tool = "exec_shell"
645 command = "cargo check"
646 "#,
647 )
648 .expect("write permissions");
649
650 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
651 let existing = ToolAskRule::exec_shell("cargo check");
652 let added_rule = ToolAskRule::file_path("read_file", "docs/README.md");
653 let added = store
654 .append_ask_rules(&[existing, added_rule.clone(), added_rule.clone()])
655 .expect("append ask rules");
656
657 assert_eq!(added, 1);
658 assert_eq!(
659 store.permissions().rules,
660 vec![ToolAskRule::exec_shell("cargo check"), added_rule.clone(),]
661 );
662 let body = fs::read_to_string(&permissions_path).expect("read permissions");
663 assert!(body.contains("# keep this permission note"));
664 assert_eq!(body.matches("docs/README.md").count(), 1);
665 assert!(!body.contains("decision"));
666
667 let before_duplicate_append = body;
668 assert_eq!(
669 store
670 .append_ask_rules(&[added_rule])
671 .expect("dedupe ask rule"),
672 0
673 );
674 assert_eq!(
675 fs::read_to_string(&permissions_path).expect("read unchanged permissions"),
676 before_duplicate_append
677 );
678
679 let reloaded =
680 ConfigStore::load(Some(dir.path().join(CONFIG_FILE_NAME))).expect("reload config store");
681 assert_eq!(reloaded.permissions(), store.permissions());
682 }
683
684 #[test]
685 fn config_store_appends_ask_rule_to_inline_rules_array() {
686 let dir = tempfile::tempdir().expect("tempdir");
687 let config_path = dir.path().join(CONFIG_FILE_NAME);
688 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
689 fs::write(
690 &permissions_path,
691 "# inline rules stay valid\nrules = [{ tool = \"exec_shell\", command = \"cargo check\" }]\n",
692 )
693 .expect("write permissions");
694
695 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
696 assert_eq!(
697 store
698 .append_ask_rules(&[ToolAskRule::file_path("read_file", "README.md")])
699 .expect("append inline ask rule"),
700 1
701 );
702
703 let body = fs::read_to_string(&permissions_path).expect("read permissions");
704 assert!(body.contains("# inline rules stay valid"));
705 let parsed: PermissionsToml = toml::from_str(&body).expect("parse persisted permissions");
706 assert_eq!(
707 parsed.rules,
708 vec![
709 ToolAskRule::exec_shell("cargo check"),
710 ToolAskRule::file_path("read_file", "README.md"),
711 ]
712 );
713 }
714
715 #[test]
716 fn config_store_appends_exact_workspace_allow_rules() {
717 let dir = tempfile::tempdir().expect("tempdir");
718 let config_path = dir.path().join(CONFIG_FILE_NAME);
719 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
720 fs::write(&permissions_path, "# remembered grants stay user-owned\n")
721 .expect("write permissions");
722 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
723 let rule = ToolAskRule::exec_shell("cargo test")
724 .into_exact_workspace_allow(dir.path().to_string_lossy());
725
726 assert_eq!(
727 store
728 .append_allow_rules(&[rule.clone(), rule.clone()])
729 .expect("append allow rule"),
730 1
731 );
732
733 let body = fs::read_to_string(&permissions_path).expect("read permissions");
734 assert!(body.contains("# remembered grants stay user-owned"));
735 assert!(body.contains("action = \"allow\""));
736 assert!(body.contains("command_exact = true"));
737 // Windows paths may make toml_edit choose a different valid quoting style
738 // than Rust's Debug output. Check the rendered field shape here and its
739 // exact value through the typed parse below.
740 assert!(body.lines().any(|line| line.starts_with("workspace = ")));
741 let parsed: PermissionsToml = toml::from_str(&body).expect("parse permissions");
742 assert_eq!(parsed.rules, vec![rule.clone()]);
743
744 let exact = store
745 .exec_policy_engine()
746 .check(codewhale_execpolicy::ExecPolicyContext {
747 command: "cargo test",
748 cwd: dir.path().to_string_lossy().as_ref(),
749 tool: Some("exec_shell"),
750 path: None,
751 ask_for_approval: codewhale_execpolicy::AskForApproval::OnRequest,
752 sandbox_mode: Some("workspace-write"),
753 })
754 .expect("check exact grant");
755 assert_eq!(
756 exact.matched_action,
757 Some(codewhale_execpolicy::PermissionAction::Allow)
758 );
759 assert!(!exact.requires_approval);
760
761 let extra_args = store
762 .exec_policy_engine()
763 .check(codewhale_execpolicy::ExecPolicyContext {
764 command: "cargo test --workspace",
765 cwd: dir.path().to_string_lossy().as_ref(),
766 tool: Some("exec_shell"),
767 path: None,
768 ask_for_approval: codewhale_execpolicy::AskForApproval::OnRequest,
769 sandbox_mode: Some("workspace-write"),
770 })
771 .expect("check extra args");
772 assert!(extra_args.requires_approval);
773
774 let reloaded = ConfigStore::load(Some(config_path)).expect("reload config store");
775 assert_eq!(reloaded.permissions().rules, vec![rule]);
776 }
777
778 #[test]
779 fn config_store_rejects_broad_or_unscoped_allow_rules() {
780 let dir = tempfile::tempdir().expect("tempdir");
781 let config_path = dir.path().join(CONFIG_FILE_NAME);
782 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
783
784 let mut unscoped = ToolAskRule::exec_shell("cargo test");
785 unscoped.action = codewhale_execpolicy::PermissionAction::Allow;
786 assert!(
787 store
788 .append_allow_rules(&[unscoped])
789 .expect_err("unscoped allow must fail")
790 .to_string()
791 .contains("scoped to a workspace")
792 );
793
794 let mut prefix = ToolAskRule::exec_shell("cargo test");
795 prefix.action = codewhale_execpolicy::PermissionAction::Allow;
796 prefix.workspace = Some(dir.path().to_string_lossy().into_owned());
797 assert!(
798 store
799 .append_allow_rules(&[prefix])
800 .expect_err("prefix allow must fail")
801 .to_string()
802 .contains("exact matching")
803 );
804
805 assert!(
806 store
807 .append_allow_rules(&[ToolAskRule::new("exec_shell")
808 .into_exact_workspace_allow(dir.path().to_string_lossy())])
809 .expect_err("tool-wide allow must fail")
810 .to_string()
811 .contains("exact command or path")
812 );
813 }
814
815 #[test]
816 fn config_store_does_not_overwrite_invalid_permissions_file() {
817 let dir = tempfile::tempdir().expect("tempdir");
818 let config_path = dir.path().join(CONFIG_FILE_NAME);
819 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
820 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
821 let invalid = "rules = \"not-an-array\"\n";
822 fs::write(&permissions_path, invalid).expect("write invalid permissions");
823
824 let error = store
825 .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")])
826 .expect_err("invalid permissions should fail");
827
828 assert!(error.to_string().contains("failed to parse permissions"));
829 assert_eq!(
830 fs::read_to_string(&permissions_path).expect("read invalid permissions"),
831 invalid
832 );
833 assert!(store.permissions().is_empty());
834 }
835
836 #[test]
837 fn duplicate_append_refreshes_permissions_changed_on_disk() {
838 let dir = tempfile::tempdir().expect("tempdir");
839 let config_path = dir.path().join(CONFIG_FILE_NAME);
840 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
841 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
842 fs::write(
843 permissions_path,
844 "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo check\"\n",
845 )
846 .expect("write external permissions update");
847
848 assert_eq!(
849 store
850 .append_ask_rules(&[ToolAskRule::exec_shell("cargo check")])
851 .expect("dedupe external ask rule"),
852 0
853 );
854 assert_eq!(
855 store.permissions().rules,
856 vec![ToolAskRule::exec_shell("cargo check")]
857 );
858 }
859
860 #[cfg(unix)]
861 #[test]
862 fn config_store_secures_persisted_permissions_file() {
863 let dir = tempfile::tempdir().expect("tempdir");
864 let config_path = dir.path().join(CONFIG_FILE_NAME);
865 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
866 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
867
868 store
869 .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")])
870 .expect("append ask rule");
871
872 let mode = fs::metadata(permissions_path)
873 .expect("permissions metadata")
874 .permissions()
875 .mode()
876 & 0o777;
877 assert_eq!(mode, 0o600);
878 }
879
880 #[test]
881 fn permission_snapshot_removes_array_table_rule_without_reformatting_neighbors() {
882 let dir = tempfile::tempdir().expect("tempdir");
883 let config_path = dir.path().join(CONFIG_FILE_NAME);
884 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
885 fs::write(
886 &permissions_path,
887 r#"# operator-owned header
888 [[rules]]
889 tool = "exec_shell"
890 command = "cargo check"
891
892 # remove only this record
893 [[rules]]
894 tool = "exec_shell"
895 command = "cargo test"
896 action = "deny"
897
898 # keep this record and comment
899 [[rules]]
900 tool = "edit_file"
901 path = "src/lib.rs"
902 action = "allow"
903 "#,
904 )
905 .expect("write permissions");
906
907 let snapshot =
908 load_permissions_snapshot(Some(config_path.clone())).expect("load permission snapshot");
909 assert!(snapshot.file_exists());
910 assert_eq!(
911 snapshot.path().canonicalize().expect("snapshot path"),
912 permissions_path.canonicalize().expect("permissions path")
913 );
914 assert_eq!(snapshot.rules().len(), 3);
915 let token = snapshot
916 .removal_token(1)
917 .expect("middle rule token")
918 .to_string();
919
920 let removed =
921 remove_permission_rule(Some(config_path.clone()), 1, &token).expect("remove middle rule");
922 assert_eq!(removed.command.as_deref(), Some("cargo test"));
923 assert_eq!(removed.action, PermissionAction::Deny);
924
925 let body = fs::read_to_string(&permissions_path).expect("read permissions");
926 assert!(body.contains("# operator-owned header"));
927 assert!(body.contains("# keep this record and comment"));
928 assert!(!body.contains("cargo test"));
929 assert!(body.contains("command = \"cargo check\""));
930 assert!(body.contains("path = \"src/lib.rs\""));
931 let parsed: PermissionsToml = toml::from_str(&body).expect("parse edited permissions");
932 assert_eq!(
933 parsed.rules,
934 vec![ToolAskRule::exec_shell("cargo check"), {
935 let mut rule = ToolAskRule::file_path("edit_file", "src/lib.rs");
936 rule.action = PermissionAction::Allow;
937 rule
938 },]
939 );
940
941 let reloaded =
942 load_permissions_snapshot(Some(config_path)).expect("reload permission snapshot");
943 assert_eq!(reloaded.rules(), parsed.rules);
944 }
945
946 #[test]
947 fn permission_snapshot_removes_inline_array_rule() {
948 let dir = tempfile::tempdir().expect("tempdir");
949 let config_path = dir.path().join(CONFIG_FILE_NAME);
950 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
951 fs::write(
952 &permissions_path,
953 "# inline style stays inline\nrules = [\n { tool = \"exec_shell\", command = \"cargo check\" },\n { tool = \"write_file\", path = \"README.md\", action = \"allow\" },\n]\n",
954 )
955 .expect("write permissions");
956
957 let snapshot =
958 load_permissions_snapshot(Some(config_path.clone())).expect("load permission snapshot");
959 let token = snapshot
960 .removal_token(0)
961 .expect("first rule token")
962 .to_string();
963 let removed = remove_permission_rule(Some(config_path), 0, &token).expect("remove inline rule");
964
965 assert_eq!(removed.command.as_deref(), Some("cargo check"));
966 let body = fs::read_to_string(&permissions_path).expect("read permissions");
967 assert!(body.contains("# inline style stays inline"));
968 assert!(body.contains("rules = ["));
969 assert!(!body.contains("cargo check"));
970 let parsed: PermissionsToml = toml::from_str(&body).expect("parse inline permissions");
971 assert_eq!(parsed.rules.len(), 1);
972 assert_eq!(parsed.rules[0].path.as_deref(), Some("README.md"));
973 }
974
975 #[test]
976 fn permission_removal_preserves_file_header_when_first_array_table_is_removed() {
977 let dir = tempfile::tempdir().expect("tempdir");
978 let config_path = dir.path().join(CONFIG_FILE_NAME);
979 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
980 fs::write(
981 &permissions_path,
982 concat!(
983 "# keep this file-level explanation\n",
984 "[[rules]]\n",
985 "tool = \"exec_shell\"\n",
986 "command = \"cargo check\"\n\n",
987 "[[rules]]\n",
988 "tool = \"exec_shell\"\n",
989 "command = \"cargo test\"\n",
990 ),
991 )
992 .expect("write permissions");
993 let snapshot =
994 load_permissions_snapshot(Some(config_path.clone())).expect("load permission snapshot");
995 let token = snapshot
996 .removal_token(0)
997 .expect("first rule token")
998 .to_string();
999
1000 remove_permission_rule(Some(config_path), 0, &token).expect("remove first rule");
1001
1002 let body = fs::read_to_string(&permissions_path).expect("read permissions");
1003 assert!(body.contains("# keep this file-level explanation"));
1004 assert!(!body.contains("cargo check"));
1005 assert!(body.contains("cargo test"));
1006 }
1007
1008 #[test]
1009 fn permission_removal_rejects_stale_snapshot_without_writing() {
1010 let dir = tempfile::tempdir().expect("tempdir");
1011 let config_path = dir.path().join(CONFIG_FILE_NAME);
1012 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
1013 fs::write(
1014 &permissions_path,
1015 "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo check\"\n",
1016 )
1017 .expect("write permissions");
1018
1019 let snapshot =
1020 load_permissions_snapshot(Some(config_path.clone())).expect("load permission snapshot");
1021 let token = snapshot
1022 .removal_token(0)
1023 .expect("first rule token")
1024 .to_string();
1025 let changed = concat!(
1026 "[[rules]]\n",
1027 "tool = \"read_file\"\n",
1028 "path = \"README.md\"\n\n",
1029 "[[rules]]\n",
1030 "tool = \"exec_shell\"\n",
1031 "command = \"cargo check\"\n",
1032 );
1033 fs::write(&permissions_path, changed).expect("write concurrent permissions update");
1034
1035 let error =
1036 remove_permission_rule(Some(config_path), 0, &token).expect_err("stale removal must fail");
1037
1038 assert!(
1039 error
1040 .to_string()
1041 .contains("permissions changed after they were listed")
1042 );
1043 assert_eq!(
1044 fs::read_to_string(&permissions_path).expect("read unchanged permissions"),
1045 changed
1046 );
1047 }
1048
1049 struct EnvGuard {
1050 deepseek_api_key: Option<OsString>,
1051 deepseek_base_url: Option<OsString>,
1052 deepseek_anthropic_base_url: Option<OsString>,
1053 deepseek_claude_base_url: Option<OsString>,
1054 deepseek_http_headers: Option<OsString>,
1055 deepseek_model: Option<OsString>,
1056 deepseek_default_text_model: Option<OsString>,
1057 deepseek_provider: Option<OsString>,
1058 deepseek_auth_mode: Option<OsString>,
1059 nvidia_api_key: Option<OsString>,
1060 nvidia_nim_api_key: Option<OsString>,
1061 nim_base_url: Option<OsString>,
1062 nvidia_base_url: Option<OsString>,
1063 nvidia_nim_base_url: Option<OsString>,
1064 openrouter_api_key: Option<OsString>,
1065 openrouter_base_url: Option<OsString>,
1066 openrouter_model: Option<OsString>,
1067 openmodel_api_key: Option<OsString>,
1068 openmodel_base_url: Option<OsString>,
1069 openmodel_model: Option<OsString>,
1070 xiaomi_mimo_token_plan_api_key: Option<OsString>,
1071 mimo_token_plan_api_key: Option<OsString>,
1072 xiaomi_mimo_api_key: Option<OsString>,
1073 xiaomi_api_key: Option<OsString>,
1074 mimo_api_key: Option<OsString>,
1075 xiaomi_mimo_base_url: Option<OsString>,
1076 mimo_base_url: Option<OsString>,
1077 xiaomi_mimo_model: Option<OsString>,
1078 mimo_model: Option<OsString>,
1079 xiaomi_mimo_mode: Option<OsString>,
1080 mimo_mode: Option<OsString>,
1081 wanjie_ark_api_key: Option<OsString>,
1082 volcengine_api_key: Option<OsString>,
1083 volcengine_ark_api_key: Option<OsString>,
1084 ark_api_key: Option<OsString>,
1085 volcengine_base_url: Option<OsString>,
1086 volcengine_ark_base_url: Option<OsString>,
1087 ark_base_url: Option<OsString>,
1088 wanjie_ark_base_url: Option<OsString>,
1089 wanjie_base_url: Option<OsString>,
1090 wanjie_maas_base_url: Option<OsString>,
1091 volcengine_model: Option<OsString>,
1092 volcengine_ark_model: Option<OsString>,
1093 wanjie_ark_model: Option<OsString>,
1094 wanjie_model: Option<OsString>,
1095 wanjie_maas_model: Option<OsString>,
1096 novita_api_key: Option<OsString>,
1097 novita_base_url: Option<OsString>,
1098 novita_model: Option<OsString>,
1099 fireworks_api_key: Option<OsString>,
1100 fireworks_base_url: Option<OsString>,
1101 fireworks_model: Option<OsString>,
1102 siliconflow_api_key: Option<OsString>,
1103 siliconflow_base_url: Option<OsString>,
1104 siliconflow_model: Option<OsString>,
1105 arcee_api_key: Option<OsString>,
1106 arcee_base_url: Option<OsString>,
1107 arcee_model: Option<OsString>,
1108 moonshot_api_key: Option<OsString>,
1109 moonshot_base_url: Option<OsString>,
1110 moonshot_model: Option<OsString>,
1111 kimi_api_key: Option<OsString>,
1112 kimi_base_url: Option<OsString>,
1113 kimi_model: Option<OsString>,
1114 kimi_model_name: Option<OsString>,
1115 zai_api_key: Option<OsString>,
1116 z_ai_api_key: Option<OsString>,
1117 zhipu_api_key: Option<OsString>,
1118 glm_api_key: Option<OsString>,
1119 zai_base_url: Option<OsString>,
1120 z_ai_base_url: Option<OsString>,
1121 zhipu_base_url: Option<OsString>,
1122 zhipuai_base_url: Option<OsString>,
1123 bigmodel_base_url: Option<OsString>,
1124 zai_model: Option<OsString>,
1125 z_ai_model: Option<OsString>,
1126 zhipu_model: Option<OsString>,
1127 zhipuai_model: Option<OsString>,
1128 bigmodel_model: Option<OsString>,
1129 glm_model: Option<OsString>,
1130 stepfun_api_key: Option<OsString>,
1131 step_api_key: Option<OsString>,
1132 stepfun_base_url: Option<OsString>,
1133 stepfun_model: Option<OsString>,
1134 minimax_api_key: Option<OsString>,
1135 minimax_base_url: Option<OsString>,
1136 minimax_anthropic_base_url: Option<OsString>,
1137 minimax_model: Option<OsString>,
1138 sakana_api_key: Option<OsString>,
1139 fugu_api_key: Option<OsString>,
1140 sakana_base_url: Option<OsString>,
1141 sakana_model: Option<OsString>,
1142 sglang_api_key: Option<OsString>,
1143 sglang_base_url: Option<OsString>,
1144 vllm_api_key: Option<OsString>,
1145 vllm_base_url: Option<OsString>,
1146 ollama_api_key: Option<OsString>,
1147 ollama_base_url: Option<OsString>,
1148 huggingface_api_key: Option<OsString>,
1149 huggingface_token: Option<OsString>,
1150 huggingface_base_url: Option<OsString>,
1151 hf_base_url: Option<OsString>,
1152 huggingface_model: Option<OsString>,
1153 hf_model: Option<OsString>,
1154 codewhale_provider: Option<OsString>,
1155 codewhale_model: Option<OsString>,
1156 codewhale_base_url: Option<OsString>,
1157 xai_api_key: Option<OsString>,
1158 xai_base_url: Option<OsString>,
1159 xai_model: Option<OsString>,
1160 telecomjs_api_key: Option<OsString>,
1161 telecomjs_base_url: Option<OsString>,
1162 telecomjs_model: Option<OsString>,
1163 opencode_go_api_key: Option<OsString>,
1164 opencode_go_base_url: Option<OsString>,
1165 opencode_go_model: Option<OsString>,
1166 opencode_zen_api_key: Option<OsString>,
1167 opencode_api_key: Option<OsString>,
1168 opencode_zen_base_url: Option<OsString>,
1169 opencode_zen_model: Option<OsString>,
1170 meta_model_api_key: Option<OsString>,
1171 model_api_key: Option<OsString>,
1172 meta_model_api_base_url: Option<OsString>,
1173 model_api_base_url: Option<OsString>,
1174 meta_model_api_model: Option<OsString>,
1175 model_api_model: Option<OsString>,
1176 }
1177
1178 impl EnvGuard {
1179 fn without_deepseek_runtime_overrides() -> Self {
1180 let guard = Self {
1181 deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"),
1182 deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"),
1183 deepseek_anthropic_base_url: env::var_os("DEEPSEEK_ANTHROPIC_BASE_URL"),
1184 deepseek_claude_base_url: env::var_os("DEEPSEEK_CLAUDE_BASE_URL"),
1185 deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"),
1186 deepseek_model: env::var_os("DEEPSEEK_MODEL"),
1187 deepseek_default_text_model: env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"),
1188 deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"),
1189 deepseek_auth_mode: env::var_os("DEEPSEEK_AUTH_MODE"),
1190 codewhale_provider: env::var_os("CODEWHALE_PROVIDER"),
1191 codewhale_model: env::var_os("CODEWHALE_MODEL"),
1192 codewhale_base_url: env::var_os("CODEWHALE_BASE_URL"),
1193 xai_api_key: env::var_os("XAI_API_KEY"),
1194 xai_base_url: env::var_os("XAI_BASE_URL"),
1195 xai_model: env::var_os("XAI_MODEL"),
1196 telecomjs_api_key: env::var_os("TELECOMJS_API_KEY"),
1197 telecomjs_base_url: env::var_os("TELECOMJS_BASE_URL"),
1198 telecomjs_model: env::var_os("TELECOMJS_MODEL"),
1199 opencode_go_api_key: env::var_os("OPENCODE_GO_API_KEY"),
1200 opencode_go_base_url: env::var_os("OPENCODE_GO_BASE_URL"),
1201 opencode_go_model: env::var_os("OPENCODE_GO_MODEL"),
1202 opencode_zen_api_key: env::var_os("OPENCODE_ZEN_API_KEY"),
1203 opencode_api_key: env::var_os("OPENCODE_API_KEY"),
1204 opencode_zen_base_url: env::var_os("OPENCODE_ZEN_BASE_URL"),
1205 opencode_zen_model: env::var_os("OPENCODE_ZEN_MODEL"),
1206 meta_model_api_key: env::var_os("META_MODEL_API_KEY"),
1207 model_api_key: env::var_os("MODEL_API_KEY"),
1208 meta_model_api_base_url: env::var_os("META_MODEL_API_BASE_URL"),
1209 model_api_base_url: env::var_os("MODEL_API_BASE_URL"),
1210 meta_model_api_model: env::var_os("META_MODEL_API_MODEL"),
1211 model_api_model: env::var_os("MODEL_API_MODEL"),
1212 nvidia_api_key: env::var_os("NVIDIA_API_KEY"),
1213 nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"),
1214 nim_base_url: env::var_os("NIM_BASE_URL"),
1215 nvidia_base_url: env::var_os("NVIDIA_BASE_URL"),
1216 nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"),
1217 openrouter_api_key: env::var_os("OPENROUTER_API_KEY"),
1218 openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"),
1219 openrouter_model: env::var_os("OPENROUTER_MODEL"),
1220 openmodel_api_key: env::var_os("OPENMODEL_API_KEY"),
1221 openmodel_base_url: env::var_os("OPENMODEL_BASE_URL"),
1222 openmodel_model: env::var_os("OPENMODEL_MODEL"),
1223 xiaomi_mimo_token_plan_api_key: env::var_os("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"),
1224 mimo_token_plan_api_key: env::var_os("MIMO_TOKEN_PLAN_API_KEY"),
1225 xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"),
1226 xiaomi_api_key: env::var_os("XIAOMI_API_KEY"),
1227 mimo_api_key: env::var_os("MIMO_API_KEY"),
1228 xiaomi_mimo_base_url: env::var_os("XIAOMI_MIMO_BASE_URL"),
1229 mimo_base_url: env::var_os("MIMO_BASE_URL"),
1230 xiaomi_mimo_model: env::var_os("XIAOMI_MIMO_MODEL"),
1231 mimo_model: env::var_os("MIMO_MODEL"),
1232 xiaomi_mimo_mode: env::var_os("XIAOMI_MIMO_MODE"),
1233 mimo_mode: env::var_os("MIMO_MODE"),
1234 wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"),
1235 volcengine_api_key: env::var_os("VOLCENGINE_API_KEY"),
1236 volcengine_ark_api_key: env::var_os("VOLCENGINE_ARK_API_KEY"),
1237 ark_api_key: env::var_os("ARK_API_KEY"),
1238 volcengine_base_url: env::var_os("VOLCENGINE_BASE_URL"),
1239 volcengine_ark_base_url: env::var_os("VOLCENGINE_ARK_BASE_URL"),
1240 ark_base_url: env::var_os("ARK_BASE_URL"),
1241 wanjie_ark_base_url: env::var_os("WANJIE_ARK_BASE_URL"),
1242 wanjie_base_url: env::var_os("WANJIE_BASE_URL"),
1243 wanjie_maas_base_url: env::var_os("WANJIE_MAAS_BASE_URL"),
1244 volcengine_model: env::var_os("VOLCENGINE_MODEL"),
1245 volcengine_ark_model: env::var_os("VOLCENGINE_ARK_MODEL"),
1246 wanjie_ark_model: env::var_os("WANJIE_ARK_MODEL"),
1247 wanjie_model: env::var_os("WANJIE_MODEL"),
1248 wanjie_maas_model: env::var_os("WANJIE_MAAS_MODEL"),
1249 novita_api_key: env::var_os("NOVITA_API_KEY"),
1250 novita_base_url: env::var_os("NOVITA_BASE_URL"),
1251 novita_model: env::var_os("NOVITA_MODEL"),
1252 fireworks_api_key: env::var_os("FIREWORKS_API_KEY"),
1253 fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"),
1254 fireworks_model: env::var_os("FIREWORKS_MODEL"),
1255 siliconflow_api_key: env::var_os("SILICONFLOW_API_KEY"),
1256 siliconflow_base_url: env::var_os("SILICONFLOW_BASE_URL"),
1257 siliconflow_model: env::var_os("SILICONFLOW_MODEL"),
1258 arcee_api_key: env::var_os("ARCEE_API_KEY"),
1259 arcee_base_url: env::var_os("ARCEE_BASE_URL"),
1260 arcee_model: env::var_os("ARCEE_MODEL"),
1261 moonshot_api_key: env::var_os("MOONSHOT_API_KEY"),
1262 moonshot_base_url: env::var_os("MOONSHOT_BASE_URL"),
1263 moonshot_model: env::var_os("MOONSHOT_MODEL"),
1264 kimi_api_key: env::var_os("KIMI_API_KEY"),
1265 kimi_base_url: env::var_os("KIMI_BASE_URL"),
1266 kimi_model: env::var_os("KIMI_MODEL"),
1267 kimi_model_name: env::var_os("KIMI_MODEL_NAME"),
1268 zai_api_key: env::var_os("ZAI_API_KEY"),
1269 z_ai_api_key: env::var_os("Z_AI_API_KEY"),
1270 zhipu_api_key: env::var_os("ZHIPU_API_KEY"),
1271 glm_api_key: env::var_os("GLM_API_KEY"),
1272 zai_base_url: env::var_os("ZAI_BASE_URL"),
1273 z_ai_base_url: env::var_os("Z_AI_BASE_URL"),
1274 zhipu_base_url: env::var_os("ZHIPU_BASE_URL"),
1275 zhipuai_base_url: env::var_os("ZHIPUAI_BASE_URL"),
1276 bigmodel_base_url: env::var_os("BIGMODEL_BASE_URL"),
1277 zai_model: env::var_os("ZAI_MODEL"),
1278 z_ai_model: env::var_os("Z_AI_MODEL"),
1279 zhipu_model: env::var_os("ZHIPU_MODEL"),
1280 zhipuai_model: env::var_os("ZHIPUAI_MODEL"),
1281 bigmodel_model: env::var_os("BIGMODEL_MODEL"),
1282 glm_model: env::var_os("GLM_MODEL"),
1283 stepfun_api_key: env::var_os("STEPFUN_API_KEY"),
1284 step_api_key: env::var_os("STEP_API_KEY"),
1285 stepfun_base_url: env::var_os("STEPFUN_BASE_URL"),
1286 stepfun_model: env::var_os("STEPFUN_MODEL"),
1287 minimax_api_key: env::var_os("MINIMAX_API_KEY"),
1288 minimax_base_url: env::var_os("MINIMAX_BASE_URL"),
1289 minimax_anthropic_base_url: env::var_os("MINIMAX_ANTHROPIC_BASE_URL"),
1290 minimax_model: env::var_os("MINIMAX_MODEL"),
1291 sakana_api_key: env::var_os("SAKANA_API_KEY"),
1292 fugu_api_key: env::var_os("FUGU_API_KEY"),
1293 sakana_base_url: env::var_os("SAKANA_BASE_URL"),
1294 sakana_model: env::var_os("SAKANA_MODEL"),
1295 sglang_api_key: env::var_os("SGLANG_API_KEY"),
1296 sglang_base_url: env::var_os("SGLANG_BASE_URL"),
1297 vllm_api_key: env::var_os("VLLM_API_KEY"),
1298 vllm_base_url: env::var_os("VLLM_BASE_URL"),
1299 ollama_api_key: env::var_os("OLLAMA_API_KEY"),
1300 ollama_base_url: env::var_os("OLLAMA_BASE_URL"),
1301 huggingface_api_key: env::var_os("HUGGINGFACE_API_KEY"),
1302 huggingface_token: env::var_os("HF_TOKEN"),
1303 huggingface_base_url: env::var_os("HUGGINGFACE_BASE_URL"),
1304 hf_base_url: env::var_os("HF_BASE_URL"),
1305 huggingface_model: env::var_os("HUGGINGFACE_MODEL"),
1306 hf_model: env::var_os("HF_MODEL"),
1307 };
1308 // Safety: test-only environment mutation guarded by a module mutex.
1309 unsafe {
1310 env::remove_var("DEEPSEEK_API_KEY");
1311 env::remove_var("DEEPSEEK_BASE_URL");
1312 env::remove_var("DEEPSEEK_ANTHROPIC_BASE_URL");
1313 env::remove_var("DEEPSEEK_CLAUDE_BASE_URL");
1314 env::remove_var("DEEPSEEK_HTTP_HEADERS");
1315 env::remove_var("DEEPSEEK_MODEL");
1316 env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL");
1317 env::remove_var("DEEPSEEK_PROVIDER");
1318 env::remove_var("DEEPSEEK_AUTH_MODE");
1319 env::remove_var("CODEWHALE_PROVIDER");
1320 env::remove_var("CODEWHALE_MODEL");
1321 env::remove_var("CODEWHALE_BASE_URL");
1322 env::remove_var("XAI_API_KEY");
1323 env::remove_var("XAI_BASE_URL");
1324 env::remove_var("XAI_MODEL");
1325 env::remove_var("TELECOMJS_API_KEY");
1326 env::remove_var("TELECOMJS_BASE_URL");
1327 env::remove_var("TELECOMJS_MODEL");
1328 env::remove_var("OPENCODE_GO_API_KEY");
1329 env::remove_var("OPENCODE_GO_BASE_URL");
1330 env::remove_var("OPENCODE_GO_MODEL");
1331 env::remove_var("OPENCODE_ZEN_API_KEY");
1332 env::remove_var("OPENCODE_API_KEY");
1333 env::remove_var("OPENCODE_ZEN_BASE_URL");
1334 env::remove_var("OPENCODE_ZEN_MODEL");
1335 env::remove_var("META_MODEL_API_KEY");
1336 env::remove_var("MODEL_API_KEY");
1337 env::remove_var("META_MODEL_API_BASE_URL");
1338 env::remove_var("MODEL_API_BASE_URL");
1339 env::remove_var("META_MODEL_API_MODEL");
1340 env::remove_var("MODEL_API_MODEL");
1341 env::remove_var("NVIDIA_API_KEY");
1342 env::remove_var("NVIDIA_NIM_API_KEY");
1343 env::remove_var("NIM_BASE_URL");
1344 env::remove_var("NVIDIA_BASE_URL");
1345 env::remove_var("NVIDIA_NIM_BASE_URL");
1346 env::remove_var("OPENROUTER_API_KEY");
1347 env::remove_var("OPENROUTER_BASE_URL");
1348 env::remove_var("OPENROUTER_MODEL");
1349 env::remove_var("OPENMODEL_API_KEY");
1350 env::remove_var("OPENMODEL_BASE_URL");
1351 env::remove_var("OPENMODEL_MODEL");
1352 env::remove_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY");
1353 env::remove_var("MIMO_TOKEN_PLAN_API_KEY");
1354 env::remove_var("XIAOMI_MIMO_API_KEY");
1355 env::remove_var("XIAOMI_API_KEY");
1356 env::remove_var("MIMO_API_KEY");
1357 env::remove_var("XIAOMI_MIMO_BASE_URL");
1358 env::remove_var("MIMO_BASE_URL");
1359 env::remove_var("XIAOMI_MIMO_MODEL");
1360 env::remove_var("MIMO_MODEL");
1361 env::remove_var("XIAOMI_MIMO_MODE");
1362 env::remove_var("MIMO_MODE");
1363 env::remove_var("WANJIE_ARK_API_KEY");
1364 env::remove_var("VOLCENGINE_API_KEY");
1365 env::remove_var("VOLCENGINE_ARK_API_KEY");
1366 env::remove_var("ARK_API_KEY");
1367 env::remove_var("VOLCENGINE_BASE_URL");
1368 env::remove_var("VOLCENGINE_ARK_BASE_URL");
1369 env::remove_var("ARK_BASE_URL");
1370 env::remove_var("WANJIE_ARK_BASE_URL");
1371 env::remove_var("WANJIE_BASE_URL");
1372 env::remove_var("WANJIE_MAAS_BASE_URL");
1373 env::remove_var("VOLCENGINE_MODEL");
1374 env::remove_var("VOLCENGINE_ARK_MODEL");
1375 env::remove_var("WANJIE_ARK_MODEL");
1376 env::remove_var("WANJIE_MODEL");
1377 env::remove_var("WANJIE_MAAS_MODEL");
1378 env::remove_var("NOVITA_API_KEY");
1379 env::remove_var("NOVITA_BASE_URL");
1380 env::remove_var("NOVITA_MODEL");
1381 env::remove_var("FIREWORKS_API_KEY");
1382 env::remove_var("FIREWORKS_BASE_URL");
1383 env::remove_var("FIREWORKS_MODEL");
1384 env::remove_var("SILICONFLOW_API_KEY");
1385 env::remove_var("SILICONFLOW_BASE_URL");
1386 env::remove_var("SILICONFLOW_MODEL");
1387 env::remove_var("ARCEE_API_KEY");
1388 env::remove_var("ARCEE_BASE_URL");
1389 env::remove_var("ARCEE_MODEL");
1390 env::remove_var("MOONSHOT_API_KEY");
1391 env::remove_var("MOONSHOT_BASE_URL");
1392 env::remove_var("MOONSHOT_MODEL");
1393 env::remove_var("KIMI_API_KEY");
1394 env::remove_var("KIMI_BASE_URL");
1395 env::remove_var("KIMI_MODEL");
1396 env::remove_var("KIMI_MODEL_NAME");
1397 env::remove_var("ZAI_API_KEY");
1398 env::remove_var("Z_AI_API_KEY");
1399 env::remove_var("ZHIPU_API_KEY");
1400 env::remove_var("GLM_API_KEY");
1401 env::remove_var("ZAI_BASE_URL");
1402 env::remove_var("Z_AI_BASE_URL");
1403 env::remove_var("ZHIPU_BASE_URL");
1404 env::remove_var("ZHIPUAI_BASE_URL");
1405 env::remove_var("BIGMODEL_BASE_URL");
1406 env::remove_var("ZAI_MODEL");
1407 env::remove_var("Z_AI_MODEL");
1408 env::remove_var("ZHIPU_MODEL");
1409 env::remove_var("ZHIPUAI_MODEL");
1410 env::remove_var("BIGMODEL_MODEL");
1411 env::remove_var("GLM_MODEL");
1412 env::remove_var("STEPFUN_API_KEY");
1413 env::remove_var("STEP_API_KEY");
1414 env::remove_var("STEPFUN_BASE_URL");
1415 env::remove_var("STEPFUN_MODEL");
1416 env::remove_var("MINIMAX_API_KEY");
1417 env::remove_var("MINIMAX_BASE_URL");
1418 env::remove_var("MINIMAX_ANTHROPIC_BASE_URL");
1419 env::remove_var("MINIMAX_MODEL");
1420 env::remove_var("SAKANA_API_KEY");
1421 env::remove_var("FUGU_API_KEY");
1422 env::remove_var("SAKANA_BASE_URL");
1423 env::remove_var("SAKANA_MODEL");
1424 env::remove_var("SGLANG_API_KEY");
1425 env::remove_var("SGLANG_BASE_URL");
1426 env::remove_var("VLLM_API_KEY");
1427 env::remove_var("VLLM_BASE_URL");
1428 env::remove_var("OLLAMA_API_KEY");
1429 env::remove_var("OLLAMA_BASE_URL");
1430 env::remove_var("HUGGINGFACE_API_KEY");
1431 env::remove_var("HF_TOKEN");
1432 env::remove_var("HUGGINGFACE_BASE_URL");
1433 env::remove_var("HF_BASE_URL");
1434 env::remove_var("HUGGINGFACE_MODEL");
1435 env::remove_var("HF_MODEL");
1436 }
1437 guard
1438 }
1439
1440 unsafe fn restore_var(key: &str, value: Option<OsString>) {
1441 if let Some(value) = value {
1442 unsafe { env::set_var(key, value) };
1443 } else {
1444 unsafe { env::remove_var(key) };
1445 }
1446 }
1447 }
1448
1449 impl Drop for EnvGuard {
1450 fn drop(&mut self) {
1451 // Safety: test-only environment mutation guarded by a module mutex.
1452 unsafe {
1453 Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
1454 Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
1455 Self::restore_var(
1456 "DEEPSEEK_ANTHROPIC_BASE_URL",
1457 self.deepseek_anthropic_base_url.take(),
1458 );
1459 Self::restore_var(
1460 "DEEPSEEK_CLAUDE_BASE_URL",
1461 self.deepseek_claude_base_url.take(),
1462 );
1463 Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take());
1464 Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
1465 Self::restore_var(
1466 "DEEPSEEK_DEFAULT_TEXT_MODEL",
1467 self.deepseek_default_text_model.take(),
1468 );
1469 Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
1470 Self::restore_var("DEEPSEEK_AUTH_MODE", self.deepseek_auth_mode.take());
1471 Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take());
1472 Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take());
1473 Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take());
1474 Self::restore_var("XAI_API_KEY", self.xai_api_key.take());
1475 Self::restore_var("XAI_BASE_URL", self.xai_base_url.take());
1476 Self::restore_var("XAI_MODEL", self.xai_model.take());
1477 Self::restore_var("TELECOMJS_API_KEY", self.telecomjs_api_key.take());
1478 Self::restore_var("TELECOMJS_BASE_URL", self.telecomjs_base_url.take());
1479 Self::restore_var("TELECOMJS_MODEL", self.telecomjs_model.take());
1480 Self::restore_var("OPENCODE_GO_API_KEY", self.opencode_go_api_key.take());
1481 Self::restore_var("OPENCODE_GO_BASE_URL", self.opencode_go_base_url.take());
1482 Self::restore_var("OPENCODE_GO_MODEL", self.opencode_go_model.take());
1483 Self::restore_var("OPENCODE_ZEN_API_KEY", self.opencode_zen_api_key.take());
1484 Self::restore_var("OPENCODE_API_KEY", self.opencode_api_key.take());
1485 Self::restore_var("OPENCODE_ZEN_BASE_URL", self.opencode_zen_base_url.take());
1486 Self::restore_var("OPENCODE_ZEN_MODEL", self.opencode_zen_model.take());
1487 Self::restore_var("META_MODEL_API_KEY", self.meta_model_api_key.take());
1488 Self::restore_var("MODEL_API_KEY", self.model_api_key.take());
1489 Self::restore_var(
1490 "META_MODEL_API_BASE_URL",
1491 self.meta_model_api_base_url.take(),
1492 );
1493 Self::restore_var("MODEL_API_BASE_URL", self.model_api_base_url.take());
1494 Self::restore_var("META_MODEL_API_MODEL", self.meta_model_api_model.take());
1495 Self::restore_var("MODEL_API_MODEL", self.model_api_model.take());
1496 Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
1497 Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
1498 Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
1499 Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
1500 Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
1501 Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take());
1502 Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take());
1503 Self::restore_var("OPENROUTER_MODEL", self.openrouter_model.take());
1504 Self::restore_var("OPENMODEL_API_KEY", self.openmodel_api_key.take());
1505 Self::restore_var("OPENMODEL_BASE_URL", self.openmodel_base_url.take());
1506 Self::restore_var("OPENMODEL_MODEL", self.openmodel_model.take());
1507 Self::restore_var(
1508 "XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
1509 self.xiaomi_mimo_token_plan_api_key.take(),
1510 );
1511 Self::restore_var(
1512 "MIMO_TOKEN_PLAN_API_KEY",
1513 self.mimo_token_plan_api_key.take(),
1514 );
1515 Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take());
1516 Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take());
1517 Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take());
1518 Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take());
1519 Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take());
1520 Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take());
1521 Self::restore_var("MIMO_MODEL", self.mimo_model.take());
1522 Self::restore_var("XIAOMI_MIMO_MODE", self.xiaomi_mimo_mode.take());
1523 Self::restore_var("MIMO_MODE", self.mimo_mode.take());
1524 Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take());
1525 Self::restore_var("VOLCENGINE_API_KEY", self.volcengine_api_key.take());
1526 Self::restore_var("VOLCENGINE_ARK_API_KEY", self.volcengine_ark_api_key.take());
1527 Self::restore_var("ARK_API_KEY", self.ark_api_key.take());
1528 Self::restore_var("VOLCENGINE_BASE_URL", self.volcengine_base_url.take());
1529 Self::restore_var(
1530 "VOLCENGINE_ARK_BASE_URL",
1531 self.volcengine_ark_base_url.take(),
1532 );
1533 Self::restore_var("ARK_BASE_URL", self.ark_base_url.take());
1534 Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take());
1535 Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take());
1536 Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take());
1537 Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take());
1538 Self::restore_var("VOLCENGINE_ARK_MODEL", self.volcengine_ark_model.take());
1539 Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take());
1540 Self::restore_var("WANJIE_MODEL", self.wanjie_model.take());
1541 Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take());
1542 Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take());
1543 Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take());
1544 Self::restore_var("NOVITA_MODEL", self.novita_model.take());
1545 Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take());
1546 Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take());
1547 Self::restore_var("FIREWORKS_MODEL", self.fireworks_model.take());
1548 Self::restore_var("SILICONFLOW_API_KEY", self.siliconflow_api_key.take());
1549 Self::restore_var("SILICONFLOW_BASE_URL", self.siliconflow_base_url.take());
1550 Self::restore_var("SILICONFLOW_MODEL", self.siliconflow_model.take());
1551 Self::restore_var("ARCEE_API_KEY", self.arcee_api_key.take());
1552 Self::restore_var("ARCEE_BASE_URL", self.arcee_base_url.take());
1553 Self::restore_var("ARCEE_MODEL", self.arcee_model.take());
1554 Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take());
1555 Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take());
1556 Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take());
1557 Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take());
1558 Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take());
1559 Self::restore_var("KIMI_MODEL", self.kimi_model.take());
1560 Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take());
1561 Self::restore_var("ZAI_API_KEY", self.zai_api_key.take());
1562 Self::restore_var("Z_AI_API_KEY", self.z_ai_api_key.take());
1563 Self::restore_var("ZHIPU_API_KEY", self.zhipu_api_key.take());
1564 Self::restore_var("GLM_API_KEY", self.glm_api_key.take());
1565 Self::restore_var("ZAI_BASE_URL", self.zai_base_url.take());
1566 Self::restore_var("Z_AI_BASE_URL", self.z_ai_base_url.take());
1567 Self::restore_var("ZHIPU_BASE_URL", self.zhipu_base_url.take());
1568 Self::restore_var("ZHIPUAI_BASE_URL", self.zhipuai_base_url.take());
1569 Self::restore_var("BIGMODEL_BASE_URL", self.bigmodel_base_url.take());
1570 Self::restore_var("ZAI_MODEL", self.zai_model.take());
1571 Self::restore_var("Z_AI_MODEL", self.z_ai_model.take());
1572 Self::restore_var("ZHIPU_MODEL", self.zhipu_model.take());
1573 Self::restore_var("ZHIPUAI_MODEL", self.zhipuai_model.take());
1574 Self::restore_var("BIGMODEL_MODEL", self.bigmodel_model.take());
1575 Self::restore_var("GLM_MODEL", self.glm_model.take());
1576 Self::restore_var("STEPFUN_API_KEY", self.stepfun_api_key.take());
1577 Self::restore_var("STEP_API_KEY", self.step_api_key.take());
1578 Self::restore_var("STEPFUN_BASE_URL", self.stepfun_base_url.take());
1579 Self::restore_var("STEPFUN_MODEL", self.stepfun_model.take());
1580 Self::restore_var("MINIMAX_API_KEY", self.minimax_api_key.take());
1581 Self::restore_var("MINIMAX_BASE_URL", self.minimax_base_url.take());
1582 Self::restore_var(
1583 "MINIMAX_ANTHROPIC_BASE_URL",
1584 self.minimax_anthropic_base_url.take(),
1585 );
1586 Self::restore_var("MINIMAX_MODEL", self.minimax_model.take());
1587 Self::restore_var("SAKANA_API_KEY", self.sakana_api_key.take());
1588 Self::restore_var("FUGU_API_KEY", self.fugu_api_key.take());
1589 Self::restore_var("SAKANA_BASE_URL", self.sakana_base_url.take());
1590 Self::restore_var("SAKANA_MODEL", self.sakana_model.take());
1591 Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take());
1592 Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take());
1593 Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take());
1594 Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take());
1595 Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take());
1596 Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take());
1597 Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take());
1598 Self::restore_var("HF_TOKEN", self.huggingface_token.take());
1599 Self::restore_var("HUGGINGFACE_BASE_URL", self.huggingface_base_url.take());
1600 Self::restore_var("HF_BASE_URL", self.hf_base_url.take());
1601 Self::restore_var("HUGGINGFACE_MODEL", self.huggingface_model.take());
1602 Self::restore_var("HF_MODEL", self.hf_model.take());
1603 }
1604 }
1605 }
1606
1607 struct RecordingSecretsStore {
1608 gets: Mutex<Vec<String>>,
1609 value: Option<String>,
1610 }
1611
1612 impl RecordingSecretsStore {
1613 fn with_value(value: &str) -> Self {
1614 Self {
1615 gets: Mutex::new(Vec::new()),
1616 value: Some(value.to_string()),
1617 }
1618 }
1619 }
1620
1621 impl codewhale_secrets::KeyringStore for RecordingSecretsStore {
1622 fn get(&self, key: &str) -> Result<Option<String>, codewhale_secrets::SecretsError> {
1623 self.gets.lock().unwrap().push(key.to_string());
1624 Ok(self.value.clone())
1625 }
1626
1627 fn set(&self, _key: &str, _value: &str) -> Result<(), codewhale_secrets::SecretsError> {
1628 Ok(())
1629 }
1630
1631 fn delete(&self, _key: &str) -> Result<(), codewhale_secrets::SecretsError> {
1632 Ok(())
1633 }
1634
1635 fn backend_name(&self) -> &'static str {
1636 "recording"
1637 }
1638 }
1639
1640 #[test]
1641 fn root_deepseek_fields_are_runtime_fallbacks() {
1642 let _lock = env_lock();
1643 let _env = EnvGuard::without_deepseek_runtime_overrides();
1644 let config = ConfigToml {
1645 api_key: Some("root-key".to_string()),
1646 base_url: Some("https://api.deepseek.com".to_string()),
1647 default_text_model: Some("deepseek-v4-pro".to_string()),
1648 ..ConfigToml::default()
1649 };
1650
1651 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1652
1653 assert_eq!(resolved.provider, ProviderKind::Deepseek);
1654 assert_eq!(resolved.api_key.as_deref(), Some("root-key"));
1655 assert_eq!(resolved.base_url, "https://api.deepseek.com");
1656 assert_eq!(resolved.model, "deepseek-v4-pro");
1657 }
1658
1659 #[test]
1660 fn deepseek_runtime_defaults_to_beta_endpoint() {
1661 let _lock = env_lock();
1662 let _env = EnvGuard::without_deepseek_runtime_overrides();
1663 let config = ConfigToml::default();
1664
1665 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1666
1667 assert_eq!(resolved.provider, ProviderKind::Deepseek);
1668 assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_BASE_URL);
1669 assert_eq!(resolved.model, DEFAULT_DEEPSEEK_MODEL);
1670 }
1671
1672 #[test]
1673 fn provider_specific_deepseek_fields_override_tui_compat_fields() {
1674 let _lock = env_lock();
1675 let _env = EnvGuard::without_deepseek_runtime_overrides();
1676 let mut config = ConfigToml {
1677 api_key: Some("root-key".to_string()),
1678 base_url: Some("https://api.deepseek.com".to_string()),
1679 default_text_model: Some("deepseek-v4-pro".to_string()),
1680 ..ConfigToml::default()
1681 };
1682 config.providers.deepseek.api_key = Some("provider-key".to_string());
1683 config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string());
1684 config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
1685
1686 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1687
1688 assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
1689 assert_eq!(resolved.base_url, "https://gateway.example/v1");
1690 assert_eq!(resolved.model, "deepseek-v4-flash");
1691 }
1692
1693 #[test]
1694 fn provider_http_headers_override_root_headers() {
1695 let _lock = env_lock();
1696 let _env = EnvGuard::without_deepseek_runtime_overrides();
1697 let mut config = ConfigToml {
1698 api_key: Some("root-key".to_string()),
1699 base_url: Some("https://api.deepseek.com".to_string()),
1700 default_text_model: Some("deepseek-v4-pro".to_string()),
1701 ..ConfigToml::default()
1702 };
1703 config.providers.deepseek.api_key = Some("provider-key".to_string());
1704 config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string());
1705 config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
1706 config
1707 .http_headers
1708 .insert("X-Shared".to_string(), "root".to_string());
1709 config
1710 .providers
1711 .deepseek
1712 .http_headers
1713 .insert("X-Model-Provider-Id".to_string(), "tongyi".to_string());
1714 config
1715 .providers
1716 .deepseek
1717 .http_headers
1718 .insert("X-Shared".to_string(), "provider".to_string());
1719
1720 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1721
1722 assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
1723 assert_eq!(resolved.base_url, "https://gateway.example/v1");
1724 assert_eq!(resolved.model, "deepseek-v4-flash");
1725 assert_eq!(
1726 resolved
1727 .http_headers
1728 .get("X-Model-Provider-Id")
1729 .map(String::as_str),
1730 Some("tongyi")
1731 );
1732 assert_eq!(
1733 resolved.http_headers.get("X-Shared").map(String::as_str),
1734 Some("provider")
1735 );
1736 }
1737
1738 #[test]
1739 fn insecure_skip_tls_verify_resolves_only_for_active_provider() {
1740 let _lock = env_lock();
1741 let _env = EnvGuard::without_deepseek_runtime_overrides();
1742 let mut config = ConfigToml {
1743 provider: ProviderKind::Openai,
1744 ..ConfigToml::default()
1745 };
1746 config.providers.deepseek.insecure_skip_tls_verify = Some(true);
1747
1748 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1749
1750 assert_eq!(resolved.provider, ProviderKind::Openai);
1751 assert!(!resolved.insecure_skip_tls_verify);
1752
1753 config.providers.openai.insecure_skip_tls_verify = Some(true);
1754 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1755
1756 assert_eq!(resolved.provider, ProviderKind::Openai);
1757 assert!(resolved.insecure_skip_tls_verify);
1758 }
1759
1760 #[test]
1761 fn openai_provider_accepts_dashscope_bailian_base_url_and_model() {
1762 let _lock = env_lock();
1763 let _env = EnvGuard::without_deepseek_runtime_overrides();
1764 let mut config = ConfigToml {
1765 provider: ProviderKind::Openai,
1766 ..ConfigToml::default()
1767 };
1768 config.providers.openai.api_key = Some("dashscope-table-key".to_string());
1769 config.providers.openai.base_url =
1770 Some("https://dashscope-intl.aliyuncs.com/compatible-mode/v1".to_string());
1771 config.providers.openai.model = Some("qwen-plus".to_string());
1772
1773 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1774
1775 assert_eq!(resolved.provider, ProviderKind::Openai);
1776 assert_eq!(resolved.api_key.as_deref(), Some("dashscope-table-key"));
1777 assert_eq!(
1778 resolved.base_url,
1779 "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
1780 );
1781 assert_eq!(resolved.model, "qwen-plus");
1782 }
1783
1784 #[test]
1785 fn http_headers_env_overrides_config() {
1786 let _lock = env_lock();
1787 let _env = EnvGuard::without_deepseek_runtime_overrides();
1788 let mut config = ConfigToml::default();
1789 config
1790 .http_headers
1791 .insert("X-Model-Provider-Id".to_string(), "from-file".to_string());
1792 // Safety: test-only environment mutation guarded by a module mutex.
1793 unsafe {
1794 env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env");
1795 }
1796
1797 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1798
1799 assert_eq!(
1800 resolved
1801 .http_headers
1802 .get("X-Model-Provider-Id")
1803 .map(String::as_str),
1804 Some("from-env")
1805 );
1806 }
1807
1808 #[test]
1809 fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() {
1810 let _lock = env_lock();
1811 let _env = EnvGuard::without_deepseek_runtime_overrides();
1812 let config = ConfigToml {
1813 provider: ProviderKind::NvidiaNim,
1814 ..ConfigToml::default()
1815 };
1816
1817 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1818
1819 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
1820 assert_eq!(resolved.base_url, DEFAULT_NVIDIA_NIM_BASE_URL);
1821 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
1822 }
1823
1824 #[test]
1825 fn nvidia_nim_provider_uses_provider_specific_credentials() {
1826 let _lock = env_lock();
1827 let _env = EnvGuard::without_deepseek_runtime_overrides();
1828 let mut config = ConfigToml {
1829 provider: ProviderKind::NvidiaNim,
1830 ..ConfigToml::default()
1831 };
1832 config.providers.nvidia_nim.api_key = Some("nim-key".to_string());
1833 config.providers.nvidia_nim.base_url = Some("https://nim.example/v1".to_string());
1834 config.providers.nvidia_nim.model = Some("deepseek-ai/deepseek-v4-pro".to_string());
1835
1836 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1837
1838 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
1839 assert_eq!(resolved.api_key.as_deref(), Some("nim-key"));
1840 assert_eq!(resolved.base_url, "https://nim.example/v1");
1841 assert_eq!(resolved.model, "deepseek-ai/deepseek-v4-pro");
1842 }
1843
1844 #[test]
1845 fn multiword_provider_sections_accept_the_kebab_canonical_id() {
1846 // The canonical provider ids are kebab (`nvidia-nim`, `wanjie-ark`,
1847 // `xiaomi-mimo`) everywhere users see them; before 2026-08-04 the TOML
1848 // fields carried only the snake_case name, so a `[providers.nvidia-nim]`
1849 // section parsed into nothing and the override was silently lost.
1850 let toml_src = "\
1851 [providers.nvidia-nim]
1852 model = \"nim-kebab-model\"
1853
1854 [providers.wanjie-ark]
1855 model = \"wanjie-kebab-model\"
1856
1857 [providers.xiaomi-mimo]
1858 model = \"mimo-kebab-model\"
1859 ";
1860 let parsed: ConfigToml = toml::from_str(toml_src).expect("kebab provider sections parse");
1861 assert_eq!(
1862 parsed.providers.nvidia_nim.model.as_deref(),
1863 Some("nim-kebab-model")
1864 );
1865 assert_eq!(
1866 parsed.providers.wanjie_ark.model.as_deref(),
1867 Some("wanjie-kebab-model")
1868 );
1869 assert_eq!(
1870 parsed.providers.xiaomi_mimo.model.as_deref(),
1871 Some("mimo-kebab-model")
1872 );
1873 }
1874
1875 #[test]
1876 fn nvidia_nim_provider_normalizes_flash_aliases() {
1877 let _lock = env_lock();
1878 let _env = EnvGuard::without_deepseek_runtime_overrides();
1879 let cli = CliRuntimeOverrides {
1880 provider: Some(ProviderKind::NvidiaNim),
1881 model: Some("deepseek-v4-flash".to_string()),
1882 ..CliRuntimeOverrides::default()
1883 };
1884
1885 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
1886
1887 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
1888 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_FLASH_MODEL);
1889 }
1890
1891 #[test]
1892 fn nvidia_nim_custom_env_url_does_not_inherit_ambient_credentials() {
1893 let _lock = env_lock();
1894 let _env = EnvGuard::without_deepseek_runtime_overrides();
1895 // Safety: test-only environment mutation guarded by a module mutex.
1896 unsafe {
1897 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
1898 env::set_var("NVIDIA_API_KEY", "nim-env-key");
1899 env::set_var("NVIDIA_NIM_BASE_URL", "https://nim-env.example/v1");
1900 }
1901
1902 let config = ConfigToml::default();
1903 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1904
1905 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
1906 assert_eq!(resolved.api_key, None);
1907 assert_eq!(resolved.api_key_source, None);
1908 assert_eq!(resolved.base_url, "https://nim-env.example/v1");
1909 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
1910 }
1911
1912 #[test]
1913 fn nvidia_nim_provider_accepts_short_nim_base_url_alias() {
1914 let _lock = env_lock();
1915 let _env = EnvGuard::without_deepseek_runtime_overrides();
1916 // Safety: test-only environment mutation guarded by a module mutex.
1917 unsafe {
1918 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
1919 env::set_var("NVIDIA_API_KEY", "nim-env-key");
1920 env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
1921 }
1922
1923 let config = ConfigToml::default();
1924 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1925
1926 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
1927 assert_eq!(resolved.base_url, "https://short-nim.example/v1");
1928 }
1929
1930 #[test]
1931 fn nvidia_nim_provider_can_fallback_to_deepseek_api_key_env() {
1932 let _lock = env_lock();
1933 let _env = EnvGuard::without_deepseek_runtime_overrides();
1934 // Safety: test-only environment mutation guarded by a module mutex.
1935 unsafe {
1936 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
1937 env::set_var("DEEPSEEK_API_KEY", "deepseek-compat-key");
1938 }
1939
1940 let config = ConfigToml::default();
1941 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1942
1943 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
1944 assert_eq!(resolved.api_key.as_deref(), Some("deepseek-compat-key"));
1945 }
1946
1947 #[test]
1948 fn list_values_redacts_root_api_key() {
1949 let config = ConfigToml {
1950 api_key: Some("sk-deepseek-secret".to_string()),
1951 ..ConfigToml::default()
1952 };
1953
1954 let values = config.list_values();
1955
1956 assert_eq!(
1957 values.get("api_key").map(String::as_str),
1958 Some("sk-d***cret")
1959 );
1960 }
1961
1962 #[test]
1963 fn list_values_fully_redacts_short_api_key() {
1964 let config = ConfigToml {
1965 api_key: Some("short-key".to_string()),
1966 ..ConfigToml::default()
1967 };
1968
1969 let values = config.list_values();
1970
1971 assert_eq!(values.get("api_key").map(String::as_str), Some("********"));
1972 }
1973
1974 #[test]
1975 fn get_display_value_redacts_sensitive_keys() {
1976 let mut config = ConfigToml {
1977 api_key: Some("sk-deepseek-secret".to_string()),
1978 ..ConfigToml::default()
1979 };
1980 config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string());
1981 config.model = Some("deepseek-v4-pro".to_string());
1982
1983 assert_eq!(
1984 config.get_display_value("api_key").as_deref(),
1985 Some("sk-d***cret")
1986 );
1987 assert_eq!(
1988 config
1989 .get_display_value("providers.openrouter.api_key")
1990 .as_deref(),
1991 Some("open***alue")
1992 );
1993 assert_eq!(
1994 config.get_display_value("model").as_deref(),
1995 Some("deepseek-v4-pro")
1996 );
1997 }
1998
1999 #[test]
2000 fn stream_chunk_timeout_display_defaults_to_900_for_flat_key() {
2001 let config = ConfigToml::default();
2002
2003 assert_eq!(
2004 config
2005 .get_display_value("stream_chunk_timeout_secs")
2006 .as_deref(),
2007 Some("900")
2008 );
2009 }
2010
2011 #[test]
2012 fn stream_chunk_timeout_display_reads_tui_table_for_flat_key() {
2013 let config: ConfigToml = toml::from_str(
2014 r#"
2015 [tui]
2016 stream_chunk_timeout_secs = 1200
2017 "#,
2018 )
2019 .expect("config toml");
2020
2021 assert_eq!(
2022 config
2023 .get_display_value("stream_chunk_timeout_secs")
2024 .as_deref(),
2025 Some("1200")
2026 );
2027 }
2028
2029 #[test]
2030 fn stream_chunk_timeout_display_supports_dotted_tui_key() {
2031 let config: ConfigToml = toml::from_str(
2032 r#"
2033 [tui]
2034 stream_chunk_timeout_secs = 1200
2035 "#,
2036 )
2037 .expect("config toml");
2038
2039 assert_eq!(
2040 config
2041 .get_display_value("tui.stream_chunk_timeout_secs")
2042 .as_deref(),
2043 Some("1200")
2044 );
2045 }
2046
2047 #[test]
2048 fn stream_chunk_timeout_display_zero_maps_to_default_and_clamps() {
2049 let zero: ConfigToml = toml::from_str(
2050 r#"
2051 [tui]
2052 stream_chunk_timeout_secs = 0
2053 "#,
2054 )
2055 .expect("zero config toml");
2056 assert_eq!(
2057 zero.get_display_value("stream_chunk_timeout_secs")
2058 .as_deref(),
2059 Some("900")
2060 );
2061
2062 let high: ConfigToml = toml::from_str(
2063 r#"
2064 [tui]
2065 stream_chunk_timeout_secs = 9999
2066 "#,
2067 )
2068 .expect("high config toml");
2069 assert_eq!(
2070 high.get_display_value("stream_chunk_timeout_secs")
2071 .as_deref(),
2072 Some("3600")
2073 );
2074 }
2075
2076 #[test]
2077 fn config_display_redacts_nested_extra_secrets() {
2078 let mut config = ConfigToml::default();
2079 let mut profile = toml::map::Map::new();
2080 profile.insert(
2081 "chatgpt_access_token".to_string(),
2082 toml::Value::String("raw-chatgpt-access-token-value".to_string()),
2083 );
2084 profile.insert(
2085 "safe_label".to_string(),
2086 toml::Value::String("visible".to_string()),
2087 );
2088
2089 let mut nested = toml::map::Map::new();
2090 nested.insert(
2091 "refresh_token".to_string(),
2092 toml::Value::String("raw-refresh-token-value".to_string()),
2093 );
2094 nested.insert("expires_at".to_string(), toml::Value::Integer(1234));
2095 profile.insert("session".to_string(), toml::Value::Table(nested));
2096
2097 config
2098 .extras
2099 .insert("extras".to_string(), toml::Value::Table(profile));
2100
2101 let listed = config.list_values();
2102 let rendered = listed.get("extras").expect("extras are listed");
2103
2104 assert!(rendered.contains("chatgpt_access_token"));
2105 assert!(rendered.contains("refresh_token"));
2106 assert!(rendered.contains("safe_label = \"visible\""));
2107 assert!(!rendered.contains("raw-chatgpt-access-token-value"));
2108 assert!(!rendered.contains("raw-refresh-token-value"));
2109
2110 let display = config
2111 .get_display_value("extras")
2112 .expect("extras display value");
2113 assert!(!display.contains("raw-chatgpt-access-token-value"));
2114 assert!(!display.contains("raw-refresh-token-value"));
2115 }
2116
2117 #[test]
2118 fn config_display_redacts_sensitive_extra_leaf_keys_and_headers() {
2119 let mut config = ConfigToml::default();
2120 config.extras.insert(
2121 "chatgpt_access_token".to_string(),
2122 toml::Value::String("raw-chatgpt-token-value".to_string()),
2123 );
2124 config.http_headers.insert(
2125 "Authorization".to_string(),
2126 "Bearer raw-header-token".to_string(),
2127 );
2128 config
2129 .http_headers
2130 .insert("X-Test".to_string(), "ok".to_string());
2131
2132 assert_eq!(
2133 config.get_display_value("chatgpt_access_token").as_deref(),
2134 Some("\"raw-***alue\"")
2135 );
2136
2137 let headers = config
2138 .list_values()
2139 .get("http_headers")
2140 .expect("headers are listed")
2141 .clone();
2142 assert!(headers.contains("Authorization=Bear***oken"));
2143 assert!(headers.contains("X-Test=ok"));
2144 assert!(!headers.contains("raw-header-token"));
2145 }
2146
2147 #[test]
2148 fn hook_sinks_config_uses_separate_table_from_lifecycle_hooks() -> Result<()> {
2149 let raw = r#"
2150 [hooks]
2151 enabled = true
2152 default_timeout_secs = 20
2153
2154 [[hooks.hooks]]
2155 event = "message_submit"
2156 command = "echo ok"
2157
2158 [hook_sinks]
2159 unix_socket_path = "/tmp/cw-hooks.sock"
2160 "#;
2161
2162 let config: ConfigToml = toml::from_str(raw)?;
2163
2164 assert_eq!(
2165 config.get_value("hook_sinks.unix_socket_path").as_deref(),
2166 Some("/tmp/cw-hooks.sock")
2167 );
2168 assert!(
2169 config.extras.contains_key("hooks"),
2170 "legacy lifecycle hooks table must remain an opaque extra"
2171 );
2172
2173 let serialized = toml::to_string_pretty(&config)?;
2174 let round_tripped: ConfigToml = toml::from_str(&serialized)?;
2175 let hooks = round_tripped
2176 .extras
2177 .get("hooks")
2178 .and_then(toml::Value::as_table)
2179 .expect("hooks table preserved");
2180
2181 assert_eq!(
2182 hooks.get("enabled").and_then(toml::Value::as_bool),
2183 Some(true)
2184 );
2185 assert_eq!(
2186 hooks
2187 .get("default_timeout_secs")
2188 .and_then(toml::Value::as_integer),
2189 Some(20)
2190 );
2191 assert!(
2192 hooks.get("hooks").and_then(toml::Value::as_array).is_some(),
2193 "nested lifecycle hooks array must survive config rewrites"
2194 );
2195 assert_eq!(
2196 round_tripped
2197 .get_value("hook_sinks.unix_socket_path")
2198 .as_deref(),
2199 Some("/tmp/cw-hooks.sock")
2200 );
2201
2202 Ok(())
2203 }
2204
2205 #[test]
2206 fn hook_sinks_unix_socket_path_round_trips_through_key_value_api() -> Result<()> {
2207 let mut config = ConfigToml::default();
2208
2209 config.set_value("hook_sinks.unix_socket_path", "/tmp/cw-events.sock")?;
2210
2211 assert_eq!(
2212 config.get_value("hook_sinks.unix_socket_path").as_deref(),
2213 Some("/tmp/cw-events.sock")
2214 );
2215 assert_eq!(
2216 config
2217 .list_values()
2218 .get("hook_sinks.unix_socket_path")
2219 .map(String::as_str),
2220 Some("/tmp/cw-events.sock")
2221 );
2222
2223 config.unset_value("hook_sinks.unix_socket_path")?;
2224 assert_eq!(config.get_value("hook_sinks.unix_socket_path"), None);
2225
2226 Ok(())
2227 }
2228
2229 /// End-to-end smoke for the preferred Kimi Code setup path:
2230 /// 1. Start from a fresh root config that uses DeepSeek defaults.
2231 /// 2. Mutate it through the same key-value setters the
2232 /// `codewhale config set providers.moonshot.*` CLI invokes.
2233 /// 3. Switch the active provider through `CODEWHALE_PROVIDER` —
2234 /// the public env alias — without ever touching the legacy
2235 /// `DEEPSEEK_PROVIDER` name.
2236 /// 4. Resolve the runtime and confirm the doctor/runtime values.
2237 ///
2238 /// No real API key is required; the `api_key` here is just a
2239 /// non-empty placeholder.
2240 #[test]
2241 fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> {
2242 let _lock = env_lock();
2243 let _env = EnvGuard::without_deepseek_runtime_overrides();
2244
2245 let mut config = ConfigToml {
2246 provider: ProviderKind::Deepseek,
2247 default_text_model: Some("deepseek-v4-pro".to_string()),
2248 ..ConfigToml::default()
2249 };
2250
2251 // Same key paths a user would run via `codewhale config set`.
2252 config.set_value("providers.moonshot.api_key", "kimi-code-key-placeholder")?;
2253 config.set_value("providers.moonshot.auth_mode", "api_key")?;
2254 config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
2255 config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
2256
2257 // Public env alias for the active-provider switch.
2258 // Safety: test-only env mutation guarded by env_lock().
2259 unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") };
2260
2261 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2262
2263 assert_eq!(resolved.provider, ProviderKind::Moonshot);
2264 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
2265 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
2266 assert_eq!(resolved.auth_mode.as_deref(), Some("api_key"));
2267 assert_eq!(
2268 resolved.api_key.as_deref(),
2269 Some("kimi-code-key-placeholder")
2270 );
2271 assert_eq!(
2272 resolved.api_key_source,
2273 Some(RuntimeApiKeySource::ConfigFile)
2274 );
2275 Ok(())
2276 }
2277
2278 #[test]
2279 fn moonshot_provider_config_values_round_trip() -> Result<()> {
2280 let mut config = ConfigToml::default();
2281
2282 config.set_value("providers.moonshot.api_key", "moonshot-secret-value")?;
2283 config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
2284 config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
2285 config.set_value("providers.moonshot.auth_mode", "api_key")?;
2286 config.set_value("providers.moonshot.http_headers", "X-Test=ok")?;
2287
2288 assert_eq!(
2289 config
2290 .get_display_value("providers.moonshot.api_key")
2291 .as_deref(),
2292 Some("moon***alue")
2293 );
2294 assert_eq!(
2295 config.get_value("providers.moonshot.base_url").as_deref(),
2296 Some(DEFAULT_KIMI_CODE_BASE_URL)
2297 );
2298 assert_eq!(
2299 config.get_value("providers.moonshot.model").as_deref(),
2300 Some(DEFAULT_KIMI_CODE_MODEL)
2301 );
2302 assert_eq!(
2303 config.get_value("providers.moonshot.auth_mode").as_deref(),
2304 Some("api_key")
2305 );
2306 assert_eq!(
2307 config
2308 .list_values()
2309 .get("providers.moonshot.api_key")
2310 .map(String::as_str),
2311 Some("moon***alue")
2312 );
2313
2314 config.unset_value("providers.moonshot.auth_mode")?;
2315 config.unset_value("providers.moonshot.base_url")?;
2316 config.unset_value("providers.moonshot.model")?;
2317
2318 assert_eq!(config.get_value("providers.moonshot.auth_mode"), None);
2319 assert_eq!(config.get_value("providers.moonshot.base_url"), None);
2320 assert_eq!(config.get_value("providers.moonshot.model"), None);
2321 Ok(())
2322 }
2323
2324 #[test]
2325 fn custom_provider_config_set_get_unset_round_trip() -> Result<()> {
2326 let mut config = ConfigToml::default();
2327
2328 // The owner flow from #5174: build an Alibaba Model Studio custom
2329 // provider entirely through `config set providers.<id>.<field>`.
2330 config.set_value("providers.alibaba_studio.kind", "openai-compatible")?;
2331 config.set_value(
2332 "providers.alibaba_studio.base_url",
2333 "https://coding.dashscope.aliyuncs.com/v1",
2334 )?;
2335 config.set_value("providers.alibaba_studio.model", "qwen3-coder-plus")?;
2336 config.set_value("providers.alibaba_studio.api_key", "sk-studio-secret")?;
2337 config.set_value("providers.alibaba_studio.context_window", "256000")?;
2338
2339 assert_eq!(
2340 config.get_value("providers.alibaba_studio.kind").as_deref(),
2341 Some("openai-compatible")
2342 );
2343 assert_eq!(
2344 config
2345 .get_value("providers.alibaba_studio.base_url")
2346 .as_deref(),
2347 Some("https://coding.dashscope.aliyuncs.com/v1")
2348 );
2349 assert_eq!(
2350 config
2351 .get_value("providers.alibaba_studio.model")
2352 .as_deref(),
2353 Some("qwen3-coder-plus")
2354 );
2355 assert_eq!(
2356 config
2357 .get_value("providers.alibaba_studio.context_window")
2358 .as_deref(),
2359 Some("256000")
2360 );
2361 assert_eq!(
2362 config
2363 .get_display_value("providers.alibaba_studio.api_key")
2364 .as_deref(),
2365 Some("********")
2366 );
2367
2368 // The set must land in a real `[providers.alibaba_studio]` table, never
2369 // in a literal top-level extras key (#5167).
2370 let serialized = toml::to_string(&config)?;
2371 assert!(
2372 serialized.contains("[providers.alibaba_studio]"),
2373 "custom provider legs must serialize as a providers table, got:\n{serialized}"
2374 );
2375 assert!(
2376 config
2377 .extras
2378 .keys()
2379 .all(|key| !key.starts_with("providers.")),
2380 "no literal 'providers.*' extras key may round-trip: {:?}",
2381 config.extras.keys().collect::<Vec<_>>()
2382 );
2383
2384 // The table must satisfy the runtime binding contract for named custom
2385 // providers.
2386 config.set_value("provider", "alibaba_studio")?;
2387 assert_eq!(config.provider_id(), "alibaba_studio");
2388
2389 config.unset_value("providers.alibaba_studio.api_key")?;
2390 assert_eq!(config.get_value("providers.alibaba_studio.api_key"), None);
2391 assert_eq!(
2392 config
2393 .get_value("providers.alibaba_studio.model")
2394 .as_deref(),
2395 Some("qwen3-coder-plus")
2396 );
2397 Ok(())
2398 }
2399
2400 #[test]
2401 fn custom_provider_set_rejects_unknown_field_with_corrective_error() {
2402 let mut config = ConfigToml::default();
2403
2404 let err = config
2405 .set_value("providers.alibaba_studio.bogus", "x")
2406 .expect_err("unknown custom provider fields must not fall into extras");
2407 let message = format!("{err:#}");
2408 assert!(
2409 message.contains("bogus") && message.contains("base_url"),
2410 "error must name the bad leg and the valid shape, got: {message}"
2411 );
2412 assert!(config.providers.extras.is_empty());
2413 }
2414
2415 #[test]
2416 fn builtin_provider_set_rejects_unknown_field_with_corrective_error() {
2417 let mut config = ConfigToml::default();
2418
2419 let err = config
2420 .set_value("providers.deepseek.bogus", "x")
2421 .expect_err("unknown built-in provider fields must not fall into extras");
2422 let message = format!("{err:#}");
2423 assert!(
2424 message.contains("bogus") && message.contains("deepseek"),
2425 "error must name the bad leg and provider, got: {message}"
2426 );
2427 assert!(config.providers.extras.is_empty());
2428 }
2429
2430 #[test]
2431 fn siliconflow_cn_provider_config_values_round_trip() -> Result<()> {
2432 let mut config = ConfigToml::default();
2433
2434 config.set_value("providers.siliconflow_cn.api_key", "sf-cn-secret-value")?;
2435 config.set_value(
2436 "providers.siliconflow_cn.base_url",
2437 DEFAULT_SILICONFLOW_CN_BASE_URL,
2438 )?;
2439 config.set_value("providers.siliconflow_cn.model", DEFAULT_SILICONFLOW_MODEL)?;
2440 config.set_value("providers.siliconflow_cn.http_headers", "X-Test=ok")?;
2441
2442 assert_eq!(
2443 config
2444 .get_display_value("providers.siliconflow_cn.api_key")
2445 .as_deref(),
2446 Some("sf-c***alue")
2447 );
2448 assert_eq!(
2449 config
2450 .get_value("providers.siliconflow_cn.base_url")
2451 .as_deref(),
2452 Some(DEFAULT_SILICONFLOW_CN_BASE_URL)
2453 );
2454 assert_eq!(
2455 config
2456 .get_value("providers.siliconflow_cn.model")
2457 .as_deref(),
2458 Some(DEFAULT_SILICONFLOW_MODEL)
2459 );
2460 assert_eq!(
2461 config
2462 .list_values()
2463 .get("providers.siliconflow_cn.api_key")
2464 .map(String::as_str),
2465 Some("sf-c***alue")
2466 );
2467
2468 config.unset_value("providers.siliconflow_cn.api_key")?;
2469 config.unset_value("providers.siliconflow_cn.base_url")?;
2470 config.unset_value("providers.siliconflow_cn.model")?;
2471 config.unset_value("providers.siliconflow_cn.http_headers")?;
2472
2473 assert_eq!(config.get_value("providers.siliconflow_cn.api_key"), None);
2474 assert_eq!(config.get_value("providers.siliconflow_cn.base_url"), None);
2475 assert_eq!(config.get_value("providers.siliconflow_cn.model"), None);
2476 assert_eq!(
2477 config.get_value("providers.siliconflow_cn.http_headers"),
2478 None
2479 );
2480 Ok(())
2481 }
2482
2483 #[test]
2484 fn volcengine_provider_config_values_round_trip() -> Result<()> {
2485 let mut config = ConfigToml::default();
2486
2487 config.set_value("providers.volcengine.api_key", "volcengine-secret-value")?;
2488 config.set_value("providers.volcengine.base_url", DEFAULT_VOLCENGINE_BASE_URL)?;
2489 config.set_value("providers.volcengine.model", DEFAULT_VOLCENGINE_MODEL)?;
2490 config.set_value("providers.volcengine.http_headers", "X-Test=ok")?;
2491
2492 assert_eq!(
2493 config
2494 .get_display_value("providers.volcengine.api_key")
2495 .as_deref(),
2496 Some("volc***alue")
2497 );
2498 assert_eq!(
2499 config.get_value("providers.volcengine.base_url").as_deref(),
2500 Some(DEFAULT_VOLCENGINE_BASE_URL)
2501 );
2502 assert_eq!(
2503 config.get_value("providers.volcengine.model").as_deref(),
2504 Some(DEFAULT_VOLCENGINE_MODEL)
2505 );
2506 assert_eq!(
2507 config
2508 .get_value("providers.volcengine.http_headers")
2509 .as_deref(),
2510 Some("X-Test=ok")
2511 );
2512 assert_eq!(
2513 config
2514 .list_values()
2515 .get("providers.volcengine.http_headers")
2516 .map(String::as_str),
2517 Some("X-Test=ok")
2518 );
2519
2520 config.unset_value("providers.volcengine.http_headers")?;
2521 assert_eq!(config.get_value("providers.volcengine.http_headers"), None);
2522 Ok(())
2523 }
2524
2525 #[test]
2526 fn provider_key_value_api_covers_all_provider_metadata_entries() -> Result<()> {
2527 for provider in ProviderKind::ALL {
2528 let table = provider.provider().provider_config_key();
2529 let mut config = ConfigToml::default();
2530 let api_key = format!("secret-value-for-{table}-123456");
2531 let api_key_path = format!("providers.{table}.api_key");
2532 let base_url_path = format!("providers.{table}.base_url");
2533 let model_path = format!("providers.{table}.model");
2534 let context_window_path = format!("providers.{table}.context_window");
2535 let headers_path = format!("providers.{table}.http_headers");
2536 let mode_path = format!("providers.{table}.mode");
2537 let auth_mode_path = format!("providers.{table}.auth_mode");
2538 let insecure_path = format!("providers.{table}.insecure_skip_tls_verify");
2539 let path_suffix_path = format!("providers.{table}.path_suffix");
2540
2541 config.set_value(&api_key_path, &api_key)?;
2542 config.set_value(&base_url_path, "https://gateway.example/v1")?;
2543 config.set_value(&model_path, "provider-test-model")?;
2544 config.set_value(&context_window_path, "1000000")?;
2545 config.set_value(&headers_path, "X-Test=ok")?;
2546 config.set_value(&mode_path, "concise")?;
2547 config.set_value(&auth_mode_path, "api_key")?;
2548 config.set_value(&insecure_path, "true")?;
2549 config.set_value(&path_suffix_path, "/chat/completions")?;
2550
2551 assert_eq!(
2552 config.get_value(&api_key_path).as_deref(),
2553 Some(api_key.as_str())
2554 );
2555 assert_eq!(
2556 config.get_value(&base_url_path).as_deref(),
2557 Some("https://gateway.example/v1")
2558 );
2559 assert_eq!(
2560 config.get_value(&model_path).as_deref(),
2561 Some("provider-test-model")
2562 );
2563 assert_eq!(
2564 config.get_value(&context_window_path).as_deref(),
2565 Some("1000000")
2566 );
2567 assert_eq!(
2568 config.get_value(&headers_path).as_deref(),
2569 Some("X-Test=ok")
2570 );
2571 assert_eq!(config.get_value(&mode_path).as_deref(), Some("concise"));
2572 assert_eq!(
2573 config.get_value(&auth_mode_path).as_deref(),
2574 Some("api_key")
2575 );
2576 assert_eq!(config.get_value(&insecure_path).as_deref(), Some("true"));
2577 assert_eq!(
2578 config.get_value(&path_suffix_path).as_deref(),
2579 Some("/chat/completions")
2580 );
2581
2582 let listed = config.list_values();
2583 let listed_api_key = listed
2584 .get(&api_key_path)
2585 .expect("provider API key is listed");
2586 assert!(listed_api_key.contains("***"));
2587 assert_ne!(listed_api_key, &api_key);
2588 assert_eq!(
2589 listed.get(&headers_path).map(String::as_str),
2590 Some("X-Test=ok")
2591 );
2592 assert_eq!(
2593 listed.get(&context_window_path).map(String::as_str),
2594 Some("1000000")
2595 );
2596 assert_eq!(listed.get(&insecure_path).map(String::as_str), Some("true"));
2597
2598 config.unset_value(&api_key_path)?;
2599 config.unset_value(&base_url_path)?;
2600 config.unset_value(&model_path)?;
2601 config.unset_value(&context_window_path)?;
2602 config.unset_value(&headers_path)?;
2603 config.unset_value(&mode_path)?;
2604 config.unset_value(&auth_mode_path)?;
2605 config.unset_value(&insecure_path)?;
2606 config.unset_value(&path_suffix_path)?;
2607
2608 assert_eq!(config.get_value(&api_key_path), None);
2609 assert_eq!(config.get_value(&base_url_path), None);
2610 assert_eq!(config.get_value(&model_path), None);
2611 assert_eq!(config.get_value(&context_window_path), None);
2612 assert_eq!(config.get_value(&headers_path), None);
2613 assert_eq!(config.get_value(&mode_path), None);
2614 assert_eq!(config.get_value(&auth_mode_path), None);
2615 assert_eq!(config.get_value(&insecure_path), None);
2616 assert_eq!(config.get_value(&path_suffix_path), None);
2617
2618 if provider == ProviderKind::Deepseek {
2619 assert_eq!(config.api_key, None);
2620 assert_eq!(config.base_url, None);
2621 assert_eq!(config.default_text_model, None);
2622 assert!(config.http_headers.is_empty());
2623 }
2624 }
2625
2626 Ok(())
2627 }
2628
2629 #[test]
2630 fn provider_context_window_rejects_zero() {
2631 let mut config = ConfigToml::default();
2632 let err = config
2633 .set_value("providers.openai.context_window", "0")
2634 .expect_err("zero context window should be rejected");
2635
2636 assert!(err.to_string().contains("greater than 0"));
2637 }
2638
2639 #[test]
2640 fn project_merge_denies_credentials_endpoints_and_provider_selection() {
2641 let mut base = ConfigToml {
2642 provider: ProviderKind::Deepseek,
2643 api_key: Some("user-key".to_string()),
2644 base_url: Some("https://api.deepseek.com".to_string()),
2645 default_text_model: Some("deepseek-v4-flash".to_string()),
2646 ..ConfigToml::default()
2647 };
2648 base.providers.openrouter.api_key = Some("user-openrouter-key".to_string());
2649 base.providers.openrouter.path_suffix = Some("/chat/completions".to_string());
2650
2651 let mut project = ConfigToml {
2652 provider: ProviderKind::Openrouter,
2653 api_key: Some("attacker-key".to_string()),
2654 base_url: Some("https://evil.example/v1".to_string()),
2655 default_text_model: Some("deepseek-v4-pro".to_string()),
2656 auth_mode: Some("oauth".to_string()),
2657 telemetry: Some(true),
2658 telemetry_endpoint: Some("https://collector.evil.example/ingest".to_string()),
2659 ..ConfigToml::default()
2660 };
2661 project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string());
2662 project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string());
2663 project.providers.openrouter.insecure_skip_tls_verify = Some(true);
2664 project.providers.openrouter.path_suffix = Some("/attacker/chat".to_string());
2665 project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string());
2666 project.providers.volcengine.model = Some("DeepSeek-V4-Pro".to_string());
2667 project.providers.moonshot.model = Some("kimi-k2.6".to_string());
2668
2669 base.merge_project_overrides(project);
2670
2671 assert_eq!(base.provider, ProviderKind::Deepseek);
2672 assert_eq!(base.api_key.as_deref(), Some("user-key"));
2673 assert_eq!(base.base_url.as_deref(), Some("https://api.deepseek.com"));
2674 assert_eq!(base.auth_mode, None);
2675 assert_eq!(base.telemetry, None);
2676 // A repo-local `.codewhale/config.toml` cannot aim telemetry at a host of
2677 // its choosing any more than it can turn telemetry on. Both are ignored by
2678 // omission from the explicit field list `merge_project_overrides` copies;
2679 // this pins that omission.
2680 assert_eq!(base.telemetry_endpoint, None);
2681 assert_eq!(
2682 base.providers.openrouter.api_key.as_deref(),
2683 Some("user-openrouter-key")
2684 );
2685 assert_eq!(base.providers.openrouter.base_url, None);
2686 assert_eq!(base.providers.openrouter.insecure_skip_tls_verify, None);
2687 assert_eq!(
2688 base.providers.openrouter.path_suffix.as_deref(),
2689 Some("/chat/completions")
2690 );
2691 assert_eq!(base.default_text_model.as_deref(), Some("deepseek-v4-pro"));
2692 assert_eq!(
2693 base.providers.openrouter.model.as_deref(),
2694 Some("deepseek/deepseek-v4-pro")
2695 );
2696 assert_eq!(
2697 base.providers.volcengine.model.as_deref(),
2698 Some("DeepSeek-V4-Pro")
2699 );
2700 assert_eq!(base.providers.moonshot.model.as_deref(), Some("kimi-k2.6"));
2701 }
2702
2703 #[test]
2704 fn project_merge_forwards_all_provider_model_overrides() {
2705 let mut project_toml = String::new();
2706 for provider in ProviderKind::ALL {
2707 let key = provider.provider().provider_config_key();
2708 project_toml.push_str(&format!(
2709 "[providers.{key}]\nmodel = \"project-{key}-model\"\n\n"
2710 ));
2711 }
2712
2713 let project: ConfigToml =
2714 toml::from_str(&project_toml).expect("project provider overrides parse");
2715 let mut base = ConfigToml::default();
2716
2717 base.merge_project_overrides(project);
2718
2719 for provider in ProviderKind::ALL {
2720 let key = provider.provider().provider_config_key();
2721 let expected = format!("project-{key}-model");
2722 assert_eq!(
2723 base.providers.for_provider(provider).model.as_deref(),
2724 Some(expected.as_str()),
2725 "provider {key} should merge repo-local model override"
2726 );
2727 }
2728 }
2729
2730 #[test]
2731 fn project_merge_does_not_replace_user_hotbar_bindings() {
2732 let mut base = ConfigToml {
2733 hotbar: Some(vec![HotbarBindingToml {
2734 slot: 1,
2735 action: "mode.plan".to_string(),
2736 label: Some("Plan".to_string()),
2737 }]),
2738 ..ConfigToml::default()
2739 };
2740 let project = ConfigToml {
2741 hotbar: Some(vec![HotbarBindingToml {
2742 slot: 1,
2743 action: "mode.yolo".to_string(),
2744 label: Some("Yolo".to_string()),
2745 }]),
2746 ..ConfigToml::default()
2747 };
2748
2749 base.merge_project_overrides(project);
2750
2751 assert_eq!(
2752 base.hotbar,
2753 Some(vec![HotbarBindingToml {
2754 slot: 1,
2755 action: "mode.plan".to_string(),
2756 label: Some("Plan".to_string()),
2757 }])
2758 );
2759 }
2760
2761 #[test]
2762 fn project_merge_only_tightens_approval_and_sandbox_policy() {
2763 let mut strict = ConfigToml {
2764 approval_policy: Some("never".to_string()),
2765 sandbox_mode: Some("read-only".to_string()),
2766 ..ConfigToml::default()
2767 };
2768 strict.merge_project_overrides(ConfigToml {
2769 approval_policy: Some("on-request".to_string()),
2770 sandbox_mode: Some("workspace-write".to_string()),
2771 ..ConfigToml::default()
2772 });
2773 assert_eq!(strict.approval_policy.as_deref(), Some("never"));
2774 assert_eq!(strict.sandbox_mode.as_deref(), Some("read-only"));
2775
2776 let mut permissive = ConfigToml {
2777 approval_policy: Some("auto".to_string()),
2778 sandbox_mode: Some("workspace-write".to_string()),
2779 ..ConfigToml::default()
2780 };
2781 permissive.merge_project_overrides(ConfigToml {
2782 approval_policy: Some("never".to_string()),
2783 sandbox_mode: Some("read-only".to_string()),
2784 ..ConfigToml::default()
2785 });
2786 assert_eq!(permissive.approval_policy.as_deref(), Some("never"));
2787 assert_eq!(permissive.sandbox_mode.as_deref(), Some("read-only"));
2788
2789 let mut unset = ConfigToml::default();
2790 unset.merge_project_overrides(ConfigToml {
2791 approval_policy: Some("on-request".to_string()),
2792 sandbox_mode: Some("workspace-write".to_string()),
2793 ..ConfigToml::default()
2794 });
2795 assert_eq!(unset.approval_policy, None);
2796 assert_eq!(unset.sandbox_mode, None);
2797 }
2798
2799 #[test]
2800 fn list_values_redacts_unicode_api_key_without_byte_slicing() {
2801 let config = ConfigToml {
2802 api_key: Some("密钥密钥密钥密钥123456789".to_string()),
2803 ..ConfigToml::default()
2804 };
2805
2806 let values = config.list_values();
2807
2808 assert_eq!(
2809 values.get("api_key").map(String::as_str),
2810 Some("密钥密钥***6789")
2811 );
2812 }
2813
2814 #[test]
2815 fn app_homes_prefer_home_env_before_platform_home_fallback() {
2816 let _lock = env_lock();
2817 let home =
2818 std::env::temp_dir().join(format!("codewhale-config-home-env-{}", std::process::id()));
2819 let userprofile = std::env::temp_dir().join(format!(
2820 "codewhale-config-userprofile-{}",
2821 std::process::id()
2822 ));
2823 let _env = StateEnvRestore {
2824 home: env::var_os("HOME"),
2825 userprofile: env::var_os("USERPROFILE"),
2826 codewhale_home: env::var_os("CODEWHALE_HOME"),
2827 };
2828 // Safety: test-only environment mutation is serialized by env_lock().
2829 unsafe {
2830 env::set_var("HOME", &home);
2831 env::set_var("USERPROFILE", &userprofile);
2832 env::remove_var("CODEWHALE_HOME");
2833 }
2834
2835 assert_eq!(
2836 codewhale_home().expect("codewhale home"),
2837 home.join(CODEWHALE_APP_DIR)
2838 );
2839 assert_eq!(
2840 legacy_deepseek_home().expect("legacy home"),
2841 home.join(LEGACY_APP_DIR)
2842 );
2843
2844 let explicit = std::env::temp_dir().join(format!(
2845 "codewhale-config-explicit-home-{}",
2846 std::process::id()
2847 ));
2848 // Safety: test-only environment mutation is serialized by env_lock().
2849 unsafe {
2850 env::set_var("CODEWHALE_HOME", &explicit);
2851 }
2852 assert_eq!(codewhale_home().expect("explicit home"), explicit);
2853 }
2854
2855 #[test]
2856 fn relative_codewhale_home_is_a_hard_error() {
2857 let _lock = env_lock();
2858 let _env = StateEnvRestore {
2859 home: env::var_os("HOME"),
2860 userprofile: env::var_os("USERPROFILE"),
2861 codewhale_home: env::var_os("CODEWHALE_HOME"),
2862 };
2863 // Safety: test-only environment mutation is serialized by env_lock().
2864 unsafe {
2865 env::set_var("CODEWHALE_HOME", ".codewhale");
2866 }
2867
2868 let error = codewhale_home().expect_err("relative global home must fail closed");
2869 let message = format!("{error:#}");
2870 assert!(message.contains("CODEWHALE_HOME"), "{message}");
2871 assert!(message.contains("absolute"), "{message}");
2872 }
2873
2874 #[test]
2875 fn migrate_config_reports_copied_legacy_path() {
2876 let _lock = env_lock();
2877 struct LegacyConfigGuard {
2878 path: PathBuf,
2879 original: Option<Vec<u8>>,
2880 }
2881
2882 impl LegacyConfigGuard {
2883 fn install(path: PathBuf, contents: &[u8]) -> Self {
2884 let original = fs::read(&path).ok();
2885 fs::create_dir_all(path.parent().expect("legacy config parent")).expect("legacy dir");
2886 fs::write(&path, contents).expect("legacy config");
2887 Self { path, original }
2888 }
2889 }
2890
2891 impl Drop for LegacyConfigGuard {
2892 fn drop(&mut self) {
2893 if let Some(original) = self.original.take() {
2894 let _ = fs::write(&self.path, original);
2895 } else {
2896 let _ = fs::remove_file(&self.path);
2897 if let Some(parent) = self.path.parent() {
2898 let _ = fs::remove_dir(parent);
2899 }
2900 }
2901 }
2902 }
2903
2904 let unique = std::time::SystemTime::now()
2905 .duration_since(std::time::UNIX_EPOCH)
2906 .expect("clock")
2907 .as_nanos();
2908 let home = std::env::temp_dir().join(format!(
2909 "codewhale-config-migration-{}-{unique}",
2910 std::process::id()
2911 ));
2912 let legacy_dir = home.join(LEGACY_APP_DIR);
2913 let primary_dir = home.join(CODEWHALE_APP_DIR);
2914 let legacy_config = legacy_dir.join(CONFIG_FILE_NAME);
2915 let _legacy = LegacyConfigGuard::install(legacy_config.clone(), b"provider = \"deepseek\"\n");
2916
2917 let _env = StateEnvRestore {
2918 home: env::var_os("HOME"),
2919 userprofile: env::var_os("USERPROFILE"),
2920 codewhale_home: env::var_os("CODEWHALE_HOME"),
2921 };
2922 // Safety: test-only environment mutation is serialized by env_lock().
2923 unsafe {
2924 env::set_var("HOME", &home);
2925 env::set_var("USERPROFILE", &home);
2926 env::remove_var("CODEWHALE_HOME");
2927 }
2928
2929 let migration = migrate_config_if_needed()
2930 .expect("migration")
2931 .expect("legacy config should be copied");
2932
2933 assert_eq!(migration.legacy_path, legacy_config);
2934 assert_eq!(migration.primary_path, primary_dir.join(CONFIG_FILE_NAME));
2935 let notice = migration.user_notice();
2936 assert!(notice.contains(&legacy_dir.join(CONFIG_FILE_NAME).display().to_string()));
2937 assert!(notice.contains(&primary_dir.join(CONFIG_FILE_NAME).display().to_string()));
2938 assert!(notice.contains(".codewhale path for future edits"));
2939 assert!(notice.contains(".deepseek file remains only as a compatibility fallback"));
2940 assert_eq!(
2941 fs::read_to_string(primary_dir.join(CONFIG_FILE_NAME)).expect("primary config"),
2942 "provider = \"deepseek\"\n"
2943 );
2944
2945 let _ = fs::remove_dir_all(home);
2946 }
2947
2948 #[test]
2949 fn explicit_codewhale_home_bypasses_legacy_config_fallback_and_migration() {
2950 let _lock = env_lock();
2951 let unique = std::time::SystemTime::now()
2952 .duration_since(std::time::UNIX_EPOCH)
2953 .expect("clock")
2954 .as_nanos();
2955 let home = std::env::temp_dir().join(format!(
2956 "codewhale-config-explicit-isolation-{}-{unique}",
2957 std::process::id()
2958 ));
2959 let legacy_config = home.join(LEGACY_APP_DIR).join(CONFIG_FILE_NAME);
2960 fs::create_dir_all(legacy_config.parent().expect("legacy config parent")).expect("legacy dir");
2961 fs::write(&legacy_config, b"provider = \"deepseek\"\n").expect("legacy config");
2962
2963 let explicit_home = home.join("isolated-codewhale");
2964 let _env = StateEnvRestore {
2965 home: env::var_os("HOME"),
2966 userprofile: env::var_os("USERPROFILE"),
2967 codewhale_home: env::var_os("CODEWHALE_HOME"),
2968 };
2969 // Safety: test-only environment mutation is serialized by env_lock().
2970 unsafe {
2971 env::set_var("HOME", &home);
2972 env::set_var("USERPROFILE", &home);
2973 env::set_var("CODEWHALE_HOME", &explicit_home);
2974 }
2975
2976 assert_eq!(
2977 default_config_path().expect("default config path"),
2978 explicit_home.join(CONFIG_FILE_NAME),
2979 "explicit CODEWHALE_HOME must not read ambient legacy config"
2980 );
2981 assert!(
2982 migrate_config_if_needed()
2983 .expect("migration check")
2984 .is_none(),
2985 "explicit CODEWHALE_HOME must not migrate ambient legacy config"
2986 );
2987 assert!(
2988 !explicit_home.join(CONFIG_FILE_NAME).exists(),
2989 "legacy config must not be copied into explicit CODEWHALE_HOME"
2990 );
2991
2992 let _ = fs::remove_dir_all(home);
2993 }
2994
2995 // ── ensure_state_dir legacy migration (#3240) ───────────────────────
2996
2997 /// Saves and restores the env vars that the state-resolvers read.
2998 struct StateEnvRestore {
2999 home: Option<OsString>,
3000 userprofile: Option<OsString>,
3001 codewhale_home: Option<OsString>,
3002 }
3003
3004 impl Drop for StateEnvRestore {
3005 fn drop(&mut self) {
3006 // Safety: test-only environment mutation is serialized by env_lock().
3007 unsafe {
3008 match self.home.take() {
3009 Some(value) => env::set_var("HOME", value),
3010 None => env::remove_var("HOME"),
3011 }
3012 match self.userprofile.take() {
3013 Some(value) => env::set_var("USERPROFILE", value),
3014 None => env::remove_var("USERPROFILE"),
3015 }
3016 match self.codewhale_home.take() {
3017 Some(value) => env::set_var("CODEWHALE_HOME", value),
3018 None => env::remove_var("CODEWHALE_HOME"),
3019 }
3020 }
3021 }
3022 }
3023
3024 /// Points `HOME`/`USERPROFILE` at a fresh temp tree and clears
3025 /// `CODEWHALE_HOME` so `codewhale_home()` -> `<home>/.codewhale` and
3026 /// `legacy_deepseek_home()` -> `<home>/.deepseek`. Env is restored on drop.
3027 struct StateDirEnv {
3028 home: PathBuf,
3029 _restore: StateEnvRestore,
3030 }
3031
3032 impl StateDirEnv {
3033 fn install(unique: u128) -> Self {
3034 let home = std::env::temp_dir().join(format!(
3035 "codewhale-state-migration-{}-{unique}",
3036 std::process::id()
3037 ));
3038 let restore = StateEnvRestore {
3039 home: env::var_os("HOME"),
3040 userprofile: env::var_os("USERPROFILE"),
3041 codewhale_home: env::var_os("CODEWHALE_HOME"),
3042 };
3043 // Safety: test-only environment mutation is serialized by env_lock().
3044 unsafe {
3045 env::set_var("HOME", &home);
3046 env::set_var("USERPROFILE", &home);
3047 env::remove_var("CODEWHALE_HOME");
3048 }
3049 Self {
3050 home,
3051 _restore: restore,
3052 }
3053 }
3054 fn legacy(&self, sub: &str) -> PathBuf {
3055 self.home.join(LEGACY_APP_DIR).join(sub)
3056 }
3057 fn primary(&self, sub: &str) -> PathBuf {
3058 self.home.join(CODEWHALE_APP_DIR).join(sub)
3059 }
3060 }
3061
3062 #[test]
3063 fn ensure_state_dir_relocates_legacy_subdir_on_first_write() {
3064 let _lock = env_lock();
3065 let unique = std::time::SystemTime::now()
3066 .duration_since(std::time::UNIX_EPOCH)
3067 .expect("clock")
3068 .as_nanos();
3069 let state_env = StateDirEnv::install(unique);
3070 // Seed a legacy subdir; primary must not exist yet.
3071 fs::create_dir_all(state_env.legacy("slop_ledger")).expect("legacy dir");
3072 fs::write(
3073 state_env.legacy("slop_ledger").join("slop_ledger.json"),
3074 b"legacy",
3075 )
3076 .expect("legacy file");
3077 assert!(!state_env.primary("slop_ledger").exists());
3078
3079 let (dir, migration) =
3080 ensure_state_dir_with_migration("slop_ledger").expect("ensure_state_dir");
3081 assert_eq!(dir, state_env.primary("slop_ledger"));
3082 let migration = migration.expect("legacy migration should be reported");
3083 assert_eq!(migration.kind, StateMigrationKind::Relocated);
3084 assert_eq!(migration.subdir, "slop_ledger");
3085 assert_eq!(migration.legacy_path, state_env.legacy("slop_ledger"));
3086 assert_eq!(migration.primary_path, state_env.primary("slop_ledger"));
3087 // Legacy contents relocated into primary.
3088 assert_eq!(
3089 fs::read_to_string(state_env.primary("slop_ledger").join("slop_ledger.json"))
3090 .expect("migrated file"),
3091 "legacy"
3092 );
3093 // The legacy subdir was relocated (moved), so .deepseek stops growing.
3094 assert!(
3095 !state_env.legacy("slop_ledger").exists(),
3096 "legacy subdir should be removed after relocation"
3097 );
3098 // Idempotent: a second call is a no-op now that primary exists.
3099 let (_, repeated_migration) =
3100 ensure_state_dir_with_migration("slop_ledger").expect("idempotent ensure");
3101 assert!(repeated_migration.is_none());
3102 let _ = fs::remove_dir_all(&state_env.home);
3103 }
3104
3105 #[test]
3106 fn state_migration_notice_explains_preserved_data_and_canonical_root() {
3107 let migration = StateMigration {
3108 subdir: "sessions".to_string(),
3109 legacy_path: PathBuf::from("/home/alice/.deepseek/sessions"),
3110 primary_path: PathBuf::from("/home/alice/.codewhale/sessions"),
3111 kind: StateMigrationKind::Relocated,
3112 };
3113
3114 let notice = migration.user_notice();
3115
3116 assert!(notice.contains("Codewhale migrated legacy state"));
3117 assert!(notice.contains("/home/alice/.deepseek/sessions"));
3118 assert!(notice.contains("/home/alice/.codewhale/sessions"));
3119 assert!(notice.contains("Your data was preserved"));
3120 assert!(notice.contains("Use .codewhale as the canonical state location"));
3121 assert!(notice.contains("remove the legacy .deepseek tree"));
3122 }
3123
3124 #[test]
3125 fn copied_state_migration_notice_says_legacy_copy_remains() {
3126 let migration = StateMigration {
3127 subdir: "catalog".to_string(),
3128 legacy_path: PathBuf::from("/home/alice/.deepseek/catalog"),
3129 primary_path: PathBuf::from("/home/alice/.codewhale/catalog"),
3130 kind: StateMigrationKind::Copied,
3131 };
3132
3133 let notice = migration.user_notice();
3134
3135 assert!(notice.contains("copied"));
3136 assert!(notice.contains("legacy .deepseek copy was left in place"));
3137 }
3138
3139 #[test]
3140 fn ensure_state_dir_writes_to_primary_when_both_exist() {
3141 let _lock = env_lock();
3142 let unique = std::time::SystemTime::now()
3143 .duration_since(std::time::UNIX_EPOCH)
3144 .expect("clock")
3145 .as_nanos();
3146 let state_env = StateDirEnv::install(unique);
3147 // Migrated user: primary already exists; a legacy orphan also remains.
3148 fs::create_dir_all(state_env.primary("sessions")).expect("primary dir");
3149 fs::write(state_env.primary("sessions").join("a.json"), b"primary").expect("primary file");
3150 fs::create_dir_all(state_env.legacy("sessions")).expect("legacy dir");
3151 fs::write(state_env.legacy("sessions").join("old.json"), b"legacy").expect("legacy file");
3152
3153 let (dir, migration) = ensure_state_dir_with_migration("sessions").expect("ensure_state_dir");
3154 assert_eq!(dir, state_env.primary("sessions"));
3155 assert!(
3156 migration.is_none(),
3157 "existing primary must not emit a migration event"
3158 );
3159 // Primary untouched; legacy orphan left as-is (not migrated, not deleted).
3160 assert_eq!(
3161 fs::read_to_string(state_env.primary("sessions").join("a.json")).expect("primary"),
3162 "primary"
3163 );
3164 assert!(
3165 state_env.legacy("sessions").exists(),
3166 "existing legacy orphan must not be deleted when primary exists"
3167 );
3168 let _ = fs::remove_dir_all(&state_env.home);
3169 }
3170
3171 #[test]
3172 fn resolve_state_dir_still_finds_legacy_for_backfill() {
3173 let _lock = env_lock();
3174 let unique = std::time::SystemTime::now()
3175 .duration_since(std::time::UNIX_EPOCH)
3176 .expect("clock")
3177 .as_nanos();
3178 let state_env = StateDirEnv::install(unique);
3179 // Only legacy exists -> read resolver returns legacy (backfill).
3180 fs::create_dir_all(state_env.legacy("catalog")).expect("legacy dir");
3181 assert_eq!(
3182 resolve_state_dir("catalog").expect("resolve"),
3183 state_env.legacy("catalog")
3184 );
3185 // After the primary is created (e.g. via a write), the read resolver
3186 // returns primary — legacy is reachable only while primary is absent.
3187 ensure_state_dir("catalog").expect("ensure");
3188 assert_eq!(
3189 resolve_state_dir("catalog").expect("resolve after migrate"),
3190 state_env.primary("catalog")
3191 );
3192 let _ = fs::remove_dir_all(&state_env.home);
3193 }
3194
3195 #[test]
3196 fn explicit_codewhale_home_bypasses_legacy_state_fallback_and_migration() {
3197 let _lock = env_lock();
3198 let unique = std::time::SystemTime::now()
3199 .duration_since(std::time::UNIX_EPOCH)
3200 .expect("clock")
3201 .as_nanos();
3202 let state_env = StateDirEnv::install(unique);
3203 let explicit_home = state_env.home.join("isolated-codewhale");
3204 // Safety: test-only environment mutation is serialized by env_lock().
3205 unsafe {
3206 env::set_var("CODEWHALE_HOME", &explicit_home);
3207 }
3208 fs::create_dir_all(state_env.legacy("catalog")).expect("legacy dir");
3209 fs::write(state_env.legacy("catalog").join("legacy.json"), b"legacy").expect("legacy file");
3210
3211 let primary = explicit_home.join("catalog");
3212 assert_eq!(
3213 resolve_state_dir("catalog").expect("resolve"),
3214 primary,
3215 "explicit CODEWHALE_HOME must not read ambient legacy state"
3216 );
3217
3218 let ensured = ensure_state_dir("catalog").expect("ensure");
3219 assert_eq!(ensured, primary);
3220 assert!(
3221 state_env.legacy("catalog").join("legacy.json").exists(),
3222 "explicit CODEWHALE_HOME must not migrate ambient legacy state"
3223 );
3224 assert!(
3225 !primary.join("legacy.json").exists(),
3226 "legacy contents must not be copied into an explicit CODEWHALE_HOME"
3227 );
3228 let _ = fs::remove_dir_all(&state_env.home);
3229 }
3230
3231 #[test]
3232 fn state_resolvers_reject_path_traversal_subdirs() {
3233 // Defense against path injection (#3240 hardening): the public state
3234 // resolvers must refuse subdirs that could escape the state root.
3235 for bad in ["..", "../secret", "/etc", "a/../../b"] {
3236 let err = ensure_state_dir(bad)
3237 .err()
3238 .unwrap_or_else(|| panic!("expected {bad:?} to be rejected"));
3239 assert!(
3240 format!("{err:#}").contains("state subdir"),
3241 "expected rejection of {bad:?}, got {err:#}"
3242 );
3243 assert!(
3244 resolve_state_dir(bad).is_err(),
3245 "read resolver must also reject {bad:?}"
3246 );
3247 }
3248 // Safe values are accepted (including the root sentinel ".").
3249 assert!(ensure_safe_state_subdir(".").is_ok());
3250 assert!(ensure_safe_state_subdir("sessions").is_ok());
3251 assert!(ensure_safe_state_subdir("a/b").is_ok());
3252 assert!(ensure_safe_state_subdir("").is_err());
3253 }
3254
3255 #[test]
3256 fn project_state_resolvers_reject_path_traversal_subdirs() {
3257 let dir = tempfile::tempdir().expect("tempdir");
3258 let workspace = dir.path().join("workspace");
3259 fs::create_dir_all(&workspace).expect("workspace");
3260
3261 for bad in ["..", "../secret", "/etc", "a/../../b"] {
3262 let err = resolve_project_state_dir(&workspace, bad)
3263 .err()
3264 .unwrap_or_else(|| panic!("expected {bad:?} to be rejected"));
3265 assert!(
3266 format!("{err:#}").contains("state subdir"),
3267 "expected rejection of {bad:?}, got {err:#}"
3268 );
3269 assert!(
3270 ensure_project_state_dir(&workspace, bad).is_err(),
3271 "write resolver must also reject {bad:?}"
3272 );
3273 }
3274
3275 let canonical_workspace = workspace.canonicalize().expect("canonical workspace");
3276 let safe = resolve_project_state_dir(&workspace, "notes.md")
3277 .expect("safe project state subdir should resolve")
3278 .1;
3279 assert_eq!(
3280 safe,
3281 canonical_workspace.join(LEGACY_APP_DIR).join("notes.md")
3282 );
3283 let created =
3284 ensure_project_state_dir(&workspace, "a/b").expect("safe nested project state dir");
3285 assert_eq!(
3286 created,
3287 canonical_workspace.join(CODEWHALE_APP_DIR).join("a/b")
3288 );
3289 }
3290
3291 #[test]
3292 fn project_state_resolvers_reject_workspace_traversal() {
3293 let dir = tempfile::tempdir().expect("tempdir");
3294 let workspace = dir.path().join("workspace");
3295 fs::create_dir_all(&workspace).expect("workspace");
3296 let bad_workspace = workspace.join("..").join("outside");
3297
3298 let err = resolve_project_state_dir(&bad_workspace, "notes.md")
3299 .expect_err("workspace traversal should fail");
3300 assert!(format!("{err:#}").contains("project workspace path"));
3301 assert!(ensure_project_state_dir(&bad_workspace, "state").is_err());
3302 }
3303
3304 #[test]
3305 fn normalize_config_file_path_rejects_traversal() {
3306 let err = normalize_config_file_path(PathBuf::from("../config.toml"))
3307 .expect_err("traversal path should fail");
3308 assert!(format!("{err:#}").contains("cannot contain '..'"));
3309 }
3310
3311 #[test]
3312 fn config_store_save_revalidates_path_before_parent_creation() {
3313 let dir = tempfile::tempdir().expect("tempdir");
3314 let outside_dir = dir.path().join("outside");
3315 let traversal_path = dir
3316 .path()
3317 .join("allowed")
3318 .join("..")
3319 .join("outside")
3320 .join(CONFIG_FILE_NAME);
3321 let mut store = ConfigStore {
3322 path: traversal_path,
3323 config: ConfigToml::default(),
3324 permissions: PermissionsToml::default(),
3325 original_raw: None,
3326 };
3327
3328 let err = store
3329 .save()
3330 .expect_err("save should reject traversal before creating parents");
3331
3332 assert!(format!("{err:#}").contains("cannot contain '..'"));
3333 assert!(
3334 !outside_dir.exists(),
3335 "save must not create directories from an unvalidated path"
3336 );
3337 }
3338
3339 #[test]
3340 fn resolve_config_path_rejects_relative_env_path_before_cwd_resolution() {
3341 let _lock = env_lock();
3342 struct ConfigPathEnvGuard {
3343 codewhale: Option<OsString>,
3344 deepseek: Option<OsString>,
3345 }
3346 impl Drop for ConfigPathEnvGuard {
3347 fn drop(&mut self) {
3348 // Safety: test-only environment mutation is serialized by env_lock().
3349 unsafe {
3350 match self.codewhale.as_ref() {
3351 Some(value) => env::set_var("CODEWHALE_CONFIG_PATH", value),
3352 None => env::remove_var("CODEWHALE_CONFIG_PATH"),
3353 }
3354 match self.deepseek.as_ref() {
3355 Some(value) => env::set_var("DEEPSEEK_CONFIG_PATH", value),
3356 None => env::remove_var("DEEPSEEK_CONFIG_PATH"),
3357 }
3358 }
3359 }
3360 }
3361 let _guard = ConfigPathEnvGuard {
3362 codewhale: env::var_os("CODEWHALE_CONFIG_PATH"),
3363 deepseek: env::var_os("DEEPSEEK_CONFIG_PATH"),
3364 };
3365
3366 // Safety: test-only environment mutation is serialized by env_lock().
3367 unsafe {
3368 env::set_var("CODEWHALE_CONFIG_PATH", "../config.toml");
3369 env::remove_var("DEEPSEEK_CONFIG_PATH");
3370 }
3371
3372 let err = resolve_config_path(None).expect_err("relative env path should fail");
3373 let message = format!("{err:#}");
3374 assert!(message.contains("CODEWHALE_CONFIG_PATH"), "{message}");
3375 assert!(message.contains("absolute"), "{message}");
3376 }
3377
3378 #[cfg(unix)]
3379 #[test]
3380 fn normalize_config_file_path_rejects_symlink_file() {
3381 let dir = tempfile::tempdir().expect("tempdir");
3382 let target = dir.path().join("target.toml");
3383 let link = dir.path().join(CONFIG_FILE_NAME);
3384 fs::write(&target, "model = \"deepseek-v4-flash\"\n").expect("write target");
3385 std::os::unix::fs::symlink(&target, &link).expect("symlink config");
3386
3387 let err = normalize_config_file_path(link).expect_err("symlink config should fail");
3388 assert!(format!("{err:#}").contains("must not be a symlink"));
3389 }
3390
3391 #[cfg(unix)]
3392 #[test]
3393 fn load_project_config_rejects_symlinked_primary_config() {
3394 let workspace = tempfile::tempdir().expect("workspace tempdir");
3395 let outside = tempfile::tempdir().expect("outside tempdir");
3396 let primary_dir = workspace.path().join(CODEWHALE_APP_DIR);
3397 let legacy_dir = workspace.path().join(LEGACY_APP_DIR);
3398 fs::create_dir_all(&primary_dir).expect("mkdir primary");
3399 fs::create_dir_all(&legacy_dir).expect("mkdir legacy");
3400 let outside_config = outside.path().join(CONFIG_FILE_NAME);
3401 fs::write(&outside_config, "model = \"outside-model\"\n").expect("write outside config");
3402 fs::write(
3403 legacy_dir.join(CONFIG_FILE_NAME),
3404 "model = \"legacy-model\"\n",
3405 )
3406 .expect("write legacy config");
3407 std::os::unix::fs::symlink(&outside_config, primary_dir.join(CONFIG_FILE_NAME))
3408 .expect("symlink project config");
3409
3410 let loaded = load_project_config(workspace.path());
3411
3412 assert!(
3413 loaded.is_none(),
3414 "symlinked primary project config should stop the project overlay"
3415 );
3416 }
3417
3418 #[test]
3419 fn load_project_config_keeps_unknown_provider_names_strict() {
3420 let workspace = tempfile::tempdir().expect("workspace tempdir");
3421 let config_dir = workspace.path().join(CODEWHALE_APP_DIR);
3422 fs::create_dir_all(&config_dir).expect("mkdir project config");
3423 fs::write(
3424 config_dir.join(CONFIG_FILE_NAME),
3425 r#"provider = "acme_zen_gateway"
3426 model = "must-not-apply"
3427
3428 [providers.acme_zen_gateway]
3429 kind = "openai-compatible"
3430 base_url = "https://acme.example/v1"
3431 "#,
3432 )
3433 .expect("write project config");
3434
3435 assert!(
3436 load_project_config(workspace.path()).is_none(),
3437 "project overlays must not gain named-provider authority"
3438 );
3439 // ...and the reason is available, not just the absence.
3440 let (path, reason) = load_project_config_outcome(workspace.path())
3441 .invalid()
3442 .map(|(path, reason)| (path.to_path_buf(), reason.to_string()))
3443 .expect("unknown provider must report why the config was rejected");
3444 assert!(path.ends_with(CONFIG_FILE_NAME), "{path:?}");
3445 assert!(reason.contains("acme_zen_gateway"), "{reason}");
3446 }
3447
3448 #[test]
3449 fn malformed_project_config_is_distinguishable_from_a_missing_one() {
3450 // #4733: the loader returned `None` for both cases. A project config can
3451 // only *tighten* approval/sandbox posture, so reporting a broken file as
3452 // "no project config" silently drops those restrictions and falls back to
3453 // the user's more permissive baseline.
3454 let workspace = tempfile::tempdir().expect("workspace tempdir");
3455 assert!(
3456 matches!(
3457 load_project_config_outcome(workspace.path()),
3458 ProjectConfigOutcome::Missing
3459 ),
3460 "a workspace with no project config must report Missing"
3461 );
3462
3463 let config_dir = workspace.path().join(CODEWHALE_APP_DIR);
3464 fs::create_dir_all(&config_dir).expect("mkdir project config");
3465 fs::write(
3466 config_dir.join(CONFIG_FILE_NAME),
3467 "approval_policy = \"unless-trusted\"\nthis is not valid toml\n",
3468 )
3469 .expect("write malformed project config");
3470
3471 let outcome = load_project_config_outcome(workspace.path());
3472 let (path, reason) = outcome
3473 .invalid()
3474 .expect("a malformed project config must not read as Missing");
3475 assert!(path.ends_with(CONFIG_FILE_NAME), "{path:?}");
3476 assert!(!reason.is_empty(), "the rejection must carry a reason");
3477 // The reason must not leak the file's contents — config files hold
3478 // credentials.
3479 assert!(
3480 !reason.contains("approval_policy"),
3481 "reason must not echo file contents: {reason}"
3482 );
3483
3484 // The lossy convenience wrapper still behaves as before for callers that
3485 // only want the config.
3486 assert!(load_project_config(workspace.path()).is_none());
3487 }
3488
3489 #[test]
3490 fn well_formed_project_config_still_loads() {
3491 let workspace = tempfile::tempdir().expect("workspace tempdir");
3492 let config_dir = workspace.path().join(CODEWHALE_APP_DIR);
3493 fs::create_dir_all(&config_dir).expect("mkdir project config");
3494 fs::write(
3495 config_dir.join(CONFIG_FILE_NAME),
3496 "approval_policy = \"unless-trusted\"\n",
3497 )
3498 .expect("write project config");
3499
3500 let outcome = load_project_config_outcome(workspace.path());
3501 assert!(outcome.invalid().is_none(), "{outcome:?}");
3502 assert_eq!(
3503 outcome
3504 .into_config()
3505 .and_then(|config| config.approval_policy)
3506 .as_deref(),
3507 Some("unless-trusted"),
3508 );
3509 }
3510
3511 #[cfg(unix)]
3512 #[test]
3513 fn load_sibling_permissions_rejects_symlink_file() {
3514 let dir = tempfile::tempdir().expect("tempdir");
3515 let config_path = dir.path().join(CONFIG_FILE_NAME);
3516 let outside = dir.path().join("outside-permissions.toml");
3517 let permissions_link = dir.path().join(PERMISSIONS_FILE_NAME);
3518 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
3519 fs::write(&outside, "").expect("write outside permissions");
3520 std::os::unix::fs::symlink(&outside, &permissions_link).expect("symlink permissions");
3521
3522 let err = load_sibling_permissions(&config_path).expect_err("symlink permissions should fail");
3523 assert!(format!("{err:#}").contains("must not be a symlink"));
3524 }
3525
3526 #[cfg(unix)]
3527 #[test]
3528 fn append_ask_rules_rejects_symlinked_permissions_file() {
3529 let dir = tempfile::tempdir().expect("tempdir");
3530 let config_path = dir.path().join(CONFIG_FILE_NAME);
3531 let outside = dir.path().join("outside-permissions.toml");
3532 let permissions_link = dir.path().join(PERMISSIONS_FILE_NAME);
3533 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
3534 fs::write(&outside, "").expect("write outside permissions");
3535 let mut store = ConfigStore::load(Some(config_path)).expect("load store before link");
3536 std::os::unix::fs::symlink(&outside, &permissions_link).expect("symlink permissions");
3537
3538 let err = store
3539 .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")])
3540 .expect_err("symlink permissions should fail");
3541
3542 assert!(format!("{err:#}").contains("must not be a symlink"));
3543 assert_eq!(
3544 fs::read_to_string(&outside).expect("read outside permissions"),
3545 ""
3546 );
3547 }
3548
3549 #[cfg(unix)]
3550 #[test]
3551 fn remove_permission_rule_rejects_symlinked_permissions_file() {
3552 let dir = tempfile::tempdir().expect("tempdir");
3553 let config_path = dir.path().join(CONFIG_FILE_NAME);
3554 let outside = dir.path().join("outside-permissions.toml");
3555 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
3556 fs::write(
3557 &permissions_path,
3558 "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo test\"\n",
3559 )
3560 .expect("write permissions");
3561 let snapshot =
3562 load_permissions_snapshot(Some(config_path.clone())).expect("load permission snapshot");
3563 let token = snapshot
3564 .removal_token(0)
3565 .expect("first rule token")
3566 .to_string();
3567 fs::rename(&permissions_path, &outside).expect("move permissions outside");
3568 std::os::unix::fs::symlink(&outside, &permissions_path).expect("symlink permissions");
3569
3570 let err = remove_permission_rule(Some(config_path), 0, &token)
3571 .expect_err("symlink permissions should fail");
3572
3573 assert!(format!("{err:#}").contains("must not be a symlink"));
3574 assert!(
3575 fs::read_to_string(&outside)
3576 .expect("read outside permissions")
3577 .contains("cargo test")
3578 );
3579 }
3580
3581 #[cfg(unix)]
3582 #[test]
3583 fn write_config_backup_rejects_symlink_file() {
3584 let dir = tempfile::tempdir().expect("tempdir");
3585 let config_path = dir.path().join(CONFIG_FILE_NAME);
3586 let outside = dir.path().join("outside-backup.toml");
3587 let backup_link = config_backup_path(&config_path);
3588 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
3589 fs::write(&outside, "").expect("write outside backup");
3590 std::os::unix::fs::symlink(&outside, &backup_link).expect("symlink backup");
3591
3592 let err = write_one_time_config_backup(&config_path).expect_err("symlink backup should fail");
3593 assert!(format!("{err:#}").contains("must not be a symlink"));
3594 }
3595
3596 #[cfg(unix)]
3597 #[test]
3598 fn save_clamps_existing_config_permissions() {
3599 use std::time::{SystemTime, UNIX_EPOCH};
3600
3601 let unique = SystemTime::now()
3602 .duration_since(UNIX_EPOCH)
3603 .expect("clock")
3604 .as_nanos();
3605 let dir = std::env::temp_dir().join(format!(
3606 "deepseek-config-perms-{}-{unique}",
3607 std::process::id()
3608 ));
3609 fs::create_dir_all(&dir).expect("mkdir");
3610 let path = dir.join(CONFIG_FILE_NAME);
3611 fs::write(&path, "api_key = \"old\"\n").expect("seed config");
3612 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod seed");
3613
3614 let mut store = ConfigStore {
3615 path: path.clone(),
3616 config: ConfigToml {
3617 api_key: Some("new-secret".to_string()),
3618 ..ConfigToml::default()
3619 },
3620 permissions: PermissionsToml::default(),
3621 original_raw: Some("api_key = \"old\"\n".to_string()),
3622 };
3623 store.save().expect("save");
3624
3625 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
3626 assert_eq!(mode, 0o600);
3627
3628 let _ = fs::remove_dir_all(dir);
3629 }
3630
3631 #[test]
3632 fn config_store_save_skips_identical_serialized_body() {
3633 use std::time::{SystemTime, UNIX_EPOCH};
3634
3635 let unique = SystemTime::now()
3636 .duration_since(UNIX_EPOCH)
3637 .expect("clock")
3638 .as_nanos();
3639 let dir = std::env::temp_dir().join(format!(
3640 "codewhale-config-noop-save-{}-{unique}",
3641 std::process::id()
3642 ));
3643 fs::create_dir_all(&dir).expect("mkdir");
3644 let path = dir.join(CONFIG_FILE_NAME);
3645 let config = ConfigToml {
3646 model: Some("deepseek-v4-flash".to_string()),
3647 ..ConfigToml::default()
3648 };
3649 let body = toml::to_string_pretty(&config).expect("serialize");
3650 fs::write(&path, &body).expect("seed config");
3651 #[cfg(unix)]
3652 fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("chmod seed");
3653
3654 let mut store = ConfigStore {
3655 path: path.clone(),
3656 config,
3657 permissions: PermissionsToml::default(),
3658 original_raw: Some(body.clone()),
3659 };
3660 store.save().expect("identical save should not rewrite");
3661
3662 #[cfg(unix)]
3663 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("chmod restore");
3664 assert_eq!(fs::read_to_string(&path).expect("read config"), body);
3665 assert!(
3666 !config_backup_path(&path).exists(),
3667 "no-op save must not create a migration backup"
3668 );
3669
3670 let _ = fs::remove_dir_all(dir);
3671 }
3672
3673 #[test]
3674 fn config_store_save_creates_one_time_backup_before_changed_write() {
3675 use std::time::{SystemTime, UNIX_EPOCH};
3676
3677 let unique = SystemTime::now()
3678 .duration_since(UNIX_EPOCH)
3679 .expect("clock")
3680 .as_nanos();
3681 let dir = std::env::temp_dir().join(format!(
3682 "codewhale-config-backup-save-{}-{unique}",
3683 std::process::id()
3684 ));
3685 fs::create_dir_all(&dir).expect("mkdir");
3686 let path = dir.join(CONFIG_FILE_NAME);
3687 let original = "model = \"deepseek-v4-flash\"\n";
3688 fs::write(&path, original).expect("seed config");
3689
3690 let mut store = ConfigStore {
3691 path: path.clone(),
3692 config: ConfigToml {
3693 model: Some("deepseek-v4-pro".to_string()),
3694 ..ConfigToml::default()
3695 },
3696 permissions: PermissionsToml::default(),
3697 original_raw: Some(original.to_string()),
3698 };
3699 store.save().expect("changed save");
3700
3701 let backup_path = config_backup_path(&path);
3702 assert_eq!(
3703 fs::read_to_string(&backup_path).expect("read backup"),
3704 original
3705 );
3706 let updated = fs::read_to_string(&path).expect("read updated config");
3707 assert!(updated.contains("model = \"deepseek-v4-pro\""));
3708
3709 let _ = fs::remove_dir_all(dir);
3710 }
3711
3712 #[test]
3713 fn config_backup_strips_plaintext_api_keys_but_preserves_non_secret_auth_metadata() {
3714 let dir = tempfile::tempdir().expect("tempdir");
3715 let path = dir.path().join(CONFIG_FILE_NAME);
3716 let original = r#"api_key = "root-test-credential"
3717 default_text_model = "deepseek-v4-pro"
3718
3719 [providers.openrouter]
3720 api_key = "provider-test-credential"
3721 api_key_env = "OPENROUTER_API_KEY"
3722 auth_mode = "api_key"
3723 "#;
3724 fs::write(&path, original).expect("seed config");
3725
3726 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
3727 store.config.default_text_model = Some("deepseek-v4-flash".to_string());
3728 store.save().expect("changed save");
3729
3730 let backup = fs::read_to_string(config_backup_path(&path)).expect("read backup");
3731 assert!(!backup.contains("root-test-credential"), "{backup}");
3732 assert!(!backup.contains("provider-test-credential"), "{backup}");
3733 assert!(
3734 !backup
3735 .lines()
3736 .any(|line| line.trim_start().starts_with("api_key ="))
3737 );
3738 assert!(backup.contains("api_key_env = \"OPENROUTER_API_KEY\""));
3739 assert!(backup.contains("auth_mode = \"api_key\""));
3740 assert!(backup.contains("default_text_model = \"deepseek-v4-pro\""));
3741 }
3742
3743 #[test]
3744 fn config_backup_scrub_repairs_an_existing_plaintext_backup() {
3745 let dir = tempfile::tempdir().expect("tempdir");
3746 let path = dir.path().join(CONFIG_FILE_NAME);
3747 fs::write(&path, "model = \"new-model\"\n").expect("seed config");
3748 let backup_path = config_backup_path(&path);
3749 fs::write(
3750 &backup_path,
3751 "api_key = \"old-test-credential\"\nmodel = \"old-model\"\n",
3752 )
3753 .expect("seed backup");
3754
3755 scrub_plaintext_api_keys_from_config_backup(&path).expect("scrub backup");
3756
3757 let backup = fs::read_to_string(backup_path).expect("read backup");
3758 assert!(!backup.contains("old-test-credential"), "{backup}");
3759 assert!(!backup.contains("api_key"), "{backup}");
3760 assert!(backup.contains("model = \"old-model\""));
3761 }
3762
3763 #[test]
3764 fn config_store_save_preserves_comments() {
3765 let dir = tempfile::tempdir().expect("tempdir");
3766 let config_path = dir.path().join(CONFIG_FILE_NAME);
3767 let original = "# my model\nmodel = \"deepseek-v4-flash\"\n# end comment\n";
3768 fs::write(&config_path, original).expect("write config");
3769
3770 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
3771 store.config.model = Some("deepseek-v4-pro".to_string());
3772 store.save().expect("save");
3773
3774 let body = fs::read_to_string(&config_path).expect("read config");
3775 assert!(body.contains("# my model"), "prefix comment preserved");
3776 assert!(body.contains("# end comment"), "suffix comment preserved");
3777 assert!(body.contains("model = \"deepseek-v4-pro\""));
3778 }
3779
3780 #[test]
3781 fn config_store_save_preserves_disabled_keys() {
3782 let dir = tempfile::tempdir().expect("tempdir");
3783 let config_path = dir.path().join(CONFIG_FILE_NAME);
3784 fs::write(
3785 &config_path,
3786 "# my note\nmodel = \"deepseek-v4-flash\"\n# base_url = \"http://localhost:11434/v1\"\n",
3787 )
3788 .expect("write config");
3789
3790 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
3791 store.config.model = Some("deepseek-v4-pro".to_string());
3792 store.save().expect("save");
3793
3794 let body = fs::read_to_string(&config_path).expect("read config");
3795 assert!(
3796 body.contains("# base_url = \"http://localhost:11434/v1\""),
3797 "disabled key preserved as comment"
3798 );
3799 assert!(body.contains("model = \"deepseek-v4-pro\""));
3800 }
3801
3802 #[test]
3803 fn config_store_save_preserves_comments_with_other_keys() {
3804 // Realistic scenario: user already has api_key + model, adds a comment,
3805 // then changes model via `codewhale config set model`.
3806 let dir = tempfile::tempdir().expect("tempdir");
3807 let config_path = dir.path().join(CONFIG_FILE_NAME);
3808 fs::write(
3809 &config_path,
3810 "# my deepseek key\napi_key = \"sk-1234\"\n\n# my current model\nmodel = \"deepseek-v4-flash\"\n",
3811 )
3812 .expect("write config");
3813
3814 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
3815 store.config.model = Some("deepseek-v4-pro".to_string());
3816 store.save().expect("save");
3817
3818 let body = fs::read_to_string(&config_path).expect("read config");
3819 assert!(body.contains("# my deepseek key"), "api_key comment lost");
3820 assert!(body.contains("# my current model"), "model comment lost");
3821 assert!(
3822 body.contains("model = \"deepseek-v4-pro\""),
3823 "new model not written"
3824 );
3825 assert!(body.contains("api_key = \"sk-1234\""), "api_key lost");
3826 }
3827
3828 #[test]
3829 fn setup_transaction_applies_config_store_body_preserving_comments() {
3830 // #3410: the comment-preserving ConfigStore write must compose with
3831 // SetupTransaction so a setup step can update config.toml atomically
3832 // alongside sibling setup files.
3833 let dir = tempfile::tempdir().expect("tempdir");
3834 let config_path = dir.path().join(CONFIG_FILE_NAME);
3835 let state_path = dir.path().join(crate::setup_state::SETUP_STATE_FILE_NAME);
3836 fs::write(
3837 &config_path,
3838 "# my model\nmodel = \"deepseek-v4-flash\"\n# end comment\n",
3839 )
3840 .expect("write config");
3841
3842 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
3843 store.config.model = Some("deepseek-v4-pro".to_string());
3844
3845 let mut transaction = persistence::SetupTransaction::new();
3846 transaction.stage(
3847 &config_path,
3848 store.rendered_body().expect("rendered body").into_bytes(),
3849 );
3850 transaction
3851 .stage_json(&state_path, &SetupState::default())
3852 .expect("stage setup state");
3853 transaction.commit().expect("commit");
3854
3855 let body = fs::read_to_string(&config_path).expect("read config");
3856 assert!(body.contains("# my model"), "prefix comment preserved");
3857 assert!(body.contains("# end comment"), "suffix comment preserved");
3858 assert!(body.contains("model = \"deepseek-v4-pro\""));
3859 assert!(state_path.exists(), "sibling setup state written");
3860 }
3861
3862 #[test]
3863 fn setup_transaction_rolls_back_config_store_body_on_sibling_failure() {
3864 // #3410 rollback expectation: when a sibling stage fails to apply, the
3865 // already-written config.toml is restored byte-for-byte, comments and
3866 // all — no half-applied setup.
3867 let dir = tempfile::tempdir().expect("tempdir");
3868 let config_path = dir.path().join(CONFIG_FILE_NAME);
3869 let original = "# my model\nmodel = \"deepseek-v4-flash\"\n# end comment\n";
3870 fs::write(&config_path, original).expect("write config");
3871 // A parent that is a regular file makes the second stage unwritable.
3872 let blocker = dir.path().join("blocker");
3873 fs::write(&blocker, b"file, not a directory").expect("write blocker");
3874
3875 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
3876 store.config.model = Some("deepseek-v4-pro".to_string());
3877
3878 let mut transaction = persistence::SetupTransaction::new();
3879 transaction.stage(
3880 &config_path,
3881 store.rendered_body().expect("rendered body").into_bytes(),
3882 );
3883 transaction.stage(blocker.join("nested.json"), b"{}".to_vec());
3884 transaction
3885 .commit()
3886 .expect_err("commit must fail on unwritable sibling");
3887
3888 let body = fs::read_to_string(&config_path).expect("read config");
3889 assert_eq!(body, original, "config restored byte-for-byte on rollback");
3890 }
3891
3892 #[test]
3893 fn config_store_load_fails_on_malformed_config_without_touching_file() {
3894 // #3410 malformed-config posture: repair is explicit, never implicit.
3895 // Loading a malformed config surfaces a parse error naming the path and
3896 // leaves the file bytes untouched for the user (or doctor) to repair.
3897 let dir = tempfile::tempdir().expect("tempdir");
3898 let config_path = dir.path().join(CONFIG_FILE_NAME);
3899 let secret = "cw-secret-store-load-4507";
3900 let malformed =
3901 format!("# half-edited config\n[providers.xai]\napi_key = \"{secret}\" trailing-junk\n");
3902 fs::write(&config_path, &malformed).expect("write config");
3903
3904 let err = ConfigStore::load(Some(config_path.clone())).expect_err("malformed must not parse");
3905 let diagnostic = format!("{err:#}");
3906
3907 assert!(
3908 diagnostic.contains("failed to parse config"),
3909 "error should name the parse failure: {err:#}"
3910 );
3911 assert!(!diagnostic.contains(secret), "{diagnostic}");
3912 assert!(!diagnostic.contains("api_key"), "{diagnostic}");
3913 assert!(diagnostic.contains("file contents were omitted"));
3914 let body = fs::read_to_string(&config_path).expect("read config");
3915 assert_eq!(body, malformed, "malformed config left untouched");
3916 }
3917
3918 #[test]
3919 fn config_store_rendered_body_preserves_comments_at_legacy_deepseek_path() {
3920 // #3410 legacy case: a config still living under `.deepseek/` keeps its
3921 // comments when written back through a transaction at the same path.
3922 let dir = tempfile::tempdir().expect("tempdir");
3923 let legacy_dir = dir.path().join(".deepseek");
3924 fs::create_dir_all(&legacy_dir).expect("legacy dir");
3925 let config_path = legacy_dir.join(CONFIG_FILE_NAME);
3926 fs::write(
3927 &config_path,
3928 "# legacy home config\nmodel = \"deepseek-v4-flash\"\n",
3929 )
3930 .expect("write config");
3931
3932 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
3933 store.config.model = Some("deepseek-v4-pro".to_string());
3934
3935 let mut transaction = persistence::SetupTransaction::new();
3936 transaction.stage(
3937 &config_path,
3938 store.rendered_body().expect("rendered body").into_bytes(),
3939 );
3940 transaction.commit().expect("commit");
3941
3942 let body = fs::read_to_string(&config_path).expect("read config");
3943 assert!(body.contains("# legacy home config"), "comment preserved");
3944 assert!(body.contains("model = \"deepseek-v4-pro\""));
3945 }
3946
3947 #[test]
3948 fn merge_and_preserve_comments_returns_err_on_invalid_serialized() {
3949 let secret = "sentinel";
3950 let err = merge_and_preserve_comments(
3951 &format!("api_key = \"{secret}\" trailing-junk\n"),
3952 "model = 1\n",
3953 )
3954 .expect_err("invalid serialized should fail");
3955 let diagnostic = format!("{err:#}");
3956 assert!(
3957 diagnostic.contains("failed to parse serialized"),
3958 "unexpected error: {err:#}"
3959 );
3960 assert!(!diagnostic.contains(secret), "{diagnostic}");
3961 assert!(!diagnostic.contains("api_key"), "{diagnostic}");
3962 }
3963
3964 #[test]
3965 fn merge_and_preserve_comments_returns_err_on_invalid_original() {
3966 let secret = "cw-secret-original-4507";
3967 let err = merge_and_preserve_comments(
3968 "model = 1\n",
3969 &format!("api_key = \"{secret}\" trailing-junk\n"),
3970 )
3971 .expect_err("invalid original should fail");
3972 let diagnostic = format!("{err:#}");
3973 assert!(
3974 diagnostic.contains("failed to parse original"),
3975 "unexpected error: {err:#}"
3976 );
3977 assert!(!diagnostic.contains(secret), "{diagnostic}");
3978 assert!(!diagnostic.contains("api_key"), "{diagnostic}");
3979 }
3980
3981 #[test]
3982 fn config_store_save_rejects_a_stale_or_corrupt_original_snapshot() {
3983 let dir = tempfile::tempdir().expect("tempdir");
3984 let config_path = dir.path().join(CONFIG_FILE_NAME);
3985 // A full typed writer must never overwrite bytes that differ from its
3986 // original snapshot, even when that snapshot is corrupt.
3987 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
3988
3989 // Bypass ConfigStore::load to inject a deliberately broken original_raw.
3990 let mut store = ConfigStore {
3991 path: config_path.clone(),
3992 config: ConfigToml {
3993 model: Some("deepseek-v4-pro".to_string()),
3994 ..ConfigToml::default()
3995 },
3996 permissions: PermissionsToml::default(),
3997 original_raw: Some("{ broken".to_string()),
3998 };
3999 let error = store
4000 .save()
4001 .expect_err("stale original bytes must fail instead of overwriting");
4002 assert!(
4003 error.to_string().contains("reload") && error.to_string().contains("retry"),
4004 "{error:#}"
4005 );
4006
4007 let body = fs::read_to_string(&config_path).expect("read config");
4008 assert_eq!(body, "model = \"deepseek-v4-flash\"\n");
4009 }
4010
4011 #[test]
4012 fn sequential_config_stores_cannot_resurrect_a_concurrent_change() {
4013 let dir = tempfile::tempdir().expect("tempdir");
4014 let config_path = dir.path().join(CONFIG_FILE_NAME);
4015 fs::write(
4016 &config_path,
4017 "model = \"before\"\n[providers.xai.external_credentials]\naccess = \"read_only\"\nprovider = \"xai\"\nsource = \"grok_cli\"\npath = \"/external/auth.json\"\nconsent_version = 1\n",
4018 )
4019 .expect("seed config");
4020 let mut stale = ConfigStore::load(Some(config_path.clone())).expect("load stale store");
4021
4022 mutate_config_document(&config_path, |document| {
4023 unset_config_document_value(document, &["providers", "xai", "external_credentials"])?;
4024 set_config_document_value(document, &["tui", "low_motion"], true)
4025 })
4026 .expect("concurrent targeted update");
4027 stale.config.model = Some("stale-writer".to_string());
4028 let error = stale
4029 .save()
4030 .expect_err("stale typed snapshot must not overwrite revocation");
4031 assert!(error.to_string().contains("config changed"), "{error:#}");
4032
4033 let saved = fs::read_to_string(config_path).expect("read final config");
4034 assert!(!saved.contains("external_credentials"), "{saved}");
4035 assert!(saved.contains("low_motion = true"), "{saved}");
4036 assert!(!saved.contains("stale-writer"), "{saved}");
4037 }
4038
4039 #[test]
4040 fn provider_kind_parses_openrouter_and_novita_aliases() {
4041 assert_eq!(
4042 ProviderKind::parse("openrouter"),
4043 Some(ProviderKind::Openrouter)
4044 );
4045 assert_eq!(
4046 ProviderKind::parse("OPEN_ROUTER"),
4047 Some(ProviderKind::Openrouter)
4048 );
4049 assert_eq!(
4050 ProviderKind::parse("xiaomi-mimo"),
4051 Some(ProviderKind::XiaomiMimo)
4052 );
4053 assert_eq!(
4054 ProviderKind::parse("xiaomi"),
4055 Some(ProviderKind::XiaomiMimo)
4056 );
4057 assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita));
4058 assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita));
4059 assert_eq!(
4060 ProviderKind::parse("fireworks-ai"),
4061 Some(ProviderKind::Fireworks)
4062 );
4063 assert_eq!(
4064 ProviderKind::parse("silicon-flow"),
4065 Some(ProviderKind::Siliconflow)
4066 );
4067 assert_eq!(
4068 ProviderKind::parse("silicon_flow"),
4069 Some(ProviderKind::Siliconflow)
4070 );
4071 assert_eq!(ProviderKind::parse("kimi"), Some(ProviderKind::Moonshot));
4072 assert_eq!(
4073 ProviderKind::parse("moonshot-ai"),
4074 Some(ProviderKind::Moonshot)
4075 );
4076 assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang));
4077 assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm));
4078 assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm));
4079 assert_eq!(ProviderKind::parse("ollama"), Some(ProviderKind::Ollama));
4080 assert_eq!(
4081 ProviderKind::parse("ollama-local"),
4082 Some(ProviderKind::Ollama)
4083 );
4084 assert_eq!(
4085 ProviderKind::parse("wanjie-ark"),
4086 Some(ProviderKind::WanjieArk)
4087 );
4088 assert_eq!(
4089 ProviderKind::parse("ark_wanjie"),
4090 Some(ProviderKind::WanjieArk)
4091 );
4092 for alias in ["huggingface", "hugging-face", "hugging_face", "hf"] {
4093 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Huggingface));
4094
4095 let parsed: ConfigToml =
4096 toml::from_str(&format!("provider = \"{alias}\"")).expect("huggingface alias");
4097 assert_eq!(parsed.provider, ProviderKind::Huggingface);
4098 }
4099
4100 for alias in ["deepinfra", "deep-infra", "deep_infra"] {
4101 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepinfra));
4102
4103 let parsed: ConfigToml =
4104 toml::from_str(&format!("provider = \"{alias}\"")).expect("deepinfra alias");
4105 assert_eq!(parsed.provider, ProviderKind::Deepinfra);
4106 }
4107
4108 for alias in ["sakana", "sakana-ai", "sakana_ai", "fugu"] {
4109 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Sakana));
4110
4111 let parsed: ConfigToml =
4112 toml::from_str(&format!("provider = \"{alias}\"")).expect("sakana alias");
4113 assert_eq!(parsed.provider, ProviderKind::Sakana);
4114 }
4115
4116 for alias in ["qianfan", "baidu-qianfan", "baidu_qianfan", "baidu"] {
4117 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Qianfan));
4118
4119 let parsed: ConfigToml =
4120 toml::from_str(&format!("provider = \"{alias}\"")).expect("qianfan alias");
4121 assert_eq!(parsed.provider, ProviderKind::Qianfan);
4122 }
4123
4124 let parsed: ConfigToml =
4125 toml::from_str("provider = \"ark-wanjie\"").expect("wanjie provider alias");
4126 assert_eq!(parsed.provider, ProviderKind::WanjieArk);
4127
4128 let parsed: ConfigToml =
4129 toml::from_str("provider = \"silicon-flow\"").expect("siliconflow provider alias");
4130 assert_eq!(parsed.provider, ProviderKind::Siliconflow);
4131 }
4132
4133 /// Models.dev publishes provider ids that do not always match CodeWhale's
4134 /// canonical id (`fireworks-ai`, `togetherai`, `novita-ai`, `moonshotai`).
4135 /// These MUST normalize onto the right [`ProviderKind`] via
4136 /// [`ProviderKind::parse`], which is the seam `ModelReferenceCard::from_offering`
4137 /// uses to label a live-catalog row's provider kind. A miss here means
4138 /// Fireworks/Together/Novita/Moonshot models from the live Models.dev catalog
4139 /// land under an `unknown` kind (Refs #4186).
4140 #[test]
4141 fn provider_kind_normalizes_models_dev_provider_ids() {
4142 let cases = [
4143 ("fireworks-ai", ProviderKind::Fireworks),
4144 ("togetherai", ProviderKind::Together),
4145 ("together-ai", ProviderKind::Together),
4146 ("together_ai", ProviderKind::Together),
4147 ("novita-ai", ProviderKind::Novita),
4148 ("novita_ai", ProviderKind::Novita),
4149 // Live Models.dev key for Moonshot/Kimi (verified 2026-07-08).
4150 ("moonshotai", ProviderKind::Moonshot),
4151 ("moonshot-ai", ProviderKind::Moonshot),
4152 ("moonshot_ai", ProviderKind::Moonshot),
4153 ("nvidia", ProviderKind::NvidiaNim),
4154 ("xiaomi", ProviderKind::XiaomiMimo),
4155 ("deepinfra", ProviderKind::Deepinfra),
4156 ("siliconflow", ProviderKind::Siliconflow),
4157 // Models.dev spells the China endpoint `siliconflow-cn`; CodeWhale's
4158 // canonical id is `siliconflow-CN` and `parse` is case-insensitive.
4159 ("siliconflow-cn", ProviderKind::SiliconflowCN),
4160 ("openrouter", ProviderKind::Openrouter),
4161 ("longcat", ProviderKind::LongCat),
4162 ("xai", ProviderKind::Xai),
4163 ("x-ai", ProviderKind::Xai),
4164 ("x_ai", ProviderKind::Xai),
4165 ("grok", ProviderKind::Xai),
4166 ];
4167 for (models_dev_id, expected) in cases {
4168 assert_eq!(
4169 ProviderKind::parse(models_dev_id),
4170 Some(expected),
4171 "Models.dev id {models_dev_id:?} must normalize onto {expected:?}"
4172 );
4173 }
4174
4175 // The separator-free Models.dev ids must also deserialize from config TOML,
4176 // so a `provider = "togetherai"` / `"novita-ai"` / `"moonshotai"` line
4177 // resolves identically.
4178 for (alias, expected) in [
4179 ("togetherai", ProviderKind::Together),
4180 ("novita-ai", ProviderKind::Novita),
4181 ("fireworks-ai", ProviderKind::Fireworks),
4182 ("moonshotai", ProviderKind::Moonshot),
4183 ("grok", ProviderKind::Xai),
4184 ] {
4185 let parsed: ConfigToml =
4186 toml::from_str(&format!("provider = \"{alias}\"")).expect("models.dev id alias");
4187 assert_eq!(parsed.provider, expected, "toml provider = {alias:?}");
4188 }
4189 }
4190
4191 /// Pin the Fireworks and Together transport metadata against the real provider
4192 /// APIs: the OpenAI-compatible base URL and the canonical API-key env var. These
4193 /// are the two primary providers this audit targets, so a regression to a wrong
4194 /// base URL or env var name fails here.
4195 #[test]
4196 fn fireworks_and_together_base_url_and_auth_metadata() {
4197 let fireworks = provider::provider_for_kind(ProviderKind::Fireworks);
4198 assert_eq!(fireworks.id(), "fireworks");
4199 assert_eq!(
4200 fireworks.default_base_url(),
4201 "https://api.fireworks.ai/inference/v1"
4202 );
4203 assert_eq!(fireworks.default_base_url(), DEFAULT_FIREWORKS_BASE_URL);
4204 assert_eq!(fireworks.env_vars(), &["FIREWORKS_API_KEY"]);
4205 // Fireworks wire model ids are namespaced `accounts/fireworks/models/<name>`.
4206 assert!(
4207 fireworks
4208 .default_model()
4209 .starts_with("accounts/fireworks/models/"),
4210 "Fireworks default model must use the accounts/fireworks/models/ prefix, got {:?}",
4211 fireworks.default_model()
4212 );
4213
4214 let together = provider::provider_for_kind(ProviderKind::Together);
4215 assert_eq!(together.id(), "together");
4216 assert_eq!(together.default_base_url(), "https://api.together.xyz/v1");
4217 assert_eq!(together.default_base_url(), DEFAULT_TOGETHER_BASE_URL);
4218 assert_eq!(together.env_vars(), &["TOGETHER_API_KEY"]);
4219 // Together wire model ids are `<org>/<Model>` (a slash-namespaced id).
4220 assert!(
4221 together.default_model().contains('/'),
4222 "Together default model must be an <org>/<Model> id, got {:?}",
4223 together.default_model()
4224 );
4225
4226 // The env-based key resolver (secrets crate) must recognize both providers
4227 // by canonical id; a shell-exported key would otherwise be ignored.
4228 let _lock = env_lock();
4229 unsafe {
4230 std::env::set_var("FIREWORKS_API_KEY", "fw-test-key");
4231 std::env::set_var("TOGETHER_API_KEY", "tg-test-key");
4232 }
4233 assert_eq!(
4234 codewhale_secrets::env_for("fireworks").as_deref(),
4235 Some("fw-test-key")
4236 );
4237 assert_eq!(
4238 codewhale_secrets::env_for("together").as_deref(),
4239 Some("tg-test-key")
4240 );
4241 unsafe {
4242 std::env::remove_var("FIREWORKS_API_KEY");
4243 std::env::remove_var("TOGETHER_API_KEY");
4244 }
4245 }
4246
4247 #[test]
4248 fn unknown_provider_error_lists_huggingface() {
4249 let mut config = ConfigToml::default();
4250 let err = config
4251 .set_value("provider", "not-a-provider")
4252 .expect_err("unknown provider should fail");
4253 let message = err.to_string();
4254 assert!(message.contains("unknown provider 'not-a-provider'"));
4255 assert!(message.contains("huggingface"));
4256 }
4257
4258 #[test]
4259 fn config_store_preserves_named_custom_provider_identity_across_typed_dispatch_reads() {
4260 let _lock = env_lock();
4261 let _env = EnvGuard::without_deepseek_runtime_overrides();
4262 let dir = tempfile::tempdir().expect("tempdir");
4263 let path = dir.path().join("config.toml");
4264 fs::write(
4265 &path,
4266 r#"# written by the TUI custom-provider flow
4267 provider = "acme_zen_gateway"
4268
4269 [providers.acme_zen_gateway]
4270 kind = "openai-compatible"
4271 base_url = "https://acme.example/v1"
4272 model = "deepseek-v4-flash-free"
4273 api_key_env = "ACME_ZEN_GATEWAY_API_KEY"
4274 "#,
4275 )
4276 .expect("custom provider fixture");
4277
4278 let mut store = ConfigStore::load(Some(path.clone())).expect("dispatcher config should load");
4279 assert_eq!(store.config.provider, ProviderKind::Custom);
4280 assert_eq!(store.config.provider_id(), "acme_zen_gateway");
4281 assert_eq!(
4282 store.config.get_value("provider").as_deref(),
4283 Some("acme_zen_gateway")
4284 );
4285 assert_eq!(
4286 store
4287 .config
4288 .list_values()
4289 .get("provider")
4290 .map(String::as_str),
4291 Some("acme_zen_gateway")
4292 );
4293
4294 let resolved = store
4295 .config
4296 .resolve_runtime_options(&CliRuntimeOverrides::default());
4297 assert_eq!(resolved.provider, ProviderKind::Custom);
4298 assert_eq!(resolved.provider_source, ProviderSource::Config);
4299 assert_eq!(resolved.base_url, "https://acme.example/v1");
4300 assert_eq!(resolved.model, "deepseek-v4-flash-free");
4301
4302 store
4303 .config
4304 .set_value("telemetry", "false")
4305 .expect("unrelated typed mutation");
4306 let rendered = store
4307 .rendered_body()
4308 .expect("render custom provider config");
4309 assert!(
4310 rendered.contains("provider = \"acme_zen_gateway\""),
4311 "{rendered}"
4312 );
4313 assert!(!rendered.contains("provider = \"custom\""), "{rendered}");
4314 assert!(!rendered.contains("[providers.custom]"), "{rendered}");
4315
4316 store.save().expect("save custom provider config");
4317 let reloaded = ConfigStore::load(Some(path)).expect("reload custom provider config");
4318 assert_eq!(reloaded.config.provider_id(), "acme_zen_gateway");
4319 }
4320
4321 #[test]
4322 fn named_custom_root_provider_requires_a_matching_openai_compatible_table() {
4323 for body in [
4324 "provider = \"acme_zen_gateway\"\n",
4325 r#"provider = "acme_zen_gateway"
4326
4327 [providers.acme_zen_gateway]
4328 kind = "anthropic-messages"
4329 base_url = "https://acme.example/v1"
4330 "#,
4331 ] {
4332 let dir = tempfile::tempdir().expect("tempdir");
4333 let path = dir.path().join("config.toml");
4334 fs::write(&path, body).expect("invalid custom provider fixture");
4335 let err = ConfigStore::load(Some(path)).expect_err("invalid custom route should fail");
4336 let message = format!("{err:#}");
4337 assert!(message.contains("acme_zen_gateway"), "{message}");
4338 assert!(message.contains("openai-compatible") || message.contains("matching"));
4339 }
4340 }
4341
4342 #[test]
4343 fn provider_kind_accepts_legacy_deepseek_cn_aliases() {
4344 for alias in [
4345 "deepseek-cn",
4346 "deepseek_china",
4347 "deepseekcn",
4348 "deepseek-china",
4349 ] {
4350 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepseek));
4351
4352 let parsed: ConfigToml =
4353 toml::from_str(&format!("provider = \"{alias}\"")).expect("legacy provider alias");
4354 assert_eq!(parsed.provider, ProviderKind::Deepseek);
4355 }
4356 }
4357
4358 #[test]
4359 fn deepseek_anthropic_aliases_collapse_onto_primary_with_wire_toggle() {
4360 let _lock = env_lock();
4361 let _env = EnvGuard::without_deepseek_runtime_overrides();
4362 // Dialect is not a catalog identity — aliases resolve to DeepSeek primary.
4363 for alias in [
4364 "deepseek-anthropic",
4365 "deepseek_anthropic",
4366 "deepseek-claude",
4367 "deepseek_claude",
4368 ] {
4369 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepseek));
4370 }
4371
4372 let provider = provider::resolve_provider("deepseek-anthropic")
4373 .expect("deepseek anthropic alias resolves to primary");
4374 assert_eq!(provider.kind(), ProviderKind::Deepseek);
4375 assert_eq!(provider.id(), "deepseek");
4376
4377 // wire=anthropic selects the Messages endpoint without a second provider.
4378 let config: ConfigToml = toml::from_str(
4379 r#"
4380 provider = "deepseek"
4381
4382 [providers.deepseek]
4383 wire = "anthropic"
4384 "#,
4385 )
4386 .expect("deepseek wire config");
4387 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4388 assert_eq!(resolved.provider, ProviderKind::Deepseek);
4389 assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL);
4390 // Legacy serde kind still resolves the anthropic endpoint.
4391 let legacy = ConfigToml {
4392 provider: ProviderKind::DeepseekAnthropic,
4393 ..ConfigToml::default()
4394 };
4395 let legacy_resolved = legacy.resolve_runtime_options(&CliRuntimeOverrides::default());
4396 assert_eq!(
4397 legacy_resolved.base_url,
4398 DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
4399 );
4400 }
4401
4402 #[test]
4403 fn openmodel_route_defaults_to_messages_endpoint() {
4404 let _lock = env_lock();
4405 let _env = EnvGuard::without_deepseek_runtime_overrides();
4406 for alias in ["openmodel", "open-model", "open_model"] {
4407 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Openmodel));
4408
4409 let parsed: ConfigToml =
4410 toml::from_str(&format!("provider = \"{alias}\"")).expect("openmodel alias");
4411 assert_eq!(parsed.provider, ProviderKind::Openmodel);
4412 }
4413
4414 let provider = provider::resolve_provider("openmodel").expect("openmodel metadata resolves");
4415 assert_eq!(provider.kind(), ProviderKind::Openmodel);
4416 assert_eq!(provider.provider_config_key(), "openmodel");
4417 assert_eq!(provider.default_model(), DEFAULT_OPENMODEL_MODEL);
4418 assert_eq!(provider.default_base_url(), DEFAULT_OPENMODEL_BASE_URL);
4419 assert_eq!(provider.env_vars(), &["OPENMODEL_API_KEY"]);
4420 assert_eq!(
4421 provider.wire_policy().fixed(),
4422 Some(provider::WireFormat::AnthropicMessages)
4423 );
4424
4425 let config = ConfigToml {
4426 provider: ProviderKind::Openmodel,
4427 ..ConfigToml::default()
4428 };
4429 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4430
4431 assert_eq!(resolved.provider, ProviderKind::Openmodel);
4432 assert_eq!(resolved.base_url, DEFAULT_OPENMODEL_BASE_URL);
4433 assert_eq!(resolved.model, DEFAULT_OPENMODEL_MODEL);
4434
4435 unsafe {
4436 std::env::set_var("OPENMODEL_BASE_URL", "https://gateway.example.test");
4437 std::env::set_var("OPENMODEL_MODEL", "claude-sonnet-4-20250514");
4438 }
4439 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4440 assert_eq!(resolved.base_url, "https://gateway.example.test");
4441 assert_eq!(resolved.model, "claude-sonnet-4-20250514");
4442 unsafe {
4443 std::env::remove_var("OPENMODEL_BASE_URL");
4444 std::env::remove_var("OPENMODEL_MODEL");
4445 }
4446 }
4447
4448 #[test]
4449 fn xai_api_key_provider_resolves_defaults_and_scopes_env_credentials() {
4450 let _lock = env_lock();
4451 let _env = EnvGuard::without_deepseek_runtime_overrides();
4452
4453 let config = ConfigToml {
4454 provider: ProviderKind::Xai,
4455 ..ConfigToml::default()
4456 };
4457 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4458
4459 assert_eq!(resolved.provider, ProviderKind::Xai);
4460 assert_eq!(resolved.base_url, DEFAULT_XAI_BASE_URL);
4461 assert_eq!(resolved.model, DEFAULT_XAI_MODEL);
4462 assert_eq!(resolved.api_key, None);
4463
4464 unsafe {
4465 std::env::set_var("XAI_API_KEY", "xai-env-key");
4466 std::env::set_var("XAI_BASE_URL", "https://xai-gateway.example/v1");
4467 std::env::set_var("XAI_MODEL", "grok-4.3");
4468 }
4469
4470 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4471 assert_eq!(resolved.api_key, None);
4472 assert_eq!(resolved.api_key_source, None);
4473 assert_eq!(resolved.base_url, "https://xai-gateway.example/v1");
4474 assert_eq!(resolved.model, "grok-4.3");
4475 }
4476
4477 #[test]
4478 fn opencode_go_resolves_current_chat_completions_route() {
4479 let _lock = env_lock();
4480 let _env = EnvGuard::without_deepseek_runtime_overrides();
4481
4482 for alias in ["opencode-go", "opencode_go", "opencodego"] {
4483 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::OpencodeGo));
4484
4485 let parsed: ConfigToml =
4486 toml::from_str(&format!("provider = \"{alias}\"")).expect("OpenCode Go alias");
4487 assert_eq!(parsed.provider, ProviderKind::OpencodeGo);
4488 }
4489
4490 let metadata = provider::resolve_provider("opencode_go").expect("provider metadata");
4491 assert_eq!(metadata.id(), "opencode-go");
4492 assert_eq!(metadata.display_name(), "OpenCode Go");
4493 assert_eq!(metadata.provider_config_key(), "opencode_go");
4494 assert_eq!(metadata.default_base_url(), DEFAULT_OPENCODE_GO_BASE_URL);
4495 assert_eq!(metadata.default_model(), DEFAULT_OPENCODE_GO_MODEL);
4496 assert_eq!(metadata.env_vars(), &["OPENCODE_GO_API_KEY"]);
4497 assert_eq!(
4498 metadata.wire_policy().fixed(),
4499 Some(provider::WireFormat::ChatCompletions)
4500 );
4501
4502 let config: ConfigToml = toml::from_str(
4503 r#"
4504 provider = "opencode-go"
4505
4506 [providers.opencode_go]
4507 api_key = "go-config-key"
4508 model = "opencode-go/glm-5.2"
4509 "#,
4510 )
4511 .expect("OpenCode Go provider table");
4512 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4513 assert_eq!(resolved.provider, ProviderKind::OpencodeGo);
4514 assert_eq!(resolved.base_url, DEFAULT_OPENCODE_GO_BASE_URL);
4515 assert_eq!(resolved.model, OPENCODE_GO_GLM_5_2_MODEL);
4516 assert_eq!(resolved.api_key.as_deref(), Some("go-config-key"));
4517 assert_eq!(
4518 resolved.api_key_source,
4519 Some(RuntimeApiKeySource::ConfigFile)
4520 );
4521
4522 // Provider-specific environment overrides remain available, but model ids
4523 // stay inside the Chat Completions allowlist.
4524 unsafe {
4525 std::env::set_var("OPENCODE_GO_API_KEY", "go-env-key");
4526 std::env::set_var("OPENCODE_GO_MODEL", "opencode-go/mimo-v2.5-pro");
4527 }
4528 assert_eq!(
4529 codewhale_secrets::env_for("opencode-go").as_deref(),
4530 Some("go-env-key")
4531 );
4532 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4533 assert_eq!(resolved.base_url, DEFAULT_OPENCODE_GO_BASE_URL);
4534 assert_eq!(resolved.model, OPENCODE_GO_MIMO_V2_5_PRO_MODEL);
4535
4536 for model in [OPENCODE_GO_GROK_4_5_MODEL, OPENCODE_GO_KIMI_K3_MODEL] {
4537 assert_eq!(opencode_go_chat_model_id(model), Some(model));
4538 assert_eq!(
4539 opencode_go_chat_model_id(&format!("opencode-go/{model}")),
4540 Some(model)
4541 );
4542 }
4543
4544 // The Go roster also includes Messages-only models. Even a custom base URL
4545 // cannot make one safe for this Chat Completions provider. Resolution must
4546 // keep the configured id (so diagnostics name it) rather than silently
4547 // substituting the Chat Completions default; the route layer rejects it.
4548 unsafe {
4549 std::env::set_var("OPENCODE_GO_BASE_URL", "https://go-gateway.example/v1");
4550 std::env::set_var("OPENCODE_GO_MODEL", "minimax-m3");
4551 }
4552 assert!(opencode_go_chat_model_id("minimax-m3").is_none());
4553 assert!(opencode_go_chat_model_id("qwen3.7-max").is_none());
4554 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4555 assert_eq!(resolved.base_url, "https://go-gateway.example/v1");
4556 assert_eq!(resolved.model, "minimax-m3");
4557 assert_eq!(resolved.api_key, None);
4558 }
4559
4560 #[test]
4561 fn telecomjs_resolves_key_scoped_chat_completions_route() {
4562 let _lock = env_lock();
4563 let _env = EnvGuard::without_deepseek_runtime_overrides();
4564
4565 for alias in [
4566 "telecomjs",
4567 "telecom-js",
4568 "telecom_js",
4569 "telecomjs-cn",
4570 "tokenhub",
4571 ] {
4572 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Telecomjs));
4573
4574 let parsed: ConfigToml =
4575 toml::from_str(&format!("provider = \"{alias}\"")).expect("TelecomJS alias");
4576 assert_eq!(parsed.provider, ProviderKind::Telecomjs);
4577 }
4578
4579 let metadata = provider::resolve_provider("tokenhub").expect("provider metadata");
4580 assert_eq!(metadata.id(), "telecomjs");
4581 assert_eq!(metadata.display_name(), "TelecomJS TokenHub");
4582 assert_eq!(metadata.provider_config_key(), "telecomjs");
4583 assert_eq!(metadata.default_base_url(), DEFAULT_TELECOMJS_BASE_URL);
4584 assert_eq!(metadata.default_model(), DEFAULT_TELECOMJS_MODEL);
4585 assert_eq!(metadata.env_vars(), &["TELECOMJS_API_KEY"]);
4586 assert_eq!(
4587 metadata.wire_policy(),
4588 provider::WirePolicy::Fixed(provider::WireFormat::ChatCompletions)
4589 );
4590
4591 let config: ConfigToml = toml::from_str(
4592 r#"
4593 provider = "telecomjs"
4594
4595 [providers.telecomjs]
4596 api_key = "telecom-config-key"
4597 model = "glm-5.2"
4598 "#,
4599 )
4600 .expect("TelecomJS provider table");
4601 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4602 assert_eq!(resolved.provider, ProviderKind::Telecomjs);
4603 assert_eq!(resolved.base_url, DEFAULT_TELECOMJS_BASE_URL);
4604 assert_eq!(resolved.model, "glm-5.2");
4605 assert_eq!(resolved.api_key.as_deref(), Some("telecom-config-key"));
4606 assert_eq!(
4607 resolved.api_key_source,
4608 Some(RuntimeApiKeySource::ConfigFile)
4609 );
4610
4611 unsafe {
4612 std::env::set_var("TELECOMJS_API_KEY", "telecom-env-key");
4613 std::env::set_var("TELECOMJS_MODEL", "kimi-k2.5");
4614 }
4615 assert_eq!(
4616 codewhale_secrets::env_for("tokenhub").as_deref(),
4617 Some("telecom-env-key")
4618 );
4619
4620 let env_config = ConfigToml {
4621 provider: ProviderKind::Telecomjs,
4622 ..ConfigToml::default()
4623 };
4624 let resolved = env_config.resolve_runtime_options(&CliRuntimeOverrides::default());
4625 assert_eq!(resolved.base_url, DEFAULT_TELECOMJS_BASE_URL);
4626 assert_eq!(resolved.model, "kimi-k2.5");
4627 assert_eq!(resolved.api_key.as_deref(), Some("telecom-env-key"));
4628 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
4629 }
4630
4631 #[test]
4632 fn opencode_zen_configures_model_aware_provider_with_catalog_proof() {
4633 let _lock = env_lock();
4634 let _env = EnvGuard::without_deepseek_runtime_overrides();
4635
4636 for alias in [
4637 "opencode-zen",
4638 "opencode_zen",
4639 "opencodezen",
4640 "zen",
4641 "opencode",
4642 ] {
4643 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::OpencodeZen));
4644 }
4645
4646 let metadata = provider::resolve_provider("opencode_zen").expect("Zen provider metadata");
4647 assert_eq!(metadata.id(), "opencode-zen");
4648 assert_eq!(metadata.display_name(), "OpenCode Zen");
4649 assert_eq!(metadata.provider_config_key(), "opencode_zen");
4650 assert_eq!(metadata.default_base_url(), DEFAULT_OPENCODE_ZEN_BASE_URL);
4651 assert_eq!(metadata.default_model(), DEFAULT_OPENCODE_ZEN_MODEL);
4652 assert_eq!(
4653 metadata.env_vars(),
4654 &["OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"]
4655 );
4656 assert_eq!(metadata.wire_policy(), provider::WirePolicy::ModelAware);
4657
4658 let config: ConfigToml = toml::from_str(
4659 r#"
4660 provider = "opencode-zen"
4661
4662 [providers.opencode_zen]
4663 api_key = "zen-config-key"
4664 base_url = "https://zen-gateway.example/v1"
4665 model = "gpt-5.5"
4666 "#,
4667 )
4668 .expect("OpenCode Zen provider table");
4669 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4670 assert_eq!(resolved.provider, ProviderKind::OpencodeZen);
4671 assert_eq!(resolved.base_url, "https://zen-gateway.example/v1");
4672 assert_eq!(resolved.model, "gpt-5.5");
4673 assert_eq!(resolved.api_key.as_deref(), Some("zen-config-key"));
4674
4675 let resolver = crate::route::RouteResolver::new();
4676 let route = resolver
4677 .resolve(&crate::route::RouteRequest {
4678 explicit_provider: Some(ProviderKind::OpencodeZen),
4679 model_selector: Some(crate::route::LogicalModelRef::from("gpt-5.5")),
4680 saved_provider_model: None,
4681 base_url_override: None,
4682 limit_overrides: Vec::new(),
4683 })
4684 .expect("documented Zen model must resolve");
4685 assert_eq!(route.protocol(), crate::route::RequestProtocol::Responses);
4686 assert_eq!(route.endpoint().endpoint_key, "responses");
4687 }
4688
4689 #[test]
4690 fn meta_model_api_scopes_both_documented_key_names_to_official_endpoint() {
4691 let _lock = env_lock();
4692 let _env = EnvGuard::without_deepseek_runtime_overrides();
4693
4694 let config = ConfigToml {
4695 provider: ProviderKind::Meta,
4696 ..ConfigToml::default()
4697 };
4698 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4699
4700 assert_eq!(resolved.provider, ProviderKind::Meta);
4701 assert_eq!(resolved.base_url, DEFAULT_META_BASE_URL);
4702 assert_eq!(resolved.model, DEFAULT_META_MODEL);
4703 assert_eq!(resolved.api_key, None);
4704
4705 unsafe {
4706 std::env::set_var("MODEL_API_KEY", "meta-official-key");
4707 std::env::set_var("MODEL_API_BASE_URL", "https://meta-gateway.example/v1");
4708 std::env::set_var("MODEL_API_MODEL", "muse-spark-canary");
4709 }
4710 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4711 assert_eq!(resolved.api_key, None);
4712 assert_eq!(resolved.api_key_source, None);
4713 assert_eq!(resolved.base_url, "https://meta-gateway.example/v1");
4714 assert_eq!(resolved.model, "muse-spark-canary");
4715
4716 unsafe {
4717 std::env::set_var("META_MODEL_API_KEY", "meta-models-dev-key");
4718 std::env::set_var("META_MODEL_API_BASE_URL", "https://meta-primary.example/v1");
4719 std::env::set_var("META_MODEL_API_MODEL", "muse-spark-1.1");
4720 }
4721 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4722 assert_eq!(resolved.api_key, None);
4723 assert_eq!(resolved.api_key_source, None);
4724 assert_eq!(resolved.base_url, "https://meta-primary.example/v1");
4725 assert_eq!(resolved.model, "muse-spark-1.1");
4726 }
4727
4728 #[test]
4729 fn provider_metadata_registry_covers_every_provider_kind_once() {
4730 let providers = provider::all_providers();
4731 // Full registry keeps legacy dialect/plan kinds for provider_for_kind.
4732 assert_eq!(providers.len(), 41);
4733 // Catalog surface is one identity per vendor (no dual-wire / plan rows).
4734 assert_eq!(ProviderKind::ALL.len(), 36);
4735 assert!(ProviderKind::ALL.len() < providers.len());
4736
4737 let mut ids = std::collections::BTreeSet::new();
4738 for provider in providers {
4739 assert!(ids.insert(provider.id()), "duplicate provider id");
4740 assert_eq!(provider.id(), provider.kind().as_str());
4741 assert_eq!(provider.kind().provider().id(), provider.id());
4742 }
4743 // Catalog entries are a subset of the full registry.
4744 for kind in ProviderKind::ALL {
4745 assert!(
4746 providers.iter().any(|p| p.kind() == kind),
4747 "catalog kind {kind:?} missing from full registry"
4748 );
4749 }
4750 }
4751
4752 #[test]
4753 fn provider_metadata_lookup_does_not_fall_back_to_deepseek() {
4754 assert!(provider::lookup_provider("not-a-provider").is_none());
4755 assert!(provider::resolve_provider("not-a-provider").is_none());
4756 assert!(provider::lookup_provider("deepseek-cn").is_none());
4757 assert_eq!(
4758 provider::resolve_provider("deepseek-cn")
4759 .expect("legacy alias resolves")
4760 .kind(),
4761 ProviderKind::Deepseek
4762 );
4763 }
4764
4765 #[test]
4766 fn provider_metadata_preserves_alias_and_config_key_semantics() {
4767 assert_eq!(
4768 provider::resolve_provider("open_router")
4769 .expect("openrouter alias")
4770 .kind(),
4771 ProviderKind::Openrouter
4772 );
4773 assert_eq!(
4774 provider::resolve_provider("xiaomi")
4775 .expect("xiaomi alias")
4776 .kind(),
4777 ProviderKind::XiaomiMimo
4778 );
4779 assert_eq!(
4780 provider::resolve_provider("kimi")
4781 .expect("kimi alias")
4782 .kind(),
4783 ProviderKind::Moonshot
4784 );
4785 assert_eq!(
4786 provider::resolve_provider("hf")
4787 .expect("huggingface alias")
4788 .kind(),
4789 ProviderKind::Huggingface
4790 );
4791 assert_eq!(
4792 provider::resolve_provider("grok")
4793 .expect("xAI grok alias")
4794 .kind(),
4795 ProviderKind::Xai
4796 );
4797 assert_eq!(
4798 provider::resolve_provider("muse-spark")
4799 .expect("Meta Muse Spark alias")
4800 .kind(),
4801 ProviderKind::Meta
4802 );
4803
4804 let siliconflow_cn =
4805 provider::resolve_provider("siliconflow-cn").expect("siliconflow-cn alias resolves");
4806 assert_eq!(siliconflow_cn.kind(), ProviderKind::SiliconflowCN);
4807 assert_eq!(siliconflow_cn.id(), "siliconflow-CN");
4808 assert_eq!(siliconflow_cn.provider_config_key(), "siliconflow_cn");
4809
4810 let config = ProvidersToml::default();
4811 let shared_table = config.for_provider(ProviderKind::SiliconflowCN);
4812 assert!(!std::ptr::eq(
4813 shared_table,
4814 config.for_provider(ProviderKind::Siliconflow)
4815 ));
4816 }
4817
4818 #[test]
4819 fn provider_metadata_defaults_match_runtime_helpers() {
4820 for kind in ProviderKind::ALL {
4821 let provider = kind.provider();
4822 assert_eq!(provider.default_model(), default_model_for_provider(kind));
4823 assert_eq!(
4824 provider.default_base_url(),
4825 default_base_url_for_provider(kind)
4826 );
4827 assert!(!provider.display_name().trim().is_empty());
4828 // The dynamic custom provider (#1519) intentionally declares no
4829 // built-in auth env var: the key env var name is supplied per entry via
4830 // `[providers.<name>] api_key_env = "..."`. Every built-in provider
4831 // still must declare at least one.
4832 if kind != ProviderKind::Custom {
4833 assert!(!provider.env_vars().is_empty());
4834 }
4835 // OpenAI Codex (ChatGPT) speaks the Responses API; DeepSeek and
4836 // OpenCode Zen select a protocol per exact model offering; Anthropic
4837 // and the Anthropic-compatible routes speak native Messages; every
4838 // other built-in provider is OpenAI-compatible Chat Completions.
4839 let expected_wire = match kind {
4840 ProviderKind::Deepseek | ProviderKind::OpencodeZen => None,
4841 ProviderKind::OpenaiCodex => Some(provider::WireFormat::Responses),
4842 ProviderKind::Anthropic
4843 | ProviderKind::DeepseekAnthropic
4844 | ProviderKind::MinimaxAnthropic
4845 | ProviderKind::Openmodel
4846 | ProviderKind::ModelstudioTokenPlanAnthropic
4847 | ProviderKind::ModelstudioCodingPlanAnthropic => {
4848 Some(provider::WireFormat::AnthropicMessages)
4849 }
4850 _ => Some(provider::WireFormat::ChatCompletions),
4851 };
4852 assert_eq!(provider.wire_policy().fixed(), expected_wire);
4853 }
4854 }
4855
4856 #[test]
4857 fn openrouter_provider_defaults_to_canonical_endpoint_and_model() {
4858 let _lock = env_lock();
4859 let _env = EnvGuard::without_deepseek_runtime_overrides();
4860 let config = ConfigToml {
4861 provider: ProviderKind::Openrouter,
4862 ..ConfigToml::default()
4863 };
4864
4865 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4866
4867 assert_eq!(resolved.provider, ProviderKind::Openrouter);
4868 assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
4869 assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL);
4870 }
4871
4872 #[test]
4873 fn xiaomi_mimo_provider_defaults_to_canonical_endpoint_and_model() {
4874 let _lock = env_lock();
4875 let _env = EnvGuard::without_deepseek_runtime_overrides();
4876 let config = ConfigToml {
4877 provider: ProviderKind::XiaomiMimo,
4878 ..ConfigToml::default()
4879 };
4880
4881 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4882
4883 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
4884 assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
4885 assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
4886 }
4887
4888 #[test]
4889 fn xiaomi_provider_alias_table_maps_to_mimo_runtime_config() {
4890 let _lock = env_lock();
4891 let _env = EnvGuard::without_deepseek_runtime_overrides();
4892 let config: ConfigToml = toml::from_str(
4893 r#"
4894 provider = "xiaomi-mimo"
4895 default_text_model = "deepseek/deepseek-v4-pro"
4896
4897 [providers.xiaomi]
4898 api_key = "mimo-table-key"
4899 base_url = "https://token-plan-sgp.xiaomimimo.com/v1"
4900 model = "mimo-v2.5-pro"
4901 "#,
4902 )
4903 .expect("xiaomi provider alias config");
4904
4905 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4906
4907 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
4908 assert_eq!(resolved.api_key.as_deref(), Some("mimo-table-key"));
4909 assert_eq!(
4910 resolved.base_url,
4911 "https://token-plan-sgp.xiaomimimo.com/v1"
4912 );
4913 assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
4914 }
4915
4916 #[test]
4917 fn xiaomi_token_plan_key_rewrites_saved_pay_as_you_go_base_url() {
4918 let _lock = env_lock();
4919 let _env = EnvGuard::without_deepseek_runtime_overrides();
4920 let config: ConfigToml = toml::from_str(
4921 r#"
4922 provider = "xiaomi-mimo"
4923
4924 [providers.xiaomi_mimo]
4925 api_key = "tp-test-token-plan-key"
4926 base_url = "https://api.xiaomimimo.com/v1"
4927 model = "mimo-v2.5-pro"
4928 "#,
4929 )
4930 .expect("xiaomi token-plan config");
4931
4932 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4933
4934 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
4935 assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
4936 assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
4937 }
4938
4939 #[test]
4940 fn xiaomi_mimo_token_plan_mode_accepts_region_aliases() {
4941 let _lock = env_lock();
4942 let _env = EnvGuard::without_deepseek_runtime_overrides();
4943 let config: ConfigToml = toml::from_str(
4944 r#"
4945 provider = "mimo"
4946
4947 [providers.mimo]
4948 mode = "token-plan-ams"
4949 "#,
4950 )
4951 .expect("xiaomi token-plan region config");
4952
4953 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4954
4955 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
4956 assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL);
4957 }
4958
4959 #[test]
4960 fn xiaomi_mimo_unknown_mode_stays_on_token_plan_endpoint() {
4961 let _lock = env_lock();
4962 let _env = EnvGuard::without_deepseek_runtime_overrides();
4963 let config: ConfigToml = toml::from_str(
4964 r#"
4965 provider = "mimo"
4966
4967 [providers.mimo]
4968 mode = "token-plan-usa"
4969 "#,
4970 )
4971 .expect("xiaomi token-plan unknown mode config");
4972
4973 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4974
4975 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
4976 assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
4977 }
4978
4979 #[test]
4980 fn xiaomi_mimo_aliases_resolve_to_canonical_models() {
4981 assert_eq!(
4982 normalize_model_for_provider(ProviderKind::XiaomiMimo, "omni"),
4983 "mimo-v2.5"
4984 );
4985 assert_eq!(
4986 normalize_model_for_provider(ProviderKind::XiaomiMimo, "pro-ultraspeed"),
4987 "mimo-v2.5-pro-ultraspeed"
4988 );
4989 assert_eq!(
4990 normalize_model_for_provider(ProviderKind::XiaomiMimo, "tts"),
4991 "mimo-v2.5-tts"
4992 );
4993 assert_eq!(
4994 normalize_model_for_provider(ProviderKind::XiaomiMimo, "voice-design"),
4995 "mimo-v2.5-tts-voicedesign"
4996 );
4997 assert_eq!(
4998 normalize_model_for_provider(ProviderKind::XiaomiMimo, "voiceclone"),
4999 "mimo-v2.5-tts-voiceclone"
5000 );
5001 assert_eq!(
5002 normalize_model_for_provider(ProviderKind::XiaomiMimo, "custom-mimo-model"),
5003 "custom-mimo-model"
5004 );
5005 }
5006
5007 #[test]
5008 fn zai_aliases_resolve_to_canonical_models() {
5009 // GLM-5.2 is the default; the glm-5.1 alias must still resolve to 5.1
5010 // (not to the default), and GLM-5-Turbo resolves to its own id.
5011 assert_eq!(
5012 normalize_model_for_provider(ProviderKind::Zai, "glm-5.1"),
5013 ZAI_GLM_5_1_MODEL
5014 );
5015 assert_eq!(
5016 normalize_model_for_provider(ProviderKind::Zai, "glm-5-2"),
5017 DEFAULT_ZAI_MODEL
5018 );
5019 assert_eq!(DEFAULT_ZAI_MODEL, "GLM-5.2");
5020 // GLM-5.3 is a peer, not the default: its aliases must land on its own id
5021 // and must never fold into DEFAULT_ZAI_MODEL.
5022 for alias in ["glm-5.3", "glm-5-3", "zai-glm-5.3", "zai-glm-5-3"] {
5023 assert_eq!(
5024 normalize_model_for_provider(ProviderKind::Zai, alias),
5025 ZAI_GLM_5_3_MODEL,
5026 "{alias} must canonicalize to GLM-5.3"
5027 );
5028 assert_ne!(
5029 normalize_model_for_provider(ProviderKind::Zai, alias),
5030 DEFAULT_ZAI_MODEL,
5031 "{alias} must not resolve to the Z.ai default"
5032 );
5033 }
5034 assert_eq!(
5035 normalize_model_for_provider(ProviderKind::Zai, "glm-5-turbo"),
5036 ZAI_GLM_5_TURBO_MODEL
5037 );
5038 assert_eq!(
5039 normalize_model_for_provider(ProviderKind::Zai, "custom-glm-preview"),
5040 "custom-glm-preview"
5041 );
5042 }
5043
5044 #[test]
5045 fn zhipu_aliases_fold_into_zai_provider() {
5046 // Zhipu AI and Z.ai are the same vendor; `zhipu`/`zhipuai`/`bigmodel`
5047 // resolve to the single Zai provider rather than a separate one.
5048 assert_eq!(ProviderKind::parse("zhipu"), Some(ProviderKind::Zai));
5049 assert_eq!(ProviderKind::parse("zhipuai"), Some(ProviderKind::Zai));
5050 assert_eq!(ProviderKind::parse("bigmodel"), Some(ProviderKind::Zai));
5051 assert_eq!(ProviderKind::parse("big-model"), Some(ProviderKind::Zai));
5052
5053 // A `[providers.zhipu]` table (BigModel China endpoint) merges into the Zai
5054 // provider config through the serde alias.
5055 let parsed: ConfigToml = toml::from_str(
5056 r#"
5057 [providers.zhipu]
5058 api_key = "$ZHIPU_API_KEY"
5059 base_url = "https://open.bigmodel.cn/api/paas/v4/"
5060 model = "glm-5-2"
5061 "#,
5062 )
5063 .expect("zhipu provider table parses");
5064
5065 let provider = parsed.providers.for_provider(ProviderKind::Zai);
5066 assert_eq!(provider.api_key.as_deref(), Some("$ZHIPU_API_KEY"));
5067 assert_eq!(
5068 provider.base_url.as_deref(),
5069 Some("https://open.bigmodel.cn/api/paas/v4/")
5070 );
5071 assert_eq!(provider.model.as_deref(), Some("glm-5-2"));
5072
5073 // GLM aliases canonicalize under the Zai umbrella.
5074 assert_eq!(
5075 normalize_model_for_provider(ProviderKind::Zai, "glm-5-2"),
5076 DEFAULT_ZAI_MODEL
5077 );
5078 }
5079
5080 #[test]
5081 fn novita_provider_defaults_to_canonical_endpoint_and_model() {
5082 let _lock = env_lock();
5083 let _env = EnvGuard::without_deepseek_runtime_overrides();
5084 let config = ConfigToml {
5085 provider: ProviderKind::Novita,
5086 ..ConfigToml::default()
5087 };
5088
5089 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5090
5091 assert_eq!(resolved.provider, ProviderKind::Novita);
5092 assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
5093 assert_eq!(resolved.model, DEFAULT_NOVITA_MODEL);
5094 }
5095
5096 #[test]
5097 fn fireworks_provider_defaults_to_canonical_endpoint_and_model() {
5098 let _lock = env_lock();
5099 let _env = EnvGuard::without_deepseek_runtime_overrides();
5100 let config = ConfigToml {
5101 provider: ProviderKind::Fireworks,
5102 ..ConfigToml::default()
5103 };
5104
5105 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5106
5107 assert_eq!(resolved.provider, ProviderKind::Fireworks);
5108 assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
5109 assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL);
5110 }
5111
5112 #[test]
5113 fn siliconflow_provider_defaults_to_canonical_endpoint_and_model() {
5114 let _lock = env_lock();
5115 let _env = EnvGuard::without_deepseek_runtime_overrides();
5116 let config = ConfigToml {
5117 provider: ProviderKind::Siliconflow,
5118 ..ConfigToml::default()
5119 };
5120
5121 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5122
5123 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
5124 assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_BASE_URL);
5125 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
5126 }
5127
5128 #[test]
5129 fn siliconflow_cn_config_falls_back_to_shared_table_when_unset() {
5130 let _lock = env_lock();
5131 let _env = EnvGuard::without_deepseek_runtime_overrides();
5132 let mut config = ConfigToml {
5133 provider: ProviderKind::SiliconflowCN,
5134 ..ConfigToml::default()
5135 };
5136 config.providers.siliconflow.api_key = Some("sf-shared-key".to_string());
5137 config.providers.siliconflow.base_url = Some(DEFAULT_SILICONFLOW_BASE_URL.to_string());
5138 config.providers.siliconflow.model = Some("deepseek-chat".to_string());
5139 config.providers.siliconflow_cn.base_url = Some(DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
5140
5141 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5142
5143 assert_eq!(resolved.provider, ProviderKind::SiliconflowCN);
5144 assert_eq!(resolved.api_key.as_deref(), Some("sf-shared-key"));
5145 assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_CN_BASE_URL);
5146 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL);
5147 }
5148
5149 #[test]
5150 fn siliconflow_cn_first_class_config_preserves_provider_scoped_route() {
5151 let _lock = env_lock();
5152 let _env = EnvGuard::without_deepseek_runtime_overrides();
5153 let mut config = ConfigToml {
5154 provider: ProviderKind::SiliconflowCN,
5155 ..ConfigToml::default()
5156 };
5157 config.providers.siliconflow_cn.api_key = Some("sf-cn-file-key".to_string());
5158 config.providers.siliconflow_cn.base_url = Some(DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
5159 config.providers.siliconflow_cn.model = Some(DEFAULT_SILICONFLOW_MODEL.to_string());
5160
5161 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5162
5163 assert_eq!(resolved.provider, ProviderKind::SiliconflowCN);
5164 assert_eq!(resolved.api_key.as_deref(), Some("sf-cn-file-key"));
5165 assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_CN_BASE_URL);
5166 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
5167 }
5168
5169 #[test]
5170 fn moonshot_provider_defaults_to_kimi_k27_code() {
5171 let _lock = env_lock();
5172 let _env = EnvGuard::without_deepseek_runtime_overrides();
5173 let config = ConfigToml {
5174 provider: ProviderKind::Moonshot,
5175 ..ConfigToml::default()
5176 };
5177
5178 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5179
5180 assert_eq!(resolved.provider, ProviderKind::Moonshot);
5181 assert_eq!(resolved.base_url, DEFAULT_MOONSHOT_BASE_URL);
5182 assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
5183 }
5184
5185 #[test]
5186 fn zai_stepfun_minimax_and_sakana_default_to_first_party_routes() {
5187 let _lock = env_lock();
5188 let _env = EnvGuard::without_deepseek_runtime_overrides();
5189
5190 for (provider, expected_base_url, expected_model) in [
5191 (ProviderKind::Zai, DEFAULT_ZAI_BASE_URL, DEFAULT_ZAI_MODEL),
5192 (
5193 ProviderKind::Stepfun,
5194 DEFAULT_STEPFUN_BASE_URL,
5195 DEFAULT_STEPFUN_MODEL,
5196 ),
5197 (
5198 ProviderKind::Minimax,
5199 DEFAULT_MINIMAX_BASE_URL,
5200 DEFAULT_MINIMAX_MODEL,
5201 ),
5202 (
5203 ProviderKind::MinimaxAnthropic,
5204 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
5205 DEFAULT_MINIMAX_MODEL,
5206 ),
5207 (
5208 ProviderKind::Sakana,
5209 DEFAULT_SAKANA_BASE_URL,
5210 DEFAULT_SAKANA_MODEL,
5211 ),
5212 ] {
5213 let config = ConfigToml {
5214 provider,
5215 ..ConfigToml::default()
5216 };
5217 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5218
5219 assert_eq!(resolved.provider, provider);
5220 assert_eq!(resolved.base_url, expected_base_url);
5221 assert_eq!(resolved.model, expected_model);
5222 }
5223 }
5224
5225 #[test]
5226 fn qianfan_provider_defaults_to_openai_compatible_endpoint_and_model() {
5227 let _lock = env_lock();
5228 let _env = EnvGuard::without_deepseek_runtime_overrides();
5229 let config = ConfigToml {
5230 provider: ProviderKind::Qianfan,
5231 ..ConfigToml::default()
5232 };
5233
5234 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5235
5236 assert_eq!(resolved.provider, ProviderKind::Qianfan);
5237 assert_eq!(resolved.base_url, DEFAULT_QIANFAN_BASE_URL);
5238 assert_eq!(resolved.model, DEFAULT_QIANFAN_MODEL);
5239 }
5240
5241 #[test]
5242 fn qianfan_provider_preserves_configured_base_url_and_model() {
5243 let _lock = env_lock();
5244 let _env = EnvGuard::without_deepseek_runtime_overrides();
5245 let mut config = ConfigToml {
5246 provider: ProviderKind::Qianfan,
5247 ..ConfigToml::default()
5248 };
5249 config.providers.qianfan.api_key = Some("qianfan-table-key".to_string());
5250 config.providers.qianfan.base_url = Some("https://qianfan.baidubce.com/v2".to_string());
5251 config.providers.qianfan.model = Some("custom-qianfan-service-id".to_string());
5252
5253 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5254
5255 assert_eq!(resolved.provider, ProviderKind::Qianfan);
5256 assert_eq!(resolved.api_key.as_deref(), Some("qianfan-table-key"));
5257 assert_eq!(resolved.base_url, "https://qianfan.baidubce.com/v2");
5258 assert_eq!(resolved.model, "custom-qianfan-service-id");
5259 }
5260
5261 #[test]
5262 fn first_party_provider_env_model_overrides_pass_through() {
5263 let _lock = env_lock();
5264 let _env = EnvGuard::without_deepseek_runtime_overrides();
5265 unsafe {
5266 env::set_var("CODEWHALE_PROVIDER", "minimax");
5267 env::set_var("MINIMAX_MODEL", "MiniMax-M2.7-highspeed");
5268 env::set_var("MINIMAX_BASE_URL", "https://minimax.example/v1");
5269 }
5270
5271 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
5272
5273 assert_eq!(resolved.provider, ProviderKind::Minimax);
5274 assert_eq!(resolved.base_url, "https://minimax.example/v1");
5275 assert_eq!(resolved.model, "MiniMax-M2.7-highspeed");
5276 }
5277
5278 #[test]
5279 fn minimax_env_model_override_canonicalizes_known_aliases() {
5280 let _lock = env_lock();
5281 let _env = EnvGuard::without_deepseek_runtime_overrides();
5282 unsafe {
5283 env::set_var("CODEWHALE_PROVIDER", "minimax");
5284 env::set_var("MINIMAX_MODEL", "minimax-m2-5-highspeed");
5285 }
5286
5287 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
5288
5289 assert_eq!(resolved.provider, ProviderKind::Minimax);
5290 assert_eq!(resolved.model, "MiniMax-M2.5-highspeed");
5291 }
5292
5293 #[test]
5294 fn minimax_wire_anthropic_selects_messages_endpoint() {
5295 let _lock = env_lock();
5296 let _env = EnvGuard::without_deepseek_runtime_overrides();
5297 // minimax-anthropic is an alias of MiniMax; dialect is wire config.
5298 assert_eq!(
5299 ProviderKind::parse("minimax-anthropic"),
5300 Some(ProviderKind::Minimax)
5301 );
5302
5303 let config: ConfigToml = toml::from_str(
5304 r#"
5305 provider = "minimax"
5306
5307 [providers.minimax]
5308 wire = "anthropic"
5309 model = "MiniMax-M2.7"
5310 "#,
5311 )
5312 .expect("minimax wire config");
5313 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5314
5315 assert_eq!(resolved.provider, ProviderKind::Minimax);
5316 assert_eq!(resolved.base_url, DEFAULT_MINIMAX_ANTHROPIC_BASE_URL);
5317 assert_eq!(resolved.model, "MiniMax-M2.7");
5318 }
5319
5320 #[test]
5321 fn parse_config_identity_preserves_legacy_table_kinds() {
5322 // Legacy dual-wire spellings name the user's own [providers.*] table;
5323 // config-table identity must not collapse them onto the vendor primary
5324 // the way catalog `parse` does.
5325 assert_eq!(
5326 ProviderKind::parse_config_identity("deepseek-anthropic"),
5327 Some(ProviderKind::DeepseekAnthropic)
5328 );
5329 assert_eq!(
5330 ProviderKind::parse_config_identity("deepseek_anthropic"),
5331 Some(ProviderKind::DeepseekAnthropic)
5332 );
5333 assert_eq!(
5334 ProviderKind::parse_config_identity("minimax-anthropic"),
5335 Some(ProviderKind::MinimaxAnthropic)
5336 );
5337 assert_eq!(
5338 ProviderKind::parse_config_identity("MINIMAX-ANTHROPIC"),
5339 Some(ProviderKind::MinimaxAnthropic)
5340 );
5341 // Primary spellings and aliases resolve exactly as catalog `parse`.
5342 assert_eq!(
5343 ProviderKind::parse_config_identity("deepseek-cn"),
5344 Some(ProviderKind::Deepseek)
5345 );
5346 assert_eq!(
5347 ProviderKind::parse_config_identity("minimax"),
5348 Some(ProviderKind::Minimax)
5349 );
5350 assert_eq!(ProviderKind::parse_config_identity("nope"), None);
5351 }
5352
5353 #[test]
5354 fn legacy_dual_wire_toml_table_supplies_credentials_and_endpoint() {
5355 let _lock = env_lock();
5356 let _env = EnvGuard::without_deepseek_runtime_overrides();
5357 // TOML serde keeps the legacy kind, so the user's own named table is
5358 // read for credentials and the Messages endpoint is selected.
5359 let config: ConfigToml = toml::from_str(
5360 r#"
5361 provider = "deepseek-anthropic"
5362
5363 [providers.deepseek-anthropic]
5364 api_key = "sk-legacy-deepseek-table"
5365 "#,
5366 )
5367 .expect("legacy deepseek-anthropic config");
5368 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5369
5370 assert_eq!(resolved.provider, ProviderKind::DeepseekAnthropic);
5371 assert_eq!(
5372 resolved.api_key.as_deref(),
5373 Some("sk-legacy-deepseek-table")
5374 );
5375 assert_eq!(
5376 resolved.api_key_source,
5377 Some(RuntimeApiKeySource::ConfigFile)
5378 );
5379 assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL);
5380 }
5381
5382 #[test]
5383 fn legacy_dual_wire_env_provider_preserves_named_table_credentials() {
5384 let _lock = env_lock();
5385 let _env = EnvGuard::without_deepseek_runtime_overrides();
5386 // CODEWHALE_PROVIDER must resolve legacy spellings to the same kind TOML
5387 // serde produces; catalog collapse would orphan the user's own table and
5388 // drop the configured key.
5389 for (slug, kind, table, base_url) in [
5390 (
5391 "deepseek-anthropic",
5392 ProviderKind::DeepseekAnthropic,
5393 "deepseek-anthropic",
5394 DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
5395 ),
5396 (
5397 "minimax-anthropic",
5398 ProviderKind::MinimaxAnthropic,
5399 "minimax-anthropic",
5400 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
5401 ),
5402 (
5403 "minimax_anthropic",
5404 ProviderKind::MinimaxAnthropic,
5405 "minimax-anthropic",
5406 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
5407 ),
5408 ] {
5409 unsafe {
5410 env::set_var("CODEWHALE_PROVIDER", slug);
5411 }
5412 let config: ConfigToml = toml::from_str(&format!(
5413 "[providers.{table}]\napi_key = \"sk-legacy-{table}\"\n"
5414 ))
5415 .expect("legacy table config");
5416 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5417
5418 assert_eq!(resolved.provider, kind, "env slug {slug}");
5419 assert_eq!(
5420 resolved.api_key.as_deref(),
5421 Some(format!("sk-legacy-{table}").as_str()),
5422 "env slug {slug} must read the user's own named table"
5423 );
5424 assert_eq!(
5425 resolved.api_key_source,
5426 Some(RuntimeApiKeySource::ConfigFile)
5427 );
5428 assert_eq!(resolved.base_url, base_url, "env slug {slug}");
5429 unsafe {
5430 env::remove_var("CODEWHALE_PROVIDER");
5431 }
5432 }
5433 }
5434
5435 #[test]
5436 fn config_set_legacy_dual_wire_slug_keeps_named_table_identity() {
5437 let _lock = env_lock();
5438 let _env = EnvGuard::without_deepseek_runtime_overrides();
5439 // `config set provider <legacy-slug>` must keep the table-owning kind —
5440 // collapsing to the primary would rewrite the config to a provider whose
5441 // table the user never configured.
5442 let mut config = ConfigToml::default();
5443 config
5444 .set_value("provider", "minimax-anthropic")
5445 .expect("legacy slug provider set");
5446 assert_eq!(config.provider, ProviderKind::MinimaxAnthropic);
5447
5448 // Flat `providers.<legacy>.<field>` keys target the legacy table, not a
5449 // synthetic custom provider.
5450 config
5451 .set_value("providers.minimax_anthropic.api_key", "sk-minimax-legacy")
5452 .expect("legacy table api_key set");
5453 assert_eq!(
5454 config.providers.minimax_anthropic.api_key.as_deref(),
5455 Some("sk-minimax-legacy")
5456 );
5457 assert!(config.providers.extras.is_empty());
5458 assert_eq!(
5459 config
5460 .get_value("providers.minimax_anthropic.api_key")
5461 .as_deref(),
5462 Some("sk-minimax-legacy")
5463 );
5464 config
5465 .unset_value("providers.minimax_anthropic.api_key")
5466 .expect("legacy table api_key unset");
5467 assert_eq!(config.providers.minimax_anthropic.api_key, None);
5468 }
5469
5470 #[test]
5471 fn sakana_env_overrides_resolve_fugu_route() {
5472 let _lock = env_lock();
5473 let _env = EnvGuard::without_deepseek_runtime_overrides();
5474 unsafe {
5475 env::set_var("CODEWHALE_PROVIDER", "sakana");
5476 env::set_var("SAKANA_BASE_URL", "https://sakana.example/v1");
5477 env::set_var("SAKANA_MODEL", "fugu-ultra-20260615");
5478 }
5479
5480 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
5481
5482 assert_eq!(resolved.provider, ProviderKind::Sakana);
5483 assert_eq!(resolved.base_url, "https://sakana.example/v1");
5484 assert_eq!(resolved.model, "fugu-ultra-20260615");
5485 }
5486
5487 #[test]
5488 fn moonshot_provider_preserves_explicit_kimi_k26() {
5489 let _lock = env_lock();
5490 let _env = EnvGuard::without_deepseek_runtime_overrides();
5491 let mut config = ConfigToml {
5492 provider: ProviderKind::Moonshot,
5493 ..ConfigToml::default()
5494 };
5495 config.providers.moonshot.model = Some("kimi-k2.6".to_string());
5496
5497 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5498
5499 assert_eq!(resolved.provider, ProviderKind::Moonshot);
5500 assert_eq!(resolved.model, MOONSHOT_KIMI_K2_6_MODEL);
5501 }
5502
5503 #[test]
5504 fn moonshot_legacy_kimi_import_uses_kimi_code_endpoint_and_model() {
5505 let _lock = env_lock();
5506 let _env = EnvGuard::without_deepseek_runtime_overrides();
5507 let mut config = ConfigToml {
5508 provider: ProviderKind::Moonshot,
5509 ..ConfigToml::default()
5510 };
5511 config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string());
5512
5513 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5514
5515 assert_eq!(resolved.provider, ProviderKind::Moonshot);
5516 assert_eq!(resolved.auth_mode.as_deref(), Some("kimi_oauth"));
5517 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
5518 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
5519 assert_eq!(resolved.api_key, None);
5520 assert_eq!(resolved.api_key_source, None);
5521 }
5522
5523 #[test]
5524 fn moonshot_kimi_code_api_key_endpoint_defaults_to_kimi_for_coding() {
5525 let _lock = env_lock();
5526 let _env = EnvGuard::without_deepseek_runtime_overrides();
5527 let mut config = ConfigToml {
5528 provider: ProviderKind::Moonshot,
5529 ..ConfigToml::default()
5530 };
5531 config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
5532 config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
5533
5534 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5535
5536 assert_eq!(resolved.provider, ProviderKind::Moonshot);
5537 assert_eq!(resolved.auth_mode, None);
5538 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
5539 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
5540 assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
5541 assert_eq!(
5542 resolved.api_key_source,
5543 Some(RuntimeApiKeySource::ConfigFile)
5544 );
5545 }
5546
5547 /// `CODEWHALE_PROVIDER` is the user-facing env alias for switching the
5548 /// active provider. It must be honored by the runtime resolver and win
5549 /// over a root `provider = "deepseek"` config entry.
5550 #[test]
5551 fn codewhale_provider_env_switches_active_provider() {
5552 let _lock = env_lock();
5553 let _env = EnvGuard::without_deepseek_runtime_overrides();
5554 // Safety: test-only env mutation guarded by env_lock().
5555 unsafe {
5556 env::set_var("CODEWHALE_PROVIDER", "moonshot");
5557 }
5558 let mut config = ConfigToml {
5559 provider: ProviderKind::Deepseek,
5560 ..ConfigToml::default()
5561 };
5562 config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
5563 config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
5564
5565 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5566
5567 assert_eq!(resolved.provider, ProviderKind::Moonshot);
5568 assert_eq!(
5569 resolved.provider_source,
5570 ProviderSource::Env("CODEWHALE_PROVIDER")
5571 );
5572 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
5573 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
5574 assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
5575 }
5576
5577 /// When both `CODEWHALE_PROVIDER` and the legacy `DEEPSEEK_PROVIDER`
5578 /// are set, the public alias wins — a user adopting `CODEWHALE_*` in a
5579 /// fresh shell config is not tripped up by a stale legacy export still
5580 /// living in their dotfiles.
5581 #[test]
5582 fn codewhale_provider_env_wins_over_deepseek_provider_env() {
5583 let _lock = env_lock();
5584 let _env = EnvGuard::without_deepseek_runtime_overrides();
5585 // Safety: test-only env mutation guarded by env_lock().
5586 unsafe {
5587 env::set_var("CODEWHALE_PROVIDER", "moonshot");
5588 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
5589 }
5590 let config = ConfigToml {
5591 provider: ProviderKind::Deepseek,
5592 ..ConfigToml::default()
5593 };
5594
5595 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5596
5597 assert_eq!(resolved.provider, ProviderKind::Moonshot);
5598 assert_eq!(
5599 resolved.provider_source,
5600 ProviderSource::Env("CODEWHALE_PROVIDER")
5601 );
5602 }
5603
5604 #[test]
5605 fn legacy_deepseek_provider_env_records_provider_source() {
5606 let _lock = env_lock();
5607 let _env = EnvGuard::without_deepseek_runtime_overrides();
5608 // Safety: test-only env mutation guarded by env_lock().
5609 unsafe {
5610 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
5611 }
5612 let config = ConfigToml {
5613 provider: ProviderKind::Deepseek,
5614 ..ConfigToml::default()
5615 };
5616
5617 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5618
5619 assert_eq!(resolved.provider, ProviderKind::Openrouter);
5620 assert_eq!(
5621 resolved.provider_source,
5622 ProviderSource::Env("DEEPSEEK_PROVIDER")
5623 );
5624 }
5625
5626 #[test]
5627 fn cli_provider_records_provider_source() {
5628 let _lock = env_lock();
5629 let _env = EnvGuard::without_deepseek_runtime_overrides();
5630 // Safety: test-only env mutation guarded by env_lock().
5631 unsafe {
5632 env::set_var("CODEWHALE_PROVIDER", "moonshot");
5633 }
5634 let cli = CliRuntimeOverrides {
5635 provider: Some(ProviderKind::Openai),
5636 ..CliRuntimeOverrides::default()
5637 };
5638 let config = ConfigToml {
5639 provider: ProviderKind::Deepseek,
5640 ..ConfigToml::default()
5641 };
5642
5643 let resolved = config.resolve_runtime_options(&cli);
5644
5645 assert_eq!(resolved.provider, ProviderKind::Openai);
5646 assert_eq!(resolved.provider_source, ProviderSource::Cli);
5647 }
5648
5649 #[test]
5650 fn config_provider_records_provider_source() {
5651 let _lock = env_lock();
5652 let _env = EnvGuard::without_deepseek_runtime_overrides();
5653 let config = ConfigToml {
5654 provider: ProviderKind::Moonshot,
5655 ..ConfigToml::default()
5656 };
5657
5658 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5659
5660 assert_eq!(resolved.provider, ProviderKind::Moonshot);
5661 assert_eq!(resolved.provider_source, ProviderSource::Config);
5662 }
5663
5664 /// `CODEWHALE_MODEL` is the user-facing env alias for picking a model
5665 /// against the active provider. It must be honored by the runtime
5666 /// resolver in place of `DEEPSEEK_MODEL`.
5667 #[test]
5668 fn codewhale_model_env_alias_overrides_default_for_active_provider() {
5669 let _lock = env_lock();
5670 let _env = EnvGuard::without_deepseek_runtime_overrides();
5671 // Safety: test-only env mutation guarded by env_lock().
5672 unsafe {
5673 env::set_var("CODEWHALE_PROVIDER", "moonshot");
5674 env::set_var("CODEWHALE_MODEL", "custom-kimi-test-model");
5675 }
5676 let config = ConfigToml::default();
5677
5678 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5679
5680 assert_eq!(resolved.provider, ProviderKind::Moonshot);
5681 assert_eq!(resolved.model, "custom-kimi-test-model");
5682 }
5683
5684 #[test]
5685 fn blank_codewhale_model_env_alias_does_not_override_default_for_active_provider() {
5686 let _lock = env_lock();
5687 let _env = EnvGuard::without_deepseek_runtime_overrides();
5688 // Safety: test-only env mutation guarded by env_lock().
5689 unsafe {
5690 env::set_var("CODEWHALE_PROVIDER", "moonshot");
5691 env::set_var("CODEWHALE_MODEL", " ");
5692 }
5693 let config = ConfigToml::default();
5694
5695 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5696
5697 assert_eq!(resolved.provider, ProviderKind::Moonshot);
5698 assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
5699 }
5700
5701 #[test]
5702 fn deepseek_default_text_model_legacy_alias_still_overrides_active_provider_model() {
5703 let _lock = env_lock();
5704 let _env = EnvGuard::without_deepseek_runtime_overrides();
5705 // Safety: test-only env mutation guarded by env_lock().
5706 unsafe {
5707 env::set_var("CODEWHALE_PROVIDER", "moonshot");
5708 env::set_var("DEEPSEEK_DEFAULT_TEXT_MODEL", "legacy-env-model");
5709 }
5710 let config = ConfigToml::default();
5711
5712 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5713
5714 assert_eq!(resolved.provider, ProviderKind::Moonshot);
5715 assert_eq!(resolved.model, "legacy-env-model");
5716 }
5717
5718 #[test]
5719 fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() {
5720 let _lock = env_lock();
5721 let _env = EnvGuard::without_deepseek_runtime_overrides();
5722 let config = ConfigToml {
5723 provider: ProviderKind::WanjieArk,
5724 ..ConfigToml::default()
5725 };
5726
5727 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5728
5729 assert_eq!(resolved.provider, ProviderKind::WanjieArk);
5730 assert_eq!(resolved.base_url, DEFAULT_WANJIE_ARK_BASE_URL);
5731 assert_eq!(resolved.model, DEFAULT_WANJIE_ARK_MODEL);
5732 }
5733
5734 #[test]
5735 fn sglang_provider_defaults_to_local_endpoint_and_model() {
5736 let _lock = env_lock();
5737 let _env = EnvGuard::without_deepseek_runtime_overrides();
5738 let config = ConfigToml {
5739 provider: ProviderKind::Sglang,
5740 ..ConfigToml::default()
5741 };
5742
5743 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5744
5745 assert_eq!(resolved.provider, ProviderKind::Sglang);
5746 assert_eq!(resolved.base_url, DEFAULT_SGLANG_BASE_URL);
5747 assert_eq!(resolved.model, DEFAULT_SGLANG_MODEL);
5748 }
5749
5750 #[test]
5751 fn vllm_provider_defaults_to_local_endpoint_and_model() {
5752 let _lock = env_lock();
5753 let _env = EnvGuard::without_deepseek_runtime_overrides();
5754 let config = ConfigToml {
5755 provider: ProviderKind::Vllm,
5756 ..ConfigToml::default()
5757 };
5758
5759 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5760
5761 assert_eq!(resolved.provider, ProviderKind::Vllm);
5762 assert_eq!(resolved.base_url, DEFAULT_VLLM_BASE_URL);
5763 assert_eq!(resolved.model, DEFAULT_VLLM_MODEL);
5764 }
5765
5766 #[test]
5767 fn ollama_provider_defaults_to_local_endpoint_and_small_model() {
5768 let _lock = env_lock();
5769 let _env = EnvGuard::without_deepseek_runtime_overrides();
5770 let config = ConfigToml {
5771 provider: ProviderKind::Ollama,
5772 ..ConfigToml::default()
5773 };
5774
5775 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5776
5777 assert_eq!(resolved.provider, ProviderKind::Ollama);
5778 assert_eq!(resolved.base_url, DEFAULT_OLLAMA_BASE_URL);
5779 assert_eq!(resolved.model, DEFAULT_OLLAMA_MODEL);
5780 assert_eq!(resolved.api_key, None);
5781 }
5782
5783 #[test]
5784 fn self_hosted_providers_do_not_probe_secret_store_by_default() {
5785 let _lock = env_lock();
5786 let _env = EnvGuard::without_deepseek_runtime_overrides();
5787 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
5788 let secrets = Secrets::new(store.clone());
5789
5790 for provider in [
5791 ProviderKind::Sglang,
5792 ProviderKind::Vllm,
5793 ProviderKind::Ollama,
5794 ] {
5795 let config = ConfigToml {
5796 provider,
5797 ..ConfigToml::default()
5798 };
5799
5800 let resolved =
5801 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
5802
5803 assert_eq!(resolved.provider, provider);
5804 assert_eq!(resolved.api_key, None);
5805 }
5806
5807 assert!(
5808 store.gets.lock().unwrap().is_empty(),
5809 "self-hosted providers should not read the secret store by default"
5810 );
5811 }
5812
5813 #[test]
5814 fn self_hosted_api_key_auth_can_use_secret_store_when_requested() {
5815 let _lock = env_lock();
5816 let _env = EnvGuard::without_deepseek_runtime_overrides();
5817 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
5818 let secrets = Secrets::new(store.clone());
5819 let config = ConfigToml {
5820 provider: ProviderKind::Ollama,
5821 auth_mode: Some("api_key".to_string()),
5822 ..ConfigToml::default()
5823 };
5824
5825 let resolved =
5826 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
5827
5828 assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
5829 assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]);
5830 }
5831
5832 #[test]
5833 fn moonshot_api_key_mode_can_use_secret_store_by_default() {
5834 let _lock = env_lock();
5835 let _env = EnvGuard::without_deepseek_runtime_overrides();
5836 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
5837 let secrets = Secrets::new(store.clone());
5838 let config = ConfigToml {
5839 provider: ProviderKind::Moonshot,
5840 ..ConfigToml::default()
5841 };
5842
5843 let resolved =
5844 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
5845
5846 assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
5847 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
5848 assert_eq!(store.gets.lock().unwrap().as_slice(), ["moonshot"]);
5849 }
5850
5851 #[test]
5852 fn modelstudio_variants_resolve_one_shared_secret_store_slot() {
5853 let _lock = env_lock();
5854 let _env = EnvGuard::without_deepseek_runtime_overrides();
5855 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
5856 let secrets = Secrets::new(store.clone());
5857
5858 // One Alibaba Cloud Model Studio account authenticates every plan/dialect
5859 // variant, so all four resolve the family's single canonical slot.
5860 for provider in [
5861 ProviderKind::ModelstudioTokenPlan,
5862 ProviderKind::ModelstudioTokenPlanAnthropic,
5863 ProviderKind::ModelstudioCodingPlan,
5864 ProviderKind::ModelstudioCodingPlanAnthropic,
5865 ] {
5866 let config = ConfigToml {
5867 provider,
5868 ..ConfigToml::default()
5869 };
5870
5871 let resolved =
5872 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
5873
5874 assert_eq!(resolved.provider, provider);
5875 assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
5876 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
5877 }
5878
5879 assert_eq!(
5880 store.gets.lock().unwrap().as_slice(),
5881 [
5882 "modelstudio-token-plan",
5883 "modelstudio-token-plan",
5884 "modelstudio-token-plan",
5885 "modelstudio-token-plan"
5886 ]
5887 );
5888 }
5889
5890 #[test]
5891 fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() {
5892 let _lock = env_lock();
5893 let _env = EnvGuard::without_deepseek_runtime_overrides();
5894 let store = Arc::new(RecordingSecretsStore::with_value("stale-deepseek-key"));
5895 let secrets = Secrets::new(store.clone());
5896 let config = ConfigToml {
5897 base_url: Some("http://127.0.0.1:8000/v1".to_string()),
5898 ..ConfigToml::default()
5899 };
5900
5901 let resolved =
5902 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
5903
5904 assert_eq!(resolved.provider, ProviderKind::Deepseek);
5905 assert_eq!(resolved.base_url, "http://127.0.0.1:8000/v1");
5906 assert_eq!(resolved.api_key, None);
5907 assert!(
5908 store.gets.lock().unwrap().is_empty(),
5909 "loopback custom endpoints should not read macOS Keychain or any secret store"
5910 );
5911 }
5912
5913 #[test]
5914 fn remote_custom_provider_endpoint_does_not_reuse_ambient_official_credentials() {
5915 let _lock = env_lock();
5916 let _env = EnvGuard::without_deepseek_runtime_overrides();
5917 // Safety: test-only env mutation guarded by env_lock().
5918 unsafe { env::set_var("OPENROUTER_API_KEY", "ambient-official-key") };
5919 let store = Arc::new(RecordingSecretsStore::with_value("saved-official-key"));
5920 let secrets = Secrets::new(store.clone());
5921 let mut config = ConfigToml {
5922 provider: ProviderKind::Openrouter,
5923 ..ConfigToml::default()
5924 };
5925 config.providers.openrouter.base_url = Some("https://gateway.example/v1".to_string());
5926
5927 let resolved =
5928 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
5929
5930 assert_eq!(resolved.base_url, "https://gateway.example/v1");
5931 assert_eq!(resolved.api_key, None);
5932 assert_eq!(resolved.api_key_source, None);
5933 assert!(
5934 store.gets.lock().unwrap().is_empty(),
5935 "a custom endpoint must not read the provider's global secret slot"
5936 );
5937 }
5938
5939 #[test]
5940 fn env_custom_provider_endpoint_does_not_reuse_ambient_official_credentials() {
5941 let _lock = env_lock();
5942 let _env = EnvGuard::without_deepseek_runtime_overrides();
5943 // A provider-specific base URL and API key are independent ambient
5944 // exports; the resolver cannot safely assume the key belongs to a remote
5945 // custom gateway. Custom routes must bind their credential explicitly.
5946 // Safety: test-only env mutation guarded by env_lock().
5947 unsafe {
5948 env::set_var("OPENROUTER_BASE_URL", "https://gateway.example/v1");
5949 env::set_var("OPENROUTER_API_KEY", "ambient-official-key");
5950 }
5951 let store = Arc::new(RecordingSecretsStore::with_value("saved-official-key"));
5952 let secrets = Secrets::new(store.clone());
5953 let config = ConfigToml {
5954 provider: ProviderKind::Openrouter,
5955 ..ConfigToml::default()
5956 };
5957
5958 let resolved =
5959 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
5960
5961 assert_eq!(resolved.base_url, "https://gateway.example/v1");
5962 assert_eq!(resolved.api_key, None);
5963 assert_eq!(resolved.api_key_source, None);
5964 assert!(
5965 store.gets.lock().unwrap().is_empty(),
5966 "an env-supplied custom endpoint must not read the provider's global secret slot"
5967 );
5968 }
5969
5970 #[test]
5971 fn remote_custom_provider_endpoint_accepts_only_explicitly_bound_credentials() {
5972 let _lock = env_lock();
5973 let _env = EnvGuard::without_deepseek_runtime_overrides();
5974 // Safety: test-only env mutation guarded by env_lock().
5975 unsafe { env::set_var("OPENROUTER_API_KEY", "ambient-official-key") };
5976 let store = Arc::new(RecordingSecretsStore::with_value("saved-official-key"));
5977 let secrets = Secrets::new(store.clone());
5978 let mut config = ConfigToml {
5979 provider: ProviderKind::Openrouter,
5980 ..ConfigToml::default()
5981 };
5982 config.providers.openrouter.base_url = Some("https://gateway.example/v1".to_string());
5983 config.providers.openrouter.api_key = Some("route-bound-key".to_string());
5984
5985 let resolved =
5986 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
5987
5988 assert_eq!(resolved.api_key.as_deref(), Some("route-bound-key"));
5989 assert_eq!(
5990 resolved.api_key_source,
5991 Some(RuntimeApiKeySource::ConfigFile)
5992 );
5993 assert!(store.gets.lock().unwrap().is_empty());
5994 }
5995
5996 #[test]
5997 fn auth_mode_none_suppresses_every_runtime_credential_source() {
5998 let _lock = env_lock();
5999 let _env = EnvGuard::without_deepseek_runtime_overrides();
6000 // Safety: test-only env mutation guarded by env_lock().
6001 unsafe { env::set_var("OPENROUTER_API_KEY", "ambient-key") };
6002 let store = Arc::new(RecordingSecretsStore::with_value("saved-key"));
6003 let secrets = Secrets::new(store.clone());
6004 let mut config = ConfigToml {
6005 provider: ProviderKind::Openrouter,
6006 auth_mode: Some("none".to_string()),
6007 ..ConfigToml::default()
6008 };
6009 config.providers.openrouter.api_key = Some("configured-key".to_string());
6010 config.http_headers.insert(
6011 "aUtHoRiZaTiOn".to_string(),
6012 "Bearer configured-header-secret".to_string(),
6013 );
6014 config.providers.openrouter.http_headers.insert(
6015 "X-API-Key".to_string(),
6016 "configured-x-api-key-secret".to_string(),
6017 );
6018 config.providers.openrouter.http_headers.insert(
6019 "Api-Key".to_string(),
6020 "configured-api-key-secret".to_string(),
6021 );
6022 config.providers.openrouter.http_headers.extend([
6023 (
6024 "Proxy-Authorization".to_string(),
6025 "Basic configured-proxy-secret".to_string(),
6026 ),
6027 (
6028 "X-Auth-Token".to_string(),
6029 "configured-auth-token".to_string(),
6030 ),
6031 (
6032 "X-Access-Token".to_string(),
6033 "configured-access-token".to_string(),
6034 ),
6035 (
6036 "X-Goog-Api-Key".to_string(),
6037 "configured-google-key".to_string(),
6038 ),
6039 ("Cookie".to_string(), "session=secret".to_string()),
6040 ]);
6041 config
6042 .providers
6043 .openrouter
6044 .http_headers
6045 .insert("X-Route-Metadata".to_string(), "safe".to_string());
6046 let cli = CliRuntimeOverrides {
6047 api_key: Some("explicit-cli-key".to_string()),
6048 ..CliRuntimeOverrides::default()
6049 };
6050
6051 let resolved = config.resolve_runtime_options_with_secrets(&cli, &secrets);
6052
6053 assert_eq!(resolved.api_key, None);
6054 assert_eq!(resolved.api_key_source, None);
6055 for name in [
6056 "authorization",
6057 "x-api-key",
6058 "api-key",
6059 "proxy-authorization",
6060 "x-auth-token",
6061 "x-access-token",
6062 "x-goog-api-key",
6063 "cookie",
6064 ] {
6065 assert!(
6066 !resolved
6067 .http_headers
6068 .keys()
6069 .any(|candidate| candidate.eq_ignore_ascii_case(name)),
6070 "disabled auth leaked {name}: {:?}",
6071 resolved.http_headers
6072 );
6073 }
6074 assert_eq!(
6075 resolved
6076 .http_headers
6077 .get("X-Route-Metadata")
6078 .map(String::as_str),
6079 Some("safe")
6080 );
6081 assert!(
6082 store.gets.lock().unwrap().is_empty(),
6083 "disabled auth must not probe any durable credential source"
6084 );
6085 }
6086
6087 #[test]
6088 fn deepseek_official_endpoint_family_shares_canonical_model_namespace() {
6089 let _lock = env_lock();
6090 let _env = EnvGuard::without_deepseek_runtime_overrides();
6091
6092 for base_url in [
6093 "https://api.deepseek.com",
6094 "https://api.deepseek.com/v1/",
6095 "https://api.deepseek.com/beta",
6096 ] {
6097 assert!(provider_base_url_is_official(
6098 ProviderKind::Deepseek,
6099 base_url
6100 ));
6101 assert!(!provider_preserves_custom_base_url_model(
6102 ProviderKind::Deepseek,
6103 base_url
6104 ));
6105 }
6106 assert!(!provider_base_url_is_official(
6107 ProviderKind::Deepseek,
6108 "https://api.deepseek.com.evil.example/v1"
6109 ));
6110 }
6111
6112 #[test]
6113 fn ollama_provider_preserves_model_tags() {
6114 let _lock = env_lock();
6115 let _env = EnvGuard::without_deepseek_runtime_overrides();
6116 let cli = CliRuntimeOverrides {
6117 provider: Some(ProviderKind::Ollama),
6118 model: Some("deepseek-coder-v2:16b".to_string()),
6119 ..CliRuntimeOverrides::default()
6120 };
6121
6122 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
6123
6124 assert_eq!(resolved.provider, ProviderKind::Ollama);
6125 assert_eq!(resolved.model, "deepseek-coder-v2:16b");
6126 }
6127
6128 #[test]
6129 fn ollama_remote_env_url_does_not_inherit_ambient_optional_key() {
6130 let _lock = env_lock();
6131 let _env = EnvGuard::without_deepseek_runtime_overrides();
6132 // Safety: test-only environment mutation guarded by a module mutex.
6133 unsafe {
6134 env::set_var("DEEPSEEK_PROVIDER", "ollama-local");
6135 env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1");
6136 env::set_var("OLLAMA_API_KEY", "ollama-env-key");
6137 }
6138
6139 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6140
6141 assert_eq!(resolved.provider, ProviderKind::Ollama);
6142 assert_eq!(resolved.base_url, "http://ollama.example/v1");
6143 assert_eq!(resolved.api_key, None);
6144 assert_eq!(resolved.api_key_source, None);
6145 }
6146
6147 #[test]
6148 fn openrouter_env_overrides_key_and_model_when_config_missing() {
6149 let _lock = env_lock();
6150 let _env = EnvGuard::without_deepseek_runtime_overrides();
6151 // Safety: test-only environment mutation guarded by a module mutex.
6152 unsafe {
6153 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
6154 env::set_var("OPENROUTER_API_KEY", "or-env-key");
6155 env::set_var("OPENROUTER_MODEL", "deepseek-v4-flash");
6156 }
6157
6158 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6159
6160 assert_eq!(resolved.provider, ProviderKind::Openrouter);
6161 assert_eq!(resolved.api_key.as_deref(), Some("or-env-key"));
6162 assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
6163 assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL);
6164 }
6165
6166 #[test]
6167 fn xiaomi_mimo_custom_env_url_does_not_inherit_ambient_key() {
6168 let _lock = env_lock();
6169 let _env = EnvGuard::without_deepseek_runtime_overrides();
6170 // Safety: test-only environment mutation guarded by a module mutex.
6171 unsafe {
6172 env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
6173 env::set_var("MIMO_API_KEY", "mimo-env-key");
6174 env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1");
6175 env::set_var("MIMO_MODEL", "mimo-v2.5");
6176 }
6177
6178 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6179
6180 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6181 assert_eq!(resolved.api_key, None);
6182 assert_eq!(resolved.api_key_source, None);
6183 assert_eq!(resolved.base_url, "https://mimo-gateway.example/v1");
6184 assert_eq!(resolved.model, "mimo-v2.5");
6185 }
6186
6187 #[test]
6188 fn xiaomi_mimo_env_token_plan_mode_uses_token_plan_key_and_endpoint() {
6189 let _lock = env_lock();
6190 let _env = EnvGuard::without_deepseek_runtime_overrides();
6191 // Safety: test-only environment mutation guarded by a module mutex.
6192 unsafe {
6193 env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
6194 env::set_var("XIAOMI_MIMO_MODE", "token-plan-cn");
6195 env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key");
6196 env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key");
6197 }
6198
6199 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6200
6201 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6202 assert_eq!(resolved.api_key.as_deref(), Some("tp-env-key"));
6203 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
6204 assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL);
6205 }
6206
6207 #[test]
6208 fn xiaomi_mimo_env_pay_as_you_go_mode_prefers_standard_key() {
6209 let _lock = env_lock();
6210 let _env = EnvGuard::without_deepseek_runtime_overrides();
6211 // Safety: test-only environment mutation guarded by a module mutex.
6212 unsafe {
6213 env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
6214 env::set_var("XIAOMI_MIMO_MODE", "pay-as-you-go");
6215 env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key");
6216 env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key");
6217 }
6218
6219 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6220
6221 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6222 assert_eq!(resolved.api_key.as_deref(), Some("sk-env-key"));
6223 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
6224 assert_eq!(resolved.base_url, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL);
6225 }
6226
6227 #[test]
6228 fn novita_env_overrides_key_and_model_when_config_missing() {
6229 let _lock = env_lock();
6230 let _env = EnvGuard::without_deepseek_runtime_overrides();
6231 // Safety: test-only environment mutation guarded by a module mutex.
6232 unsafe {
6233 env::set_var("DEEPSEEK_PROVIDER", "novita");
6234 env::set_var("NOVITA_API_KEY", "novita-env-key");
6235 env::set_var("NOVITA_MODEL", "deepseek-v4-flash");
6236 }
6237
6238 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6239
6240 assert_eq!(resolved.provider, ProviderKind::Novita);
6241 assert_eq!(resolved.api_key.as_deref(), Some("novita-env-key"));
6242 assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
6243 assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
6244 }
6245
6246 #[test]
6247 fn fireworks_env_overrides_key_and_model_when_config_missing() {
6248 let _lock = env_lock();
6249 let _env = EnvGuard::without_deepseek_runtime_overrides();
6250 // Safety: test-only environment mutation guarded by a module mutex.
6251 unsafe {
6252 env::set_var("DEEPSEEK_PROVIDER", "fireworks");
6253 env::set_var("FIREWORKS_API_KEY", "fw-env-key");
6254 env::set_var(
6255 "FIREWORKS_MODEL",
6256 "accounts/fireworks/models/account-specific-model",
6257 );
6258 }
6259
6260 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6261
6262 assert_eq!(resolved.provider, ProviderKind::Fireworks);
6263 assert_eq!(resolved.api_key.as_deref(), Some("fw-env-key"));
6264 assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
6265 assert_eq!(
6266 resolved.model,
6267 "accounts/fireworks/models/account-specific-model"
6268 );
6269 }
6270
6271 #[test]
6272 fn siliconflow_custom_env_url_does_not_inherit_ambient_key() {
6273 let _lock = env_lock();
6274 let _env = EnvGuard::without_deepseek_runtime_overrides();
6275 // Safety: test-only environment mutation guarded by a module mutex.
6276 unsafe {
6277 env::set_var("CODEWHALE_PROVIDER", "siliconflow");
6278 env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
6279 env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1");
6280 env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash");
6281 }
6282
6283 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6284
6285 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
6286 assert_eq!(resolved.api_key, None);
6287 assert_eq!(resolved.api_key_source, None);
6288 assert_eq!(resolved.base_url, "https://sf-mirror.example/v1");
6289 assert_eq!(resolved.model, "deepseek-v4-flash");
6290 }
6291
6292 #[test]
6293 fn arcee_provider_defaults_to_direct_api_endpoint_and_model() {
6294 let _lock = env_lock();
6295 let _env = EnvGuard::without_deepseek_runtime_overrides();
6296 let config = ConfigToml {
6297 provider: ProviderKind::Arcee,
6298 ..ConfigToml::default()
6299 };
6300
6301 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6302
6303 assert_eq!(resolved.provider, ProviderKind::Arcee);
6304 assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL);
6305 assert_eq!(resolved.model, DEFAULT_ARCEE_MODEL);
6306 }
6307
6308 #[test]
6309 fn arcee_custom_env_url_does_not_inherit_ambient_key() {
6310 let _lock = env_lock();
6311 let _env = EnvGuard::without_deepseek_runtime_overrides();
6312 // Safety: test-only environment mutation guarded by a module mutex.
6313 unsafe {
6314 env::set_var("CODEWHALE_PROVIDER", "arcee");
6315 env::set_var("ARCEE_API_KEY", "arcee-env-key");
6316 env::set_var("ARCEE_BASE_URL", "https://arcee-mirror.example/api/v1");
6317 env::set_var("ARCEE_MODEL", "trinity-large-preview");
6318 }
6319
6320 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6321
6322 assert_eq!(resolved.provider, ProviderKind::Arcee);
6323 assert_eq!(resolved.api_key, None);
6324 assert_eq!(resolved.api_key_source, None);
6325 assert_eq!(resolved.base_url, "https://arcee-mirror.example/api/v1");
6326 assert_eq!(resolved.model, "trinity-large-preview");
6327 }
6328
6329 #[test]
6330 fn arcee_provider_config_overrides_runtime_defaults() {
6331 let _lock = env_lock();
6332 let _env = EnvGuard::without_deepseek_runtime_overrides();
6333 let mut config = ConfigToml {
6334 provider: ProviderKind::Arcee,
6335 ..ConfigToml::default()
6336 };
6337 config.providers.arcee.api_key = Some("arcee-file-key".to_string());
6338 config.providers.arcee.base_url = Some(DEFAULT_ARCEE_BASE_URL.to_string());
6339 config.providers.arcee.model = Some("arcee-trinity-large-preview".to_string());
6340
6341 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6342
6343 assert_eq!(resolved.provider, ProviderKind::Arcee);
6344 assert_eq!(resolved.api_key.as_deref(), Some("arcee-file-key"));
6345 assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL);
6346 assert_eq!(resolved.model, ARCEE_TRINITY_LARGE_PREVIEW_MODEL);
6347 }
6348
6349 #[test]
6350 fn huggingface_custom_env_url_does_not_inherit_documented_key_names() {
6351 let _lock = env_lock();
6352 let _env = EnvGuard::without_deepseek_runtime_overrides();
6353 // Safety: test-only environment mutation guarded by a module mutex.
6354 unsafe {
6355 env::set_var("CODEWHALE_PROVIDER", "hf");
6356 env::set_var("HUGGINGFACE_API_KEY", "hf-full-key");
6357 env::set_var("HF_TOKEN", "hf-token-fallback");
6358 env::set_var("HUGGINGFACE_BASE_URL", "https://hf-full.example/v1");
6359 env::set_var("HF_BASE_URL", "https://hf-short.example/v1");
6360 env::set_var("HUGGINGFACE_MODEL", "org/full-model");
6361 env::set_var("HF_MODEL", "org/short-model");
6362 }
6363
6364 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6365
6366 assert_eq!(resolved.provider, ProviderKind::Huggingface);
6367 assert_eq!(resolved.api_key, None);
6368 assert_eq!(resolved.api_key_source, None);
6369 assert_eq!(resolved.base_url, "https://hf-full.example/v1");
6370 assert_eq!(resolved.model, "org/full-model");
6371 }
6372
6373 #[test]
6374 fn huggingface_short_custom_env_url_does_not_inherit_ambient_token() {
6375 let _lock = env_lock();
6376 let _env = EnvGuard::without_deepseek_runtime_overrides();
6377 // Safety: test-only environment mutation guarded by a module mutex.
6378 unsafe {
6379 env::set_var("CODEWHALE_PROVIDER", "huggingface");
6380 env::set_var("HF_TOKEN", "hf-token-fallback");
6381 env::set_var("HF_BASE_URL", "https://hf-short.example/v1");
6382 env::set_var("HF_MODEL", "org/short-model");
6383 }
6384
6385 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6386
6387 assert_eq!(resolved.provider, ProviderKind::Huggingface);
6388 assert_eq!(resolved.api_key, None);
6389 assert_eq!(resolved.api_key_source, None);
6390 assert_eq!(resolved.base_url, "https://hf-short.example/v1");
6391 assert_eq!(resolved.model, "org/short-model");
6392 }
6393
6394 #[test]
6395 fn huggingface_token_fallback_resolves_when_primary_api_key_is_blank() {
6396 let _lock = env_lock();
6397 let _env = EnvGuard::without_deepseek_runtime_overrides();
6398 // Safety: test-only environment mutation guarded by a module mutex.
6399 unsafe {
6400 env::set_var("CODEWHALE_PROVIDER", "huggingface");
6401 env::set_var("HUGGINGFACE_API_KEY", " ");
6402 env::set_var("HF_TOKEN", "hf-token-fallback");
6403 }
6404
6405 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6406
6407 assert_eq!(resolved.provider, ProviderKind::Huggingface);
6408 assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback"));
6409 }
6410
6411 #[test]
6412 fn siliconflow_cn_base_url_env_normalizes_model_aliases() {
6413 let _lock = env_lock();
6414 let _env = EnvGuard::without_deepseek_runtime_overrides();
6415 // Safety: test-only environment mutation guarded by a module mutex.
6416 unsafe {
6417 env::set_var("CODEWHALE_PROVIDER", "siliconflow");
6418 env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
6419 env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1");
6420 }
6421
6422 for (alias, expected) in [
6423 ("deepseek-v4-flash", DEFAULT_SILICONFLOW_FLASH_MODEL),
6424 ("deepseek-reasoner", DEFAULT_SILICONFLOW_MODEL),
6425 ] {
6426 // Safety: test-only environment mutation guarded by a module mutex.
6427 unsafe {
6428 env::set_var("SILICONFLOW_MODEL", alias);
6429 }
6430
6431 let resolved =
6432 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6433
6434 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
6435 assert_eq!(resolved.base_url, "https://api.siliconflow.cn/v1");
6436 assert_eq!(resolved.model, expected);
6437 }
6438 }
6439
6440 #[test]
6441 fn wanjie_ark_custom_env_url_does_not_inherit_ambient_key() {
6442 let _lock = env_lock();
6443 let _env = EnvGuard::without_deepseek_runtime_overrides();
6444 // Safety: test-only environment mutation guarded by a module mutex.
6445 unsafe {
6446 env::set_var("DEEPSEEK_PROVIDER", "wanjie-ark");
6447 env::set_var("WANJIE_ARK_API_KEY", "wanjie-env-key");
6448 env::set_var("WANJIE_ARK_BASE_URL", "https://wanjie.example/api/v1");
6449 env::set_var("WANJIE_ARK_MODEL", "account-model-id");
6450 }
6451
6452 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6453
6454 assert_eq!(resolved.provider, ProviderKind::WanjieArk);
6455 assert_eq!(resolved.api_key, None);
6456 assert_eq!(resolved.api_key_source, None);
6457 assert_eq!(resolved.base_url, "https://wanjie.example/api/v1");
6458 assert_eq!(resolved.model, "account-model-id");
6459 }
6460
6461 #[test]
6462 fn volcengine_custom_env_alias_url_does_not_inherit_ambient_key() {
6463 let _lock = env_lock();
6464 let _env = EnvGuard::without_deepseek_runtime_overrides();
6465 // Safety: test-only environment mutation guarded by a module mutex.
6466 unsafe {
6467 env::set_var("DEEPSEEK_PROVIDER", "volcengine");
6468 env::set_var("ARK_API_KEY", "volcengine-env-key");
6469 env::set_var("ARK_BASE_URL", "https://volcengine.example/api/coding/v3");
6470 env::set_var("VOLCENGINE_ARK_MODEL", "DeepSeek-V4-Flash");
6471 }
6472
6473 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6474
6475 assert_eq!(resolved.provider, ProviderKind::Volcengine);
6476 assert_eq!(resolved.api_key, None);
6477 assert_eq!(resolved.api_key_source, None);
6478 assert_eq!(
6479 resolved.base_url,
6480 "https://volcengine.example/api/coding/v3"
6481 );
6482 assert_eq!(resolved.model, "DeepSeek-V4-Flash");
6483 }
6484
6485 #[test]
6486 fn openrouter_provider_normalizes_flash_aliases() {
6487 let _lock = env_lock();
6488 let _env = EnvGuard::without_deepseek_runtime_overrides();
6489 let cli = CliRuntimeOverrides {
6490 provider: Some(ProviderKind::Openrouter),
6491 model: Some("deepseek-v4-flash".to_string()),
6492 ..CliRuntimeOverrides::default()
6493 };
6494
6495 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
6496
6497 assert_eq!(resolved.provider, ProviderKind::Openrouter);
6498 assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL);
6499 }
6500
6501 #[test]
6502 fn qwen3_6_plus_resolves_to_canonical_on_openrouter() {
6503 let _lock = env_lock();
6504 let _env = EnvGuard::without_deepseek_runtime_overrides();
6505 let config = ConfigToml {
6506 provider: ProviderKind::Openrouter,
6507 ..ConfigToml::default()
6508 };
6509
6510 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides {
6511 model: Some("qwen3.6-plus".to_string()),
6512 ..CliRuntimeOverrides::default()
6513 });
6514
6515 assert_eq!(resolved.provider, ProviderKind::Openrouter);
6516 assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL);
6517 }
6518
6519 #[test]
6520 fn qwen3_6_plus_alias_qwen_dash_resolves() {
6521 let _lock = env_lock();
6522 let _env = EnvGuard::without_deepseek_runtime_overrides();
6523 let config = ConfigToml {
6524 provider: ProviderKind::Openrouter,
6525 ..ConfigToml::default()
6526 };
6527
6528 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides {
6529 model: Some("qwen-3.6-plus".to_string()),
6530 ..CliRuntimeOverrides::default()
6531 });
6532
6533 assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL);
6534 }
6535
6536 #[test]
6537 fn openrouter_provider_normalizes_recent_large_model_aliases() {
6538 let _lock = env_lock();
6539 let _env = EnvGuard::without_deepseek_runtime_overrides();
6540
6541 for (alias, expected) in [
6542 (
6543 "trinity-large-thinking",
6544 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL,
6545 ),
6546 ("qwen3.6-flash", OPENROUTER_QWEN_3_6_FLASH_MODEL),
6547 ("qwen3.6-35b-a3b", OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
6548 ("qwen3.6-max-preview", OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
6549 ("qwen3.6-plus", OPENROUTER_QWEN_3_6_PLUS_MODEL),
6550 ("qwen3.7-plus", OPENROUTER_QWEN_3_7_PLUS_MODEL),
6551 ("qwen-3.7-plus", OPENROUTER_QWEN_3_7_PLUS_MODEL),
6552 ("mimo-v2.5-pro", OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
6553 ("kimi-k2.7-code", OPENROUTER_KIMI_K2_7_CODE_MODEL),
6554 ("kimi", OPENROUTER_KIMI_K2_7_CODE_MODEL),
6555 ("kimi-k2.6", OPENROUTER_KIMI_K2_6_MODEL),
6556 ("minimax-m3", OPENROUTER_MINIMAX_M3_MODEL),
6557 ("minimax-2.7", OPENROUTER_MINIMAX_M2_7_MODEL),
6558 ("gemma-4-31b-it", OPENROUTER_GEMMA_4_31B_MODEL),
6559 ("glm-5.1", OPENROUTER_GLM_5_1_MODEL),
6560 ("glm-5.2", OPENROUTER_GLM_5_2_MODEL),
6561 ("glm-5.3", OPENROUTER_GLM_5_3_MODEL),
6562 ] {
6563 let cli = CliRuntimeOverrides {
6564 provider: Some(ProviderKind::Openrouter),
6565 model: Some(alias.to_string()),
6566 ..CliRuntimeOverrides::default()
6567 };
6568
6569 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
6570
6571 assert_eq!(resolved.provider, ProviderKind::Openrouter);
6572 assert_eq!(resolved.model, expected);
6573 }
6574 }
6575
6576 #[test]
6577 fn novita_provider_normalizes_flash_aliases() {
6578 let _lock = env_lock();
6579 let _env = EnvGuard::without_deepseek_runtime_overrides();
6580 let cli = CliRuntimeOverrides {
6581 provider: Some(ProviderKind::Novita),
6582 model: Some("deepseek-v4-flash".to_string()),
6583 ..CliRuntimeOverrides::default()
6584 };
6585
6586 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
6587
6588 assert_eq!(resolved.provider, ProviderKind::Novita);
6589 assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
6590 }
6591
6592 #[test]
6593 fn siliconflow_provider_normalizes_flash_aliases() {
6594 let _lock = env_lock();
6595 let _env = EnvGuard::without_deepseek_runtime_overrides();
6596 let cli = CliRuntimeOverrides {
6597 provider: Some(ProviderKind::Siliconflow),
6598 model: Some("deepseek-v4-flash".to_string()),
6599 ..CliRuntimeOverrides::default()
6600 };
6601
6602 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
6603
6604 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
6605 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL);
6606 }
6607
6608 #[test]
6609 fn siliconflow_provider_normalizes_reasoning_aliases_to_pro() {
6610 let _lock = env_lock();
6611 let _env = EnvGuard::without_deepseek_runtime_overrides();
6612
6613 for alias in ["deepseek-reasoner", "deepseek-r1"] {
6614 let cli = CliRuntimeOverrides {
6615 provider: Some(ProviderKind::Siliconflow),
6616 model: Some(alias.to_string()),
6617 ..CliRuntimeOverrides::default()
6618 };
6619
6620 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
6621
6622 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
6623 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
6624 }
6625 }
6626
6627 #[test]
6628 fn siliconflow_provider_preserves_deepseek_v3_2_alias() {
6629 let _lock = env_lock();
6630 let _env = EnvGuard::without_deepseek_runtime_overrides();
6631 let cli = CliRuntimeOverrides {
6632 provider: Some(ProviderKind::Siliconflow),
6633 model: Some("deepseek-v3.2".to_string()),
6634 ..CliRuntimeOverrides::default()
6635 };
6636
6637 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
6638
6639 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
6640 assert_eq!(resolved.model, "deepseek-v3.2");
6641 }
6642
6643 #[test]
6644 fn sglang_provider_normalizes_flash_aliases() {
6645 let _lock = env_lock();
6646 let _env = EnvGuard::without_deepseek_runtime_overrides();
6647 let cli = CliRuntimeOverrides {
6648 provider: Some(ProviderKind::Sglang),
6649 model: Some("deepseek-v4-flash".to_string()),
6650 ..CliRuntimeOverrides::default()
6651 };
6652
6653 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
6654
6655 assert_eq!(resolved.provider, ProviderKind::Sglang);
6656 assert_eq!(resolved.model, DEFAULT_SGLANG_FLASH_MODEL);
6657 }
6658
6659 #[test]
6660 fn vllm_provider_normalizes_flash_aliases() {
6661 let _lock = env_lock();
6662 let _env = EnvGuard::without_deepseek_runtime_overrides();
6663 let cli = CliRuntimeOverrides {
6664 provider: Some(ProviderKind::Vllm),
6665 model: Some("deepseek-v4-flash".to_string()),
6666 ..CliRuntimeOverrides::default()
6667 };
6668
6669 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
6670
6671 assert_eq!(resolved.provider, ProviderKind::Vllm);
6672 assert_eq!(resolved.model, DEFAULT_VLLM_FLASH_MODEL);
6673 }
6674
6675 #[test]
6676 fn openrouter_provider_specific_config_overrides_env() {
6677 let _lock = env_lock();
6678 let _env = EnvGuard::without_deepseek_runtime_overrides();
6679 let mut config = ConfigToml {
6680 provider: ProviderKind::Openrouter,
6681 ..ConfigToml::default()
6682 };
6683 config.providers.openrouter.api_key = Some("file-key".to_string());
6684 config.providers.openrouter.base_url = Some("https://or-mirror.example/v1".to_string());
6685
6686 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6687
6688 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
6689 assert_eq!(resolved.base_url, "https://or-mirror.example/v1");
6690 }
6691
6692 #[test]
6693 fn openrouter_custom_base_url_preserves_provider_model() {
6694 let _lock = env_lock();
6695 let _env = EnvGuard::without_deepseek_runtime_overrides();
6696 let mut config = ConfigToml {
6697 provider: ProviderKind::Openrouter,
6698 ..ConfigToml::default()
6699 };
6700 config.providers.openrouter.base_url = Some("https://gateway.example.com/v1".to_string());
6701 config.providers.openrouter.model = Some("DeepSeek-V4-Pro".to_string());
6702
6703 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6704
6705 assert_eq!(resolved.provider, ProviderKind::Openrouter);
6706 assert_eq!(resolved.base_url, "https://gateway.example.com/v1");
6707 assert_eq!(resolved.model, "DeepSeek-V4-Pro");
6708 }
6709
6710 #[test]
6711 fn openai_compatible_tokenhub_route_preserves_provider_scope() {
6712 let _lock = env_lock();
6713 let _env = EnvGuard::without_deepseek_runtime_overrides();
6714 let mut config = ConfigToml {
6715 provider: ProviderKind::Openai,
6716 ..ConfigToml::default()
6717 };
6718 config.providers.openai.api_key = Some("tokenhub-file-key".to_string());
6719 config.providers.openai.base_url = Some("https://tokenhub.tencentmaas.com/v1".to_string());
6720 config.providers.openai.model = Some("deepseek-ai/DeepSeek-V4-Pro".to_string());
6721
6722 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6723
6724 assert_eq!(resolved.provider, ProviderKind::Openai);
6725 assert_eq!(resolved.api_key.as_deref(), Some("tokenhub-file-key"));
6726 assert_eq!(resolved.base_url, "https://tokenhub.tencentmaas.com/v1");
6727 assert_eq!(resolved.model, "deepseek-ai/DeepSeek-V4-Pro");
6728 }
6729
6730 #[test]
6731 fn openrouter_compatible_base_url_preserves_namespaced_wire_model() {
6732 let _lock = env_lock();
6733 let _env = EnvGuard::without_deepseek_runtime_overrides();
6734 let mut config = ConfigToml {
6735 provider: ProviderKind::Openrouter,
6736 ..ConfigToml::default()
6737 };
6738 config.providers.openrouter.base_url = Some("https://openrouter-compatible.example/v1".into());
6739 config.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".into());
6740
6741 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6742
6743 assert_eq!(resolved.provider, ProviderKind::Openrouter);
6744 assert_eq!(resolved.provider_source, ProviderSource::Config);
6745 assert_eq!(
6746 resolved.base_url,
6747 "https://openrouter-compatible.example/v1"
6748 );
6749 assert_eq!(resolved.model, "deepseek/deepseek-v4-pro");
6750 }
6751
6752 #[test]
6753 fn fireworks_custom_base_url_preserves_provider_model() {
6754 let _lock = env_lock();
6755 let _env = EnvGuard::without_deepseek_runtime_overrides();
6756 let mut config = ConfigToml {
6757 provider: ProviderKind::Fireworks,
6758 ..ConfigToml::default()
6759 };
6760 config.providers.fireworks.base_url = Some("https://my-gateway.example/v1".to_string());
6761 config.providers.fireworks.model = Some("DeepSeek-V4-Pro".to_string());
6762
6763 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6764
6765 assert_eq!(resolved.provider, ProviderKind::Fireworks);
6766 assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
6767 // Custom base URL skips provider-specific model prefixing.
6768 assert_eq!(resolved.model, "DeepSeek-V4-Pro");
6769 }
6770
6771 #[test]
6772 fn siliconflow_custom_base_url_preserves_provider_model() {
6773 let _lock = env_lock();
6774 let _env = EnvGuard::without_deepseek_runtime_overrides();
6775 let mut config = ConfigToml {
6776 provider: ProviderKind::Siliconflow,
6777 ..ConfigToml::default()
6778 };
6779 config.providers.siliconflow.base_url = Some("https://my-gateway.example/v1".to_string());
6780 config.providers.siliconflow.model = Some("DeepSeek-V4-Pro".to_string());
6781
6782 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6783
6784 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
6785 assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
6786 assert_eq!(resolved.model, "DeepSeek-V4-Pro");
6787 }
6788
6789 #[test]
6790 fn sentinel_config_values_fall_through_without_becoming_runtime_keys() {
6791 let _lock = env_lock();
6792 let _env = EnvGuard::without_deepseek_runtime_overrides();
6793
6794 for sentinel in [API_KEYRING_SENTINEL, " __KEYRING__ "] {
6795 let store = Arc::new(RecordingSecretsStore::with_value("stored-key"));
6796 let secrets = Secrets::new(store.clone());
6797 let mut official = ConfigToml::default();
6798 official.providers.deepseek.api_key = Some(sentinel.to_string());
6799 let resolved = official
6800 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
6801 assert_eq!(resolved.api_key.as_deref(), Some("stored-key"));
6802 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
6803
6804 let custom_store = Arc::new(RecordingSecretsStore::with_value("must-not-be-read"));
6805 let custom_secrets = Secrets::new(custom_store.clone());
6806 let mut custom = ConfigToml {
6807 provider: ProviderKind::Openrouter,
6808 ..ConfigToml::default()
6809 };
6810 custom.providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
6811 custom.providers.openrouter.api_key = Some(sentinel.to_string());
6812 let resolved = custom
6813 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &custom_secrets);
6814 assert_eq!(resolved.api_key, None);
6815 assert_eq!(resolved.api_key_source, None);
6816 assert!(custom_store.gets.lock().unwrap().is_empty());
6817
6818 let empty_store = Arc::new(RecordingSecretsStore {
6819 gets: Mutex::new(Vec::new()),
6820 value: None,
6821 });
6822 let empty_secrets = Secrets::new(empty_store);
6823 let mut xiaomi = ConfigToml {
6824 provider: ProviderKind::XiaomiMimo,
6825 ..ConfigToml::default()
6826 };
6827 xiaomi.providers.xiaomi_mimo.api_key = Some(sentinel.to_string());
6828 let resolved = xiaomi
6829 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &empty_secrets);
6830 assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
6831 assert_eq!(resolved.api_key, None);
6832 assert_eq!(resolved.api_key_source, None);
6833 }
6834 }
6835
6836 #[test]
6837 fn config_file_resolves_above_env_and_keyring() {
6838 use codewhale_secrets::KeyringStore;
6839 let _lock = env_lock();
6840 let _env = EnvGuard::without_deepseek_runtime_overrides();
6841 // Safety: env mutation guarded by env_lock().
6842 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
6843
6844 let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
6845 store.set("deepseek", "ring-key").unwrap();
6846 let secrets = Secrets::new(store);
6847
6848 let mut config = ConfigToml::default();
6849 config.providers.deepseek.api_key = Some("file-key".to_string());
6850
6851 let resolved =
6852 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
6853 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
6854 assert_eq!(
6855 resolved.api_key_source,
6856 Some(RuntimeApiKeySource::ConfigFile)
6857 );
6858
6859 // Safety: env mutation guarded by env_lock().
6860 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
6861 }
6862
6863 #[test]
6864 fn env_resolves_when_config_file_and_keyring_empty() {
6865 let _lock = env_lock();
6866 let _env = EnvGuard::without_deepseek_runtime_overrides();
6867 // Safety: env mutation guarded by env_lock().
6868 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
6869
6870 let secrets = Secrets::new(std::sync::Arc::new(
6871 codewhale_secrets::InMemoryKeyringStore::new(),
6872 ));
6873 let config = ConfigToml::default();
6874
6875 let resolved =
6876 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
6877 assert_eq!(resolved.api_key.as_deref(), Some("env-key"));
6878 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
6879
6880 // Safety: env mutation guarded by env_lock().
6881 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
6882 }
6883
6884 #[test]
6885 fn config_file_resolves_when_keyring_and_env_empty() {
6886 let _lock = env_lock();
6887 let _env = EnvGuard::without_deepseek_runtime_overrides();
6888
6889 let secrets = Secrets::new(std::sync::Arc::new(
6890 codewhale_secrets::InMemoryKeyringStore::new(),
6891 ));
6892 let mut config = ConfigToml::default();
6893 config.providers.deepseek.api_key = Some("file-key".to_string());
6894
6895 let resolved =
6896 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
6897 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
6898 assert_eq!(
6899 resolved.api_key_source,
6900 Some(RuntimeApiKeySource::ConfigFile)
6901 );
6902 }
6903
6904 #[test]
6905 fn keyring_resolves_when_config_file_empty_even_if_env_is_set() {
6906 use codewhale_secrets::KeyringStore;
6907 let _lock = env_lock();
6908 let _env = EnvGuard::without_deepseek_runtime_overrides();
6909 // Safety: env mutation guarded by env_lock().
6910 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key") };
6911
6912 let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
6913 store.set("deepseek", "ring-key").unwrap();
6914 let secrets = Secrets::new(store);
6915
6916 let resolved = ConfigToml::default()
6917 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
6918 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
6919 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
6920
6921 // Safety: env mutation guarded by env_lock().
6922 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
6923 }
6924
6925 #[test]
6926 fn cli_flag_still_overrides_keyring() {
6927 use codewhale_secrets::KeyringStore;
6928 let _lock = env_lock();
6929 let _env = EnvGuard::without_deepseek_runtime_overrides();
6930
6931 let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
6932 store.set("deepseek", "ring-key").unwrap();
6933 let secrets = Secrets::new(store);
6934
6935 let cli = CliRuntimeOverrides {
6936 api_key: Some("cli-key".to_string()),
6937 ..CliRuntimeOverrides::default()
6938 };
6939 let resolved = ConfigToml::default().resolve_runtime_options_with_secrets(&cli, &secrets);
6940 assert_eq!(resolved.api_key.as_deref(), Some("cli-key"));
6941 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli));
6942 }
6943
6944 #[test]
6945 fn provider_chain_initial_current_is_active() {
6946 let chain = ProviderChain::new(
6947 ProviderKind::NvidiaNim,
6948 &[ProviderKind::Deepseek, ProviderKind::Openrouter],
6949 );
6950
6951 assert_eq!(chain.current(), ProviderKind::NvidiaNim);
6952 assert_eq!(chain.position(), 0);
6953 assert_eq!(
6954 chain.providers(),
6955 &[
6956 ProviderKind::NvidiaNim,
6957 ProviderKind::Deepseek,
6958 ProviderKind::Openrouter,
6959 ]
6960 );
6961 assert!(!chain.is_fallback_active());
6962 }
6963
6964 #[test]
6965 fn provider_chain_advance_switches_to_fallback() {
6966 let mut chain = ProviderChain::new(
6967 ProviderKind::NvidiaNim,
6968 &[ProviderKind::Deepseek, ProviderKind::Openrouter],
6969 );
6970
6971 assert!(chain.has_next());
6972 assert_eq!(chain.advance(), Some(ProviderKind::Deepseek));
6973 assert_eq!(chain.current(), ProviderKind::Deepseek);
6974 assert!(chain.is_fallback_active());
6975 }
6976
6977 #[test]
6978 fn provider_chain_exhausts_returns_none() {
6979 let mut chain = ProviderChain::new(ProviderKind::Deepseek, &[ProviderKind::Openrouter]);
6980
6981 assert_eq!(chain.advance(), Some(ProviderKind::Openrouter));
6982 assert!(!chain.has_next());
6983 assert_eq!(chain.advance(), None);
6984 }
6985
6986 #[test]
6987 fn provider_chain_skips_duplicates() {
6988 let chain = ProviderChain::new(
6989 ProviderKind::Deepseek,
6990 &[
6991 ProviderKind::Deepseek,
6992 ProviderKind::NvidiaNim,
6993 ProviderKind::Deepseek,
6994 ],
6995 );
6996
6997 assert_eq!(
6998 chain.providers(),
6999 &[ProviderKind::Deepseek, ProviderKind::NvidiaNim]
7000 );
7001 }
7002
7003 #[test]
7004 fn provider_chain_remaining_counts_current_and_untried_entries() {
7005 let mut chain = ProviderChain::new(
7006 ProviderKind::Deepseek,
7007 &[ProviderKind::NvidiaNim, ProviderKind::Openrouter],
7008 );
7009
7010 assert_eq!(chain.remaining(), 3);
7011 assert_eq!(chain.advance(), Some(ProviderKind::NvidiaNim));
7012 assert_eq!(chain.remaining(), 2);
7013 }
7014
7015 #[test]
7016 fn config_toml_parses_fallback_providers() {
7017 let config: ConfigToml = toml::from_str(
7018 r#"
7019 provider = "nvidia-nim"
7020 fallback_providers = ["deepseek", "openrouter"]
7021 "#,
7022 )
7023 .expect("fallback providers config");
7024
7025 assert_eq!(config.provider, ProviderKind::NvidiaNim);
7026 assert_eq!(
7027 config.fallback_providers,
7028 [ProviderKind::Deepseek, ProviderKind::Openrouter]
7029 );
7030 }
7031
7032 #[test]
7033 fn empty_fallback_providers_do_not_serialize() {
7034 let serialized = toml::to_string_pretty(&ConfigToml::default()).expect("config serializes");
7035
7036 assert!(!serialized.contains("fallback_providers"));
7037 }
7038
7039 #[test]
7040 fn empty_provider_header_tables_do_not_survive_round_trip() {
7041 let polluted = r#"
7042 [http_headers]
7043
7044 [providers.anthropic.http_headers]
7045 " " = "ignored"
7046 "X-Blank" = " "
7047
7048 [providers.openrouter.http_headers]
7049
7050 [providers.xai]
7051 model = " "
7052 "#;
7053 let config: ConfigToml = toml::from_str(polluted).expect("polluted config parses");
7054 let serialized = toml::to_string_pretty(&config).expect("config serializes");
7055
7056 assert!(
7057 !serialized.contains("[http_headers]"),
7058 "empty root headers must not be serialized:\n{serialized}"
7059 );
7060 assert!(
7061 !serialized.contains("[providers.anthropic"),
7062 "empty Anthropic provider state must not be serialized:\n{serialized}"
7063 );
7064 assert!(
7065 !serialized.contains("[providers.openrouter"),
7066 "empty OpenRouter provider state must not be serialized:\n{serialized}"
7067 );
7068 assert!(
7069 !serialized.contains("[providers.xai"),
7070 "blank provider fields must not be serialized:\n{serialized}"
7071 );
7072
7073 let round_tripped: ConfigToml = toml::from_str(&serialized).expect("canonical config parses");
7074 assert!(round_tripped.http_headers.is_empty());
7075 assert!(round_tripped.providers.anthropic.http_headers.is_empty());
7076 assert!(round_tripped.providers.openrouter.http_headers.is_empty());
7077 }
7078
7079 #[test]
7080 fn workflow_config_defaults_match_product_surface() {
7081 // #4128 / Section 2.11: omitted `[workflow]` keys resolve to the
7082 // documented product defaults so launch/approval/persist share one model.
7083 let defaults = WorkflowConfigToml::default();
7084 assert!(defaults.automatic);
7085 assert!(defaults.auto_start_read_only);
7086 assert!(defaults.require_approval_for_writes);
7087 assert_eq!(defaults.auto_start_child_limit, 16);
7088 assert_eq!(defaults.max_children, 1000);
7089 assert_eq!(defaults.max_concurrent, 16);
7090 assert_eq!(defaults.max_depth, 2);
7091 assert_eq!(defaults.default_token_budget, 120_000);
7092 assert_eq!(defaults.max_parallel_writes_without_worktree, 0);
7093 assert!(defaults.persist_completed_activity);
7094 assert!(defaults.persist_completed_across_restarts);
7095 }
7096
7097 #[test]
7098 fn workflow_config_absent_table_stays_none_empty_table_fills_defaults() {
7099 let absent: ConfigToml = toml::from_str("").expect("empty config parses");
7100 assert!(absent.workflow.is_none());
7101
7102 let empty_table: ConfigToml = toml::from_str(
7103 r#"
7104 [workflow]
7105 "#,
7106 )
7107 .expect("empty workflow table should parse");
7108 assert_eq!(
7109 empty_table.workflow.expect("workflow table present"),
7110 WorkflowConfigToml::default()
7111 );
7112 }
7113
7114 #[test]
7115 fn workflow_config_partial_override_and_round_trip() {
7116 let config: ConfigToml = toml::from_str(
7117 r#"
7118 [workflow]
7119 automatic = false
7120 max_children = 16
7121 default_token_budget = 50000
7122 "#,
7123 )
7124 .expect("workflow overrides should parse");
7125
7126 let workflow = config.workflow.expect("workflow table");
7127 assert!(!workflow.automatic);
7128 assert_eq!(workflow.max_children, 16);
7129 assert_eq!(workflow.default_token_budget, 50_000);
7130 // Unset keys keep product defaults.
7131 assert!(workflow.auto_start_read_only);
7132 assert!(workflow.require_approval_for_writes);
7133 assert_eq!(workflow.auto_start_child_limit, 16);
7134 assert_eq!(workflow.max_concurrent, 16);
7135 assert_eq!(workflow.max_depth, 2);
7136 assert_eq!(workflow.max_parallel_writes_without_worktree, 0);
7137 assert!(workflow.persist_completed_activity);
7138 assert!(workflow.persist_completed_across_restarts);
7139
7140 let serialized = toml::to_string_pretty(&workflow).expect("workflow serializes");
7141 let round_tripped: WorkflowConfigToml =
7142 toml::from_str(&serialized).expect("serialized workflow parses");
7143 assert_eq!(round_tripped, workflow);
7144 }
7145
7146 #[test]
7147 fn fleet_exec_config_default_matches_subagent_depth() {
7148 // Fleet workers and standalone sub-agents share one recursion axis:
7149 // the fleet default equals DEFAULT_SPAWN_DEPTH (3) and affords >=3
7150 // nested delegation levels out of the box.
7151 assert_eq!(
7152 FleetExecConfig::default().max_spawn_depth,
7153 DEFAULT_SPAWN_DEPTH
7154 );
7155 assert_eq!(FleetExecConfig::default().max_spawn_depth, 3);
7156 const { assert!(DEFAULT_SPAWN_DEPTH <= MAX_SPAWN_DEPTH_CEILING) };
7157 }
7158
7159 #[test]
7160 fn fleet_exec_config_parses_max_spawn_depth() {
7161 let config: ConfigToml = toml::from_str(
7162 r#"
7163 [fleet.exec]
7164 max_spawn_depth = 2
7165 "#,
7166 )
7167 .expect("fleet exec config should parse");
7168
7169 assert_eq!(config.fleet.expect("fleet config").exec.max_spawn_depth, 2);
7170 }
7171
7172 #[test]
7173 fn fleet_profile_defaults_round_trip_through_config() {
7174 let config: ConfigToml = toml::from_str(
7175 r#"
7176 [fleet.profiles.default]
7177 "#,
7178 )
7179 .expect("fleet profile config should parse");
7180
7181 let profile = config
7182 .fleet
7183 .expect("fleet config")
7184 .profiles
7185 .get("default")
7186 .expect("default profile")
7187 .clone();
7188
7189 assert_eq!(profile, FleetProfile::default());
7190 assert!(!profile.permissions.allow_shell);
7191 assert!(!profile.permissions.trust);
7192 assert!(profile.permissions.approval_required);
7193
7194 let serialized = toml::to_string_pretty(&profile).expect("profile serializes");
7195 let round_tripped: FleetProfile =
7196 toml::from_str(&serialized).expect("serialized profile parses");
7197 assert_eq!(round_tripped, profile);
7198 }
7199
7200 #[test]
7201 fn fleet_profile_explicit_config_parses_role_loadout_permissions() {
7202 let config: ConfigToml = toml::from_str(
7203 r#"
7204 [fleet.profiles.verifier]
7205 slot = "verifier"
7206 loadout = "review"
7207 model = "deepseek-v4-pro"
7208
7209 [fleet.profiles.verifier.role]
7210 name = "verifier"
7211 description = "Read-only verification worker"
7212 instructions = "Check the patch and report evidence."
7213
7214 [fleet.profiles.verifier.permissions]
7215 allow_shell = false
7216 trust = false
7217 approval_required = true
7218
7219 [fleet.profiles.verifier.delegation]
7220 max_spawn_depth = 0
7221 concurrency = 3
7222 "#,
7223 )
7224 .expect("fleet profile config should parse");
7225
7226 let profile = config
7227 .fleet
7228 .expect("fleet config")
7229 .profiles
7230 .get("verifier")
7231 .expect("verifier profile")
7232 .clone();
7233
7234 assert_eq!(profile.slot, FleetSlot::Verifier);
7235 assert_eq!(profile.role.name, "verifier");
7236 assert_eq!(
7237 profile.role.description.as_deref(),
7238 Some("Read-only verification worker")
7239 );
7240 assert_eq!(
7241 profile.role.instructions.as_deref(),
7242 Some("Check the patch and report evidence.")
7243 );
7244 // "review" was a retired decorative tier: it parses as Custom and keeps
7245 // the same auto routing it always had.
7246 assert_eq!(profile.loadout, FleetLoadout::Custom("review".to_string()));
7247 assert_eq!(profile.model.as_deref(), Some("deepseek-v4-pro"));
7248 assert!(!profile.permissions.allow_shell);
7249 assert!(!profile.permissions.trust);
7250 assert!(profile.permissions.approval_required);
7251 assert_eq!(profile.delegation.max_spawn_depth, Some(0));
7252 assert_eq!(profile.delegation.max_concurrency, Some(3));
7253 }
7254
7255 #[test]
7256 fn fleet_loadout_accepts_default_model_classes() {
7257 assert_eq!(FleetLoadout::from_name("fast"), FleetLoadout::Fast);
7258 assert_eq!(FleetLoadout::from_name("inherit"), FleetLoadout::Inherit);
7259 assert_eq!(FleetLoadout::from_name(""), FleetLoadout::Inherit);
7260 assert_eq!(FleetLoadout::Fast.as_str(), "fast");
7261 // Retired tiers stay parseable as Custom so old configs keep loading
7262 // with identical (auto) routing.
7263 assert_eq!(
7264 FleetLoadout::from_name("strong"),
7265 FleetLoadout::Custom("strong".to_string())
7266 );
7267 assert_eq!(
7268 FleetLoadout::from_name("tool-heavy"),
7269 FleetLoadout::Custom("tool-heavy".to_string())
7270 );
7271 assert_eq!(
7272 FleetLoadout::Custom("strong".to_string()).as_str(),
7273 "strong"
7274 );
7275 }
7276
7277 #[test]
7278 fn fallback_providers_do_not_change_runtime_resolution() {
7279 let _lock = env_lock();
7280 let _env = EnvGuard::without_deepseek_runtime_overrides();
7281 let config = ConfigToml {
7282 provider: ProviderKind::NvidiaNim,
7283 fallback_providers: vec![ProviderKind::Deepseek],
7284 ..ConfigToml::default()
7285 };
7286
7287 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7288
7289 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
7290 }
7291
7292 #[test]
7293 fn harness_posture_default_is_standard() {
7294 let posture = HarnessPosture::default();
7295
7296 assert_eq!(
7297 posture,
7298 HarnessPosture {
7299 kind: HarnessPostureKind::Standard,
7300 max_subagents: 0,
7301 prefer_codebase_search: false,
7302 compaction_strategy: HarnessCompactionStrategy::Default,
7303 tool_surface: HarnessToolSurface::Full,
7304 safety_posture: HarnessSafetyPosture::Standard,
7305 }
7306 );
7307 }
7308
7309 #[test]
7310 fn harness_posture_factories_are_typed() {
7311 assert_eq!(
7312 HarnessPosture::cache_heavy(),
7313 HarnessPosture {
7314 kind: HarnessPostureKind::CacheHeavy,
7315 max_subagents: 10,
7316 prefer_codebase_search: false,
7317 compaction_strategy: HarnessCompactionStrategy::PrefixCache,
7318 tool_surface: HarnessToolSurface::Full,
7319 safety_posture: HarnessSafetyPosture::Standard,
7320 }
7321 );
7322 assert_eq!(
7323 HarnessPosture::lean(),
7324 HarnessPosture {
7325 kind: HarnessPostureKind::Lean,
7326 max_subagents: 20,
7327 prefer_codebase_search: true,
7328 compaction_strategy: HarnessCompactionStrategy::Aggressive,
7329 tool_surface: HarnessToolSurface::Full,
7330 safety_posture: HarnessSafetyPosture::Standard,
7331 }
7332 );
7333 }
7334
7335 #[test]
7336 fn harness_profile_serde_round_trips_as_a_whole_struct() {
7337 let profile = HarnessProfile {
7338 provider_route: "deepseek".to_string(),
7339 model_pattern: "deepseek-v4.*".to_string(),
7340 posture: HarnessPosture::cache_heavy(),
7341 };
7342
7343 let json = serde_json::to_string(&profile).expect("serialize profile");
7344 let round_tripped: HarnessProfile = serde_json::from_str(&json).expect("deserialize profile");
7345
7346 assert_eq!(round_tripped, profile);
7347 }
7348
7349 #[test]
7350 fn config_toml_accepts_harness_profiles() {
7351 let config: ConfigToml = toml::from_str(
7352 r#"
7353 provider = "deepseek"
7354 model = "deepseek-v4-pro"
7355
7356 [[harness_profiles]]
7357 provider_route = "deepseek"
7358 model_pattern = "deepseek-v4.*"
7359
7360 [harness_profiles.posture]
7361 kind = "cache-heavy"
7362 max_subagents = 10
7363 compaction_strategy = "prefix-cache"
7364 tool_surface = "read-only"
7365 safety_posture = "strict"
7366 "#,
7367 )
7368 .expect("parse harness profiles");
7369
7370 assert_eq!(
7371 config.harness_profiles,
7372 vec![HarnessProfile {
7373 provider_route: "deepseek".to_string(),
7374 model_pattern: "deepseek-v4.*".to_string(),
7375 posture: HarnessPosture {
7376 kind: HarnessPostureKind::CacheHeavy,
7377 max_subagents: 10,
7378 prefer_codebase_search: false,
7379 compaction_strategy: HarnessCompactionStrategy::PrefixCache,
7380 tool_surface: HarnessToolSurface::ReadOnly,
7381 safety_posture: HarnessSafetyPosture::Strict,
7382 },
7383 }]
7384 );
7385 }
7386
7387 #[test]
7388 fn harness_profile_matches_provider_alias_and_model_wildcard() {
7389 let profile = HarnessProfile {
7390 provider_route: "xiaomi-mimo".to_string(),
7391 model_pattern: "mimo-v2.?-pro".to_string(),
7392 posture: HarnessPosture::cache_heavy(),
7393 };
7394
7395 assert!(profile.matches_route("mimo", "mimo-v2.5-pro"));
7396 assert!(!profile.matches_route("mimo", "mimo-v2.50-pro"));
7397 assert!(!profile.matches_route("deepseek", "mimo-v2.5-pro"));
7398 }
7399
7400 #[test]
7401 fn resolve_harness_profile_returns_first_matching_profile() {
7402 let config = ConfigToml {
7403 harness_profiles: vec![
7404 HarnessProfile {
7405 provider_route: "deepseek".to_string(),
7406 model_pattern: "deepseek-v4-flash".to_string(),
7407 posture: HarnessPosture::lean(),
7408 },
7409 HarnessProfile {
7410 provider_route: "deepseek".to_string(),
7411 model_pattern: "deepseek-v4-*".to_string(),
7412 posture: HarnessPosture::cache_heavy(),
7413 },
7414 ],
7415 ..ConfigToml::default()
7416 };
7417
7418 let flash = config
7419 .resolve_harness_profile("deepseek-cn", "deepseek-v4-flash")
7420 .expect("exact profile should match first");
7421 assert_eq!(flash.posture.kind, HarnessPostureKind::Lean);
7422
7423 let pro = config
7424 .resolve_harness_profile("deepseek", "deepseek-v4-pro")
7425 .expect("wildcard profile should match pro model");
7426 assert_eq!(pro.posture.kind, HarnessPostureKind::CacheHeavy);
7427 }
7428
7429 #[test]
7430 fn resolve_harness_profile_uses_built_in_seed_when_config_has_no_match() {
7431 let config = ConfigToml::default();
7432
7433 let xiaomi = config
7434 .resolve_harness_profile("xiaomi", "mimo-v2.5-pro")
7435 .expect("direct Xiaomi MiMo seed should resolve");
7436 assert_eq!(xiaomi.provider_route, "xiaomi-mimo");
7437 assert_eq!(xiaomi.posture.kind, HarnessPostureKind::CacheHeavy);
7438
7439 let arcee = config
7440 .resolve_harness_profile("arcee", "trinity-large-thinking")
7441 .expect("direct Arcee seed should resolve");
7442 assert_eq!(arcee.posture.kind, HarnessPostureKind::CacheHeavy);
7443
7444 let local = config
7445 .resolve_harness_profile("vllm", "Qwen/Qwen3.6-Coder")
7446 .expect("local seed should resolve");
7447 assert_eq!(local.posture.kind, HarnessPostureKind::Lean);
7448 assert!(local.posture.prefer_codebase_search);
7449 }
7450
7451 #[test]
7452 fn configured_harness_profile_overrides_built_in_seed() {
7453 let config = ConfigToml {
7454 harness_profiles: vec![HarnessProfile {
7455 provider_route: "xiaomi-mimo".to_string(),
7456 model_pattern: "mimo-v2.5-pro".to_string(),
7457 posture: HarnessPosture {
7458 kind: HarnessPostureKind::Custom,
7459 max_subagents: 3,
7460 prefer_codebase_search: true,
7461 compaction_strategy: HarnessCompactionStrategy::Default,
7462 tool_surface: HarnessToolSurface::Auto,
7463 safety_posture: HarnessSafetyPosture::Strict,
7464 },
7465 }],
7466 ..ConfigToml::default()
7467 };
7468
7469 let profile = config
7470 .resolve_harness_profile("xiaomi-mimo", "mimo-v2.5-pro")
7471 .expect("configured profile should match first");
7472
7473 assert_eq!(profile.posture.kind, HarnessPostureKind::Custom);
7474 assert_eq!(profile.posture.max_subagents, 3);
7475 assert_eq!(profile.posture.tool_surface, HarnessToolSurface::Auto);
7476 assert_eq!(profile.posture.safety_posture, HarnessSafetyPosture::Strict);
7477 }
7478
7479 #[test]
7480 fn resolve_harness_profile_returns_none_when_route_or_model_misses() {
7481 let config = ConfigToml {
7482 harness_profiles: vec![HarnessProfile {
7483 provider_route: "huggingface".to_string(),
7484 model_pattern: "deepseek-ai/*".to_string(),
7485 posture: HarnessPosture::lean(),
7486 }],
7487 ..ConfigToml::default()
7488 };
7489
7490 assert!(
7491 config
7492 .resolve_harness_profile("openrouter", "deepseek-ai/DeepSeek-V4-Pro")
7493 .is_none()
7494 );
7495 assert!(
7496 config
7497 .resolve_harness_profile("deepseek", "Qwen/Qwen3.6-Coder")
7498 .is_none()
7499 );
7500 assert!(
7501 config
7502 .resolve_harness_profile("openai", "mimo-v2.5-pro")
7503 .is_none()
7504 );
7505 }
7506
7507 #[test]
7508 fn resolving_harness_profile_does_not_change_runtime_options() {
7509 let _lock = env_lock();
7510 let _env = EnvGuard::without_deepseek_runtime_overrides();
7511 let config = ConfigToml {
7512 provider: ProviderKind::Deepseek,
7513 model: Some("deepseek-v4-pro".to_string()),
7514 harness_profiles: vec![HarnessProfile {
7515 provider_route: "deepseek".to_string(),
7516 model_pattern: "deepseek-v4-*".to_string(),
7517 posture: HarnessPosture::lean(),
7518 }],
7519 ..ConfigToml::default()
7520 };
7521
7522 let profile = config
7523 .resolve_harness_profile("deepseek", "deepseek-v4-pro")
7524 .expect("profile should resolve for display/future runtime");
7525 assert_eq!(profile.posture.kind, HarnessPostureKind::Lean);
7526
7527 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7528 assert_eq!(resolved.provider, ProviderKind::Deepseek);
7529 assert_eq!(resolved.model, "deepseek-v4-pro");
7530 }
7531
7532 #[test]
7533 fn harness_posture_kind_rejects_unknown_values() {
7534 let err = toml::from_str::<ConfigToml>(
7535 r#"
7536 [[harness_profiles]]
7537 provider_route = "deepseek"
7538 model_pattern = "deepseek-v4.*"
7539
7540 [harness_profiles.posture]
7541 kind = "cahce-heavy"
7542 "#,
7543 )
7544 .expect_err("misspelled kind should not deserialize as custom");
7545
7546 assert!(err.to_string().contains("cahce-heavy"));
7547 }
7548
7549 #[test]
7550 fn harness_posture_rejects_unknown_policy_keys() {
7551 let err = toml::from_str::<ConfigToml>(
7552 r#"
7553 [[harness_profiles]]
7554 provider_route = "deepseek"
7555 model_pattern = "deepseek-v4.*"
7556
7557 [harness_profiles.posture]
7558 kind = "custom"
7559 unknown_policy = "surprise"
7560 "#,
7561 )
7562 .expect_err("unknown posture keys should not be ignored");
7563
7564 assert!(err.to_string().contains("unknown_policy"));
7565 }
7566
7567 #[test]
7568 fn test_verbosity_resolution() {
7569 let _lock = env_lock();
7570 // Test TOML parsing
7571 let toml_str = r#"
7572 verbosity = "concise"
7573 "#;
7574 let config: ConfigToml = toml::from_str(toml_str).unwrap();
7575 assert_eq!(config.verbosity, Some("concise".to_string()));
7576
7577 // Test Env overrides
7578 let _env = EnvGuard::without_deepseek_runtime_overrides();
7579 unsafe {
7580 std::env::set_var("CODEWHALE_VERBOSITY", "normal");
7581 }
7582 let env_overrides = EnvRuntimeOverrides::load();
7583 assert_eq!(env_overrides.verbosity, Some("normal".to_string()));
7584 unsafe {
7585 std::env::remove_var("CODEWHALE_VERBOSITY");
7586 }
7587
7588 // Test fallback to DEEPSEEK_VERBOSITY
7589 unsafe {
7590 std::env::set_var("DEEPSEEK_VERBOSITY", "concise");
7591 }
7592 let env_overrides = EnvRuntimeOverrides::load();
7593 assert_eq!(env_overrides.verbosity, Some("concise".to_string()));
7594 unsafe {
7595 std::env::remove_var("DEEPSEEK_VERBOSITY");
7596 }
7597 }
7598
7599 // ─── Named operator-scoped Fleet configurations (#5039) ──────────────────────
7600
7601 #[test]
7602 fn named_fleet_legacy_only_config_loads_unchanged() {
7603 // A config with only the legacy [fleet] table and no [fleets.*] tables.
7604 let config: ConfigToml = toml::from_str(
7605 r#"
7606 [fleet.exec]
7607 max_spawn_depth = 2
7608 "#,
7609 )
7610 .expect("legacy fleet config should parse");
7611
7612 assert_eq!(config.fleet.expect("legacy fleet").exec.max_spawn_depth, 2);
7613 assert!(
7614 config.fleets.is_empty(),
7615 "no named fleets should be present"
7616 );
7617 }
7618
7619 #[test]
7620 fn named_fleet_parses_operator_and_trust() {
7621 let config: ConfigToml = toml::from_str(
7622 r#"
7623 [fleets.alice-team]
7624 operator = "alice"
7625 default_trust_level = "local"
7626 max_trust_level = "operator"
7627 "#,
7628 )
7629 .expect("named fleet config should parse");
7630
7631 let fleet = config.fleets.get("alice-team").expect("alice-team fleet");
7632 assert_eq!(fleet.operator, "alice");
7633 assert_eq!(fleet.default_trust_level, "local");
7634 assert_eq!(fleet.max_trust_level, "operator");
7635 }
7636
7637 #[test]
7638 fn named_fleet_defaults_apply_when_fields_absent() {
7639 let config: ConfigToml = toml::from_str(
7640 r#"
7641 [fleets.minimal]
7642 operator = "bob"
7643 "#,
7644 )
7645 .expect("minimal named fleet should parse");
7646
7647 let fleet = config.fleets.get("minimal").expect("minimal fleet");
7648 assert_eq!(fleet.operator, "bob");
7649 assert_eq!(fleet.default_trust_level, "sandbox");
7650 assert!(fleet.require_identity_verification);
7651 assert_eq!(fleet.max_trust_level, "operator");
7652 assert!(fleet.roles.is_empty());
7653 assert!(fleet.profiles.is_empty());
7654 }
7655
7656 #[test]
7657 fn named_fleet_mixed_legacy_and_named_both_load() {
7658 // Users may have the global [fleet] default AND named [fleets.*] tables.
7659 let config: ConfigToml = toml::from_str(
7660 r#"
7661 [fleet]
7662 default_trust_level = "sandbox"
7663
7664 [fleets.team-a]
7665 operator = "alice"
7666 default_trust_level = "local"
7667
7668 [fleets.team-b]
7669 operator = "bob"
7670 default_trust_level = "remote-verified"
7671 "#,
7672 )
7673 .expect("mixed fleet config should parse");
7674
7675 assert_eq!(
7676 config
7677 .fleet
7678 .as_ref()
7679 .expect("legacy fleet")
7680 .default_trust_level,
7681 "sandbox"
7682 );
7683 assert_eq!(config.fleets.len(), 2);
7684 assert_eq!(config.fleets["team-a"].default_trust_level, "local");
7685 assert_eq!(
7686 config.fleets["team-b"].default_trust_level,
7687 "remote-verified"
7688 );
7689 }
7690
7691 #[test]
7692 fn named_fleet_multiple_configs_independent_profiles() {
7693 let config: ConfigToml = toml::from_str(
7694 r#"
7695 [fleets.fleet-one]
7696 operator = "alice"
7697
7698 [fleets.fleet-one.profiles.fast-verifier]
7699 slot = "verifier"
7700 loadout = "fast"
7701
7702 [fleets.fleet-two]
7703 operator = "bob"
7704
7705 [fleets.fleet-two.profiles.slow-reviewer]
7706 slot = "reviewer"
7707 loadout = "inherit"
7708 "#,
7709 )
7710 .expect("multiple named fleets with profiles should parse");
7711
7712 let fleet_one = config.fleets.get("fleet-one").expect("fleet-one");
7713 assert_eq!(fleet_one.operator, "alice");
7714 assert_eq!(
7715 fleet_one
7716 .profiles
7717 .get("fast-verifier")
7718 .expect("fast-verifier profile")
7719 .slot,
7720 FleetSlot::Verifier
7721 );
7722
7723 let fleet_two = config.fleets.get("fleet-two").expect("fleet-two");
7724 assert_eq!(fleet_two.operator, "bob");
7725 assert_eq!(
7726 fleet_two
7727 .profiles
7728 .get("slow-reviewer")
7729 .expect("slow-reviewer profile")
7730 .slot,
7731 FleetSlot::Reviewer
7732 );
7733 }
7734
7735 #[test]
7736 fn resolve_fleet_returns_named_fleet() {
7737 let config: ConfigToml = toml::from_str(
7738 r#"
7739 [fleets.my-fleet]
7740 operator = "alice"
7741 default_trust_level = "local"
7742 "#,
7743 )
7744 .expect("fleet config");
7745
7746 let fleet = config.resolve_fleet("my-fleet").expect("resolve my-fleet");
7747 assert_eq!(fleet.operator, "alice");
7748 assert_eq!(fleet.default_trust_level, "local");
7749 }
7750
7751 #[test]
7752 fn resolve_fleet_unknown_name_gives_actionable_error() {
7753 let config: ConfigToml = toml::from_str(
7754 r#"
7755 [fleets.real-fleet]
7756 operator = "alice"
7757 "#,
7758 )
7759 .expect("fleet config");
7760
7761 let err = config
7762 .resolve_fleet("ghost-fleet")
7763 .expect_err("unknown fleet");
7764 match err {
7765 FleetResolutionError::UnknownFleet { name, available } => {
7766 assert_eq!(name, "ghost-fleet");
7767 assert_eq!(available, vec!["real-fleet".to_string()]);
7768 }
7769 other => panic!("expected UnknownFleet, got {other}"),
7770 }
7771 }
7772
7773 #[test]
7774 fn resolve_fleet_unknown_with_no_fleets_configured() {
7775 let config: ConfigToml = ConfigToml::default();
7776
7777 let err = config.resolve_fleet("anything").expect_err("no fleets");
7778 match err {
7779 FleetResolutionError::UnknownFleet { name, available } => {
7780 assert_eq!(name, "anything");
7781 assert!(available.is_empty());
7782 }
7783 other => panic!("expected UnknownFleet, got {other}"),
7784 }
7785 }
7786
7787 #[test]
7788 fn resolve_fleet_for_operator_returns_single_match() {
7789 let config: ConfigToml = toml::from_str(
7790 r#"
7791 [fleets.alice-team]
7792 operator = "alice"
7793 default_trust_level = "local"
7794 "#,
7795 )
7796 .expect("fleet config");
7797
7798 let (name, fleet) = config
7799 .resolve_fleet_for_operator("alice")
7800 .expect("resolve alice");
7801 assert_eq!(name, "alice-team");
7802 assert_eq!(fleet.operator, "alice");
7803 }
7804
7805 #[test]
7806 fn resolve_fleet_for_operator_unknown_gives_actionable_error() {
7807 let config: ConfigToml = toml::from_str(
7808 r#"
7809 [fleets.alice-team]
7810 operator = "alice"
7811 "#,
7812 )
7813 .expect("fleet config");
7814
7815 let err = config
7816 .resolve_fleet_for_operator("charlie")
7817 .expect_err("unknown operator");
7818 match err {
7819 FleetResolutionError::UnknownOperator {
7820 operator,
7821 available,
7822 } => {
7823 assert_eq!(operator, "charlie");
7824 assert_eq!(available, vec!["alice".to_string()]);
7825 }
7826 other => panic!("expected UnknownOperator, got {other}"),
7827 }
7828 }
7829
7830 #[test]
7831 fn resolve_fleet_for_operator_ambiguous_gives_actionable_error() {
7832 let config: ConfigToml = toml::from_str(
7833 r#"
7834 [fleets.fleet-a]
7835 operator = "alice"
7836
7837 [fleets.fleet-b]
7838 operator = "alice"
7839 "#,
7840 )
7841 .expect("fleet config");
7842
7843 let err = config
7844 .resolve_fleet_for_operator("alice")
7845 .expect_err("ambiguous operator");
7846 match err {
7847 FleetResolutionError::AmbiguousOperator {
7848 operator,
7849 mut fleet_names,
7850 } => {
7851 assert_eq!(operator, "alice");
7852 fleet_names.sort();
7853 assert_eq!(fleet_names, vec!["fleet-a", "fleet-b"]);
7854 }
7855 other => panic!("expected AmbiguousOperator, got {other}"),
7856 }
7857 }
7858
7859 #[test]
7860 fn named_fleet_error_messages_are_actionable() {
7861 // Verify Display output is human-readable and contains key hints.
7862 let no_fleets_err = FleetResolutionError::UnknownFleet {
7863 name: "x".to_string(),
7864 available: vec![],
7865 };
7866 let msg = no_fleets_err.to_string();
7867 assert!(msg.contains("x"), "should contain fleet name");
7868 assert!(msg.contains("config.toml"), "should mention config.toml");
7869
7870 let with_candidates_err = FleetResolutionError::UnknownFleet {
7871 name: "x".to_string(),
7872 available: vec!["fleet-one".to_string(), "fleet-two".to_string()],
7873 };
7874 let msg = with_candidates_err.to_string();
7875 assert!(msg.contains("fleet-one"), "should list available fleets");
7876 assert!(msg.contains("fleet-two"), "should list available fleets");
7877
7878 let unknown_op_err = FleetResolutionError::UnknownOperator {
7879 operator: "nobody".to_string(),
7880 available: vec!["alice".to_string()],
7881 };
7882 let msg = unknown_op_err.to_string();
7883 assert!(msg.contains("nobody"), "should contain operator name");
7884 assert!(msg.contains("alice"), "should list known operators");
7885
7886 let ambiguous_err = FleetResolutionError::AmbiguousOperator {
7887 operator: "alice".to_string(),
7888 fleet_names: vec!["fleet-a".to_string(), "fleet-b".to_string()],
7889 };
7890 let msg = ambiguous_err.to_string();
7891 assert!(msg.contains("alice"), "should contain operator");
7892 assert!(msg.contains("fleet-a"), "should list fleet names");
7893 assert!(msg.contains("fleet-b"), "should list fleet names");
7894 assert!(
7895 msg.contains("explicitly"),
7896 "should prompt user to be explicit"
7897 );
7898 }
7899
7900 #[test]
7901 fn named_fleet_as_fleet_config_view_matches_fields() {
7902 let config: ConfigToml = toml::from_str(
7903 r#"
7904 [fleets.team]
7905 operator = "alice"
7906 default_trust_level = "local"
7907 max_trust_level = "operator"
7908 require_identity_verification = false
7909
7910 [fleets.team.exec]
7911 max_turns = 100
7912 "#,
7913 )
7914 .expect("fleet config");
7915
7916 let named = config.fleets.get("team").expect("team fleet");
7917 let view = named.as_fleet_config();
7918 assert_eq!(view.default_trust_level, "local");
7919 assert_eq!(view.max_trust_level, "operator");
7920 assert!(!view.require_identity_verification);
7921 assert_eq!(view.exec.max_turns, 100);
7922 }
7923
7924 #[test]
7925 fn named_fleet_serializes_and_round_trips() {
7926 let config: ConfigToml = toml::from_str(
7927 r#"
7928 [fleets.my-fleet]
7929 operator = "alice"
7930 default_trust_level = "local"
7931
7932 [fleets.my-fleet.exec]
7933 max_spawn_depth = 1
7934 "#,
7935 )
7936 .expect("fleet config");
7937
7938 let fleet = config.fleets.get("my-fleet").expect("my-fleet");
7939 let serialized = toml::to_string_pretty(fleet).expect("serializes");
7940 let round_tripped: NamedFleetConfigToml = toml::from_str(&serialized).expect("round trips");
7941 assert_eq!(round_tripped.operator, "alice");
7942 assert_eq!(round_tripped.default_trust_level, "local");
7943 assert_eq!(round_tripped.exec.max_spawn_depth, 1);
7944 }
7945
7946 /// Save and restore the telemetry env vars around a test that mutates them.
7947 ///
7948 /// Held together with [`env_lock`]: the process environment is global, so
7949 /// every telemetry test serialises on the same mutex the provider tests use.
7950 struct TelemetryEnvGuard {
7951 codewhale: Option<OsString>,
7952 deepseek: Option<OsString>,
7953 floor: Option<OsString>,
7954 codewhale_endpoint: Option<OsString>,
7955 deepseek_endpoint: Option<OsString>,
7956 _lock: std::sync::MutexGuard<'static, ()>,
7957 }
7958
7959 impl TelemetryEnvGuard {
7960 fn take() -> Self {
7961 let lock = env_lock();
7962 let guard = Self {
7963 codewhale: env::var_os("CODEWHALE_TELEMETRY"),
7964 deepseek: env::var_os("DEEPSEEK_TELEMETRY"),
7965 floor: env::var_os(TELEMETRY_FLOOR_ENV),
7966 // The endpoint variables are cleared too. Resolution now has a
7967 // shipped default, so an ambient endpoint in the developer's or
7968 // CI's environment is the difference between pinning the default
7969 // and pinning whatever that machine happened to export.
7970 codewhale_endpoint: env::var_os("CODEWHALE_TELEMETRY_ENDPOINT"),
7971 deepseek_endpoint: env::var_os("DEEPSEEK_TELEMETRY_ENDPOINT"),
7972 _lock: lock,
7973 };
7974 // Safety: test-only environment mutation guarded by the module mutex.
7975 unsafe {
7976 env::remove_var("CODEWHALE_TELEMETRY");
7977 env::remove_var("DEEPSEEK_TELEMETRY");
7978 env::remove_var(TELEMETRY_FLOOR_ENV);
7979 env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT");
7980 env::remove_var("DEEPSEEK_TELEMETRY_ENDPOINT");
7981 }
7982 guard
7983 }
7984
7985 fn set(&self, value: &str) {
7986 // Safety: test-only environment mutation guarded by the module mutex.
7987 unsafe {
7988 env::set_var("CODEWHALE_TELEMETRY", value);
7989 }
7990 }
7991
7992 fn set_endpoint(&self, value: &str) {
7993 // Safety: test-only environment mutation guarded by the module mutex.
7994 unsafe {
7995 env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", value);
7996 }
7997 }
7998
7999 fn set_floor(&self, value: &str) {
8000 // Safety: test-only environment mutation guarded by the module mutex.
8001 unsafe {
8002 env::set_var(TELEMETRY_FLOOR_ENV, value);
8003 }
8004 }
8005
8006 fn clear(&self) {
8007 // Safety: test-only environment mutation guarded by the module mutex.
8008 unsafe {
8009 env::remove_var("CODEWHALE_TELEMETRY");
8010 env::remove_var("DEEPSEEK_TELEMETRY");
8011 env::remove_var(TELEMETRY_FLOOR_ENV);
8012 }
8013 }
8014 }
8015
8016 impl Drop for TelemetryEnvGuard {
8017 fn drop(&mut self) {
8018 // Safety: test-only environment mutation guarded by the module mutex.
8019 unsafe {
8020 match self.codewhale.take() {
8021 Some(value) => env::set_var("CODEWHALE_TELEMETRY", value),
8022 None => env::remove_var("CODEWHALE_TELEMETRY"),
8023 }
8024 match self.deepseek.take() {
8025 Some(value) => env::set_var("DEEPSEEK_TELEMETRY", value),
8026 None => env::remove_var("DEEPSEEK_TELEMETRY"),
8027 }
8028 match self.floor.take() {
8029 Some(value) => env::set_var(TELEMETRY_FLOOR_ENV, value),
8030 None => env::remove_var(TELEMETRY_FLOOR_ENV),
8031 }
8032 match self.codewhale_endpoint.take() {
8033 Some(value) => env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", value),
8034 None => env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT"),
8035 }
8036 match self.deepseek_endpoint.take() {
8037 Some(value) => env::set_var("DEEPSEEK_TELEMETRY_ENDPOINT", value),
8038 None => env::remove_var("DEEPSEEK_TELEMETRY_ENDPOINT"),
8039 }
8040 }
8041 }
8042 }
8043
8044 #[test]
8045 fn env_telemetry_off_is_a_floor_over_cli_on() {
8046 let guard = TelemetryEnvGuard::take();
8047 guard.set("0");
8048
8049 let config = ConfigToml {
8050 telemetry: Some(true),
8051 ..ConfigToml::default()
8052 };
8053 let cli = CliRuntimeOverrides {
8054 telemetry: Some(true),
8055 ..CliRuntimeOverrides::default()
8056 };
8057
8058 let resolved = config.resolve_runtime_options(&cli);
8059
8060 // `--telemetry true` must not be able to climb back over an explicit
8061 // `CODEWHALE_TELEMETRY=0`. Off is a floor, not one more precedence rung.
8062 assert!(!resolved.telemetry);
8063 // …but the environment is a run-scoped switch, not a revocation. See
8064 // `a_run_scoped_off_is_a_kill_switch_and_not_a_revocation`.
8065 assert!(!resolved.telemetry_explicit_off);
8066 }
8067
8068 #[test]
8069 fn persisted_telemetry_off_is_a_floor_over_cli_on() {
8070 // Regression: `--telemetry true` used to beat `telemetry = false` in the
8071 // config file, and the dispatcher then forwarded the resolved `true` as
8072 // `CODEWHALE_TELEMETRY=true`, which also outranked the child's own copy of
8073 // that file. Any wrapper script, alias, or agent harness passing the flag
8074 // silently re-enabled a user who had turned telemetry off through the one
8075 // switch the first-run notice advertises as permanent.
8076 let guard = TelemetryEnvGuard::take();
8077
8078 let config = ConfigToml {
8079 telemetry: Some(false),
8080 ..ConfigToml::default()
8081 };
8082 let cli = CliRuntimeOverrides {
8083 telemetry: Some(true),
8084 ..CliRuntimeOverrides::default()
8085 };
8086
8087 let resolved = config.resolve_runtime_options(&cli);
8088 assert!(!resolved.telemetry);
8089 // And it stays an answer, so the run re-asserts the tombstone.
8090 assert!(resolved.telemetry_explicit_off);
8091
8092 // An environment "on" loses to it as well: re-enabling is writing the
8093 // durable register the off was written in.
8094 guard.set("1");
8095 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8096 assert!(!resolved.telemetry);
8097 assert!(resolved.telemetry_explicit_off);
8098 }
8099
8100 #[test]
8101 fn a_run_scoped_off_is_a_kill_switch_and_not_a_revocation() {
8102 // Regression: an explicit `CODEWHALE_TELEMETRY=0` marked the run as an
8103 // *answer*, so the telemetry crate took its destructive opt-out branch —
8104 // deleting the install id and truncating the user's own dry-run records —
8105 // on a recipe the runtime docs prescribe for one command. Worse, the
8106 // dispatcher forwards a resolved `false` on every ordinary run, so the
8107 // shipped default was indistinguishable from a revocation.
8108 let guard = TelemetryEnvGuard::take();
8109
8110 for value in ["0", "false", "off", "disabled", "no"] {
8111 guard.set(value);
8112 let config = ConfigToml {
8113 telemetry: Some(true),
8114 ..ConfigToml::default()
8115 };
8116 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8117 assert!(!resolved.telemetry, "{value} must stop the run");
8118 assert!(
8119 !resolved.telemetry_explicit_off,
8120 "{value} must not read as a revocation"
8121 );
8122 }
8123
8124 // The same for the per-run flag, with the environment back to silent.
8125 guard.clear();
8126 let cli = CliRuntimeOverrides {
8127 telemetry: Some(false),
8128 ..CliRuntimeOverrides::default()
8129 };
8130 let resolved = ConfigToml {
8131 telemetry: Some(true),
8132 ..ConfigToml::default()
8133 }
8134 .resolve_runtime_options(&cli);
8135 assert!(!resolved.telemetry);
8136 assert!(!resolved.telemetry_explicit_off);
8137 }
8138
8139 #[test]
8140 fn the_dispatcher_states_the_floor_rather_than_letting_the_child_infer_it() {
8141 // The child cannot tell an operator's declared kill switch from the
8142 // shipped default: both arrive as `CODEWHALE_TELEMETRY=false`. So the
8143 // dispatcher states it, and the statement outranks the inference — which
8144 // is what lets the first-run notice refuse to ask under a real floor while
8145 // still asking on an ordinary first run.
8146 let guard = TelemetryEnvGuard::take();
8147 assert!(!telemetry_floor_in_force());
8148
8149 guard.set("0");
8150 assert!(telemetry_floor_in_force());
8151
8152 // A forwarded resolved `false` with the dispatcher saying "no floor" is
8153 // the ordinary first run.
8154 guard.set("false");
8155 guard.set_floor("0");
8156 assert!(!telemetry_floor_in_force());
8157
8158 // A declared floor holds even where the value alone would not show it.
8159 guard.set("true");
8160 guard.set_floor("1");
8161 assert!(telemetry_floor_in_force());
8162 let resolved = ConfigToml {
8163 telemetry: Some(true),
8164 ..ConfigToml::default()
8165 }
8166 .resolve_runtime_options(&CliRuntimeOverrides::default());
8167 assert!(!resolved.telemetry);
8168
8169 // An unreadable value is a floor: a typo in a kill switch never resolves
8170 // to "on".
8171 guard.clear();
8172 guard.set("maybe");
8173 assert!(telemetry_floor_in_force());
8174 }
8175
8176 #[test]
8177 fn unparseable_telemetry_env_fails_closed() {
8178 let guard = TelemetryEnvGuard::take();
8179 guard.set("maybe");
8180
8181 let config = ConfigToml {
8182 telemetry: Some(true),
8183 ..ConfigToml::default()
8184 };
8185 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8186
8187 // A typo must not fall through to the config file's `true`.
8188 assert!(!resolved.telemetry);
8189 // …but it is also not the user answering "no", so it is not an explicit
8190 // opt-out either.
8191 assert!(!resolved.telemetry_explicit_off);
8192 }
8193
8194 #[test]
8195 fn telemetry_env_invalid_is_recorded_rather_than_swallowed() {
8196 let guard = TelemetryEnvGuard::take();
8197 guard.set("sure why not");
8198
8199 let env = EnvRuntimeOverrides::load();
8200 assert_eq!(env.telemetry, None);
8201 assert!(env.telemetry_env_invalid);
8202
8203 guard.set("disabled");
8204 let env = EnvRuntimeOverrides::load();
8205 assert_eq!(env.telemetry, Some(false));
8206 assert!(!env.telemetry_env_invalid);
8207
8208 guard.set("enabled");
8209 let env = EnvRuntimeOverrides::load();
8210 assert_eq!(env.telemetry, Some(true));
8211 assert!(!env.telemetry_env_invalid);
8212 }
8213
8214 #[test]
8215 fn telemetry_explicit_off_distinguishes_an_answer_from_the_default() {
8216 let _guard = TelemetryEnvGuard::take();
8217
8218 // Nobody said anything: off, but not an answer.
8219 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
8220 assert!(!resolved.telemetry);
8221 assert!(!resolved.telemetry_explicit_off);
8222
8223 // The config file says no.
8224 let config = ConfigToml {
8225 telemetry: Some(false),
8226 ..ConfigToml::default()
8227 };
8228 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8229 assert!(!resolved.telemetry);
8230 assert!(resolved.telemetry_explicit_off);
8231
8232 // A CLI `--telemetry true` does not override the file's `false`: the
8233 // persistent switch is a floor, and the answer stands.
8234 let cli = CliRuntimeOverrides {
8235 telemetry: Some(true),
8236 ..CliRuntimeOverrides::default()
8237 };
8238 let resolved = config.resolve_runtime_options(&cli);
8239 assert!(!resolved.telemetry);
8240 assert!(resolved.telemetry_explicit_off);
8241
8242 // A CLI `--telemetry false` stops the run without being an answer: it is
8243 // scoped to the run, and a run-scoped switch must not delete state.
8244 let cli = CliRuntimeOverrides {
8245 telemetry: Some(false),
8246 ..CliRuntimeOverrides::default()
8247 };
8248 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
8249 assert!(!resolved.telemetry);
8250 assert!(!resolved.telemetry_explicit_off);
8251 }
8252
8253 /// The shipped default endpoint, pinned by value.
8254 ///
8255 /// A default nobody asserts is a default that drifts, and this one decides
8256 /// which host an enabled session contacts. The literal is repeated here on
8257 /// purpose: comparing the constant to itself would pass against any edit.
8258 #[test]
8259 fn an_unconfigured_endpoint_resolves_to_the_shipped_default() {
8260 let _guard = TelemetryEnvGuard::take();
8261
8262 assert_eq!(
8263 DEFAULT_TELEMETRY_ENDPOINT,
8264 "https://telemetry.codewhale.net/v1/telemetry"
8265 );
8266 let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
8267 assert_eq!(
8268 resolved.telemetry_endpoint.as_deref(),
8269 Some(DEFAULT_TELEMETRY_ENDPOINT),
8270 "an unconfigured endpoint must resolve to the shipped default"
8271 );
8272
8273 // …and it decides only *where*, never *whether*. The default endpoint must
8274 // not have made an unconfigured install start collecting: telemetry is
8275 // still off, and still not an answer anyone gave.
8276 assert!(!resolved.telemetry);
8277 assert!(!resolved.telemetry_explicit_off);
8278 }
8279
8280 /// A user-set endpoint beats the shipped default, from either source, and an
8281 /// explicitly *empty* one is the dry-run sink rather than a missing value.
8282 #[test]
8283 fn a_configured_endpoint_beats_the_shipped_default() {
8284 let guard = TelemetryEnvGuard::take();
8285
8286 let config = ConfigToml {
8287 telemetry_endpoint: Some("https://collector.internal/v1/batch".to_string()),
8288 ..ConfigToml::default()
8289 };
8290 assert_eq!(
8291 config
8292 .resolve_runtime_options(&CliRuntimeOverrides::default())
8293 .telemetry_endpoint
8294 .as_deref(),
8295 Some("https://collector.internal/v1/batch"),
8296 "the config file must outrank the shipped default"
8297 );
8298
8299 // The environment outranks the file, which outranks the default.
8300 guard.set_endpoint("https://collector.env.internal/v1/batch");
8301 assert_eq!(
8302 config
8303 .resolve_runtime_options(&CliRuntimeOverrides::default())
8304 .telemetry_endpoint
8305 .as_deref(),
8306 Some("https://collector.env.internal/v1/batch")
8307 );
8308 assert_eq!(
8309 ConfigToml::default()
8310 .resolve_runtime_options(&CliRuntimeOverrides::default())
8311 .telemetry_endpoint
8312 .as_deref(),
8313 Some("https://collector.env.internal/v1/batch")
8314 );
8315 }
8316
8317 /// An empty endpoint means "contact nobody", not "use the default".
8318 ///
8319 /// `None` at the resolved layer is what `codewhale-telemetry`'s client reads as
8320 /// the dry-run sink: batches are serialized exactly as a server would see them
8321 /// and appended to `dryrun.jsonl`, and no HTTP client is constructed. With a
8322 /// shipped default in place that path is only reachable through an explicit
8323 /// empty value, so both ways of writing one are pinned here.
8324 #[test]
8325 fn an_empty_endpoint_keeps_the_dry_run_sink_reachable() {
8326 let guard = TelemetryEnvGuard::take();
8327
8328 let config = ConfigToml {
8329 telemetry_endpoint: Some(String::new()),
8330 ..ConfigToml::default()
8331 };
8332 assert_eq!(
8333 config
8334 .resolve_runtime_options(&CliRuntimeOverrides::default())
8335 .telemetry_endpoint,
8336 None,
8337 "`telemetry_endpoint = \"\"` must resolve to the dry-run sink"
8338 );
8339
8340 // Whitespace is the same statement typed less carefully.
8341 let config = ConfigToml {
8342 telemetry_endpoint: Some(" ".to_string()),
8343 ..ConfigToml::default()
8344 };
8345 assert_eq!(
8346 config
8347 .resolve_runtime_options(&CliRuntimeOverrides::default())
8348 .telemetry_endpoint,
8349 None
8350 );
8351
8352 // An emptied environment variable says it too, and it says it over a
8353 // config file that names a real host — otherwise the documented one-shot
8354 // `CODEWHALE_TELEMETRY_ENDPOINT= codewhale …` would silently keep sending.
8355 let config = ConfigToml {
8356 telemetry_endpoint: Some("https://collector.internal/v1/batch".to_string()),
8357 ..ConfigToml::default()
8358 };
8359 guard.set_endpoint("");
8360 assert_eq!(
8361 config
8362 .resolve_runtime_options(&CliRuntimeOverrides::default())
8363 .telemetry_endpoint,
8364 None
8365 );
8366 assert_eq!(
8367 ConfigToml::default()
8368 .resolve_runtime_options(&CliRuntimeOverrides::default())
8369 .telemetry_endpoint,
8370 None,
8371 "an emptied environment variable must not fall through to the default"
8372 );
8373 }
8374
8375 #[test]
8376 fn telemetry_endpoint_round_trips_through_all_four_verbs() {
8377 let mut config = ConfigToml::default();
8378 assert_eq!(config.get_value("telemetry_endpoint"), None);
8379 assert!(!config.list_values().contains_key("telemetry_endpoint"));
8380
8381 config
8382 .set_value("telemetry_endpoint", "https://collector.example/ingest")
8383 .expect("set telemetry_endpoint");
8384 assert_eq!(
8385 config.get_value("telemetry_endpoint").as_deref(),
8386 Some("https://collector.example/ingest")
8387 );
8388 assert_eq!(
8389 config
8390 .list_values()
8391 .get("telemetry_endpoint")
8392 .map(String::as_str),
8393 Some("https://collector.example/ingest")
8394 );
8395
8396 // Scheme rules belong at send time, not at set time: staging a value the
8397 // client will later refuse must still be possible.
8398 config
8399 .set_value("telemetry_endpoint", "http://collector.example/ingest")
8400 .expect("staging an http endpoint is not a set-time error");
8401 assert_eq!(
8402 config.get_value("telemetry_endpoint").as_deref(),
8403 Some("http://collector.example/ingest")
8404 );
8405
8406 config
8407 .unset_value("telemetry_endpoint")
8408 .expect("unset telemetry_endpoint");
8409 assert_eq!(config.get_value("telemetry_endpoint"), None);
8410 assert!(!config.list_values().contains_key("telemetry_endpoint"));
8411
8412 // The key must land as a typed field, never in `extras` — an extras key
8413 // would serialize after the section tables and break the file.
8414 assert!(!config.extras.contains_key("telemetry_endpoint"));
8415 }
8416
8417 #[test]
8418 fn telemetry_endpoint_stays_a_scalar_sibling_of_telemetry() {
8419 // `telemetry` is a root scalar and every section table is declared after
8420 // it, so the endpoint must serialize as a scalar too. A `[telemetry]`
8421 // table would be a hard parse failure on load, and a scalar emitted after
8422 // a table is a TOML `ValueAfterTable` error on save.
8423 let config = ConfigToml {
8424 telemetry: Some(true),
8425 telemetry_endpoint: Some("https://collector.example/ingest".to_string()),
8426 ..ConfigToml::default()
8427 };
8428 let rendered = toml::to_string_pretty(&config).expect("serialize config");
8429 assert!(
8430 rendered.contains("telemetry_endpoint = \"https://collector.example/ingest\""),
8431 "{rendered}"
8432 );
8433 assert!(!rendered.contains("[telemetry]"), "{rendered}");
8434
8435 let endpoint_at = rendered
8436 .find("telemetry_endpoint =")
8437 .expect("endpoint present");
8438 if let Some(first_table_at) = rendered.find("\n[") {
8439 assert!(
8440 endpoint_at < first_table_at,
8441 "telemetry_endpoint must precede every section table:\n{rendered}"
8442 );
8443 }
8444
8445 let round_tripped: ConfigToml = toml::from_str(&rendered).expect("round trips");
8446 assert_eq!(
8447 round_tripped.telemetry_endpoint.as_deref(),
8448 Some("https://collector.example/ingest")
8449 );
8450 assert_eq!(round_tripped.telemetry, Some(true));
8451 }
8452
8453 #[test]
8454 fn a_telemetry_table_is_a_hard_load_failure_and_stays_unbuildable() {
8455 // Documents *why* the endpoint is a sibling scalar rather than
8456 // `[telemetry] endpoint = …`: `telemetry` is already `Option<bool>`, so a
8457 // table of that name cannot deserialize at all.
8458 let err = toml::from_str::<ConfigToml>("[telemetry]\nenabled = true\n")
8459 .expect_err("a [telemetry] table must not deserialize");
8460 let _ = err;
8461 }
8462
8463 #[test]
8464 fn telemetry_notice_is_owed_until_it_is_answered_out_loud() {
8465 let mut state = SetupState::default();
8466
8467 // A fresh record owes the notice, and "owed" is not an answer either way.
8468 assert!(state.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION));
8469 assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
8470 assert!(!state.telemetry_declined(TELEMETRY_NOTICE_VERSION));
8471
8472 state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, false);
8473 assert!(!state.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION));
8474 assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
8475 assert!(state.telemetry_declined(TELEMETRY_NOTICE_VERSION));
8476
8477 state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, true);
8478 assert!(state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
8479 assert!(!state.telemetry_declined(TELEMETRY_NOTICE_VERSION));
8480
8481 // A decision recorded against different notice content is stale: the
8482 // notice is owed again, and the stale `true` is not consent to the new
8483 // content.
8484 state.record_telemetry_notice("0", true);
8485 assert!(state.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION));
8486 assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
8487 assert!(!state.telemetry_declined(TELEMETRY_NOTICE_VERSION));
8488 }
8489
8490 #[test]
8491 fn deferring_the_constitution_checkpoint_does_not_answer_the_telemetry_notice() {
8492 // `complete_constitution_checkpoint(_, Deferred)` persists a completed
8493 // checkpoint without showing the user anything — reached from the
8494 // skip-onboarding path. The telemetry notice must not mirror that.
8495 let mut state = SetupState::default();
8496 state.complete_constitution_checkpoint("0.9.4", ConstitutionChoice::Deferred);
8497
8498 assert_eq!(
8499 state.constitution_checkpoint_completed_for.as_deref(),
8500 Some("0.9.4")
8501 );
8502 assert_eq!(state.telemetry_notice_decided_for, None);
8503 assert!(state.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION));
8504 assert!(!state.telemetry_opt_in);
8505 }
8506
8507 #[test]
8508 fn inherited_setup_state_never_carries_a_telemetry_decision() {
8509 // Upgrading users with no `setup_state.json` get a derived record. It must
8510 // not manufacture an answer they never gave.
8511 let state = SetupState::derive_inherited(&InheritedConfigFacts {
8512 has_provider_route: true,
8513 has_credentials_or_local_runtime: true,
8514 trust_chosen: true,
8515 language: Some("en".to_string()),
8516 ..InheritedConfigFacts::default()
8517 });
8518 assert_eq!(state.telemetry_notice_decided_for, None);
8519 assert!(!state.telemetry_opt_in);
8520 assert!(state.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION));
8521 }
8522
8523 #[test]
8524 fn telemetry_notice_fields_round_trip_and_stay_absent_when_unanswered() {
8525 let mut state = SetupState::default();
8526 let rendered = serde_json::to_string(&state).expect("serialize setup state");
8527 assert!(
8528 !rendered.contains("telemetry_notice_decided_for"),
8529 "an unanswered notice must not write a field: {rendered}"
8530 );
8531 assert!(
8532 !rendered.contains("telemetry_opt_in"),
8533 "a false opt-in must not write a field: {rendered}"
8534 );
8535
8536 state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, true);
8537 let rendered = serde_json::to_string(&state).expect("serialize setup state");
8538 let round_tripped: SetupState = serde_json::from_str(&rendered).expect("round trips");
8539 assert_eq!(round_tripped, state);
8540
8541 // Records written before these fields existed load as "notice owed".
8542 let legacy: SetupState =
8543 serde_json::from_str(r#"{"schema_version":1}"#).expect("legacy record loads");
8544 assert_eq!(legacy.telemetry_notice_decided_for, None);
8545 assert!(!legacy.telemetry_opt_in);
8546 }
8547
8547 lines RUST