magi-code 0.97.5

Repository-aware CLI coding agent for terminal work
Documentation
use std::{collections::BTreeMap, net::IpAddr};

use super::{
    agent::{
        AutoCompactionSettings, MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES,
        MAX_TUI_SUBAGENT_CARD_ROWS, MIN_TUI_SUBAGENT_CARD_ROWS, SubagentsSettings, TuiSettings,
    },
    core::Settings,
    providers::{MAX_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS, ProviderStreamSettings},
    services::{
        LspSettings, MAX_LSP_DIAGNOSTICS_WAIT_MS, MAX_LSP_IDLE_SHUTDOWN_MINUTES,
        MAX_MCP_TIMEOUT_SECONDS, McpHttpServerConfig, McpOAuthConfig, McpServerConfig,
        McpServersSettings,
    },
};
use crate::config::custom_provider_config::validate_custom_provider_settings;

pub(crate) fn validate_settings(settings: &Settings) -> anyhow::Result<()> {
    validate_custom_provider_settings(settings)?;
    validate_context_model_overrides(settings.context.as_ref())?;
    validate_mcp_servers_settings(&settings.mcp_servers)?;
    validate_lsp_settings(&settings.lsp)?;
    settings.compaction.auto.validate()?;
    settings.summarizer.validate()?;
    settings.side.validate()?;
    settings.provider_stream.validate()?;
    settings.subagents.validate()?;
    settings.tui.validate()?;
    settings.tools.code_mode.validate()?;
    anyhow::ensure!(
        !settings
            .tools
            .disabled
            .iter()
            .any(|name| name.trim() == "code_mode"),
        "use capabilities.tools.code_mode.enabled instead of disabling code_mode in tools.disabled"
    );
    for (name, threshold) in [
        (
            "internal_tooling.jev.humanize_protection.content_threshold",
            settings.jev.humanize_protection.content_threshold,
        ),
        (
            "internal_tooling.jev.humanize_protection.humanized_threshold",
            settings.jev.humanize_protection.humanized_threshold,
        ),
        (
            "internal_tooling.jev.skill_suggestion.gate_threshold",
            settings.jev.skill_suggestion.gate_threshold,
        ),
        (
            "internal_tooling.jev.skill_suggestion.fit_threshold",
            settings.jev.skill_suggestion.fit_threshold,
        ),
        (
            "internal_tooling.jev.compaction_timing.threshold",
            settings.jev.compaction_timing.threshold,
        ),
    ] {
        anyhow::ensure!(
            (0.0..=1.0).contains(&threshold),
            "{name} must be between 0 and 1"
        );
    }
    anyhow::ensure!(
        (1..=99).contains(&settings.jev.compaction_timing.soft_threshold_percent),
        "internal_tooling.jev.compaction_timing.soft_threshold_percent must be between 1 and 99"
    );
    let completion = &settings.jev.completion_verification;
    anyhow::ensure!(
        (0.0..=1.0).contains(&completion.threshold),
        "internal_tooling.jev.completion_verification.threshold must be between 0 and 1"
    );
    anyhow::ensure!(
        completion.max_state_bytes > 0,
        "internal_tooling.jev.completion_verification.max_state_bytes must be positive"
    );
    anyhow::ensure!(
        settings.jev.enabled || !completion.enabled,
        "internal_tooling.jev.completion_verification.enabled requires internal_tooling.jev.enabled"
    );

    anyhow::ensure!(
        settings.jev.prompt_injection_protection.max_assessed_bytes > 0,
        "internal_tooling.jev.prompt_injection_protection.max_assessed_bytes must be positive"
    );

    for (name, enabled) in [
        (
            "internal_tooling.jev.bash_file_tracking.enabled",
            settings.jev.bash_file_tracking.enabled,
        ),
        (
            "internal_tooling.jev.skill_suggestion.enabled",
            settings.jev.skill_suggestion.enabled,
        ),
        (
            "internal_tooling.jev.compaction_timing.enabled",
            settings.jev.compaction_timing.enabled,
        ),
    ] {
        anyhow::ensure!(
            settings.jev.enabled || !enabled,
            "{name} requires internal_tooling.jev.enabled"
        );
    }
    if settings.jev.enabled {
        crate::typesafe::TypeSafeClient::from_environment()?;
    }
    Ok(())
}

fn validate_context_model_overrides(
    context: Option<&crate::context::ContextBudget>,
) -> anyhow::Result<()> {
    let Some(context) = context else {
        return Ok(());
    };
    for (key, model_override) in &context.model_overrides {
        let Some((provider, model)) = key.split_once('/') else {
            anyhow::bail!("context.model_overrides key '{key}' must use provider/model");
        };
        if provider.is_empty() || model.is_empty() {
            anyhow::bail!("context.model_overrides key '{key}' must use non-empty provider/model");
        }
        if key
            .chars()
            .any(|ch| ch.is_ascii_whitespace() || ch.is_ascii_control())
        {
            anyhow::bail!(
                "context.model_overrides key '{key}' must not contain ASCII whitespace or control characters"
            );
        }
        if model_override.is_empty() {
            anyhow::bail!(
                "context.model_overrides.{key} must set at least one of max_tokens, reserve_tokens, or keep_recent_tokens"
            );
        }
    }
    Ok(())
}

