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