Skip to main content

agentmux_srv/migrations/
m0018_ambient_login_registry.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Grandfather `use_ambient_login` on the GLOBAL cross-channel definition
5//! registry (`~/.agentmux/shared/agents/definitions/`) — the sibling of
6//! `m0017_ambient_login_grandfather`, which handles the per-channel SQLite
7//! projections.
8//!
9//! Why a separate, Global-scoped step: an agent created in channel A is
10//! surfaced in channel B only via its registry record (`agent_def_get`'s
11//! registry fallback), so a record left at the serde-default `0` would
12//! block that agent's spawns in every OTHER channel even after m0017
13//! grandfathered channel A's SQLite rows. Global scope also means this
14//! runs exactly once ever — a Channel-scoped registry pass would re-run
15//! when a NEW channel is created later and wrongly flip agents that were
16//! deliberately created fail-by-default after the upgrade.
17//!
18//! Spec §2.4 of SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md.
19
20use std::collections::HashSet;
21
22use crate::backend::storage::store::Store;
23use crate::registry;
24use super::{Migration, MigrationContext, MigrationError, MigrationScope};
25
26pub struct M0018AmbientLoginRegistry;
27
28impl Migration for M0018AmbientLoginRegistry {
29    fn id(&self) -> &'static str { "0018_ambient_login_registry" }
30    fn scope(&self) -> MigrationScope { MigrationScope::Global }
31    fn description(&self) -> &'static str {
32        "Grandfather use_ambient_login=1 on linkless cross-channel definition records"
33    }
34
35    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
36        // Isolation boundary, not a bug: this migration reads oauth-linked
37        // agent ids from ctx.shared_store_path (isolated to a fresh,
38        // per-instance store — see agentmux_common::isolated_auth_enabled)
39        // and, unlike m0017's channel-scoped sibling, writes its conclusion
40        // into the REAL global cross-channel definitions registry
41        // (ctx.home is always the true global root, per resolve_home's doc
42        // comment). An isolated store starts with zero links by design, so
43        // without this guard, merely booting an isolated dev-test instance
44        // for the first time would flip every real agent everywhere to
45        // use_ambient_login=1 — reproducing the exact "changes real
46        // production auth state" bug this whole feature exists to prevent
47        // (reagent/codex on PR #2318). There is no isolated-store
48        // equivalent of this write to redirect to instead: it is skipped
49        // outright.
50        if agentmux_common::isolated_auth_enabled() {
51            return Ok(());
52        }
53        let def_dir = ctx.home.join("shared").join("agents").join("definitions");
54        if !def_dir.exists() {
55            return Ok(());
56        }
57        let def_store = registry::DefinitionStore::open(def_dir)
58            .map_err(|e| MigrationError(format!("ambient_login_registry: open def store: {}", e)))?;
59
60        // Same oauth-class-only rule as m0017 (shared helper — the two
61        // passes must never disagree): api-key links don't forfeit
62        // grandfathering.
63        let linked: HashSet<String> = if ctx.shared_store_path.exists() {
64            let shared = Store::open_shared(&ctx.shared_store_path)
65                .map_err(|e| MigrationError(format!("ambient_login_registry: open shared store: {}", e)))?;
66            super::m0017_ambient_login_grandfather::oauth_linked_agent_ids(&shared)
67                .map_err(|e| MigrationError(format!("ambient_login_registry: read links: {}", e)))?
68        } else {
69            HashSet::new()
70        };
71
72        let records = def_store
73            .list_active()
74            .map_err(|e| MigrationError(format!("ambient_login_registry: list records: {}", e)))?;
75        let mut flipped = 0usize;
76        for mut rec in records {
77            let id = rec.data.id.clone();
78            let want = if linked.contains(&id) { 0 } else { 1 };
79            if rec.data.use_ambient_login != want {
80                rec.data.use_ambient_login = want;
81                def_store
82                    .upsert(&rec)
83                    .map_err(|e| MigrationError(format!("ambient_login_registry: upsert {}: {}", id, e)))?;
84                flipped += 1;
85            }
86        }
87        tracing::info!(
88            target: "identity",
89            flipped,
90            "identity.spawn: ambient-login registry grandfather — {} record(s) updated",
91            flipped,
92        );
93        Ok(())
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::registry::{DefinitionRecord, DefinitionRecordV1, DEF_MAX_SUPPORTED_SCHEMA};
101    // Process-global env access — shared with registry::paths,
102    // migrations::runner, and migrations::m0011_shared_store_backfill's
103    // tests, which mutate the SAME AGENTMUX_ISOLATED_AUTH var. A
104    // module-local lock only serializes tests within this file; Cargo runs
105    // a crate's tests in one multi-threaded process, so a local-only lock
106    // would still let this module's test race against those (reagent/codex
107    // on PR #2318).
108    use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_LOCK;
109
110    fn record(id: &str) -> DefinitionRecord {
111        DefinitionRecord {
112            schema_version: DEF_MAX_SUPPORTED_SCHEMA,
113            data: DefinitionRecordV1 {
114                id: id.to_string(),
115                name: id.to_string(),
116                provider: "claude".to_string(),
117                is_seeded: 0,
118                ..Default::default()
119            },
120        }
121    }
122
123    #[test]
124    fn linkless_record_flips_to_ambient_linked_record_stays() {
125        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
126        std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
127        // Auth is shared on every channel by default, so a leaked channel no
128        // longer changes isolation; cleared anyway so the test starts from a
129        // known env.
130        std::env::remove_var("AGENTMUX_CHANNEL");
131        let home = tempfile::tempdir().unwrap();
132        let def_dir = home.path().join("shared").join("agents").join("definitions");
133        let def_store = registry::DefinitionStore::open(def_dir.clone()).unwrap();
134        def_store.upsert(&record("remote-linkless")).unwrap();
135        def_store.upsert(&record("remote-linked")).unwrap();
136
137        let shared = home.path().join("shared").join("store.db");
138        std::fs::create_dir_all(shared.parent().unwrap()).unwrap();
139        let shared_store = Store::open_shared(&shared).unwrap();
140        let acct = crate::backend::storage::store::IdentityAccount {
141            id: "acct-1".to_string(),
142            name: "claude-acct-1".to_string(),
143            provider: "claude".to_string(),
144            kind: "oauth".to_string(),
145            display_name: String::new(),
146            secret_ref: crate::backend::storage::store::SecretRef::OAuthConfigDir {
147                dir: "/tmp/nowhere".to_string(),
148            },
149            context: serde_json::json!({}),
150            status: "unknown".to_string(),
151            created_at: 0,
152            updated_at: 0,
153        };
154        shared_store.identity_upsert(&acct).unwrap();
155        shared_store
156            .agent_identity_link("remote-linked", "acct-1", "claude")
157            .unwrap();
158        drop(shared_store);
159
160        let ctx = MigrationContext {
161            home: home.path().to_path_buf(),
162            data_dir: home.path().to_path_buf(),
163            shared_store_path: shared,
164            channel_store_path: home.path().join("unused-objects.db"),
165        };
166        M0018AmbientLoginRegistry.up(&ctx).unwrap();
167
168        let reopened = registry::DefinitionStore::open(def_dir).unwrap();
169        assert_eq!(
170            reopened.get("remote-linkless").unwrap().unwrap().data.use_ambient_login,
171            1,
172            "linkless registry record must be grandfathered to ambient"
173        );
174        assert_eq!(
175            reopened.get("remote-linked").unwrap().unwrap().data.use_ambient_login,
176            0,
177            "linked registry record must keep fail-by-default"
178        );
179    }
180
181    /// reagent/codex P1 on PR #2318: an isolated boot's shared store is a
182    /// fresh, empty, per-instance file — computing `linked` from IT and
183    /// then rewriting the REAL global cross-channel definitions registry
184    /// would flip every genuinely-linked agent everywhere to
185    /// use_ambient_login=1, merely because a disposable test store had no
186    /// links yet. `up()` must skip entirely when isolated, leaving the
187    /// real registry completely untouched regardless of what the isolated
188    /// store contains.
189    #[test]
190    fn skips_global_registry_rewrite_when_isolated() {
191        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
192        std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
193        std::env::remove_var("AGENTMUX_CHANNEL");
194
195        let home = tempfile::tempdir().unwrap();
196        let def_dir = home.path().join("shared").join("agents").join("definitions");
197        let def_store = registry::DefinitionStore::open(def_dir.clone()).unwrap();
198        // Already correctly marked fail-by-default, mirroring a real agent
199        // with a real oauth link in the REAL global shared store (which
200        // this test deliberately never touches — only ctx.shared_store_path,
201        // pointed at a separate, empty "isolated" file, as
202        // resolve_shared_store_path() would under AGENTMUX_ISOLATED_AUTH=1).
203        let mut linked_rec = record("remote-linked");
204        linked_rec.data.use_ambient_login = 0;
205        def_store.upsert(&linked_rec).unwrap();
206
207        let isolated_shared = home.path().join("isolated-instance").join("identity-store.db");
208        std::fs::create_dir_all(isolated_shared.parent().unwrap()).unwrap();
209
210        let ctx = MigrationContext {
211            home: home.path().to_path_buf(),
212            data_dir: home.path().to_path_buf(),
213            shared_store_path: isolated_shared,
214            channel_store_path: home.path().join("unused-objects.db"),
215        };
216        M0018AmbientLoginRegistry.up(&ctx).unwrap();
217
218        let reopened = registry::DefinitionStore::open(def_dir).unwrap();
219        assert_eq!(
220            reopened.get("remote-linked").unwrap().unwrap().data.use_ambient_login,
221            0,
222            "isolated boot must not rewrite the real global registry at all, \
223             even though the isolated shared store has zero links"
224        );
225
226        std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
227    }
228
229    #[test]
230    fn missing_registry_dir_is_a_noop() {
231        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
232        std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
233        std::env::remove_var("AGENTMUX_CHANNEL");
234        let home = tempfile::tempdir().unwrap();
235        let ctx = MigrationContext {
236            home: home.path().to_path_buf(),
237            data_dir: home.path().to_path_buf(),
238            shared_store_path: home.path().join("shared").join("store.db"),
239            channel_store_path: home.path().join("unused-objects.db"),
240        };
241        M0018AmbientLoginRegistry.up(&ctx).unwrap();
242    }
243}