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