Skip to main content

hyper_agent_core/
credentials.rs

1//! Credential resolution with a priority chain: env vars -> TOML config.
2//!
3//! This replaces the old `load_api_key_from_keyring()` approach with a
4//! deterministic, testable resolver that does not depend on OS keyring.
5//!
6//! # Resolution order
7//!
8//! | Credential       | 1st (highest priority)       | 2nd                     | 3rd (TOML fallback)                    |
9//! |------------------|------------------------------|-------------------------|----------------------------------------|
10//! | Anthropic API    | `ANTHROPIC_OAUTH_TOKEN`      | `ANTHROPIC_API_KEY`     | `credentials.anthropic_api_key`        |
11//! | Hyperliquid key  | `HYPERLIQUID_PRIVATE_KEY`    | —                       | `credentials.hyperliquid_private_key`  |
12
13use crate::config::CredentialsSection;
14
15/// Resolves credentials from environment variables and TOML config.
16///
17/// The resolver checks env vars first, then falls back to the TOML
18/// `[credentials]` section. Empty strings are treated as absent.
19pub struct CredentialResolver {
20    config: CredentialsSection,
21}
22
23impl CredentialResolver {
24    /// Create a new resolver backed by the given TOML credentials section.
25    pub fn new(config: CredentialsSection) -> Self {
26        Self { config }
27    }
28
29    /// Resolve the Anthropic API key.
30    ///
31    /// Priority: `ANTHROPIC_OAUTH_TOKEN` > `ANTHROPIC_API_KEY` > TOML field.
32    pub fn anthropic_key(&self) -> Option<String> {
33        std::env::var("ANTHROPIC_OAUTH_TOKEN")
34            .ok()
35            .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
36            .or_else(|| self.config.anthropic_api_key.clone())
37            .filter(|s| !s.is_empty())
38    }
39
40    /// Resolve the Hyperliquid private key.
41    ///
42    /// Priority: `HYPERLIQUID_PRIVATE_KEY` env var > TOML field.
43    pub fn hyperliquid_key(&self) -> Option<String> {
44        std::env::var("HYPERLIQUID_PRIVATE_KEY")
45            .ok()
46            .or_else(|| self.config.hyperliquid_private_key.clone())
47            .filter(|s| !s.is_empty())
48    }
49
50    /// Derive the Ethereum address from the Hyperliquid private key.
51    ///
52    /// Returns `None` if no key is configured. Returns `Some(Err(..))` if
53    /// the key is present but cannot be parsed.
54    pub fn hyperliquid_address(&self) -> Option<Result<String, String>> {
55        self.hyperliquid_key().map(|key_hex| {
56            let stripped = key_hex
57                .strip_prefix("0x")
58                .or_else(|| key_hex.strip_prefix("0X"))
59                .unwrap_or(&key_hex);
60            let key_bytes =
61                hex::decode(stripped).map_err(|e| format!("invalid hex in private key: {e}"))?;
62            let signing_key = k256::ecdsa::SigningKey::from_bytes(key_bytes.as_slice().into())
63                .map_err(|e| format!("invalid secp256k1 key: {e}"))?;
64            let verifying_key = signing_key.verifying_key();
65            let point = verifying_key.to_encoded_point(false);
66            let pubkey_bytes = &point.as_bytes()[1..];
67            use sha3::{Digest, Keccak256};
68            let hash = Keccak256::digest(pubkey_bytes);
69            Ok(format!("0x{}", hex::encode(&hash[12..])))
70        })
71    }
72}
73
74// ---------------------------------------------------------------------------
75// Tests
76// ---------------------------------------------------------------------------
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use std::env;
82    use std::sync::Mutex;
83
84    /// Global mutex to serialize tests that manipulate environment variables.
85    static ENV_LOCK: Mutex<()> = Mutex::new(());
86
87    fn clear_cred_env_vars() {
88        env::remove_var("ANTHROPIC_OAUTH_TOKEN");
89        env::remove_var("ANTHROPIC_API_KEY");
90        env::remove_var("HYPERLIQUID_PRIVATE_KEY");
91    }
92
93    fn empty_config() -> CredentialsSection {
94        CredentialsSection {
95            anthropic_api_key: None,
96            hyperliquid_private_key: None,
97        }
98    }
99
100    #[test]
101    fn test_anthropic_key_from_oauth_token_env() {
102        let _lock = ENV_LOCK.lock().unwrap();
103        clear_cred_env_vars();
104        env::set_var("ANTHROPIC_OAUTH_TOKEN", "oauth-tok");
105        env::set_var("ANTHROPIC_API_KEY", "api-key");
106
107        let resolver = CredentialResolver::new(CredentialsSection {
108            anthropic_api_key: Some("toml-key".to_string()),
109            hyperliquid_private_key: None,
110        });
111
112        // OAUTH_TOKEN wins over API_KEY and TOML
113        assert_eq!(resolver.anthropic_key().as_deref(), Some("oauth-tok"));
114        clear_cred_env_vars();
115    }
116
117    #[test]
118    fn test_anthropic_key_from_api_key_env() {
119        let _lock = ENV_LOCK.lock().unwrap();
120        clear_cred_env_vars();
121        env::set_var("ANTHROPIC_API_KEY", "api-key");
122
123        let resolver = CredentialResolver::new(CredentialsSection {
124            anthropic_api_key: Some("toml-key".to_string()),
125            hyperliquid_private_key: None,
126        });
127
128        assert_eq!(resolver.anthropic_key().as_deref(), Some("api-key"));
129        clear_cred_env_vars();
130    }
131
132    #[test]
133    fn test_anthropic_key_from_toml() {
134        let _lock = ENV_LOCK.lock().unwrap();
135        clear_cred_env_vars();
136
137        let resolver = CredentialResolver::new(CredentialsSection {
138            anthropic_api_key: Some("toml-key".to_string()),
139            hyperliquid_private_key: None,
140        });
141
142        assert_eq!(resolver.anthropic_key().as_deref(), Some("toml-key"));
143        clear_cred_env_vars();
144    }
145
146    #[test]
147    fn test_anthropic_key_none_when_all_absent() {
148        let _lock = ENV_LOCK.lock().unwrap();
149        clear_cred_env_vars();
150
151        let resolver = CredentialResolver::new(empty_config());
152        assert!(resolver.anthropic_key().is_none());
153        clear_cred_env_vars();
154    }
155
156    #[test]
157    fn test_anthropic_key_empty_string_treated_as_absent() {
158        let _lock = ENV_LOCK.lock().unwrap();
159        clear_cred_env_vars();
160        env::set_var("ANTHROPIC_API_KEY", "");
161
162        let resolver = CredentialResolver::new(CredentialsSection {
163            anthropic_api_key: Some(String::new()),
164            hyperliquid_private_key: None,
165        });
166
167        assert!(resolver.anthropic_key().is_none());
168        clear_cred_env_vars();
169    }
170
171    #[test]
172    fn test_hyperliquid_key_from_env() {
173        let _lock = ENV_LOCK.lock().unwrap();
174        clear_cred_env_vars();
175        env::set_var("HYPERLIQUID_PRIVATE_KEY", "0xENV");
176
177        let resolver = CredentialResolver::new(CredentialsSection {
178            anthropic_api_key: None,
179            hyperliquid_private_key: Some("0xTOML".to_string()),
180        });
181
182        assert_eq!(resolver.hyperliquid_key().as_deref(), Some("0xENV"));
183        clear_cred_env_vars();
184    }
185
186    #[test]
187    fn test_hyperliquid_key_from_toml() {
188        let _lock = ENV_LOCK.lock().unwrap();
189        clear_cred_env_vars();
190
191        let resolver = CredentialResolver::new(CredentialsSection {
192            anthropic_api_key: None,
193            hyperliquid_private_key: Some("0xTOML".to_string()),
194        });
195
196        assert_eq!(resolver.hyperliquid_key().as_deref(), Some("0xTOML"));
197        clear_cred_env_vars();
198    }
199
200    #[test]
201    fn test_hyperliquid_key_none_when_absent() {
202        let _lock = ENV_LOCK.lock().unwrap();
203        clear_cred_env_vars();
204
205        let resolver = CredentialResolver::new(empty_config());
206        assert!(resolver.hyperliquid_key().is_none());
207        clear_cred_env_vars();
208    }
209}