agentmux_srv/identity/resolver/provider.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Provider classification: [`ProviderClass`] and [`provider_class`].
5//!
6//! Split out of the single ~2193-line `resolver.rs` (pure relocation, no
7//! behavior change).
8
9/// What kind of credential a provider uses, and how
10/// `inject_identity_env` puts it into the agent's env at spawn time.
11/// Per `docs/specs/archive/SPEC_OAUTH_IDENTITY_BUNDLES_2026_05_22.md` §4.3.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum ProviderClass {
14 /// **API-key class.** The binding's `SecretRef` resolves to a
15 /// single secret string, injected as the listed env vars. All
16 /// listed vars receive the same value — multi-var emission
17 /// covers "two CLIs want different var names for the same secret"
18 /// (e.g. github writes both `GITHUB_TOKEN` and `GH_TOKEN`).
19 ApiKey { env_vars: &'static [&'static str] },
20 /// **OAuth class.** The binding's `SecretRef` is a
21 /// `SecretRef::OAuthConfigDir` pointer; the resolver sets
22 /// `config_dir_env_var = <dir>` at spawn so the CLI reads its
23 /// OAuth tokens from the per-bundle directory.
24 OAuth { config_dir_env_var: &'static str },
25}
26
27/// Classify a provider id. `None` for unknown providers — the
28/// resolver logs and skips them.
29///
30/// reagent P1 on #2263: this used to match only canonical IDs directly, but
31/// `backend/providers.rs` registers aliases (`gemini-cli`→`gemini`,
32/// `copilot-cli`/`github-copilot`→`copilot`, `claude-code`→`claude`, etc.)
33/// that `get_provider` already resolves — meaning `provider_class` and
34/// `get_provider` could disagree on a definition/link still using an alias
35/// ID, silently skipping both the spawn gate and config-dir injection for
36/// it. Resolve to the canonical ID first so the two can never drift.
37///
38/// `resolve_provider_alias` only knows the CLI-tool registry (claude/codex/
39/// gemini/etc.) — it returns `""` as a sentinel for anything outside that
40/// registry, which includes the api-key-class service identifiers below
41/// ("github"/"anthropic"/"openai"/"kimi"/"aws" — a completely different
42/// namespace, not CLI tools). Only substitute the resolved value when it's
43/// non-empty; otherwise keep matching on the original id so that namespace
44/// is untouched.
45pub fn provider_class(provider: &str) -> Option<ProviderClass> {
46 let resolved = crate::backend::providers::resolve_provider_alias(provider);
47 let provider = if resolved.is_empty() { provider } else { resolved };
48 match provider {
49 // ── API-key class ─────────────────────────────────────────
50 // ApiKey.env_vars values match the legacy provider_env_vars
51 // matrix exactly — the new dispatch is additive.
52 "github" => Some(ProviderClass::ApiKey {
53 env_vars: &["GITHUB_TOKEN", "GH_TOKEN"],
54 }),
55 "anthropic" => Some(ProviderClass::ApiKey {
56 env_vars: &["ANTHROPIC_API_KEY"],
57 }),
58 "openai" => Some(ProviderClass::ApiKey {
59 env_vars: &["OPENAI_API_KEY"],
60 }),
61 "kimi" => Some(ProviderClass::ApiKey {
62 env_vars: &["MOONSHOT_API_KEY"],
63 }),
64 "aws" => Some(ProviderClass::ApiKey {
65 env_vars: &["AWS_ACCESS_KEY_ID"],
66 }),
67 // ── OAuth class ───────────────────────────────────────────
68 // Env-var names come from the CLI provider registry
69 // (`crates/srv/src/backend/providers.rs` —
70 // `ProviderConfig::auth_config_dir_env_var`) so the resolver
71 // can never drift from the launcher spawn path: there is one
72 // source of truth per CLI for which env var redirects its
73 // config / auth directory. The match arm enumerates which
74 // providers we currently treat as OAuth-class for identity
75 // bundles. Originally just claude / codex / openclaw (spec
76 // §4.3) — gemini and copilot were added later
77 // (REPORT_AUTH_ARCHITECTURE_STATE_AND_RETHINK_2026_07_21.md
78 // §2.5 / §6, Phase C) to close a drift gap: the frontend's
79 // `ProviderDefinition` table already marked both
80 // `authType: "oauth"`, but this match arm (the actual gate
81 // for the spawn-time enforcement AND the per-account
82 // isolation-dir minting in identity_handlers.rs, both of
83 // which key off this single function) hadn't caught up —
84 // meaning neither actually applied to them despite the UI
85 // already presenting them as oauth-class. See
86 // `oauth_class_matches_frontend_authtype_oauth_set` below,
87 // which pins this set staying in sync with the frontend going
88 // forward so this doesn't silently drift again.
89 "claude" | "codex" | "openclaw" | "gemini" | "copilot" => {
90 crate::backend::providers::get_provider(provider).map(|cfg| {
91 ProviderClass::OAuth {
92 config_dir_env_var: cfg.auth_config_dir_env_var,
93 }
94 })
95 }
96 _ => None,
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn provider_class_oauth_providers() {
106 // The known oauth providers must classify as OAuth with the SAME
107 // config-dir env vars the CLI provider registry defines (single
108 // source of truth). Pinning the expected strings here catches drift
109 // in either direction — if the registry changes a value, this test
110 // fails and the change becomes deliberate.
111 assert_eq!(
112 provider_class("claude"),
113 Some(ProviderClass::OAuth { config_dir_env_var: "CLAUDE_CONFIG_DIR" }),
114 );
115 assert_eq!(
116 provider_class("codex"),
117 Some(ProviderClass::OAuth { config_dir_env_var: "CODEX_HOME" }),
118 );
119 assert_eq!(
120 provider_class("openclaw"),
121 Some(ProviderClass::OAuth { config_dir_env_var: "OPENCLAW_HOME" }),
122 );
123 assert_eq!(
124 provider_class("gemini"),
125 Some(ProviderClass::OAuth { config_dir_env_var: "GEMINI_CLI_HOME" }),
126 );
127 assert_eq!(
128 provider_class("copilot"),
129 Some(ProviderClass::OAuth { config_dir_env_var: "COPILOT_HOME" }),
130 );
131 }
132
133 #[test]
134 fn oauth_class_matches_frontend_authtype_oauth_set() {
135 // REPORT_AUTH_ARCHITECTURE_STATE_AND_RETHINK_2026_07_21.md §2.5
136 // found gemini/copilot marked `authType: "oauth"` in the frontend's
137 // `ProviderDefinition` table (frontend/app/view/agent/providers/
138 // index.ts) while this function — the actual gate for both the
139 // spawn-time enforcement AND the per-account isolation-dir minting
140 // — hadn't caught up, so neither mechanism applied to them despite
141 // the UI already presenting them as oauth-class. This pins the two
142 // sets staying equal going forward. There's no automated cross-
143 // language check available, so this list is a manually-maintained
144 // mirror of the frontend table — if you add a new `authType:
145 // "oauth"` provider there, update FRONTEND_OAUTH_TYPED here too, in
146 // the SAME change, not as a follow-up.
147 const FRONTEND_OAUTH_TYPED: &[&str] = &["claude", "codex", "gemini", "openclaw", "copilot"];
148 const ALL_KNOWN_PROVIDERS: &[&str] = &[
149 "claude", "codex", "muxcode", "gemini", "qwen", "openclaw", "kimi", "copilot", "pi",
150 ];
151 for p in ALL_KNOWN_PROVIDERS {
152 let is_oauth_class = matches!(provider_class(p), Some(ProviderClass::OAuth { .. }));
153 let is_frontend_oauth_typed = FRONTEND_OAUTH_TYPED.contains(p);
154 assert_eq!(
155 is_oauth_class, is_frontend_oauth_typed,
156 "provider '{p}': backend OAuth-class ({is_oauth_class}) must match \
157 frontend authType:\"oauth\" ({is_frontend_oauth_typed}) — see this \
158 test's doc comment",
159 );
160 }
161 }
162
163 #[test]
164 fn provider_class_resolves_aliases_to_the_same_result_as_canonical() {
165 // reagent P1 on #2263: provider_class used to match only canonical
166 // IDs, silently disagreeing with get_provider (which already
167 // resolves aliases) for any definition/link still using one.
168 assert_eq!(provider_class("claude-code"), provider_class("claude"));
169 assert_eq!(provider_class("claude_code"), provider_class("claude"));
170 assert_eq!(provider_class("codex-cli"), provider_class("codex"));
171 assert_eq!(provider_class("openclaw-cli"), provider_class("openclaw"));
172 assert_eq!(provider_class("open-claw"), provider_class("openclaw"));
173 // Api-key-class aliases must resolve identically too — this isn't
174 // gated on oauth-class providers specifically.
175 assert_eq!(provider_class("kimi-cli"), provider_class("kimi"));
176 // A truly unknown id must still classify as None, not panic or
177 // silently match something via an empty-string fallback.
178 assert_eq!(provider_class("totally-unknown-provider-xyz"), None);
179 }
180}