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