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