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