magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
use super::*;
use crate::persistence::{CrossProcessFileLock, atomic_write_with_permissions, sync_parent_dir};

#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) struct StoredToken {
    pub(crate) client_id: String,
    pub(crate) access_token: String,
    #[serde(default)]
    pub(crate) refresh_token: Option<String>,
    #[serde(default)]
    pub(crate) expires_at: Option<i64>,
    #[serde(default)]
    pub(crate) granted_scopes: Vec<String>,
    #[serde(default)]
    pub(crate) client_secret: Option<String>,
    #[serde(default)]
    pub(crate) authorization_server: Option<String>,
    #[serde(default)]
    pub(crate) issuer: Option<String>,
    #[serde(default)]
    pub(crate) token_endpoint: Option<String>,
    #[serde(default)]
    pub(crate) resource: Option<String>,
    pub(crate) server_url: String,
    pub(crate) token_received_at: i64,
}

impl fmt::Debug for StoredToken {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("StoredToken")
            .field("client_id", &self.client_id)
            .field("access_token", &"[REDACTED]")
            .field(
                "refresh_token",
                &self.refresh_token.as_ref().map(|_| "[REDACTED]"),
            )
            .field("expires_at", &self.expires_at)
            .field("granted_scopes", &self.granted_scopes)
            .field(
                "client_secret",
                &self.client_secret.as_ref().map(|_| "[REDACTED]"),
            )
            .field("authorization_server", &self.authorization_server)
            .field("issuer", &self.issuer)
            .field("token_endpoint", &self.token_endpoint)
            .field("resource", &self.resource)
            .field("server_url", &self.server_url)
            .field("token_received_at", &self.token_received_at)
            .finish()
    }
}
fn validate_token_server_name(server_name: &str) -> McpResult<()> {
    if server_name.is_empty()
        || server_name.contains("__")
        || !server_name
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
    {
        return Err(McpError::Config(format!(
            "MCP server name '{server_name}' is not a valid token store name"
        )));
    }
    Ok(())
}

pub(super) fn token_file_path(mc_home: &Path, server_name: &str) -> McpResult<PathBuf> {
    validate_token_server_name(server_name)?;
    Ok(mc_home
        .join("mcp-tokens")
        .join(format!("{server_name}.json")))
}
pub(crate) fn read_token(mc_home: &Path, server_name: &str) -> McpResult<Option<StoredToken>> {
    let path = token_file_path(mc_home, server_name)?;
    let _lock = CrossProcessFileLock::acquire(&path).map_err(lock_error)?;
    read_token_unlocked(mc_home, server_name)
}

fn read_token_unlocked(mc_home: &Path, server_name: &str) -> McpResult<Option<StoredToken>> {
    let path = token_file_path(mc_home, server_name)?;
    match read_token_file_text(&path, server_name)? {
        Some(text) => serde_json::from_str(&text).map(Some).map_err(|_| {
            McpError::Config(format!(
                "MCP OAuth token file for server '{server_name}' is corrupt"
            ))
        }),
        None => Ok(None),
    }
}

#[cfg(windows)]
fn validate_token_file_path_before_open(path: &Path) -> McpResult<()> {
    use std::os::windows::fs::MetadataExt;
    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
    match fs::symlink_metadata(path) {
        Ok(metadata) if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 => {
            Err(McpError::Config(
                "MCP OAuth token file must be a regular private file; reparse-point token files are not allowed".to_string(),
            ))
        }
        Ok(_) => Ok(()),
        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(McpError::Transport(format!(
            "failed to stat MCP OAuth token file: {error}"
        ))),
    }
}

#[cfg(not(windows))]
fn validate_token_file_path_before_open(_path: &Path) -> McpResult<()> {
    Ok(())
}

#[cfg(unix)]
fn path_is_symlink(path: &Path) -> bool {
    fs::symlink_metadata(path)
        .map(|metadata| metadata.file_type().is_symlink())
        .unwrap_or(false)
}

#[cfg(all(unix, target_os = "linux"))]
fn o_no_follow() -> i32 {
    0x20000
}

#[cfg(all(unix, not(target_os = "linux")))]
fn o_no_follow() -> i32 {
    0x100
}

#[cfg(unix)]
fn validate_open_token_file(file: &fs::File) -> McpResult<()> {
    use std::os::unix::fs::PermissionsExt;
    let metadata = file.metadata().map_err(|error| {
        McpError::Transport(format!("failed to stat MCP OAuth token file: {error}"))
    })?;
    if !metadata.is_file() {
        return Err(McpError::Config(
            "MCP OAuth token file must be a regular private file".to_string(),
        ));
    }
    if metadata.permissions().mode() & 0o077 != 0 {
        return Err(McpError::Config(
            "MCP OAuth token file permissions must be private/owner-only (0600 or stricter)"
                .to_string(),
        ));
    }
    Ok(())
}

