magi-code 0.102.0

Repository-aware CLI coding agent for terminal work
Documentation
//! Claude subscription quota: CLI-owned credentials, never persisted or logged here.
#[cfg(target_os = "macos")]
use std::process::{Command, Stdio};
use std::time::Duration;

use reqwest::{blocking::Client, redirect::Policy};
use serde::Deserialize;

use crate::support::http_body::read_bounded_response_text;
#[cfg(target_os = "macos")]
use crate::{
    cancellation::AgentCancellation,
    tools::process::{BoundedChildProcessLimits, run_bounded_child_process},
};

const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
const AUTH_ERROR: &str = "Claude usage authentication failed; run `claude auth login`";

#[derive(Debug, Deserialize)]
struct CredentialFile {
    #[serde(rename = "claudeAiOauth")]
    oauth: OAuthToken,
}

#[derive(Debug, Deserialize)]
struct OAuthToken {
    #[serde(rename = "accessToken")]
    access: String,
}

#[derive(Clone, Debug, Deserialize)]
pub(crate) struct ClaudeUsage {
    pub(crate) five_hour: Option<ClaudeWindow>,
    pub(crate) seven_day: Option<ClaudeWindow>,
}

#[derive(Clone, Debug, Deserialize)]
pub(crate) struct ClaudeWindow {
    pub(crate) utilization: f64,
    pub(crate) resets_at: Option<String>,
}

/// Hash the CLI-reported account, not its rotating OAuth token.
/// `status` is a signed-in `claude auth status --json` result.
pub(crate) fn connected_account(status: &serde_json::Value) -> Option<[u8; 32]> {
    use sha2::{Digest, Sha256};
    let email = status.get("email")?.as_str()?.trim();
    let org = status
        .get("orgId")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("");
    if email.is_empty() {
        return None;
    }
    Some(Sha256::digest(format!("{email}\0{org}").as_bytes()).into())
}

/// Blocking: call only on a worker. Read credentials from CLI-owned storage, never magi-code auth.
/// `status` is the caller's signed-in probe; it is not re-probed here.
pub(crate) fn load_claude_usage(status: &serde_json::Value) -> Result<ClaudeUsage, String> {
    let token = read_cli_token(status)?;
    let client = Client::builder()
        .timeout(Duration::from_secs(15))
        .connect_timeout(Duration::from_secs(15))
        .redirect(Policy::none())
        .build()
        .map_err(|_| "Could not create Claude usage HTTP client".to_owned())?;
    let response = client
        .get(USAGE_URL)
        .bearer_auth(&token)
        .header("anthropic-beta", "oauth-2025-04-20")
        .header("Accept", "application/json")
        .header("Cache-Control", "no-cache")
        .send()
        .map_err(|error| {
            if error.is_timeout() {
                "Claude usage request timed out"
            } else {
                "Claude usage request failed"
            }
            .to_owned()
        })?;
    if response.status() == reqwest::StatusCode::UNAUTHORIZED
        || response.status() == reqwest::StatusCode::FORBIDDEN
    {
        return Err(AUTH_ERROR.into());
    }
    if !response.status().is_success() {
        return Err(format!(
            "Claude usage request failed (HTTP {})",
            response.status().as_u16()
        ));
    }
    let body = read_bounded_response_text(response, 256 * 1024).map_err(|_| {
        "Could not read Claude usage response within size and time limits".to_owned()
    })?;
    parse_usage(&body)
}

fn parse_usage(body: &str) -> Result<ClaudeUsage, String> {
    let usage: ClaudeUsage =
        serde_json::from_str(body).map_err(|_| "Invalid Claude usage response".to_owned())?;
    if usage.five_hour.is_none() && usage.seven_day.is_none() {
        return Err("Claude usage response has no quota windows".into());
    }
    if [usage.five_hour.as_ref(), usage.seven_day.as_ref()]
        .into_iter()
        .flatten()
        .any(|window| !window.utilization.is_finite() || window.utilization < 0.0)
    {
        return Err("Invalid Claude usage percentage".into());
    }
    Ok(usage)
}