pub(crate) fn validate_lsp_settings(settings: &LspSettings) -> anyhow::Result<()> {
    if !(1..=MAX_LSP_DIAGNOSTICS_WAIT_MS).contains(&settings.diagnostics_wait_ms) {
        anyhow::bail!(
            "lsp.diagnostics_wait_ms must be between 1 and {MAX_LSP_DIAGNOSTICS_WAIT_MS} milliseconds"
        );
    }
    if !(1..=MAX_LSP_IDLE_SHUTDOWN_MINUTES).contains(&settings.idle_shutdown_minutes) {
        anyhow::bail!(
            "lsp.idle_shutdown_minutes must be between 1 and {MAX_LSP_IDLE_SHUTDOWN_MINUTES} minutes"
        );
    }
    for (name, config) in &settings.servers {
        validate_lsp_server_name(name)?;
        if config.command.trim().is_empty() {
            anyhow::bail!("lsp.servers.{name}.command must not be empty");
        }
    }
    Ok(())
}

fn validate_lsp_server_name(name: &str) -> anyhow::Result<()> {
    if name.trim().is_empty() {
        anyhow::bail!("lsp server name must not be empty");
    }
    if name.contains("__") {
        anyhow::bail!("lsp server name '{name}' must not contain '__'");
    }
    Ok(())
}

pub(crate) fn validate_mcp_servers_settings(servers: &McpServersSettings) -> anyhow::Result<()> {
    for (name, config) in servers {
        validate_mcp_server_name(name)?;
        if config.enabled() {
            validate_mcp_server(name, config)?;
        }
    }
    Ok(())
}

pub(super) fn validate_mcp_server(name: &str, config: &McpServerConfig) -> anyhow::Result<()> {
    match config {
        McpServerConfig::Stdio(stdio) => {
            if stdio.command.trim().is_empty() {
                anyhow::bail!("mcp_servers.{name}.command must not be empty");
            }
            validate_mcp_timeout(name, stdio.timeout)
        }
        McpServerConfig::Http(http) => validate_mcp_http_server(name, http),
    }
}

fn validate_mcp_timeout(name: &str, timeout: Option<u64>) -> anyhow::Result<()> {
    if let Some(timeout) = timeout
        && !(1..=MAX_MCP_TIMEOUT_SECONDS).contains(&timeout)
    {
        anyhow::bail!(
            "mcp_servers.{name}.timeout must be between 1 and {MAX_MCP_TIMEOUT_SECONDS} seconds"
        );
    }
    Ok(())
}

fn validate_mcp_http_server(name: &str, config: &McpHttpServerConfig) -> anyhow::Result<()> {
    validate_mcp_http_url(name, &config.url)?;
    validate_mcp_timeout(name, config.timeout)?;
    if let Some(oauth) = &config.oauth {
        validate_mcp_oauth_config(name, oauth, &config.headers)?;
    }
    for (header_name, header_value) in &config.headers {
        validate_mcp_http_header_name(name, header_name)?;
        if reqwest::header::HeaderValue::from_str(header_value).is_err() {
            anyhow::bail!(
                "mcp_servers.{name}.headers.{header_name} contains an invalid header value"
            );
        }
    }
    Ok(())
}

fn validate_mcp_oauth_config(
    name: &str,
    oauth: &McpOAuthConfig,
    headers: &BTreeMap<String, String>,
) -> anyhow::Result<()> {
    for header_name in headers.keys() {
        if header_name.eq_ignore_ascii_case("authorization")
            || header_name.eq_ignore_ascii_case("proxy-authorization")
        {
            anyhow::bail!(
                "mcp_servers.{name}.headers.{header_name} must not be configured when mcp_servers.{name}.oauth is configured"
            );
        }
    }
    if let Some(client_id) = &oauth.client_id
        && client_id.trim().is_empty()
    {
        anyhow::bail!("mcp_servers.{name}.oauth.client_id must not be empty");
    }
    for scope in &oauth.scopes {
        if scope.trim().is_empty()
            || scope
                .bytes()
                .any(|byte| !byte.is_ascii() || byte.is_ascii_control())
        {
            anyhow::bail!(
                "mcp_servers.{name}.oauth.scopes entries must be non-empty printable ASCII"
            );
        }
    }
    if let Some(url) = &oauth.authorization_server {
        validate_mcp_http_url_field(name, "oauth.authorization_server", url)?;
    }
    Ok(())
}

