agentmux_srv\identity/
resolver.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Identity → env-var resolver.
5//!
6//! Per-provider matrix of which env vars carry which credential. The
7//! GitHub PAT becomes both `GITHUB_TOKEN` and `GH_TOKEN` because both
8//! the official `gh` CLI and direct API consumers (curl, oct.js) read
9//! one or the other; emitting both is the lowest-friction way to make
10//! every common workflow Just Work.
11
12use std::collections::HashMap;
13use std::path::Path;
14use std::sync::Arc;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use crate::backend::storage::error::StoreError;
18use crate::backend::storage::wstore::{SecretRef, WaveStore};
19use crate::backend::wps::{Broker, WaveEvent};
20
21/// Canonical-value enumeration for OAuth-class `IdentityAccount.status`.
22///
23/// `IdentityAccount.status` is a `String` (free-form) at the SQLite layer
24/// — api-key rows keep using whatever the legacy paths wrote
25/// (`"unknown"`, `"ok"`, etc.). For oauth-class bindings we pin a small
26/// closed set per spec §4.4 so the frontend status-badge dispatch is
27/// deterministic and the resolver's expiry probe can never write an
28/// off-the-spec string. Every place the resolver SETS or READS an
29/// oauth-class status uses these constants.
30pub mod oauth_status {
31    /// Token file present and (probed) not expired.
32    pub const VALID: &str = "valid";
33    /// Access token expired; refresh likely succeeds.
34    pub const EXPIRED: &str = "expired";
35    /// Refresh rejected / file missing / parse error; user must Reconnect.
36    pub const NEEDS_REAUTH: &str = "needs_reauth";
37    /// Never probed (initial state on bundle import / unprobed provider).
38    pub const UNKNOWN: &str = "unknown";
39}
40
41/// Result of probing a per-bundle OAuth token directory.
42///
43/// Computed by [`probe_oauth_status`] reading the CLI's on-disk token
44/// file (e.g. `<dir>/.credentials.json` for Claude Code). Maps directly
45/// to [`oauth_status`] strings. Returned as an enum so the caller can
46/// branch without re-parsing the string.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum OAuthProbeStatus {
49    Valid,
50    Expired,
51    NeedsReauth,
52}
53
54impl OAuthProbeStatus {
55    pub fn as_str(self) -> &'static str {
56        match self {
57            Self::Valid => oauth_status::VALID,
58            Self::Expired => oauth_status::EXPIRED,
59            Self::NeedsReauth => oauth_status::NEEDS_REAUTH,
60        }
61    }
62}
63
64/// Cheap on-disk probe of the per-bundle OAuth token file for a
65/// provider. No network calls — just reads + parses the token JSON,
66/// then compares `expiresAt` against `now_ms`.
67///
68/// **Provider token-file shape (spec §4.4 + §4.5):**
69/// - `claude` — `<dir>/.credentials.json` with
70///   `{ "claudeAiOauth": { "accessToken", "refreshToken", "expiresAt": <ms> } }`
71///   (Anthropic's documented format — see
72///   `docs/specs/agentmux-isolated-auth.md` §1.6).
73/// - `codex` — `<dir>/.credentials.json` (MCP OAuth). Exact field
74///   layout undocumented by OpenAI; for now we treat presence-of-file
75///   as `Valid` and absence as `NeedsReauth`, deferring strict expiry
76///   parsing until the shape is pinned down. Falls through to the
77///   Claude parser as a best-effort — if the file is shape-compatible
78///   (some CLIs reuse Anthropic's format) the expiry check still works.
79/// - `openclaw` — same fallback as codex.
80///
81/// **Returns** `Some(status)` on a definitive read, `None` when probing
82/// isn't supported for the provider (so the caller skips status
83/// updates rather than mis-writing `needs_reauth` for a provider whose
84/// file we just don't know how to parse yet).
85pub fn probe_oauth_status(
86    provider: &str,
87    dir: &str,
88    now_ms: i64,
89) -> Option<OAuthProbeStatus> {
90    let probe_path: std::path::PathBuf = match provider {
91        // Claude Code + codex + openclaw all write to
92        // `<config_dir>/.credentials.json` per
93        // `docs/specs/provider-auth-isolation.md` (the agentmux-managed
94        // dir is what CLAUDE_CONFIG_DIR / CODEX_HOME / OPENCLAW_HOME
95        // point at). Codex / openclaw token field-layout is not
96        // publicly documented; the parser below treats unrecognised
97        // shapes as `Valid` so we don't false-positive a Reconnect on
98        // a working session — strict expiry parsing for those two is
99        // a follow-up once their JSON is pinned down.
100        "claude" | "codex" | "openclaw" => Path::new(dir).join(".credentials.json"),
101        _ => return None,
102    };
103
104    let contents = match std::fs::read_to_string(&probe_path) {
105        Ok(s) => s,
106        Err(e) => {
107            tracing::debug!(
108                target: "identity",
109                provider,
110                path = %probe_path.display(),
111                error = %e,
112                "oauth probe: token file unreadable — status=needs_reauth"
113            );
114            return Some(OAuthProbeStatus::NeedsReauth);
115        }
116    };
117    let json: serde_json::Value = match serde_json::from_str(&contents) {
118        Ok(v) => v,
119        Err(e) => {
120            tracing::debug!(
121                target: "identity",
122                provider,
123                path = %probe_path.display(),
124                error = %e,
125                "oauth probe: token file parse failed — status=needs_reauth"
126            );
127            return Some(OAuthProbeStatus::NeedsReauth);
128        }
129    };
130
131    // Claude shape — `claudeAiOauth.expiresAt` is ms since epoch.
132    // Many shape-compatible providers nest under the same key; try
133    // that first, then fall back to any top-level `expiresAt` /
134    // `expires_at` an alternative provider might use.
135    let expires_at_ms = json
136        .get("claudeAiOauth")
137        .and_then(|o| o.get("expiresAt"))
138        .and_then(|v| v.as_i64())
139        .or_else(|| json.get("expiresAt").and_then(|v| v.as_i64()))
140        .or_else(|| json.get("expires_at").and_then(|v| v.as_i64()));
141
142    let has_refresh = json
143        .get("claudeAiOauth")
144        .and_then(|o| o.get("refreshToken"))
145        .and_then(|v| v.as_str())
146        .map(|s| !s.is_empty())
147        .unwrap_or(false)
148        || json
149            .get("refresh_token")
150            .and_then(|v| v.as_str())
151            .map(|s| !s.is_empty())
152            .unwrap_or(false);
153
154    match expires_at_ms {
155        Some(exp) if exp <= now_ms => {
156            // Past expiry. If a refresh token is present, the next
157            // CLI call will likely refresh it cleanly → `expired`
158            // (transient, not user-actionable). Without a refresh
159            // token the user must re-OAuth → `needs_reauth`.
160            if has_refresh {
161                Some(OAuthProbeStatus::Expired)
162            } else {
163                Some(OAuthProbeStatus::NeedsReauth)
164            }
165        }
166        Some(_) => Some(OAuthProbeStatus::Valid),
167        None => {
168            // Shape doesn't expose an expiry we can parse. Treat the
169            // file's existence as `Valid` rather than guess — false
170            // `needs_reauth` would force the user to reconnect a
171            // working session. codex / openclaw fall here today.
172            tracing::debug!(
173                target: "identity",
174                provider,
175                path = %probe_path.display(),
176                "oauth probe: file present but no parseable expiry — status=valid (best-effort)"
177            );
178            Some(OAuthProbeStatus::Valid)
179        }
180    }
181}
182
183/// Errors specific to the resolver. Every variant is recoverable
184/// (the spawn proceeds with whatever env vars resolved successfully)
185/// — they exist for tracing visibility, not control flow.
186#[derive(Debug, thiserror::Error)]
187pub enum ResolverError {
188    #[error("account not found: {0}")]
189    AccountNotFound(String),
190
191    #[error("env var not set in srv environment: {0}")]
192    EnvVarMissing(String),
193
194    #[error("AWS Secrets Manager backend not yet supported (Phase 3)")]
195    SecretsManagerUnsupported,
196
197    #[error("PlaintextDev secrets are disabled in release builds")]
198    PlaintextDevDisabledInRelease,
199
200    /// `OAuthConfigDir` is a filesystem pointer, not a secret string —
201    /// `resolve_secret` cannot turn it into a credential value because
202    /// the credential lives in a CLI-managed token file inside the dir.
203    /// Oauth-class providers must be routed through the config-dir
204    /// injection path that PR B adds to `inject_identity_env`. Seeing
205    /// this error from `resolve_secret` means the caller forgot to
206    /// dispatch by provider class first.
207    #[error("OAuthConfigDir is a config-dir pointer, not a resolvable secret — routed via the oauth-class injection path, not resolve_secret")]
208    OAuthConfigDirNotASecret,
209
210    #[error("storage error: {0}")]
211    Storage(#[from] StoreError),
212}
213
214/// What kind of credential a provider uses, and how
215/// `inject_identity_env` puts it into the agent's env at spawn time.
216/// Per `SPEC_OAUTH_IDENTITY_BUNDLES_2026_05_22.md` §4.3.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum ProviderClass {
219    /// **API-key class.** The binding's `SecretRef` resolves to a
220    /// single secret string, injected as the listed env vars. All
221    /// listed vars receive the same value — multi-var emission
222    /// covers "two CLIs want different var names for the same secret"
223    /// (e.g. github writes both `GITHUB_TOKEN` and `GH_TOKEN`).
224    ApiKey { env_vars: &'static [&'static str] },
225    /// **OAuth class.** The binding's `SecretRef` is a
226    /// `SecretRef::OAuthConfigDir` pointer; the resolver sets
227    /// `config_dir_env_var = <dir>` at spawn so the CLI reads its
228    /// OAuth tokens from the per-bundle directory.
229    OAuth { config_dir_env_var: &'static str },
230}
231
232/// Classify a provider id. `None` for unknown providers — the
233/// resolver logs and skips them.
234pub fn provider_class(provider: &str) -> Option<ProviderClass> {
235    match provider {
236        // ── API-key class ─────────────────────────────────────────
237        // ApiKey.env_vars values match the legacy provider_env_vars
238        // matrix exactly — the new dispatch is additive.
239        "github" => Some(ProviderClass::ApiKey {
240            env_vars: &["GITHUB_TOKEN", "GH_TOKEN"],
241        }),
242        "anthropic" => Some(ProviderClass::ApiKey {
243            env_vars: &["ANTHROPIC_API_KEY"],
244        }),
245        "openai" => Some(ProviderClass::ApiKey {
246            env_vars: &["OPENAI_API_KEY"],
247        }),
248        "kimi" => Some(ProviderClass::ApiKey {
249            env_vars: &["MOONSHOT_API_KEY"],
250        }),
251        "aws" => Some(ProviderClass::ApiKey {
252            env_vars: &["AWS_ACCESS_KEY_ID"],
253        }),
254        // ── OAuth class ───────────────────────────────────────────
255        // Env-var names come from the CLI provider registry
256        // (`agentmux-srv/src/backend/providers.rs` —
257        // `ProviderConfig::auth_config_dir_env_var`) so the resolver
258        // can never drift from the launcher spawn path: there is one
259        // source of truth per CLI for which env var redirects its
260        // config / auth directory. The match arm enumerates which
261        // providers we currently treat as OAuth-class for identity
262        // bundles (claude / codex / openclaw — per spec §4.3); the
263        // env-var string is read from the registry, not duplicated.
264        "claude" | "codex" | "openclaw" => {
265            crate::backend::providers::get_provider(provider).map(|cfg| {
266                ProviderClass::OAuth {
267                    config_dir_env_var: cfg.auth_config_dir_env_var,
268                }
269            })
270        }
271        _ => None,
272    }
273}
274
275/// Legacy convenience: env vars for an api-key provider. Delegates to
276/// [`provider_class`]; returns empty for oauth-class providers (their
277/// resolution path doesn't go through string-secret env-var injection)
278/// and for unknown providers.
279pub fn provider_env_vars(provider: &str) -> Vec<&'static str> {
280    match provider_class(provider) {
281        Some(ProviderClass::ApiKey { env_vars }) => env_vars.to_vec(),
282        _ => Vec::new(),
283    }
284}
285
286/// Resolve a `SecretRef` to the plaintext credential string. Each
287/// backend has a distinct path:
288///
289/// - **Env**: read `env_var` from the srv process's own environment.
290///   Caller is expected to have set this in their shell or a
291///   .env-style loader before launching AgentMux.
292/// - **PlaintextDev**: return the literal stored string. **Debug
293///   builds only** — guarded behind `cfg(debug_assertions)`. In
294///   release builds, the same call returns
295///   [`ResolverError::PlaintextDevDisabledInRelease`] so a forgotten
296///   dev-secret never leaks into a packaged binary. Reagent P1 on
297///   PR #751 caught the missing guard. Phase 3's encrypted vault is
298///   the production path.
299/// - **SecretsManager**: deferred. Returns
300///   [`ResolverError::SecretsManagerUnsupported`].
301pub fn resolve_secret(secret_ref: &SecretRef) -> Result<String, ResolverError> {
302    match secret_ref {
303        SecretRef::Env { env_var } => std::env::var(env_var)
304            .map_err(|_| ResolverError::EnvVarMissing(env_var.clone())),
305        SecretRef::PlaintextDev { plaintext_dev } => {
306            #[cfg(debug_assertions)]
307            {
308                Ok(plaintext_dev.clone())
309            }
310            #[cfg(not(debug_assertions))]
311            {
312                let _ = plaintext_dev;
313                Err(ResolverError::PlaintextDevDisabledInRelease)
314            }
315        }
316        SecretRef::SecretsManager { .. } => Err(ResolverError::SecretsManagerUnsupported),
317        SecretRef::OAuthConfigDir { dir } => {
318            // Read but ignored — the resolver doesn't consume the
319            // pointer; PR B's oauth-class dispatch in
320            // `inject_identity_env` reads `dir` and sets the
321            // provider's config-dir env var directly, bypassing
322            // `resolve_secret` entirely.
323            let _ = dir;
324            Err(ResolverError::OAuthConfigDirNotASecret)
325        }
326    }
327}
328
329/// Inject identity-derived env vars into the spawn map for a block.
330///
331/// This is the public entry point called from the CLI-spawn paths
332/// (`AgentInputCommand` in websocket.rs and `AgentSendCommand` in
333/// app_api.rs). Resolution flow:
334///
335/// 1. Look up the active `AgentInstance` for this block. If none
336///    exists, the caller didn't go through the launch modal — return
337///    immediately, no injection.
338/// 2. Read its `identity_id`. Empty / "blank" → no injection (the
339///    user picked the blank singleton at launch, meaning "use ambient
340///    creds").
341/// 3. Read the `db_identity_bindings` rows for that identity_id.
342/// 4. For each binding: fetch the account, resolve its `SecretRef`,
343///    look up the provider's env-var matrix, write each var into
344///    `env_vars`. Any per-binding failure is logged and skipped —
345///    other bindings still inject. The agent CLI launches with
346///    whatever resolved cleanly plus whatever ambient env was already
347///    in the spawn map.
348///
349/// This function is intentionally infallible at the top level. It
350/// has no `Result`, just side-effects on `env_vars` and `tracing::warn`
351/// for every per-binding error. The spawn never aborts because a
352/// secret didn't resolve.
353pub fn inject_identity_env(
354    wstore: Arc<WaveStore>,
355    block_id: &str,
356    env_vars: &mut HashMap<String, String>,
357) {
358    inject_identity_env_with_broker(wstore, None, block_id, env_vars);
359}
360
361/// `inject_identity_env` + optional broker handle so the OAuth-class
362/// branch can publish `identitybundlebindings:changed:<bundle_id>` on a
363/// status change discovered by the expiry probe. The broker is
364/// `Option<Arc<Broker>>` — `None` (the legacy entry point, kept for
365/// test ergonomics) skips the publish; in production both call sites
366/// (`app_api.rs` AgentSendCommand + `websocket.rs` AgentInputCommand)
367/// pass `Some(broker.clone())` so the IdentityManager's bindings table
368/// flips its status badge without a reload. Per spec §4.4.
369pub fn inject_identity_env_with_broker(
370    wstore: Arc<WaveStore>,
371    broker: Option<Arc<Broker>>,
372    block_id: &str,
373    env_vars: &mut HashMap<String, String>,
374) {
375    // Step 1: instance lookup.
376    let instance = match wstore.instance_get_active_for_block(block_id) {
377        Ok(Some(i)) => i,
378        Ok(None) => {
379            // Block has no agent instance row — nothing to inject.
380            return;
381        }
382        Err(e) => {
383            tracing::warn!(target: "identity", "instance lookup failed for block {}: {}", block_id, e);
384            return;
385        }
386    };
387
388    // Step 2: identity_id check.
389    if instance.identity_id.is_empty() || instance.identity_id == "blank" {
390        // Empty or legacy "blank" sentinel → ambient creds (no
391        // injection). The UI no longer produces these for new
392        // launches (identity is now required at submit-time —
393        // SPEC_LAUNCH_MODAL_STATE_MACHINE_2026_05_19.md), so seeing
394        // one here means either a legacy continuation row or a UI
395        // regression. Warn so the regression is visible in logs.
396        tracing::warn!(
397            target: "identity",
398            "instance {} has empty/blank identity_id — falling back to ambient creds. \
399             Legacy row or UI regression?",
400            block_id
401        );
402        return;
403    }
404
405    // Step 3: bindings.
406    let bindings = match wstore.bundle_identity_bindings(&instance.identity_id) {
407        Ok(b) => b,
408        Err(e) => {
409            tracing::warn!(
410                target: "identity",
411                "bindings lookup failed for identity {}: {}",
412                instance.identity_id,
413                e,
414            );
415            return;
416        }
417    };
418
419    if bindings.is_empty() {
420        // Identity exists but has no accounts bound. Nothing to inject.
421        return;
422    }
423
424    // Step 4: per-binding resolution + env injection.
425    //
426    // Each binding's provider determines HOW its account contributes
427    // to the agent's env (SPEC_OAUTH_IDENTITY_BUNDLES §4.3):
428    //   - ApiKey  — resolve secret_ref to a string, inject as env var(s).
429    //   - OAuth   — expect SecretRef::OAuthConfigDir, inject its dir
430    //               as the provider's config-dir env var.
431    //
432    // Per-binding failures (unknown provider, account row missing,
433    // mismatched secret_ref, secret resolution failed) are logged and
434    // skipped — other bindings still inject.
435    for binding in &bindings {
436        let class = match provider_class(&binding.provider) {
437            Some(c) => c,
438            None => {
439                tracing::warn!(
440                    target: "identity",
441                    "no provider class for {} (binding for identity {}) — skipping",
442                    binding.provider,
443                    instance.identity_id,
444                );
445                continue;
446            }
447        };
448
449        let account = match wstore.identity_get(&binding.account_id) {
450            Ok(Some(a)) => a,
451            Ok(None) => {
452                tracing::warn!(
453                    target: "identity",
454                    "account {} bound to identity {} but row not found — skipping",
455                    binding.account_id,
456                    instance.identity_id,
457                );
458                continue;
459            }
460            Err(e) => {
461                tracing::warn!(
462                    target: "identity",
463                    "account lookup failed for {}: {}",
464                    binding.account_id,
465                    e,
466                );
467                continue;
468            }
469        };
470
471        match class {
472            ProviderClass::ApiKey { env_vars: env_keys } => {
473                let secret = match resolve_secret(&account.secret_ref) {
474                    Ok(s) => s,
475                    Err(e) => {
476                        tracing::warn!(
477                            target: "identity",
478                            "secret resolution failed for account {} (provider {}): {} — skipping",
479                            binding.account_id,
480                            binding.provider,
481                            e,
482                        );
483                        continue;
484                    }
485                };
486                let env_key_count = env_keys.len();
487                for key in env_keys {
488                    env_vars.insert(key.to_string(), secret.clone());
489                }
490                tracing::info!(
491                    target: "identity",
492                    "injected {} env var(s) for api-key provider {} (identity={}, account={})",
493                    env_key_count,
494                    binding.provider,
495                    instance.identity_id,
496                    binding.account_id,
497                );
498            }
499            ProviderClass::OAuth { config_dir_env_var } => {
500                // OAuth-class bindings expect SecretRef::OAuthConfigDir.
501                // Any other variant is a misconfiguration — log and
502                // skip rather than mis-inject the wrong secret.
503                let dir = match &account.secret_ref {
504                    SecretRef::OAuthConfigDir { dir } => dir.clone(),
505                    other => {
506                        tracing::warn!(
507                            target: "identity",
508                            "oauth-class provider {} has non-OAuthConfigDir secret_ref \
509                             ({:?}) on account {} — skipping",
510                            binding.provider,
511                            other,
512                            binding.account_id,
513                        );
514                        continue;
515                    }
516                };
517                env_vars.insert(config_dir_env_var.to_string(), dir.clone());
518                tracing::info!(
519                    target: "identity",
520                    "injected {} for oauth provider {} (identity={}, account={})",
521                    config_dir_env_var,
522                    binding.provider,
523                    instance.identity_id,
524                    binding.account_id,
525                );
526
527                // Per spec §4.4 — cheap on-disk expiry probe. Reads the
528                // CLI's token file inside the bundle dir and refines
529                // the IdentityAccount's `status` so the UI can show
530                // valid/expired/needs_reauth. Best-effort: probe and
531                // upsert failures are logged + ignored (mirrors the
532                // per-binding "log + skip" pattern). The probe runs at
533                // every spawn but is a single `fs::read_to_string` +
534                // JSON parse — negligible overhead.
535                let now_ms = SystemTime::now()
536                    .duration_since(UNIX_EPOCH)
537                    .map(|d| d.as_millis() as i64)
538                    .unwrap_or(0);
539                if let Some(probed) = probe_oauth_status(&binding.provider, &dir, now_ms) {
540                    let new_status = probed.as_str();
541                    if account.status != new_status {
542                        let mut updated = account.clone();
543                        updated.status = new_status.to_string();
544                        updated.updated_at = now_ms;
545                        match wstore.identity_upsert(&updated) {
546                            Ok(()) => {
547                                tracing::info!(
548                                    target: "identity",
549                                    provider = %binding.provider,
550                                    account_id = %binding.account_id,
551                                    old_status = %account.status,
552                                    new_status,
553                                    "oauth probe: status updated"
554                                );
555                                // Publish bindings-changed so the
556                                // IdentityManager's Status column
557                                // refreshes without a reload. The
558                                // bindings list itself didn't change,
559                                // but the account row a binding points
560                                // at did — the UI fetches accounts
561                                // alongside bindings, so it's the same
562                                // subscription channel.
563                                if let Some(b) = broker.as_ref() {
564                                    b.publish(WaveEvent {
565                                        event: format!(
566                                            "identitybundlebindings:changed:{}",
567                                            instance.identity_id,
568                                        ),
569                                        scopes: vec![],
570                                        sender: String::new(),
571                                        persist: 0,
572                                        data: None,
573                                    });
574                                }
575                            }
576                            Err(e) => {
577                                tracing::warn!(
578                                    target: "identity",
579                                    provider = %binding.provider,
580                                    account_id = %binding.account_id,
581                                    error = %e,
582                                    "oauth probe: identity_upsert failed — status not persisted",
583                                );
584                            }
585                        }
586                    }
587                }
588            }
589        }
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use crate::backend::storage::wstore::{
597        AgentInstance, Identity, IdentityAccount, InstanceStatus, SecretRef,
598    };
599
600    fn make_store() -> Arc<WaveStore> {
601        Arc::new(WaveStore::open_in_memory().unwrap())
602    }
603
604    fn make_account(
605        id: &str,
606        provider: &str,
607        secret_ref: SecretRef,
608    ) -> IdentityAccount {
609        IdentityAccount {
610            id: id.to_string(),
611            name: format!("{}-{}", provider, id),
612            provider: provider.to_string(),
613            kind: "pat".to_string(),
614            display_name: String::new(),
615            secret_ref,
616            context: serde_json::json!({}),
617            status: "unknown".to_string(),
618            created_at: 0,
619            updated_at: 0,
620        }
621    }
622
623    fn make_instance(block_id: &str, identity_id: &str) -> AgentInstance {
624        AgentInstance {
625            id: format!("inst-{block_id}"),
626            definition_id: "def-1".to_string(),
627            parent_instance_id: String::new(),
628            block_id: block_id.to_string(),
629            session_id: String::new(),
630            status: InstanceStatus::Running.as_str().to_string(),
631            github_context: String::new(),
632            started_at: 0,
633            ended_at: 0,
634            created_at: 0,
635            identity_id: identity_id.to_string(),
636            memory_id: String::new(),
637            instance_name: String::new(),
638            working_directory: String::new(),
639            display_hidden: false,
640        }
641    }
642
643    #[test]
644    fn provider_env_vars_matrix() {
645        assert_eq!(provider_env_vars("github"), vec!["GITHUB_TOKEN", "GH_TOKEN"]);
646        assert_eq!(provider_env_vars("anthropic"), vec!["ANTHROPIC_API_KEY"]);
647        assert_eq!(provider_env_vars("openai"), vec!["OPENAI_API_KEY"]);
648        assert_eq!(provider_env_vars("kimi"), vec!["MOONSHOT_API_KEY"]);
649        assert_eq!(provider_env_vars("aws"), vec!["AWS_ACCESS_KEY_ID"]);
650        assert!(provider_env_vars("unknown").is_empty());
651    }
652
653    // PlaintextDev-using tests are gated behind cfg(debug_assertions)
654    // because release builds reject PlaintextDev with
655    // ResolverError::PlaintextDevDisabledInRelease, so the assertions
656    // below would fail under `cargo test --release`. Reagent P2
657    // (PR #751). The Env / SecretsManager / unknown-provider paths
658    // are tested separately and have no debug-only dependency.
659
660    #[cfg(debug_assertions)]
661    #[test]
662    fn resolve_plaintext_dev() {
663        let s = resolve_secret(&SecretRef::PlaintextDev {
664            plaintext_dev: "ghp_test123".to_string(),
665        })
666        .unwrap();
667        assert_eq!(s, "ghp_test123");
668    }
669
670    #[test]
671    fn resolve_env_var_missing() {
672        let res = resolve_secret(&SecretRef::Env {
673            env_var: "AGENTMUX_TEST_NEVER_SET_X9Q".to_string(),
674        });
675        assert!(matches!(res, Err(ResolverError::EnvVarMissing(_))));
676    }
677
678    #[test]
679    fn resolve_secrets_manager_unsupported() {
680        let res = resolve_secret(&SecretRef::SecretsManager {
681            sm_path: "ignored".to_string(),
682            sm_json_path: None,
683        });
684        assert!(matches!(res, Err(ResolverError::SecretsManagerUnsupported)));
685    }
686
687    #[test]
688    fn provider_class_oauth_providers() {
689        // Spec §4.3 — the three known oauth providers must classify
690        // as OAuth with the SAME config-dir env vars the CLI provider
691        // registry defines (single source of truth). Pinning the
692        // expected strings here catches drift in either direction —
693        // if the registry changes a value, this test fails and the
694        // change becomes deliberate.
695        assert_eq!(
696            provider_class("claude"),
697            Some(ProviderClass::OAuth { config_dir_env_var: "CLAUDE_CONFIG_DIR" }),
698        );
699        assert_eq!(
700            provider_class("codex"),
701            Some(ProviderClass::OAuth { config_dir_env_var: "CODEX_HOME" }),
702        );
703        assert_eq!(
704            provider_class("openclaw"),
705            Some(ProviderClass::OAuth { config_dir_env_var: "OPENCLAW_HOME" }),
706        );
707    }
708
709    #[cfg(debug_assertions)]
710    #[test]
711    fn inject_oauth_class_sets_config_dir_env_var() {
712        let store = make_store();
713
714        let mut def = crate::backend::storage::wstore::AgentDefinition {
715            id: "def-1".to_string(),
716            slug: String::new(),
717            name: "T".to_string(),
718            icon: "✦".to_string(),
719            provider: "claude".to_string(),
720            description: String::new(),
721            working_directory: String::new(),
722            shell: String::new(),
723            provider_flags: String::new(),
724            auto_start: 0,
725            restart_on_crash: 0,
726            idle_timeout_minutes: 0,
727            created_at: 0,
728            agent_type: String::new(),
729            environment: String::new(),
730            agent_bus_id: String::new(),
731            is_seeded: 0,
732            accounts: String::new(),
733            parent_id: String::new(),
734            branch_label: String::new(),
735            updated_at: 0,
736            user_hidden: 0,
737        };
738        store.agent_def_insert(&mut def).unwrap();
739
740        let identity = Identity {
741            id: "id-oauth".to_string(),
742            name: "OAuth".to_string(),
743            description: String::new(),
744            is_blank: false,
745            created_at: 0,
746            updated_at: 0,
747        };
748        store.bundle_identity_upsert(&identity).unwrap();
749
750        let claude = make_account(
751            "acct-claude",
752            "claude",
753            SecretRef::OAuthConfigDir {
754                dir: "/var/agentmux/identities/id-oauth/claude".to_string(),
755            },
756        );
757        store.identity_upsert(&claude).unwrap();
758        store
759            .bundle_identity_bind("id-oauth", "claude", "acct-claude")
760            .unwrap();
761
762        let inst = make_instance("block-oauth", "id-oauth");
763        store.instance_create(&inst).unwrap();
764
765        let mut env: HashMap<String, String> = HashMap::new();
766        inject_identity_env(store, "block-oauth", &mut env);
767
768        // OAuth dispatch sets the provider's config-dir env var.
769        assert_eq!(
770            env.get("CLAUDE_CONFIG_DIR").map(String::as_str),
771            Some("/var/agentmux/identities/id-oauth/claude"),
772        );
773        // And does NOT set the anthropic api-key env var — dispatch
774        // is by provider class, not by token shape.
775        assert!(env.get("ANTHROPIC_API_KEY").is_none());
776    }
777
778    #[cfg(debug_assertions)]
779    #[test]
780    fn inject_oauth_class_skips_account_with_non_oauth_secret_ref() {
781        // An oauth-class provider (claude) bound to an account whose
782        // SecretRef is the API-key shape (Env) is a misconfiguration:
783        // the resolver logs + skips rather than mis-injecting the
784        // wrong secret as if it were a config-dir.
785        let store = make_store();
786
787        let mut def = crate::backend::storage::wstore::AgentDefinition {
788            id: "def-1".to_string(),
789            slug: String::new(),
790            name: "T".to_string(),
791            icon: "✦".to_string(),
792            provider: "claude".to_string(),
793            description: String::new(),
794            working_directory: String::new(),
795            shell: String::new(),
796            provider_flags: String::new(),
797            auto_start: 0,
798            restart_on_crash: 0,
799            idle_timeout_minutes: 0,
800            created_at: 0,
801            agent_type: String::new(),
802            environment: String::new(),
803            agent_bus_id: String::new(),
804            is_seeded: 0,
805            accounts: String::new(),
806            parent_id: String::new(),
807            branch_label: String::new(),
808            updated_at: 0,
809            user_hidden: 0,
810        };
811        store.agent_def_insert(&mut def).unwrap();
812
813        let identity = Identity {
814            id: "id-bad".to_string(),
815            name: "Bad".to_string(),
816            description: String::new(),
817            is_blank: false,
818            created_at: 0,
819            updated_at: 0,
820        };
821        store.bundle_identity_upsert(&identity).unwrap();
822
823        let bad = make_account(
824            "acct-bad",
825            "claude",
826            SecretRef::Env {
827                env_var: "CLAUDE_TOKEN_NOT_A_DIR".to_string(),
828            },
829        );
830        store.identity_upsert(&bad).unwrap();
831        store
832            .bundle_identity_bind("id-bad", "claude", "acct-bad")
833            .unwrap();
834
835        let inst = make_instance("block-bad", "id-bad");
836        store.instance_create(&inst).unwrap();
837
838        let mut env: HashMap<String, String> = HashMap::new();
839        inject_identity_env(store, "block-bad", &mut env);
840
841        // Nothing injected — the binding was skipped.
842        assert!(env.get("CLAUDE_CONFIG_DIR").is_none());
843        assert!(env.is_empty());
844    }
845
846    #[test]
847    fn resolve_oauth_config_dir_is_not_a_secret() {
848        // OAuthConfigDir is a pointer to a CLI-managed token directory,
849        // not a resolvable secret string. PR B's oauth-class dispatch
850        // in `inject_identity_env` reads `dir` and sets the provider's
851        // config-dir env var directly, bypassing `resolve_secret`. The
852        // error here is a guard against a caller forgetting that
853        // dispatch — pre-PR-B nothing produces this variant, but the
854        // arm has to exist for the match to be exhaustive.
855        let res = resolve_secret(&SecretRef::OAuthConfigDir {
856            dir: "/path/to/bundle/claude".to_string(),
857        });
858        assert!(matches!(res, Err(ResolverError::OAuthConfigDirNotASecret)));
859    }
860
861    #[test]
862    fn inject_no_instance_does_nothing() {
863        let store = make_store();
864        let mut env: HashMap<String, String> = HashMap::new();
865        inject_identity_env(store, "block-no-instance", &mut env);
866        assert!(env.is_empty());
867    }
868
869    #[test]
870    fn inject_blank_identity_does_nothing() {
871        let store = make_store();
872        // Need a definition for the FK on db_agent_instances.
873        let mut def = crate::backend::storage::wstore::AgentDefinition {
874            id: "def-1".to_string(),
875            slug: String::new(),
876            name: "T".to_string(),
877            icon: "✦".to_string(),
878            provider: "claude".to_string(),
879            description: String::new(),
880            working_directory: String::new(),
881            shell: String::new(),
882            provider_flags: String::new(),
883            auto_start: 0,
884            restart_on_crash: 0,
885            idle_timeout_minutes: 0,
886            created_at: 0,
887            agent_type: String::new(),
888            environment: String::new(),
889            agent_bus_id: String::new(),
890            is_seeded: 0,
891            accounts: String::new(),
892            parent_id: String::new(),
893            branch_label: String::new(),
894            updated_at: 0,
895            user_hidden: 0,
896        };
897        store.agent_def_insert(&mut def).unwrap();
898
899        let mut inst = make_instance("block-blank", "blank");
900        store.instance_create(&inst).unwrap();
901        let _ = inst; // keep clippy happy
902
903        let mut env: HashMap<String, String> = HashMap::new();
904        inject_identity_env(store, "block-blank", &mut env);
905        assert!(env.is_empty());
906    }
907
908    #[cfg(debug_assertions)]
909    #[test]
910    fn inject_full_round_trip_plaintext_dev() {
911        let store = make_store();
912
913        // Agent definition.
914        let mut def = crate::backend::storage::wstore::AgentDefinition {
915            id: "def-1".to_string(),
916            slug: String::new(),
917            name: "T".to_string(),
918            icon: "✦".to_string(),
919            provider: "claude".to_string(),
920            description: String::new(),
921            working_directory: String::new(),
922            shell: String::new(),
923            provider_flags: String::new(),
924            auto_start: 0,
925            restart_on_crash: 0,
926            idle_timeout_minutes: 0,
927            created_at: 0,
928            agent_type: String::new(),
929            environment: String::new(),
930            agent_bus_id: String::new(),
931            is_seeded: 0,
932            accounts: String::new(),
933            parent_id: String::new(),
934            branch_label: String::new(),
935            updated_at: 0,
936            user_hidden: 0,
937        };
938        store.agent_def_insert(&mut def).unwrap();
939
940        // Identity bundle.
941        let identity = Identity {
942            id: "id-work".to_string(),
943            name: "Work".to_string(),
944            description: String::new(),
945            is_blank: false,
946            created_at: 0,
947            updated_at: 0,
948        };
949        store.bundle_identity_upsert(&identity).unwrap();
950
951        // GitHub account (PlaintextDev for test simplicity).
952        let github = make_account(
953            "acct-gh",
954            "github",
955            SecretRef::PlaintextDev {
956                plaintext_dev: "ghp_round_trip".to_string(),
957            },
958        );
959        store.identity_upsert(&github).unwrap();
960        store
961            .bundle_identity_bind("id-work", "github", "acct-gh")
962            .unwrap();
963
964        // Anthropic account.
965        let anthropic = make_account(
966            "acct-anth",
967            "anthropic",
968            SecretRef::PlaintextDev {
969                plaintext_dev: "sk-ant-round_trip".to_string(),
970            },
971        );
972        store.identity_upsert(&anthropic).unwrap();
973        store
974            .bundle_identity_bind("id-work", "anthropic", "acct-anth")
975            .unwrap();
976
977        // Instance for the block, pointing at id-work.
978        let inst = make_instance("block-1", "id-work");
979        store.instance_create(&inst).unwrap();
980
981        let mut env: HashMap<String, String> = HashMap::new();
982        inject_identity_env(store, "block-1", &mut env);
983
984        // GitHub writes both standard env-var names from one secret.
985        assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("ghp_round_trip"));
986        assert_eq!(env.get("GH_TOKEN").map(String::as_str), Some("ghp_round_trip"));
987        // Anthropic writes its single env var.
988        assert_eq!(
989            env.get("ANTHROPIC_API_KEY").map(String::as_str),
990            Some("sk-ant-round_trip"),
991        );
992    }
993
994    #[cfg(debug_assertions)]
995    #[test]
996    fn inject_partial_success_skips_failed_bindings() {
997        let store = make_store();
998
999        let mut def = crate::backend::storage::wstore::AgentDefinition {
1000            id: "def-1".to_string(),
1001            slug: String::new(),
1002            name: "T".to_string(),
1003            icon: "✦".to_string(),
1004            provider: "claude".to_string(),
1005            description: String::new(),
1006            working_directory: String::new(),
1007            shell: String::new(),
1008            provider_flags: String::new(),
1009            auto_start: 0,
1010            restart_on_crash: 0,
1011            idle_timeout_minutes: 0,
1012            created_at: 0,
1013            agent_type: String::new(),
1014            environment: String::new(),
1015            agent_bus_id: String::new(),
1016            is_seeded: 0,
1017            accounts: String::new(),
1018            parent_id: String::new(),
1019            branch_label: String::new(),
1020            updated_at: 0,
1021            user_hidden: 0,
1022        };
1023        store.agent_def_insert(&mut def).unwrap();
1024
1025        let identity = Identity {
1026            id: "id-mixed".to_string(),
1027            name: "Mixed".to_string(),
1028            description: String::new(),
1029            is_blank: false,
1030            created_at: 0,
1031            updated_at: 0,
1032        };
1033        store.bundle_identity_upsert(&identity).unwrap();
1034
1035        // Working account.
1036        let good = make_account(
1037            "acct-good",
1038            "github",
1039            SecretRef::PlaintextDev {
1040                plaintext_dev: "ghp_good".to_string(),
1041            },
1042        );
1043        store.identity_upsert(&good).unwrap();
1044        store
1045            .bundle_identity_bind("id-mixed", "github", "acct-good")
1046            .unwrap();
1047
1048        // Account whose Env-backed secret references a missing var.
1049        let bad = make_account(
1050            "acct-bad",
1051            "anthropic",
1052            SecretRef::Env {
1053                env_var: "AGENTMUX_TEST_DEFINITELY_NOT_SET_4242".to_string(),
1054            },
1055        );
1056        store.identity_upsert(&bad).unwrap();
1057        store
1058            .bundle_identity_bind("id-mixed", "anthropic", "acct-bad")
1059            .unwrap();
1060
1061        let inst = make_instance("block-mixed", "id-mixed");
1062        store.instance_create(&inst).unwrap();
1063
1064        let mut env: HashMap<String, String> = HashMap::new();
1065        inject_identity_env(store, "block-mixed", &mut env);
1066
1067        // GitHub injection succeeded.
1068        assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("ghp_good"));
1069        assert_eq!(env.get("GH_TOKEN").map(String::as_str), Some("ghp_good"));
1070        // Anthropic was skipped (env var missing) but didn't abort.
1071        assert!(env.get("ANTHROPIC_API_KEY").is_none());
1072    }
1073
1074    #[cfg(debug_assertions)]
1075    #[test]
1076    fn inject_unknown_provider_is_skipped() {
1077        let store = make_store();
1078
1079        let mut def = crate::backend::storage::wstore::AgentDefinition {
1080            id: "def-1".to_string(),
1081            slug: String::new(),
1082            name: "T".to_string(),
1083            icon: "✦".to_string(),
1084            provider: "claude".to_string(),
1085            description: String::new(),
1086            working_directory: String::new(),
1087            shell: String::new(),
1088            provider_flags: String::new(),
1089            auto_start: 0,
1090            restart_on_crash: 0,
1091            idle_timeout_minutes: 0,
1092            created_at: 0,
1093            agent_type: String::new(),
1094            environment: String::new(),
1095            agent_bus_id: String::new(),
1096            is_seeded: 0,
1097            accounts: String::new(),
1098            parent_id: String::new(),
1099            branch_label: String::new(),
1100            updated_at: 0,
1101            user_hidden: 0,
1102        };
1103        store.agent_def_insert(&mut def).unwrap();
1104
1105        let identity = Identity {
1106            id: "id-future".to_string(),
1107            name: "Future".to_string(),
1108            description: String::new(),
1109            is_blank: false,
1110            created_at: 0,
1111            updated_at: 0,
1112        };
1113        store.bundle_identity_upsert(&identity).unwrap();
1114
1115        let custom = make_account(
1116            "acct-custom",
1117            "custom",
1118            SecretRef::PlaintextDev {
1119                plaintext_dev: "ignored".to_string(),
1120            },
1121        );
1122        store.identity_upsert(&custom).unwrap();
1123        store
1124            .bundle_identity_bind("id-future", "custom", "acct-custom")
1125            .unwrap();
1126
1127        let inst = make_instance("block-future", "id-future");
1128        store.instance_create(&inst).unwrap();
1129
1130        let mut env: HashMap<String, String> = HashMap::new();
1131        inject_identity_env(store, "block-future", &mut env);
1132        // No env-var matrix for "custom" — nothing injected, no panic.
1133        assert!(env.is_empty());
1134    }
1135
1136    // ── PR D — OAuth expiry probe + status semantics ───────────────────
1137
1138    /// Helper: write a Claude-shape `.credentials.json` into a temp dir
1139    /// and return the dir path. `expires_ms` controls validity; `with_refresh`
1140    /// toggles the refreshToken field so the resolver can distinguish
1141    /// `Expired` (refresh present) from `NeedsReauth` (no refresh).
1142    fn write_claude_creds(
1143        dir: &std::path::Path,
1144        expires_ms: i64,
1145        with_refresh: bool,
1146    ) {
1147        std::fs::create_dir_all(dir).unwrap();
1148        let body = serde_json::json!({
1149            "claudeAiOauth": {
1150                "accessToken": "test-access",
1151                "refreshToken": if with_refresh { "test-refresh" } else { "" },
1152                "expiresAt": expires_ms,
1153            }
1154        });
1155        std::fs::write(
1156            dir.join(".credentials.json"),
1157            serde_json::to_string(&body).unwrap(),
1158        )
1159        .unwrap();
1160    }
1161
1162    #[test]
1163    fn probe_oauth_status_unknown_provider_returns_none() {
1164        // Probing a provider that isn't in the oauth-class set is a
1165        // signal to the caller to leave `status` alone — None ≠
1166        // NeedsReauth. Guards against silent mis-classification of
1167        // api-key providers if a future caller accidentally feeds
1168        // them through here.
1169        let r = probe_oauth_status("github", "/tmp/whatever", 0);
1170        assert_eq!(r, None);
1171    }
1172
1173    #[test]
1174    fn probe_oauth_status_missing_dir_is_needs_reauth() {
1175        let r = probe_oauth_status("claude", "/definitely/does/not/exist-xyz-9q", 0);
1176        assert_eq!(r, Some(OAuthProbeStatus::NeedsReauth));
1177    }
1178
1179    #[test]
1180    fn probe_oauth_status_future_expiry_is_valid() {
1181        let tmp = tempfile::tempdir().unwrap();
1182        let now_ms = 1_700_000_000_000;
1183        write_claude_creds(tmp.path(), now_ms + 3_600_000, true);
1184        let r = probe_oauth_status("claude", tmp.path().to_str().unwrap(), now_ms);
1185        assert_eq!(r, Some(OAuthProbeStatus::Valid));
1186    }
1187
1188    #[test]
1189    fn probe_oauth_status_past_expiry_with_refresh_is_expired() {
1190        let tmp = tempfile::tempdir().unwrap();
1191        let now_ms = 1_700_000_000_000;
1192        write_claude_creds(tmp.path(), now_ms - 1, true);
1193        let r = probe_oauth_status("claude", tmp.path().to_str().unwrap(), now_ms);
1194        assert_eq!(r, Some(OAuthProbeStatus::Expired));
1195    }
1196
1197    #[test]
1198    fn probe_oauth_status_past_expiry_no_refresh_is_needs_reauth() {
1199        // No refresh token in the file → the CLI can't auto-refresh
1200        // and the user has to OAuth again. Maps to `needs_reauth`,
1201        // NOT `expired` (per spec §4.4).
1202        let tmp = tempfile::tempdir().unwrap();
1203        let now_ms = 1_700_000_000_000;
1204        write_claude_creds(tmp.path(), now_ms - 1, false);
1205        let r = probe_oauth_status("claude", tmp.path().to_str().unwrap(), now_ms);
1206        assert_eq!(r, Some(OAuthProbeStatus::NeedsReauth));
1207    }
1208
1209    #[test]
1210    fn probe_oauth_status_malformed_json_is_needs_reauth() {
1211        let tmp = tempfile::tempdir().unwrap();
1212        std::fs::write(tmp.path().join(".credentials.json"), "{ not json").unwrap();
1213        let r = probe_oauth_status("claude", tmp.path().to_str().unwrap(), 0);
1214        assert_eq!(r, Some(OAuthProbeStatus::NeedsReauth));
1215    }
1216
1217    #[test]
1218    fn probe_oauth_status_codex_unknown_shape_is_valid_best_effort() {
1219        // codex / openclaw token-file layouts aren't publicly
1220        // documented; our parser falls through to "Valid" when the
1221        // file exists but lacks any parseable expiry. Better than
1222        // false `needs_reauth` on a working session — strict parsing
1223        // is a follow-up once the shape is pinned.
1224        let tmp = tempfile::tempdir().unwrap();
1225        std::fs::write(
1226            tmp.path().join(".credentials.json"),
1227            r#"{"some":"opaque-codex-blob"}"#,
1228        )
1229        .unwrap();
1230        let r = probe_oauth_status("codex", tmp.path().to_str().unwrap(), 0);
1231        assert_eq!(r, Some(OAuthProbeStatus::Valid));
1232    }
1233
1234    #[cfg(debug_assertions)]
1235    #[test]
1236    fn inject_oauth_class_probes_and_flips_status_to_needs_reauth() {
1237        // Full integration: an oauth-class binding pointing at a
1238        // bundle dir with NO token file → the probe surfaces
1239        // `needs_reauth` and the resolver upserts the account row
1240        // with the new status. Spec §4.4.
1241        let store = make_store();
1242
1243        let mut def = crate::backend::storage::wstore::AgentDefinition {
1244            id: "def-1".to_string(),
1245            slug: String::new(),
1246            name: "T".to_string(),
1247            icon: "✦".to_string(),
1248            provider: "claude".to_string(),
1249            description: String::new(),
1250            working_directory: String::new(),
1251            shell: String::new(),
1252            provider_flags: String::new(),
1253            auto_start: 0,
1254            restart_on_crash: 0,
1255            idle_timeout_minutes: 0,
1256            created_at: 0,
1257            agent_type: String::new(),
1258            environment: String::new(),
1259            agent_bus_id: String::new(),
1260            is_seeded: 0,
1261            accounts: String::new(),
1262            parent_id: String::new(),
1263            branch_label: String::new(),
1264            updated_at: 0,
1265            user_hidden: 0,
1266        };
1267        store.agent_def_insert(&mut def).unwrap();
1268
1269        let identity = Identity {
1270            id: "id-probe".to_string(),
1271            name: "Probe".to_string(),
1272            description: String::new(),
1273            is_blank: false,
1274            created_at: 0,
1275            updated_at: 0,
1276        };
1277        store.bundle_identity_upsert(&identity).unwrap();
1278
1279        // Bundle dir intentionally empty — probe should report
1280        // needs_reauth (no token file).
1281        let tmp = tempfile::tempdir().unwrap();
1282        let bundle_dir = tmp.path().to_str().unwrap().to_string();
1283
1284        let claude = IdentityAccount {
1285            id: "acct-claude".to_string(),
1286            name: "claude-acct-claude".to_string(),
1287            provider: "claude".to_string(),
1288            kind: "oauth".to_string(),
1289            display_name: String::new(),
1290            secret_ref: SecretRef::OAuthConfigDir { dir: bundle_dir },
1291            context: serde_json::json!({}),
1292            // Start as "valid" — the probe should flip it to
1293            // "needs_reauth".
1294            status: oauth_status::VALID.to_string(),
1295            created_at: 0,
1296            updated_at: 0,
1297        };
1298        store.identity_upsert(&claude).unwrap();
1299        store
1300            .bundle_identity_bind("id-probe", "claude", "acct-claude")
1301            .unwrap();
1302
1303        let inst = make_instance("block-probe", "id-probe");
1304        store.instance_create(&inst).unwrap();
1305
1306        let mut env: HashMap<String, String> = HashMap::new();
1307        inject_identity_env(store.clone(), "block-probe", &mut env);
1308
1309        // Env injection still happened (resolver doesn't block on
1310        // probe outcome — the CLI launches with the dir env var set
1311        // and will trigger OAuth itself when it sees no tokens).
1312        assert!(env.get("CLAUDE_CONFIG_DIR").is_some());
1313
1314        // Status row was UPDATED to needs_reauth.
1315        let after = store.identity_get("acct-claude").unwrap().unwrap();
1316        assert_eq!(after.status, oauth_status::NEEDS_REAUTH);
1317    }
1318
1319    #[cfg(debug_assertions)]
1320    #[test]
1321    fn inject_oauth_class_probe_preserves_status_when_valid() {
1322        // Future-dated token + status already "valid" → no-op
1323        // upsert (no spurious updated_at churn). The assertion is
1324        // that the status remains "valid" — proving the probe
1325        // didn't misclassify a working session.
1326        let store = make_store();
1327
1328        let mut def = crate::backend::storage::wstore::AgentDefinition {
1329            id: "def-1".to_string(),
1330            slug: String::new(),
1331            name: "T".to_string(),
1332            icon: "✦".to_string(),
1333            provider: "claude".to_string(),
1334            description: String::new(),
1335            working_directory: String::new(),
1336            shell: String::new(),
1337            provider_flags: String::new(),
1338            auto_start: 0,
1339            restart_on_crash: 0,
1340            idle_timeout_minutes: 0,
1341            created_at: 0,
1342            agent_type: String::new(),
1343            environment: String::new(),
1344            agent_bus_id: String::new(),
1345            is_seeded: 0,
1346            accounts: String::new(),
1347            parent_id: String::new(),
1348            branch_label: String::new(),
1349            updated_at: 0,
1350            user_hidden: 0,
1351        };
1352        store.agent_def_insert(&mut def).unwrap();
1353
1354        let identity = Identity {
1355            id: "id-ok".to_string(),
1356            name: "Ok".to_string(),
1357            description: String::new(),
1358            is_blank: false,
1359            created_at: 0,
1360            updated_at: 0,
1361        };
1362        store.bundle_identity_upsert(&identity).unwrap();
1363
1364        let tmp = tempfile::tempdir().unwrap();
1365        let now_ms = SystemTime::now()
1366            .duration_since(UNIX_EPOCH)
1367            .unwrap()
1368            .as_millis() as i64;
1369        write_claude_creds(tmp.path(), now_ms + 3_600_000, true);
1370
1371        let claude = IdentityAccount {
1372            id: "acct-ok".to_string(),
1373            name: "claude-acct-ok".to_string(),
1374            provider: "claude".to_string(),
1375            kind: "oauth".to_string(),
1376            display_name: String::new(),
1377            secret_ref: SecretRef::OAuthConfigDir {
1378                dir: tmp.path().to_str().unwrap().to_string(),
1379            },
1380            context: serde_json::json!({}),
1381            status: oauth_status::VALID.to_string(),
1382            created_at: 0,
1383            updated_at: 0,
1384        };
1385        store.identity_upsert(&claude).unwrap();
1386        store
1387            .bundle_identity_bind("id-ok", "claude", "acct-ok")
1388            .unwrap();
1389
1390        let inst = make_instance("block-ok", "id-ok");
1391        store.instance_create(&inst).unwrap();
1392
1393        let mut env: HashMap<String, String> = HashMap::new();
1394        inject_identity_env(store.clone(), "block-ok", &mut env);
1395
1396        let after = store.identity_get("acct-ok").unwrap().unwrap();
1397        assert_eq!(after.status, oauth_status::VALID);
1398        // updated_at unchanged — the resolver only upserts when the
1399        // probed status differs from the stored value.
1400        assert_eq!(after.updated_at, 0);
1401    }
1402}