Skip to main content

harn_vm/llm/
provider_auth.rs

1use serde::{Deserialize, Serialize};
2
3use crate::llm_config::{self, ProviderDef};
4use crate::value::{VmError, VmValue};
5
6/// Credential resolution state reported by Harn's dispatch authority.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ProviderCredentialStatus {
10    Ok,
11    Missing,
12    NotRequired,
13    Deferred,
14}
15
16impl ProviderCredentialStatus {
17    pub const fn as_str(self) -> &'static str {
18        match self {
19            Self::Ok => "ok",
20            Self::Missing => "missing",
21            Self::NotRequired => "not_required",
22            Self::Deferred => "deferred",
23        }
24    }
25}
26
27/// Secret-free provider usability status for native hosts and VM projections.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ProviderAuthStatus {
30    pub name: String,
31    pub available: bool,
32    pub credential_status: ProviderCredentialStatus,
33}
34
35#[derive(Debug)]
36pub(crate) struct ResolvedProviderAuth {
37    pub status: ProviderAuthStatus,
38    credential: ResolvedProviderCredential,
39}
40
41impl ResolvedProviderAuth {
42    pub(crate) fn into_api_key(self) -> Option<String> {
43        match self.credential {
44            ResolvedProviderCredential::Key(api_key) => Some(api_key),
45            _ => None,
46        }
47    }
48}
49
50#[derive(Debug)]
51enum ResolvedProviderCredential {
52    Key(String),
53    Missing,
54    NotRequired,
55    Deferred,
56    ResolutionError(VmError),
57}
58
59/// Resolve one provider's usability through the exact credential path used by
60/// dispatch. The returned status never contains secret material.
61pub fn provider_auth_status(provider: &str) -> ProviderAuthStatus {
62    let definition = llm_config::provider_config(provider);
63    resolve_provider_auth_with_definition(provider, definition.as_ref()).status
64}
65
66/// Resolve all configured and runtime-registered providers in stable name order.
67pub fn provider_auth_statuses() -> Vec<ProviderAuthStatus> {
68    super::provider::register_default_providers();
69    let mut names: std::collections::BTreeSet<String> =
70        llm_config::provider_names().into_iter().collect();
71    names.extend(super::provider::registered_provider_names());
72    names
73        .into_iter()
74        .map(|name| provider_auth_status(&name))
75        .collect()
76}
77
78pub fn available_provider_names() -> Vec<String> {
79    llm_config::provider_names()
80        .into_iter()
81        .filter(|provider| provider_auth_status(provider).available)
82        .collect()
83}
84
85pub(crate) fn provider_auth_status_with_definition(
86    provider: &str,
87    definition: &ProviderDef,
88) -> ProviderAuthStatus {
89    resolve_provider_auth_with_definition(provider, Some(definition)).status
90}
91
92pub(crate) fn resolve_provider_auth(provider: &str) -> ResolvedProviderAuth {
93    let definition = llm_config::provider_config(provider);
94    resolve_provider_auth_with_definition(provider, definition.as_ref())
95}
96
97fn resolve_provider_auth_with_definition(
98    provider: &str,
99    definition: Option<&ProviderDef>,
100) -> ResolvedProviderAuth {
101    let name = provider.to_string();
102    let credential = if provider == "mock"
103        || provider == "fake"
104        || super::mock::cli_llm_mock_replay_active()
105        || super::mock::builtin_llm_mock_active()
106    {
107        ResolvedProviderCredential::NotRequired
108    } else if let Some(definition) = definition {
109        if definition.is_credential_resolution_platform_managed() {
110            ResolvedProviderCredential::Deferred
111        } else if definition.auth_style == "none"
112            || matches!(definition.auth_env, llm_config::AuthEnv::None)
113        {
114            ResolvedProviderCredential::NotRequired
115        } else {
116            resolved_credential_from_probe(probe_api_key(Some(definition)))
117        }
118    } else {
119        resolved_credential_from_probe(probe_api_key(None))
120    };
121    let (available, credential_status) = match &credential {
122        ResolvedProviderCredential::Key(_) => (true, ProviderCredentialStatus::Ok),
123        ResolvedProviderCredential::Missing | ResolvedProviderCredential::ResolutionError(_) => {
124            (false, ProviderCredentialStatus::Missing)
125        }
126        ResolvedProviderCredential::NotRequired => (true, ProviderCredentialStatus::NotRequired),
127        ResolvedProviderCredential::Deferred => (true, ProviderCredentialStatus::Deferred),
128    };
129    ResolvedProviderAuth {
130        status: ProviderAuthStatus {
131            name,
132            available,
133            credential_status,
134        },
135        credential,
136    }
137}
138
139fn resolved_credential_from_probe(
140    result: Result<Option<String>, ProviderCredentialError>,
141) -> ResolvedProviderCredential {
142    match result {
143        Ok(Some(api_key)) => ResolvedProviderCredential::Key(api_key),
144        Ok(None) => ResolvedProviderCredential::NotRequired,
145        Err(ProviderCredentialError::Missing) => ResolvedProviderCredential::Missing,
146        Err(ProviderCredentialError::Resolution(error)) => {
147            ResolvedProviderCredential::ResolutionError(error)
148        }
149    }
150}
151
152/// Resolve the provider credential used by dispatch.
153pub fn resolve_api_key(provider: &str) -> Result<String, VmError> {
154    let definition = llm_config::provider_config(provider);
155    resolve_api_key_with_definition(provider, definition.as_ref())
156}
157
158fn resolve_api_key_with_definition(
159    provider: &str,
160    definition: Option<&ProviderDef>,
161) -> Result<String, VmError> {
162    let selection_hint = {
163        let config_path = llm_config::loaded_config_path()
164            .map(|path| path.display().to_string())
165            .unwrap_or_else(|| "<built-in defaults>".to_string());
166        format!(
167            " (provider '{provider}' selected via LLM_PROVIDER / llm.toml @ {config_path}; \
168             set HARN_LLM_PROVIDER=mock or LLM_PROVIDER=mock for offline use)"
169        )
170    };
171
172    match resolve_provider_auth_with_definition(provider, definition).credential {
173        ResolvedProviderCredential::Key(api_key) => Ok(api_key),
174        ResolvedProviderCredential::NotRequired | ResolvedProviderCredential::Deferred => {
175            Ok(String::new())
176        }
177        ResolvedProviderCredential::ResolutionError(error) => Err(error),
178        ResolvedProviderCredential::Missing => {
179            if let Some(definition) = definition {
180                let aggregate_hint = no_credentials_message();
181                let requirement = match &definition.auth_env {
182                    llm_config::AuthEnv::Single(env) => {
183                        format!("set {env} environment variable")
184                    }
185                    llm_config::AuthEnv::Multiple(envs) => {
186                        format!("set one of {} environment variables", envs.join(", "))
187                    }
188                    llm_config::AuthEnv::None => return Ok(String::new()),
189                };
190                Err(missing_key_error(format!(
191                    "Missing API key: {requirement}{selection_hint}\n{aggregate_hint}"
192                )))
193            } else {
194                let aggregate_hint = no_credentials_message();
195                Err(missing_key_error(format!(
196                    "Missing API key: set ANTHROPIC_API_KEY environment variable{selection_hint}\n{aggregate_hint}"
197                )))
198            }
199        }
200    }
201}
202
203#[derive(Debug)]
204enum ProviderCredentialError {
205    Missing,
206    Resolution(VmError),
207}
208
209/// Read one credential-bearing variable through the session environment rather
210/// than the raw process environment.
211///
212/// Under an isolated policy no credential resolves — that is what makes an eval
213/// run reproducible instead of quietly picking up the launcher's key. Under a
214/// granted policy, a declared variable resolves to the granted value, so harn's
215/// own `llm_call` can use it.
216///
217/// A grant-resolution failure (an unresolvable `secret_store` pointer) is a
218/// missing credential from this path's point of view; `resolve_api_key` renders
219/// the same loud "Missing API key" guidance it renders for an unset variable,
220/// and the spawn boundary still reports the underlying `MissingSecret`.
221fn session_auth_env(name: &str) -> Option<String> {
222    crate::stdlib::process::session_env_var(name)
223        .ok()
224        .flatten()
225        .filter(|value| !value.is_empty())
226}
227
228fn probe_api_key(
229    definition: Option<&ProviderDef>,
230) -> Result<Option<String>, ProviderCredentialError> {
231    let Some(definition) = definition else {
232        return session_auth_env("ANTHROPIC_API_KEY")
233            .map(Some)
234            .ok_or(ProviderCredentialError::Missing);
235    };
236    match &definition.auth_env {
237        llm_config::AuthEnv::None => Ok(None),
238        llm_config::AuthEnv::Single(env) => {
239            let raw = session_auth_env(env).ok_or(ProviderCredentialError::Missing)?;
240            resolve_auth_env_value(env, &raw)
241                .map_err(ProviderCredentialError::Resolution)
242                .map(Some)
243        }
244        llm_config::AuthEnv::Multiple(envs) => {
245            for env in envs {
246                let Some(raw) = session_auth_env(env) else {
247                    continue;
248                };
249                return resolve_auth_env_value(env, &raw)
250                    .map_err(ProviderCredentialError::Resolution)
251                    .map(Some);
252            }
253            Err(ProviderCredentialError::Missing)
254        }
255    }
256}
257
258fn missing_key_error(message: String) -> VmError {
259    VmError::Thrown(VmValue::String(arcstr::ArcStr::from(message)))
260}
261
262fn resolve_auth_env_value(env_name: &str, raw: &str) -> Result<String, VmError> {
263    match crate::secrets::resolve_secret_ref_to_string(raw) {
264        Ok(Some(secret)) => Ok(secret),
265        Ok(None) => Ok(raw.to_string()),
266        Err(error) => Err(missing_key_error(format!(
267            "Failed to resolve API key secret reference from {env_name}: {error}"
268        ))),
269    }
270}
271
272/// Build the canonical no-credentials guidance from the live catalog.
273pub fn no_credentials_message() -> String {
274    let mut envs = Vec::new();
275    for name in llm_config::provider_names() {
276        if let Some(definition) = llm_config::provider_config(&name) {
277            if definition.auth_style == "none" {
278                continue;
279            }
280            for env in llm_config::auth_env_names(&definition.auth_env) {
281                if !envs.contains(&env) {
282                    envs.push(env);
283                }
284            }
285        }
286    }
287    envs.sort();
288    envs.dedup();
289    let env_list = if envs.is_empty() {
290        "(no providers declared)".to_string()
291    } else {
292        envs.join(", ")
293    };
294    format!(
295        "No LLM providers configured. Set one of these env vars to an API key or \
296         harn-secret://namespace/name reference: {env_list} (or run a local Ollama). \
297         For diagnostics: `harn doctor`. For a recommended setup: `harn models recommend` \
298         (when available)."
299    )
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    struct ScopedEnv {
307        name: &'static str,
308        previous: Option<String>,
309    }
310
311    impl ScopedEnv {
312        fn set(name: &'static str, value: &str) -> Self {
313            let previous = std::env::var(name).ok();
314            unsafe { std::env::set_var(name, value) };
315            Self { name, previous }
316        }
317
318        fn unset(name: &'static str) -> Self {
319            let previous = std::env::var(name).ok();
320            unsafe { std::env::remove_var(name) };
321            Self { name, previous }
322        }
323    }
324
325    impl Drop for ScopedEnv {
326        fn drop(&mut self) {
327            unsafe {
328                match &self.previous {
329                    Some(value) => std::env::set_var(self.name, value),
330                    None => std::env::remove_var(self.name),
331                }
332            }
333        }
334    }
335
336    #[test]
337    fn typed_status_covers_each_credential_resolution_class() {
338        let _guard = crate::llm::env_guard();
339        let _anthropic = ScopedEnv::unset("ANTHROPIC_API_KEY");
340        let _azure_key = ScopedEnv::unset("AZURE_OPENAI_API_KEY");
341        let _azure_token = ScopedEnv::unset("AZURE_OPENAI_AD_TOKEN");
342        let _azure_bearer = ScopedEnv::unset("AZURE_OPENAI_BEARER_TOKEN");
343
344        assert_eq!(
345            provider_auth_status("anthropic").credential_status,
346            ProviderCredentialStatus::Missing
347        );
348        assert_eq!(
349            provider_auth_status("ollama").credential_status,
350            ProviderCredentialStatus::NotRequired
351        );
352        assert_eq!(
353            provider_auth_status("bedrock").credential_status,
354            ProviderCredentialStatus::Deferred
355        );
356        assert_eq!(
357            provider_auth_status("vertex").credential_status,
358            ProviderCredentialStatus::Deferred
359        );
360        assert_eq!(resolve_api_key("bedrock").unwrap(), "");
361        assert_eq!(resolve_api_key("vertex").unwrap(), "");
362        assert!(resolve_api_key("azure_openai").is_err());
363    }
364
365    #[test]
366    fn status_and_dispatch_resolve_the_same_secret_reference_without_caching() {
367        let _guard = crate::llm::env_guard();
368        let _providers = ScopedEnv::set("HARN_SECRET_PROVIDERS", "env");
369        let _reference = ScopedEnv::set(
370            "ANTHROPIC_API_KEY",
371            "harn-secret://provider/anthropic-api-key",
372        );
373        let secret = ScopedEnv::set("HARN_SECRET_PROVIDER_ANTHROPIC_API_KEY", "sk-from-ref");
374
375        assert_eq!(resolve_api_key("anthropic").unwrap(), "sk-from-ref");
376        assert_eq!(
377            provider_auth_status("anthropic").credential_status,
378            ProviderCredentialStatus::Ok
379        );
380
381        drop(secret);
382        let _missing = ScopedEnv::unset("HARN_SECRET_PROVIDER_ANTHROPIC_API_KEY");
383        assert_eq!(
384            provider_auth_status("anthropic").credential_status,
385            ProviderCredentialStatus::Missing
386        );
387        let error = resolve_api_key("anthropic").unwrap_err();
388        let message = match error {
389            VmError::Thrown(VmValue::String(message)) => message.to_string(),
390            other => format!("{other:?}"),
391        };
392        assert!(
393            message.contains("Failed to resolve API key secret reference from ANTHROPIC_API_KEY")
394        );
395        assert!(message.contains("provider/anthropic-api-key"));
396        assert!(!message.contains("sk-from-ref"));
397    }
398
399    /// Install a environment policy for the duration of a test and clear it on
400    /// drop, so a panicking assertion cannot leak a profile into a sibling test
401    /// sharing the thread.
402    struct ScopedEnvironment;
403
404    impl ScopedEnvironment {
405        fn install(environment: crate::security::SessionEnvironment) -> Self {
406            crate::stdlib::process::set_session_environment(Some(environment));
407            Self
408        }
409    }
410
411    impl Drop for ScopedEnvironment {
412        fn drop(&mut self) {
413            crate::stdlib::process::set_session_environment(None);
414        }
415    }
416
417    #[test]
418    fn isolated_policy_hides_the_launcher_key_from_dispatch() {
419        // The launcher has a key; the session is isolated. Harn's own credential
420        // path must not see it, or a "no credentials" eval silently runs against
421        // whatever the operator happened to have exported.
422        let _guard = crate::llm::env_guard();
423        let _key = ScopedEnv::set("ANTHROPIC_API_KEY", "sk-launcher");
424
425        assert_eq!(
426            provider_auth_status("anthropic").credential_status,
427            ProviderCredentialStatus::Ok,
428            "outside a session, bootstrap reads still use the process env"
429        );
430
431        let _environment =
432            ScopedEnvironment::install(crate::security::SessionEnvironment::isolated());
433        assert_eq!(
434            provider_auth_status("anthropic").credential_status,
435            ProviderCredentialStatus::Missing,
436            "isolated must close the in-process credential path, not just subprocess env"
437        );
438        let message = match resolve_api_key("anthropic").unwrap_err() {
439            VmError::Thrown(VmValue::String(message)) => message.to_string(),
440            other => format!("{other:?}"),
441        };
442        assert!(message.contains("Missing API key"));
443        assert!(!message.contains("sk-launcher"), "error leaked the key");
444    }
445
446    #[test]
447    fn lane_grant_reaches_harns_own_dispatch() {
448        // A granted policy must make its provider key usable
449        // for its own llm_call, not only hand it to subprocesses. The launcher
450        // variable here is deliberately NOT the provider's auth env var, so the
451        // key can only arrive through the grant's `expose_as_env`.
452        use crate::security::{
453            EnvironmentPolicyKind, GrantSourceSpec, GrantSpec, SessionEnvironment,
454        };
455
456        let _guard = crate::llm::env_guard();
457        let _absent = ScopedEnv::unset("ANTHROPIC_API_KEY");
458        let _source = ScopedEnv::set("LAUNCHER_ANTHROPIC_SECRET", "sk-granted");
459
460        let granted = SessionEnvironment::launch(
461            EnvironmentPolicyKind::Granted,
462            vec![GrantSpec {
463                name: "anthropic".to_string(),
464                source: GrantSourceSpec::Env {
465                    var: "LAUNCHER_ANTHROPIC_SECRET".to_string(),
466                },
467                expose_as_env: Some("ANTHROPIC_API_KEY".to_string()),
468                for_command: None,
469            }],
470            &|name| std::env::var(name).ok(),
471        )
472        .expect("granted policy launch");
473        let _environment = ScopedEnvironment::install(granted);
474
475        assert_eq!(
476            provider_auth_status("anthropic").credential_status,
477            ProviderCredentialStatus::Ok
478        );
479        assert_eq!(resolve_api_key("anthropic").unwrap(), "sk-granted");
480    }
481
482    #[test]
483    fn an_empty_auth_variable_is_a_missing_credential() {
484        // An exported-but-empty key is not a credential. The multi-var branch
485        // always skipped empties; the single-var branch now agrees, so a blank
486        // export fails loudly at resolution instead of reaching a provider as an
487        // empty bearer token.
488        let _guard = crate::llm::env_guard();
489        let _blank = ScopedEnv::set("ANTHROPIC_API_KEY", "");
490        assert_eq!(
491            provider_auth_status("anthropic").credential_status,
492            ProviderCredentialStatus::Missing
493        );
494        assert!(resolve_api_key("anthropic").is_err());
495    }
496
497    #[test]
498    fn status_serialization_is_stable_and_secret_free() {
499        let status = ProviderAuthStatus {
500            name: "example".to_string(),
501            available: true,
502            credential_status: ProviderCredentialStatus::Deferred,
503        };
504        assert_eq!(
505            serde_json::to_value(status).unwrap(),
506            serde_json::json!({
507                "name": "example",
508                "available": true,
509                "credential_status": "deferred",
510            })
511        );
512    }
513
514    #[test]
515    fn available_provider_names_uses_dispatch_semantics() {
516        let _guard = crate::llm::env_guard();
517        let _vertex_token = ScopedEnv::unset("VERTEX_AI_ACCESS_TOKEN");
518        let _google_token = ScopedEnv::unset("GOOGLE_OAUTH_ACCESS_TOKEN");
519        let _google_credentials = ScopedEnv::unset("GOOGLE_APPLICATION_CREDENTIALS");
520
521        let available = available_provider_names();
522        assert!(available.iter().any(|name| name == "bedrock"));
523        assert!(available.iter().any(|name| name == "vertex"));
524    }
525}