Skip to main content

magi_code/config/
mod.rs

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