fn read_cli_token(status: &serde_json::Value) -> Result<String, String> {
    #[cfg(target_os = "macos")]
    {
        let mut command = Command::new("security");
        command
            .args([
                "find-generic-password",
                "-s",
                "Claude Code-credentials",
                "-w",
            ])
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        use std::os::unix::process::CommandExt;
        command.process_group(0);
        let child = command.spawn().map_err(|_| AUTH_ERROR.to_owned())?;
        let output = run_bounded_child_process(
            child,
            BoundedChildProcessLimits {
                stdout_max_bytes: 64 * 1024,
                stderr_max_bytes: 1024,
                timeout: Duration::from_secs(3),
                poll_interval: Duration::from_millis(20),
            },
            &AgentCancellation::default(),
        )
        .map_err(|_| AUTH_ERROR.to_owned())?;
        if output.timed_out || output.stdout_truncated || output.cleanup_warning.is_some() {
            return Err(AUTH_ERROR.into());
        }
        if output.status.is_some_and(|status| status.success()) {
            return parse_token(&output.stdout);
        }
        // Keychain may be empty: the CLI then uses its credentials file.
        if output.status.and_then(|status| status.code()) != Some(44) {
            return Err(AUTH_ERROR.into());
        }
    }
    let directory = status
        .get("configDirectory")
        .and_then(serde_json::Value::as_str)
        .filter(|path| !path.is_empty())
        .ok_or(AUTH_ERROR)?;
    let path = std::path::Path::new(directory).join(".credentials.json");
    parse_token(&read_private_credentials_file(&path).ok_or(AUTH_ERROR)?)
}

/// Opens once without following symlinks, then validates and reads that same handle,
/// so a swapped path cannot bypass the type, size and permission checks.
fn read_private_credentials_file(path: &std::path::Path) -> Option<String> {
    use std::io::Read;
    const MAX_CREDENTIALS_BYTES: u64 = 64 * 1024;
    let mut options = std::fs::OpenOptions::new();
    options.read(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        // O_NONBLOCK keeps a FIFO planted at this path from blocking open before the
        // regular-file check; it does not change reads of regular files.
        options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
    }
    let file = options.open(path).ok()?;
    let metadata = file.metadata().ok()?;
    if !metadata.is_file() || metadata.len() > MAX_CREDENTIALS_BYTES {
        return None;
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if metadata.permissions().mode() & 0o077 != 0 {
            return None;
        }
    }
    let mut text = String::new();
    file.take(MAX_CREDENTIALS_BYTES + 1)
        .read_to_string(&mut text)
        .ok()?;
    (text.len() as u64 <= MAX_CREDENTIALS_BYTES).then_some(text)
}

fn parse_token(text: &str) -> Result<String, String> {
    let credentials: CredentialFile =
        serde_json::from_str(text).map_err(|_| AUTH_ERROR.to_owned())?;
    let access = credentials.oauth.access;
    if access.trim().is_empty() {
        return Err(AUTH_ERROR.into());
    }
    Ok(access)
}

pub(crate) fn quota_label(
    usage: &ClaudeUsage,
    now: chrono::DateTime<chrono::Utc>,
) -> Option<String> {
    let (window, suffix) = usage
        .five_hour
        .as_ref()
        .map(|window| (window, "5h"))
        .or_else(|| usage.seven_day.as_ref().map(|window| (window, "w")))?;
    let reset = if suffix == "5h" {
        window.resets_at.as_deref()
    } else {
        None
    }
    .and_then(|timestamp| chrono::DateTime::parse_from_rfc3339(timestamp).ok())
    .map(|reset| reset.signed_duration_since(now).num_seconds())
    .filter(|&seconds| seconds > 0)
    .map(|seconds| {
        let minutes = (seconds + 59) / 60;
        if minutes < 60 {
            format!("{minutes}m")
        } else {
            format!("{}h{}m", minutes / 60, minutes % 60)
        }
    });
    Some(format!(
        "Claude {:.0}/100%({})",
        window.utilization,
        reset.as_deref().unwrap_or(suffix)
    ))
}