agentmux_srv/identity/resolver/secret.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! [`resolve_secret`]: turning a `SecretRef` into a plaintext credential.
5//!
6//! Split out of the single ~2193-line `resolver.rs` (pure relocation, no
7//! behavior change).
8
9use crate::backend::storage::store::SecretRef;
10
11use super::errors::ResolverError;
12
13/// Resolve a `SecretRef` to the plaintext credential string. Each
14/// backend has a distinct path:
15///
16/// - **Env**: read `env_var` from the srv process's own environment.
17/// Caller is expected to have set this in their shell or a
18/// .env-style loader before launching AgentMux.
19/// - **PlaintextDev**: return the literal stored string. **Debug
20/// builds only** — guarded behind `cfg(debug_assertions)`. In
21/// release builds, the same call returns
22/// [`ResolverError::PlaintextDevDisabledInRelease`] so a forgotten
23/// dev-secret never leaks into a packaged binary. Reagent P1 on
24/// PR #751 caught the missing guard. Phase 3's encrypted vault is
25/// the production path.
26/// - **SecretsManager**: deferred. Returns
27/// [`ResolverError::SecretsManagerUnsupported`].
28pub fn resolve_secret(secret_ref: &SecretRef) -> Result<String, ResolverError> {
29 match secret_ref {
30 SecretRef::Env { env_var } => std::env::var(env_var)
31 .map_err(|_| ResolverError::EnvVarMissing(env_var.clone())),
32 SecretRef::PlaintextDev { plaintext_dev } => {
33 #[cfg(debug_assertions)]
34 {
35 Ok(plaintext_dev.clone())
36 }
37 #[cfg(not(debug_assertions))]
38 {
39 let _ = plaintext_dev;
40 Err(ResolverError::PlaintextDevDisabledInRelease)
41 }
42 }
43 SecretRef::SecretsManager { .. } => Err(ResolverError::SecretsManagerUnsupported),
44 SecretRef::OAuthConfigDir { dir } => {
45 // Read but ignored — the resolver doesn't consume the
46 // pointer; PR B's oauth-class dispatch in
47 // `inject_identity_env` reads `dir` and sets the
48 // provider's config-dir env var directly, bypassing
49 // `resolve_secret` entirely.
50 let _ = dir;
51 Err(ResolverError::OAuthConfigDirNotASecret)
52 }
53 SecretRef::Keychain { account, .. } => {
54 // Account API keys: pull the plaintext from the OS
55 // keychain at spawn time. The account string is
56 // `acct:<account_id>`; secret_store reconstructs the key
57 // from the id, so strip the namespace prefix here.
58 let account_id = account.strip_prefix("acct:").unwrap_or(account);
59 crate::identity::secret_store::get(account_id)
60 .map(|z| z.to_string())
61 .map_err(ResolverError::KeychainError)
62 }
63 }
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69
70 // PlaintextDev-using tests are gated behind cfg(debug_assertions)
71 // because release builds reject PlaintextDev with
72 // ResolverError::PlaintextDevDisabledInRelease, so the assertions
73 // below would fail under `cargo test --release`. Reagent P2
74 // (PR #751). The Env / SecretsManager / unknown-provider paths
75 // are tested separately and have no debug-only dependency.
76
77 #[cfg(debug_assertions)]
78 #[test]
79 fn resolve_plaintext_dev() {
80 let s = resolve_secret(&SecretRef::PlaintextDev {
81 plaintext_dev: "ghp_test123".to_string(),
82 })
83 .unwrap();
84 assert_eq!(s, "ghp_test123");
85 }
86
87 #[test]
88 fn resolve_env_var_missing() {
89 let res = resolve_secret(&SecretRef::Env {
90 env_var: "AGENTMUX_TEST_NEVER_SET_X9Q".to_string(),
91 });
92 assert!(matches!(res, Err(ResolverError::EnvVarMissing(_))));
93 }
94
95 #[test]
96 fn resolve_secrets_manager_unsupported() {
97 let res = resolve_secret(&SecretRef::SecretsManager {
98 sm_path: "ignored".to_string(),
99 sm_json_path: None,
100 });
101 assert!(matches!(res, Err(ResolverError::SecretsManagerUnsupported)));
102 }
103
104 #[test]
105 fn resolve_oauth_config_dir_is_not_a_secret() {
106 // OAuthConfigDir is a pointer to a CLI-managed token directory,
107 // not a resolvable secret string. PR B's oauth-class dispatch
108 // in `inject_identity_env` reads `dir` and sets the provider's
109 // config-dir env var directly, bypassing `resolve_secret`. The
110 // error here is a guard against a caller forgetting that
111 // dispatch — pre-PR-B nothing produces this variant, but the
112 // arm has to exist for the match to be exhaustive.
113 let res = resolve_secret(&SecretRef::OAuthConfigDir {
114 dir: "/path/to/bundle/claude".to_string(),
115 });
116 assert!(matches!(res, Err(ResolverError::OAuthConfigDirNotASecret)));
117 }
118}