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