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