mod auth;
mod codex_auth;
mod custom_provider_config;
mod hooks;
mod paths;
mod settings;
mod settings_storage;
use crate::thinking::ThinkingLevel;
pub(crate) use settings::DEFAULT_TUI_SUBAGENT_CARD_ROWS;
#[cfg(test)]
use std::path::PathBuf;
use std::{collections::BTreeMap, env, fmt, io::IsTerminal};
#[cfg(test)]
pub(crate) use auth::Auth;
#[cfg(test)]
pub(crate) use auth::write_auth;
pub(crate) use auth::{
AuthProviderRecord, AuthState, ProviderCredential, cancel_codex_login_before_commit,
persist_codex_login_if_current, read_auth, read_auth_store, remove_provider_auth,
resolve_provider_credential,
};
pub(crate) use auth::{
CredentialReadiness, classify_provider_auth_record, current_provider_auth_readiness,
extract_chatgpt_account_id_from_jwt,
};
#[cfg(test)]
pub(crate) use auth::{
classify_codex_oauth_record, custom_provider_auth_readiness, extract_oauth_account_id_from_jwt,
};
pub(crate) use codex_auth::{
NormalizedToken, OPENAI_CODEX_CLIENT_ID, OPENAI_CODEX_REDIRECT_URI,
OPENAI_CODEX_RELOGIN_GUIDANCE, codex_credential_from_store,
codex_credential_from_store_with_exchange, exchange_codex_code,
force_refresh_codex_credential_from_store, persist_codex_token, refresh_codex_token,
refreshed_codex_auth_state,
};
pub use custom_provider_config::{
CustomProviderConfig, CustomProviderFastMode, CustomProviderHeaderValue,
CustomReasoningProtocol,
};
pub(crate) use custom_provider_config::{
derive_custom_provider_id, looks_like_secret_value, make_custom_provider_config,
normalize_custom_provider_base_url, normalized_extra_models, validate_custom_provider_id,
validate_optional_env_var_name,
};
pub use hooks::{
HookDefinition, HookFailurePolicy, HookPayloadMode, HookSettings, InjectedContentSettings,
InjectedContentStyle,
};
pub use paths::McPaths;
#[cfg(test)]
pub(crate) use settings::ensure_settings_schema_files;
#[cfg(test)]
pub(crate) use settings::load_config_with_settings;
#[cfg(test)]
pub(crate) use settings::selected_primary_agent;
pub(crate) use settings::set_selected_model;
pub(crate) use settings::update_settings_checked;
pub use settings::{
AnthropicCacheTtl, AstGrepToolSettings, AutoCompactionSettings, BashToolSettings,
CompactionSettings, FastSettings, FindToolSettings, GrepToolSettings, HashEditToolSettings,
HerdrSettings, InstructionsSettings, IntegrationsSettings, ListFilesToolSettings,
LspServerConfig, LspServersSettings, LspSettings, McpHttpServerConfig, McpOAuthConfig,
McpServerConfig, McpServersSettings, McpStdioServerConfig, ModelsSettings, OpenAiCodexSettings,
OpenAiResponsesSettings, ProviderStreamSettings, ReadToolSettings, SelectedModelSettings,
SessionTitleSettings, Settings, SkillsSettings, SubagentsSettings, SubagentsToolSettings,
SummarizerSettings, TextVerbosity, ToolOutputCompressionSettings, ToolSettings,
TtsrRuleSetting, TtsrSettings, TuiSettings, ViewImageToolSettings,
ViewImageVisionModelSettings, WriteToolSettings,
};
pub(crate) use settings::{AppearanceSettings, AutoCompactionLimit, set_appearance_theme};
pub(crate) use settings::{
CompactionConfig, SessionTitleConfig, clamp_subagent_max_depth, validate_mcp_http_url_field,
validate_mcp_server_name, validate_view_image_identifier, validate_view_image_max_image_bytes,
};
pub(crate) use settings::{
DEFAULT_MCP_TIMEOUT_SECONDS, DEFAULT_SESSION_RETENTION_DAYS, SettingsListKind, SettingsScope,
};
pub(crate) use settings::{DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES, MAX_VIEW_IMAGE_MAX_IMAGE_BYTES};
pub(crate) use settings::{
disabled_model_ids_from_settings, disabled_skill_names_from_settings,
disabled_subagent_profile_names_from_settings, disabled_tool_names_from_settings,
load_startup_config_with_settings,
};
pub(crate) use settings::{
disabled_names_for_modal_scope, fast_mode_enabled, read_settings, read_settings_locked,
remove_custom_provider, set_fast_mode, set_mcp_server_enabled, set_model_disabled_for_scope,
set_selected_primary_agent, set_skill_disabled_for_scope, set_subagent_profile_disabled,
set_thinking_level, set_tool_disabled, toggle_fast_mode, upsert_custom_provider,
};
#[cfg(test)]
pub(crate) use settings::{
disabled_skill_names, read_settings_for_scope, set_skill_disabled,
update_settings_preserving_unknown_top_level_fields, write_settings,
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum ColorChoice {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct CliConfigOverrides {
pub(crate) provider: Option<String>,
pub(crate) model: Option<String>,
pub(crate) api_key: Option<String>,
pub(crate) color: Option<ColorChoice>,
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct EffectiveConfig {
pub(crate) provider: Option<String>,
pub(crate) model: Option<String>,
pub(crate) no_color: bool,
pub(crate) file_autocomplete_respects_gitignore: bool,
pub(crate) custom_providers: BTreeMap<String, CustomProviderConfig>,
pub(crate) thinking_level: ThinkingLevel,
pub(crate) auth: Option<ProviderCredential>,
pub(crate) paths: McPaths,
}
impl fmt::Debug for EffectiveConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("EffectiveConfig")
.field("provider", &self.provider)
.field("model", &self.model)
.field("no_color", &self.no_color)
.field(
"file_autocomplete_respects_gitignore",
&self.file_autocomplete_respects_gitignore,
)
.field("custom_providers", &self.custom_providers)
.field("auth", &self.auth)
.field("thinking_level", &self.thinking_level)
.field("paths", &self.paths)
.finish()
}
}
impl EffectiveConfig {
#[cfg(test)]
pub(crate) fn load(paths: McPaths, cli: CliConfigOverrides) -> anyhow::Result<Self> {
let (config, _, _, _) = load_config_with_settings(paths, cli)?;
Ok(config)
}
pub(crate) fn selected_provider_model(settings: &Settings) -> (Option<String>, Option<String>) {
(
env::var("MC_PROVIDER")
.ok()
.or_else(|| settings.selected_model.provider.clone()),
env::var("MC_MODEL")
.ok()
.or_else(|| settings.selected_model.model.clone()),
)
}
pub(crate) fn from_loaded_settings(
paths: McPaths,
cli: CliConfigOverrides,
settings: Settings,
) -> anyhow::Result<Self> {
let (color_enabled, _) = resolve_output_style(&settings, cli.color);
let (provider, model) = Self::selected_provider_model(&settings);
let provider = cli.provider.or(provider);
let model = cli.model.or(model);
let provider_id = provider
.as_deref()
.unwrap_or(crate::providers::OPENAI_CODEX_PROVIDER);
reject_retired_provider_selection(provider_id, model.as_deref().unwrap_or(""))?;
let auth_file = read_auth(&paths)?;
let auth = resolve_provider_credential(
provider_id,
&auth_file,
cli.api_key,
&settings.custom_providers,
)?;
Ok(Self {
provider,
model,
no_color: !color_enabled,
file_autocomplete_respects_gitignore: settings.file_autocomplete_respects_gitignore,
custom_providers: settings.custom_providers,
thinking_level: settings.selected_model.thinking_level.unwrap_or_default(),
auth,
paths,
})
}
#[cfg(test)]
pub(crate) fn validate_provider_ready(&self) -> Result<(), ConfigError> {
if self.provider.as_deref().unwrap_or_default().is_empty() {
return Err(ConfigError::MissingProvider {
settings_path: self.paths.settings_file.clone(),
});
}
if self.model.as_deref().unwrap_or_default().is_empty() {
return Err(ConfigError::MissingModel {
settings_path: self.paths.settings_file.clone(),
});
}
self.require_auth()?;
Ok(())
}
pub(crate) fn auth_state(&self) -> AuthState {
AuthState::for_provider_with_custom(
self.provider_id(),
self.auth.as_ref(),
&self.custom_providers,
)
}
pub(crate) fn require_auth(&self) -> Result<ProviderCredential, ConfigError> {
self.auth_state()
.credential()
.cloned()
.ok_or_else(|| self.missing_auth_error())
}
pub(crate) fn current_store_provider_auth_ready(&self) -> anyhow::Result<bool> {
let auth_store = read_auth_store(&self.paths)?;
Ok(current_provider_auth_readiness(
self.provider_id(),
auth_store.auth(),
&self.custom_providers,
)
.is_ready())
}
pub(crate) fn resolve_provider_auth_for_runtime(&self) -> anyhow::Result<ProviderCredential> {
self.resolve_provider_auth_for_runtime_with(codex_credential_from_store)
}
#[cfg(test)]
pub(crate) fn resolve_provider_auth_for_runtime_with_exchange(
&self,
exchange: impl FnOnce(&str) -> anyhow::Result<NormalizedToken>,
) -> anyhow::Result<ProviderCredential> {
self.resolve_provider_auth_for_runtime_with(|paths| {
codex_credential_from_store_with_exchange(paths, exchange)
})
}
fn resolve_provider_auth_for_runtime_with(
&self,
prepare_codex_auth: impl FnOnce(&McPaths) -> anyhow::Result<ProviderCredential>,
) -> anyhow::Result<ProviderCredential> {
if self.provider_id() == crate::providers::ANTHROPIC_PROVIDER {
return resolve_provider_credential(
self.provider_id(),
&read_auth(&self.paths)?,
None,
&self.custom_providers,
)?
.ok_or_else(|| self.missing_auth_error().into());
}
if self.provider_id() != crate::providers::OPENAI_CODEX_PROVIDER {
return self.require_auth().map_err(Into::into);
}
prepare_codex_auth(&self.paths)
}
pub(crate) fn missing_auth_error(&self) -> ConfigError {
ConfigError::missing_auth_for_custom_providers(
self.provider_id(),
&self.custom_providers,
&self.paths.auth_file,
)
}
pub(crate) fn provider_id(&self) -> &str {
self.provider
.as_deref()
.unwrap_or(crate::providers::OPENAI_CODEX_PROVIDER)
}
}
pub(crate) fn reject_retired_provider_selection(provider: &str, model: &str) -> anyhow::Result<()> {
if provider != "claude-code" {
return Ok(());
}
let model = if model.trim().is_empty() {
"<model>"
} else {
model
};
anyhow::bail!(
"stale provider selection 'claude-code/{model}': the Claude Code provider was removed; select 'anthropic/{model}' instead, then set ANTHROPIC_API_KEY or configure a provider-keyed 'anthropic' API key; Claude Code OAuth/subscription credentials are not reused"
);
}
pub(crate) fn load_effective_provider_selection(
paths: &McPaths,
provider: &str,
model: &str,
) -> anyhow::Result<EffectiveConfig> {
reject_retired_provider_selection(provider, model)?;
let settings = read_settings(paths)?;
let auth_file = read_auth(paths)?;
let auth = resolve_provider_credential(provider, &auth_file, None, &settings.custom_providers)?;
let (color_enabled, _) = resolve_output_style(&settings, None);
Ok(EffectiveConfig {
provider: Some(provider.to_string()),
model: Some(model.to_string()),
no_color: !color_enabled,
file_autocomplete_respects_gitignore: settings.file_autocomplete_respects_gitignore,
custom_providers: settings.custom_providers,
thinking_level: settings.selected_model.thinking_level.unwrap_or_default(),
auth,
paths: paths.clone(),
})
}
impl ConfigError {
#[cfg(test)]
pub(crate) fn missing_auth(provider: &str) -> Self {
missing_auth_error(provider, None, &PathBuf::from("~/.magi-code/auth.json"))
}
pub(crate) fn missing_auth_for_custom_providers(
provider: &str,
custom_providers: &BTreeMap<String, CustomProviderConfig>,
auth_file: &std::path::Path,
) -> Self {
missing_auth_error(provider, custom_providers.get(provider), auth_file)
}
}
fn missing_auth_error(
provider: &str,
custom: Option<&CustomProviderConfig>,
auth_file: &std::path::Path,
) -> ConfigError {
let auth_path = auth_file.display();
let message = if let Some(custom) = custom {
match &custom.api_key_env_var {
Some(env_var) => format!(
"missing auth: custom provider '{provider}' is configured but environment variable {env_var} is missing or empty"
),
None => format!(
"missing auth: custom provider '{provider}' is configured for no-auth but could not be prepared"
),
}
} else if provider == crate::providers::OPENAI_CODEX_PROVIDER {
format!(
"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}"
)
} else if provider == crate::providers::ANTHROPIC_PROVIDER {
format!(
"missing auth: provider 'anthropic' requires an Anthropic API key; set ANTHROPIC_API_KEY or configure provider-keyed API-key auth in {auth_path}; OPENAI_API_KEY, MC_API_KEY, and --api-key are not used for Anthropic"
)
} else {
format!(
"missing auth for provider '{provider}'; configure provider-keyed auth in {auth_path}"
)
};
ConfigError::MissingAuth {
provider: provider.to_string(),
message,
}
}
fn resolve_output_style(settings: &Settings, cli_color: Option<ColorChoice>) -> (bool, bool) {
let stdout_is_tty = std::io::stdout().is_terminal();
resolve_output_style_for_stdout(settings, cli_color, stdout_is_tty)
}
pub(crate) fn resolve_output_style_for_stdout(
settings: &Settings,
cli_color: Option<ColorChoice>,
stdout_is_tty: bool,
) -> (bool, bool) {
let policy = crate::appearance::resolve_color_policy_from_env(
settings.no_color,
cli_color,
stdout_is_tty,
env::var_os("NO_COLOR").is_some(),
env::var("COLORTERM").ok().as_deref(),
env::var("TERM").ok().as_deref(),
);
(policy.color_enabled, policy.unicode_enabled)
}
#[allow(clippy::enum_variant_names)]
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub(crate) enum ConfigError {
#[cfg(test)]
#[error(
"missing provider; set --provider, MC_PROVIDER, or selected_model.provider in {settings_path}"
)]
MissingProvider { settings_path: PathBuf },
#[cfg(test)]
#[error("missing model; set --model, MC_MODEL, or selected_model.model in {settings_path}")]
MissingModel { settings_path: PathBuf },
#[error("{message}")]
MissingAuth { provider: String, message: String },
}
pub(crate) fn load_context_budget(
config: &EffectiveConfig,
) -> anyhow::Result<crate::context::ContextBudget> {
let settings = read_settings(&config.paths)?;
let mut budget = settings.context.unwrap_or_default();
let provider = config.provider_id();
let model = config
.model
.as_deref()
.unwrap_or_else(|| crate::providers::default_model_for_provider(provider));
if let Some(context_window) =
crate::model_catalog::cached_model_context_window(&config.paths, provider, model)
{
budget.max_tokens = context_window;
}
budget.apply_model_override(provider, model);
Ok(budget)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
collections::{BTreeMap, BTreeSet},
env, fs,
path::{Path, PathBuf},
};
use tempfile::TempDir;
fn parse_validated_settings(raw: &str) -> anyhow::Result<Settings> {
let settings = serde_json::from_str(raw)?;
settings::validate_settings(&settings)?;
Ok(settings)
}
#[test]
fn mcp_server_name_validation_rejects_ambiguous_trailing_underscore() {
for valid in ["_leading", "interior_name"] {
validate_mcp_server_name(valid).unwrap();
}
let error = validate_mcp_server_name("bad_").unwrap_err().to_string();
assert!(error.contains("bad_"), "{error}");
assert!(error.contains("must not end with '_'"), "{error}");
assert!(error.contains("mcp__<server>__<tool>"), "{error}");
assert!(error.contains("ambiguous"), "{error}");
}
#[test]
fn tool_output_compression_settings_default_and_serde_round_trip() {
assert!(!ToolOutputCompressionSettings::default().enabled);
let defaults: Settings = serde_json::from_str("{}").unwrap();
assert!(!defaults.tools.output_compression.enabled);
let enabled: Settings =
serde_json::from_str(r#"{"tools":{"output_compression":{"enabled":true}}}"#).unwrap();
assert!(enabled.tools.output_compression.enabled);
let serialized = serde_json::to_value(&enabled).unwrap();
assert_eq!(
serialized["capabilities"]["tools"]["output_compression"]["enabled"],
true
);
}
struct EnvSnapshot(Vec<(&'static str, Option<std::ffi::OsString>)>);
impl Drop for EnvSnapshot {
fn drop(&mut self) {
let env = crate::test_support::env::env_lock();
for (key, value) in &self.0 {
match value {
Some(value) => env.set_var(key, value),
None => env.remove_var(key),
}
}
}
}
fn isolate_env() -> EnvSnapshot {
let keys = [
"MC_PROVIDER",
"MC_MODEL",
"MC_API_KEY",
"OPENAI_API_KEY",
"MC_HOME",
"NO_COLOR",
"ANTHROPIC_API_KEY",
"CUSTOM_PROVIDER_API_KEY",
"OTHER_PROVIDER_API_KEY",
];
let env = crate::test_support::env::env_lock();
let snapshot = EnvSnapshot(keys.map(|key| (key, env::var_os(key))).into());
for key in keys {
env.remove_var(key);
}
snapshot
}
fn write_auth_fixture(path: &Path, text: &str) {
fs::write(path, text).unwrap();
#[cfg(unix)]
set_mode(path, 0o600);
}
#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_mode(mode);
fs::set_permissions(path, permissions).unwrap();
}
#[test]
fn compaction_settings_default_to_active_or_require_complete_override() {
let absent: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(absent.compaction, CompactionSettings::default());
assert_eq!(
absent
.compaction
.resolve_config(" active-provider ", " active-model ")
.unwrap(),
CompactionConfig {
provider: "active-provider".to_string(),
model: "active-model".to_string(),
}
);
let override_settings: Settings = serde_json::from_str(
r#"{"compaction":{"provider":" compact-provider ","model":" compact-model "}}"#,
)
.unwrap();
assert_eq!(
override_settings
.compaction
.resolve_config("active-provider", "active-model")
.unwrap(),
CompactionConfig {
provider: "compact-provider".to_string(),
model: "compact-model".to_string(),
}
);
for raw in [
r#"{"compaction":{"provider":"compact-provider"}}"#,
r#"{"compaction":{"model":"compact-model"}}"#,
r#"{"compaction":{"provider":" ","model":"compact-model"}}"#,
r#"{"compaction":{"provider":"compact-provider","model":" "}}"#,
] {
let settings: Settings = serde_json::from_str(raw).unwrap();
let error = settings
.compaction
.resolve_config("active-provider", "active-model")
.unwrap_err();
assert!(error.contains("compaction.provider"), "{error}");
}
}
#[test]
fn compaction_settings_persistence_preserves_unknown_fields() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"future_setting":{"keep":true},"compaction":{"provider":"old","model":"old-model"}}"#,
)
.unwrap();
update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
settings.compaction = CompactionSettings {
provider: Some("new".to_string()),
model: Some("new-model".to_string()),
..CompactionSettings::default()
};
})
.unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(value["future_setting"]["keep"], true);
assert_eq!(value["agent"]["compaction"]["provider"], "new");
assert_eq!(value["agent"]["compaction"]["model"], "new-model");
assert!(!value.to_string().contains("api_key"));
}
#[test]
fn session_title_settings_require_enabled_and_explicit_provider_model() {
let absent: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(absent.session_titles.eligible_config().unwrap(), None);
let disabled: Settings = serde_json::from_str(
r#"{"session_titles":{"enabled":false,"provider":"title-provider","model":"title-model"}}"#,
)
.unwrap();
assert_eq!(
disabled.session_titles.provider.as_deref(),
Some("title-provider")
);
assert_eq!(
disabled.session_titles.model.as_deref(),
Some("title-model")
);
assert_eq!(disabled.session_titles.eligible_config().unwrap(), None);
let enabled: Settings = serde_json::from_str(
r#"{"session_titles":{"enabled":true,"provider":" title-provider ","model":" title-model "}}"#,
)
.unwrap();
assert_eq!(
enabled.session_titles.eligible_config().unwrap(),
Some(SessionTitleConfig {
provider: "title-provider".to_string(),
model: "title-model".to_string(),
})
);
let missing_provider: Settings =
serde_json::from_str(r#"{"session_titles":{"enabled":true,"model":"title-model"}}"#)
.unwrap();
assert!(
missing_provider
.session_titles
.eligible_config()
.unwrap_err()
.contains("provider")
);
let blank_model: Settings = serde_json::from_str(
r#"{"session_titles":{"enabled":true,"provider":"title-provider","model":" "}}"#,
)
.unwrap();
assert!(
blank_model
.session_titles
.eligible_config()
.unwrap_err()
.contains("model")
);
}
#[test]
fn title_provider_selection_uses_explicit_settings_without_assistant_fallback() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
upsert_custom_provider(
&paths,
"title-provider",
make_custom_provider_config("Title Provider", "http://localhost:8080/v1", "").unwrap(),
)
.unwrap();
update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
settings.selected_model.provider = Some("assistant-provider".to_string());
settings.selected_model.model = Some("assistant-model".to_string());
settings.session_titles = SessionTitleSettings {
enabled: true,
provider: Some("title-provider".to_string()),
model: Some("title-model".to_string()),
};
})
.unwrap();
env.set_var("MC_PROVIDER", "env-provider");
env.set_var("MC_MODEL", "env-model");
let title = read_settings(&paths)
.unwrap()
.session_titles
.eligible_config()
.unwrap()
.unwrap();
let config =
load_effective_provider_selection(&paths, &title.provider, &title.model).unwrap();
assert_eq!(config.provider.as_deref(), Some("title-provider"));
assert_eq!(config.model.as_deref(), Some("title-model"));
assert!(matches!(config.auth, Some(ProviderCredential::NoAuth)));
}
#[test]
fn hook_settings_defaults_are_inert_redacted_and_warn() {
let settings: Settings = serde_json::from_str("{}").unwrap();
assert!(!settings.hooks.enabled);
assert!(!settings.hooks.show_in_tui);
assert_eq!(settings.hooks.payload, HookPayloadMode::Redacted);
assert_eq!(settings.hooks.failure_policy, HookFailurePolicy::Warn);
assert!(!settings.hooks.provider_context_injection);
assert_eq!(settings.hooks.provider_context_max_bytes, 4096);
assert!(settings.hooks.before_tool.is_empty());
assert!(settings.hooks.after_tool.is_empty());
}
#[test]
fn hook_settings_parse_policies_payloads_filters_and_bounds() {
let settings: Settings = serde_json::from_str(
r#"{
"hooks": {
"enabled": true,
"show_in_tui": true,
"payload": "full",
"timeout_seconds": 10,
"stdout_max_bytes": 1024,
"stderr_max_bytes": 2048,
"failure_policy": "ignore",
"provider_context_injection": true,
"provider_context_max_bytes": 8192,
"before_tool": [
{"label":"gate","command":"printf ok","failure_policy":"block","include_tools":["write"],"exclude_tools":["read"],"provider_context_injection":true}
],
"after_tool": [
{"label":"audit","command":"printf ok","payload":"redacted","failure_policy":"fail","provider_context_injection":false,"provider_context_max_bytes":16384}
]
}
}"#,
)
.unwrap();
assert!(settings.hooks.enabled);
assert!(settings.hooks.show_in_tui);
assert_eq!(settings.hooks.payload, HookPayloadMode::Full);
assert_eq!(settings.hooks.failure_policy, HookFailurePolicy::Ignore);
assert!(settings.hooks.provider_context_injection);
assert_eq!(settings.hooks.provider_context_max_bytes, 8192);
assert_eq!(
settings.hooks.before_tool[0].provider_context_injection,
Some(true)
);
assert_eq!(
settings.hooks.before_tool[0].failure_policy,
Some(HookFailurePolicy::Block)
);
assert!(settings.hooks.before_tool[0].matches_tool("write"));
assert!(!settings.hooks.before_tool[0].matches_tool("read"));
assert_eq!(
settings.hooks.after_tool[0].payload,
Some(HookPayloadMode::Redacted)
);
assert_eq!(
settings.hooks.after_tool[0].failure_policy,
Some(HookFailurePolicy::Fail)
);
assert_eq!(
settings.hooks.after_tool[0].provider_context_injection,
Some(false)
);
assert_eq!(
settings.hooks.after_tool[0].provider_context_max_bytes,
Some(16384)
);
}
#[test]
fn hook_settings_show_in_tui_survives_when_hooks_are_disabled() {
let settings: Settings =
serde_json::from_str(r#"{"hooks":{"enabled":false,"show_in_tui":true}}"#).unwrap();
assert!(!settings.hooks.enabled);
assert!(settings.hooks.show_in_tui);
assert!(settings.hooks.before_tool.is_empty());
assert!(settings.hooks.after_tool.is_empty());
let serialized = serde_json::to_value(&settings).unwrap();
assert_eq!(serialized["automation"]["hooks"]["enabled"], false);
assert_eq!(serialized["automation"]["hooks"]["show_in_tui"], true);
}
#[test]
fn hook_settings_reject_after_block_and_invalid_limits() {
let after_block = serde_json::from_str::<Settings>(
r#"{"hooks":{"enabled":true,"after_tool":[{"command":"printf no","failure_policy":"block"}]}}"#,
)
.unwrap_err()
.to_string();
assert!(after_block.contains("block"), "{after_block}");
let timeout = serde_json::from_str::<Settings>(r#"{"hooks":{"timeout_seconds":0}}"#)
.unwrap_err()
.to_string();
assert!(timeout.contains("timeout_seconds"), "{timeout}");
let limit = serde_json::from_str::<Settings>(r#"{"hooks":{"stdout_max_bytes":0}}"#)
.unwrap_err()
.to_string();
assert!(limit.contains("stdout_max_bytes"), "{limit}");
let provider_limit =
serde_json::from_str::<Settings>(r#"{"hooks":{"provider_context_max_bytes":16385}}"#)
.unwrap_err()
.to_string();
assert!(
provider_limit.contains("provider_context_max_bytes"),
"{provider_limit}"
);
}
#[test]
fn hook_settings_preserve_unknown_fields_on_settings_update() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"future_setting":true,"hooks":{"enabled":false,"show_in_tui":true,"future":{"keep":true},"before_tool":[{"label":"audit","command":"printf ok"}]}}"#,
)
.unwrap();
set_selected_primary_agent(&paths, Some("tars_1")).unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(value["future_setting"], true);
assert_eq!(value["automation"]["hooks"]["enabled"], false);
assert_eq!(value["automation"]["hooks"]["show_in_tui"], true);
assert_eq!(
value["automation"]["hooks"]["before_tool"][0]["label"],
"audit"
);
assert_eq!(value["automation"]["hooks"]["future"]["keep"], true);
assert_eq!(value["agent"]["primary_agent"], "tars_1");
}
#[test]
fn hook_settings_update_known_fields_override_unknown_merge_source() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"hooks":{"enabled":false,"future":{"keep":true}}}"#,
)
.unwrap();
update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
settings.hooks.enabled = true;
})
.unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(value["automation"]["hooks"]["enabled"], true);
assert_eq!(value["automation"]["hooks"]["future"]["keep"], true);
}
#[test]
fn herdr_settings_preserve_unknown_fields_on_settings_update() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"future_setting":true,"integrations":{"herdr":{"enabled":true,"future":"keep"}}}"#,
)
.unwrap();
set_selected_primary_agent(&paths, Some("tars_1")).unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(value["future_setting"], true);
assert_eq!(
value["automation"]["integrations"]["herdr"]["enabled"],
true
);
assert_eq!(
value["automation"]["integrations"]["herdr"]["future"],
"keep"
);
assert_eq!(value["agent"]["primary_agent"], "tars_1");
}
#[test]
fn hook_settings_reject_after_block_after_global_override_resolution() {
let inherited_block = serde_json::from_str::<Settings>(
r#"{"hooks":{"enabled":true,"failure_policy":"block","after_tool":[{"command":"printf no"}]}}"#,
)
.unwrap_err()
.to_string();
assert!(inherited_block.contains("block"), "{inherited_block}");
let explicit_override: Settings = serde_json::from_str(
r#"{"hooks":{"enabled":true,"failure_policy":"block","after_tool":[{"command":"printf ok","failure_policy":"warn"}]}}"#,
)
.unwrap();
assert_eq!(
explicit_override.hooks.after_tool[0].failure_policy,
Some(HookFailurePolicy::Warn)
);
}
#[test]
fn thinking_level_settings_default_valid_invalid_and_persistence() {
let absent: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(absent.selected_model.thinking_level, None);
let valid: Settings =
serde_json::from_str(r#"{"selected_model":{"thinking_level":"high"}}"#).unwrap();
assert_eq!(
valid.selected_model.thinking_level,
Some(ThinkingLevel::High)
);
assert!(
serde_json::to_string(&valid)
.unwrap()
.contains("thinking_level")
);
let error =
serde_json::from_str::<Settings>(r#"{"selected_model":{"thinking_level":"maximum"}}"#)
.unwrap_err()
.to_string();
assert!(error.contains("expected one of"), "{error}");
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"provider":"openai-codex","future_setting":{"keep":true},"auth":{"access":"do-not-copy"}}"#,
)
.unwrap();
set_thinking_level(&paths, ThinkingLevel::Medium).unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(value["agent"]["model"]["thinking_level"], "medium");
assert_eq!(value["future_setting"]["keep"], true);
assert_eq!(value["auth"]["access"], "do-not-copy");
assert!(!value.to_string().contains("sk-secret"));
let config = EffectiveConfig::load(paths, CliConfigOverrides::default()).unwrap();
assert_eq!(config.thinking_level, ThinkingLevel::Medium);
}
#[test]
fn selected_primary_agent_settings_default_null_and_valid_serde() {
let absent: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(absent.selected_primary_agent, None);
let null: Settings = serde_json::from_str(r#"{"selected_primary_agent":null}"#).unwrap();
assert_eq!(null.selected_primary_agent, None);
let valid: Settings =
serde_json::from_str(r#"{"selected_primary_agent":"orchestrator"}"#).unwrap();
assert_eq!(
valid.selected_primary_agent.as_deref(),
Some("orchestrator")
);
let serialized = serde_json::to_string(&valid).unwrap();
assert!(serialized.contains("primary_agent"));
assert!(serialized.contains("orchestrator"));
}
#[test]
fn selected_primary_agent_persistence_preserves_unknown_fields_and_stores_only_id_or_null() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"selected_model":{"provider":"openai-codex"},"future_setting":{"keep":true},"auth":{"accountId":"do-not-copy"}}"#,
)
.unwrap();
set_selected_primary_agent(&paths, Some("tars_1")).unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(value["future_setting"]["keep"], true);
assert_eq!(value["agent"]["model"]["provider"], "openai-codex");
assert_eq!(value["agent"]["primary_agent"], "tars_1");
assert!(!value.to_string().contains("profile body"));
assert!(!value.to_string().contains("sk-secret"));
set_selected_primary_agent(&paths, None).unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(
value
.get("agent")
.and_then(|agent| agent.get("primary_agent")),
Some(&serde_json::Value::Null)
);
assert_eq!(value["future_setting"]["keep"], true);
}
#[test]
fn selected_primary_agent_persistence_rejects_invalid_ids() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let error = set_selected_primary_agent(&paths, Some("bad/id")).unwrap_err();
assert!(error.to_string().contains("primary agent id"), "{error}");
assert!(!paths.settings_file.exists());
}
#[test]
fn custom_provider_base_url_normalizes_api_root_and_rejects_endpoint_urls() {
assert_eq!(
normalize_custom_provider_base_url("https://example.test/v1/").unwrap(),
"https://example.test/v1"
);
assert_eq!(
normalize_custom_provider_base_url("https://example.test/v4").unwrap(),
"https://example.test/v4"
);
assert_eq!(
normalize_custom_provider_base_url("https://example.test/v1").unwrap(),
"https://example.test/v1"
);
assert_eq!(
normalize_custom_provider_base_url("https://api.example.test/api/v2").unwrap(),
"https://api.example.test/api/v2"
);
assert_eq!(
normalize_custom_provider_base_url("https://example.test").unwrap(),
"https://example.test"
);
assert_eq!(
normalize_custom_provider_base_url("http://localhost:11434/v1").unwrap(),
"http://localhost:11434/v1"
);
assert!(normalize_custom_provider_base_url("https://example.test/v1/responses").is_err());
assert!(normalize_custom_provider_base_url("https://example.test/v4/models").is_err());
assert!(
normalize_custom_provider_base_url("https://example.test/api/completions").is_err()
);
assert!(
normalize_custom_provider_base_url("https://example.test/v2/chat/completions").is_err()
);
assert_eq!(
normalize_custom_provider_base_url("http://example.test/v1").unwrap(),
"http://example.test/v1"
);
let userinfo_error =
normalize_custom_provider_base_url("https://user:password@example.test/v1")
.unwrap_err()
.to_string();
assert!(userinfo_error.contains("userinfo"), "{userinfo_error}");
assert!(!userinfo_error.contains("password"), "{userinfo_error}");
assert!(
normalize_custom_provider_base_url("https://example.test/v1?api_key=secret").is_err()
);
}
#[test]
fn persisted_custom_provider_base_url_rejects_userinfo_without_leaking_secret() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"custom_providers":{"bad":{"label":"Bad","base_url":"https://user:password@example.test/v1","api_key_env_var":"CUSTOM_PROVIDER_API_KEY"}}}"#,
)
.unwrap();
write_auth_fixture(&paths.auth_file, "{}");
let error = EffectiveConfig::load(
paths,
CliConfigOverrides {
provider: Some("bad".to_string()),
model: Some("model".to_string()),
..CliConfigOverrides::default()
},
)
.unwrap_err()
.to_string();
assert!(error.contains("custom provider 'bad'"), "{error}");
assert!(error.contains("userinfo"), "{error}");
assert!(!error.contains("password"), "{error}");
}
#[test]
fn custom_provider_id_derives_from_label_and_rejects_reserved() {
assert_eq!(
derive_custom_provider_id("Local Llama").unwrap(),
"local-llama"
);
assert_eq!(
derive_custom_provider_id(" My__Provider!! 1 ").unwrap(),
"my-provider-1"
);
assert!(derive_custom_provider_id("!!!").is_err());
assert!(derive_custom_provider_id("openai").is_err());
assert!(derive_custom_provider_id("openai codex").is_err());
assert!(validate_custom_provider_id("anthropic").is_err());
assert!(validate_custom_provider_id("claude-code").is_err());
}
#[test]
fn retired_claude_code_selection_fails_before_auth_resolution() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"selected_model":{"provider":"claude-code","model":"claude-persisted"}}"#,
)
.unwrap();
write_auth_fixture(&paths.auth_file, "{}");
let persisted_error = EffectiveConfig::load(paths.clone(), CliConfigOverrides::default())
.unwrap_err()
.to_string();
fs::write(&paths.settings_file, "{}").unwrap();
write_auth_fixture(
&paths.auth_file,
r#"{"claude-code":{"type":"oauth","access":"stale-oauth-access"}}"#,
);
let cli_error = EffectiveConfig::load(
paths.clone(),
CliConfigOverrides {
provider: Some("claude-code".to_string()),
model: Some("claude-cli".to_string()),
..CliConfigOverrides::default()
},
)
.unwrap_err()
.to_string();
env.set_var("MC_PROVIDER", "claude-code");
env.set_var("MC_MODEL", "claude-env");
let env_error = EffectiveConfig::load(paths.clone(), CliConfigOverrides::default())
.unwrap_err()
.to_string();
let direct_error =
load_effective_provider_selection(&paths, "claude-code", "claude-direct")
.unwrap_err()
.to_string();
for (error, model) in [
(persisted_error, "claude-persisted"),
(cli_error, "claude-cli"),
(env_error, "claude-env"),
(direct_error, "claude-direct"),
] {
assert!(
error.contains(&format!("stale provider selection 'claude-code/{model}'")),
"{error}"
);
assert!(error.contains(&format!("anthropic/{model}")), "{error}");
assert!(error.contains("ANTHROPIC_API_KEY"), "{error}");
assert!(
error.contains("provider-keyed 'anthropic' API key"),
"{error}"
);
assert!(error.contains("Claude Code OAuth/subscription"), "{error}");
assert!(!error.contains("stale-oauth-access"), "{error}");
}
}
#[test]
fn custom_provider_label_rejects_secret_like_values() {
assert!(
make_custom_provider_config("sk-secret-looking", "http://localhost:8080/v1", "")
.is_err()
);
assert!(make_custom_provider_config("Local Llama", "http://localhost:8080/v1", "").is_ok());
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"custom_providers":{"local":{"label":"sk-secret-looking","base_url":"http://localhost:8080/v1"}}}"#,
)
.unwrap();
write_auth_fixture(&paths.auth_file, "{}");
let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
.unwrap_err()
.to_string();
assert!(error.contains("label"), "{error}");
assert!(!error.contains("sk-secret-looking"), "{error}");
}
#[test]
fn optional_env_var_validation_allows_blank_and_rejects_secret_like_values() {
assert_eq!(validate_optional_env_var_name(" ").unwrap(), None);
assert_eq!(
validate_optional_env_var_name(" LOCAL_PROVIDER_API_KEY ")
.unwrap()
.as_deref(),
Some("LOCAL_PROVIDER_API_KEY")
);
assert!(validate_optional_env_var_name("sk-secret-looking").is_err());
assert!(validate_optional_env_var_name("lowercase").is_err());
}
#[test]
fn custom_provider_settings_reject_invalid_ids() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"custom_providers":{"Bad_ID":{"label":"Bad","base_url":"https://provider.test/v1"}}}"#,
)
.unwrap();
write_auth_fixture(&paths.auth_file, "{}");
let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
.unwrap_err()
.to_string();
assert!(error.contains("custom provider 'Bad_ID'"), "{error}");
assert!(error.contains("provider id"), "{error}");
}
#[test]
fn custom_provider_settings_reject_uppercase_provider_id() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"custom_providers":{"OpenAI":{"label":"Bad","base_url":"https://provider.test/v1"}}}"#,
)
.unwrap();
write_auth_fixture(&paths.auth_file, "{}");
let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
.unwrap_err()
.to_string();
assert!(error.contains("custom provider 'OpenAI'"), "{error}");
assert!(error.contains("provider id"), "{error}");
}
#[test]
fn custom_provider_settings_reject_secret_like_env_var() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1","api_key_env_var":"sk-secret-looking"}}}"#,
)
.unwrap();
write_auth_fixture(&paths.auth_file, "{}");
let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
.unwrap_err()
.to_string();
assert!(error.contains("custom provider 'local'"), "{error}");
assert!(error.contains("api_key_env_var"), "{error}");
assert!(!error.contains("sk-secret-looking"), "{error}");
}
#[test]
fn custom_provider_settings_reject_models_dev_provider_secret_like_values() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1","models_dev_provider":"sk-secret-looking"}}}"#,
)
.unwrap();
write_auth_fixture(&paths.auth_file, "{}");
let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
.unwrap_err()
.to_string();
assert!(error.contains("custom provider 'local'"), "{error}");
assert!(error.contains("models_dev_provider"), "{error}");
assert!(!error.contains("sk-secret-looking"), "{error}");
}
#[test]
fn custom_provider_settings_reject_uppercase_models_dev_provider() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1","models_dev_provider":"OpenAI"}}}"#,
)
.unwrap();
write_auth_fixture(&paths.auth_file, "{}");
let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
.unwrap_err()
.to_string();
assert!(error.contains("custom provider 'local'"), "{error}");
assert!(error.contains("models_dev_provider"), "{error}");
}
#[test]
fn custom_provider_settings_reject_endpoint_urls() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1/responses"}}}"#,
)
.unwrap();
write_auth_fixture(&paths.auth_file, "{}");
let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
.unwrap_err()
.to_string();
assert!(error.contains("custom provider 'local'"), "{error}");
assert!(error.contains("endpoint URL"), "{error}");
assert!(!error.contains("provider.test"), "{error}");
}
#[test]
fn custom_provider_optional_auth_serde_compatibility() {
let env_backed: Settings = serde_json::from_str(
r#"{"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1","api_key_env_var":"LOCAL_API_KEY"}}}"#,
)
.unwrap();
assert_eq!(
env_backed.custom_providers["local"]
.api_key_env_var
.as_deref(),
Some("LOCAL_API_KEY")
);
let no_auth: Settings = serde_json::from_str(
r#"{"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1","models_dev_provider":" openrouter ","extra_models":[" glm-5.2 ","glm-5.2"]}}}"#,
)
.unwrap();
assert_eq!(no_auth.custom_providers["local"].api_key_env_var, None);
assert_eq!(
no_auth.custom_providers["local"]
.models_dev_provider
.as_deref(),
Some("openrouter")
);
assert_eq!(
no_auth.custom_providers["local"].extra_models,
vec!["glm-5.2".to_string(), "glm-5.2".to_string()]
);
assert_eq!(
normalized_extra_models(&no_auth.custom_providers["local"].extra_models).unwrap(),
vec!["glm-5.2".to_string()]
);
assert!(!no_auth.custom_providers["local"].use_responses_endpoint);
let explicit_false: Settings = serde_json::from_str(
r#"{"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1","use_responses_endpoint":false}}}"#,
)
.unwrap();
assert!(!explicit_false.custom_providers["local"].use_responses_endpoint);
let explicit_true: Settings = serde_json::from_str(
r#"{"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1","use_responses_endpoint":true}}}"#,
)
.unwrap();
assert!(explicit_true.custom_providers["local"].use_responses_endpoint);
let serialized_true = serde_json::to_string(&explicit_true).unwrap();
assert!(serialized_true.contains("use_responses_endpoint"));
let invalid_endpoint_mode = serde_json::from_str::<Settings>(
r#"{"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1","use_responses_endpoint":"true"}}}"#,
)
.unwrap_err()
.to_string();
assert!(
invalid_endpoint_mode.contains("invalid type"),
"{invalid_endpoint_mode}"
);
assert!(
invalid_endpoint_mode.contains("boolean"),
"{invalid_endpoint_mode}"
);
let whitespace: Settings = serde_json::from_str(
r#"{"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1","api_key_env_var":" "}}}"#,
)
.unwrap();
assert_eq!(whitespace.custom_providers["local"].api_key_env_var, None);
let serialized = serde_json::to_string(&no_auth).unwrap();
assert!(!serialized.contains("api_key_env_var"));
assert!(serialized.contains("models_dev_provider"));
assert!(serialized.contains("extra_models"));
assert!(!serialized.contains("use_responses_endpoint"));
}
#[test]
fn custom_provider_settings_reject_secret_like_extra_models() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1","extra_models":["sk-secret-looking"]}}}"#,
)
.unwrap();
write_auth_fixture(&paths.auth_file, "{}");
let error = EffectiveConfig::load(paths, CliConfigOverrides::default())
.unwrap_err()
.to_string();
assert!(error.contains("custom provider 'local'"), "{error}");
assert!(error.contains("extra_models"), "{error}");
assert!(!error.contains("sk-secret-looking"), "{error}");
}
#[test]
fn custom_provider_settings_reject_invalid_extra_models() {
for raw in [
r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1","extra_models":[" "]}}}"#,
r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1","extra_models":["bad model"]}}}"#,
] {
let error = parse_validated_settings(raw).unwrap_err().to_string();
assert!(error.contains("extra_models"), "{error}");
assert!(!error.contains("bad model"), "{error}");
}
}
#[test]
fn no_auth_custom_provider_resolves_ready_without_global_fallback() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
upsert_custom_provider(
&paths,
"local-provider",
make_custom_provider_config("Local", "http://localhost:8080/v1", "").unwrap(),
)
.unwrap();
env.set_var("OPENAI_API_KEY", "ignored-openai");
env.set_var("MC_API_KEY", "ignored-mc");
let config = EffectiveConfig::load(
paths,
CliConfigOverrides {
provider: Some("local-provider".into()),
model: Some("model-a".into()),
api_key: Some("ignored-cli".into()),
..CliConfigOverrides::default()
},
)
.unwrap();
assert!(matches!(config.auth, Some(ProviderCredential::NoAuth)));
assert!(config.auth_state().is_ready());
}
#[test]
fn custom_provider_serialized_schema_has_no_dead_catalog_path() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let cfg = make_custom_provider_config(
"Local",
"https://provider.test/v1/",
"CUSTOM_PROVIDER_API_KEY",
)
.unwrap();
upsert_custom_provider(&paths, "local-provider", cfg).unwrap();
let text = fs::read_to_string(&paths.settings_file).unwrap();
assert!(text.contains("custom"));
assert!(text.contains("base_url"));
assert!(text.contains("api_key_env_var"));
assert!(!text.contains("use_responses_endpoint"));
assert!(!text.contains("catalog_path"));
}
#[test]
fn custom_provider_update_preserves_unknown_top_level_settings_fields() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"selected_model":{"provider":"openai-codex"},"future_setting":{"keep":true}}"#,
)
.unwrap();
let cfg = make_custom_provider_config(
"Local",
"https://provider.test/v1",
"CUSTOM_PROVIDER_API_KEY",
)
.unwrap();
upsert_custom_provider(&paths, "local-provider", cfg).unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(value["future_setting"]["keep"], true);
assert_eq!(value["agent"]["model"]["provider"], "openai-codex");
assert_eq!(
value["providers"]["custom"]["local-provider"]["label"],
"Local"
);
}
#[test]
fn custom_provider_auth_reads_named_env_only() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let cfg = make_custom_provider_config(
"Local",
"https://provider.test/v1",
"CUSTOM_PROVIDER_API_KEY",
)
.unwrap();
upsert_custom_provider(&paths, "local-provider", cfg).unwrap();
env.set_var("OPENAI_API_KEY", "ignored-openai");
env.set_var("MC_API_KEY", "ignored-mc");
let config = EffectiveConfig::load(
paths.clone(),
CliConfigOverrides {
provider: Some("local-provider".into()),
model: Some("model-a".into()),
api_key: Some("ignored-cli".into()),
..CliConfigOverrides::default()
},
)
.unwrap();
assert!(config.auth.is_none());
env.set_var("CUSTOM_PROVIDER_API_KEY", "custom-key");
let config = EffectiveConfig::load(
paths,
CliConfigOverrides {
provider: Some("local-provider".into()),
model: Some("model-a".into()),
api_key: Some("ignored-cli".into()),
..CliConfigOverrides::default()
},
)
.unwrap();
assert!(
matches!(config.auth, Some(ProviderCredential::ApiKey { key }) if key == "custom-key")
);
}
#[test]
fn read_auth_missing_file_returns_default() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
assert_eq!(read_auth(&paths).unwrap(), Auth::default());
}
#[cfg(unix)]
#[test]
fn read_auth_rejects_group_or_world_accessible_file_on_unix() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.auth_file, r#"{"api_key":"secret"}"#).unwrap();
set_mode(&paths.auth_file, 0o644);
let error = read_auth(&paths).unwrap_err().to_string();
assert!(error.contains("auth.json"), "{error}");
assert!(error.contains("private/owner-only"), "{error}");
}
#[cfg(unix)]
#[test]
fn read_auth_rejects_insecure_invalid_json_before_parse_on_unix() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.auth_file, "not json").unwrap();
set_mode(&paths.auth_file, 0o664);
let error = read_auth(&paths).unwrap_err().to_string();
assert!(error.contains("private/owner-only"), "{error}");
assert!(!error.contains("expected"), "{error}");
}
#[cfg(unix)]
#[test]
fn read_auth_rejects_symlink_on_unix() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
let target = temp.path().join("target-auth.json");
fs::write(&target, r#"{"api_key":"secret"}"#).unwrap();
set_mode(&target, 0o600);
symlink(&target, &paths.auth_file).unwrap();
let error = read_auth(&paths).unwrap_err().to_string();
assert!(error.contains("auth.json"), "{error}");
assert!(error.contains("symlink"), "{error}");
}
#[cfg(unix)]
#[test]
fn write_auth_uses_private_permissions_on_unix() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
write_auth(&paths, &Auth::default()).unwrap();
let mode = fs::metadata(&paths.auth_file).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn remove_provider_auth_removes_only_selected_provider() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
write_auth_fixture(
&paths.auth_file,
r#"{
"api_key":"legacy-key",
"openai-codex":{"type":"oauth","access":"codex-access","refresh":"codex-refresh","accountId":"acct"},
"other":{"type":"api_key","key":"other-key"}
}"#,
);
let removal =
remove_provider_auth(&paths, crate::providers::OPENAI_CODEX_PROVIDER).unwrap();
let saved = read_auth(&paths).unwrap();
assert!(removal.removed);
assert!(
!saved
.providers
.contains_key(crate::providers::OPENAI_CODEX_PROVIDER)
);
assert!(saved.providers.contains_key("other"));
assert_eq!(saved.api_key.as_deref(), Some("legacy-key"));
}
#[test]
fn remove_missing_provider_invalidates_pending_login_without_changing_credentials() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
write_auth_fixture(&paths.auth_file, r#"{"api_key":"legacy-key"}"#);
let before = read_auth_store(&paths).unwrap();
let removal =
remove_provider_auth(&paths, crate::providers::OPENAI_CODEX_PROVIDER).unwrap();
let after = read_auth_store(&paths).unwrap();
assert!(!removal.removed);
assert_eq!(
after.provider_generation(crate::providers::OPENAI_CODEX_PROVIDER),
before.provider_generation(crate::providers::OPENAI_CODEX_PROVIDER) + 1
);
assert_eq!(before.auth(), after.auth());
assert_eq!(
read_auth(&paths).unwrap().api_key.as_deref(),
Some("legacy-key")
);
}
#[test]
fn remove_provider_auth_respects_mc_home_paths() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("custom-mc-home"));
fs::create_dir_all(&paths.root).unwrap();
write_auth_fixture(
&paths.auth_file,
r#"{"openai-codex":{"type":"oauth","access":"codex-access","accountId":"acct"}}"#,
);
remove_provider_auth(&paths, crate::providers::OPENAI_CODEX_PROVIDER).unwrap();
assert!(paths.auth_file.exists());
assert!(
!read_auth(&paths)
.unwrap()
.providers
.contains_key(crate::providers::OPENAI_CODEX_PROVIDER)
);
}
#[test]
fn remove_provider_auth_malformed_auth_fails_without_write() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
write_auth_fixture(&paths.auth_file, "not json");
let error = remove_provider_auth(&paths, crate::providers::OPENAI_CODEX_PROVIDER)
.unwrap_err()
.to_string();
assert!(
error.contains("expected") || error.contains("key"),
"{error}"
);
assert_eq!(fs::read_to_string(&paths.auth_file).unwrap(), "not json");
}
#[cfg(unix)]
#[test]
fn remove_provider_auth_unsafe_auth_file_fails_without_mutation() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
let original =
r#"{"openai-codex":{"type":"oauth","access":"codex-access","accountId":"acct"}}"#;
fs::write(&paths.auth_file, original).unwrap();
set_mode(&paths.auth_file, 0o644);
let error = remove_provider_auth(&paths, crate::providers::OPENAI_CODEX_PROVIDER)
.unwrap_err()
.to_string();
assert!(error.contains("private/owner-only"), "{error}");
assert_eq!(fs::read_to_string(&paths.auth_file).unwrap(), original);
}
#[test]
fn config_precedence_cli_over_env_over_files() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"provider":"file-provider","model":"file-model"}"#,
)
.unwrap();
write_auth_fixture(&paths.auth_file, r#"{"api_key":"file-key"}"#);
env.set_var("MC_PROVIDER", "env-provider");
env.set_var("MC_MODEL", "env-model");
env.set_var("MC_API_KEY", "env-key");
let config = EffectiveConfig::load(
paths,
CliConfigOverrides {
provider: Some("cli-provider".to_string()),
model: Some("cli-model".to_string()),
api_key: Some("cli-key".to_string()),
..CliConfigOverrides::default()
},
)
.unwrap();
assert_eq!(config.provider.as_deref(), Some("cli-provider"));
assert_eq!(config.model.as_deref(), Some("cli-model"));
assert!(
matches!(config.auth, Some(ProviderCredential::ApiKey { key }) if key == "cli-key")
);
env.remove_var("MC_PROVIDER");
env.remove_var("MC_MODEL");
env.remove_var("MC_API_KEY");
}
#[test]
fn config_precedence_env_over_files() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"provider":"file-provider","model":"file-model"}"#,
)
.unwrap();
write_auth_fixture(&paths.auth_file, r#"{"api_key":"file-key"}"#);
env.set_var("MC_PROVIDER", "env-provider");
env.set_var("MC_MODEL", "env-model");
env.set_var("MC_API_KEY", "env-key");
let config = EffectiveConfig::load(paths, CliConfigOverrides::default()).unwrap();
assert_eq!(config.provider.as_deref(), Some("env-provider"));
assert_eq!(config.model.as_deref(), Some("env-model"));
assert!(
matches!(config.auth, Some(ProviderCredential::ApiKey { key }) if key == "env-key")
);
env.remove_var("MC_PROVIDER");
env.remove_var("MC_MODEL");
env.remove_var("MC_API_KEY");
}
#[test]
fn file_autocomplete_respects_gitignore_defaults_true() {
let settings: Settings = serde_json::from_str("{}").unwrap();
assert!(settings.file_autocomplete_respects_gitignore);
}
#[test]
fn file_autocomplete_respects_gitignore_explicit_false_parses() {
let settings: Settings =
serde_json::from_str(r#"{"file_autocomplete_respects_gitignore":false}"#).unwrap();
assert!(!settings.file_autocomplete_respects_gitignore);
}
#[test]
fn file_autocomplete_respects_gitignore_effective_config_preserves_setting() {
let _env = isolate_env();
let _guard = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"file_autocomplete_respects_gitignore":false}"#,
)
.unwrap();
let config = EffectiveConfig::load(paths, CliConfigOverrides::default()).unwrap();
assert!(!config.file_autocomplete_respects_gitignore);
}
#[test]
fn file_autocomplete_respects_gitignore_provider_selection_preserves_setting() {
let _env = isolate_env();
let _guard = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"file_autocomplete_respects_gitignore":false}"#,
)
.unwrap();
let config = load_effective_provider_selection(&paths, "openai", "gpt-test").unwrap();
assert!(!config.file_autocomplete_respects_gitignore);
}
#[test]
fn settings_no_color_applies_when_env_is_absent() {
let _env = isolate_env();
let _guard = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
let config = EffectiveConfig::load(paths, CliConfigOverrides::default()).unwrap();
assert!(config.no_color);
}
#[test]
fn no_color_env_presence_overrides_settings_false() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.settings_file, r#"{"no_color":false}"#).unwrap();
env.set_var("NO_COLOR", "");
let config = EffectiveConfig::load(paths, CliConfigOverrides::default()).unwrap();
assert!(config.no_color);
env.remove_var("NO_COLOR");
}
#[test]
fn credential_like_settings_fields_are_ignored_for_auth() {
let _env = isolate_env();
let _guard = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{
"selected_model":{"provider":"openai","model":"file-model"},
"api_key":"settings-should-not-win",
"openai_api_key":"settings-should-not-win",
"access":"settings-should-not-win",
"refresh":"settings-should-not-win",
"accountId":"settings-should-not-win",
"openai":{"type":"api_key","key":"settings-should-not-win"},
"openai-codex":{"type":"oauth","access":"settings-should-not-win","accountId":"settings-should-not-win"}
}"#,
)
.unwrap();
let config = EffectiveConfig::load(paths, CliConfigOverrides::default()).unwrap();
assert_eq!(config.provider.as_deref(), Some("openai"));
assert!(config.auth.is_none());
}
#[test]
fn mc_home_selects_runtime_root_before_settings_are_read() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let env_root = temp.path().join("env-root");
fs::create_dir_all(&env_root).unwrap();
fs::write(env_root.join("settings.json"), r#"{"mc_home":"ignored"}"#).unwrap();
env.set_var("MC_HOME", &env_root);
let paths = McPaths::resolve().unwrap();
assert_eq!(paths.root, env_root);
assert_eq!(paths.prompts, env_root.join("prompts"));
env.remove_var("MC_HOME");
}
#[test]
fn mc_home_rejects_empty_relative_and_existing_file_values() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
env.set_var("MC_HOME", "");
let empty = McPaths::resolve().unwrap_err().to_string();
assert!(empty.contains("MC_HOME"), "{empty}");
assert!(empty.contains("empty"), "{empty}");
env.set_var("MC_HOME", "relative-mc-home");
let relative = McPaths::resolve().unwrap_err().to_string();
assert!(relative.contains("MC_HOME"), "{relative}");
assert!(relative.contains("absolute"), "{relative}");
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("not-a-directory");
fs::write(&file_path, "not dir").unwrap();
env.set_var("MC_HOME", &file_path);
let file = McPaths::resolve().unwrap_err().to_string();
assert!(file.contains("MC_HOME"), "{file}");
assert!(file.contains("directory"), "{file}");
}
#[test]
fn mc_home_allows_absolute_missing_directory_without_creating_it() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let missing = temp.path().join("missing-mc-home");
env.set_var("MC_HOME", &missing);
let paths = McPaths::resolve().unwrap();
assert_eq!(paths.root, missing);
assert!(!paths.root.exists());
}
#[test]
fn mc_paths_from_root_includes_prompt_override_directory() {
let root = PathBuf::from("/tmp/mc-test-root");
let paths = McPaths::from_root(root.clone());
assert_eq!(paths.prompts, root.join("prompts"));
assert_eq!(paths.subagents, root.join("subagents"));
}
#[test]
fn anthropic_env_api_key_resolves_for_anthropic_provider() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
env.set_var("ANTHROPIC_API_KEY", "anthropic-env-key");
let config = EffectiveConfig::load(
paths,
CliConfigOverrides {
provider: Some(crate::providers::ANTHROPIC_PROVIDER.to_string()),
model: Some(crate::providers::DEFAULT_ANTHROPIC_MODEL.to_string()),
..CliConfigOverrides::default()
},
)
.unwrap();
assert!(
matches!(config.auth, Some(ProviderCredential::ApiKey { ref key }) if key == "anthropic-env-key")
);
assert!(config.auth_state().is_ready());
}
#[test]
fn anthropic_runtime_auth_tracks_logout_and_preserves_env_key() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
write_auth_fixture(
&paths.auth_file,
r#"{"anthropic":{"type":"api_key","key":"stored-key"}}"#,
);
let config = EffectiveConfig::load(
paths.clone(),
CliConfigOverrides {
provider: Some(crate::providers::ANTHROPIC_PROVIDER.to_string()),
model: Some(crate::providers::DEFAULT_ANTHROPIC_MODEL.to_string()),
..CliConfigOverrides::default()
},
)
.unwrap();
assert_eq!(
config.resolve_provider_auth_for_runtime().unwrap(),
ProviderCredential::ApiKey {
key: "stored-key".into()
}
);
remove_provider_auth(&paths, crate::providers::ANTHROPIC_PROVIDER).unwrap();
assert!(config.resolve_provider_auth_for_runtime().is_err());
env.set_var("ANTHROPIC_API_KEY", "env-key");
assert_eq!(
config.resolve_provider_auth_for_runtime().unwrap(),
ProviderCredential::ApiKey {
key: "env-key".into()
}
);
}
#[test]
fn anthropic_auth_precedence_is_explicit() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
write_auth_fixture(
&paths.auth_file,
r#"{"anthropic":{"type":"api_key","key":"stored-key"}}"#,
);
env.set_var("ANTHROPIC_API_KEY", "env-key");
env.set_var("MC_API_KEY", "ignored-mc");
let config = EffectiveConfig::load(
paths,
CliConfigOverrides {
provider: Some(crate::providers::ANTHROPIC_PROVIDER.to_string()),
model: Some(crate::providers::DEFAULT_ANTHROPIC_MODEL.to_string()),
api_key: Some("ignored-cli".to_string()),
..CliConfigOverrides::default()
},
)
.unwrap();
assert!(
matches!(config.auth, Some(ProviderCredential::ApiKey { key }) if key == "env-key")
);
}
#[test]
fn anthropic_does_not_use_openai_api_key() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
env.set_var("OPENAI_API_KEY", "ignored-openai");
env.set_var("MC_API_KEY", "ignored-mc");
let config = EffectiveConfig::load(
paths,
CliConfigOverrides {
provider: Some(crate::providers::ANTHROPIC_PROVIDER.to_string()),
model: Some(crate::providers::DEFAULT_ANTHROPIC_MODEL.to_string()),
api_key: Some("ignored-cli".to_string()),
..CliConfigOverrides::default()
},
)
.unwrap();
assert!(config.auth.is_none());
let error = config.require_auth().unwrap_err().to_string();
assert!(error.contains("ANTHROPIC_API_KEY"), "{error}");
assert!(!error.contains("ignored"), "{error}");
}
#[test]
fn anthropic_rejects_oauth_and_no_auth() {
assert!(
!AuthState::for_provider(
crate::providers::ANTHROPIC_PROVIDER,
Some(&ProviderCredential::OAuth {
access: "oauth".to_string(),
account_id: None,
}),
)
.is_ready()
);
assert!(
!AuthState::for_provider(
crate::providers::ANTHROPIC_PROVIDER,
Some(&ProviderCredential::NoAuth),
)
.is_ready()
);
}
#[test]
fn anthropic_stored_api_key_resolves_without_other_provider_keys() {
let _env = isolate_env();
let _guard = crate::test_support::env::env_lock();
let auth = Auth {
providers: BTreeMap::from([(
crate::providers::ANTHROPIC_PROVIDER.to_string(),
AuthProviderRecord::ApiKey {
key: "stored-anthropic-key".to_string(),
},
)]),
..Auth::default()
};
let credential = resolve_provider_credential(
crate::providers::ANTHROPIC_PROVIDER,
&auth,
Some("ignored-cli-key".to_string()),
&BTreeMap::new(),
)
.unwrap();
assert_eq!(
credential,
Some(ProviderCredential::ApiKey {
key: "stored-anthropic-key".to_string()
})
);
assert!(
AuthState::for_provider(crate::providers::ANTHROPIC_PROVIDER, credential.as_ref())
.is_ready()
);
}
#[test]
fn custom_provider_auth_readiness_matches_named_env_and_no_auth_modes() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let named = make_custom_provider_config(
"Local AI",
"http://localhost:8080/v1",
"CUSTOM_PROVIDER_API_KEY",
)
.unwrap();
let custom_providers = BTreeMap::from([("local-ai".to_string(), named.clone())]);
let auth = Auth::default();
assert_eq!(
custom_provider_auth_readiness(&named),
CredentialReadiness::Missing
);
assert_eq!(
resolve_provider_credential("local-ai", &auth, None, &custom_providers).unwrap(),
None
);
env.set_var("CUSTOM_PROVIDER_API_KEY", "custom-provider-key");
assert_eq!(
custom_provider_auth_readiness(&named),
CredentialReadiness::Ready
);
let credential =
resolve_provider_credential("local-ai", &auth, None, &custom_providers).unwrap();
assert_eq!(
credential,
Some(ProviderCredential::ApiKey {
key: "custom-provider-key".to_string()
})
);
assert!(
AuthState::for_provider_with_custom("local-ai", credential.as_ref(), &custom_providers)
.is_ready()
);
let no_auth =
make_custom_provider_config("Local No Auth", "http://localhost:8081/v1", "").unwrap();
let no_auth_providers = BTreeMap::from([("local-no-auth".to_string(), no_auth.clone())]);
assert_eq!(
custom_provider_auth_readiness(&no_auth),
CredentialReadiness::Ready
);
let credential =
resolve_provider_credential("local-no-auth", &auth, None, &no_auth_providers).unwrap();
assert_eq!(credential, Some(ProviderCredential::NoAuth));
assert!(
AuthState::for_provider_with_custom(
"local-no-auth",
credential.as_ref(),
&no_auth_providers
)
.is_ready()
);
}
#[test]
fn provider_auth_readiness_enforces_provider_specific_credential_matrix() {
let now = 1_000;
let ready_codex = AuthProviderRecord::OAuth {
access: "access".to_string(),
refresh: None,
expires: Some(now + 3_600),
account_id: Some("account".to_string()),
};
let refreshable_codex = AuthProviderRecord::OAuth {
access: String::new(),
refresh: Some("refresh".to_string()),
expires: Some(now - 1),
account_id: Some("account".to_string()),
};
let expired_without_refresh = AuthProviderRecord::OAuth {
access: "expired".to_string(),
refresh: None,
expires: Some(now - 1),
account_id: Some("account".to_string()),
};
let missing_account = AuthProviderRecord::OAuth {
access: "access".to_string(),
refresh: Some("refresh".to_string()),
expires: Some(now + 3_600),
account_id: None,
};
assert_eq!(
classify_codex_oauth_record("access", None, Some(now + 3_600), Some("account"), now,),
CredentialReadiness::Ready
);
assert_eq!(
classify_provider_auth_record(
crate::providers::OPENAI_CODEX_PROVIDER,
Some(&ready_codex),
now,
),
CredentialReadiness::Ready
);
assert_eq!(
classify_codex_oauth_record("", Some("refresh"), Some(now - 1), Some("account"), now,),
CredentialReadiness::Refreshable
);
assert_eq!(
classify_provider_auth_record(
crate::providers::OPENAI_CODEX_PROVIDER,
Some(&refreshable_codex),
now,
),
CredentialReadiness::Refreshable
);
assert_eq!(
classify_provider_auth_record(
crate::providers::OPENAI_CODEX_PROVIDER,
Some(&expired_without_refresh),
now,
),
CredentialReadiness::Invalid
);
assert_eq!(
classify_provider_auth_record(
crate::providers::OPENAI_CODEX_PROVIDER,
Some(&missing_account),
now,
),
CredentialReadiness::Refreshable
);
assert_eq!(
classify_provider_auth_record(
crate::providers::OPENAI_CODEX_PROVIDER,
Some(&AuthProviderRecord::ApiKey {
key: "not-oauth".to_string(),
}),
now,
),
CredentialReadiness::Invalid
);
assert_eq!(
classify_provider_auth_record(crate::providers::OPENAI_CODEX_PROVIDER, None, now,),
CredentialReadiness::Missing
);
assert_eq!(
classify_provider_auth_record(
crate::providers::ANTHROPIC_PROVIDER,
Some(&AuthProviderRecord::ApiKey {
key: "anthropic-key".to_string(),
}),
now,
),
CredentialReadiness::Ready
);
assert_eq!(
classify_provider_auth_record(
crate::providers::ANTHROPIC_PROVIDER,
Some(&AuthProviderRecord::ApiKey { key: String::new() }),
now,
),
CredentialReadiness::Invalid
);
assert_eq!(
classify_provider_auth_record(
crate::providers::ANTHROPIC_PROVIDER,
Some(&ready_codex),
now,
),
CredentialReadiness::Invalid
);
}
#[test]
fn refreshable_codex_oauth_is_hidden_from_public_auth_state_until_runtime_refresh() {
let mut auth = Auth::default();
auth.providers.insert(
crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: String::new(),
refresh: Some("refresh".to_string()),
expires: Some(1),
account_id: None,
},
);
let credential = resolve_provider_credential(
crate::providers::OPENAI_CODEX_PROVIDER,
&auth,
None,
&BTreeMap::new(),
)
.unwrap();
assert_eq!(credential, None);
assert!(
!AuthState::for_provider(crate::providers::OPENAI_CODEX_PROVIDER, credential.as_ref())
.is_ready()
);
}
#[test]
fn manually_constructed_empty_codex_oauth_is_not_auth_ready() {
let credential = ProviderCredential::OAuth {
access: String::new(),
account_id: Some("account".to_string()),
};
let state =
AuthState::for_provider(crate::providers::OPENAI_CODEX_PROVIDER, Some(&credential));
assert_eq!(
state,
AuthState::Missing {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
}
);
}
#[test]
fn openai_env_and_legacy_api_keys_do_not_create_builtin_auth() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
write_auth_fixture(
&paths.auth_file,
r#"{"api_key":"legacy-key","openai":{"type":"api_key","key":"provider-key"}}"#,
);
env.set_var("OPENAI_API_KEY", "env-openai-key");
let config = EffectiveConfig::load(
paths,
CliConfigOverrides {
provider: Some("openai".to_string()),
..CliConfigOverrides::default()
},
)
.unwrap();
assert!(config.auth_state().credential().is_none());
env.remove_var("OPENAI_API_KEY");
}
#[test]
fn legacy_flat_api_key_is_openai_compatibility_only() {
let _env = isolate_env();
let _guard = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
write_auth_fixture(&paths.auth_file, r#"{"api_key":"legacy-key"}"#);
let openai = EffectiveConfig::load(
paths.clone(),
CliConfigOverrides {
provider: Some("openai".to_string()),
..CliConfigOverrides::default()
},
)
.unwrap();
let codex = EffectiveConfig::load(
paths,
CliConfigOverrides {
provider: Some("openai-codex".to_string()),
..CliConfigOverrides::default()
},
)
.unwrap();
assert!(openai.auth_state().credential().is_none());
assert!(codex.auth.is_none());
}
#[test]
fn openai_codex_resolves_oauth_and_ignores_openai_api_key_env() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
write_auth_fixture(
&paths.auth_file,
r#"{"openai-codex":{"type":"oauth","access":"codex-access","refresh":"refresh","expires":4102444800,"accountId":"acct"}}"#,
);
env.set_var("OPENAI_API_KEY", "must-not-use");
let config = EffectiveConfig::load(
paths,
CliConfigOverrides {
provider: Some("openai-codex".to_string()),
..CliConfigOverrides::default()
},
)
.unwrap();
assert_eq!(
config.auth,
Some(ProviderCredential::OAuth {
access: "codex-access".to_string(),
account_id: Some("acct".to_string())
})
);
env.remove_var("OPENAI_API_KEY");
}
#[test]
fn codex_api_key_is_not_auth_ready() {
let state = AuthState::for_provider(
crate::providers::OPENAI_CODEX_PROVIDER,
Some(&ProviderCredential::ApiKey {
key: "not-for-codex".to_string(),
}),
);
assert_eq!(
state,
AuthState::Missing {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string()
}
);
}
#[test]
fn codex_oauth_is_auth_ready() {
assert!(
AuthState::for_provider(
crate::providers::OPENAI_CODEX_PROVIDER,
Some(&ProviderCredential::OAuth {
access: "codex-access".to_string(),
account_id: Some("acct".to_string()),
}),
)
.is_ready()
);
}
#[test]
fn effective_config_expired_codex_without_refresh_is_not_ready() {
let _env = isolate_env();
let _guard = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
write_auth_fixture(
&paths.auth_file,
&format!(
r#"{{"openai-codex":{{"type":"oauth","access":"expired-access","expires":{},"accountId":"acct"}}}}"#,
chrono::Utc::now().timestamp() - 60
),
);
let config = EffectiveConfig::load(
paths,
CliConfigOverrides {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some(crate::providers::DEFAULT_CODEX_MODEL.to_string()),
..CliConfigOverrides::default()
},
)
.unwrap();
assert!(!config.auth_state().is_ready());
assert!(
config
.require_auth()
.unwrap_err()
.to_string()
.contains("re-login")
);
}
fn fake_account_jwt(payload_json: &str) -> String {
use base64::Engine;
let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json);
format!("{header}.{payload}.")
}
#[test]
fn jwt_account_id_helpers_share_claim_parsing_without_secret_leakage() {
let standard = fake_account_jwt(
r#"{"https://api.openai.com/auth.chatgpt_account_id":"acct_standard"}"#,
);
let nested = fake_account_jwt(
r#"{"https://api.openai.com/auth":{"chatgpt_account_id":"acct_nested"}}"#,
);
let login_alias = fake_account_jwt(r#"{"accountId":"acct_login"}"#);
let missing = fake_account_jwt(r#"{"sub":"user"}"#);
assert_eq!(
extract_chatgpt_account_id_from_jwt(&standard).unwrap(),
"acct_standard"
);
assert_eq!(
extract_chatgpt_account_id_from_jwt(&nested).unwrap(),
"acct_nested"
);
assert_eq!(
extract_oauth_account_id_from_jwt(&login_alias).as_deref(),
Some("acct_login")
);
let error = extract_chatgpt_account_id_from_jwt(&missing)
.unwrap_err()
.to_string();
assert!(
error.contains("missing ChatGPT account id claim"),
"{error}"
);
assert!(!error.contains("acct_"));
}
#[test]
fn missing_auth_diagnostic_uses_loaded_custom_provider_without_global_settings() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let global_root = temp.path().join("global-mc-home");
fs::create_dir_all(&global_root).unwrap();
fs::write(global_root.join("settings.json"), r#"{}"#).unwrap();
env.set_var("MC_HOME", &global_root);
let config = EffectiveConfig {
provider: Some("local-ai".to_string()),
model: Some("model-a".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: BTreeMap::from([(
"local-ai".to_string(),
CustomProviderConfig {
label: "Local AI".to_string(),
base_url: "http://localhost:8080/v1".to_string(),
fast_mode: None,
api_key_env_var: Some("LOCAL_AI_REQUIRED_KEY".to_string()),
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: CustomReasoningProtocol::default(),
extra_models: Vec::new(),
request_headers: BTreeMap::new(),
},
)]),
thinking_level: ThinkingLevel::Default,
auth: None,
paths: McPaths::from_root(temp.path().join("local-config")),
};
let error = config.require_auth().unwrap_err().to_string();
assert!(error.contains("custom provider 'local-ai'"), "{error}");
assert!(error.contains("LOCAL_AI_REQUIRED_KEY"), "{error}");
}
#[test]
fn missing_auth_and_selection_errors_use_resolved_mc_home_paths() {
let _env = isolate_env();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("custom-mc-home"));
fs::create_dir_all(&paths.root).unwrap();
let config = EffectiveConfig::load(
paths.clone(),
CliConfigOverrides {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some(crate::providers::DEFAULT_CODEX_MODEL.to_string()),
..CliConfigOverrides::default()
},
)
.unwrap();
let auth_error = config.require_auth().unwrap_err().to_string();
assert!(
auth_error.contains(&paths.auth_file.display().to_string()),
"{auth_error}"
);
let missing_provider = EffectiveConfig {
provider: Some(String::new()),
model: Some(String::new()),
no_color: true,
file_autocomplete_respects_gitignore: true,
custom_providers: BTreeMap::new(),
thinking_level: ThinkingLevel::Default,
auth: None,
paths: paths.clone(),
}
.validate_provider_ready()
.unwrap_err()
.to_string();
assert!(
missing_provider.contains(&paths.settings_file.display().to_string()),
"{missing_provider}"
);
let missing_model = EffectiveConfig {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some(String::new()),
no_color: true,
file_autocomplete_respects_gitignore: true,
custom_providers: BTreeMap::new(),
thinking_level: ThinkingLevel::Default,
auth: None,
paths: paths.clone(),
}
.validate_provider_ready()
.unwrap_err()
.to_string();
assert!(
missing_model.contains(&paths.settings_file.display().to_string()),
"{missing_model}"
);
}
#[test]
fn color_resolution_precedence_decouples_unicode_from_no_color() {
let _env = isolate_env();
let env = crate::test_support::env::env_lock();
let mut settings = Settings {
no_color: Some(true),
..Settings::default()
};
assert_eq!(
resolve_output_style_for_stdout(&settings, Some(ColorChoice::Always), false),
(true, false)
);
assert_eq!(
resolve_output_style_for_stdout(&settings, Some(ColorChoice::Never), true),
(false, true)
);
assert_eq!(
resolve_output_style_for_stdout(&settings, Some(ColorChoice::Auto), true),
(true, true)
);
settings.no_color = Some(false);
env.set_var("NO_COLOR", "1");
assert_eq!(
resolve_output_style_for_stdout(&settings, None, true),
(false, true)
);
assert_eq!(
resolve_output_style_for_stdout(&settings, Some(ColorChoice::Auto), true),
(true, true)
);
env.remove_var("NO_COLOR");
assert_eq!(
resolve_output_style_for_stdout(&settings, None, true),
(true, true)
);
}
#[test]
fn unknown_provider_missing_auth_message_is_actionable() {
let error = ConfigError::missing_auth("openai").to_string();
assert!(error.contains("missing auth for provider 'openai'"));
assert!(error.contains("configure"));
}
#[test]
fn codex_missing_oauth_message_is_actionable() {
let error = ConfigError::missing_auth(crate::providers::OPENAI_CODEX_PROVIDER).to_string();
assert!(error.contains("missing OAuth auth"));
assert!(error.contains("accountId"));
}
#[test]
fn codex_rejects_cli_api_key_with_actionable_diagnostic() {
let _env = isolate_env();
let _guard = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
let config = EffectiveConfig::load(
paths,
CliConfigOverrides {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some(crate::providers::DEFAULT_CODEX_MODEL.to_string()),
api_key: Some("sk-cli-secret".to_string()),
..CliConfigOverrides::default()
},
)
.unwrap();
let error = config.require_auth().unwrap_err().to_string();
assert!(!config.auth_state().is_ready());
assert!(error.contains("--api-key"));
assert!(error.contains("unsupported for openai-codex"));
assert!(error.contains("OAuth"));
assert!(!error.contains("sk-cli-secret"));
}
#[test]
fn credential_debug_output_is_redacted() {
let auth = Auth {
api_key: Some("sk-flat-secret".to_string()),
providers: BTreeMap::from([
(
"openai".to_string(),
AuthProviderRecord::ApiKey {
key: "sk-provider-secret".to_string(),
},
),
(
"openai-codex".to_string(),
AuthProviderRecord::OAuth {
access: "access-secret".to_string(),
refresh: Some("refresh-secret".to_string()),
expires: Some(123),
account_id: Some("acct-secret".to_string()),
},
),
]),
};
let temp = TempDir::new().unwrap();
let config = EffectiveConfig {
provider: Some("openai-codex".to_string()),
model: Some("model".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
auth: Some(ProviderCredential::OAuth {
access: "effective-access-secret".to_string(),
account_id: Some("effective-acct-secret".to_string()),
}),
paths: McPaths::from_root(temp.path().join("mc")),
};
let debug = format!(
"{auth:?} {:?} {:?} {config:?}",
config.auth.as_ref().unwrap(),
AuthState::for_provider(
crate::providers::OPENAI_CODEX_PROVIDER,
config.auth.as_ref()
)
);
for secret in [
"sk-flat-secret",
"sk-provider-secret",
"access-secret",
"refresh-secret",
"acct-secret",
"effective-access-secret",
"effective-acct-secret",
] {
assert!(!debug.contains(secret), "debug leaked {secret}: {debug}");
}
assert!(debug.contains("<redacted>"));
}
#[test]
fn codex_rejects_api_key_as_ready_auth() {
assert!(
!AuthState::for_provider(
crate::providers::OPENAI_CODEX_PROVIDER,
Some(&ProviderCredential::ApiKey {
key: "sk-test".to_string(),
}),
)
.is_ready()
);
}
#[test]
fn openai_oauth_is_not_auth_ready() {
let state = AuthState::for_provider(
"openai",
Some(&ProviderCredential::OAuth {
access: "codex-access".to_string(),
account_id: Some("acct".to_string()),
}),
);
assert_eq!(
state,
AuthState::Missing {
provider: "openai".to_string()
}
);
}
#[test]
fn disabled_skill_settings_default_and_preserve_unknown_fields() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"future_setting":true,"disabled_skills":[" review ","","plan","review"],"provider":"openai"}"#,
)
.unwrap();
let disabled = disabled_skill_names(&paths).unwrap();
assert_eq!(
disabled,
BTreeSet::from(["plan".to_string(), "review".to_string()])
);
let disabled = set_skill_disabled(&paths, "lint", true).unwrap();
assert!(disabled.contains("lint"));
let disabled = set_skill_disabled(&paths, "plan", false).unwrap();
assert!(!disabled.contains("plan"));
assert!(disabled.contains("review"));
let raw = fs::read_to_string(&paths.settings_file).unwrap();
let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(value["future_setting"], true);
assert_eq!(value["agent"]["model"]["provider"], "openai");
assert_eq!(
value["knowledge"]["skills"]["disabled"],
serde_json::json!(["lint", "review"])
);
assert!(value.get("disabled_skills").is_none());
assert!(raw.contains("review"));
assert!(raw.contains("lint"));
assert!(!raw.contains("api_key"));
assert!(!raw.contains("accountId"));
assert_eq!(paths.auth_file, paths.root.join("auth.json"));
assert!(!paths.auth_file.exists());
}
#[test]
fn disabled_model_settings_scope_and_unknown_fields() {
let temp = TempDir::new().unwrap();
let paths =
McPaths::from_root_and_project_dir(temp.path().join("mc"), temp.path().join("repo"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(temp.path().join("repo")).unwrap();
fs::write(
&paths.settings_file,
r#"{"future_setting":true,"models":{"disabled":[" openai-codex/gpt-a ","","openai-codex/gpt-a"],"future":true}}"#,
)
.unwrap();
let settings = read_settings(&paths).unwrap();
assert_eq!(
disabled_model_ids_from_settings(&settings),
BTreeSet::from(["openai-codex/gpt-a".to_string()])
);
set_model_disabled_for_scope(
&paths,
SettingsScope::Project,
"anthropic/claude-test",
true,
)
.unwrap();
let project_disabled = disabled_names_for_modal_scope(
&paths,
SettingsScope::Project,
SettingsListKind::Models,
)
.unwrap();
assert!(project_disabled.contains("anthropic/claude-test"));
assert!(project_disabled.contains("openai-codex/gpt-a"));
set_model_disabled_for_scope(
&paths,
SettingsScope::Project,
"anthropic/claude-test",
false,
)
.unwrap();
let raw = fs::read_to_string(&paths.settings_file).unwrap();
let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(value["future_setting"], true);
assert_eq!(value["models"]["future"], true);
}
#[test]
fn disabled_skill_settings_empty_list_omitted() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
assert!(disabled_skill_names(&paths).unwrap().is_empty());
set_skill_disabled(&paths, "review", true).unwrap();
set_skill_disabled(&paths, "review", false).unwrap();
let raw = fs::read_to_string(&paths.settings_file).unwrap();
assert!(!raw.contains("disabled_skills"));
assert!(!raw.contains("disabled"));
}
#[test]
fn custom_reasoning_protocol_settings_default_round_trip_invalid_and_schema() {
let absent: Settings = serde_json::from_str(
r#"{"custom_providers":{"local":{"label":"Local","base_url":"https://provider.test/v1"}}}"#,
)
.unwrap();
assert_eq!(
absent.custom_providers["local"].reasoning_protocol,
CustomReasoningProtocol::GptLike
);
let serialized = serde_json::to_value(&absent).unwrap();
assert!(
serialized["providers"]["custom"]["local"]
.get("reasoning_protocol")
.is_none()
);
for (raw, expected) in [
("gpt-like", CustomReasoningProtocol::GptLike),
("anthropic-like", CustomReasoningProtocol::AnthropicLike),
] {
let settings: Settings = serde_json::from_value(serde_json::json!({
"custom_providers": {
"local": {
"label": "Local",
"base_url": "https://provider.test/v1",
"reasoning_protocol": raw
}
}
}))
.unwrap();
assert_eq!(
settings.custom_providers["local"].reasoning_protocol,
expected
);
if expected == CustomReasoningProtocol::AnthropicLike {
assert_eq!(
serde_json::to_value(&settings).unwrap()["providers"]["custom"]["local"]["reasoning_protocol"],
raw
);
} else {
assert!(
serde_json::to_value(&settings).unwrap()["providers"]["custom"]["local"]
.get("reasoning_protocol")
.is_none()
);
}
}
for value in [serde_json::json!("other"), serde_json::json!(true)] {
let error = serde_json::from_value::<Settings>(serde_json::json!({
"custom_providers": {
"local": {
"label": "Local",
"base_url": "https://provider.test/v1",
"reasoning_protocol": value
}
}
}))
.unwrap_err();
assert!(!error.to_string().is_empty());
}
let schema = serde_json::to_value(schemars::schema_for!(Settings)).unwrap();
assert_eq!(
schema["$defs"]["CustomReasoningProtocol"]["enum"],
serde_json::json!(["gpt-like", "anthropic-like"])
);
}
}