Skip to main content

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}