Skip to main content

magi_code/config/
mod.rs

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