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