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::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::wstore::{
46    Identity, IdentityAccount, SecretRef, WaveStore,
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    /// Whether the Default bundle was created (vs. reused) this run.
78    pub default_bundle_created: bool,
79    /// Count of `db_agent_instances` rows whose `identity_id` was
80    /// updated from empty / `"blank"` → Default bundle id.
81    pub instances_backfilled: usize,
82}
83
84/// Entry point — call once on srv startup, after WaveStore is open and
85/// before the srv begins accepting requests. `home_dir_override` is
86/// `None` in production (resolves `dirs::home_dir()`); tests use
87/// `Some(tempdir)` so they can plant fake `~/.<auth_dir_name>/.credentials.json`
88/// files without touching the user's real home.
89///
90/// Returns the [`MigrationStats`] for logging. Never panics; every
91/// internal failure path logs at `warn` and continues.
92pub fn run_default_bundle_migration(
93    wstore: &Arc<WaveStore>,
94    broker: Option<&Arc<Broker>>,
95    home_dir_override: Option<PathBuf>,
96) -> MigrationStats {
97    let mut stats = MigrationStats::default();
98
99    // Resolve `<HOME>`. Skipping when `dirs::home_dir()` fails is the
100    // only way the migration becomes a no-op without surfacing an error
101    // to the user — without a home dir we couldn't probe ambient creds
102    // anyway. Tests pass `Some(tempdir)` to bypass.
103    let home = match home_dir_override.or_else(dirs::home_dir) {
104        Some(h) => h,
105        None => {
106            tracing::debug!(
107                target: "identity",
108                "oauth-bundles migration: no home_dir resolvable — skipping"
109            );
110            return stats;
111        }
112    };
113
114    // Enumerate every oauth-class provider in the registry. The match
115    // arm `provider_class("claude" | "codex" | "openclaw")` returns
116    // `Some(ProviderClass::OAuth { .. })`; everything else returns
117    // either `None` (unknown / new) or `ApiKey { .. }`. Iterating the
118    // registry (not a hardcoded list) means a new oauth-class provider
119    // added to `providers.rs` + `resolver.rs::provider_class` is
120    // automatically picked up by this migration on the next release.
121    let oauth_providers: Vec<&str> = get_provider_list()
122        .filter_map(|p| match provider_class(p.id) {
123            Some(ProviderClass::OAuth { .. }) => Some(p.id),
124            _ => None,
125        })
126        .collect();
127
128    stats.providers_examined = oauth_providers.len();
129
130    // Snapshot the existing bindings table ONCE per migration run. We
131    // walk every bundle's bindings and tally which providers are
132    // already covered — that's the "this provider is already managed,
133    // skip" gate. Doing it once (rather than per-provider) keeps the
134    // migration O(bundles) instead of O(providers × bundles), matters
135    // little in practice (single-digit counts on both axes) but is the
136    // clearer shape.
137    let covered_providers: std::collections::HashSet<String> = bound_providers(wstore);
138
139    let now_ms = SystemTime::now()
140        .duration_since(UNIX_EPOCH)
141        .map(|d| d.as_millis() as i64)
142        .unwrap_or(0);
143
144    // Track whether we created the Default bundle this run. Lazy: only
145    // do the upsert on the FIRST seedable provider, so a no-op
146    // migration (everything covered, or no ambient creds) doesn't even
147    // create the Default row.
148    let mut default_ready: Option<()> = None;
149
150    for provider_id in oauth_providers {
151        // Already-bound check — any bundle that has a binding for this
152        // provider counts as covered. The user has already done the
153        // OAuth flow once through `auth.start`, OR a prior run of this
154        // migration seeded it. Either way, don't touch the ambient
155        // creds again.
156        if covered_providers.contains(provider_id) {
157            stats.providers_skipped_existing += 1;
158            tracing::debug!(
159                target: "identity",
160                provider_id,
161                "oauth-bundles migration: provider already bound — skipping"
162            );
163            continue;
164        }
165
166        // Ambient-creds check — `<HOME>/.<auth_dir_name>/.credentials.json`.
167        // Use the provider registry's `auth_dir_name` (never hardcode
168        // `claude` / `.claude` — that's what makes the migration
169        // extensible to codex / openclaw without code changes).
170        let provider_cfg = match get_provider(provider_id) {
171            Some(p) => p,
172            None => {
173                // Should be impossible — we enumerated FROM the
174                // registry. Belt-and-braces.
175                tracing::warn!(
176                    target: "identity",
177                    provider_id,
178                    "oauth-bundles migration: provider missing from registry mid-iteration — skipping"
179                );
180                continue;
181            }
182        };
183        let ambient_dir = home.join(format!(".{}", provider_cfg.auth_dir_name));
184        let creds_file = ambient_dir.join(".credentials.json");
185        if !creds_file.exists() {
186            stats.providers_skipped_no_ambient += 1;
187            tracing::debug!(
188                target: "identity",
189                provider_id,
190                path = %creds_file.display(),
191                "oauth-bundles migration: no ambient credentials file — skipping"
192            );
193            continue;
194        }
195
196        // Ambient creds exist — ensure the Default bundle row exists.
197        // Lazy upsert: only the first seedable provider triggers it.
198        if default_ready.is_none() {
199            match ensure_default_bundle(wstore, now_ms) {
200                Ok(created) => {
201                    stats.default_bundle_created = created;
202                    default_ready = Some(());
203                }
204                Err(e) => {
205                    tracing::warn!(
206                        target: "identity",
207                        error = %e,
208                        "oauth-bundles migration: failed to upsert Default bundle — aborting migration this run"
209                    );
210                    // Bail entirely — without the bundle row we can't
211                    // bind anything, and the back-fill below would
212                    // point rows at a non-existent FK.
213                    return stats;
214                }
215            }
216        }
217
218        // Probe the ambient creds so the seeded binding lands with an
219        // accurate status (valid / expired / needs_reauth) rather than
220        // the resolver having to discover it on the next spawn.
221        // `probe_oauth_status` reads `<dir>/.credentials.json` itself,
222        // so we pass the ambient_dir, not the creds_file.
223        let ambient_dir_str = ambient_dir.to_string_lossy().to_string();
224        let probed_status = probe_oauth_status(provider_id, &ambient_dir_str, now_ms)
225            .map(|s| s.as_str())
226            .unwrap_or(oauth_status::UNKNOWN);
227
228        // Mirror `persist_oauth_binding_or_synthetic` (PR C):
229        // reuse the account_id if a binding already exists, mint a
230        // fresh uuid otherwise. (In practice we already filtered out
231        // covered providers above, so this is always the mint path —
232        // but the lookup costs nothing and keeps the shape identical
233        // to PR C, which makes the two paths interchangeable for any
234        // future refactor.)
235        let account_id = wstore
236            .bundle_identity_bindings(DEFAULT_BUNDLE_ID)
237            .ok()
238            .into_iter()
239            .flatten()
240            .find(|b| b.provider == provider_id)
241            .map(|b| b.account_id)
242            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
243
244        let account = IdentityAccount {
245            id: account_id.clone(),
246            name: format!("{provider_id}-oauth"),
247            provider: provider_id.to_string(),
248            kind: "oauth".to_string(),
249            display_name: String::new(),
250            secret_ref: SecretRef::OAuthConfigDir {
251                // Point at the ambient dir (`~/.claude/`, `~/.codex/`,
252                // `~/.openclaw/`) — NOT a per-bundle copy. Per spec §5
253                // the SecretRef is a pointer; we don't move tokens.
254                // The CLI keeps reading + refreshing in place, the
255                // resolver injects the dir at spawn time, end-to-end
256                // identical to a manually-configured bundle.
257                dir: ambient_dir_str.clone(),
258            },
259            context: serde_json::json!({}),
260            status: probed_status.to_string(),
261            created_at: now_ms,
262            updated_at: now_ms,
263        };
264
265        if let Err(e) = wstore.identity_upsert(&account) {
266            tracing::warn!(
267                target: "identity",
268                provider_id,
269                error = %e,
270                "oauth-bundles migration: identity_upsert failed — skipping provider"
271            );
272            continue;
273        }
274        if let Err(e) = wstore.bundle_identity_bind(DEFAULT_BUNDLE_ID, provider_id, &account_id) {
275            tracing::warn!(
276                target: "identity",
277                provider_id,
278                account_id,
279                error = %e,
280                "oauth-bundles migration: bundle_identity_bind failed — account row persisted but no binding"
281            );
282            continue;
283        }
284
285        stats.providers_seeded += 1;
286        tracing::info!(
287            target: "identity",
288            provider_id,
289            account_id,
290            dir = %ambient_dir.display(),
291            status = probed_status,
292            "oauth-bundles migration: bound ambient credentials into Default bundle"
293        );
294
295        // Best-effort broker publish so any open UI refreshes. None in
296        // tests; Some(broker) in production. Per spec §5 + the same
297        // pattern as `persist_oauth_binding_or_synthetic`.
298        if let Some(b) = broker {
299            b.publish(WaveEvent {
300                event: format!("identitybundlebindings:changed:{DEFAULT_BUNDLE_ID}"),
301                scopes: vec![],
302                sender: String::new(),
303                persist: 0,
304                data: None,
305            });
306        }
307
308        // The probe returned `Valid` / `Expired` / `NeedsReauth` /
309        // `None`. Logging here keeps the diagnostic trail intact for
310        // the "I just upgraded and my agent has the wrong status"
311        // support thread.
312        if let Some(probed) = OAuthProbeStatus::from_str(probed_status) {
313            tracing::info!(
314                target: "identity",
315                provider_id,
316                ?probed,
317                "oauth-bundles migration: probe status"
318            );
319        }
320    }
321
322    // Back-fill the empty / "blank" identity_id rows on
323    // db_agent_instances → Default bundle id. Per spec §5 step 4.
324    //
325    // Gate: the Default bundle must exist (FK target). It exists if
326    // EITHER we seeded a provider in THIS run (`default_ready`) OR a
327    // previous run already seeded it. The latter case matters because
328    // a legacy row (`identity_id == ""` / `"blank"`) created between
329    // restarts — by a code path that still produces them — would
330    // otherwise never get repaired: subsequent startups would
331    // `providers_skipped_existing` and skip the back-fill too. codex
332    // P2 on #983.
333    let default_bundle_exists = default_ready.is_some()
334        || wstore
335            .bundle_identity_list()
336            .ok()
337            .map(|bs| bs.iter().any(|b| b.id == DEFAULT_BUNDLE_ID))
338            .unwrap_or(false);
339    if default_bundle_exists {
340        match wstore.instance_backfill_identity_id(DEFAULT_BUNDLE_ID) {
341            Ok(rows) => {
342                stats.instances_backfilled = rows;
343                if rows > 0 {
344                    tracing::info!(
345                        target: "identity",
346                        rows,
347                        bundle_id = DEFAULT_BUNDLE_ID,
348                        "oauth-bundles migration: back-filled empty/blank identity_id rows"
349                    );
350                }
351            }
352            Err(e) => {
353                tracing::warn!(
354                    target: "identity",
355                    error = %e,
356                    "oauth-bundles migration: instance_backfill_identity_id failed — rows unchanged"
357                );
358            }
359        }
360    }
361
362    tracing::info!(
363        target: "identity",
364        ?stats,
365        "oauth-bundles migration: complete"
366    );
367    stats
368}
369
370/// Collect the set of providers that are bound in ANY identity bundle
371/// today. The migration's idempotency gate — if a provider is already
372/// in here, the ambient-creds seed is a no-op for that provider.
373fn bound_providers(wstore: &Arc<WaveStore>) -> std::collections::HashSet<String> {
374    let mut out = std::collections::HashSet::new();
375    let bundles = match wstore.bundle_identity_list() {
376        Ok(b) => b,
377        Err(e) => {
378            tracing::warn!(
379                target: "identity",
380                error = %e,
381                "oauth-bundles migration: bundle_identity_list failed — treating all providers as uncovered"
382            );
383            return out;
384        }
385    };
386    for bundle in bundles {
387        match wstore.bundle_identity_bindings(&bundle.id) {
388            Ok(bindings) => {
389                for b in bindings {
390                    out.insert(b.provider);
391                }
392            }
393            Err(e) => {
394                tracing::warn!(
395                    target: "identity",
396                    bundle_id = %bundle.id,
397                    error = %e,
398                    "oauth-bundles migration: bundle_identity_bindings failed for bundle — skipping"
399                );
400            }
401        }
402    }
403    out
404}
405
406/// Look up the Default bundle by id; create it (via
407/// `bundle_identity_upsert`) if missing. Returns `true` when a new row
408/// was inserted, `false` when an existing row was reused.
409fn ensure_default_bundle(
410    wstore: &Arc<WaveStore>,
411    now_ms: i64,
412) -> Result<bool, crate::backend::storage::error::StoreError> {
413    if let Some(existing) = wstore.bundle_identity_get(DEFAULT_BUNDLE_ID)? {
414        // Already exists. Don't churn `updated_at` — the bundle is
415        // unchanged by this run.
416        let _ = existing;
417        return Ok(false);
418    }
419    let identity = Identity {
420        id: DEFAULT_BUNDLE_ID.to_string(),
421        name: DEFAULT_BUNDLE_NAME.to_string(),
422        description: "Seeded from ambient OAuth credentials on first launch.".to_string(),
423        is_blank: false,
424        created_at: now_ms,
425        updated_at: now_ms,
426    };
427    wstore.bundle_identity_upsert(&identity)?;
428    Ok(true)
429}
430
431// Small helper to round-trip the probe status string back to the enum,
432// purely for the `tracing::info!` line at the seed site. Keeping the
433// resolver's enum + constants single-source.
434impl OAuthProbeStatus {
435    fn from_str(s: &str) -> Option<Self> {
436        match s {
437            oauth_status::VALID => Some(Self::Valid),
438            oauth_status::EXPIRED => Some(Self::Expired),
439            oauth_status::NEEDS_REAUTH => Some(Self::NeedsReauth),
440            _ => None,
441        }
442    }
443}
444
445// ─── Tests ───────────────────────────────────────────────────────────────
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::backend::storage::wstore::{IdentityAccount, SecretRef};
451
452    /// Create a fresh in-memory store. The seeded blank-singleton +
453    /// schema are already wired up by `WaveStore::open_in_memory`.
454    fn make_store() -> Arc<WaveStore> {
455        Arc::new(WaveStore::open_in_memory().unwrap())
456    }
457
458    /// Plant a Claude-shape ambient credentials file at
459    /// `<home>/.claude/.credentials.json` with a future expiry so the
460    /// probe reports `Valid`.
461    fn plant_ambient_claude_creds(home: &std::path::Path) {
462        let dir = home.join(".claude");
463        std::fs::create_dir_all(&dir).unwrap();
464        // Far-future expiry so the probe doesn't false-positive
465        // expired when the test machine's clock has drifted.
466        let body = serde_json::json!({
467            "claudeAiOauth": {
468                "accessToken": "test-access",
469                "refreshToken": "test-refresh",
470                "expiresAt": 99_999_999_999_999_i64,
471            }
472        });
473        std::fs::write(
474            dir.join(".credentials.json"),
475            serde_json::to_string(&body).unwrap(),
476        )
477        .unwrap();
478    }
479
480    #[test]
481    fn no_home_dir_skips_silently() {
482        // `home_dir_override = None` here is impossible to set up
483        // without affecting the user's real home, so we exercise the
484        // empty path via the no-ambient-creds branch (tempdir as
485        // home, no files planted). The "no home_dir resolvable" branch
486        // is unreachable via the public API short of unsetting HOME on
487        // every supported OS — covered by visual inspection.
488        let store = make_store();
489        let tmp = tempfile::tempdir().unwrap();
490        let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
491        assert!(stats.providers_examined > 0); // claude + codex + openclaw
492        assert_eq!(stats.providers_seeded, 0);
493        assert_eq!(stats.providers_skipped_no_ambient, stats.providers_examined);
494        assert_eq!(stats.providers_skipped_existing, 0);
495        assert!(!stats.default_bundle_created);
496        assert_eq!(stats.instances_backfilled, 0);
497    }
498
499    #[test]
500    fn ambient_claude_creds_create_default_bundle_and_bind() {
501        let store = make_store();
502        let tmp = tempfile::tempdir().unwrap();
503        plant_ambient_claude_creds(tmp.path());
504
505        let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
506
507        assert!(stats.default_bundle_created);
508        assert_eq!(stats.providers_seeded, 1);
509
510        // Default bundle row exists.
511        let default = store.bundle_identity_get(DEFAULT_BUNDLE_ID).unwrap();
512        assert!(default.is_some(), "Default bundle should be created");
513        let default = default.unwrap();
514        assert_eq!(default.name, DEFAULT_BUNDLE_NAME);
515        assert!(!default.is_blank);
516
517        // Binding for claude.
518        let bindings = store.bundle_identity_bindings(DEFAULT_BUNDLE_ID).unwrap();
519        assert_eq!(bindings.len(), 1);
520        let claude_binding = &bindings[0];
521        assert_eq!(claude_binding.provider, "claude");
522
523        // Account row exists with OAuthConfigDir pointing at the
524        // ambient dir.
525        let account = store.identity_get(&claude_binding.account_id).unwrap().unwrap();
526        assert_eq!(account.provider, "claude");
527        assert_eq!(account.kind, "oauth");
528        assert_eq!(account.status, oauth_status::VALID);
529        match account.secret_ref {
530            SecretRef::OAuthConfigDir { dir } => {
531                let expected = tmp.path().join(".claude").to_string_lossy().to_string();
532                assert_eq!(dir, expected);
533            }
534            other => panic!("expected OAuthConfigDir, got {:?}", other),
535        }
536    }
537
538    #[test]
539    fn idempotent_second_run_is_noop() {
540        // Running the migration twice with ambient creds present
541        // must NOT produce a second binding row or a second account
542        // row. Per spec §5: idempotent across restarts.
543        let store = make_store();
544        let tmp = tempfile::tempdir().unwrap();
545        plant_ambient_claude_creds(tmp.path());
546
547        let s1 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
548        assert_eq!(s1.providers_seeded, 1);
549        assert!(s1.default_bundle_created);
550
551        let bindings_after_first = store.bundle_identity_bindings(DEFAULT_BUNDLE_ID).unwrap();
552        assert_eq!(bindings_after_first.len(), 1);
553
554        let s2 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
555        assert_eq!(s2.providers_seeded, 0);
556        // Second run reuses the existing bundle row (no churn) and
557        // sees the existing binding → providers_skipped_existing.
558        assert!(!s2.default_bundle_created);
559        assert_eq!(s2.providers_skipped_existing, 1);
560
561        let bindings_after_second = store.bundle_identity_bindings(DEFAULT_BUNDLE_ID).unwrap();
562        assert_eq!(bindings_after_second.len(), 1);
563        // Same account_id — no orphan.
564        assert_eq!(
565            bindings_after_first[0].account_id,
566            bindings_after_second[0].account_id,
567        );
568    }
569
570    #[test]
571    fn existing_binding_in_other_bundle_skips_provider() {
572        // If the user has ALREADY done the auth.start flow and a
573        // user-named bundle already binds claude, the migration must
574        // NOT also seed the Default bundle for claude — that would
575        // double-bind the same provider across bundles. Per spec §5:
576        // "If a binding already exists → skip this provider."
577        let store = make_store();
578        let tmp = tempfile::tempdir().unwrap();
579        plant_ambient_claude_creds(tmp.path());
580
581        // Pre-seed a separate bundle that already binds claude.
582        let work_bundle = Identity {
583            id: "work-bundle".to_string(),
584            name: "Work".to_string(),
585            description: String::new(),
586            is_blank: false,
587            created_at: 0,
588            updated_at: 0,
589        };
590        store.bundle_identity_upsert(&work_bundle).unwrap();
591        let work_account = IdentityAccount {
592            id: "acct-work-claude".to_string(),
593            name: "work-claude".to_string(),
594            provider: "claude".to_string(),
595            kind: "oauth".to_string(),
596            display_name: String::new(),
597            secret_ref: SecretRef::OAuthConfigDir {
598                dir: "/somewhere/work/claude".to_string(),
599            },
600            context: serde_json::json!({}),
601            status: oauth_status::VALID.to_string(),
602            created_at: 0,
603            updated_at: 0,
604        };
605        store.identity_upsert(&work_account).unwrap();
606        store
607            .bundle_identity_bind("work-bundle", "claude", "acct-work-claude")
608            .unwrap();
609
610        let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
611
612        // Claude was covered by the work bundle → skipped.
613        assert_eq!(stats.providers_seeded, 0);
614        assert!(stats.providers_skipped_existing >= 1);
615
616        // Default bundle should NOT be created (we only do that on
617        // the first seedable provider; with claude skipped and codex
618        // / openclaw lacking ambient creds, there's nothing to seed).
619        assert!(!stats.default_bundle_created);
620        let default = store.bundle_identity_get(DEFAULT_BUNDLE_ID).unwrap();
621        assert!(default.is_none(), "Default bundle must not be auto-created when nothing to seed");
622    }
623
624    #[test]
625    fn backfills_empty_identity_id_rows_after_seed() {
626        // The Default bundle exists after seeding → empty /
627        // "blank" identity_id rows on db_agent_instances get
628        // back-filled to point at it.
629        let store = make_store();
630        let tmp = tempfile::tempdir().unwrap();
631        plant_ambient_claude_creds(tmp.path());
632
633        // Need an agent definition for the FK on db_agent_instances.
634        let mut def = crate::backend::storage::wstore::AgentDefinition {
635            id: "def-1".to_string(),
636            slug: String::new(),
637            name: "T".to_string(),
638            icon: "✦".to_string(),
639            provider: "claude".to_string(),
640            description: String::new(),
641            working_directory: String::new(),
642            shell: String::new(),
643            provider_flags: String::new(),
644            auto_start: 0,
645            restart_on_crash: 0,
646            idle_timeout_minutes: 0,
647            created_at: 0,
648            agent_type: String::new(),
649            environment: String::new(),
650            agent_bus_id: String::new(),
651            is_seeded: 0,
652            accounts: String::new(),
653            parent_id: String::new(),
654            branch_label: String::new(),
655            updated_at: 0,
656            user_hidden: 0,
657        };
658        store.agent_def_insert(&mut def).unwrap();
659
660        // Plant two legacy rows — one with empty identity_id, one
661        // with the legacy "blank" sentinel. Both should be
662        // back-filled.
663        let inst_empty = crate::backend::storage::wstore::AgentInstance {
664            id: "inst-empty".to_string(),
665            definition_id: "def-1".to_string(),
666            parent_instance_id: String::new(),
667            block_id: "block-empty".to_string(),
668            session_id: String::new(),
669            status: "running".to_string(),
670            github_context: String::new(),
671            started_at: 0,
672            ended_at: 0,
673            created_at: 0,
674            identity_id: String::new(),
675            memory_id: String::new(),
676            instance_name: String::new(),
677            working_directory: String::new(),
678            display_hidden: false,
679        };
680        store.instance_create(&inst_empty).unwrap();
681
682        let inst_blank = crate::backend::storage::wstore::AgentInstance {
683            id: "inst-blank".to_string(),
684            definition_id: "def-1".to_string(),
685            parent_instance_id: String::new(),
686            block_id: "block-blank".to_string(),
687            session_id: String::new(),
688            status: "running".to_string(),
689            github_context: String::new(),
690            started_at: 0,
691            ended_at: 0,
692            created_at: 0,
693            identity_id: "blank".to_string(),
694            memory_id: String::new(),
695            instance_name: String::new(),
696            working_directory: String::new(),
697            display_hidden: false,
698        };
699        store.instance_create(&inst_blank).unwrap();
700
701        // Plant a row that ALREADY has a real identity_id — must NOT
702        // be touched by the back-fill.
703        let inst_set = crate::backend::storage::wstore::AgentInstance {
704            id: "inst-set".to_string(),
705            definition_id: "def-1".to_string(),
706            parent_instance_id: String::new(),
707            block_id: "block-set".to_string(),
708            session_id: String::new(),
709            status: "running".to_string(),
710            github_context: String::new(),
711            started_at: 0,
712            ended_at: 0,
713            created_at: 0,
714            identity_id: "some-existing-bundle".to_string(),
715            memory_id: String::new(),
716            instance_name: String::new(),
717            working_directory: String::new(),
718            display_hidden: false,
719        };
720        store.instance_create(&inst_set).unwrap();
721
722        let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
723
724        assert!(stats.default_bundle_created);
725        assert_eq!(stats.instances_backfilled, 2);
726
727        // Verify the two legacy rows now point at Default.
728        let after_empty = store.instance_get("inst-empty").unwrap().unwrap();
729        assert_eq!(after_empty.identity_id, DEFAULT_BUNDLE_ID);
730        let after_blank = store.instance_get("inst-blank").unwrap().unwrap();
731        assert_eq!(after_blank.identity_id, DEFAULT_BUNDLE_ID);
732        // The non-empty row stays put.
733        let after_set = store.instance_get("inst-set").unwrap().unwrap();
734        assert_eq!(after_set.identity_id, "some-existing-bundle");
735    }
736
737    #[test]
738    fn backfills_legacy_rows_added_between_runs() {
739        // Subsequent-startup self-heal — codex P2 on #983: if a
740        // legacy row (`identity_id == ""` / `"blank"`) gets created
741        // AFTER the first migration seeded Default, the next startup
742        // would normally `providers_skipped_existing` for everything
743        // and skip the back-fill too. The back-fill gate must check
744        // "Default bundle exists" (whether seeded this run OR a prior
745        // run), not "did we seed in this run", so the new legacy row
746        // gets repaired.
747        let store = make_store();
748        let tmp = tempfile::tempdir().unwrap();
749        plant_ambient_claude_creds(tmp.path());
750
751        // Agent def + initial run that creates Default.
752        let mut def = crate::backend::storage::wstore::AgentDefinition {
753            id: "def-1".to_string(),
754            slug: String::new(),
755            name: "T".to_string(),
756            icon: "✦".to_string(),
757            provider: "claude".to_string(),
758            description: String::new(),
759            working_directory: String::new(),
760            shell: String::new(),
761            provider_flags: String::new(),
762            auto_start: 0,
763            restart_on_crash: 0,
764            idle_timeout_minutes: 0,
765            created_at: 0,
766            agent_type: String::new(),
767            environment: String::new(),
768            agent_bus_id: String::new(),
769            is_seeded: 0,
770            accounts: String::new(),
771            parent_id: String::new(),
772            branch_label: String::new(),
773            updated_at: 0,
774            user_hidden: 0,
775        };
776        store.agent_def_insert(&mut def).unwrap();
777
778        let s1 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
779        assert!(s1.default_bundle_created);
780        assert_eq!(s1.instances_backfilled, 0); // no legacy rows yet
781
782        // Between runs: a code path adds a legacy row with empty
783        // identity_id (simulates an older spawn path lingering).
784        let inst_late = crate::backend::storage::wstore::AgentInstance {
785            id: "inst-late".to_string(),
786            definition_id: "def-1".to_string(),
787            parent_instance_id: String::new(),
788            block_id: "block-late".to_string(),
789            session_id: String::new(),
790            status: "running".to_string(),
791            github_context: String::new(),
792            started_at: 0,
793            ended_at: 0,
794            created_at: 0,
795            identity_id: String::new(),
796            memory_id: String::new(),
797            instance_name: String::new(),
798            working_directory: String::new(),
799            display_hidden: false,
800        };
801        store.instance_create(&inst_late).unwrap();
802
803        // Second run: claude is `providers_skipped_existing`, so
804        // `default_ready` stays None — but Default exists from run 1,
805        // so the back-fill MUST still run.
806        let s2 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
807        assert!(!s2.default_bundle_created); // already there
808        assert_eq!(
809            s2.instances_backfilled, 1,
810            "subsequent-run back-fill must repair newly-added legacy rows"
811        );
812        let after = store.instance_get("inst-late").unwrap().unwrap();
813        assert_eq!(after.identity_id, DEFAULT_BUNDLE_ID);
814    }
815
816    #[test]
817    fn no_ambient_no_default_bundle_no_backfill() {
818        // The no-ambient path must not create the Default bundle
819        // (FK target would be missing) and must not back-fill rows
820        // to a non-existent id. Per spec §5 step 6.
821        let store = make_store();
822        let tmp = tempfile::tempdir().unwrap();
823        // NO `plant_ambient_claude_creds` — empty home.
824
825        // Plant a row with empty identity_id.
826        let mut def = crate::backend::storage::wstore::AgentDefinition {
827            id: "def-1".to_string(),
828            slug: String::new(),
829            name: "T".to_string(),
830            icon: "✦".to_string(),
831            provider: "claude".to_string(),
832            description: String::new(),
833            working_directory: String::new(),
834            shell: String::new(),
835            provider_flags: String::new(),
836            auto_start: 0,
837            restart_on_crash: 0,
838            idle_timeout_minutes: 0,
839            created_at: 0,
840            agent_type: String::new(),
841            environment: String::new(),
842            agent_bus_id: String::new(),
843            is_seeded: 0,
844            accounts: String::new(),
845            parent_id: String::new(),
846            branch_label: String::new(),
847            updated_at: 0,
848            user_hidden: 0,
849        };
850        store.agent_def_insert(&mut def).unwrap();
851        let inst = crate::backend::storage::wstore::AgentInstance {
852            id: "inst-empty".to_string(),
853            definition_id: "def-1".to_string(),
854            parent_instance_id: String::new(),
855            block_id: "block-empty".to_string(),
856            session_id: String::new(),
857            status: "running".to_string(),
858            github_context: String::new(),
859            started_at: 0,
860            ended_at: 0,
861            created_at: 0,
862            identity_id: String::new(),
863            memory_id: String::new(),
864            instance_name: String::new(),
865            working_directory: String::new(),
866            display_hidden: false,
867        };
868        store.instance_create(&inst).unwrap();
869
870        let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
871
872        assert_eq!(stats.providers_seeded, 0);
873        assert!(!stats.default_bundle_created);
874        assert_eq!(stats.instances_backfilled, 0);
875
876        // Row still has empty identity_id — no spurious FK write.
877        let after = store.instance_get("inst-empty").unwrap().unwrap();
878        assert_eq!(after.identity_id, "");
879    }
880}