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