1use 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
53pub const DEFAULT_BUNDLE_ID: &str = "default";
58
59pub const DEFAULT_BUNDLE_NAME: &str = "Default";
61
62#[derive(Debug, Default, Clone, PartialEq, Eq)]
66pub struct MigrationStats {
67 pub providers_examined: usize,
69 pub providers_skipped_existing: usize,
72 pub providers_skipped_no_ambient: usize,
75 pub providers_seeded: usize,
77 pub default_bundle_created: bool,
79 pub instances_backfilled: usize,
82}
83
84pub 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 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 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 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 let mut default_ready: Option<()> = None;
149
150 for provider_id in oauth_providers {
151 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 let provider_cfg = match get_provider(provider_id) {
171 Some(p) => p,
172 None => {
173 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 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 return stats;
214 }
215 }
216 }
217
218 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 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 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 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 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 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
370fn 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
406fn 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 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
431impl 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#[cfg(test)]
448mod tests {
449 use super::*;
450 use crate::backend::storage::wstore::{IdentityAccount, SecretRef};
451
452 fn make_store() -> Arc<WaveStore> {
455 Arc::new(WaveStore::open_in_memory().unwrap())
456 }
457
458 fn plant_ambient_claude_creds(home: &std::path::Path) {
462 let dir = home.join(".claude");
463 std::fs::create_dir_all(&dir).unwrap();
464 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 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); 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 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 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 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 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 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 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 let store = make_store();
578 let tmp = tempfile::tempdir().unwrap();
579 plant_ambient_claude_creds(tmp.path());
580
581 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 assert_eq!(stats.providers_seeded, 0);
614 assert!(stats.providers_skipped_existing >= 1);
615
616 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 let store = make_store();
630 let tmp = tempfile::tempdir().unwrap();
631 plant_ambient_claude_creds(tmp.path());
632
633 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 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 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 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 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 let store = make_store();
748 let tmp = tempfile::tempdir().unwrap();
749 plant_ambient_claude_creds(tmp.path());
750
751 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); 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 let s2 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
807 assert!(!s2.default_bundle_created); 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 let store = make_store();
822 let tmp = tempfile::tempdir().unwrap();
823 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 let after = store.instance_get("inst-empty").unwrap().unwrap();
878 assert_eq!(after.identity_id, "");
879 }
880}