Skip to main content

magi_code/config/
mod.rs

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