Skip to main content

agent_core/core/auth/
account.rs

1//! Account references for multi-account OAuth credentials.
2//!
3//! An account is a `(provider, label)` pair. The bare provider storage key
4//! (`"openai-codex"`) is the `default` account; every other account is stored
5//! under `"<provider>@<label>"`. Nothing that already exists moves or needs
6//! migration.
7//!
8//! Labels are validated at every boundary (CLI, env, config, HTTP query, proxy
9//! body) *before* they are used as a JSON key, a file-name fragment, a URL
10//! parameter, or a log field. A label that fails the grammar never reaches
11//! the filesystem.
12
13use std::collections::BTreeMap;
14use std::fmt;
15
16use serde::{Deserialize, Serialize};
17
18use super::broker::BrokerError;
19use super::provider::OAuthProviderId;
20
21/// Reserved word for the bare provider slot. Never a label.
22pub const DEFAULT_ACCOUNT_NAME: &str = "default";
23
24/// Reserved selector word for opt-in automatic selection (G5).
25pub const AUTO_ACCOUNT_NAME: &str = "auto";
26
27const MAX_LABEL_LEN: usize = 32;
28
29// ── Label ────────────────────────────────────────────────────────────────────
30
31/// Validated account label. Grammar: `^[a-z0-9][a-z0-9._-]{0,31}$`; the words
32/// `default` and `auto` are reserved and rejected.
33#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
34pub struct AccountLabel(String);
35
36impl AccountLabel {
37    pub fn parse(raw: &str) -> Result<Self, String> {
38        let value = raw.trim();
39        if value.is_empty() {
40            return Err("account label is empty".into());
41        }
42        if value == DEFAULT_ACCOUNT_NAME || value == AUTO_ACCOUNT_NAME {
43            return Err(format!("'{value}' is reserved and cannot be used as an account label"));
44        }
45        if value.len() > MAX_LABEL_LEN {
46            return Err(format!(
47                "account label is too long ({} chars; max {MAX_LABEL_LEN})",
48                value.len()
49            ));
50        }
51        let mut chars = value.chars();
52        let first = chars.next().expect("non-empty");
53        if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
54            return Err(
55                "account label must start with a lowercase letter or digit (a-z, 0-9)".into(),
56            );
57        }
58        if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
59        {
60            return Err(
61                "account label may only contain a-z, 0-9, '.', '_' and '-' (max 32 chars)".into(),
62            );
63        }
64        Ok(Self(value.to_string()))
65    }
66
67    pub fn as_str(&self) -> &str {
68        &self.0
69    }
70}
71
72impl fmt::Debug for AccountLabel {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        write!(f, "AccountLabel({})", self.0)
75    }
76}
77
78impl fmt::Display for AccountLabel {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        f.write_str(&self.0)
81    }
82}
83
84// ── Account ──────────────────────────────────────────────────────────────────
85
86/// A concrete account slot for a provider.
87#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
88pub enum Account {
89    /// The bare provider key — every pre-existing installation.
90    #[default]
91    Default,
92    Named(AccountLabel),
93}
94
95impl Account {
96    /// `"default"` → [`Account::Default`]; anything else must be a valid
97    /// label. Empty or malformed input is an error — an explicit account
98    /// request is never coerced to the default slot.
99    pub fn parse(raw: &str) -> Result<Self, String> {
100        let value = raw.trim();
101        if value == DEFAULT_ACCOUNT_NAME {
102            return Ok(Self::Default);
103        }
104        AccountLabel::parse(value).map(Self::Named)
105    }
106
107    pub fn named(label: &str) -> Result<Self, String> {
108        AccountLabel::parse(label).map(Self::Named)
109    }
110
111    pub fn is_default(&self) -> bool {
112        matches!(self, Self::Default)
113    }
114
115    /// `"default"` or the label text. Safe for display, JSON and URLs.
116    pub fn label_str(&self) -> &str {
117        match self {
118            Self::Default => DEFAULT_ACCOUNT_NAME,
119            Self::Named(label) => label.as_str(),
120        }
121    }
122}
123
124impl fmt::Debug for Account {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        write!(f, "Account({})", self.label_str())
127    }
128}
129
130impl fmt::Display for Account {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        f.write_str(self.label_str())
133    }
134}
135
136impl Serialize for Account {
137    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
138        serializer.serialize_str(self.label_str())
139    }
140}
141
142impl<'de> Deserialize<'de> for Account {
143    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
144        let raw = String::deserialize(deserializer)?;
145        Account::parse(&raw).map_err(serde::de::Error::custom)
146    }
147}
148
149// ── CredentialRef ────────────────────────────────────────────────────────────
150
151/// A fully addressed OAuth credential: provider + account slot.
152#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
153pub struct CredentialRef {
154    pub provider: OAuthProviderId,
155    pub account: Account,
156}
157
158impl CredentialRef {
159    pub fn new(provider: OAuthProviderId, account: Account) -> Self {
160        Self { provider, account }
161    }
162
163    pub fn default_for(provider: OAuthProviderId) -> Self {
164        Self {
165            provider,
166            account: Account::Default,
167        }
168    }
169
170    /// `"openai-codex"` for the default slot, `"openai-codex@astra2"` otherwise.
171    pub fn storage_key(&self) -> String {
172        match &self.account {
173            Account::Default => self.provider.as_str().to_string(),
174            Account::Named(label) => format!("{}@{}", self.provider.as_str(), label.as_str()),
175        }
176    }
177
178    /// Inverse of [`storage_key`](Self::storage_key). Returns `None` for keys
179    /// that are not OAuth provider keys (static keys, cloud state, unknown
180    /// providers) and for malformed labels (`"x@default"`, `"x@Bad Label"`).
181    pub fn parse_storage_key(key: &str) -> Option<Self> {
182        match key.split_once('@') {
183            None => {
184                let provider: OAuthProviderId = key.parse().ok()?;
185                Some(Self::default_for(provider))
186            }
187            Some((provider, label)) => {
188                let provider: OAuthProviderId = provider.parse().ok()?;
189                let label = AccountLabel::parse(label).ok()?;
190                Some(Self::new(provider, Account::Named(label)))
191            }
192        }
193    }
194
195    /// True if `key` names *some* slot of `provider` (valid or not). Used to
196    /// surface malformed sibling keys in listings without ever loading them.
197    pub(crate) fn key_belongs_to(key: &str, provider: OAuthProviderId) -> bool {
198        key == provider.as_str()
199            || key
200                .strip_prefix(provider.as_str())
201                .is_some_and(|rest| rest.starts_with('@'))
202    }
203}
204
205#[derive(Serialize, Deserialize)]
206struct CredentialRefWire {
207    provider: String,
208    account: String,
209}
210
211impl Serialize for CredentialRef {
212    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
213        CredentialRefWire {
214            provider: self.provider.as_str().to_string(),
215            account: self.account.label_str().to_string(),
216        }
217        .serialize(serializer)
218    }
219}
220
221impl<'de> Deserialize<'de> for CredentialRef {
222    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
223        let wire = CredentialRefWire::deserialize(deserializer)?;
224        let provider: OAuthProviderId = wire.provider.parse().map_err(serde::de::Error::custom)?;
225        let account = Account::parse(&wire.account).map_err(serde::de::Error::custom)?;
226        Ok(Self { provider, account })
227    }
228}
229
230impl fmt::Debug for CredentialRef {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        write!(f, "CredentialRef({})", self.storage_key())
233    }
234}
235
236impl fmt::Display for CredentialRef {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        f.write_str(&self.storage_key())
239    }
240}
241
242// ── Selection policy ─────────────────────────────────────────────────────────
243
244/// What a caller asks for when no explicit account is given.
245#[derive(Clone, PartialEq, Eq, Debug)]
246pub enum AccountSelector {
247    Account(Account),
248    /// Opt-in automatic selection (G5). Resolved through the broker's
249    /// installed capacity selector; fails closed when none is installed.
250    Auto,
251    /// A configured selector that failed validation. Kept in the policy so
252    /// resolution FAILS CLOSED instead of silently using another account.
253    /// `reason` is secret-free (grammar message + config/env source).
254    Invalid { source: String, reason: String },
255}
256
257impl AccountSelector {
258    /// `"auto"` → `Auto`; otherwise an [`Account`].
259    pub fn parse(raw: &str) -> Result<Self, String> {
260        let value = raw.trim();
261        if value == AUTO_ACCOUNT_NAME {
262            return Ok(Self::Auto);
263        }
264        Account::parse(value).map(Self::Account)
265    }
266
267    /// Parse and, on failure, produce the fail-closed `Invalid` selector.
268    pub fn parse_or_invalid(raw: &str, source: &str) -> Self {
269        match Self::parse(raw) {
270            Ok(selector) => selector,
271            Err(reason) => Self::Invalid {
272                source: source.to_string(),
273                reason,
274            },
275        }
276    }
277
278    pub fn as_config_value(&self) -> String {
279        match self {
280            Self::Account(account) => account.label_str().to_string(),
281            Self::Auto => AUTO_ACCOUNT_NAME.to_string(),
282            Self::Invalid { .. } => String::new(),
283        }
284    }
285}
286
287/// Environment variable that selects the account for a provider:
288/// `SYNAPS_ACCOUNT_<PROVIDER>` with `-` → `_`, upper-cased.
289pub fn account_env_var(provider: OAuthProviderId) -> String {
290    format!(
291        "SYNAPS_ACCOUNT_{}",
292        provider.as_str().to_ascii_uppercase().replace('-', "_")
293    )
294}
295
296/// Config key carrying the selected account for a provider.
297pub fn account_config_key(provider: OAuthProviderId) -> String {
298    format!("auth.account.{}", provider.as_str())
299}
300
301/// Per-provider account selection. Built once from config (+ env overlay);
302/// never mutates the process environment or the active profile.
303#[derive(Clone, Debug, Default, PartialEq, Eq)]
304pub struct AccountPolicy {
305    per_provider: BTreeMap<OAuthProviderId, AccountSelector>,
306}
307
308impl AccountPolicy {
309    pub fn new() -> Self {
310        Self::default()
311    }
312
313    pub fn set(&mut self, provider: OAuthProviderId, selector: AccountSelector) {
314        self.per_provider.insert(provider, selector);
315    }
316
317    pub fn with(mut self, provider: OAuthProviderId, selector: AccountSelector) -> Self {
318        self.set(provider, selector);
319        self
320    }
321
322    pub fn selector(&self, provider: OAuthProviderId) -> AccountSelector {
323        self.per_provider
324            .get(&provider)
325            .cloned()
326            .unwrap_or(AccountSelector::Account(Account::Default))
327    }
328
329    pub fn is_empty(&self) -> bool {
330        self.per_provider.is_empty()
331    }
332
333    /// Build from the `auth.account.<provider> = <label|auto>` map.
334    ///
335    /// Fail closed: a malformed label for a known provider becomes
336    /// [`AccountSelector::Invalid`], so every later resolution for that
337    /// provider errors instead of quietly using the default (or any other)
338    /// account. Unknown providers cannot affect resolution and are reported
339    /// as warnings only. Empty values mean "unset".
340    pub fn from_config_map(map: &BTreeMap<String, String>) -> (Self, Vec<String>) {
341        let mut policy = Self::new();
342        let mut warnings = Vec::new();
343        for (provider, value) in map {
344            if value.trim().is_empty() {
345                continue;
346            }
347            let source = format!("auth.account.{provider}");
348            let Ok(provider_id) = provider.parse::<OAuthProviderId>() else {
349                warnings.push(format!("{source}: unknown OAuth provider (ignored)"));
350                continue;
351            };
352            let selector = AccountSelector::parse_or_invalid(value, &source);
353            if let AccountSelector::Invalid { reason, .. } = &selector {
354                warnings.push(format!(
355                    "{source}: {reason} — account selection for {provider} is disabled until fixed"
356                ));
357            }
358            policy.set(provider_id, selector);
359        }
360        (policy, warnings)
361    }
362
363    /// Apply `SYNAPS_ACCOUNT_<PROVIDER>` overrides on top of `self`. A
364    /// malformed value fails closed for that provider (a typo must never
365    /// redirect traffic to a different account). Empty values are ignored.
366    pub fn with_env_overlay(mut self) -> Self {
367        for descriptor in super::provider::DESCRIPTORS.iter() {
368            let var = account_env_var(descriptor.id);
369            let Ok(raw) = std::env::var(&var) else {
370                continue;
371            };
372            if raw.trim().is_empty() {
373                continue;
374            }
375            let selector = AccountSelector::parse_or_invalid(&raw, &var);
376            if let AccountSelector::Invalid { reason, .. } = &selector {
377                tracing::warn!(var = %var, "invalid account selector; failing closed: {reason}");
378            }
379            self.set(descriptor.id, selector);
380        }
381        self
382    }
383
384    /// Precedence: env `SYNAPS_ACCOUNT_<PROVIDER>` > config
385    /// `auth.account.<provider>` > default. Reads the active profile's config
386    /// file; never writes anything.
387    pub fn from_environment() -> Self {
388        let (policy, _warnings) = Self::from_config_map(&crate::config::load_config().auth.accounts);
389        policy.with_env_overlay()
390    }
391
392    /// Resolve the credential to use for `provider` when the caller did not
393    /// pin one. `Auto` is not resolvable here (the broker consults its
394    /// capacity selector); `Invalid` always errors.
395    pub fn resolve(&self, provider: OAuthProviderId) -> Result<CredentialRef, BrokerError> {
396        match self.selector(provider) {
397            AccountSelector::Account(account) => Ok(CredentialRef::new(provider, account)),
398            AccountSelector::Auto => Err(BrokerError::NoAccountAvailable {
399                provider: provider.as_str().to_string(),
400                reason: "automatic account selection requires a capacity selector".into(),
401                failure: None,
402            }),
403            AccountSelector::Invalid { source, reason } => Err(BrokerError::InvalidAccount(
404                format!("{source}: {reason}"),
405            )),
406        }
407    }
408}
409
410// ── Listing row ──────────────────────────────────────────────────────────────
411
412/// Non-secret listing row for one stored account. Never carries token
413/// material; `account_id_prefix` is at most 8 characters.
414///
415/// Construct with [`AccountSummary::new`] and set the optional fields you
416/// know; new optional fields are added with serde defaults so older brokers
417/// and clients keep interoperating.
418#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
419pub struct AccountSummary {
420    pub provider: String,
421    pub label: String,
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub identity: Option<String>,
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub account_id_prefix: Option<String>,
426    /// Stable, opaque fingerprint of the provider seat this slot holds
427    /// ([`seat_fingerprint`] over the FULL provider account id). Two aliases
428    /// of one seat share it; a re-login with another seat changes it. `None`
429    /// when the provider exposes no account id — consumers that must act on
430    /// exactly one seat (activation, spending) fail closed on `None`.
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub seat_fingerprint: Option<String>,
433    /// Access-token expiry (epoch ms); `0` when unknown.
434    #[serde(default)]
435    pub expires: u64,
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub added_at: Option<u64>,
438    /// True when the active policy would select this account.
439    #[serde(default)]
440    pub selected: bool,
441    /// Broker-side cooldown (epoch ms) after a reported provider limit.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub cooldown_until: Option<u64>,
444}
445
446impl AccountSummary {
447    /// Row for one slot with every optional field unset.
448    pub fn new(provider: OAuthProviderId, account: &Account) -> Self {
449        Self {
450            provider: provider.as_str().to_string(),
451            label: account.label_str().to_string(),
452            ..Self::default()
453        }
454    }
455
456    /// Set both identity views derived from the provider's account id: the
457    /// short display prefix and the full-id fingerprint.
458    pub fn with_account_id(mut self, account_id: Option<&str>) -> Self {
459        self.account_id_prefix = account_id.and_then(account_id_prefix);
460        self.seat_fingerprint = self
461            .provider
462            .parse::<OAuthProviderId>()
463            .ok()
464            .zip(account_id)
465            .and_then(|(provider, id)| seat_fingerprint(provider, id));
466        self
467    }
468
469    pub fn credential_ref(&self) -> Option<CredentialRef> {
470        let provider: OAuthProviderId = self.provider.parse().ok()?;
471        let account = Account::parse(&self.label).ok()?;
472        Some(CredentialRef::new(provider, account))
473    }
474}
475
476/// Stable, opaque seat fingerprint: hex SHA-256 over a domain tag, the
477/// provider id and the provider's FULL account id (e.g. the Codex
478/// `chatgpt_account_id` claim). Deterministic across processes and hosts, so
479/// a consumer holding a vended token can derive the same value from that
480/// token's claim and compare it with [`AccountSummary::seat_fingerprint`]
481/// before spending on the listed seat. Never derived from token material.
482pub fn seat_fingerprint(provider: OAuthProviderId, account_id: &str) -> Option<String> {
483    let id = account_id.trim();
484    if id.is_empty() {
485        return None;
486    }
487    Some(hex_digest(&[b"synaps-seat-v1", provider.as_str().as_bytes(), id.as_bytes()]))
488}
489
490fn hex_digest(parts: &[&[u8]]) -> String {
491    use sha2::{Digest, Sha256};
492    let mut hasher = Sha256::new();
493    for part in parts {
494        hasher.update(part);
495        hasher.update([0u8]);
496    }
497    format!("{:x}", hasher.finalize())
498}
499
500/// Identity of the credential material currently stored in one slot. Scopes
501/// per-slot caches and pairs a proven-capacity reading with the token vended
502/// for it: the provider seat (via [`seat_fingerprint`]) when the credential
503/// carries an account id — stable across token rotation — otherwise a digest
504/// of the refresh material, which changes on re-login and on rotation. An
505/// opaque digest: not reversible, never logged with token material.
506#[derive(Clone, PartialEq, Eq)]
507pub struct SeatIdentity(String);
508
509impl SeatIdentity {
510    pub fn of(provider: OAuthProviderId, creds: &super::OAuthCredentials) -> Self {
511        if let Some(fp) = creds
512            .account_id
513            .as_deref()
514            .and_then(|id| seat_fingerprint(provider, id))
515        {
516            return Self(format!("id:{fp}"));
517        }
518        let material = if creds.refresh.is_empty() {
519            creds.access.as_bytes()
520        } else {
521            creds.refresh.as_bytes()
522        };
523        Self(format!(
524            "cred:{}",
525            hex_digest(&[b"synaps-cred-v1", provider.as_str().as_bytes(), material])
526        ))
527    }
528
529    /// Seat fingerprint when this identity is a provider account id.
530    pub fn seat_fingerprint(&self) -> Option<&str> {
531        self.0.strip_prefix("id:")
532    }
533
534    pub fn as_str(&self) -> &str {
535        &self.0
536    }
537}
538
539impl fmt::Debug for SeatIdentity {
540    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
541        // Short preview only: enough to tell two identities apart in a trace.
542        let (kind, digest) = self.0.split_once(':').unwrap_or(("?", &self.0));
543        write!(f, "SeatIdentity({kind}:{}…)", &digest[..digest.len().min(8)])
544    }
545}
546
547/// Short, non-reversible identity preview (`account_id_prefix`).
548pub(crate) fn account_id_prefix(account_id: &str) -> Option<String> {
549    let trimmed = account_id.trim();
550    if trimmed.is_empty() {
551        return None;
552    }
553    Some(trimmed.chars().take(8).collect())
554}
555
556/// Process-wide lock for tests that mutate account-selection environment
557/// variables (global state shared by every test thread).
558#[cfg(test)]
559pub(crate) fn env_lock() -> std::sync::MutexGuard<'static, ()> {
560    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
561    ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn label_grammar() {
570        for ok in ["a", "astra2", "m27", "work.eu", "x_y-z", "0", &"a".repeat(32)] {
571            assert!(AccountLabel::parse(ok).is_ok(), "{ok} must be valid");
572        }
573        for bad in [
574            "",
575            " ",
576            "default",
577            "auto",
578            "Astra",
579            "-lead",
580            ".dot",
581            "with space",
582            "a@b",
583            "a/b",
584            "../x",
585            "émoji",
586            &"a".repeat(33),
587        ] {
588            assert!(AccountLabel::parse(bad).is_err(), "{bad:?} must be rejected");
589        }
590    }
591
592    #[test]
593    fn account_parse_default_and_named() {
594        assert!(Account::parse("").is_err(), "explicit empty account must error");
595        assert!(Account::parse("   ").is_err());
596        assert_eq!(Account::parse("default").unwrap(), Account::Default);
597        assert_eq!(Account::parse(" default ").unwrap(), Account::Default);
598        assert_eq!(Account::parse("astra2").unwrap().label_str(), "astra2");
599        assert!(Account::parse("Bad").is_err());
600        assert!(Account::parse("auto").is_err(), "auto is a selector, not an account");
601    }
602
603    #[test]
604    fn storage_key_round_trip() {
605        let default = CredentialRef::default_for(OAuthProviderId::OpenAiCodex);
606        assert_eq!(default.storage_key(), "openai-codex");
607        let named = CredentialRef::new(
608            OAuthProviderId::OpenAiCodex,
609            Account::named("astra2").unwrap(),
610        );
611        assert_eq!(named.storage_key(), "openai-codex@astra2");
612        assert_eq!(
613            CredentialRef::parse_storage_key("openai-codex").unwrap(),
614            default
615        );
616        assert_eq!(
617            CredentialRef::parse_storage_key("openai-codex@astra2").unwrap(),
618            named
619        );
620        assert_eq!(
621            CredentialRef::parse_storage_key("anthropic@m27").unwrap().provider,
622            OAuthProviderId::Anthropic
623        );
624    }
625
626    #[test]
627    fn parse_storage_key_rejects_non_oauth_and_malformed() {
628        for key in [
629            "groq",
630            "aws-bedrock",
631            "local",
632            "unknown@x",
633            "openai-codex@",
634            "openai-codex@default",
635            "openai-codex@auto",
636            "openai-codex@Bad",
637            "openai-codex@a@b",
638            "@astra2",
639            "",
640        ] {
641            assert!(
642                CredentialRef::parse_storage_key(key).is_none(),
643                "{key:?} must not parse"
644            );
645        }
646    }
647
648    #[test]
649    fn key_belongs_to_provider_prefix_is_exact() {
650        assert!(CredentialRef::key_belongs_to("kimi-code", OAuthProviderId::KimiCode));
651        assert!(CredentialRef::key_belongs_to("kimi-code@BAD", OAuthProviderId::KimiCode));
652        assert!(!CredentialRef::key_belongs_to("kimi", OAuthProviderId::KimiCode));
653        assert!(!CredentialRef::key_belongs_to("kimi-code2", OAuthProviderId::KimiCode));
654    }
655
656    #[test]
657    fn env_and_config_key_names() {
658        assert_eq!(
659            account_env_var(OAuthProviderId::OpenAiCodex),
660            "SYNAPS_ACCOUNT_OPENAI_CODEX"
661        );
662        assert_eq!(account_env_var(OAuthProviderId::Xai), "SYNAPS_ACCOUNT_XAI_AUTH");
663        assert_eq!(
664            account_config_key(OAuthProviderId::KimiCode),
665            "auth.account.kimi-code"
666        );
667    }
668
669    #[test]
670    fn policy_from_config_map_fails_closed_on_invalid() {
671        let mut map = BTreeMap::new();
672        map.insert("openai-codex".to_string(), "astra2".to_string());
673        map.insert("anthropic".to_string(), "auto".to_string());
674        map.insert("kimi-code".to_string(), "Bad Label".to_string());
675        map.insert("xai-auth".to_string(), "".to_string());
676        map.insert("groq".to_string(), "x".to_string());
677        let (policy, warnings) = AccountPolicy::from_config_map(&map);
678        assert_eq!(
679            policy.selector(OAuthProviderId::OpenAiCodex),
680            AccountSelector::Account(Account::named("astra2").unwrap())
681        );
682        assert_eq!(
683            policy.selector(OAuthProviderId::Anthropic),
684            AccountSelector::Auto
685        );
686        assert!(
687            matches!(
688                policy.selector(OAuthProviderId::KimiCode),
689                AccountSelector::Invalid { .. }
690            ),
691            "malformed label must fail closed, never default"
692        );
693        assert!(matches!(
694            policy.resolve(OAuthProviderId::KimiCode),
695            Err(BrokerError::InvalidAccount(_))
696        ));
697        assert_eq!(
698            policy.selector(OAuthProviderId::Xai),
699            AccountSelector::Account(Account::Default),
700            "empty value means unset"
701        );
702        assert_eq!(warnings.len(), 2, "{warnings:?}");
703        assert!(warnings.iter().any(|w| w.contains("kimi-code")));
704        assert!(warnings.iter().any(|w| w.contains("groq")));
705        let err = policy.resolve(OAuthProviderId::KimiCode).unwrap_err().to_string();
706        assert!(err.contains("auth.account.kimi-code"), "{err}");
707    }
708
709    #[test]
710    fn env_overlay_overrides_config_and_fails_closed_on_garbage() {
711        // Serialized through a process-wide lock: env vars are global state.
712        let _guard = env_lock();
713        let var = account_env_var(OAuthProviderId::GitHubCopilot);
714        let mut map = BTreeMap::new();
715        map.insert("github-copilot".to_string(), "cfg".to_string());
716        std::env::set_var(&var, "envlabel");
717        let (policy, _) = AccountPolicy::from_config_map(&map);
718        let policy = policy.with_env_overlay();
719        assert_eq!(
720            policy.resolve(OAuthProviderId::GitHubCopilot).unwrap().storage_key(),
721            "github-copilot@envlabel"
722        );
723        std::env::set_var(&var, "NOT VALID");
724        let (policy, _) = AccountPolicy::from_config_map(&map);
725        let policy = policy.with_env_overlay();
726        assert!(matches!(
727            policy.resolve(OAuthProviderId::GitHubCopilot),
728            Err(BrokerError::InvalidAccount(_))
729        ));
730        std::env::set_var(&var, "");
731        let (policy, _) = AccountPolicy::from_config_map(&map);
732        let policy = policy.with_env_overlay();
733        assert_eq!(
734            policy.resolve(OAuthProviderId::GitHubCopilot).unwrap().storage_key(),
735            "github-copilot@cfg",
736            "empty env value is unset"
737        );
738        std::env::remove_var(&var);
739    }
740
741    #[test]
742    fn policy_resolve_default_named_and_auto() {
743        let policy = AccountPolicy::new()
744            .with(
745                OAuthProviderId::OpenAiCodex,
746                AccountSelector::Account(Account::named("b").unwrap()),
747            )
748            .with(OAuthProviderId::Anthropic, AccountSelector::Auto);
749        assert_eq!(
750            policy.resolve(OAuthProviderId::OpenAiCodex).unwrap().storage_key(),
751            "openai-codex@b"
752        );
753        assert_eq!(
754            policy.resolve(OAuthProviderId::KimiCode).unwrap().storage_key(),
755            "kimi-code"
756        );
757        assert!(matches!(
758            policy.resolve(OAuthProviderId::Anthropic),
759            Err(BrokerError::NoAccountAvailable { .. })
760        ));
761    }
762
763    #[test]
764    fn credential_ref_serde_round_trip_and_validation() {
765        let cred = CredentialRef::new(OAuthProviderId::KimiCode, Account::named("m27").unwrap());
766        let json = serde_json::to_value(&cred).unwrap();
767        assert_eq!(json, serde_json::json!({"provider": "kimi-code", "account": "m27"}));
768        assert_eq!(serde_json::from_value::<CredentialRef>(json).unwrap(), cred);
769        assert!(serde_json::from_str::<CredentialRef>(
770            r#"{"provider":"groq","account":"default"}"#
771        )
772        .is_err());
773        assert!(serde_json::from_str::<CredentialRef>(
774            r#"{"provider":"kimi-code","account":"Bad"}"#
775        )
776        .is_err());
777    }
778
779    #[test]
780    fn account_serde_round_trip_and_validation() {
781        let json = serde_json::to_string(&Account::named("astra2").unwrap()).unwrap();
782        assert_eq!(json, "\"astra2\"");
783        assert_eq!(
784            serde_json::from_str::<Account>("\"default\"").unwrap(),
785            Account::Default
786        );
787        assert!(serde_json::from_str::<Account>("\"Bad\"").is_err());
788    }
789
790    #[test]
791    fn summary_has_no_secret_fields() {
792        let summary = AccountSummary {
793            identity: Some("user@example.com".into()),
794            expires: 1,
795            added_at: Some(2),
796            selected: true,
797            ..AccountSummary::new(OAuthProviderId::OpenAiCodex, &Account::named("astra2").unwrap())
798                .with_account_id(Some("2b2f0000-aaaa-bbbb"))
799        };
800        let json = serde_json::to_value(&summary).unwrap();
801        let keys: Vec<&str> = json.as_object().unwrap().keys().map(String::as_str).collect();
802        for forbidden in ["access", "refresh", "token", "key"] {
803            assert!(!keys.contains(&forbidden));
804        }
805        assert_eq!(json["account_id_prefix"], "2b2f0000");
806        assert_eq!(summary.credential_ref().unwrap().storage_key(), "openai-codex@astra2");
807        // Full-id fingerprint: opaque, stable, and not the id itself.
808        let fp = summary.seat_fingerprint.clone().unwrap();
809        assert_eq!(fp.len(), 64);
810        assert!(!fp.contains("2b2f0000"));
811        assert_eq!(
812            Some(fp),
813            seat_fingerprint(OAuthProviderId::OpenAiCodex, "2b2f0000-aaaa-bbbb")
814        );
815        // Older peers without the field still deserialize (serde default).
816        let legacy: AccountSummary =
817            serde_json::from_str(r#"{"provider":"openai-codex","label":"x"}"#).unwrap();
818        assert_eq!(legacy.seat_fingerprint, None);
819    }
820
821    #[test]
822    fn seat_fingerprint_is_stable_per_provider_and_full_id() {
823        let a = seat_fingerprint(OAuthProviderId::OpenAiCodex, "acct_1").unwrap();
824        assert_eq!(seat_fingerprint(OAuthProviderId::OpenAiCodex, " acct_1 ").unwrap(), a);
825        assert_ne!(seat_fingerprint(OAuthProviderId::OpenAiCodex, "acct_10").unwrap(), a);
826        assert_ne!(seat_fingerprint(OAuthProviderId::Anthropic, "acct_1").unwrap(), a);
827        assert_eq!(seat_fingerprint(OAuthProviderId::OpenAiCodex, "  "), None);
828        // Same seat under another alias: same fingerprint.
829        let alias = AccountSummary::new(OAuthProviderId::OpenAiCodex, &Account::Default)
830            .with_account_id(Some("acct_1"));
831        assert_eq!(alias.seat_fingerprint.as_deref(), Some(a.as_str()));
832        assert_eq!(alias.account_id_prefix.as_deref(), Some("acct_1"));
833    }
834
835    #[test]
836    fn seat_identity_prefers_account_id_and_never_prints_material() {
837        let creds = |refresh: &str, id: Option<&str>| super::super::OAuthCredentials {
838            auth_type: "oauth".into(),
839            refresh: refresh.into(),
840            access: "access-SECRET".into(),
841            expires: 0,
842            account_id: id.map(str::to_string),
843        };
844        let p = OAuthProviderId::OpenAiCodex;
845        // Account id present: rotation of the refresh token keeps the seat.
846        let a1 = SeatIdentity::of(p, &creds("r1-SECRET", Some("acct_1")));
847        let a2 = SeatIdentity::of(p, &creds("r2-SECRET", Some("acct_1")));
848        assert_eq!(a1, a2);
849        assert_eq!(a1.seat_fingerprint(), seat_fingerprint(p, "acct_1").as_deref());
850        // Different seat under the same slot → different identity.
851        assert_ne!(a1, SeatIdentity::of(p, &creds("r1-SECRET", Some("acct_2"))));
852        // No account id: the refresh material decides (re-login changes it).
853        let b1 = SeatIdentity::of(p, &creds("r1-SECRET", None));
854        let b2 = SeatIdentity::of(p, &creds("r2-SECRET", None));
855        assert_ne!(b1, b2);
856        assert_eq!(b1, SeatIdentity::of(p, &creds("r1-SECRET", None)));
857        assert_eq!(b1.seat_fingerprint(), None);
858        for id in [&a1, &b1] {
859            let shown = format!("{id:?}{}", id.as_str());
860            assert!(!shown.contains("SECRET") && !shown.contains("acct_1"), "{shown}");
861        }
862    }
863}