Skip to main content

agentmux_srv/migrations/
m0011_shared_store_backfill.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::backend::storage::store::Store;
5use crate::backend::storage::muxbus::MuxBusCredentials;
6use crate::drone::storage::DroneStore;
7use crate::registry;
8use super::{Migration, MigrationContext, MigrationError, MigrationScope};
9
10pub struct M0011SharedStoreBackfill;
11
12impl Migration for M0011SharedStoreBackfill {
13    fn id(&self) -> &'static str { "0011_shared_store_backfill" }
14    fn scope(&self) -> MigrationScope { MigrationScope::Global }
15    fn description(&self) -> &'static str { "Seed shared store from per-channel objects.db files" }
16
17    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
18        let shared = Store::open_shared(&ctx.shared_store_path)
19            .map_err(|e| MigrationError(format!("shared_store_backfill: open shared: {}", e)))?;
20
21        let skip_accts       = !shared.identity_list(None).map_err(|e| e.to_string()).map_err(MigrationError)?.is_empty();
22        let skip_mem_bundles = !shared.bundle_list().map_err(|e| e.to_string()).map_err(MigrationError)?.iter().all(|b| b.id == "blank");
23        let skip_drones      = !shared.drone_list().map_err(|e| e.to_string()).map_err(MigrationError)?.is_empty();
24        let skip_links       = !shared.agent_identity_list_all().map_err(|e| e.to_string()).map_err(MigrationError)?.is_empty();
25        let skip_muxbus      = shared.muxbus_load().ok().flatten().is_some();
26
27        // Always include the current channel's objects.db first.
28        // enumerate_objects_dbs scans home/channels/<ch>/… and misses paths
29        // outside that tree (e.g. custom AGENTMUX_DATA_HOME). Upserts are
30        // idempotent, so a duplicate via the sibling scan is harmless.
31        let mut sibling_stores: Vec<Store> = Vec::new();
32        if ctx.channel_store_path.exists() {
33            match Store::open_source_readonly(&ctx.channel_store_path) {
34                Ok(s) => sibling_stores.push(s),
35                Err(e) => tracing::debug!(path = %ctx.channel_store_path.display(), error = %e, "shared_store_backfill: skip current channel store"),
36            }
37        }
38        // Isolation boundary, not a bug: an isolated shared store (see
39        // agentmux_common::isolated_auth_enabled) must start genuinely
40        // empty of every OTHER channel's real identity accounts/links —
41        // scanning every sibling objects.db on the machine (ctx.home is
42        // always the true global root, per resolve_home's doc comment)
43        // would defeat the entire point of a disposable test store. This
44        // channel's own local data (added above) is fine to carry in.
45        if !agentmux_common::isolated_auth_enabled() {
46            for path in registry::enumerate_objects_dbs(&ctx.home) {
47                if path == ctx.channel_store_path { continue; } // already added above
48                match Store::open_source_readonly(&path) {
49                    Ok(s) => sibling_stores.push(s),
50                    Err(e) => tracing::debug!(path = %path.display(), error = %e, "shared_store_backfill: skip sibling"),
51                }
52            }
53        }
54
55        // Pass 1: accounts from all sources (before bindings/links for FK safety)
56        if !skip_accts {
57            for src in &sibling_stores {
58                for acct in src.identity_list(None).unwrap_or_default() {
59                    let _ = shared.identity_upsert(&acct);
60                }
61            }
62        }
63
64        // Pass 2: bundles, drones, links. (Identity bundles were
65        // dropped in Phase 4c of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md
66        // — db_identity_bundles/db_identity_bindings no longer exist, so
67        // this pass no longer backfills them.)
68        if !skip_mem_bundles {
69            for src in &sibling_stores {
70                for mem in src.bundle_list().unwrap_or_default().iter().filter(|b| b.id != "blank") {
71                    let _ = shared.bundle_upsert(mem);
72                }
73            }
74        }
75        if !skip_drones {
76            for src in &sibling_stores {
77                for drone in src.drone_list().unwrap_or_default() {
78                    let _ = shared.drone_upsert(&drone);
79                }
80            }
81        }
82        if !skip_links {
83            for src in &sibling_stores {
84                for link in src.agent_identity_list_all().unwrap_or_default() {
85                    let _ = shared.agent_identity_link(&link.agent_id, &link.account_id, &link.provider);
86                }
87            }
88        }
89        if !skip_muxbus {
90            let best: Option<MuxBusCredentials> = sibling_stores.iter()
91                .filter_map(|src| src.muxbus_load().ok().flatten())
92                .max_by_key(|c| c.expires_at);
93            if let Some(creds) = best {
94                let _ = shared.muxbus_save(&creds);
95            }
96        }
97
98        Ok(())
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::backend::storage::store::{IdentityAccount, SecretRef};
106
107    // Process-global env access — shared with registry::paths and
108    // migrations::runner's tests, which mutate the SAME
109    // AGENTMUX_ISOLATED_AUTH/AGENTMUX_INSTANCE_DIR vars. A module-local lock
110    // only serializes tests within this file; Cargo runs a crate's tests in
111    // one multi-threaded process, so a local-only lock still let this
112    // module's tests race against those two (reagent/codex on PR #2318).
113    use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_LOCK;
114
115    fn clear() {
116        std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
117        // Auth is shared on every channel by default, so a leaked channel no
118        // longer changes isolation; cleared anyway so each test starts from a
119        // known env.
120        std::env::remove_var("AGENTMUX_CHANNEL");
121    }
122
123    fn make_account(id: &str) -> IdentityAccount {
124        IdentityAccount {
125            id: id.to_string(),
126            name: format!("acct-{id}"),
127            provider: "claude".to_string(),
128            kind: "oauth".to_string(),
129            display_name: String::new(),
130            secret_ref: SecretRef::OAuthConfigDir { dir: format!("/tmp/{id}") },
131            context: serde_json::json!({}),
132            status: "valid".to_string(),
133            created_at: 0,
134            updated_at: 0,
135        }
136    }
137
138    /// Sets up: a fresh empty shared store, this channel's own (empty)
139    /// objects.db, and ONE sibling dev-branch's objects.db (under
140    /// `<home>/dev/other-branch/data/db/objects.db`, the layout
141    /// `enumerate_objects_dbs` scans) seeded with a real identity account —
142    /// simulating another, unrelated dev branch's real credentials.
143    fn setup() -> (tempfile::TempDir, MigrationContext) {
144        let tmp = tempfile::TempDir::new().unwrap();
145        let home = tmp.path().to_path_buf();
146
147        let sibling_db = home.join("dev").join("other-branch").join("data").join("db").join("objects.db");
148        std::fs::create_dir_all(sibling_db.parent().unwrap()).unwrap();
149        let sibling_store = Store::open(&sibling_db).unwrap();
150        sibling_store.identity_upsert(&make_account("sibling-acct")).unwrap();
151
152        let channel_store_path = home.join("this-channel").join("data").join("db").join("objects.db");
153        std::fs::create_dir_all(channel_store_path.parent().unwrap()).unwrap();
154        Store::open(&channel_store_path).unwrap(); // create, empty — no local accounts
155
156        let shared_store_path = home.join("shared").join("store.db");
157        std::fs::create_dir_all(shared_store_path.parent().unwrap()).unwrap();
158
159        let ctx = MigrationContext {
160            home,
161            data_dir: tmp.path().join("this-channel").join("data"),
162            shared_store_path,
163            channel_store_path,
164        };
165        (tmp, ctx)
166    }
167
168    #[test]
169    fn backfills_sibling_accounts_when_not_isolated() {
170        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
171        clear();
172        let (_tmp, ctx) = setup();
173
174        M0011SharedStoreBackfill.up(&ctx).unwrap();
175
176        let shared = Store::open_shared(&ctx.shared_store_path).unwrap();
177        let ids: Vec<String> = shared.identity_list(None).unwrap().into_iter().map(|a| a.id).collect();
178        assert!(
179            ids.contains(&"sibling-acct".to_string()),
180            "default (non-isolated) backfill must pick up the sibling branch's real account, got: {ids:?}"
181        );
182    }
183
184    #[test]
185    fn skips_sibling_accounts_when_isolated() {
186        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
187        clear();
188        std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
189        let (_tmp, ctx) = setup();
190
191        M0011SharedStoreBackfill.up(&ctx).unwrap();
192
193        let shared = Store::open_shared(&ctx.shared_store_path).unwrap();
194        let ids: Vec<String> = shared.identity_list(None).unwrap().into_iter().map(|a| a.id).collect();
195        assert!(
196            ids.is_empty(),
197            "isolated backfill must NOT pick up any other channel's real account, got: {ids:?}"
198        );
199        clear();
200    }
201}