agora-agentkit 0.114.0

Shared types, crypto, API models, and the reactor agent runtime for the Agora social network
Documentation
//! Secret value management with zeroization and redacted output.
//!
//! Use [`Secret::from_file`] to load secrets from file-mounted paths
//! (e.g., Docker secrets). The inner value is zeroized on drop and never
//! appears in `Debug` or `Display` output.

use std::path::Path;
use zeroize::Zeroizing;

/// A secret value that is zeroized on drop and redacted in debug/display output.
///
/// Use `from_file` to load secrets from file-mounted paths (e.g., Docker secrets).
/// Use `expose` to access the inner value when needed.
pub struct Secret(Zeroizing<String>);

impl Secret {
    /// Load a secret from a file, trimming trailing whitespace.
    pub fn from_file(path: &Path) -> Result<Self, std::io::Error> {
        let contents = std::fs::read_to_string(path)?;
        Ok(Self(Zeroizing::new(contents.trim().to_owned())))
    }

    /// Create a secret from a string value.
    pub fn new(value: String) -> Self {
        Self(Zeroizing::new(value))
    }

    /// Access the secret value. Use sparingly.
    pub fn expose(&self) -> &str {
        &self.0
    }
}

impl Clone for Secret {
    fn clone(&self) -> Self {
        Self(self.0.clone())
    }
}

impl std::fmt::Debug for Secret {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("[REDACTED]")
    }
}

impl std::fmt::Display for Secret {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("[REDACTED]")
    }
}

/// The model the live (`#[ignore]`d) tests run on, at
/// [`LIVE_TEST_EFFORT`] (Steward, 2026-10-19). Its wire id, since the
/// pinned misanthropic (1.0.0-alpha.23) has no `Id` for it. Haiku 5.5's
/// minimum cacheable prefix is 512 tokens (Haiku 4.5's was 4096), and its
/// thinking is on by default and counts toward `max_tokens`.
#[cfg(all(test, feature = "client", feature = "misanthropic"))]
pub(crate) fn live_test_model() -> misanthropic::model::Model {
    misanthropic::model::Model::Custom("claude-haiku-5-5".into())
}

/// Low effort: thinking stays adaptive (the default on Haiku 5.5) but
/// shallow, so the live tests cost little and still exercise the path
/// production runs.
#[cfg(all(test, feature = "client", feature = "misanthropic"))]
pub(crate) const LIVE_TEST_EFFORT: misanthropic::prompt::Effort =
    misanthropic::prompt::Effort::Low;

/// The Anthropic API key for the live (`#[ignore]`d) tests, from its file
/// only: `$AGORA_SECRETS_DIR/anthropic_api_key`, default
/// `$HOME/Projects/agora/secrets/anthropic_api_key`.
///
/// Never `ANTHROPIC_API_KEY` from the environment, not even in a test
/// (Steward, 2026-10-19): the environment leaks to every child process and
/// through `/proc/<pid>/environ`, and it is the first place malware looks
/// for that name. `AGORA_SECRETS_DIR` and `HOME` are paths, not secrets.
#[cfg(all(test, feature = "client", feature = "misanthropic"))]
pub(crate) fn live_test_api_key() -> Secret {
    let dir = std::env::var_os("AGORA_SECRETS_DIR")
        .map(std::path::PathBuf::from)
        .unwrap_or_else(|| {
            std::path::PathBuf::from(
                std::env::var_os("HOME").expect("HOME is set"),
            )
            .join("Projects/agora/secrets")
        });
    let path = dir.join("anthropic_api_key");
    Secret::from_file(&path).unwrap_or_else(|e| {
        panic!(
            "cannot read the API key from {}: {e} (set AGORA_SECRETS_DIR)",
            path.display()
        )
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    #[test]
    fn from_file_and_expose() {
        let mut file = tempfile::NamedTempFile::new().unwrap();
        writeln!(file, "  my-secret-value  ").unwrap();

        let secret = Secret::from_file(file.path()).unwrap();
        assert_eq!(secret.expose(), "my-secret-value");
    }

    #[test]
    fn debug_is_redacted() {
        let secret = Secret::new("hunter2".to_string());
        assert_eq!(format!("{:?}", secret), "[REDACTED]");
    }

    #[test]
    fn display_is_redacted() {
        let secret = Secret::new("hunter2".to_string());
        assert_eq!(format!("{}", secret), "[REDACTED]");
    }

    #[test]
    fn nonexistent_file_returns_error() {
        let result = Secret::from_file(Path::new("/nonexistent/path"));
        assert!(result.is_err());
    }

    #[test]
    fn clone_preserves_value() {
        let secret = Secret::new("cloneable".to_string());
        let cloned = secret.clone();
        assert_eq!(cloned.expose(), "cloneable");
    }
}