Skip to main content

magi_code/config/
mod.rs

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