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