1use std::collections::HashMap;
43use std::sync::{Arc, RwLock};
44
45use rusqlite::params;
46
47use super::agent_lan_keys::random_seed_bytes;
48use super::error::StoreError;
49use super::store::Store;
50
51use agentmux_common::time::now_secs;
52
53fn random_token() -> String {
57 hex::encode(random_seed_bytes())
58}
59
60#[derive(Default)]
69pub struct TokenIndex {
70 maps: RwLock<TokenMaps>,
71}
72
73#[derive(Default)]
74struct TokenMaps {
75 uid_by_token: HashMap<String, String>,
76 token_by_uid: HashMap<String, String>,
77}
78
79impl TokenIndex {
80 pub fn uid_for(&self, token: &str) -> Option<String> {
82 let maps = self.maps.read().unwrap_or_else(|e| e.into_inner());
83 maps.uid_by_token.get(token).cloned()
84 }
85
86 fn insert(&self, uid: &str, token: &str) {
87 let mut maps = self.maps.write().unwrap_or_else(|e| e.into_inner());
88 if let Some(old) = maps.token_by_uid.insert(uid.to_string(), token.to_string()) {
89 if old != token {
90 maps.uid_by_token.remove(&old);
91 }
92 }
93 maps.uid_by_token.insert(token.to_string(), uid.to_string());
94 }
95
96 pub(super) fn forget_uid(&self, uid: &str) {
99 let mut maps = self.maps.write().unwrap_or_else(|e| e.into_inner());
100 if let Some(token) = maps.token_by_uid.remove(uid) {
101 maps.uid_by_token.remove(&token);
102 }
103 }
104
105 #[cfg(test)]
106 pub fn len(&self) -> usize {
107 self.maps
108 .read()
109 .unwrap_or_else(|e| e.into_inner())
110 .uid_by_token
111 .len()
112 }
113}
114
115impl Store {
116 pub fn attach_token_index(&self) -> Result<Arc<TokenIndex>, StoreError> {
121 let conn = self.conn.lock().unwrap();
122 let index = Arc::new(TokenIndex::default());
123 {
124 let mut stmt = conn.prepare("SELECT agent_id, token FROM db_agent_tokens")?;
125 let rows =
126 stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
127 for row in rows {
128 let (uid, token) = row?;
129 index.insert(&uid, &token);
130 }
131 }
132 *self.token_index.lock().unwrap_or_else(|e| e.into_inner()) = Some(index.clone());
133 Ok(index)
134 }
135
136 pub fn token_index(&self) -> Option<Arc<TokenIndex>> {
138 self.token_index
139 .lock()
140 .unwrap_or_else(|e| e.into_inner())
141 .clone()
142 }
143
144 #[cfg_attr(not(test), allow(dead_code))]
151 pub fn agent_token_load(&self, uid: &str) -> Result<Option<String>, StoreError> {
152 let conn = self.conn.lock().unwrap();
153 let mut stmt = conn.prepare("SELECT token FROM db_agent_tokens WHERE agent_id = ?1")?;
154 match stmt.query_row(params![uid], |row| row.get::<_, String>(0)) {
155 Ok(v) => Ok(Some(v)),
156 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
157 Err(e) => Err(e.into()),
158 }
159 }
160
161 pub fn agent_token_ensure(&self, uid: &str) -> Result<String, StoreError> {
172 if uid.trim().is_empty() {
173 return Err(StoreError::Other(
174 "agent_token_ensure: refusing to mint a token for an empty uid".to_string(),
175 ));
176 }
177 let conn = self.conn.lock().unwrap();
178 conn.execute(
179 "INSERT OR IGNORE INTO db_agent_tokens (agent_id, token, created_at) VALUES (?1, ?2, ?3)",
180 params![uid, random_token(), now_secs()],
181 )?;
182 let mut stmt = conn.prepare("SELECT token FROM db_agent_tokens WHERE agent_id = ?1")?;
183 let token: String = stmt.query_row(params![uid], |row| row.get(0))?;
184 if let Some(index) = self.token_index() {
186 index.insert(uid, &token);
187 }
188 Ok(token)
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn object_store() -> Store {
197 Store::open_in_memory().unwrap()
198 }
199
200 #[test]
201 fn load_is_none_until_minted_then_ensure_is_stable() {
202 let store = object_store();
203 assert_eq!(store.agent_token_load("uid-a").unwrap(), None);
204
205 let first = store.agent_token_ensure("uid-a").unwrap();
206 assert_eq!(first.len(), 64, "32 random bytes, hex-encoded");
207 assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
208
209 assert_eq!(store.agent_token_ensure("uid-a").unwrap(), first);
211 assert_eq!(
212 store.agent_token_load("uid-a").unwrap().as_deref(),
213 Some(first.as_str())
214 );
215 }
216
217 #[test]
221 fn distinct_uids_get_distinct_tokens() {
222 let store = object_store();
223 let a = store.agent_token_ensure("4f3c-agenty").unwrap();
224 let b = store.agent_token_ensure("9b2e-agenty").unwrap();
225 assert_ne!(a, b);
226 }
227
228 #[test]
229 fn empty_uid_is_refused_not_given_a_shared_token() {
230 let store = object_store();
231 assert!(store.agent_token_ensure("").is_err());
232 assert!(store.agent_token_ensure(" ").is_err());
233 assert_eq!(store.agent_token_load("").unwrap(), None);
234 }
235
236 #[test]
242 fn the_index_follows_mint_and_is_rebuilt_on_attach() {
243 let store = object_store();
244 let before = store.agent_token_ensure("uid-a").unwrap();
245 let index = store.attach_token_index().unwrap();
246 assert_eq!(
247 index.uid_for(&before).as_deref(),
248 Some("uid-a"),
249 "loaded on attach"
250 );
251 let minted = store.agent_token_ensure("uid-b").unwrap();
252 assert_eq!(
253 index.uid_for(&minted).as_deref(),
254 Some("uid-b"),
255 "added on mint"
256 );
257 assert_eq!(index.len(), 2);
258 }
259
260 #[test]
262 fn purge_removes_the_token_from_the_index() {
263 let store = object_store();
264 let index = store.attach_token_index().unwrap();
265 let token = store.agent_token_ensure("uid-a").unwrap();
266 {
267 let conn = store.conn.lock().unwrap();
268 super::super::agents::purge_agent_dependents_for_tests(
269 &conn,
270 "uid-a",
271 store.token_index().as_deref(),
272 )
273 .unwrap();
274 }
275 assert_eq!(index.uid_for(&token), None);
276 assert_eq!(store.agent_token_load("uid-a").unwrap(), None);
277 }
278
279 #[test]
283 fn deleting_an_agent_revokes_its_token_and_tombstones_its_names() {
284 use crate::backend::storage::agents::test_agent_def;
285 for via_instance_delete in [false, true] {
286 let store = object_store();
287 let index = store.attach_token_index().unwrap();
288 let mut def = test_agent_def("uid-agenty", "AgentY", "claude", "agent", 1, "");
289 def.slug = "agenty-2".to_string();
290 store.agent_def_insert(&mut def).unwrap();
291 let token = store.agent_token_ensure("uid-agenty").unwrap();
292 assert_eq!(index.uid_for(&token).as_deref(), Some("uid-agenty"));
293
294 let deleted = if via_instance_delete {
295 store.instance_delete("uid-agenty").unwrap()
296 } else {
297 store.agent_def_delete("uid-agenty").unwrap()
298 };
299 assert!(deleted, "instance_delete={via_instance_delete}");
300 assert_eq!(
301 index.uid_for(&token),
302 None,
303 "instance_delete={via_instance_delete}"
304 );
305 let tombstones = {
306 let conn = store.conn.lock().unwrap();
307 super::super::agents::key_tombstones_for_tests(&conn)
308 };
309 let names: Vec<&str> = tombstones.iter().map(|(n, _)| n.as_str()).collect();
310 assert!(
311 names.contains(&"agenty") && names.contains(&"agenty-2"),
312 "{tombstones:?}"
313 );
314 assert!(tombstones.iter().all(|(_, uid)| uid == "uid-agenty"));
315 }
316 }
317
318 #[test]
323 fn deleting_one_of_two_same_named_agents_spares_the_others_slug() {
324 use crate::backend::storage::agents::test_agent_def;
325 let store = object_store();
326 for (id, name, slug) in [
327 ("uid-first", "AgentY", "agenty"),
328 ("uid-second", "AGENTY", "agenty-2"),
329 ] {
330 let mut def = test_agent_def(id, name, "claude", "agent", 1, "");
331 def.slug = slug.to_string();
332 store.agent_def_insert(&mut def).unwrap();
333 }
334 assert!(store.agent_def_delete("uid-second").unwrap());
335 let tombstones = {
336 let conn = store.conn.lock().unwrap();
337 super::super::agents::key_tombstones_for_tests(&conn)
338 };
339 assert_eq!(
340 tombstones,
341 vec![("agenty-2".to_string(), "uid-second".to_string())],
342 "only the deleted agent's own slug"
343 );
344 }
345
346 #[test]
352 fn deleting_an_agent_deletes_the_keys_filed_under_its_slug() {
353 use crate::backend::storage::agents::test_agent_def;
354 for via_instance_delete in [false, true] {
355 let store = object_store();
356 for (id, name, slug) in [
357 ("uid-first", "AgentY", "agenty"),
358 ("uid-second", "AGENTY", "agenty-2"),
359 ] {
360 let mut def = test_agent_def(id, name, "claude", "agent", 1, "");
361 def.slug = slug.to_string();
362 store.agent_def_insert(&mut def).unwrap();
363 }
364 for name in ["agenty", "agenty-2"] {
365 store.agent_jekt_key_ensure(name).unwrap();
366 store.agent_lan_key_ensure(name).unwrap();
367 store.agent_wan_key_ensure(name).unwrap();
368 }
369 let old_lan = store.agent_lan_key_load("agenty-2").unwrap().unwrap();
370
371 let deleted = if via_instance_delete {
372 store.instance_delete("uid-second").unwrap()
373 } else {
374 store.agent_def_delete("uid-second").unwrap()
375 };
376 assert!(deleted, "instance_delete={via_instance_delete}");
377 assert!(store.agent_jekt_key_load("agenty-2").unwrap().is_none());
378 assert!(store.agent_lan_key_load("agenty-2").unwrap().is_none());
379 assert!(store.agent_wan_key_load("agenty-2").unwrap().is_none());
380 assert!(
381 store.agent_jekt_key_load("agenty").unwrap().is_some(),
382 "the live agent's keys stay"
383 );
384 assert!(store.agent_lan_key_load("agenty").unwrap().is_some());
385 assert!(store.agent_wan_key_load("agenty").unwrap().is_some());
386
387 let reused = store.agent_lan_key_ensure("agenty-2").unwrap();
390 assert_ne!(
391 reused.public_key, old_lan.public_key,
392 "instance_delete={via_instance_delete}"
393 );
394 }
395 }
396
397 #[test]
402 fn deleting_an_agent_deletes_its_wan_key_from_the_channel_wide_store() {
403 use crate::backend::storage::agents::test_agent_def;
404 for via_instance_delete in [false, true] {
405 let store = object_store();
406 let (_wan_dir, wan) = crate::backend::storage::wan_identity::attach_temp_wan_identity(&store);
407 for (id, name, slug) in [
408 ("uid-first", "AgentY", "agenty"),
409 ("uid-second", "AGENTY", "agenty-2"),
410 ] {
411 let mut def = test_agent_def(id, name, "claude", "agent", 1, "");
412 def.slug = slug.to_string();
413 store.agent_def_insert(&mut def).unwrap();
414 }
415 for name in ["agenty", "agenty-2"] {
416 wan.agent_key_ensure(name, None).unwrap();
417 }
418 let old = wan.agent_key_load("agenty-2").unwrap().unwrap();
419
420 let deleted = if via_instance_delete {
421 store.instance_delete("uid-second").unwrap()
422 } else {
423 store.agent_def_delete("uid-second").unwrap()
424 };
425 assert!(deleted, "instance_delete={via_instance_delete}");
426 assert!(wan.agent_key_load("agenty-2").unwrap().is_none(), "instance_delete={via_instance_delete}");
427 assert!(wan.agent_key_load("agenty").unwrap().is_some(), "the live agent's WAN key stays");
428 assert_ne!(wan.agent_key_ensure("agenty-2", None).unwrap().public_key, old.public_key);
429 }
430 }
431
432 #[test]
437 fn deleting_an_agent_deletes_the_keys_under_all_its_names() {
438 use crate::backend::storage::agents::test_agent_def;
439 let store = object_store();
440 let mut zed = test_agent_def("uid-zed", "Zed Bot", "claude", "agent", 1, "");
441 zed.slug = "zb".to_string();
442 store.agent_def_insert(&mut zed).unwrap();
443 store
444 .conn()
445 .lock()
446 .unwrap()
447 .execute("UPDATE db_agents SET instance_name = 'Zeddy' WHERE id = 'uid-zed'", [])
448 .unwrap();
449 let mut other = test_agent_def("uid-other", "Other", "claude", "agent", 1, "");
450 other.slug = "zed-bot".to_string();
451 store.agent_def_insert(&mut other).unwrap();
452 for name in ["zb", "zed bot", "zeddy", "zed-bot"] {
453 store.agent_lan_key_ensure(name).unwrap();
454 store.agent_jekt_key_ensure(name).unwrap();
455 }
456
457 assert!(store.agent_def_delete("uid-zed").unwrap());
458 for gone in ["zb", "zed bot", "zeddy"] {
459 assert!(store.agent_lan_key_load(gone).unwrap().is_none(), "{gone}");
460 assert!(store.agent_jekt_key_load(gone).unwrap().is_none(), "{gone}");
461 }
462 assert!(
463 store.agent_lan_key_load("zed-bot").unwrap().is_some(),
464 "the fallback id another agent holds as its slug stays"
465 );
466 assert!(store.agent_jekt_key_load("zed-bot").unwrap().is_some());
467
468 for (id, slug) in [("uid-v2a", "agent-v2"), ("uid-v2b", "agent-v2-2")] {
472 let mut def = test_agent_def(id, "Agent (v2)", "claude", "agent", 1, "");
473 def.slug = slug.to_string();
474 store.agent_def_insert(&mut def).unwrap();
475 }
476 store.agent_lan_key_ensure("agent--v2-").unwrap();
477 assert!(store.agent_def_delete("uid-v2a").unwrap());
478 assert!(
479 store.agent_lan_key_load("agent--v2-").unwrap().is_some(),
480 "the same-named sibling still signs under it"
481 );
482
483 let mut renamed = test_agent_def("uid-renamed", "New Name", "claude", "agent", 1, "");
487 renamed.slug = "new-name".to_string();
488 store.agent_def_insert(&mut renamed).unwrap();
489 store
490 .conn()
491 .lock()
492 .unwrap()
493 .execute("UPDATE db_agents SET instance_name = 'Old (launch)' WHERE id = 'uid-renamed'", [])
494 .unwrap();
495 store.agent_lan_key_ensure("old--launch-").unwrap();
496 assert!(store.agent_def_delete("uid-renamed").unwrap());
497 assert!(store.agent_lan_key_load("old--launch-").unwrap().is_none());
498
499 let mut v2 = test_agent_def("uid-v2-renamed", "Agent (v2)", "claude", "agent", 1, "");
503 v2.slug = "agent-v2-r".to_string();
504 store.agent_def_insert(&mut v2).unwrap();
505 store
506 .conn()
507 .lock()
508 .unwrap()
509 .execute("UPDATE db_agents SET instance_name = 'Agent (v2)' WHERE id = 'uid-v2-renamed'", [])
510 .unwrap();
511 assert!(store.agent_def_delete("uid-v2b").unwrap());
514 store.agent_lan_key_ensure("agent--v2-").unwrap();
515 v2.name = "New Name".to_string();
516 store.agent_def_update(&mut v2).unwrap();
517 assert!(store.instance_rename("uid-v2-renamed", "New Name").unwrap());
518 assert!(store.agent_def_delete("uid-v2-renamed").unwrap());
519 assert!(
520 store.agent_lan_key_load("agent--v2-").unwrap().is_none(),
521 "the pre-rename fallback key is purged"
522 );
523
524 let mut many = test_agent_def("uid-many", "First Name", "claude", "agent", 1, "");
528 many.slug = "many".to_string();
529 store.agent_def_insert(&mut many).unwrap();
530 store.agent_lan_key_ensure("first-name").unwrap();
531 for i in 0..25 {
532 store
533 .conn()
534 .lock()
535 .unwrap()
536 .execute(
537 "UPDATE db_agents SET name = ?1, instance_name = ?1 WHERE id = 'uid-many'",
538 rusqlite::params![format!("Name {i}")],
539 )
540 .unwrap();
541 }
542 assert!(store.agent_def_delete("uid-many").unwrap());
543 assert!(store.agent_lan_key_load("first-name").unwrap().is_none(), "the first name, 25 renames ago");
544 }
545
546 #[test]
551 fn a_non_ascii_slug_held_by_another_agent_keeps_its_keys() {
552 let store = store_with_slugs(&[("uid-upper", "Ä", false), ("uid-lower", "ä", false)]);
553 store.agent_lan_key_ensure("ä").unwrap();
554 assert!(store.agent_def_delete("uid-lower").unwrap());
555 assert!(
556 store.agent_lan_key_load("ä").unwrap().is_some(),
557 "the live `Ä` agent's key stays"
558 );
559 }
560
561 #[test]
566 fn a_slug_another_row_or_a_template_holds_keeps_its_keys() {
567 for other_is_template in [false, true] {
568 let store = store_with_slugs(&[
569 ("uid-dup-a", "claude", other_is_template),
570 ("uid-dup-b", "claude", false),
571 ]);
572 store.agent_jekt_key_ensure("claude").unwrap();
573 store.agent_lan_key_ensure("claude").unwrap();
574 assert!(store.agent_def_delete("uid-dup-b").unwrap());
575 assert!(
576 store.agent_lan_key_load("claude").unwrap().is_some()
577 && store.agent_jekt_key_load("claude").unwrap().is_some(),
578 "template={other_is_template}"
579 );
580 }
581 }
582
583 fn store_with_slugs(rows: &[(&str, &str, bool)]) -> Store {
586 use crate::backend::storage::agents::test_agent_def;
587 let store = object_store();
588 for (id, slug, template) in rows {
589 let mut def = test_agent_def(id, slug, "claude", "agent", 1, "");
590 if *template {
591 def.is_seeded = 1;
592 }
593 store.agent_def_insert(&mut def).unwrap();
594 store.test_force_slug(id, slug).unwrap();
595 }
596 store
597 }
598}