Skip to main content

magi_code/config/
mod.rs

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