Skip to main content

magi_code/config/
mod.rs

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