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