fn validate_mcp_http_url(name: &str, url: &str) -> anyhow::Result<()> {
    validate_mcp_http_url_field(name, "url", url)
}

pub(crate) fn validate_mcp_http_url_field(
    name: &str,
    field: &str,
    url: &str,
) -> anyhow::Result<()> {
    let parsed = reqwest::Url::parse(url)
        .map_err(|_| anyhow::anyhow!("mcp_servers.{name}.{field} must be an absolute HTTP URL"))?;
    if !parsed.username().is_empty() || parsed.password().is_some() {
        anyhow::bail!("mcp_servers.{name}.{field} must not contain credentials");
    }
    match parsed.scheme() {
        "https" => Ok(()),
        "http" if is_loopback_http_host(parsed.host_str()) => Ok(()),
        "http" => anyhow::bail!(
            "mcp_servers.{name}.{field} must use https; http is allowed only for loopback hosts"
        ),
        _ => anyhow::bail!("mcp_servers.{name}.{field} must use http or https"),
    }
}

fn is_loopback_http_host(host: Option<&str>) -> bool {
    match host {
        Some("localhost") => true,
        Some(host) => host
            .trim_matches(['[', ']'])
            .parse::<IpAddr>()
            .is_ok_and(|ip| ip.is_loopback()),
        None => false,
    }
}

fn validate_mcp_http_header_name(server_name: &str, header_name: &str) -> anyhow::Result<()> {
    if header_name.is_empty() {
        anyhow::bail!("mcp_servers.{server_name}.headers contains an empty header name");
    }
    if header_name
        .bytes()
        .any(|byte| !byte.is_ascii() || byte.is_ascii_control() || byte == b':' || byte == b' ')
    {
        anyhow::bail!(
            "mcp_servers.{server_name}.headers.{header_name} must be visible ASCII without colon, spaces, or control characters"
        );
    }
    Ok(())
}

pub(crate) fn validate_mcp_server_name(name: &str) -> anyhow::Result<()> {
    if name.is_empty() {
        anyhow::bail!("mcp server name must not be empty");
    }
    if name.contains("__") {
        anyhow::bail!("mcp server name '{name}' must not contain '__'");
    }
    if name.ends_with('_') {
        anyhow::bail!(
            "mcp server name '{name}' must not end with '_' because mcp__<server>__<tool> would be ambiguous; choose a name that does not end with '_'"
        );
    }
    if !name
        .bytes()
        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
    {
        anyhow::bail!(
            "mcp server name '{name}' must contain only ASCII letters, digits, '_' or '-'"
        );
    }
    Ok(())
}

impl ProviderStreamSettings {
    fn validate(&self) -> anyhow::Result<()> {
        validate_provider_stream_timeout(
            "provider_stream.semantic_progress_timeout_seconds",
            self.semantic_progress_timeout_seconds,
        )?;
        validate_provider_stream_timeout(
            "provider_stream.subagent_semantic_progress_timeout_seconds",
            self.subagent_semantic_progress_timeout_seconds,
        )
    }
}

fn validate_provider_stream_timeout(field: &str, value: Option<u64>) -> anyhow::Result<()> {
    if let Some(value) = value
        && !(1..=MAX_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS).contains(&value)
    {
        anyhow::bail!(
            "{field} must be between 1 and {MAX_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS} seconds"
        );
    }
    Ok(())
}

impl SubagentsSettings {
    fn validate(&self) -> anyhow::Result<()> {
        if let Some(retries) = self.schema_validation_max_retries
            && retries > MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES
        {
            anyhow::bail!(
                "subagents.schema_validation_max_retries must be between 0 and {MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES}"
            );
        }
        Ok(())
    }
}

impl TuiSettings {
    fn validate(&self) -> anyhow::Result<()> {
        if let Some(rows) = self.subagent_card_rows
            && !(MIN_TUI_SUBAGENT_CARD_ROWS..=MAX_TUI_SUBAGENT_CARD_ROWS).contains(&rows)
        {
            anyhow::bail!(
                "tui.subagent_card_rows must be between {MIN_TUI_SUBAGENT_CARD_ROWS} and {MAX_TUI_SUBAGENT_CARD_ROWS}"
            );
        }
        Ok(())
    }
}

impl AutoCompactionSettings {
    fn validate(&self) -> anyhow::Result<()> {
        if self.enabled && self.threshold_percent.is_none() && self.threshold_tokens.is_none() {
            anyhow::bail!(
                "compaction.auto.enabled requires at least one of threshold_percent or threshold_tokens"
            );
        }
        if let Some(percent) = self.threshold_percent
            && !(1..=100).contains(&percent)
        {
            anyhow::bail!("compaction.auto.threshold_percent must be between 1 and 100");
        }
        if let Some(tokens) = self.threshold_tokens
            && tokens == 0
        {
            anyhow::bail!("compaction.auto.threshold_tokens must be greater than 0");
        }
        Ok(())
    }
}