Skip to main content

magi_code/config/
mod.rs

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