agentmux_srv/migrations/
m0018_ambient_login_registry.rs1use 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 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 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 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 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 #[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 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}