#[cfg(not(unix))]
fn validate_open_token_file(_file: &fs::File) -> McpResult<()> {
    Ok(())
}

fn read_token_file_text(path: &Path, server_name: &str) -> McpResult<Option<String>> {
    validate_token_file_path_before_open(path)?;
    let mut options = fs::OpenOptions::new();
    options.read(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.custom_flags(o_no_follow());
    }
    let mut file = match options.open(path) {
        Ok(file) => file,
        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
        #[cfg(unix)]
        Err(error) if path_is_symlink(path) => {
            let _ = error;
            return Err(McpError::Config(
                "MCP OAuth token file must be a regular private file; symlinked token files are not allowed".to_string(),
            ));
        }
        Err(error) => {
            return Err(McpError::Transport(format!(
                "failed to read MCP OAuth token file for server '{server_name}': {error}"
            )));
        }
    };
    validate_open_token_file(&file)?;
    let mut text = String::new();
    file.read_to_string(&mut text).map_err(|error| {
        McpError::Transport(format!(
            "failed to read MCP OAuth token file for server '{server_name}': {error}"
        ))
    })?;
    Ok(Some(text))
}

pub(crate) fn write_token(mc_home: &Path, server_name: &str, token: &StoredToken) -> McpResult<()> {
    let path = token_file_path(mc_home, server_name)?;
    let _lock = CrossProcessFileLock::acquire(&path).map_err(lock_error)?;
    write_token_unlocked(mc_home, server_name, token)
}

fn write_token_unlocked(mc_home: &Path, server_name: &str, token: &StoredToken) -> McpResult<()> {
    let path = token_file_path(mc_home, server_name)?;
    let bytes = serde_json::to_vec_pretty(token).map_err(|_| {
        McpError::Config(format!(
            "failed to serialize MCP OAuth token for server '{server_name}'"
        ))
    })?;
    atomic_write_with_permissions(&path, &bytes, Some(TOKEN_FILE_MODE)).map_err(|error| {
        McpError::Transport(format!(
            "failed to write MCP OAuth token file for server '{server_name}': {error}"
        ))
    })
}

pub(crate) fn delete_token(mc_home: &Path, server_name: &str) -> McpResult<()> {
    let path = token_file_path(mc_home, server_name)?;
    let _lock = CrossProcessFileLock::acquire(&path).map_err(lock_error)?;
    match fs::remove_file(&path) {
        Ok(()) => sync_parent_after_token_delete(&path, server_name),
        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(McpError::Transport(format!(
            "failed to delete MCP OAuth token file for server '{server_name}': {error}"
        ))),
    }
}

fn sync_parent_after_token_delete(path: &Path, server_name: &str) -> McpResult<()> {
    let parent = path.parent().ok_or_else(|| {
        McpError::Transport(format!(
            "failed to sync parent directory after deleting MCP OAuth token file for server '{server_name}': token path has no parent"
        ))
    })?;
    sync_parent_dir(parent).map_err(|error| {
        McpError::Transport(format!(
            "failed to sync parent directory after deleting MCP OAuth token file for server '{server_name}': {error}"
        ))
    })
}

pub(super) fn read_token_locked(
    mc_home: &Path,
    server_name: &str,
) -> McpResult<Option<StoredToken>> {
    with_token_lock(mc_home, server_name, || {
        read_token_unlocked(mc_home, server_name)
    })
}

pub(super) fn write_token_if_unchanged(
    mc_home: &Path,
    server_name: &str,
    expected: &StoredToken,
    next: &StoredToken,
) -> McpResult<bool> {
    with_token_lock(mc_home, server_name, || {
        let current = read_token_unlocked(mc_home, server_name)?;
        if current.as_ref() != Some(expected) {
            return Ok(false);
        }
        write_token_unlocked(mc_home, server_name, next)?;
        Ok(true)
    })
}

fn with_token_lock<T>(
    mc_home: &Path,
    server_name: &str,
    f: impl FnOnce() -> McpResult<T>,
) -> McpResult<T> {
    let path = token_file_path(mc_home, server_name)?;
    let _lock = CrossProcessFileLock::acquire(&path).map_err(lock_error)?;
    f()
}

pub(super) fn validate_token_url(token: &StoredToken, current_url: &str) -> bool {
    token.server_url == current_url
}

fn lock_error(error: anyhow::Error) -> McpError {
    McpError::Transport(format!("failed to lock MCP OAuth token file: {error}"))
}