1mod auth;
2mod custom_provider_config;
3mod hooks;
4mod paths;
5mod settings;
6
7use crate::thinking::ThinkingLevel;
8use std::{collections::BTreeMap, env, fmt, io::IsTerminal, path::PathBuf};
9
10#[cfg(test)]
11pub(crate) use auth::write_auth;
12pub use auth::{
13 Auth, AuthProviderRecord, AuthState, LogoutAuthRemoval, ProviderCredential, read_auth,
14 remove_provider_auth, resolve_provider_credential,
15};
16pub(crate) use auth::{
17 extract_chatgpt_account_id_from_jwt, extract_oauth_account_id_from_jwt, update_auth,
18};
19pub(crate) use custom_provider_config::normalized_extra_models;
20pub use custom_provider_config::{
21 CustomProviderConfig, CustomProviderRegistration, CustomReasoningProtocol,
22 derive_custom_provider_id, looks_like_secret_value, make_custom_provider_config,
23 normalize_custom_provider_base_url, validate_custom_provider_id, validate_env_var_name,
24 validate_optional_env_var_name,
25};
26pub use hooks::{
27 HookDefinition, HookFailurePolicy, HookPayloadMode, HookSettings, InjectedContentSettings,
28 InjectedContentStyle,
29};
30pub use paths::McPaths;
31pub(crate) use settings::set_selected_model;
32pub use settings::{
33 AnthropicCacheTtl, BashToolSettings, CompactionSettings, DEFAULT_MCP_TIMEOUT_SECONDS,
34 FindToolSettings, GrepToolSettings, HashEditToolSettings, HerdrSettings, InstructionsSettings,
35 IntegrationsSettings, LspServerConfig, LspServersSettings, LspSettings, McpHttpServerConfig,
36 McpOAuthConfig, McpServerConfig, McpServersSettings, McpStdioServerConfig, OpenAiCodexSettings,
37 ProviderStreamSettings, ReadToolSettings, RepoMapToolSettings, SelectedModelSettings,
38 SessionTitleSettings, Settings, SettingsListKind, SettingsScope, SkillsSettings,
39 SubagentsSettings, SubagentsToolSettings, TextVerbosity, ToolOutputCompressionSettings,
40 ToolSettings, TtsrRuleSetting, TtsrSettings, TuiSettings, ViewImageToolSettings,
41 ViewImageVisionModelSettings, WriteToolSettings,
42};
43pub(crate) use settings::{
44 CompactionConfig, SessionTitleConfig, clamp_subagent_max_depth,
45 default_view_image_max_image_bytes, ensure_settings_schema_files, validate_mcp_http_url_field,
46 validate_mcp_server_name, validate_view_image_identifier, validate_view_image_max_image_bytes,
47};
48pub(crate) use settings::{
49 disabled_model_ids_from_settings, disabled_skill_names_from_settings,
50 disabled_subagent_profile_names_from_settings, disabled_tool_names_from_settings,
51 load_config_with_settings,
52};
53pub(crate) use settings::{
54 disabled_names_for_modal_scope, read_settings, remove_custom_provider, selected_primary_agent,
55 set_mcp_server_enabled, set_model_disabled_for_scope, set_selected_primary_agent,
56 set_skill_disabled_for_scope, set_subagent_profile_disabled, set_thinking_level,
57 set_tool_disabled, upsert_custom_provider,
58};
59#[cfg(test)]
60pub(crate) use settings::{
61 disabled_skill_names, read_settings_for_scope, set_skill_disabled,
62 update_settings_preserving_unknown_top_level_fields, write_settings,
63};
64
65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
66pub enum ColorChoice {
67 #[default]
68 Auto,
69 Always,
70 Never,
71}
72
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct CliConfigOverrides {
75 pub provider: Option<String>,
76 pub model: Option<String>,
77 pub api_key: Option<String>,
78 pub color: Option<ColorChoice>,
79}
80
81#[derive(Clone, PartialEq, Eq)]
82pub struct EffectiveConfig {
83 pub provider: Option<String>,
84 pub model: Option<String>,
85 pub no_color: bool,
86 pub file_autocomplete_respects_gitignore: bool,
87 pub custom_providers: BTreeMap<String, CustomProviderConfig>,
88 pub thinking_level: ThinkingLevel,
89 pub api_key: Option<String>,
91 pub auth: Option<ProviderCredential>,
92 pub paths: McPaths,
93}
94
95impl fmt::Debug for EffectiveConfig {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 f.debug_struct("EffectiveConfig")
98 .field("provider", &self.provider)
99 .field("model", &self.model)
100 .field("no_color", &self.no_color)
101 .field(
102 "file_autocomplete_respects_gitignore",
103 &self.file_autocomplete_respects_gitignore,
104 )
105 .field("custom_providers", &self.custom_providers)
106 .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
107 .field("auth", &self.auth)
108 .field("thinking_level", &self.thinking_level)
109 .field("paths", &self.paths)
110 .finish()
111 }
112}
113
114impl EffectiveConfig {
115 pub fn load(paths: McPaths, cli: CliConfigOverrides) -> anyhow::Result<Self> {
116 let (config, _) = load_config_with_settings(paths, cli)?;
117 Ok(config)
118 }
119
120 pub(crate) fn from_loaded_settings(
121 paths: McPaths,
122 cli: CliConfigOverrides,
123 settings: Settings,
124 ) -> anyhow::Result<Self> {
125 let auth_file = read_auth(&paths)?;
126
127 let (color_enabled, _) = resolve_output_style(&settings, cli.color);
128 let provider = cli
129 .provider
130 .or_else(|| env::var("MC_PROVIDER").ok())
131 .or(settings.selected_model.provider);
132 let model = cli
133 .model
134 .or_else(|| env::var("MC_MODEL").ok())
135 .or(settings.selected_model.model);
136 let provider_id = provider
137 .as_deref()
138 .unwrap_or(crate::providers::OPENAI_CODEX_PROVIDER);
139 let auth = resolve_provider_credential(
140 provider_id,
141 &auth_file,
142 cli.api_key,
143 &settings.custom_providers,
144 )?;
145 let api_key = match &auth {
146 Some(ProviderCredential::ApiKey { key }) => Some(key.clone()),
147 _ => None,
148 };
149
150 Ok(Self {
151 provider,
152 model,
153 no_color: !color_enabled,
154 file_autocomplete_respects_gitignore: settings.file_autocomplete_respects_gitignore,
155 custom_providers: settings.custom_providers,
156 thinking_level: settings.selected_model.thinking_level.unwrap_or_default(),
157 api_key,
158 auth,
159 paths,
160 })
161 }
162
163 pub fn validate_provider_ready(&self) -> Result<(), ConfigError> {
164 if self.provider.as_deref().unwrap_or_default().is_empty() {
165 return Err(ConfigError::MissingProvider {
166 settings_path: self.paths.settings_file.clone(),
167 });
168 }
169 if self.model.as_deref().unwrap_or_default().is_empty() {
170 return Err(ConfigError::MissingModel {
171 settings_path: self.paths.settings_file.clone(),
172 });
173 }
174 self.require_auth()?;
175 Ok(())
176 }
177
178 pub fn auth_state(&self) -> AuthState {
179 AuthState::for_provider_with_custom(
180 self.provider_id(),
181 self.auth.as_ref(),
182 &self.custom_providers,
183 )
184 }
185
186 pub fn require_auth(&self) -> Result<ProviderCredential, ConfigError> {
187 self.auth_state()
188 .credential()
189 .cloned()
190 .ok_or_else(|| self.missing_auth_error())
191 }
192
193 pub(crate) fn missing_auth_error(&self) -> ConfigError {
194 ConfigError::missing_auth_for_custom_providers(
195 self.provider_id(),
196 &self.custom_providers,
197 &self.paths.auth_file,
198 )
199 }
200
201 pub fn provider_id(&self) -> &str {
202 self.provider
203 .as_deref()
204 .unwrap_or(crate::providers::OPENAI_CODEX_PROVIDER)
205 }
206}
207
208pub fn load_effective_provider_selection(
209 paths: &McPaths,
210 provider: &str,
211 model: &str,
212) -> anyhow::Result<EffectiveConfig> {
213 let settings = read_settings(paths)?;
214 let auth_file = read_auth(paths)?;
215 let auth = resolve_provider_credential(provider, &auth_file, None, &settings.custom_providers)?;
216 let api_key = match &auth {
217 Some(ProviderCredential::ApiKey { key }) => Some(key.clone()),
218 _ => None,
219 };
220 let (color_enabled, _) = resolve_output_style(&settings, None);
221 Ok(EffectiveConfig {
222 provider: Some(provider.to_string()),
223 model: Some(model.to_string()),
224 no_color: !color_enabled,
225 file_autocomplete_respects_gitignore: settings.file_autocomplete_respects_gitignore,
226 custom_providers: settings.custom_providers,
227 thinking_level: settings.selected_model.thinking_level.unwrap_or_default(),
228 api_key,
229 auth,
230 paths: paths.clone(),
231 })
232}
233
234impl ConfigError {
235 #[cfg(test)]
236 pub(crate) fn missing_auth(provider: &str) -> Self {
237 missing_auth_error(provider, None, &PathBuf::from("~/.magi-code/auth.json"))
238 }
239
240 pub(crate) fn missing_auth_for_custom_providers(
241 provider: &str,
242 custom_providers: &BTreeMap<String, CustomProviderConfig>,
243 auth_file: &std::path::Path,
244 ) -> Self {
245 missing_auth_error(provider, custom_providers.get(provider), auth_file)
246 }
247}
248
249fn missing_auth_error(
250 provider: &str,
251 custom: Option<&CustomProviderConfig>,
252 auth_file: &std::path::Path,
253) -> ConfigError {
254 let auth_path = auth_file.display();
255 let message = if let Some(custom) = custom {
256 match &custom.api_key_env_var {
257 Some(env_var) => format!(
258 "missing auth: custom provider '{provider}' is configured but environment variable {env_var} is missing or empty"
259 ),
260 None => format!(
261 "missing auth: custom provider '{provider}' is configured for no-auth but could not be prepared"
262 ),
263 }
264 } else if provider == crate::providers::OPENAI_CODEX_PROVIDER {
265 format!(
266 "missing auth: missing OAuth auth or expired OAuth credentials without refresh for provider 'openai-codex'; needs re-login with /login openai-codex; --api-key, MC_API_KEY, and OPENAI_API_KEY are unsupported for openai-codex; OAuth auth includes access token and accountId in {auth_path}"
267 )
268 } else if provider == crate::providers::CLAUDE_CODE_PROVIDER {
269 format!(
270 "missing auth: provider 'claude-code' requires Claude Code OAuth credentials from macOS Keychain service 'Claude Code-credentials' or ~/.claude/.credentials.json, or explicit provider-keyed API-key auth in {auth_path}; --api-key, MC_API_KEY, OPENAI_API_KEY, and ambient ANTHROPIC_API_KEY are ignored for claude-code; API-key fallback bills Anthropic API credits"
271 )
272 } else if provider == crate::providers::ANTHROPIC_PROVIDER {
273 format!(
274 "missing auth: provider 'anthropic' requires an Anthropic API key; set ANTHROPIC_API_KEY or configure provider-keyed API-key auth in {auth_path}; OPENAI_API_KEY, MC_API_KEY, --api-key, and Claude Code subscription OAuth are not used for Anthropic"
275 )
276 } else {
277 format!(
278 "missing auth for provider '{provider}'; configure provider-keyed auth in {auth_path}"
279 )
280 };
281 ConfigError::MissingAuth {
282 provider: provider.to_string(),
283 message,
284 }
285}
286
287fn resolve_output_style(settings: &Settings, cli_color: Option<ColorChoice>) -> (bool, bool) {
288 let stdout_is_tty = std::io::stdout().is_terminal();
289 resolve_output_style_for_stdout(settings, cli_color, stdout_is_tty)
290}
291
292pub(crate) fn resolve_output_style_for_stdout(
293 settings: &Settings,
294 cli_color: Option<ColorChoice>,
295 stdout_is_tty: bool,
296) -> (bool, bool) {
297 let color_enabled = match cli_color {
298 Some(ColorChoice::Always) => true,
299 Some(ColorChoice::Never) => false,
300 Some(ColorChoice::Auto) => stdout_is_tty,
301 None => {
302 !settings.no_color.unwrap_or(false)
303 && env::var_os("NO_COLOR").is_none()
304 && stdout_is_tty
305 }
306 };
307 (color_enabled, stdout_is_tty)
308}
309
310#[derive(Debug, thiserror::Error, PartialEq, Eq)]
311pub enum ConfigError {
312 #[error(
313 "missing provider; set --provider, MC_PROVIDER, or selected_model.provider in {settings_path}"
314 )]
315 MissingProvider { settings_path: PathBuf },
316 #[error("missing model; set --model, MC_MODEL, or selected_model.model in {settings_path}")]
317 MissingModel { settings_path: PathBuf },
318 #[error("{message}")]
319 MissingAuth { provider: String, message: String },
320}
321
322pub fn is_custom_provider_id(provider: &str, settings: &Settings) -> bool {
323 settings.custom_providers.contains_key(provider)
324}
325
326pub fn custom_provider(
327 paths: &McPaths,
328 provider: &str,
329) -> anyhow::Result<Option<CustomProviderConfig>> {
330 let settings = read_settings(paths)?;
331 Ok(settings.custom_providers.get(provider).cloned())
332}
333
334pub fn clear_provider_auth(config: &EffectiveConfig) -> anyhow::Result<Auth> {
335 Ok(remove_provider_auth(&config.paths, config.provider_id())?.auth)
336}
337
338pub fn load_context_budget(
339 config: &EffectiveConfig,
340) -> anyhow::Result<crate::context::ContextBudget> {
341 let settings = read_settings(&config.paths)?;
342 let mut budget = settings.context.unwrap_or_default();
343 let provider = config.provider_id();
344 let model = config
345 .model
346 .as_deref()
347 .unwrap_or_else(|| crate::providers::default_model_for_provider(provider));
348 if let Some(context_window) =
349 crate::model_catalog::cached_model_context_window(&config.paths, provider, model)
350 {
351 budget.max_tokens = context_window;
352 }
353 budget.apply_model_override(provider, model);
354 Ok(budget)
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use std::{
361 collections::{BTreeMap, BTreeSet},
362 env, fs,
363 path::{Path, PathBuf},
364 };
365 use tempfile::TempDir;
366
367 fn parse_validated_settings(raw: &str) -> anyhow::Result<Settings> {
368 let settings = serde_json::from_str(raw)?;
369 settings::validate_settings(&settings)?;
370 Ok(settings)
371 }
372
373 struct EnvSnapshot(Vec<(&'static str, Option<std::ffi::OsString>)>);
374
375 impl Drop for EnvSnapshot {
376 fn drop(&mut self) {
377 let env = crate::test_support::env::env_lock();
378 for (key, value) in &self.0 {
379 match value {
380 Some(value) => env.set_var(key, value),
381 None => env.remove_var(key),
382 }
383 }
384 }
385 }
386
387 fn isolate_env() -> EnvSnapshot {
388 let keys = [
389 "MC_PROVIDER",
390 "MC_MODEL",
391 "MC_API_KEY",
392 "OPENAI_API_KEY",
393 "MC_HOME",
394 "NO_COLOR",
395 "ANTHROPIC_API_KEY",
396 "ANTHROPIC_AUTH_TOKEN",
397 "CLAUDE_CODE_OAUTH_TOKEN",
398 "MC_CLAUDE_CODE_CREDENTIALS_PATH",
399 "CUSTOM_PROVIDER_API_KEY",
400 "OTHER_PROVIDER_API_KEY",
401 ];
402 let env = crate::test_support::env::env_lock();
403 let snapshot = EnvSnapshot(keys.map(|key| (key, env::var_os(key))).into());
404 for key in keys {
405 env.remove_var(key);
406 }
407 snapshot
408 }
409
410 fn write_auth_fixture(path: &Path, text: &str) {
411 fs::write(path, text).unwrap();
412 #[cfg(unix)]
413 set_mode(path, 0o600);
414 }
415
416 #[cfg(unix)]
417 fn set_mode(path: &Path, mode: u32) {
418 use std::os::unix::fs::PermissionsExt;
419 let mut permissions = fs::metadata(path).unwrap().permissions();
420 permissions.set_mode(mode);
421 fs::set_permissions(path, permissions).unwrap();
422 }
423
424 #[test]
425 fn compaction_settings_default_to_active_or_require_complete_override() {
426 let absent: Settings = serde_json::from_str("{}").unwrap();
427 assert_eq!(absent.compaction, CompactionSettings::default());
428 assert_eq!(
429 absent
430 .compaction
431 .resolve_config(" active-provider ", " active-model ")
432 .unwrap(),
433 CompactionConfig {
434 provider: "active-provider".to_string(),
435 model: "active-model".to_string(),
436 }
437 );
438
439 let override_settings: Settings = serde_json::from_str(
440 r#"{"compaction":{"provider":" compact-provider ","model":" compact-model "}}"#,
441 )
442 .unwrap();
443 assert_eq!(
444 override_settings
445 .compaction
446 .resolve_config("active-provider", "active-model")
447 .unwrap(),
448 CompactionConfig {
449 provider: "compact-provider".to_string(),
450 model: "compact-model".to_string(),
451 }
452 );
453
454 for raw in [
455 r#"{"compaction":{"provider":"compact-provider"}}"#,
456 r#"{"compaction":{"model":"compact-model"}}"#,
457 r#"{"compaction":{"provider":" ","model":"compact-model"}}"#,
458 r#"{"compaction":{"provider":"compact-provider","model":" "}}"#,
459 ] {
460 let settings: Settings = serde_json::from_str(raw).unwrap();
461 let error = settings
462 .compaction
463 .resolve_config("active-provider", "active-model")
464 .unwrap_err();
465 assert!(error.contains("compaction.provider"), "{error}");
466 }
467 }
468
469 #[test]
470 fn compaction_settings_persistence_preserves_unknown_fields() {
471 let temp = TempDir::new().unwrap();
472 let paths = McPaths::from_root(temp.path().join("mc"));
473 fs::create_dir_all(&paths.root).unwrap();
474 fs::write(
475 &paths.settings_file,
476 r#"{"future_setting":{"keep":true},"compaction":{"provider":"old","model":"old-model"}}"#,
477 )
478 .unwrap();
479
480 update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
481 settings.compaction = CompactionSettings {
482 provider: Some("new".to_string()),
483 model: Some("new-model".to_string()),
484 };
485 })
486 .unwrap();
487
488 let value: serde_json::Value =
489 serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
490 assert_eq!(value["future_setting"]["keep"], true);
491 assert_eq!(value["compaction"]["provider"], "new");
492 assert_eq!(value["compaction"]["model"], "new-model");
493 assert!(!value.to_string().contains("api_key"));
494 }
495
496 #[test]
497 fn session_title_settings_require_enabled_and_explicit_provider_model() {
498 let absent: Settings = serde_json::from_str("{}").unwrap();
499 assert_eq!(absent.session_titles.eligible_config().unwrap(), None);
500
501 let disabled: Settings = serde_json::from_str(
502 r#"{"session_titles":{"enabled":false,"provider":"title-provider","model":"title-model"}}"#,
503 )
504 .unwrap();
505 assert_eq!(
506 disabled.session_titles.provider.as_deref(),
507 Some("title-provider")
508 );
509 assert_eq!(
510 disabled.session_titles.model.as_deref(),
511 Some("title-model")
512 );
513 assert_eq!(disabled.session_titles.eligible_config().unwrap(), None);
514
515 let enabled: Settings = serde_json::from_str(
516 r#"{"session_titles":{"enabled":true,"provider":" title-provider ","model":" title-model "}}"#,
517 )
518 .unwrap();
519 assert_eq!(
520 enabled.session_titles.eligible_config().unwrap(),
521 Some(SessionTitleConfig {
522 provider: "title-provider".to_string(),
523 model: "title-model".to_string(),
524 })
525 );
526
527 let missing_provider: Settings =
528 serde_json::from_str(r#"{"session_titles":{"enabled":true,"model":"title-model"}}"#)
529 .unwrap();
530 assert!(
531 missing_provider
532 .session_titles
533 .eligible_config()
534 .unwrap_err()
535 .contains("provider")
536 );
537
538 let blank_model: Settings = serde_json::from_str(
539 r#"{"session_titles":{"enabled":true,"provider":"title-provider","model":" "}}"#,
540 )
541 .unwrap();
542 assert!(
543 blank_model
544 .session_titles
545 .eligible_config()
546 .unwrap_err()
547 .contains("model")
548 );
549 }
550
551 #[test]
552 fn title_provider_selection_uses_explicit_settings_without_assistant_fallback() {
553 let _env = isolate_env();
554 let env = crate::test_support::env::env_lock();
555 let temp = TempDir::new().unwrap();
556 let paths = McPaths::from_root(temp.path().join("mc"));
557 upsert_custom_provider(
558 &paths,
559 "title-provider",
560 make_custom_provider_config("Title Provider", "http://localhost:8080/v1", "").unwrap(),
561 )
562 .unwrap();
563 update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
564 settings.selected_model.provider = Some("assistant-provider".to_string());
565 settings.selected_model.model = Some("assistant-model".to_string());
566 settings.session_titles = SessionTitleSettings {
567 enabled: true,
568 provider: Some("title-provider".to_string()),
569 model: Some("title-model".to_string()),
570 };
571 })
572 .unwrap();
573 env.set_var("MC_PROVIDER", "env-provider");
574 env.set_var("MC_MODEL", "env-model");
575
576 let title = read_settings(&paths)
577 .unwrap()
578 .session_titles
579 .eligible_config()
580 .unwrap()
581 .unwrap();
582 let config =
583 load_effective_provider_selection(&paths, &title.provider, &title.model).unwrap();
584
585 assert_eq!(config.provider.as_deref(), Some("title-provider"));
586 assert_eq!(config.model.as_deref(), Some("title-model"));
587 assert!(matches!(config.auth, Some(ProviderCredential::NoAuth)));
588 }
589
590 #[test]
591 fn hook_settings_defaults_are_inert_redacted_and_warn() {
592 let settings: Settings = serde_json::from_str("{}").unwrap();
593 assert!(!settings.hooks.enabled);
594 assert!(!settings.hooks.show_in_tui);
595 assert_eq!(settings.hooks.payload, HookPayloadMode::Redacted);
596 assert_eq!(settings.hooks.failure_policy, HookFailurePolicy::Warn);
597 assert!(!settings.hooks.provider_context_injection);
598 assert_eq!(settings.hooks.provider_context_max_bytes, 4096);
599 assert!(settings.hooks.before_tool.is_empty());
600 assert!(settings.hooks.after_tool.is_empty());
601 }
602
603 #[test]
604 fn hook_settings_parse_policies_payloads_filters_and_bounds() {
605 let settings: Settings = serde_json::from_str(
606 r#"{
607 "hooks": {
608 "enabled": true,
609 "show_in_tui": true,
610 "payload": "full",
611 "timeout_seconds": 10,
612 "stdout_max_bytes": 1024,
613 "stderr_max_bytes": 2048,
614 "failure_policy": "ignore",
615 "provider_context_injection": true,
616 "provider_context_max_bytes": 8192,
617 "before_tool": [
618 {"label":"gate","command":"printf ok","failure_policy":"block","include_tools":["write"],"exclude_tools":["read"],"provider_context_injection":true}
619 ],
620 "after_tool": [
621 {"label":"audit","command":"printf ok","payload":"redacted","failure_policy":"fail","provider_context_injection":false,"provider_context_max_bytes":16384}
622 ]
623 }
624 }"#,
625 )
626 .unwrap();
627 assert!(settings.hooks.enabled);
628 assert!(settings.hooks.show_in_tui);
629 assert_eq!(settings.hooks.payload, HookPayloadMode::Full);
630 assert_eq!(settings.hooks.failure_policy, HookFailurePolicy::Ignore);
631 assert!(settings.hooks.provider_context_injection);
632 assert_eq!(settings.hooks.provider_context_max_bytes, 8192);
633 assert_eq!(
634 settings.hooks.before_tool[0].provider_context_injection,
635 Some(true)
636 );
637 assert_eq!(
638 settings.hooks.before_tool[0].failure_policy,
639 Some(HookFailurePolicy::Block)
640 );
641 assert!(settings.hooks.before_tool[0].matches_tool("write"));
642 assert!(!settings.hooks.before_tool[0].matches_tool("read"));
643 assert_eq!(
644 settings.hooks.after_tool[0].payload,
645 Some(HookPayloadMode::Redacted)
646 );
647 assert_eq!(
648 settings.hooks.after_tool[0].failure_policy,
649 Some(HookFailurePolicy::Fail)
650 );
651 assert_eq!(
652 settings.hooks.after_tool[0].provider_context_injection,
653 Some(false)
654 );
655 assert_eq!(
656 settings.hooks.after_tool[0].provider_context_max_bytes,
657 Some(16384)
658 );
659 }
660
661 #[test]
662 fn hook_settings_show_in_tui_survives_when_hooks_are_disabled() {
663 let settings: Settings =
664 serde_json::from_str(r#"{"hooks":{"enabled":false,"show_in_tui":true}}"#).unwrap();
665
666 assert!(!settings.hooks.enabled);
667 assert!(settings.hooks.show_in_tui);
668 assert!(settings.hooks.before_tool.is_empty());
669 assert!(settings.hooks.after_tool.is_empty());
670
671 let serialized = serde_json::to_value(&settings).unwrap();
672 assert_eq!(serialized["hooks"]["enabled"], false);
673 assert_eq!(serialized["hooks"]["show_in_tui"], true);
674 }
675
676 #[test]
677 fn hook_settings_reject_after_block_and_invalid_limits() {
678 let after_block = serde_json::from_str::<Settings>(
679 r#"{"hooks":{"enabled":true,"after_tool":[{"command":"printf no","failure_policy":"block"}]}}"#,
680 )
681 .unwrap_err()
682 .to_string();
683 assert!(after_block.contains("block"), "{after_block}");
684
685 let timeout = serde_json::from_str::<Settings>(r#"{"hooks":{"timeout_seconds":0}}"#)
686 .unwrap_err()
687 .to_string();
688 assert!(timeout.contains("timeout_seconds"), "{timeout}");
689
690 let limit = serde_json::from_str::<Settings>(r#"{"hooks":{"stdout_max_bytes":0}}"#)
691 .unwrap_err()
692 .to_string();
693 assert!(limit.contains("stdout_max_bytes"), "{limit}");
694
695 let provider_limit =
696 serde_json::from_str::<Settings>(r#"{"hooks":{"provider_context_max_bytes":16385}}"#)
697 .unwrap_err()
698 .to_string();
699 assert!(
700 provider_limit.contains("provider_context_max_bytes"),
701 "{provider_limit}"
702 );
703 }
704
705 #[test]
706 fn hook_settings_preserve_unknown_fields_on_settings_update() {
707 let temp = TempDir::new().unwrap();
708 let paths = McPaths::from_root(temp.path().join("mc"));
709 fs::create_dir_all(&paths.root).unwrap();
710 fs::write(
711 &paths.settings_file,
712 r#"{"future_setting":true,"hooks":{"enabled":false,"show_in_tui":true,"future":{"keep":true},"before_tool":[{"label":"audit","command":"printf ok"}]}}"#,
713 )
714 .unwrap();
715
716 set_selected_primary_agent(&paths, Some("tars_1")).unwrap();
717 let value: serde_json::Value =
718 serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
719 assert_eq!(value["future_setting"], true);
720 assert_eq!(value["hooks"]["enabled"], false);
721 assert_eq!(value["hooks"]["show_in_tui"], true);
722 assert_eq!(value["hooks"]["before_tool"][0]["label"], "audit");
723 assert_eq!(value["hooks"]["future"]["keep"], true);
724 assert_eq!(value["selected_primary_agent"], "tars_1");
725 }
726
727 #[test]
728 fn hook_settings_update_known_fields_override_unknown_merge_source() {
729 let temp = TempDir::new().unwrap();
730 let paths = McPaths::from_root(temp.path().join("mc"));
731 fs::create_dir_all(&paths.root).unwrap();
732 fs::write(
733 &paths.settings_file,
734 r#"{"hooks":{"enabled":false,"future":{"keep":true}}}"#,
735 )
736 .unwrap();
737
738 update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
739 settings.hooks.enabled = true;
740 })
741 .unwrap();
742
743 let value: serde_json::Value =
744 serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
745 assert_eq!(value["hooks"]["enabled"], true);
746 assert_eq!(value["hooks"]["future"]["keep"], true);
747 }
748
749 #[test]
750 fn herdr_settings_preserve_unknown_fields_on_settings_update() {
751 let temp = TempDir::new().unwrap();
752 let paths = McPaths::from_root(temp.path().join("mc"));
753 fs::create_dir_all(&paths.root).unwrap();
754 fs::write(
755 &paths.settings_file,
756 r#"{"future_setting":true,"integrations":{"herdr":{"enabled":true,"future":"keep"}}}"#,
757 )
758 .unwrap();
759
760 set_selected_primary_agent(&paths, Some("tars_1")).unwrap();
761 let value: serde_json::Value =
762 serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
763 assert_eq!(value["future_setting"], true);
764 assert_eq!(value["integrations"]["herdr"]["enabled"], true);
765 assert_eq!(value["integrations"]["herdr"]["future"], "keep");
766 assert_eq!(value["selected_primary_agent"], "tars_1");
767 }
768
769 #[test]
770 fn hook_settings_reject_after_block_after_global_override_resolution() {
771 let inherited_block = serde_json::from_str::<Settings>(
772 r#"{"hooks":{"enabled":true,"failure_policy":"block","after_tool":[{"command":"printf no"}]}}"#,
773 )
774 .unwrap_err()
775 .to_string();
776 assert!(inherited_block.contains("block"), "{inherited_block}");
777
778 let explicit_override: Settings = serde_json::from_str(
779 r#"{"hooks":{"enabled":true,"failure_policy":"block","after_tool":[{"command":"printf ok","failure_policy":"warn"}]}}"#,
780 )
781 .unwrap();
782 assert_eq!(
783 explicit_override.hooks.after_tool[0].failure_policy,
784 Some(HookFailurePolicy::Warn)
785 );
786 }
787
788 #[test]
789 fn thinking_level_settings_default_valid_invalid_and_persistence() {
790 let absent: Settings = serde_json::from_str("{}").unwrap();
791 assert_eq!(absent.selected_model.thinking_level, None);
792
793 let valid: Settings =
794 serde_json::from_str(r#"{"selected_model":{"thinking_level":"high"}}"#).unwrap();
795 assert_eq!(
796 valid.selected_model.thinking_level,
797 Some(ThinkingLevel::High)
798 );
799 assert!(
800 serde_json::to_string(&valid)
801 .unwrap()
802 .contains("thinking_level")
803 );
804
805 let error =
806 serde_json::from_str::<Settings>(r#"{"selected_model":{"thinking_level":"maximum"}}"#)
807 .unwrap_err()
808 .to_string();
809 assert!(error.contains("expected one of"), "{error}");
810
811 let temp = TempDir::new().unwrap();
812 let paths = McPaths::from_root(temp.path().join("mc"));
813 fs::create_dir_all(&paths.root).unwrap();
814 fs::write(
815 &paths.settings_file,
816 r#"{"provider":"openai-codex","future_setting":{"keep":true},"auth":{"access":"do-not-copy"}}"#,
817 )
818 .unwrap();
819
820 set_thinking_level(&paths, ThinkingLevel::Medium).unwrap();
821 let value: serde_json::Value =
822 serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
823 assert_eq!(value["selected_model"]["thinking_level"], "medium");
824 assert_eq!(value["future_setting"]["keep"], true);
825 assert_eq!(value["auth"]["access"], "do-not-copy");
826 assert!(!value.to_string().contains("sk-secret"));
827
828 let config = EffectiveConfig::load(paths, CliConfigOverrides::default()).unwrap();
829 assert_eq!(config.thinking_level, ThinkingLevel::Medium);
830 }
831
832 #[test]
833 fn selected_primary_agent_settings_default_null_and_valid_serde() {
834 let absent: Settings = serde_json::from_str("{}").unwrap();
835 assert_eq!(absent.selected_primary_agent, None);
836 let null: Settings = serde_json::from_str(r#"{"selected_primary_agent":null}"#).unwrap();
837 assert_eq!(null.selected_primary_agent, None);
838 let valid: Settings =
839 serde_json::from_str(r#"{"selected_primary_agent":"orchestrator"}"#).unwrap();
840 assert_eq!(
841 valid.selected_primary_agent.as_deref(),
842 Some("orchestrator")
843 );
844 let serialized = serde_json::to_string(&valid).unwrap();
845 assert!(serialized.contains("selected_primary_agent"));
846 assert!(serialized.contains("orchestrator"));
847 }
848
849 #[test]
850 fn selected_primary_agent_persistence_preserves_unknown_fields_and_stores_only_id_or_null() {
851 let temp = TempDir::new().unwrap();
852 let paths = McPaths::from_root(temp.path().join("mc"));
853 fs::create_dir_all(&paths.root).unwrap();
854 fs::write(
855 &paths.settings_file,
856 r#"{"selected_model":{"provider":"openai-codex"},"future_setting":{"keep":true},"auth":{"accountId":"do-not-copy"}}"#,
857 )
858 .unwrap();
859
860 set_selected_primary_agent(&paths, Some("tars_1")).unwrap();
861 let value: serde_json::Value =
862 serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
863 assert_eq!(value["future_setting"]["keep"], true);
864 assert_eq!(value["selected_model"]["provider"], "openai-codex");
865 assert_eq!(value["selected_primary_agent"], "tars_1");
866 assert!(!value.to_string().contains("profile body"));
867 assert!(!value.to_string().contains("sk-secret"));
868
869 set_selected_primary_agent(&paths, None).unwrap();
870 let value: serde_json::Value =
871 serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
872 assert_eq!(value["selected_primary_agent"], serde_json::Value::Null);
873 assert_eq!(value["future_setting"]["keep"], true);
874 }
875
876 #[test]
877 fn selected_primary_agent_persistence_rejects_invalid_ids() {
878 let temp = TempDir::new().unwrap();
879 let paths = McPaths::from_root(temp.path().join("mc"));
880 let error = set_selected_primary_agent(&paths, Some("bad/id")).unwrap_err();
881 assert!(error.to_string().contains("primary agent id"), "{error}");
882 assert!(!paths.settings_file.exists());
883 }
884
885 #[test]
886 fn custom_provider_base_url_normalizes_api_root_and_rejects_endpoint_urls() {
887 assert_eq!(
888 normalize_custom_provider_base_url("https://example.test/v1/").unwrap(),
889 "https://example.test/v1"
890 );
891 assert_eq!(
892 normalize_custom_provider_base_url("https://example.test/v4").unwrap(),
893 "https://example.test/v4"
894 );
895 assert_eq!(
896 normalize_custom_provider_base_url("https://example.test/v1").unwrap(),
897 "https://example.test/v1"
898 );
899 assert_eq!(
900 normalize_custom_provider_base_url("https://api.example.test/api/v2").unwrap(),
901 "https://api.example.test/api/v2"
902 );
903 assert_eq!(
904 normalize_custom_provider_base_url("https://example.test").unwrap(),
905 "https://example.test"
906 );
907 assert_eq!(
908 normalize_custom_provider_base_url("http://localhost:11434/v1").unwrap(),
909 "http://localhost:11434/v1"
910 );
911 assert!(normalize_custom_provider_base_url("https://example.test/v1/responses").is_err());
912 assert!(normalize_custom_provider_base_url("https://example.test/v4/models").is_err());
913 assert!(
914 normalize_custom_provider_base_url("https://example.test/api/completions").is_err()
915 );
916 assert!(
917 normalize_custom_provider_base_url("https://example.test/v2/chat/completions").is_err()
918 );
919 assert_eq!(
920 normalize_custom_provider_base_url("http://example.test/v1").unwrap(),
921 "http://example.test/v1"
922 );
923 let userinfo_error =
924 normalize_custom_provider_base_url("https://user:password@example.test/v1")
925 .unwrap_err()
926 .to_string();
927 assert!(userinfo_error.contains("userinfo"), "{userinfo_error}");
928 assert!(!userinfo_error.contains("password"), "{userinfo_error}");
929 assert!(
930 normalize_custom_provider_base_url("https://example.test/v1?api_key=secret").is_err()
931 );
932 }
933
934 #[test]
935 fn persisted_custom_provider_base_url_rejects_userinfo_without_leaking_secret() {
936 let temp = TempDir::new().unwrap();
937 let paths = McPaths::from_root(temp.path().join("mc"));
938 fs::create_dir_all(&paths.root).unwrap();
939 fs::write(
940 &paths.settings_file,
941 r#"{"custom_providers":{"bad":{"label":"Bad","base_url":"https://user:password@example.test/v1","api_key_env_var":"CUSTOM_PROVIDER_API_KEY"}}}"#,
942 )
943 .unwrap();
944 write_auth_fixture(&paths.auth_file, "{}");
945
946 let error = EffectiveConfig::load(
947 paths,
948 CliConfigOverrides {
949 provider: Some("bad".to_string()),
950 model: Some("model".to_string()),
951 ..CliConfigOverrides::default()
952 },
953 )
954 .unwrap_err()
955 .to_string();
956
957 assert!(error.contains("custom provider 'bad'"), "{error}");
958 assert!(error.contains("userinfo"), "{error}");
959 assert!(!error.contains("password"), "{error}");
960 }
961
962 #[test]
963 fn custom_provider_id_derives_from_label_and_rejects_reserved() {
964 assert_eq!(
965 derive_custom_provider_id("Local Llama").unwrap(),
966 "local-llama"
967 );
968 assert_eq!(
969 derive_custom_provider_id(" My__Provider!! 1 ").unwrap(),
970 "my-provider-1"
971 );
972 assert!(derive_custom_provider_id("!!!").is_err());
973 assert!(derive_custom_provider_id("openai").is_err());
974 assert!(derive_custom_provider_id("openai codex").is_err());
975 assert!(validate_custom_provider_id("anthropic").is_err());
976 assert!(validate_custom_provider_id("claude-code").is_err());
977 }
978
979 #[test]
980 fn custom_provider_label_rejects_secret_like_values() {
981 assert!(
982 make_custom_provider_config("sk-secret-looking", "http://localhost:8080/v1", "")
983 .is_err()
984 );
985 assert!(make_custom_provider_config("Local Llama", "http://localhost:8080/v1", "").is_ok());
986
987 let temp = TempDir::new().unwrap();
988 let paths = McPaths::from_root(temp.path().join("mc"));
989 fs::create_dir_all(&paths.root).unwrap();
990 fs::write(
991 &paths.settings_file,
992 r#"{"custom_providers":{"local":{"label":"sk-secret-looking","base_url":"http://localhost:8080/v1"}}}"#,
993 )
994 .unwrap();
995 write_auth_fixture(&paths.auth_file, "{}");
996
997 let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
998 .unwrap_err()
999 .to_string();
1000
1001 assert!(error.contains("label"), "{error}");
1002 assert!(!error.contains("sk-secret-looking"), "{error}");
1003 }
1004
1005 #[test]
1006 fn optional_env_var_validation_allows_blank_and_rejects_secret_like_values() {
1007 assert_eq!(validate_optional_env_var_name(" ").unwrap(), None);
1008 assert_eq!(
1009 validate_optional_env_var_name(" LOCAL_PROVIDER_API_KEY ")
1010 .unwrap()
1011 .as_deref(),
1012 Some("LOCAL_PROVIDER_API_KEY")
1013 );
1014 assert!(validate_optional_env_var_name("sk-secret-looking").is_err());
1015 assert!(validate_optional_env_var_name("lowercase").is_err());
1016 }
1017
1018 #[test]
1019 fn custom_provider_settings_reject_invalid_ids() {
1020 let temp = TempDir::new().unwrap();
1021 let paths = McPaths::from_root(temp.path().join("mc"));
1022 fs::create_dir_all(&paths.root).unwrap();
1023 fs::write(
1024 &paths.settings_file,
1025 r#"{"custom_providers":{"Bad_ID":{"label":"Bad","base_url":"https://provider.test/v1"}}}"#,
1026 )
1027 .unwrap();
1028 write_auth_fixture(&paths.auth_file, "{}");
1029
1030 let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
1031 .unwrap_err()
1032 .to_string();
1033
1034 assert!(error.contains("custom provider 'Bad_ID'"), "{error}");
1035 assert!(error.contains("provider id"), "{error}");
1036 }
1037
1038 #[test]
1039 fn custom_provider_settings_reject_uppercase_provider_id() {
1040 let temp = TempDir::new().unwrap();
1041 let paths = McPaths::from_root(temp.path().join("mc"));
1042 fs::create_dir_all(&paths.root).unwrap();
1043 fs::write(
1044 &paths.settings_file,
1045 r#"{"custom_providers":{"OpenAI":{"label":"Bad","base_url":"https://provider.test/v1"}}}"#,
1046 )
1047 .unwrap();
1048 write_auth_fixture(&paths.auth_file, "{}");
1049
1050 let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
1051 .unwrap_err()
1052 .to_string();
1053
1054 assert!(error.contains("custom provider 'OpenAI'"), "{error}");
1055 assert!(error.contains("provider id"), "{error}");
1056 }
1057
1058 #[test]
1059 fn custom_provider_settings_reject_secret_like_env_var() {
1060 let temp = TempDir::new().unwrap();
1061 let paths = McPaths::from_root(temp.path().join("mc"));
1062 fs::create_dir_all(&paths.root).unwrap();
1063 fs::write(
1064 &paths.settings_file,
1065 r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1","api_key_env_var":"sk-secret-looking"}}}"#,
1066 )
1067 .unwrap();
1068 write_auth_fixture(&paths.auth_file, "{}");
1069
1070 let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
1071 .unwrap_err()
1072 .to_string();
1073
1074 assert!(error.contains("custom provider 'local'"), "{error}");
1075 assert!(error.contains("api_key_env_var"), "{error}");
1076 assert!(!error.contains("sk-secret-looking"), "{error}");
1077 }
1078
1079 #[test]
1080 fn custom_provider_settings_reject_models_dev_provider_secret_like_values() {
1081 let temp = TempDir::new().unwrap();
1082 let paths = McPaths::from_root(temp.path().join("mc"));
1083 fs::create_dir_all(&paths.root).unwrap();
1084 fs::write(
1085 &paths.settings_file,
1086 r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1","models_dev_provider":"sk-secret-looking"}}}"#,
1087 )
1088 .unwrap();
1089 write_auth_fixture(&paths.auth_file, "{}");
1090
1091 let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
1092 .unwrap_err()
1093 .to_string();
1094
1095 assert!(error.contains("custom provider 'local'"), "{error}");
1096 assert!(error.contains("models_dev_provider"), "{error}");
1097 assert!(!error.contains("sk-secret-looking"), "{error}");
1098 }
1099
1100 #[test]
1101 fn custom_provider_settings_reject_uppercase_models_dev_provider() {
1102 let temp = TempDir::new().unwrap();
1103 let paths = McPaths::from_root(temp.path().join("mc"));
1104 fs::create_dir_all(&paths.root).unwrap();
1105 fs::write(
1106 &paths.settings_file,
1107 r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1","models_dev_provider":"OpenAI"}}}"#,
1108 )
1109 .unwrap();
1110 write_auth_fixture(&paths.auth_file, "{}");
1111
1112 let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
1113 .unwrap_err()
1114 .to_string();
1115
1116 assert!(error.contains("custom provider 'local'"), "{error}");
1117 assert!(error.contains("models_dev_provider"), "{error}");
1118 }
1119
1120 #[test]
1121 fn custom_provider_settings_reject_endpoint_urls() {
1122 let temp = TempDir::new().unwrap();
1123 let paths = McPaths::from_root(temp.path().join("mc"));
1124 fs::create_dir_all(&paths.root).unwrap();
1125 fs::write(
1126 &paths.settings_file,
1127 r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1/responses"}}}"#,
1128 )
1129 .unwrap();
1130 write_auth_fixture(&paths.auth_file, "{}");
1131
1132 let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
1133 .unwrap_err()
1134 .to_string();
1135
1136 assert!(error.contains("custom provider 'local'"), "{error}");
1137 assert!(error.contains("endpoint URL"), "{error}");
1138 assert!(!error.contains("provider.test"), "{error}");
1139 }
1140
1141 #[test]
1142 fn custom_provider_optional_auth_serde_compatibility() {
1143 let env_backed: Settings = serde_json::from_str(
1144 r#"{"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1","api_key_env_var":"LOCAL_API_KEY"}}}"#,
1145 )
1146 .unwrap();
1147 assert_eq!(
1148 env_backed.custom_providers["local"]
1149 .api_key_env_var
1150 .as_deref(),
1151 Some("LOCAL_API_KEY")
1152 );
1153 let no_auth: Settings = serde_json::from_str(
1154 r#"{"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1","models_dev_provider":" openrouter ","extra_models":[" glm-5.2 ","glm-5.2"]}}}"#,
1155 )
1156 .unwrap();
1157 assert_eq!(no_auth.custom_providers["local"].api_key_env_var, None);
1158 assert_eq!(
1159 no_auth.custom_providers["local"]
1160 .models_dev_provider
1161 .as_deref(),
1162 Some("openrouter")
1163 );
1164 assert_eq!(
1165 no_auth.custom_providers["local"].extra_models,
1166 vec!["glm-5.2".to_string(), "glm-5.2".to_string()]
1167 );
1168 assert_eq!(
1169 normalized_extra_models(&no_auth.custom_providers["local"].extra_models).unwrap(),
1170 vec!["glm-5.2".to_string()]
1171 );
1172 assert!(!no_auth.custom_providers["local"].use_responses_endpoint);
1173 let explicit_false: Settings = serde_json::from_str(
1174 r#"{"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1","use_responses_endpoint":false}}}"#,
1175 )
1176 .unwrap();
1177 assert!(!explicit_false.custom_providers["local"].use_responses_endpoint);
1178 let explicit_true: Settings = serde_json::from_str(
1179 r#"{"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1","use_responses_endpoint":true}}}"#,
1180 )
1181 .unwrap();
1182 assert!(explicit_true.custom_providers["local"].use_responses_endpoint);
1183 let serialized_true = serde_json::to_string(&explicit_true).unwrap();
1184 assert!(serialized_true.contains("use_responses_endpoint"));
1185 let invalid_endpoint_mode = serde_json::from_str::<Settings>(
1186 r#"{"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1","use_responses_endpoint":"true"}}}"#,
1187 )
1188 .unwrap_err()
1189 .to_string();
1190 assert!(
1191 invalid_endpoint_mode.contains("invalid type"),
1192 "{invalid_endpoint_mode}"
1193 );
1194 assert!(
1195 invalid_endpoint_mode.contains("boolean"),
1196 "{invalid_endpoint_mode}"
1197 );
1198 let whitespace: Settings = serde_json::from_str(
1199 r#"{"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1","api_key_env_var":" "}}}"#,
1200 )
1201 .unwrap();
1202 assert_eq!(whitespace.custom_providers["local"].api_key_env_var, None);
1203 let serialized = serde_json::to_string(&no_auth).unwrap();
1204 assert!(!serialized.contains("api_key_env_var"));
1205 assert!(serialized.contains("models_dev_provider"));
1206 assert!(serialized.contains("extra_models"));
1207 assert!(!serialized.contains("use_responses_endpoint"));
1208 }
1209
1210 #[test]
1211 fn custom_provider_settings_reject_secret_like_extra_models() {
1212 let temp = TempDir::new().unwrap();
1213 let paths = McPaths::from_root(temp.path().join("mc"));
1214 fs::create_dir_all(&paths.root).unwrap();
1215 fs::write(
1216 &paths.settings_file,
1217 r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1","extra_models":["sk-secret-looking"]}}}"#,
1218 )
1219 .unwrap();
1220 write_auth_fixture(&paths.auth_file, "{}");
1221
1222 let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
1223 .unwrap_err()
1224 .to_string();
1225
1226 assert!(error.contains("custom provider 'local'"), "{error}");
1227 assert!(error.contains("extra_models"), "{error}");
1228 assert!(!error.contains("sk-secret-looking"), "{error}");
1229 }
1230
1231 #[test]
1232 fn custom_provider_settings_reject_invalid_extra_models() {
1233 for raw in [
1234 r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1","extra_models":[" "]}}}"#,
1235 r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1","extra_models":["bad model"]}}}"#,
1236 ] {
1237 let error = parse_validated_settings(raw).unwrap_err().to_string();
1238 assert!(error.contains("extra_models"), "{error}");
1239 assert!(!error.contains("bad model"), "{error}");
1240 }
1241 }
1242
1243 #[test]
1244 fn claude_code_auth_uses_provider_keyed_api_key_and_ignores_global_api_keys() {
1245 let _env = isolate_env();
1246 let env = crate::test_support::env::env_lock();
1247 let temp = TempDir::new().unwrap();
1248 let paths = McPaths::from_root(temp.path().join("mc"));
1249 write_auth(
1250 &paths,
1251 &Auth {
1252 providers: BTreeMap::from([(
1253 crate::providers::CLAUDE_CODE_PROVIDER.to_string(),
1254 AuthProviderRecord::ApiKey {
1255 key: "provider-key".to_string(),
1256 },
1257 )]),
1258 ..Auth::default()
1259 },
1260 )
1261 .unwrap();
1262 env.set_var("ANTHROPIC_API_KEY", "ignored-anthropic");
1263 env.set_var("ANTHROPIC_AUTH_TOKEN", "ignored-token");
1264 env.set_var("OPENAI_API_KEY", "ignored-openai");
1265 env.set_var("MC_API_KEY", "ignored-mc");
1266 env.set_var(
1267 "MC_CLAUDE_CODE_CREDENTIALS_PATH",
1268 temp.path().join("missing-claude-credentials.json"),
1269 );
1270
1271 let config = EffectiveConfig::load(
1272 paths,
1273 CliConfigOverrides {
1274 provider: Some(crate::providers::CLAUDE_CODE_PROVIDER.to_string()),
1275 model: None,
1276 api_key: Some("ignored-cli".to_string()),
1277 ..CliConfigOverrides::default()
1278 },
1279 )
1280 .unwrap();
1281
1282 assert_eq!(
1283 config.auth,
1284 Some(ProviderCredential::ApiKey {
1285 key: "provider-key".to_string()
1286 })
1287 );
1288 assert!(config.auth_state().is_ready());
1289 assert_eq!(config.api_key.as_deref(), Some("provider-key"));
1290 }
1291
1292 #[test]
1293 fn no_auth_custom_provider_resolves_ready_without_global_fallback() {
1294 let _env = isolate_env();
1295 let env = crate::test_support::env::env_lock();
1296 let temp = TempDir::new().unwrap();
1297 let paths = McPaths::from_root(temp.path().join("mc"));
1298 upsert_custom_provider(
1299 &paths,
1300 "local-provider",
1301 make_custom_provider_config("Local", "http://localhost:8080/v1", "").unwrap(),
1302 )
1303 .unwrap();
1304 env.set_var("OPENAI_API_KEY", "ignored-openai");
1305 env.set_var("MC_API_KEY", "ignored-mc");
1306 let config = EffectiveConfig::load(
1307 paths,
1308 CliConfigOverrides {
1309 provider: Some("local-provider".into()),
1310 model: Some("model-a".into()),
1311 api_key: Some("ignored-cli".into()),
1312 ..CliConfigOverrides::default()
1313 },
1314 )
1315 .unwrap();
1316 assert!(matches!(config.auth, Some(ProviderCredential::NoAuth)));
1317 assert!(config.auth_state().is_ready());
1318 assert_eq!(config.api_key, None);
1319 }
1320
1321 #[test]
1322 fn custom_provider_serialized_schema_has_no_dead_catalog_path() {
1323 let temp = TempDir::new().unwrap();
1324 let paths = McPaths::from_root(temp.path().join("mc"));
1325 let cfg = make_custom_provider_config(
1326 "Local",
1327 "https://provider.test/v1/",
1328 "CUSTOM_PROVIDER_API_KEY",
1329 )
1330 .unwrap();
1331 upsert_custom_provider(&paths, "local-provider", cfg).unwrap();
1332 let text = fs::read_to_string(&paths.settings_file).unwrap();
1333 assert!(text.contains("custom_providers"));
1334 assert!(text.contains("base_url"));
1335 assert!(text.contains("api_key_env_var"));
1336 assert!(!text.contains("use_responses_endpoint"));
1337 assert!(!text.contains("catalog_path"));
1338 }
1339
1340 #[test]
1341 fn custom_provider_update_preserves_unknown_top_level_settings_fields() {
1342 let temp = TempDir::new().unwrap();
1343 let paths = McPaths::from_root(temp.path().join("mc"));
1344 fs::create_dir_all(&paths.root).unwrap();
1345 fs::write(
1346 &paths.settings_file,
1347 r#"{"selected_model":{"provider":"openai-codex"},"future_setting":{"keep":true}}"#,
1348 )
1349 .unwrap();
1350 let cfg = make_custom_provider_config(
1351 "Local",
1352 "https://provider.test/v1",
1353 "CUSTOM_PROVIDER_API_KEY",
1354 )
1355 .unwrap();
1356 upsert_custom_provider(&paths, "local-provider", cfg).unwrap();
1357 let value: serde_json::Value =
1358 serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
1359 assert_eq!(value["future_setting"]["keep"], true);
1360 assert_eq!(value["selected_model"]["provider"], "openai-codex");
1361 assert_eq!(
1362 value["custom_providers"]["local-provider"]["label"],
1363 "Local"
1364 );
1365 }
1366
1367 #[test]
1368 fn custom_provider_auth_reads_named_env_only() {
1369 let _env = isolate_env();
1370 let env = crate::test_support::env::env_lock();
1371 let temp = TempDir::new().unwrap();
1372 let paths = McPaths::from_root(temp.path().join("mc"));
1373 let cfg = make_custom_provider_config(
1374 "Local",
1375 "https://provider.test/v1",
1376 "CUSTOM_PROVIDER_API_KEY",
1377 )
1378 .unwrap();
1379 upsert_custom_provider(&paths, "local-provider", cfg).unwrap();
1380 env.set_var("OPENAI_API_KEY", "ignored-openai");
1381 env.set_var("MC_API_KEY", "ignored-mc");
1382 let config = EffectiveConfig::load(
1383 paths.clone(),
1384 CliConfigOverrides {
1385 provider: Some("local-provider".into()),
1386 model: Some("model-a".into()),
1387 api_key: Some("ignored-cli".into()),
1388 ..CliConfigOverrides::default()
1389 },
1390 )
1391 .unwrap();
1392 assert!(config.auth.is_none());
1393 env.set_var("CUSTOM_PROVIDER_API_KEY", "custom-key");
1394 let config = EffectiveConfig::load(
1395 paths,
1396 CliConfigOverrides {
1397 provider: Some("local-provider".into()),
1398 model: Some("model-a".into()),
1399 api_key: Some("ignored-cli".into()),
1400 ..CliConfigOverrides::default()
1401 },
1402 )
1403 .unwrap();
1404 assert_eq!(config.api_key.as_deref(), Some("custom-key"));
1405 }
1406
1407 #[test]
1408 fn read_auth_missing_file_returns_default() {
1409 let temp = TempDir::new().unwrap();
1410 let paths = McPaths::from_root(temp.path().join("mc"));
1411 assert_eq!(read_auth(&paths).unwrap(), Auth::default());
1412 }
1413
1414 #[cfg(unix)]
1415 #[test]
1416 fn read_auth_rejects_group_or_world_accessible_file_on_unix() {
1417 let temp = TempDir::new().unwrap();
1418 let paths = McPaths::from_root(temp.path().join("mc"));
1419 fs::create_dir_all(&paths.root).unwrap();
1420 fs::write(&paths.auth_file, r#"{"api_key":"secret"}"#).unwrap();
1421 set_mode(&paths.auth_file, 0o644);
1422
1423 let error = read_auth(&paths).unwrap_err().to_string();
1424 assert!(error.contains("auth.json"), "{error}");
1425 assert!(error.contains("private/owner-only"), "{error}");
1426 }
1427
1428 #[cfg(unix)]
1429 #[test]
1430 fn read_auth_rejects_insecure_invalid_json_before_parse_on_unix() {
1431 let temp = TempDir::new().unwrap();
1432 let paths = McPaths::from_root(temp.path().join("mc"));
1433 fs::create_dir_all(&paths.root).unwrap();
1434 fs::write(&paths.auth_file, "not json").unwrap();
1435 set_mode(&paths.auth_file, 0o664);
1436
1437 let error = read_auth(&paths).unwrap_err().to_string();
1438 assert!(error.contains("private/owner-only"), "{error}");
1439 assert!(!error.contains("expected"), "{error}");
1440 }
1441
1442 #[cfg(unix)]
1443 #[test]
1444 fn read_auth_rejects_symlink_on_unix() {
1445 use std::os::unix::fs::symlink;
1446
1447 let temp = TempDir::new().unwrap();
1448 let paths = McPaths::from_root(temp.path().join("mc"));
1449 fs::create_dir_all(&paths.root).unwrap();
1450 let target = temp.path().join("target-auth.json");
1451 fs::write(&target, r#"{"api_key":"secret"}"#).unwrap();
1452 set_mode(&target, 0o600);
1453 symlink(&target, &paths.auth_file).unwrap();
1454
1455 let error = read_auth(&paths).unwrap_err().to_string();
1456 assert!(error.contains("auth.json"), "{error}");
1457 assert!(error.contains("symlink"), "{error}");
1458 }
1459
1460 #[cfg(unix)]
1461 #[test]
1462 fn write_auth_uses_private_permissions_on_unix() {
1463 use std::os::unix::fs::PermissionsExt;
1464
1465 let temp = TempDir::new().unwrap();
1466 let paths = McPaths::from_root(temp.path().join("mc"));
1467 write_auth(&paths, &Auth::default()).unwrap();
1468 let mode = fs::metadata(&paths.auth_file).unwrap().permissions().mode() & 0o777;
1469 assert_eq!(mode, 0o600);
1470 }
1471
1472 #[test]
1473 fn remove_provider_auth_removes_only_selected_provider() {
1474 let temp = TempDir::new().unwrap();
1475 let paths = McPaths::from_root(temp.path().join("mc"));
1476 fs::create_dir_all(&paths.root).unwrap();
1477 write_auth_fixture(
1478 &paths.auth_file,
1479 r#"{
1480 "api_key":"legacy-key",
1481 "openai-codex":{"type":"oauth","access":"codex-access","refresh":"codex-refresh","accountId":"acct"},
1482 "other":{"type":"api_key","key":"other-key"}
1483}"#,
1484 );
1485
1486 let removal =
1487 remove_provider_auth(&paths, crate::providers::OPENAI_CODEX_PROVIDER).unwrap();
1488 let saved = read_auth(&paths).unwrap();
1489
1490 assert!(removal.removed);
1491 assert!(
1492 !saved
1493 .providers
1494 .contains_key(crate::providers::OPENAI_CODEX_PROVIDER)
1495 );
1496 assert!(saved.providers.contains_key("other"));
1497 assert_eq!(saved.api_key.as_deref(), Some("legacy-key"));
1498 }
1499
1500 #[test]
1501 fn remove_provider_auth_missing_provider_is_noop_without_write() {
1502 let temp = TempDir::new().unwrap();
1503 let paths = McPaths::from_root(temp.path().join("mc"));
1504 fs::create_dir_all(&paths.root).unwrap();
1505 write_auth_fixture(&paths.auth_file, r#"{"api_key":"legacy-key"}"#);
1506 let before = fs::metadata(&paths.auth_file).unwrap().modified().unwrap();
1507
1508 let removal =
1509 remove_provider_auth(&paths, crate::providers::OPENAI_CODEX_PROVIDER).unwrap();
1510 let after = fs::metadata(&paths.auth_file).unwrap().modified().unwrap();
1511
1512 assert!(!removal.removed);
1513 assert_eq!(before, after);
1514 assert_eq!(
1515 read_auth(&paths).unwrap().api_key.as_deref(),
1516 Some("legacy-key")
1517 );
1518 }
1519
1520 #[test]
1521 fn remove_provider_auth_respects_mc_home_paths() {
1522 let temp = TempDir::new().unwrap();
1523 let paths = McPaths::from_root(temp.path().join("custom-mc-home"));
1524 fs::create_dir_all(&paths.root).unwrap();
1525 write_auth_fixture(
1526 &paths.auth_file,
1527 r#"{"openai-codex":{"type":"oauth","access":"codex-access","accountId":"acct"}}"#,
1528 );
1529
1530 remove_provider_auth(&paths, crate::providers::OPENAI_CODEX_PROVIDER).unwrap();
1531
1532 assert!(paths.auth_file.exists());
1533 assert!(
1534 !read_auth(&paths)
1535 .unwrap()
1536 .providers
1537 .contains_key(crate::providers::OPENAI_CODEX_PROVIDER)
1538 );
1539 }
1540
1541 #[test]
1542 fn concurrent_auth_updates_preserve_independent_provider_records() {
1543 let temp = TempDir::new().unwrap();
1544 let paths = McPaths::from_root(temp.path().join("mc"));
1545 let left_paths = paths.clone();
1546 let right_paths = paths.clone();
1547 let left = std::thread::spawn(move || {
1548 update_auth(&left_paths, |auth| {
1549 auth.providers.insert(
1550 "left".to_string(),
1551 AuthProviderRecord::ApiKey {
1552 key: "left-key".to_string(),
1553 },
1554 );
1555 })
1556 .unwrap();
1557 });
1558 let right = std::thread::spawn(move || {
1559 update_auth(&right_paths, |auth| {
1560 auth.providers.insert(
1561 "right".to_string(),
1562 AuthProviderRecord::ApiKey {
1563 key: "right-key".to_string(),
1564 },
1565 );
1566 })
1567 .unwrap();
1568 });
1569 left.join().unwrap();
1570 right.join().unwrap();
1571
1572 let auth = read_auth(&paths).unwrap();
1573 assert!(matches!(
1574 auth.providers.get("left"),
1575 Some(AuthProviderRecord::ApiKey { .. })
1576 ));
1577 assert!(matches!(
1578 auth.providers.get("right"),
1579 Some(AuthProviderRecord::ApiKey { .. })
1580 ));
1581 }
1582
1583 #[test]
1584 fn remove_provider_auth_malformed_auth_fails_without_write() {
1585 let temp = TempDir::new().unwrap();
1586 let paths = McPaths::from_root(temp.path().join("mc"));
1587 fs::create_dir_all(&paths.root).unwrap();
1588 write_auth_fixture(&paths.auth_file, "not json");
1589
1590 let error = remove_provider_auth(&paths, crate::providers::OPENAI_CODEX_PROVIDER)
1591 .unwrap_err()
1592 .to_string();
1593
1594 assert!(
1595 error.contains("expected") || error.contains("key"),
1596 "{error}"
1597 );
1598 assert_eq!(fs::read_to_string(&paths.auth_file).unwrap(), "not json");
1599 }
1600
1601 #[cfg(unix)]
1602 #[test]
1603 fn remove_provider_auth_unsafe_auth_file_fails_without_mutation() {
1604 let temp = TempDir::new().unwrap();
1605 let paths = McPaths::from_root(temp.path().join("mc"));
1606 fs::create_dir_all(&paths.root).unwrap();
1607 let original =
1608 r#"{"openai-codex":{"type":"oauth","access":"codex-access","accountId":"acct"}}"#;
1609 fs::write(&paths.auth_file, original).unwrap();
1610 set_mode(&paths.auth_file, 0o644);
1611
1612 let error = remove_provider_auth(&paths, crate::providers::OPENAI_CODEX_PROVIDER)
1613 .unwrap_err()
1614 .to_string();
1615
1616 assert!(error.contains("private/owner-only"), "{error}");
1617 assert_eq!(fs::read_to_string(&paths.auth_file).unwrap(), original);
1618 }
1619
1620 #[test]
1621 fn config_precedence_cli_over_env_over_files() {
1622 let _env = isolate_env();
1623 let env = crate::test_support::env::env_lock();
1624 let temp = TempDir::new().unwrap();
1625 let paths = McPaths::from_root(temp.path().join("mc"));
1626 fs::create_dir_all(&paths.root).unwrap();
1627 fs::write(
1628 &paths.settings_file,
1629 r#"{"provider":"file-provider","model":"file-model"}"#,
1630 )
1631 .unwrap();
1632 write_auth_fixture(&paths.auth_file, r#"{"api_key":"file-key"}"#);
1633
1634 env.set_var("MC_PROVIDER", "env-provider");
1635 env.set_var("MC_MODEL", "env-model");
1636 env.set_var("MC_API_KEY", "env-key");
1637
1638 let config = EffectiveConfig::load(
1639 paths,
1640 CliConfigOverrides {
1641 provider: Some("cli-provider".to_string()),
1642 model: Some("cli-model".to_string()),
1643 api_key: Some("cli-key".to_string()),
1644 ..CliConfigOverrides::default()
1645 },
1646 )
1647 .unwrap();
1648
1649 assert_eq!(config.provider.as_deref(), Some("cli-provider"));
1650 assert_eq!(config.model.as_deref(), Some("cli-model"));
1651 assert_eq!(config.api_key.as_deref(), Some("cli-key"));
1652
1653 env.remove_var("MC_PROVIDER");
1654 env.remove_var("MC_MODEL");
1655 env.remove_var("MC_API_KEY");
1656 }
1657
1658 #[test]
1659 fn config_precedence_env_over_files() {
1660 let _env = isolate_env();
1661 let env = crate::test_support::env::env_lock();
1662 let temp = TempDir::new().unwrap();
1663 let paths = McPaths::from_root(temp.path().join("mc"));
1664 fs::create_dir_all(&paths.root).unwrap();
1665 fs::write(
1666 &paths.settings_file,
1667 r#"{"provider":"file-provider","model":"file-model"}"#,
1668 )
1669 .unwrap();
1670 write_auth_fixture(&paths.auth_file, r#"{"api_key":"file-key"}"#);
1671
1672 env.set_var("MC_PROVIDER", "env-provider");
1673 env.set_var("MC_MODEL", "env-model");
1674 env.set_var("MC_API_KEY", "env-key");
1675
1676 let config = EffectiveConfig::load(paths, CliConfigOverrides::default()).unwrap();
1677 assert_eq!(config.provider.as_deref(), Some("env-provider"));
1678 assert_eq!(config.model.as_deref(), Some("env-model"));
1679 assert_eq!(config.api_key.as_deref(), Some("env-key"));
1680
1681 env.remove_var("MC_PROVIDER");
1682 env.remove_var("MC_MODEL");
1683 env.remove_var("MC_API_KEY");
1684 }
1685
1686 #[test]
1687 fn file_autocomplete_respects_gitignore_defaults_true() {
1688 let settings: Settings = serde_json::from_str("{}").unwrap();
1689
1690 assert!(settings.file_autocomplete_respects_gitignore);
1691 }
1692
1693 #[test]
1694 fn file_autocomplete_respects_gitignore_explicit_false_parses() {
1695 let settings: Settings =
1696 serde_json::from_str(r#"{"file_autocomplete_respects_gitignore":false}"#).unwrap();
1697
1698 assert!(!settings.file_autocomplete_respects_gitignore);
1699 }
1700
1701 #[test]
1702 fn file_autocomplete_respects_gitignore_effective_config_preserves_setting() {
1703 let _env = isolate_env();
1704 let _guard = crate::test_support::env::env_lock();
1705 let temp = TempDir::new().unwrap();
1706 let paths = McPaths::from_root(temp.path().join("mc"));
1707 fs::create_dir_all(&paths.root).unwrap();
1708 fs::write(
1709 &paths.settings_file,
1710 r#"{"file_autocomplete_respects_gitignore":false}"#,
1711 )
1712 .unwrap();
1713
1714 let config = EffectiveConfig::load(paths, CliConfigOverrides::default()).unwrap();
1715
1716 assert!(!config.file_autocomplete_respects_gitignore);
1717 }
1718
1719 #[test]
1720 fn file_autocomplete_respects_gitignore_provider_selection_preserves_setting() {
1721 let _env = isolate_env();
1722 let _guard = crate::test_support::env::env_lock();
1723 let temp = TempDir::new().unwrap();
1724 let paths = McPaths::from_root(temp.path().join("mc"));
1725 fs::create_dir_all(&paths.root).unwrap();
1726 fs::write(
1727 &paths.settings_file,
1728 r#"{"file_autocomplete_respects_gitignore":false}"#,
1729 )
1730 .unwrap();
1731
1732 let config = load_effective_provider_selection(&paths, "openai", "gpt-test").unwrap();
1733
1734 assert!(!config.file_autocomplete_respects_gitignore);
1735 }
1736
1737 #[test]
1738 fn settings_no_color_applies_when_env_is_absent() {
1739 let _env = isolate_env();
1740 let _guard = crate::test_support::env::env_lock();
1741 let temp = TempDir::new().unwrap();
1742 let paths = McPaths::from_root(temp.path().join("mc"));
1743 fs::create_dir_all(&paths.root).unwrap();
1744 fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
1745
1746 let config = EffectiveConfig::load(paths, CliConfigOverrides::default()).unwrap();
1747
1748 assert!(config.no_color);
1749 }
1750
1751 #[test]
1752 fn no_color_env_presence_overrides_settings_false() {
1753 let _env = isolate_env();
1754 let env = crate::test_support::env::env_lock();
1755 let temp = TempDir::new().unwrap();
1756 let paths = McPaths::from_root(temp.path().join("mc"));
1757 fs::create_dir_all(&paths.root).unwrap();
1758 fs::write(&paths.settings_file, r#"{"no_color":false}"#).unwrap();
1759 env.set_var("NO_COLOR", "");
1760
1761 let config = EffectiveConfig::load(paths, CliConfigOverrides::default()).unwrap();
1762
1763 assert!(config.no_color);
1764 env.remove_var("NO_COLOR");
1765 }
1766
1767 #[test]
1768 fn credential_like_settings_fields_are_ignored_for_auth() {
1769 let _env = isolate_env();
1770 let _guard = crate::test_support::env::env_lock();
1771 let temp = TempDir::new().unwrap();
1772 let paths = McPaths::from_root(temp.path().join("mc"));
1773 fs::create_dir_all(&paths.root).unwrap();
1774 fs::write(
1775 &paths.settings_file,
1776 r#"{
1777 "selected_model":{"provider":"openai","model":"file-model"},
1778 "api_key":"settings-should-not-win",
1779 "openai_api_key":"settings-should-not-win",
1780 "access":"settings-should-not-win",
1781 "refresh":"settings-should-not-win",
1782 "accountId":"settings-should-not-win",
1783 "openai":{"type":"api_key","key":"settings-should-not-win"},
1784 "openai-codex":{"type":"oauth","access":"settings-should-not-win","accountId":"settings-should-not-win"}
1785 }"#,
1786 )
1787 .unwrap();
1788
1789 let config = EffectiveConfig::load(paths, CliConfigOverrides::default()).unwrap();
1790
1791 assert_eq!(config.provider.as_deref(), Some("openai"));
1792 assert!(config.auth.is_none());
1793 assert!(config.api_key.is_none());
1794 }
1795
1796 #[test]
1797 fn mc_home_selects_runtime_root_before_settings_are_read() {
1798 let _env = isolate_env();
1799 let env = crate::test_support::env::env_lock();
1800 let temp = TempDir::new().unwrap();
1801 let env_root = temp.path().join("env-root");
1802 fs::create_dir_all(&env_root).unwrap();
1803 fs::write(env_root.join("settings.json"), r#"{"mc_home":"ignored"}"#).unwrap();
1804 env.set_var("MC_HOME", &env_root);
1805
1806 let paths = McPaths::resolve().unwrap();
1807
1808 assert_eq!(paths.root, env_root);
1809 assert_eq!(paths.prompts, env_root.join("prompts"));
1810 env.remove_var("MC_HOME");
1811 }
1812
1813 #[test]
1814 fn mc_home_rejects_empty_relative_and_existing_file_values() {
1815 let _env = isolate_env();
1816 let env = crate::test_support::env::env_lock();
1817 env.set_var("MC_HOME", "");
1818 let empty = McPaths::resolve().unwrap_err().to_string();
1819 assert!(empty.contains("MC_HOME"), "{empty}");
1820 assert!(empty.contains("empty"), "{empty}");
1821
1822 env.set_var("MC_HOME", "relative-mc-home");
1823 let relative = McPaths::resolve().unwrap_err().to_string();
1824 assert!(relative.contains("MC_HOME"), "{relative}");
1825 assert!(relative.contains("absolute"), "{relative}");
1826
1827 let temp = TempDir::new().unwrap();
1828 let file_path = temp.path().join("not-a-directory");
1829 fs::write(&file_path, "not dir").unwrap();
1830 env.set_var("MC_HOME", &file_path);
1831 let file = McPaths::resolve().unwrap_err().to_string();
1832 assert!(file.contains("MC_HOME"), "{file}");
1833 assert!(file.contains("directory"), "{file}");
1834 }
1835
1836 #[test]
1837 fn mc_home_allows_absolute_missing_directory_without_creating_it() {
1838 let _env = isolate_env();
1839 let env = crate::test_support::env::env_lock();
1840 let temp = TempDir::new().unwrap();
1841 let missing = temp.path().join("missing-mc-home");
1842 env.set_var("MC_HOME", &missing);
1843
1844 let paths = McPaths::resolve().unwrap();
1845
1846 assert_eq!(paths.root, missing);
1847 assert!(!paths.root.exists());
1848 }
1849
1850 #[test]
1851 fn mc_paths_from_root_includes_prompt_override_directory() {
1852 let root = PathBuf::from("/tmp/mc-test-root");
1853 let paths = McPaths::from_root(root.clone());
1854
1855 assert_eq!(paths.prompts, root.join("prompts"));
1856 assert_eq!(paths.subagents, root.join("subagents"));
1857 }
1858
1859 #[test]
1860 fn anthropic_env_api_key_resolves_for_anthropic_provider() {
1861 let _env = isolate_env();
1862 let env = crate::test_support::env::env_lock();
1863 let temp = TempDir::new().unwrap();
1864 let paths = McPaths::from_root(temp.path().join("mc"));
1865 fs::create_dir_all(&paths.root).unwrap();
1866 env.set_var("ANTHROPIC_API_KEY", "anthropic-env-key");
1867
1868 let config = EffectiveConfig::load(
1869 paths,
1870 CliConfigOverrides {
1871 provider: Some(crate::providers::ANTHROPIC_PROVIDER.to_string()),
1872 model: Some(crate::providers::DEFAULT_ANTHROPIC_MODEL.to_string()),
1873 ..CliConfigOverrides::default()
1874 },
1875 )
1876 .unwrap();
1877
1878 assert_eq!(config.api_key.as_deref(), Some("anthropic-env-key"));
1879 assert!(config.auth_state().is_ready());
1880 }
1881
1882 #[test]
1883 fn anthropic_auth_precedence_is_explicit() {
1884 let _env = isolate_env();
1885 let env = crate::test_support::env::env_lock();
1886 let temp = TempDir::new().unwrap();
1887 let paths = McPaths::from_root(temp.path().join("mc"));
1888 fs::create_dir_all(&paths.root).unwrap();
1889 write_auth_fixture(
1890 &paths.auth_file,
1891 r#"{"anthropic":{"type":"api_key","key":"stored-key"}}"#,
1892 );
1893 env.set_var("ANTHROPIC_API_KEY", "env-key");
1894 env.set_var("MC_API_KEY", "ignored-mc");
1895
1896 let config = EffectiveConfig::load(
1897 paths,
1898 CliConfigOverrides {
1899 provider: Some(crate::providers::ANTHROPIC_PROVIDER.to_string()),
1900 model: Some(crate::providers::DEFAULT_ANTHROPIC_MODEL.to_string()),
1901 api_key: Some("ignored-cli".to_string()),
1902 ..CliConfigOverrides::default()
1903 },
1904 )
1905 .unwrap();
1906
1907 assert_eq!(config.api_key.as_deref(), Some("env-key"));
1908 }
1909
1910 #[test]
1911 fn anthropic_does_not_use_openai_api_key() {
1912 let _env = isolate_env();
1913 let env = crate::test_support::env::env_lock();
1914 let temp = TempDir::new().unwrap();
1915 let paths = McPaths::from_root(temp.path().join("mc"));
1916 fs::create_dir_all(&paths.root).unwrap();
1917 env.set_var("OPENAI_API_KEY", "ignored-openai");
1918 env.set_var("MC_API_KEY", "ignored-mc");
1919
1920 let config = EffectiveConfig::load(
1921 paths,
1922 CliConfigOverrides {
1923 provider: Some(crate::providers::ANTHROPIC_PROVIDER.to_string()),
1924 model: Some(crate::providers::DEFAULT_ANTHROPIC_MODEL.to_string()),
1925 api_key: Some("ignored-cli".to_string()),
1926 ..CliConfigOverrides::default()
1927 },
1928 )
1929 .unwrap();
1930
1931 assert!(config.auth.is_none());
1932 let error = config.require_auth().unwrap_err().to_string();
1933 assert!(error.contains("ANTHROPIC_API_KEY"), "{error}");
1934 assert!(!error.contains("ignored"), "{error}");
1935 }
1936
1937 #[test]
1938 fn anthropic_rejects_oauth_and_no_auth() {
1939 assert!(
1940 !AuthState::for_provider(
1941 crate::providers::ANTHROPIC_PROVIDER,
1942 Some(&ProviderCredential::OAuth {
1943 access: "oauth".to_string(),
1944 account_id: None,
1945 }),
1946 )
1947 .is_ready()
1948 );
1949 assert!(
1950 !AuthState::for_provider(
1951 crate::providers::ANTHROPIC_PROVIDER,
1952 Some(&ProviderCredential::NoAuth),
1953 )
1954 .is_ready()
1955 );
1956 }
1957
1958 #[test]
1959 fn openai_env_and_legacy_api_keys_do_not_create_builtin_auth() {
1960 let _env = isolate_env();
1961 let env = crate::test_support::env::env_lock();
1962 let temp = TempDir::new().unwrap();
1963 let paths = McPaths::from_root(temp.path().join("mc"));
1964 fs::create_dir_all(&paths.root).unwrap();
1965 write_auth_fixture(
1966 &paths.auth_file,
1967 r#"{"api_key":"legacy-key","openai":{"type":"api_key","key":"provider-key"}}"#,
1968 );
1969 env.set_var("OPENAI_API_KEY", "env-openai-key");
1970
1971 let config = EffectiveConfig::load(
1972 paths,
1973 CliConfigOverrides {
1974 provider: Some("openai".to_string()),
1975 ..CliConfigOverrides::default()
1976 },
1977 )
1978 .unwrap();
1979
1980 assert!(config.auth_state().credential().is_none());
1981 assert!(config.api_key.is_none());
1982 env.remove_var("OPENAI_API_KEY");
1983 }
1984
1985 #[test]
1986 fn legacy_flat_api_key_is_openai_compatibility_only() {
1987 let _env = isolate_env();
1988 let _guard = crate::test_support::env::env_lock();
1989 let temp = TempDir::new().unwrap();
1990 let paths = McPaths::from_root(temp.path().join("mc"));
1991 fs::create_dir_all(&paths.root).unwrap();
1992 write_auth_fixture(&paths.auth_file, r#"{"api_key":"legacy-key"}"#);
1993
1994 let openai = EffectiveConfig::load(
1995 paths.clone(),
1996 CliConfigOverrides {
1997 provider: Some("openai".to_string()),
1998 ..CliConfigOverrides::default()
1999 },
2000 )
2001 .unwrap();
2002 let codex = EffectiveConfig::load(
2003 paths,
2004 CliConfigOverrides {
2005 provider: Some("openai-codex".to_string()),
2006 ..CliConfigOverrides::default()
2007 },
2008 )
2009 .unwrap();
2010
2011 assert!(openai.auth_state().credential().is_none());
2012 assert!(openai.api_key.is_none());
2013 assert!(codex.auth.is_none());
2014 }
2015
2016 #[test]
2017 fn openai_codex_resolves_oauth_and_ignores_openai_api_key_env() {
2018 let _env = isolate_env();
2019 let env = crate::test_support::env::env_lock();
2020 let temp = TempDir::new().unwrap();
2021 let paths = McPaths::from_root(temp.path().join("mc"));
2022 fs::create_dir_all(&paths.root).unwrap();
2023 write_auth_fixture(
2024 &paths.auth_file,
2025 r#"{"openai-codex":{"type":"oauth","access":"codex-access","refresh":"refresh","expires":123,"accountId":"acct"}}"#,
2026 );
2027 env.set_var("OPENAI_API_KEY", "must-not-use");
2028
2029 let config = EffectiveConfig::load(
2030 paths,
2031 CliConfigOverrides {
2032 provider: Some("openai-codex".to_string()),
2033 ..CliConfigOverrides::default()
2034 },
2035 )
2036 .unwrap();
2037
2038 assert_eq!(
2039 config.auth,
2040 Some(ProviderCredential::OAuth {
2041 access: "codex-access".to_string(),
2042 account_id: Some("acct".to_string())
2043 })
2044 );
2045 assert!(config.api_key.is_none());
2046 env.remove_var("OPENAI_API_KEY");
2047 }
2048
2049 #[test]
2050 fn codex_api_key_is_not_auth_ready() {
2051 let state = AuthState::for_provider(
2052 crate::providers::OPENAI_CODEX_PROVIDER,
2053 Some(&ProviderCredential::ApiKey {
2054 key: "not-for-codex".to_string(),
2055 }),
2056 );
2057
2058 assert_eq!(
2059 state,
2060 AuthState::Missing {
2061 provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string()
2062 }
2063 );
2064 }
2065
2066 #[test]
2067 fn codex_oauth_is_auth_ready() {
2068 assert!(
2069 AuthState::for_provider(
2070 crate::providers::OPENAI_CODEX_PROVIDER,
2071 Some(&ProviderCredential::OAuth {
2072 access: "codex-access".to_string(),
2073 account_id: Some("acct".to_string()),
2074 }),
2075 )
2076 .is_ready()
2077 );
2078 }
2079
2080 #[test]
2081 fn effective_config_expired_codex_without_refresh_is_not_ready() {
2082 let _env = isolate_env();
2083 let _guard = crate::test_support::env::env_lock();
2084 let temp = TempDir::new().unwrap();
2085 let paths = McPaths::from_root(temp.path().join("mc"));
2086 fs::create_dir_all(&paths.root).unwrap();
2087 write_auth_fixture(
2088 &paths.auth_file,
2089 &format!(
2090 r#"{{"openai-codex":{{"type":"oauth","access":"expired-access","expires":{},"accountId":"acct"}}}}"#,
2091 chrono::Utc::now().timestamp() - 60
2092 ),
2093 );
2094
2095 let config = EffectiveConfig::load(
2096 paths,
2097 CliConfigOverrides {
2098 provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
2099 model: Some(crate::providers::DEFAULT_CODEX_MODEL.to_string()),
2100 ..CliConfigOverrides::default()
2101 },
2102 )
2103 .unwrap();
2104
2105 assert!(!config.auth_state().is_ready());
2106 assert!(
2107 config
2108 .require_auth()
2109 .unwrap_err()
2110 .to_string()
2111 .contains("re-login")
2112 );
2113 }
2114
2115 fn fake_account_jwt(payload_json: &str) -> String {
2116 use base64::Engine;
2117 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
2118 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json);
2119 format!("{header}.{payload}.")
2120 }
2121
2122 #[test]
2123 fn jwt_account_id_helpers_share_claim_parsing_without_secret_leakage() {
2124 let standard = fake_account_jwt(
2125 r#"{"https://api.openai.com/auth.chatgpt_account_id":"acct_standard"}"#,
2126 );
2127 let nested = fake_account_jwt(
2128 r#"{"https://api.openai.com/auth":{"chatgpt_account_id":"acct_nested"}}"#,
2129 );
2130 let login_alias = fake_account_jwt(r#"{"accountId":"acct_login"}"#);
2131 let missing = fake_account_jwt(r#"{"sub":"user"}"#);
2132
2133 assert_eq!(
2134 extract_chatgpt_account_id_from_jwt(&standard).unwrap(),
2135 "acct_standard"
2136 );
2137 assert_eq!(
2138 extract_chatgpt_account_id_from_jwt(&nested).unwrap(),
2139 "acct_nested"
2140 );
2141 assert_eq!(
2142 extract_oauth_account_id_from_jwt(&login_alias).as_deref(),
2143 Some("acct_login")
2144 );
2145 let error = extract_chatgpt_account_id_from_jwt(&missing)
2146 .unwrap_err()
2147 .to_string();
2148 assert!(
2149 error.contains("missing ChatGPT account id claim"),
2150 "{error}"
2151 );
2152 assert!(!error.contains("acct_"));
2153 }
2154
2155 #[test]
2156 fn missing_auth_diagnostic_uses_loaded_custom_provider_without_global_settings() {
2157 let _env = isolate_env();
2158 let env = crate::test_support::env::env_lock();
2159 let temp = TempDir::new().unwrap();
2160 let global_root = temp.path().join("global-mc-home");
2161 fs::create_dir_all(&global_root).unwrap();
2162 fs::write(global_root.join("settings.json"), r#"{}"#).unwrap();
2163 env.set_var("MC_HOME", &global_root);
2164
2165 let config = EffectiveConfig {
2166 provider: Some("local-ai".to_string()),
2167 model: Some("model-a".to_string()),
2168 no_color: false,
2169 file_autocomplete_respects_gitignore: true,
2170 custom_providers: BTreeMap::from([(
2171 "local-ai".to_string(),
2172 CustomProviderConfig {
2173 label: "Local AI".to_string(),
2174 base_url: "http://localhost:8080/v1".to_string(),
2175 api_key_env_var: Some("LOCAL_AI_REQUIRED_KEY".to_string()),
2176 models_dev_provider: None,
2177 use_responses_endpoint: false,
2178 supports_text_verbosity: false,
2179 reasoning_protocol: CustomReasoningProtocol::default(),
2180 extra_models: Vec::new(),
2181 },
2182 )]),
2183 thinking_level: ThinkingLevel::Default,
2184 api_key: None,
2185 auth: None,
2186 paths: McPaths::from_root(temp.path().join("local-config")),
2187 };
2188
2189 let error = config.require_auth().unwrap_err().to_string();
2190
2191 assert!(error.contains("custom provider 'local-ai'"), "{error}");
2192 assert!(error.contains("LOCAL_AI_REQUIRED_KEY"), "{error}");
2193 }
2194
2195 #[test]
2196 fn missing_auth_and_selection_errors_use_resolved_mc_home_paths() {
2197 let _env = isolate_env();
2198 let temp = TempDir::new().unwrap();
2199 let paths = McPaths::from_root(temp.path().join("custom-mc-home"));
2200 fs::create_dir_all(&paths.root).unwrap();
2201
2202 let config = EffectiveConfig::load(
2203 paths.clone(),
2204 CliConfigOverrides {
2205 provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
2206 model: Some(crate::providers::DEFAULT_CODEX_MODEL.to_string()),
2207 ..CliConfigOverrides::default()
2208 },
2209 )
2210 .unwrap();
2211 let auth_error = config.require_auth().unwrap_err().to_string();
2212 assert!(
2213 auth_error.contains(&paths.auth_file.display().to_string()),
2214 "{auth_error}"
2215 );
2216
2217 let missing_provider = EffectiveConfig {
2218 provider: Some(String::new()),
2219 model: Some(String::new()),
2220 no_color: true,
2221 file_autocomplete_respects_gitignore: true,
2222 custom_providers: BTreeMap::new(),
2223 thinking_level: ThinkingLevel::Default,
2224 api_key: None,
2225 auth: None,
2226 paths: paths.clone(),
2227 }
2228 .validate_provider_ready()
2229 .unwrap_err()
2230 .to_string();
2231 assert!(
2232 missing_provider.contains(&paths.settings_file.display().to_string()),
2233 "{missing_provider}"
2234 );
2235
2236 let missing_model = EffectiveConfig {
2237 provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
2238 model: Some(String::new()),
2239 no_color: true,
2240 file_autocomplete_respects_gitignore: true,
2241 custom_providers: BTreeMap::new(),
2242 thinking_level: ThinkingLevel::Default,
2243 api_key: None,
2244 auth: None,
2245 paths: paths.clone(),
2246 }
2247 .validate_provider_ready()
2248 .unwrap_err()
2249 .to_string();
2250 assert!(
2251 missing_model.contains(&paths.settings_file.display().to_string()),
2252 "{missing_model}"
2253 );
2254 }
2255
2256 #[test]
2257 fn color_resolution_precedence_decouples_unicode_from_no_color() {
2258 let _env = isolate_env();
2259 let env = crate::test_support::env::env_lock();
2260 let mut settings = Settings {
2261 no_color: Some(true),
2262 ..Settings::default()
2263 };
2264
2265 assert_eq!(
2266 resolve_output_style_for_stdout(&settings, Some(ColorChoice::Always), false),
2267 (true, false)
2268 );
2269 assert_eq!(
2270 resolve_output_style_for_stdout(&settings, Some(ColorChoice::Never), true),
2271 (false, true)
2272 );
2273 assert_eq!(
2274 resolve_output_style_for_stdout(&settings, Some(ColorChoice::Auto), true),
2275 (true, true)
2276 );
2277 settings.no_color = Some(false);
2278 env.set_var("NO_COLOR", "1");
2279 assert_eq!(
2280 resolve_output_style_for_stdout(&settings, None, true),
2281 (false, true)
2282 );
2283 assert_eq!(
2284 resolve_output_style_for_stdout(&settings, Some(ColorChoice::Auto), true),
2285 (true, true)
2286 );
2287 env.remove_var("NO_COLOR");
2288 assert_eq!(
2289 resolve_output_style_for_stdout(&settings, None, true),
2290 (true, true)
2291 );
2292 }
2293
2294 #[test]
2295 fn unknown_provider_missing_auth_message_is_actionable() {
2296 let error = ConfigError::missing_auth("openai").to_string();
2297 assert!(error.contains("missing auth for provider 'openai'"));
2298 assert!(error.contains("configure"));
2299 }
2300
2301 #[test]
2302 fn codex_missing_oauth_message_is_actionable() {
2303 let error = ConfigError::missing_auth(crate::providers::OPENAI_CODEX_PROVIDER).to_string();
2304 assert!(error.contains("missing OAuth auth"));
2305 assert!(error.contains("accountId"));
2306 }
2307
2308 #[test]
2309 fn codex_rejects_cli_api_key_with_actionable_diagnostic() {
2310 let _env = isolate_env();
2311 let _guard = crate::test_support::env::env_lock();
2312 let temp = TempDir::new().unwrap();
2313 let paths = McPaths::from_root(temp.path().join("mc"));
2314 fs::create_dir_all(&paths.root).unwrap();
2315
2316 let config = EffectiveConfig::load(
2317 paths,
2318 CliConfigOverrides {
2319 provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
2320 model: Some(crate::providers::DEFAULT_CODEX_MODEL.to_string()),
2321 api_key: Some("sk-cli-secret".to_string()),
2322 ..CliConfigOverrides::default()
2323 },
2324 )
2325 .unwrap();
2326 let error = config.require_auth().unwrap_err().to_string();
2327
2328 assert!(!config.auth_state().is_ready());
2329 assert!(error.contains("--api-key"));
2330 assert!(error.contains("unsupported for openai-codex"));
2331 assert!(error.contains("OAuth"));
2332 assert!(!error.contains("sk-cli-secret"));
2333 }
2334
2335 #[test]
2336 fn credential_debug_output_is_redacted() {
2337 let auth = Auth {
2338 api_key: Some("sk-flat-secret".to_string()),
2339 providers: BTreeMap::from([
2340 (
2341 "openai".to_string(),
2342 AuthProviderRecord::ApiKey {
2343 key: "sk-provider-secret".to_string(),
2344 },
2345 ),
2346 (
2347 "openai-codex".to_string(),
2348 AuthProviderRecord::OAuth {
2349 access: "access-secret".to_string(),
2350 refresh: Some("refresh-secret".to_string()),
2351 expires: Some(123),
2352 account_id: Some("acct-secret".to_string()),
2353 },
2354 ),
2355 ]),
2356 };
2357 let temp = TempDir::new().unwrap();
2358 let config = EffectiveConfig {
2359 provider: Some("openai-codex".to_string()),
2360 model: Some("model".to_string()),
2361 no_color: false,
2362 file_autocomplete_respects_gitignore: true,
2363 custom_providers: std::collections::BTreeMap::new(),
2364 thinking_level: crate::thinking::ThinkingLevel::Default,
2365 api_key: Some("sk-effective-secret".to_string()),
2366 auth: Some(ProviderCredential::OAuth {
2367 access: "effective-access-secret".to_string(),
2368 account_id: Some("effective-acct-secret".to_string()),
2369 }),
2370 paths: McPaths::from_root(temp.path().join("mc")),
2371 };
2372 let debug = format!(
2373 "{auth:?} {:?} {:?} {config:?}",
2374 config.auth.as_ref().unwrap(),
2375 AuthState::for_provider(
2376 crate::providers::OPENAI_CODEX_PROVIDER,
2377 config.auth.as_ref()
2378 )
2379 );
2380
2381 for secret in [
2382 "sk-flat-secret",
2383 "sk-provider-secret",
2384 "access-secret",
2385 "refresh-secret",
2386 "acct-secret",
2387 "sk-effective-secret",
2388 "effective-access-secret",
2389 "effective-acct-secret",
2390 ] {
2391 assert!(!debug.contains(secret), "debug leaked {secret}: {debug}");
2392 }
2393 assert!(debug.contains("<redacted>"));
2394 }
2395
2396 #[test]
2397 fn codex_rejects_api_key_as_ready_auth() {
2398 assert!(
2399 !AuthState::for_provider(
2400 crate::providers::OPENAI_CODEX_PROVIDER,
2401 Some(&ProviderCredential::ApiKey {
2402 key: "sk-test".to_string(),
2403 }),
2404 )
2405 .is_ready()
2406 );
2407 }
2408
2409 #[test]
2410 fn openai_oauth_is_not_auth_ready() {
2411 let state = AuthState::for_provider(
2412 "openai",
2413 Some(&ProviderCredential::OAuth {
2414 access: "codex-access".to_string(),
2415 account_id: Some("acct".to_string()),
2416 }),
2417 );
2418
2419 assert_eq!(
2420 state,
2421 AuthState::Missing {
2422 provider: "openai".to_string()
2423 }
2424 );
2425 }
2426
2427 #[test]
2428 fn disabled_skill_settings_default_and_preserve_unknown_fields() {
2429 let temp = TempDir::new().unwrap();
2430 let paths = McPaths::from_root(temp.path().join("mc"));
2431 fs::create_dir_all(&paths.root).unwrap();
2432 fs::write(
2433 &paths.settings_file,
2434 r#"{"future_setting":true,"disabled_skills":[" review ","","plan","review"],"provider":"openai"}"#,
2435 )
2436 .unwrap();
2437
2438 let disabled = disabled_skill_names(&paths).unwrap();
2439 assert_eq!(
2440 disabled,
2441 BTreeSet::from(["plan".to_string(), "review".to_string()])
2442 );
2443
2444 let disabled = set_skill_disabled(&paths, "lint", true).unwrap();
2445 assert!(disabled.contains("lint"));
2446 let disabled = set_skill_disabled(&paths, "plan", false).unwrap();
2447 assert!(!disabled.contains("plan"));
2448 assert!(disabled.contains("review"));
2449
2450 let raw = fs::read_to_string(&paths.settings_file).unwrap();
2451 let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
2452 assert_eq!(value["future_setting"], true);
2453 assert_eq!(value["selected_model"]["provider"], "openai");
2454 assert_eq!(
2455 value["skills"]["disabled"],
2456 serde_json::json!(["lint", "review"])
2457 );
2458 assert!(value.get("disabled_skills").is_none());
2459 assert!(raw.contains("review"));
2460 assert!(raw.contains("lint"));
2461 assert!(!raw.contains("api_key"));
2462 assert!(!raw.contains("accountId"));
2463 assert_eq!(paths.auth_file, paths.root.join("auth.json"));
2464 assert!(!paths.auth_file.exists());
2465 }
2466
2467 #[test]
2468 fn disabled_model_settings_scope_and_unknown_fields() {
2469 let temp = TempDir::new().unwrap();
2470 let paths = McPaths {
2471 root: temp.path().join("mc"),
2472 cache: temp.path().join("mc/cache"),
2473 state: temp.path().join("mc/state"),
2474 sessions: temp.path().join("mc/sessions"),
2475 checkpoints: temp.path().join("mc/checkpoints"),
2476 skills: temp.path().join("mc/skills"),
2477 prompts: temp.path().join("mc/prompts"),
2478 subagents: temp.path().join("mc/subagents"),
2479 primary_agents: temp.path().join("mc/primary-agents"),
2480 user_agents: temp.path().join("mc/agents"),
2481 settings_file: temp.path().join("mc/settings.json"),
2482 project_settings_file: temp.path().join("repo/.magi-code/settings.json"),
2483 local_settings_file: None,
2484 auth_file: temp.path().join("mc/auth.json"),
2485 };
2486 fs::create_dir_all(&paths.root).unwrap();
2487 fs::create_dir_all(temp.path().join("repo")).unwrap();
2488 fs::write(
2489 &paths.settings_file,
2490 r#"{"future_setting":true,"models":{"disabled":[" openai-codex/gpt-a ","","openai-codex/gpt-a"],"future":true}}"#,
2491 )
2492 .unwrap();
2493
2494 let settings = read_settings(&paths).unwrap();
2495 assert_eq!(
2496 disabled_model_ids_from_settings(&settings),
2497 BTreeSet::from(["openai-codex/gpt-a".to_string()])
2498 );
2499 set_model_disabled_for_scope(
2500 &paths,
2501 SettingsScope::Project,
2502 "anthropic/claude-test",
2503 true,
2504 )
2505 .unwrap();
2506 let project_disabled = disabled_names_for_modal_scope(
2507 &paths,
2508 SettingsScope::Project,
2509 SettingsListKind::Models,
2510 )
2511 .unwrap();
2512 assert!(project_disabled.contains("anthropic/claude-test"));
2513 assert!(project_disabled.contains("openai-codex/gpt-a"));
2514
2515 set_model_disabled_for_scope(
2516 &paths,
2517 SettingsScope::Project,
2518 "anthropic/claude-test",
2519 false,
2520 )
2521 .unwrap();
2522 let raw = fs::read_to_string(&paths.settings_file).unwrap();
2523 let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
2524 assert_eq!(value["future_setting"], true);
2525 assert_eq!(value["models"]["future"], true);
2526 }
2527
2528 #[test]
2529 fn disabled_skill_settings_empty_list_omitted() {
2530 let temp = TempDir::new().unwrap();
2531 let paths = McPaths::from_root(temp.path().join("mc"));
2532 assert!(disabled_skill_names(&paths).unwrap().is_empty());
2533 set_skill_disabled(&paths, "review", true).unwrap();
2534 set_skill_disabled(&paths, "review", false).unwrap();
2535 let raw = fs::read_to_string(&paths.settings_file).unwrap();
2536 assert!(!raw.contains("disabled_skills"));
2537 assert!(!raw.contains("disabled"));
2538 }
2539
2540 #[test]
2541 fn custom_reasoning_protocol_settings_default_round_trip_invalid_and_schema() {
2542 let absent: Settings = serde_json::from_str(
2543 r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1"}}}"#,
2544 )
2545 .unwrap();
2546 assert_eq!(
2547 absent.custom_providers["local"].reasoning_protocol,
2548 CustomReasoningProtocol::GptLike
2549 );
2550 let serialized = serde_json::to_value(&absent).unwrap();
2551 assert!(
2552 serialized["custom_providers"]["local"]
2553 .get("reasoning_protocol")
2554 .is_none()
2555 );
2556
2557 for (raw, expected) in [
2558 ("gpt-like", CustomReasoningProtocol::GptLike),
2559 ("anthropic-like", CustomReasoningProtocol::AnthropicLike),
2560 ] {
2561 let settings: Settings = serde_json::from_value(serde_json::json!({
2562 "custom_providers": {
2563 "local": {
2564 "label": "Local",
2565 "base_url": "https://provider.test/v1",
2566 "reasoning_protocol": raw
2567 }
2568 }
2569 }))
2570 .unwrap();
2571 assert_eq!(
2572 settings.custom_providers["local"].reasoning_protocol,
2573 expected
2574 );
2575 if expected == CustomReasoningProtocol::AnthropicLike {
2576 assert_eq!(
2577 serde_json::to_value(&settings).unwrap()["custom_providers"]["local"]["reasoning_protocol"],
2578 raw
2579 );
2580 } else {
2581 assert!(
2582 serde_json::to_value(&settings).unwrap()["custom_providers"]["local"]
2583 .get("reasoning_protocol")
2584 .is_none()
2585 );
2586 }
2587 }
2588
2589 for value in [serde_json::json!("other"), serde_json::json!(true)] {
2590 let error = serde_json::from_value::<Settings>(serde_json::json!({
2591 "custom_providers": {
2592 "local": {
2593 "label": "Local",
2594 "base_url": "https://provider.test/v1",
2595 "reasoning_protocol": value
2596 }
2597 }
2598 }))
2599 .unwrap_err();
2600 assert!(!error.to_string().is_empty());
2601 }
2602
2603 let schema = serde_json::to_value(schemars::schema_for!(Settings)).unwrap();
2604 assert_eq!(
2605 schema["$defs"]["CustomReasoningProtocol"]["enum"],
2606 serde_json::json!(["gpt-like", "anthropic-like"])
2607 );
2608 }
2609}