capo-agent 0.5.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
//! Credential storage (`~/.capo/agent/auth.json`).
//!
//! JSON map of provider name → credential. Separate file (mode `0600`
//! on Unix) so credentials don't share fate with general settings.
//!
//! M3 includes the reader + a writer helper. M4 wires `capo auth login`.

use std::collections::HashMap;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::error::{AppError, Result};

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Auth(pub HashMap<String, ProviderAuth>);

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ProviderAuth {
    ApiKey { key: String },
}

impl Auth {
    pub fn load(agent_dir: &Path) -> Result<Self> {
        load(agent_dir)
    }

    /// Look up an API key for `provider`. Returns `None` if not configured
    /// or if the credential is of a different type.
    pub fn api_key(&self, provider: &str) -> Option<&str> {
        match self.0.get(provider)? {
            ProviderAuth::ApiKey { key } => Some(key.as_str()),
        }
    }
}

/// Load `auth.json` from the per-user agent directory.
pub fn load(agent_dir: &Path) -> Result<Auth> {
    let path = agent_dir.join("auth.json");
    load_from(&path)
}

/// Load from an explicit path. Returns `Auth::default()` if the file is missing.
pub fn load_from(path: &Path) -> Result<Auth> {
    if !path.exists() {
        return Ok(Auth::default());
    }
    let raw = std::fs::read_to_string(path)
        .map_err(|err| AppError::Config(format!("failed to read {}: {err}", path.display())))?;
    serde_json::from_str(&raw)
        .map_err(|err| AppError::Config(format!("failed to parse {}: {err}", path.display())))
}

/// Write `auth.json` with restrictive permissions (`0600` on Unix; default ACL on Windows).
/// Used by M4's `capo auth login`; exposed in M3 so the mode-check test can exercise it.
pub fn save_with_mode(path: &Path, auth: &Auth) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|err| {
            AppError::Config(format!("failed to mkdir {}: {err}", parent.display()))
        })?;
    }
    let json = serde_json::to_string_pretty(auth)
        .map_err(|err| AppError::Config(format!("failed to serialize auth: {err}")))?;
    write_secret_file(path, &json)?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let perms = std::fs::Permissions::from_mode(0o600);
        std::fs::set_permissions(path, perms).map_err(|err| {
            AppError::Config(format!("failed to chmod {}: {err}", path.display()))
        })?;
    }
    Ok(())
}

#[cfg(unix)]
fn write_secret_file(path: &Path, json: &str) -> Result<()> {
    use std::io::Write;
    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};

    if path.exists() {
        let perms = std::fs::Permissions::from_mode(0o600);
        std::fs::set_permissions(path, perms).map_err(|err| {
            AppError::Config(format!("failed to chmod {}: {err}", path.display()))
        })?;
    }
    let mut file = std::fs::OpenOptions::new()
        .write(true)
        .create(true)
        .truncate(true)
        .mode(0o600)
        .open(path)
        .map_err(|err| AppError::Config(format!("failed to write {}: {err}", path.display())))?;
    file.write_all(json.as_bytes())
        .map_err(|err| AppError::Config(format!("failed to write {}: {err}", path.display())))
}

#[cfg(not(unix))]
fn write_secret_file(path: &Path, json: &str) -> Result<()> {
    std::fs::write(path, json)
        .map_err(|err| AppError::Config(format!("failed to write {}: {err}", path.display())))
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn temp_dir() -> TempDir {
        match tempfile::tempdir() {
            Ok(dir) => dir,
            Err(err) => panic!("tempdir failed: {err}"),
        }
    }

    #[test]
    fn missing_auth_file_returns_default() {
        let dir = temp_dir();
        let a = match Auth::load(dir.path()) {
            Ok(auth) => auth,
            Err(err) => panic!("load failed: {err}"),
        };
        assert!(a.0.is_empty());
        assert!(a.api_key("anthropic").is_none());
    }

    #[test]
    fn round_trip_api_key() {
        let dir = temp_dir();
        let path = dir.path().join("auth.json");
        let mut a = Auth::default();
        a.0.insert(
            "anthropic".into(),
            ProviderAuth::ApiKey {
                key: "sk-ant-test".into(),
            },
        );
        if let Err(err) = save_with_mode(&path, &a) {
            panic!("save failed: {err}");
        }

        let loaded = match load_from(&path) {
            Ok(auth) => auth,
            Err(err) => panic!("load_from failed: {err}"),
        };
        assert_eq!(loaded.api_key("anthropic"), Some("sk-ant-test"));
    }

    #[test]
    #[cfg(unix)]
    fn save_with_mode_tightens_existing_file_before_write_on_unix() {
        use std::os::unix::fs::PermissionsExt;
        let dir = temp_dir();
        let path = dir.path().join("auth.json");
        if let Err(err) = std::fs::write(&path, "old") {
            panic!("seed failed: {err}");
        }
        if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) {
            panic!("chmod seed failed: {err}");
        }

        if let Err(err) = save_with_mode(&path, &Auth::default()) {
            panic!("save failed: {err}");
        }

        let mode = match std::fs::metadata(&path) {
            Ok(metadata) => metadata.permissions().mode(),
            Err(err) => panic!("metadata failed: {err}"),
        };
        assert_eq!(mode & 0o777, 0o600, "expected 0600, got {:o}", mode & 0o777);
    }

    #[test]
    #[cfg(unix)]
    fn save_with_mode_sets_0600_on_unix() {
        use std::os::unix::fs::PermissionsExt;
        let dir = temp_dir();
        let path = dir.path().join("auth.json");
        let a = Auth::default();
        if let Err(err) = save_with_mode(&path, &a) {
            panic!("save failed: {err}");
        }

        let mode = match std::fs::metadata(&path) {
            Ok(metadata) => metadata.permissions().mode(),
            Err(err) => panic!("metadata failed: {err}"),
        };
        // Extract the low 9 perm bits (drop the file-type bits).
        assert_eq!(mode & 0o777, 0o600, "expected 0600, got {:o}", mode & 0o777);
    }
}