agentmux_srv\identity/
migration.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! One-shot startup migration that seeds a "Default" identity bundle from
5//! ambient OAuth credentials living in the user's home dir
6//! (`<HOME>/.<auth_dir_name>/.credentials.json` for each oauth-class
7//! provider).
8//!
9//! Per `SPEC_OAUTH_IDENTITY_BUNDLES_2026_05_22.md` §5 (OAuth Bundles PR E):
10//! when a user upgrades from a pre-bundle build that wrote OAuth tokens
11//! to the legacy ambient location (e.g. Claude Code's `~/.claude/`),
12//! AgentMux should auto-detect those credentials and bind them into a
13//! "Default" identity bundle so launches keep working without forcing
14//! the user to re-OAuth. Empty / `"blank"` `identity_id` rows on
15//! `db_agent_instances` are back-filled to point at the new Default
16//! bundle so the resolver picks them up at next spawn.
17//!
18//! ## Idempotency contract
19//!
20//! The migration is safe to call on every srv startup:
21//!
22//! 1. For every oauth-class provider in the registry we check whether
23//!    ANY identity bundle already has a binding for that provider. If
24//!    yes → that provider is already covered (either by a prior run of
25//!    this migration, or by a user-driven `auth.start` flow), skip it.
26//! 2. The "Default" bundle is upserted (not unconditionally
27//!    `INSERT`'d) — running the migration twice produces no extra row.
28//! 3. The IdentityAccount uuid is reused on the re-bind path
29//!    (`bundle_identity_bindings` lookup), matching the
30//!    `persist_oauth_binding_or_synthetic` pattern from PR C, so a
31//!    second run doesn't orphan rows in `db_identity_accounts`.
32//! 4. Back-fill only runs when the Default bundle exists OR was created
33//!    this run — so a fresh install with no ambient creds never
34//!    rewrites `identity_id` to a non-existent FK.
35//!
36//! Failure modes are warn-don't-block (same as `inject_identity_env`):
37//! account upsert / bind / publish errors are logged and skipped, the
38//! srv keeps coming up.
39
40use std::path::{Path, PathBuf};
41use std::sync::Arc;
42use std::time::{SystemTime, UNIX_EPOCH};
43
44use crate::backend::providers::{get_provider, get_provider_list};
45use crate::backend::storage::store::{
46    Identity, IdentityAccount, SecretRef, Store,
47};
48use crate::backend::wps::{Broker, WaveEvent};
49use crate::identity::resolver::{
50    oauth_status, probe_oauth_status, provider_class, OAuthProbeStatus, ProviderClass,
51};
52
53/// Id used for the seeded Default bundle. Fixed so the migration is
54/// idempotent across restarts (a second run looks up by id, sees the
55/// row, reuses it instead of minting a new uuid). Not the same as the
56/// `"blank"` singleton — the blank bundle has no bindings by contract.
57pub const DEFAULT_BUNDLE_ID: &str = "default";
58
59/// Human-readable name for the seeded bundle. Per spec §5.1.
60pub const DEFAULT_BUNDLE_NAME: &str = "Default";
61
62/// Summary of what the migration did. Returned for testability + log
63/// observability; the production caller only inspects field counts
64/// (e.g. logging "0 providers seeded" at info level for visibility).
65#[derive(Debug, Default, Clone, PartialEq, Eq)]
66pub struct MigrationStats {
67    /// Number of oauth-class providers in the registry we examined.
68    pub providers_examined: usize,
69    /// Number of providers for which a binding ALREADY existed (in any
70    /// bundle) — skipped without touching the ambient creds.
71    pub providers_skipped_existing: usize,
72    /// Number of providers with no ambient creds on disk — nothing to
73    /// seed.
74    pub providers_skipped_no_ambient: usize,
75    /// Number of providers we successfully bound into the Default bundle.
76    pub providers_seeded: usize,
77    /// Number of existing Default accounts repointed off the user's ambient
78    /// `~/.<provider>` dir to the AgentMux-owned dir (isolation sweep, §4.2).
79    pub providers_repointed: usize,
80    /// Whether the Default bundle was created (vs. reused) this run.
81    pub default_bundle_created: bool,
82    /// Count of `db_agent_instances` rows whose `identity_id` was
83    /// updated from empty / `"blank"` → Default bundle id.
84    pub instances_backfilled: usize,
85}
86
87/// Entry point — call once on srv startup, after Store is open and
88/// before the srv begins accepting requests. `home_dir_override` is
89/// `None` in production (resolves `dirs::home_dir()`); tests use
90/// `Some(tempdir)` so they can plant fake `~/.<auth_dir_name>/.credentials.json`
91/// files without touching the user's real home.
92///
93/// Returns the [`MigrationStats`] for logging. Never panics; every
94/// internal failure path logs at `warn` and continues.
95pub fn run_default_bundle_migration(
96    wstore: &Arc<Store>,
97    broker: Option<&Arc<Broker>>,
98    home_dir_override: Option<PathBuf>,
99) -> MigrationStats {
100    let mut stats = MigrationStats::default();
101
102    // Resolve `<HOME>`. Skipping when `dirs::home_dir()` fails is the
103    // only way the migration becomes a no-op without surfacing an error
104    // to the user — without a home dir we couldn't probe ambient creds
105    // anyway. Tests pass `Some(tempdir)` to bypass.
106    let home = match home_dir_override.or_else(dirs::home_dir) {
107        Some(h) => h,
108        None => {
109            tracing::debug!(
110                target: "identity",
111                "oauth-bundles migration: no home_dir resolvable — skipping"
112            );
113            return stats;
114        }
115    };
116
117    // Enumerate every oauth-class provider in the registry. The match
118    // arm `provider_class("claude" | "codex" | "openclaw")` returns
119    // `Some(ProviderClass::OAuth { .. })`; everything else returns
120    // either `None` (unknown / new) or `ApiKey { .. }`. Iterating the
121    // registry (not a hardcoded list) means a new oauth-class provider
122    // added to `providers.rs` + `resolver.rs::provider_class` is
123    // automatically picked up by this migration on the next release.
124    let oauth_providers: Vec<&str> = get_provider_list()
125        .filter_map(|p| match provider_class(p.id) {
126            Some(ProviderClass::OAuth { .. }) => Some(p.id),
127            _ => None,
128        })
129        .collect();
130
131    stats.providers_examined = oauth_providers.len();
132
133    // Snapshot the existing bindings table ONCE per migration run. We
134    // walk every bundle's bindings and tally which providers are
135    // already covered — that's the "this provider is already managed,
136    // skip" gate. Doing it once (rather than per-provider) keeps the
137    // migration O(bundles) instead of O(providers × bundles), matters
138    // little in practice (single-digit counts on both axes) but is the
139    // clearer shape.
140    let covered_providers: std::collections::HashSet<String> = bound_providers(wstore);
141
142    let now_ms = SystemTime::now()
143        .duration_since(UNIX_EPOCH)
144        .map(|d| d.as_millis() as i64)
145        .unwrap_or(0);
146
147    // Track whether we created the Default bundle this run. Lazy: only
148    // do the upsert on the FIRST seedable provider, so a no-op
149    // migration (everything covered, or no ambient creds) doesn't even
150    // create the Default row.
151    let mut default_ready: Option<()> = None;
152
153    for provider_id in oauth_providers {
154        // Already-bound check — any bundle that has a binding for this
155        // provider counts as covered. The user has already done the
156        // OAuth flow once through `auth.start`, OR a prior run of this
157        // migration seeded it. Either way, don't touch the ambient
158        // creds again.
159        if covered_providers.contains(provider_id) {
160            stats.providers_skipped_existing += 1;
161            tracing::debug!(
162                target: "identity",
163                provider_id,
164                "oauth-bundles migration: provider already bound — skipping"
165            );
166            continue;
167        }
168
169        // Ambient-creds check — `<HOME>/.<auth_dir_name>/.credentials.json`.
170        // Use the provider registry's `auth_dir_name` (never hardcode
171        // `claude` / `.claude` — that's what makes the migration
172        // extensible to codex / openclaw without code changes).
173        let provider_cfg = match get_provider(provider_id) {
174            Some(p) => p,
175            None => {
176                // Should be impossible — we enumerated FROM the
177                // registry. Belt-and-braces.
178                tracing::warn!(
179                    target: "identity",
180                    provider_id,
181                    "oauth-bundles migration: provider missing from registry mid-iteration — skipping"
182                );
183                continue;
184            }
185        };
186        let ambient_dir = home.join(format!(".{}", provider_cfg.auth_dir_name));
187        let creds_file = ambient_dir.join(".credentials.json");
188        if !creds_file.exists() {
189            stats.providers_skipped_no_ambient += 1;
190            tracing::debug!(
191                target: "identity",
192                provider_id,
193                path = %creds_file.display(),
194                "oauth-bundles migration: no ambient credentials file — skipping"
195            );
196            continue;
197        }
198
199        // Ambient creds exist — ensure the Default bundle row exists.
200        // Lazy upsert: only the first seedable provider triggers it.
201        if default_ready.is_none() {
202            match ensure_default_bundle(wstore, now_ms) {
203                Ok(created) => {
204                    stats.default_bundle_created = created;
205                    default_ready = Some(());
206                }
207                Err(e) => {
208                    tracing::warn!(
209                        target: "identity",
210                        error = %e,
211                        "oauth-bundles migration: failed to upsert Default bundle — aborting migration this run"
212                    );
213                    // Bail entirely — without the bundle row we can't
214                    // bind anything, and the back-fill below would
215                    // point rows at a non-existent FK.
216                    return stats;
217                }
218            }
219        }
220
221        // SPEC_PROVIDER_ISOLATION (INV-A/INV-R): the live auth dir must be
222        // AgentMux-owned, never the user's ambient `~/.<provider>`. Import the
223        // ambient credential into the AgentMux dir ONCE (read-only copy,
224        // sentinel-gated), then point the account at the AgentMux dir so the
225        // CLI reads + refreshes THERE — `~/.<provider>` is never written and
226        // never the live target. (Reverses the #983 pointer-to-ambient.)
227        let dest_dir = provider_auth_dir(&home, &provider_cfg.auth_dir_name);
228        let dest_str = dest_dir.to_string_lossy().to_string();
229        if let Err(e) = import_ambient_once(&creds_file, &dest_dir) {
230            tracing::warn!(
231                target: "identity",
232                provider_id,
233                error = %e,
234                "oauth-bundles migration: failed to import ambient creds into the AgentMux dir — skipping provider"
235            );
236            continue;
237        }
238
239        // Probe the AgentMux dir (the dir the agent will actually run in), so
240        // the seeded binding lands with an accurate status.
241        let probed_status = probe_oauth_status(provider_id, &dest_str, now_ms)
242            .map(|s| s.as_str())
243            .unwrap_or(oauth_status::UNKNOWN);
244
245        // Mirror `persist_oauth_binding_or_synthetic` (PR C): reuse the
246        // account_id if a binding already exists, mint a fresh uuid otherwise.
247        let account_id = wstore
248            .bundle_identity_bindings(DEFAULT_BUNDLE_ID)
249            .ok()
250            .into_iter()
251            .flatten()
252            .find(|b| b.provider == provider_id)
253            .map(|b| b.account_id)
254            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
255
256        let account = IdentityAccount {
257            id: account_id.clone(),
258            name: format!("{provider_id}-oauth"),
259            provider: provider_id.to_string(),
260            kind: "oauth".to_string(),
261            display_name: String::new(),
262            // INV-A: AgentMux-owned dir, NOT the user's `~/.<provider>`.
263            secret_ref: SecretRef::OAuthConfigDir { dir: dest_str.clone() },
264            context: serde_json::json!({}),
265            status: probed_status.to_string(),
266            created_at: now_ms,
267            updated_at: now_ms,
268        };
269
270        if let Err(e) = wstore.identity_upsert(&account) {
271            tracing::warn!(
272                target: "identity",
273                provider_id,
274                error = %e,
275                "oauth-bundles migration: identity_upsert failed — skipping provider"
276            );
277            continue;
278        }
279        if let Err(e) = wstore.bundle_identity_bind(DEFAULT_BUNDLE_ID, provider_id, &account_id) {
280            tracing::warn!(
281                target: "identity",
282                provider_id,
283                account_id,
284                error = %e,
285                "oauth-bundles migration: bundle_identity_bind failed — account row persisted but no binding"
286            );
287            continue;
288        }
289
290        stats.providers_seeded += 1;
291        tracing::info!(
292            target: "identity",
293            provider_id,
294            account_id,
295            dir = %dest_str,
296            status = probed_status,
297            "oauth-bundles migration: imported ambient credentials into the AgentMux dir + bound into Default bundle"
298        );
299
300        // Best-effort broker publish so any open UI refreshes. None in
301        // tests; Some(broker) in production. Per spec §5 + the same
302        // pattern as `persist_oauth_binding_or_synthetic`.
303        if let Some(b) = broker {
304            b.publish(WaveEvent {
305                event: format!("identitybundlebindings:changed:{DEFAULT_BUNDLE_ID}"),
306                scopes: vec![],
307                sender: String::new(),
308                persist: 0,
309                data: None,
310            });
311        }
312
313        // The probe returned `Valid` / `Expired` / `NeedsReauth` /
314        // `None`. Logging here keeps the diagnostic trail intact for
315        // the "I just upgraded and my agent has the wrong status"
316        // support thread.
317        if let Some(probed) = OAuthProbeStatus::from_str(probed_status) {
318            tracing::info!(
319                target: "identity",
320                provider_id,
321                ?probed,
322                "oauth-bundles migration: probe status"
323            );
324        }
325    }
326
327    // Isolation sweep (SPEC_PROVIDER_ISOLATION §4.2): repoint any pre-existing
328    // Default account still pointing at the user's ambient `~/.<provider>`
329    // (seeded by the old #983 pointer-to-ambient behaviour) to the
330    // AgentMux-owned dir, so existing agents stop reading/refreshing in the
331    // user's personal login. Idempotent + sentinel-gated.
332    sweep_default_accounts_off_ambient(wstore, broker, &home, now_ms, &mut stats);
333
334    // Back-fill the empty / "blank" identity_id rows on
335    // db_agent_instances → Default bundle id. Per spec §5 step 4.
336    //
337    // Gate: the Default bundle must exist (FK target). It exists if
338    // EITHER we seeded a provider in THIS run (`default_ready`) OR a
339    // previous run already seeded it. The latter case matters because
340    // a legacy row (`identity_id == ""` / `"blank"`) created between
341    // restarts — by a code path that still produces them — would
342    // otherwise never get repaired: subsequent startups would
343    // `providers_skipped_existing` and skip the back-fill too. codex
344    // P2 on #983.
345    let default_bundle_exists = default_ready.is_some()
346        || wstore
347            .bundle_identity_list()
348            .ok()
349            .map(|bs| bs.iter().any(|b| b.id == DEFAULT_BUNDLE_ID))
350            .unwrap_or(false);
351    if default_bundle_exists {
352        match wstore.instance_backfill_identity_id(DEFAULT_BUNDLE_ID) {
353            Ok(rows) => {
354                stats.instances_backfilled = rows;
355                if rows > 0 {
356                    tracing::info!(
357                        target: "identity",
358                        rows,
359                        bundle_id = DEFAULT_BUNDLE_ID,
360                        "oauth-bundles migration: back-filled empty/blank identity_id rows"
361                    );
362                }
363            }
364            Err(e) => {
365                tracing::warn!(
366                    target: "identity",
367                    error = %e,
368                    "oauth-bundles migration: instance_backfill_identity_id failed — rows unchanged"
369                );
370            }
371        }
372    }
373
374    tracing::info!(
375        target: "identity",
376        ?stats,
377        "oauth-bundles migration: complete"
378    );
379    stats
380}
381
382/// `<home>/.agentmux/shared/providers/<auth_dir_name>` — the AgentMux-owned
383/// live auth dir (SPEC_PROVIDER_ISOLATION INV-A). Rooted on the migration's
384/// resolved `home` (not a globally-resolved `DataPaths`) so tests using
385/// `home_dir_override` stay hermetic. Mirrors `DataPaths::provider_auth_dir`.
386fn provider_auth_dir(home: &Path, auth_dir_name: &str) -> PathBuf {
387    home.join(".agentmux")
388        .join("shared")
389        .join("providers")
390        .join(auth_dir_name)
391}
392
393/// Import the user's ambient credential into the AgentMux dir ONCE — a
394/// read-only **copy** of `~/.<provider>/.credentials.json` (never a move,
395/// never a write-back to the user's dir). Sentinel-gated
396/// (`<dest>/.agentmux-cred-seeded`) so a later logout in the AgentMux dir
397/// sticks and we never silently re-import. Returns `Ok(true)` when a copy
398/// happened this call. (SPEC_PROVIDER_ISOLATION INV-R.)
399fn import_ambient_once(ambient_creds: &Path, dest_dir: &Path) -> std::io::Result<bool> {
400    let sentinel = dest_dir.join(".agentmux-cred-seeded");
401    if sentinel.exists() {
402        return Ok(false);
403    }
404    std::fs::create_dir_all(dest_dir)?;
405    let mut copied = false;
406    if ambient_creds.exists() {
407        std::fs::copy(ambient_creds, dest_dir.join(".credentials.json"))?;
408        copied = true;
409    }
410    // Mark the AgentMux dir as "taken over" even if there was nothing to copy,
411    // so a later fresh login here isn't clobbered by a future ambient import.
412    std::fs::write(&sentinel, b"")?;
413    Ok(copied)
414}
415
416/// Repoint any Default-bundle account still pointing at the user's ambient
417/// `~/.<provider>` dir to the AgentMux-owned dir (SPEC_PROVIDER_ISOLATION
418/// INV-A / §4.2). Fixes installs seeded by the old #983 pointer-to-ambient
419/// behaviour so existing agents stop reading/refreshing in the user's personal
420/// login. Idempotent: accounts already pointing at the AgentMux dir (or a
421/// custom dir) are untouched; the credential is imported once (sentinel-gated).
422fn sweep_default_accounts_off_ambient(
423    wstore: &Arc<Store>,
424    broker: Option<&Arc<Broker>>,
425    home: &Path,
426    now_ms: i64,
427    stats: &mut MigrationStats,
428) {
429    let bindings = match wstore.bundle_identity_bindings(DEFAULT_BUNDLE_ID) {
430        Ok(b) => b,
431        Err(_) => return,
432    };
433    for b in bindings {
434        let provider_cfg = match get_provider(&b.provider) {
435            Some(p) => p,
436            None => continue,
437        };
438        let ambient_dir = home.join(format!(".{}", provider_cfg.auth_dir_name));
439        let ambient_str = ambient_dir.to_string_lossy().to_string();
440        let dest_dir = provider_auth_dir(home, &provider_cfg.auth_dir_name);
441        let dest_str = dest_dir.to_string_lossy().to_string();
442
443        let mut acct = match wstore.identity_get(&b.account_id) {
444            Ok(Some(a)) => a,
445            _ => continue,
446        };
447        let points_at_ambient = matches!(
448            &acct.secret_ref,
449            SecretRef::OAuthConfigDir { dir } if *dir == ambient_str
450        );
451        if !points_at_ambient {
452            continue; // already AgentMux-owned (or a custom dir) — leave it
453        }
454
455        if let Err(e) = import_ambient_once(&ambient_dir.join(".credentials.json"), &dest_dir) {
456            tracing::warn!(
457                target: "identity",
458                provider = %b.provider,
459                error = %e,
460                "isolation sweep: import failed — leaving account pointed at ambient"
461            );
462            continue;
463        }
464        acct.secret_ref = SecretRef::OAuthConfigDir {
465            dir: dest_str.clone(),
466        };
467        acct.status = probe_oauth_status(&b.provider, &dest_str, now_ms)
468            .map(|s| s.as_str())
469            .unwrap_or(oauth_status::UNKNOWN)
470            .to_string();
471        acct.updated_at = now_ms;
472        if let Err(e) = wstore.identity_upsert(&acct) {
473            tracing::warn!(
474                target: "identity",
475                provider = %b.provider,
476                error = %e,
477                "isolation sweep: identity_upsert failed"
478            );
479            continue;
480        }
481        stats.providers_repointed += 1;
482        tracing::info!(
483            target: "identity",
484            provider = %b.provider,
485            from = %ambient_str,
486            to = %dest_str,
487            "isolation sweep: repointed Default account off the user's ambient dir to the AgentMux dir"
488        );
489        if let Some(br) = broker {
490            br.publish(WaveEvent {
491                event: format!("identitybundlebindings:changed:{DEFAULT_BUNDLE_ID}"),
492                scopes: vec![],
493                sender: String::new(),
494                persist: 0,
495                data: None,
496            });
497        }
498    }
499}
500
501/// Collect the set of providers that are bound in ANY identity bundle
502/// today. The migration's idempotency gate — if a provider is already
503/// in here, the ambient-creds seed is a no-op for that provider.
504fn bound_providers(wstore: &Arc<Store>) -> std::collections::HashSet<String> {
505    let mut out = std::collections::HashSet::new();
506    let bundles = match wstore.bundle_identity_list() {
507        Ok(b) => b,
508        Err(e) => {
509            tracing::warn!(
510                target: "identity",
511                error = %e,
512                "oauth-bundles migration: bundle_identity_list failed — treating all providers as uncovered"
513            );
514            return out;
515        }
516    };
517    for bundle in bundles {
518        match wstore.bundle_identity_bindings(&bundle.id) {
519            Ok(bindings) => {
520                for b in bindings {
521                    out.insert(b.provider);
522                }
523            }
524            Err(e) => {
525                tracing::warn!(
526                    target: "identity",
527                    bundle_id = %bundle.id,
528                    error = %e,
529                    "oauth-bundles migration: bundle_identity_bindings failed for bundle — skipping"
530                );
531            }
532        }
533    }
534    out
535}
536
537/// Look up the Default bundle by id; create it (via
538/// `bundle_identity_upsert`) if missing. Returns `true` when a new row
539/// was inserted, `false` when an existing row was reused.
540fn ensure_default_bundle(
541    wstore: &Arc<Store>,
542    now_ms: i64,
543) -> Result<bool, crate::backend::storage::error::StoreError> {
544    if let Some(existing) = wstore.bundle_identity_get(DEFAULT_BUNDLE_ID)? {
545        // Already exists. Don't churn `updated_at` — the bundle is
546        // unchanged by this run.
547        let _ = existing;
548        return Ok(false);
549    }
550    let identity = Identity {
551        id: DEFAULT_BUNDLE_ID.to_string(),
552        name: DEFAULT_BUNDLE_NAME.to_string(),
553        description: "Seeded from ambient OAuth credentials on first launch.".to_string(),
554        is_blank: false,
555        created_at: now_ms,
556        updated_at: now_ms,
557    };
558    wstore.bundle_identity_upsert(&identity)?;
559    Ok(true)
560}
561
562// Small helper to round-trip the probe status string back to the enum,
563// purely for the `tracing::info!` line at the seed site. Keeping the
564// resolver's enum + constants single-source.
565impl OAuthProbeStatus {
566    fn from_str(s: &str) -> Option<Self> {
567        match s {
568            oauth_status::VALID => Some(Self::Valid),
569            oauth_status::EXPIRED => Some(Self::Expired),
570            oauth_status::NEEDS_REAUTH => Some(Self::NeedsReauth),
571            _ => None,
572        }
573    }
574}
575
576// ─── Tests ───────────────────────────────────────────────────────────────
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use crate::backend::storage::store::{IdentityAccount, SecretRef};
582
583    /// Create a fresh in-memory store. The seeded blank-singleton +
584    /// schema are already wired up by `Store::open_in_memory`.
585    fn make_store() -> Arc<Store> {
586        Arc::new(Store::open_in_memory().unwrap())
587    }
588
589    /// Plant a Claude-shape ambient credentials file at
590    /// `<home>/.claude/.credentials.json` with a future expiry so the
591    /// probe reports `Valid`.
592    fn plant_ambient_claude_creds(home: &std::path::Path) {
593        let dir = home.join(".claude");
594        std::fs::create_dir_all(&dir).unwrap();
595        // Far-future expiry so the probe doesn't false-positive
596        // expired when the test machine's clock has drifted.
597        let body = serde_json::json!({
598            "claudeAiOauth": {
599                "accessToken": "test-access",
600                "refreshToken": "test-refresh",
601                "expiresAt": 99_999_999_999_999_i64,
602            }
603        });
604        std::fs::write(
605            dir.join(".credentials.json"),
606            serde_json::to_string(&body).unwrap(),
607        )
608        .unwrap();
609    }
610
611    #[test]
612    fn no_home_dir_skips_silently() {
613        // `home_dir_override = None` here is impossible to set up
614        // without affecting the user's real home, so we exercise the
615        // empty path via the no-ambient-creds branch (tempdir as
616        // home, no files planted). The "no home_dir resolvable" branch
617        // is unreachable via the public API short of unsetting HOME on
618        // every supported OS — covered by visual inspection.
619        let store = make_store();
620        let tmp = tempfile::tempdir().unwrap();
621        let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
622        assert!(stats.providers_examined > 0); // claude + codex + openclaw
623        assert_eq!(stats.providers_seeded, 0);
624        assert_eq!(stats.providers_skipped_no_ambient, stats.providers_examined);
625        assert_eq!(stats.providers_skipped_existing, 0);
626        assert!(!stats.default_bundle_created);
627        assert_eq!(stats.instances_backfilled, 0);
628    }
629
630    #[test]
631    fn ambient_claude_creds_create_default_bundle_and_bind() {
632        let store = make_store();
633        let tmp = tempfile::tempdir().unwrap();
634        plant_ambient_claude_creds(tmp.path());
635
636        let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
637
638        assert!(stats.default_bundle_created);
639        assert_eq!(stats.providers_seeded, 1);
640
641        // Default bundle row exists.
642        let default = store.bundle_identity_get(DEFAULT_BUNDLE_ID).unwrap();
643        assert!(default.is_some(), "Default bundle should be created");
644        let default = default.unwrap();
645        assert_eq!(default.name, DEFAULT_BUNDLE_NAME);
646        assert!(!default.is_blank);
647
648        // Binding for claude.
649        let bindings = store.bundle_identity_bindings(DEFAULT_BUNDLE_ID).unwrap();
650        assert_eq!(bindings.len(), 1);
651        let claude_binding = &bindings[0];
652        assert_eq!(claude_binding.provider, "claude");
653
654        // SPEC_PROVIDER_ISOLATION INV-A: the account points at the AgentMux
655        // dir, NOT the user's ambient `~/.claude` (T-A1).
656        let account = store.identity_get(&claude_binding.account_id).unwrap().unwrap();
657        assert_eq!(account.provider, "claude");
658        assert_eq!(account.kind, "oauth");
659        assert_eq!(account.status, oauth_status::VALID);
660        let agentmux_dir = tmp
661            .path()
662            .join(".agentmux")
663            .join("shared")
664            .join("providers")
665            .join("claude");
666        match account.secret_ref {
667            SecretRef::OAuthConfigDir { dir } => {
668                assert_eq!(dir, agentmux_dir.to_string_lossy());
669            }
670            other => panic!("expected OAuthConfigDir, got {:?}", other),
671        }
672
673        // INV-R: the credential was COPIED into the AgentMux dir (with the
674        // import sentinel), and the user's ambient `~/.claude` is untouched.
675        let dest_creds = agentmux_dir.join(".credentials.json");
676        assert!(dest_creds.exists(), "credential should be copied into the AgentMux dir");
677        assert!(
678            agentmux_dir.join(".agentmux-cred-seeded").exists(),
679            "import sentinel should be written"
680        );
681        let ambient_creds = tmp.path().join(".claude").join(".credentials.json");
682        assert!(ambient_creds.exists(), "ambient ~/.claude creds must remain (read-only import)");
683        assert_eq!(
684            std::fs::read(&ambient_creds).unwrap(),
685            std::fs::read(&dest_creds).unwrap(),
686            "copy must be byte-identical to the ambient source"
687        );
688    }
689
690    #[test]
691    fn sweep_repoints_existing_ambient_account_to_agentmux_dir() {
692        // Simulate the old #983 state: a Default 'claude' account pointing AT
693        // the user's ambient `~/.claude`. The migration's isolation sweep
694        // (§4.2) must repoint it to the AgentMux dir (copy once) without a
695        // manual re-login — the fix for already-bound agents (Nark/Poal).
696        let store = make_store();
697        let tmp = tempfile::tempdir().unwrap();
698        plant_ambient_claude_creds(tmp.path());
699        let ambient_dir = tmp.path().join(".claude");
700
701        // Pre-seed an old-style account + binding pointing AT the ambient dir.
702        let legacy = IdentityAccount {
703            id: "acct-legacy".to_string(),
704            name: "claude-oauth".to_string(),
705            provider: "claude".to_string(),
706            kind: "oauth".to_string(),
707            display_name: String::new(),
708            secret_ref: SecretRef::OAuthConfigDir {
709                dir: ambient_dir.to_string_lossy().to_string(),
710            },
711            context: serde_json::json!({}),
712            status: oauth_status::VALID.to_string(),
713            created_at: 1,
714            updated_at: 1,
715        };
716        store.identity_upsert(&legacy).unwrap();
717        ensure_default_bundle(&store, 1).unwrap();
718        store
719            .bundle_identity_bind(DEFAULT_BUNDLE_ID, "claude", "acct-legacy")
720            .unwrap();
721
722        let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
723        // claude is already bound → the loop skips it; the sweep repoints it.
724        assert_eq!(stats.providers_seeded, 0);
725        assert_eq!(stats.providers_repointed, 1);
726
727        let updated = store.identity_get("acct-legacy").unwrap().unwrap();
728        let agentmux_dir = tmp
729            .path()
730            .join(".agentmux")
731            .join("shared")
732            .join("providers")
733            .join("claude");
734        match updated.secret_ref {
735            SecretRef::OAuthConfigDir { dir } => {
736                assert_eq!(dir, agentmux_dir.to_string_lossy());
737            }
738            other => panic!("expected OAuthConfigDir, got {:?}", other),
739        }
740        assert!(
741            agentmux_dir.join(".credentials.json").exists(),
742            "creds copied into the AgentMux dir on sweep"
743        );
744
745        // Idempotent: a second run finds it already AgentMux-owned → no repoint.
746        let s2 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
747        assert_eq!(s2.providers_repointed, 0);
748    }
749
750    #[test]
751    fn idempotent_second_run_is_noop() {
752        // Running the migration twice with ambient creds present
753        // must NOT produce a second binding row or a second account
754        // row. Per spec §5: idempotent across restarts.
755        let store = make_store();
756        let tmp = tempfile::tempdir().unwrap();
757        plant_ambient_claude_creds(tmp.path());
758
759        let s1 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
760        assert_eq!(s1.providers_seeded, 1);
761        assert!(s1.default_bundle_created);
762
763        let bindings_after_first = store.bundle_identity_bindings(DEFAULT_BUNDLE_ID).unwrap();
764        assert_eq!(bindings_after_first.len(), 1);
765
766        let s2 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
767        assert_eq!(s2.providers_seeded, 0);
768        // Second run reuses the existing bundle row (no churn) and
769        // sees the existing binding → providers_skipped_existing.
770        assert!(!s2.default_bundle_created);
771        assert_eq!(s2.providers_skipped_existing, 1);
772
773        let bindings_after_second = store.bundle_identity_bindings(DEFAULT_BUNDLE_ID).unwrap();
774        assert_eq!(bindings_after_second.len(), 1);
775        // Same account_id — no orphan.
776        assert_eq!(
777            bindings_after_first[0].account_id,
778            bindings_after_second[0].account_id,
779        );
780    }
781
782    #[test]
783    fn existing_binding_in_other_bundle_skips_provider() {
784        // If the user has ALREADY done the auth.start flow and a
785        // user-named bundle already binds claude, the migration must
786        // NOT also seed the Default bundle for claude — that would
787        // double-bind the same provider across bundles. Per spec §5:
788        // "If a binding already exists → skip this provider."
789        let store = make_store();
790        let tmp = tempfile::tempdir().unwrap();
791        plant_ambient_claude_creds(tmp.path());
792
793        // Pre-seed a separate bundle that already binds claude.
794        let work_bundle = Identity {
795            id: "work-bundle".to_string(),
796            name: "Work".to_string(),
797            description: String::new(),
798            is_blank: false,
799            created_at: 0,
800            updated_at: 0,
801        };
802        store.bundle_identity_upsert(&work_bundle).unwrap();
803        let work_account = IdentityAccount {
804            id: "acct-work-claude".to_string(),
805            name: "work-claude".to_string(),
806            provider: "claude".to_string(),
807            kind: "oauth".to_string(),
808            display_name: String::new(),
809            secret_ref: SecretRef::OAuthConfigDir {
810                dir: "/somewhere/work/claude".to_string(),
811            },
812            context: serde_json::json!({}),
813            status: oauth_status::VALID.to_string(),
814            created_at: 0,
815            updated_at: 0,
816        };
817        store.identity_upsert(&work_account).unwrap();
818        store
819            .bundle_identity_bind("work-bundle", "claude", "acct-work-claude")
820            .unwrap();
821
822        let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
823
824        // Claude was covered by the work bundle → skipped.
825        assert_eq!(stats.providers_seeded, 0);
826        assert!(stats.providers_skipped_existing >= 1);
827
828        // Default bundle should NOT be created (we only do that on
829        // the first seedable provider; with claude skipped and codex
830        // / openclaw lacking ambient creds, there's nothing to seed).
831        assert!(!stats.default_bundle_created);
832        let default = store.bundle_identity_get(DEFAULT_BUNDLE_ID).unwrap();
833        assert!(default.is_none(), "Default bundle must not be auto-created when nothing to seed");
834    }
835
836    #[test]
837    fn backfills_empty_identity_id_rows_after_seed() {
838        // The Default bundle exists after seeding → empty /
839        // "blank" identity_id rows on db_agent_instances get
840        // back-filled to point at it.
841        let store = make_store();
842        let tmp = tempfile::tempdir().unwrap();
843        plant_ambient_claude_creds(tmp.path());
844
845        // Need an agent definition for the FK on db_agent_instances.
846        let mut def = crate::backend::storage::store::AgentDefinition {
847            id: "def-1".to_string(),
848            slug: String::new(),
849            name: "T".to_string(),
850            icon: "✦".to_string(),
851            provider: "claude".to_string(),
852            description: String::new(),
853            working_directory: String::new(),
854            shell: String::new(),
855            provider_flags: String::new(),
856            auto_start: 0,
857            restart_on_crash: 0,
858            idle_timeout_minutes: 0,
859            created_at: 0,
860            agent_type: String::new(),
861            environment: String::new(),
862            agent_bus_id: String::new(),
863            is_seeded: 0,
864            accounts: String::new(),
865            parent_id: String::new(),
866            branch_label: String::new(),
867            updated_at: 0,
868            user_hidden: 0,
869            container_image: String::new(),
870            container_volumes: "[]".to_string(),
871            container_name: String::new(),
872        };
873        store.agent_def_insert(&mut def).unwrap();
874
875        // Plant two legacy rows — one with empty identity_id, one
876        // with the legacy "blank" sentinel. Both should be
877        // back-filled.
878        let inst_empty = crate::backend::storage::store::AgentInstance {
879            id: "inst-empty".to_string(),
880            definition_id: "def-1".to_string(),
881            parent_instance_id: String::new(),
882            block_id: "block-empty".to_string(),
883            session_id: String::new(),
884            status: "running".to_string(),
885            github_context: String::new(),
886            started_at: 0,
887            ended_at: 0,
888            created_at: 0,
889            identity_id: String::new(),
890            memory_id: String::new(),
891            instance_name: String::new(),
892            working_directory: String::new(),
893            display_hidden: false,
894        };
895        store.instance_create(&inst_empty).unwrap();
896
897        let inst_blank = crate::backend::storage::store::AgentInstance {
898            id: "inst-blank".to_string(),
899            definition_id: "def-1".to_string(),
900            parent_instance_id: String::new(),
901            block_id: "block-blank".to_string(),
902            session_id: String::new(),
903            status: "running".to_string(),
904            github_context: String::new(),
905            started_at: 0,
906            ended_at: 0,
907            created_at: 0,
908            identity_id: "blank".to_string(),
909            memory_id: String::new(),
910            instance_name: String::new(),
911            working_directory: String::new(),
912            display_hidden: false,
913        };
914        store.instance_create(&inst_blank).unwrap();
915
916        // Plant a row that ALREADY has a real identity_id — must NOT
917        // be touched by the back-fill.
918        let inst_set = crate::backend::storage::store::AgentInstance {
919            id: "inst-set".to_string(),
920            definition_id: "def-1".to_string(),
921            parent_instance_id: String::new(),
922            block_id: "block-set".to_string(),
923            session_id: String::new(),
924            status: "running".to_string(),
925            github_context: String::new(),
926            started_at: 0,
927            ended_at: 0,
928            created_at: 0,
929            identity_id: "some-existing-bundle".to_string(),
930            memory_id: String::new(),
931            instance_name: String::new(),
932            working_directory: String::new(),
933            display_hidden: false,
934        };
935        store.instance_create(&inst_set).unwrap();
936
937        let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
938
939        assert!(stats.default_bundle_created);
940        assert_eq!(stats.instances_backfilled, 2);
941
942        // Verify the two legacy rows now point at Default.
943        let after_empty = store.instance_get("inst-empty").unwrap().unwrap();
944        assert_eq!(after_empty.identity_id, DEFAULT_BUNDLE_ID);
945        let after_blank = store.instance_get("inst-blank").unwrap().unwrap();
946        assert_eq!(after_blank.identity_id, DEFAULT_BUNDLE_ID);
947        // The non-empty row stays put.
948        let after_set = store.instance_get("inst-set").unwrap().unwrap();
949        assert_eq!(after_set.identity_id, "some-existing-bundle");
950    }
951
952    #[test]
953    fn backfills_legacy_rows_added_between_runs() {
954        // Subsequent-startup self-heal — codex P2 on #983: if a
955        // legacy row (`identity_id == ""` / `"blank"`) gets created
956        // AFTER the first migration seeded Default, the next startup
957        // would normally `providers_skipped_existing` for everything
958        // and skip the back-fill too. The back-fill gate must check
959        // "Default bundle exists" (whether seeded this run OR a prior
960        // run), not "did we seed in this run", so the new legacy row
961        // gets repaired.
962        let store = make_store();
963        let tmp = tempfile::tempdir().unwrap();
964        plant_ambient_claude_creds(tmp.path());
965
966        // Agent def + initial run that creates Default.
967        let mut def = crate::backend::storage::store::AgentDefinition {
968            id: "def-1".to_string(),
969            slug: String::new(),
970            name: "T".to_string(),
971            icon: "✦".to_string(),
972            provider: "claude".to_string(),
973            description: String::new(),
974            working_directory: String::new(),
975            shell: String::new(),
976            provider_flags: String::new(),
977            auto_start: 0,
978            restart_on_crash: 0,
979            idle_timeout_minutes: 0,
980            created_at: 0,
981            agent_type: String::new(),
982            environment: String::new(),
983            agent_bus_id: String::new(),
984            is_seeded: 0,
985            accounts: String::new(),
986            parent_id: String::new(),
987            branch_label: String::new(),
988            updated_at: 0,
989            user_hidden: 0,
990            container_image: String::new(),
991            container_volumes: "[]".to_string(),
992            container_name: String::new(),
993        };
994        store.agent_def_insert(&mut def).unwrap();
995
996        let s1 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
997        assert!(s1.default_bundle_created);
998        assert_eq!(s1.instances_backfilled, 0); // no legacy rows yet
999
1000        // Between runs: a code path adds a legacy row with empty
1001        // identity_id (simulates an older spawn path lingering).
1002        let inst_late = crate::backend::storage::store::AgentInstance {
1003            id: "inst-late".to_string(),
1004            definition_id: "def-1".to_string(),
1005            parent_instance_id: String::new(),
1006            block_id: "block-late".to_string(),
1007            session_id: String::new(),
1008            status: "running".to_string(),
1009            github_context: String::new(),
1010            started_at: 0,
1011            ended_at: 0,
1012            created_at: 0,
1013            identity_id: String::new(),
1014            memory_id: String::new(),
1015            instance_name: String::new(),
1016            working_directory: String::new(),
1017            display_hidden: false,
1018        };
1019        store.instance_create(&inst_late).unwrap();
1020
1021        // Second run: claude is `providers_skipped_existing`, so
1022        // `default_ready` stays None — but Default exists from run 1,
1023        // so the back-fill MUST still run.
1024        let s2 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
1025        assert!(!s2.default_bundle_created); // already there
1026        assert_eq!(
1027            s2.instances_backfilled, 1,
1028            "subsequent-run back-fill must repair newly-added legacy rows"
1029        );
1030        let after = store.instance_get("inst-late").unwrap().unwrap();
1031        assert_eq!(after.identity_id, DEFAULT_BUNDLE_ID);
1032    }
1033
1034    #[test]
1035    fn no_ambient_no_default_bundle_no_backfill() {
1036        // The no-ambient path must not create the Default bundle
1037        // (FK target would be missing) and must not back-fill rows
1038        // to a non-existent id. Per spec §5 step 6.
1039        let store = make_store();
1040        let tmp = tempfile::tempdir().unwrap();
1041        // NO `plant_ambient_claude_creds` — empty home.
1042
1043        // Plant a row with empty identity_id.
1044        let mut def = crate::backend::storage::store::AgentDefinition {
1045            id: "def-1".to_string(),
1046            slug: String::new(),
1047            name: "T".to_string(),
1048            icon: "✦".to_string(),
1049            provider: "claude".to_string(),
1050            description: String::new(),
1051            working_directory: String::new(),
1052            shell: String::new(),
1053            provider_flags: String::new(),
1054            auto_start: 0,
1055            restart_on_crash: 0,
1056            idle_timeout_minutes: 0,
1057            created_at: 0,
1058            agent_type: String::new(),
1059            environment: String::new(),
1060            agent_bus_id: String::new(),
1061            is_seeded: 0,
1062            accounts: String::new(),
1063            parent_id: String::new(),
1064            branch_label: String::new(),
1065            updated_at: 0,
1066            user_hidden: 0,
1067            container_image: String::new(),
1068            container_volumes: "[]".to_string(),
1069            container_name: String::new(),
1070        };
1071        store.agent_def_insert(&mut def).unwrap();
1072        let inst = crate::backend::storage::store::AgentInstance {
1073            id: "inst-empty".to_string(),
1074            definition_id: "def-1".to_string(),
1075            parent_instance_id: String::new(),
1076            block_id: "block-empty".to_string(),
1077            session_id: String::new(),
1078            status: "running".to_string(),
1079            github_context: String::new(),
1080            started_at: 0,
1081            ended_at: 0,
1082            created_at: 0,
1083            identity_id: String::new(),
1084            memory_id: String::new(),
1085            instance_name: String::new(),
1086            working_directory: String::new(),
1087            display_hidden: false,
1088        };
1089        store.instance_create(&inst).unwrap();
1090
1091        let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
1092
1093        assert_eq!(stats.providers_seeded, 0);
1094        assert!(!stats.default_bundle_created);
1095        assert_eq!(stats.instances_backfilled, 0);
1096
1097        // Row still has empty identity_id — no spurious FK write.
1098        let after = store.instance_get("inst-empty").unwrap().unwrap();
1099        assert_eq!(after.identity_id, "");
1100    }
1101}