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