1use std::collections::HashSet;
35use std::sync::Arc;
36
37use crate::backend::storage::store::Store;
38use super::{Migration, MigrationContext, MigrationError, MigrationScope};
39
40pub(crate) fn oauth_linked_agent_ids(
45 shared: &Store,
46) -> Result<HashSet<String>, crate::backend::storage::error::StoreError> {
47 use crate::identity::resolver::{provider_class, ProviderClass};
48 Ok(shared
49 .agent_identity_link_provider_pairs()?
50 .into_iter()
51 .filter(|(_, provider)| matches!(provider_class(provider), Some(ProviderClass::OAuth { .. })))
52 .map(|(agent_id, _)| agent_id)
53 .collect())
54}
55
56pub struct M0017AmbientLoginGrandfather;
57
58impl Migration for M0017AmbientLoginGrandfather {
59 fn id(&self) -> &'static str { "0017_ambient_login_grandfather" }
60 fn scope(&self) -> MigrationScope { MigrationScope::Channel }
61 fn description(&self) -> &'static str {
62 "Grandfather use_ambient_login=1 for agents without identity links (layer-3 spawn gating)"
63 }
64
65 fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
66 if !ctx.channel_store_path.exists() {
67 return Ok(());
68 }
69 let mstore = Arc::new(
70 Store::open(&ctx.channel_store_path)
71 .map_err(|e| MigrationError(format!("ambient_login_grandfather: open mstore: {}", e)))?,
72 );
73 let linked: HashSet<String> = if ctx.shared_store_path.exists() {
77 let shared = Store::open_shared(&ctx.shared_store_path)
78 .map_err(|e| MigrationError(format!("ambient_login_grandfather: open shared store: {}", e)))?;
79 oauth_linked_agent_ids(&shared)
80 .map_err(|e| MigrationError(format!("ambient_login_grandfather: read links: {}", e)))?
81 } else {
82 HashSet::new()
83 };
84 let (ambient, managed) = mstore
85 .agents_grandfather_ambient_login(&linked)
86 .map_err(|e| MigrationError(format!("ambient_login_grandfather: {}", e)))?;
87 tracing::info!(
88 target: "identity",
89 ambient,
90 managed,
91 "identity.spawn: ambient-login grandfather — {} linkless agent(s) set to ambient, {} linked agent(s) kept fail-by-default",
92 ambient,
93 managed,
94 );
95 Ok(())
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use crate::backend::storage::store::AgentDefinition;
103
104 fn ctx_for(channel: &std::path::Path, shared: &std::path::Path) -> MigrationContext {
105 MigrationContext {
106 home: std::env::temp_dir(),
107 data_dir: std::env::temp_dir(),
108 shared_store_path: shared.to_path_buf(),
109 channel_store_path: channel.to_path_buf(),
110 }
111 }
112
113 fn make_def(id: &str) -> AgentDefinition {
114 AgentDefinition {
115 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
116 id: id.to_string(),
117 slug: id.to_string(),
118 name: id.to_string(),
119 icon: "✦".to_string(),
120 provider: "claude".to_string(),
121 description: String::new(),
122 working_directory: String::new(),
123 shell: String::new(),
124 provider_flags: String::new(),
125 auto_start: 0,
126 restart_on_crash: 0,
127 idle_timeout_minutes: 0,
128 created_at: 0,
129 agent_type: String::new(),
130 environment: String::new(),
131 agent_bus_id: String::new(),
132 is_seeded: 0,
133 accounts: String::new(),
134 parent_id: String::new(),
135 branch_label: String::new(),
136 updated_at: 0,
137 user_hidden: 0,
138 container_image: String::new(),
139 container_volumes: "[]".to_string(),
140 container_name: String::new(),
141 use_ambient_login: 0,
142 auto_continue_enabled: 0,
143 model_vendor_base_url: String::new(),
144 memory_id: String::new(),
145 }
146 }
147
148 #[test]
152 fn linkless_agent_gets_ambient_linked_agent_stays_fail_by_default() {
153 let channel = tempfile::NamedTempFile::new().unwrap();
154 let shared = tempfile::NamedTempFile::new().unwrap();
155
156 let mstore = Store::open(channel.path()).unwrap();
158 let mut linkless = make_def("agent-linkless");
159 mstore.agent_def_insert(&mut linkless).unwrap();
160 let mut linked = make_def("agent-linked");
161 mstore.agent_def_insert(&mut linked).unwrap();
162
163 let shared_store = Store::open_shared(shared.path()).unwrap();
167 let acct = crate::backend::storage::store::IdentityAccount {
168 id: "acct-1".to_string(),
169 name: "claude-acct-1".to_string(),
170 provider: "claude".to_string(),
171 kind: "oauth".to_string(),
172 display_name: String::new(),
173 secret_ref: crate::backend::storage::store::SecretRef::OAuthConfigDir {
174 dir: "/tmp/nowhere".to_string(),
175 },
176 context: serde_json::json!({}),
177 status: "unknown".to_string(),
178 created_at: 0,
179 updated_at: 0,
180 };
181 shared_store.identity_upsert(&acct).unwrap();
182 shared_store
183 .agent_identity_link("agent-linked", "acct-1", "claude")
184 .unwrap();
185 drop(shared_store);
186 drop(mstore);
187
188 M0017AmbientLoginGrandfather
189 .up(&ctx_for(channel.path(), shared.path()))
190 .unwrap();
191
192 let mstore = Store::open(channel.path()).unwrap();
193 let after_linkless = mstore.agent_def_get("agent-linkless").unwrap().unwrap();
194 assert_eq!(
195 after_linkless.use_ambient_login, 1,
196 "linkless agent must be grandfathered to ambient"
197 );
198 let after_linked = mstore.agent_def_get("agent-linked").unwrap().unwrap();
199 assert_eq!(
200 after_linked.use_ambient_login, 0,
201 "linked agent must keep fail-by-default"
202 );
203 let listed = mstore.agent_def_list().unwrap();
206 assert_eq!(
207 listed.iter().find(|a| a.id == "agent-linkless").unwrap().use_ambient_login,
208 1,
209 );
210 assert_eq!(
211 listed.iter().find(|a| a.id == "agent-linked").unwrap().use_ambient_login,
212 0,
213 );
214 }
215
216 #[test]
222 fn api_key_only_links_do_not_forfeit_grandfathering() {
223 let channel = tempfile::NamedTempFile::new().unwrap();
224 let shared = tempfile::NamedTempFile::new().unwrap();
225
226 let mstore = Store::open(channel.path()).unwrap();
227 let mut pat_only = make_def("agent-pat-only");
228 mstore.agent_def_insert(&mut pat_only).unwrap();
229 drop(mstore);
230
231 let shared_store = Store::open_shared(shared.path()).unwrap();
233 let acct = crate::backend::storage::store::IdentityAccount {
234 id: "acct-gh".to_string(),
235 name: "asaf-github".to_string(),
236 provider: "github".to_string(),
237 kind: "pat".to_string(),
238 display_name: String::new(),
239 secret_ref: crate::backend::storage::store::SecretRef::Keychain {
240 service: "agentmux".to_string(),
241 account: "acct-gh".to_string(),
242 },
243 context: serde_json::json!({}),
244 status: "unknown".to_string(),
245 created_at: 0,
246 updated_at: 0,
247 };
248 shared_store.identity_upsert(&acct).unwrap();
249 shared_store
250 .agent_identity_link("agent-pat-only", "acct-gh", "github")
251 .unwrap();
252 drop(shared_store);
253
254 M0017AmbientLoginGrandfather
255 .up(&ctx_for(channel.path(), shared.path()))
256 .unwrap();
257
258 let mstore = Store::open(channel.path()).unwrap();
259 assert_eq!(
260 mstore.agent_def_get("agent-pat-only").unwrap().unwrap().use_ambient_login,
261 1,
262 "a PAT-only agent is a de-facto ambient CLI user and must be grandfathered"
263 );
264 }
265
266 #[test]
267 fn missing_shared_store_treats_every_agent_as_linkless() {
268 let channel = tempfile::NamedTempFile::new().unwrap();
269 let mstore = Store::open(channel.path()).unwrap();
270 let mut def = make_def("agent-solo");
271 mstore.agent_def_insert(&mut def).unwrap();
272 drop(mstore);
273
274 let missing_shared = std::env::temp_dir()
275 .join("agentmux-test-shared-store-definitely-missing-x9q.db");
276 let _ = std::fs::remove_file(&missing_shared);
277 M0017AmbientLoginGrandfather
278 .up(&ctx_for(channel.path(), &missing_shared))
279 .unwrap();
280
281 let mstore = Store::open(channel.path()).unwrap();
282 assert_eq!(
283 mstore.agent_def_get("agent-solo").unwrap().unwrap().use_ambient_login,
284 1,
285 );
286 }
287
288 #[test]
289 fn missing_channel_store_is_a_noop() {
290 let shared = tempfile::NamedTempFile::new().unwrap();
291 let missing_channel = std::env::temp_dir()
292 .join("agentmux-test-channel-store-definitely-missing-x9q.db");
293 let _ = std::fs::remove_file(&missing_channel);
294 M0017AmbientLoginGrandfather
295 .up(&ctx_for(&missing_channel, shared.path()))
296 .unwrap();
297 assert!(!missing_channel.exists(), "up() must not create the store");
298 }
299}