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