agentmux_srv/backend/storage/agents/name_keys.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Slugs and the name-keyed rows (`KEY_NAME`s) an agent's names map to:
5//! collision-free slugs, tombstoning a renamed agent's keys, and finding
6//! agents by name.
7
8use super::*;
9
10/// One row `agents_matching_name` found (identity M3).
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct AgentNameMatch {
13 pub id: String,
14 pub name: String,
15 pub slug: String,
16 pub instance_name: String,
17}
18
19/// The first of `base`, `base-2`, `base-3`, … that no `db_agents` row holds.
20///
21/// Shared by `agent_def_insert_local_only` and `instance_create` so the two
22/// write paths cannot drift: `db_agents.slug` carries no `UNIQUE` constraint
23/// (deliberately — see `migrations.rs`), so uniqueness is only ever as good as
24/// the agreement between every path that inserts one. It held in only one of
25/// them until #3497 §4.
26///
27/// Callers must already hold the connection lock — the scan and the INSERT
28/// have to be one critical section, or two concurrent inserts race to the
29/// same candidate.
30///
31/// There is deliberately no "exclude this row id" option. It looks necessary
32/// for `instance_create`'s `ON CONFLICT(id) DO UPDATE` path — a row
33/// re-resolving its own slug would otherwise collide with itself — but that
34/// clause does not assign `slug` at all, so on the conflict path the computed
35/// value is discarded and an exclusion could never be observed (ReAgent P2 on
36/// #3514). On the insert path the row does not exist yet, so there is nothing
37/// to exclude either. If `slug` is ever added to that `DO UPDATE SET`, this
38/// becomes real and needs revisiting.
39pub(super) fn resolve_slug_collision(conn: &rusqlite::Connection, base: &str) -> rusqlite::Result<String> {
40 let mut candidate = base.to_string();
41 let mut n: u32 = 2;
42 loop {
43 let count: i64 = conn.query_row(
44 "SELECT COUNT(*) FROM db_agents WHERE slug = ?1",
45 params![candidate],
46 |row| row.get(0),
47 )?;
48 if count == 0 {
49 return Ok(candidate);
50 }
51 // Trimmed so the suffixed slug stays a valid agent id (at most 64
52 // chars); an over-long one fails every registration.
53 let suffix = format!("-{n}");
54 let room = agentmux_common::slug::AGENT_ID_MAX_LEN.saturating_sub(suffix.len());
55 let stem: String = base.chars().take(room).collect();
56 candidate = format!("{}{suffix}", stem.trim_end_matches('-'));
57 n += 1;
58 }
59}
60
61/// Every table keyed to an agent id that a deleted `db_agents` row leaves
62/// behind, purged explicitly (docs/specs/SPEC_AGENT_DELETE_2026_09_16.md
63/// §5.1). Called with the caller's connection lock already held, inside the
64/// same critical section as the `db_agents` DELETE. Returns rows removed.
65///
66/// Six of these declare `ON DELETE CASCADE` on `db_agents(id)` and do fire
67/// on their own — `configure_and_migrate` sets `PRAGMA foreign_keys=ON` on
68/// the production connection, contrary to §5.1's premise (and to
69/// `managed.rs`'s "only ever set in tests" comment, which is stale for this
70/// store). They are listed anyway: the rest carry no FK at all —
71/// `db_agent_credentials` and the two signing-key tables hold live secret
72/// material, so leaving them behind is a security-hygiene gap, not just
73/// clutter — and one obviously-complete list beats two half-lists where a
74/// reader has to know which half a new table belongs in to tell whether it
75/// was handled.
76///
77/// **Runs against either physical database.** Four of these
78/// (`db_agent_identity_links`, `db_agent_credentials`,
79/// `db_agent_native_memory`, `db_agent_native_memory_versions`) are created
80/// by BOTH `run_object_schema` and `run_identity_store_schema`, and per
81/// `SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md` the identity store holds the
82/// live rows — `listrecentsessions` reads links from
83/// `identity_store.agent_identity_list_all()`, not from the object store's
84/// same-named table. Purging only the object store would leave an agent's
85/// real credentials and account links on disk after a delete that promises
86/// to remove them. Tables absent from whichever connection this runs on are
87/// skipped, so one list serves both.
88///
89/// `db_work_queue.target_agent` is deliberately absent: releasing work
90/// claimed against a deleted agent is a state change, not a row to drop —
91/// open question §8.3 of the spec above.
92/// Record the lowercased names `id` is known by — the keys its legacy
93/// name-keyed signing keys may sit under — in `db_agent_key_tombstones`,
94/// before its row is deleted (identity M4a, spec §6.5.4). A template row is
95/// skipped: templates are never deleted through here as agents, and their
96/// names are shared by design. Idempotent; a missing row records nothing.
97pub(super) fn tombstone_key_names(conn: &rusqlite::Connection, id: &str) -> Result<(), StoreError> {
98 let names: Option<(String, String, String)> = conn
99 .query_row(
100 "SELECT slug, name, COALESCE(instance_name, '') FROM db_agents WHERE id = ?1 AND is_template = 0",
101 params![id],
102 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
103 )
104 .map(Some)
105 .or_else(|e| match e {
106 rusqlite::Error::QueryReturnedNoRows => Ok(None),
107 e => Err(e),
108 })?;
109 let Some((slug, name, instance_name)) = names else {
110 return Ok(());
111 };
112 let now = agentmux_common::time::now_secs();
113 // The same names, and the same "another agent may sign under it" rule,
114 // as the key purge (M4d-1): a tombstone records a name whose keys were
115 // the dead agent's, so it must never be planted against a live agent's
116 // (ReAgent on #3633). Folded as the key tables fold (`to_lowercase`);
117 // compared in Rust, never with SQLite's ASCII-only `lower()`. Deleting
118 // the second "AgentY" must not tombstone the first one's `agenty`
119 // (adversarial review of #3571).
120 let mut candidates = key_names_of(&slug, &name, &instance_name);
121 for former in former_names(conn, id)? {
122 candidates.extend(key_names_of("", &former, ""));
123 }
124 for n in candidates {
125 let n = n.trim().to_string();
126 if n.is_empty() || key_name_used_by_another(conn, id, &n)? {
127 continue;
128 }
129 conn.execute(
130 "INSERT OR REPLACE INTO db_agent_key_tombstones (name, deleted_uid, deleted_at) VALUES (?1, ?2, ?3)",
131 params![n, id, now],
132 )?;
133 }
134 Ok(())
135}
136
137/// Delete the signing keys filed under every name `id` may have signed as —
138/// its jekt HMAC key and its LAN and WAN keypairs, which are keyed by name,
139/// not UID. M4a-3 (spec §6.5.4) deleted them under the slug; M4d-1
140/// (§6.5.10) also under the display name, `instance_name` and the fallback
141/// ids ([`key_names_of`]). Without this a later agent that takes a name is
142/// handed the dead agent's keys by `ensure`'s `INSERT OR IGNORE`, and signs
143/// as it. Read before the row is deleted; a name another row may sign under
144/// ([`key_name_used_by_another`]) is that agent's key and is left alone.
145/// Folded as the key tables fold (`to_lowercase`). A template row is
146/// skipped, as in [`tombstone_key_names`]. Idempotent.
147///
148/// W3-S (`SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md` §2.2): agent WAN keys now
149/// live in the channel-wide `wan.db`, so the same names are deleted there
150/// too — otherwise a new agent of the name would inherit the key across an
151/// upgrade. A `wan.db` failure fails the delete rather than being skipped:
152/// every statement here is idempotent, so a retry converges, whereas a skipped
153/// purge would hand the key to the next agent of that name. Lock order is
154/// `objects.db` (held by the caller) → `wan.db`, as `wan_identity.rs`
155/// documents.
156pub(super) fn purge_name_keyed_keys(
157 conn: &rusqlite::Connection,
158 id: &str,
159 wan: Option<&super::super::wan_identity::WanIdentityStore>,
160) -> Result<usize, StoreError> {
161 let names: Option<(String, String, String)> = conn
162 .query_row(
163 "SELECT slug, name, COALESCE(instance_name, '') FROM db_agents WHERE id = ?1 AND is_template = 0",
164 params![id],
165 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
166 )
167 .map(Some)
168 .or_else(|e| match e {
169 rusqlite::Error::QueryReturnedNoRows => Ok(None),
170 e => Err(e),
171 })?;
172 let Some((slug, name, instance_name)) = names else {
173 return Ok(0);
174 };
175 let mut removed = 0;
176 let mut candidates = key_names_of(&slug, &name, &instance_name);
177 for former in former_names(conn, id)? {
178 for n in key_names_of("", &former, "") {
179 if !candidates.contains(&n) {
180 candidates.push(n);
181 }
182 }
183 }
184 let mut purged_names = Vec::new();
185 for key_name in candidates {
186 if key_name_used_by_another(conn, id, &key_name)? {
187 continue;
188 }
189 removed += delete_key_rows(conn, &key_name)?;
190 purged_names.push(key_name);
191 }
192 if let Some(wan) = wan {
193 removed += wan.agent_keys_delete(&purged_names)?;
194 }
195 if removed > 0 {
196 tracing::info!(
197 agent = id,
198 removed,
199 "identity M4d-1: deleted the dead agent's name-keyed signing keys"
200 );
201 }
202 Ok(removed)
203}
204
205/// Every name a key row could be filed under for this agent, folded as the
206/// key tables fold (`to_lowercase`) — identity M4d-1 (spec §6.5.10). The
207/// slug exactly as sent (M4a-3); the display name and `instance_name`,
208/// trimmed, as the tombstone records them; and `agent.open`'s fallback id,
209/// the display name lowercased with non-alphanumerics made `-` (used when a
210/// definition has no slug: `Zed Bot` is keyed `zed-bot`).
211pub(super) fn key_names_of(slug: &str, name: &str, instance_name: &str) -> Vec<String> {
212 let mut names: Vec<String> = Vec::new();
213 let mut push = |n: String| {
214 if !n.trim().is_empty() && !names.contains(&n) {
215 names.push(n);
216 }
217 };
218 push(slug.to_lowercase());
219 push(name.trim().to_lowercase());
220 push(instance_name.trim().to_lowercase());
221 // Both fallbacks of the display name and of `instance_name`: an agent
222 // renamed after its first launch keeps its launch name there, and that
223 // name's fallback may still key its signing material (Codex P1 on #3633).
224 for n in [name, instance_name] {
225 if !n.trim().is_empty() {
226 push(agent_open_fallback_id(n));
227 push(frontend_fallback_id(n));
228 }
229 }
230 names
231}
232
233/// `agent.open`'s id for a definition with no slug (`agent_open.rs`).
234pub(super) fn agent_open_fallback_id(name: &str) -> String {
235 agentmux_common::slug::path_slug(name)
236}
237
238/// The frontend's id for a launch with no slug — a template-created agent's
239/// first session. ASCII only, one dash per UTF-16 code unit
240/// (`agentmux_common::slug::js_ascii_slug`, Codex P1 on #3633).
241pub(super) fn frontend_fallback_id(name: &str) -> String {
242 agentmux_common::slug::js_ascii_slug(name)
243}
244
245/// Every display / instance name `id` has had before its current ones
246/// (`db_agent_former_names`, recorded by trigger on every rename path).
247pub(super) fn former_names(conn: &rusqlite::Connection, id: &str) -> Result<Vec<String>, StoreError> {
248 let mut stmt = conn.prepare("SELECT name FROM db_agent_former_names WHERE agent_id = ?1")?;
249 let rows = stmt.query_map(params![id], |r| r.get::<_, String>(0))?;
250 Ok(rows.collect::<Result<Vec<_>, _>>()?)
251}
252
253/// Whether another row may sign under `folded`: holds it as its slug, or its
254/// display name or `instance_name` gives that fallback id (`agent.open`'s or
255/// the frontend's).
256/// Any row, whatever its slug: a template-created agent's first session
257/// signs under the fallback id although its row has a derived slug (review
258/// of #3633), so two same-named agents share that key. Such a key is left.
259/// **Templates count**: `agent.open` of a template and template-based
260/// continuations sign under the template's slug. Folded as the key tables
261/// fold (`to_lowercase`), not SQLite's ASCII-only `lower()`: slugs `Ä` and
262/// `ä` share the key filed under `ä` (Codex P2 on #3575).
263pub(super) fn key_name_used_by_another(
264 conn: &rusqlite::Connection,
265 id: &str,
266 folded: &str,
267) -> Result<bool, StoreError> {
268 let mut stmt =
269 conn.prepare("SELECT slug, name, COALESCE(instance_name, '') FROM db_agents WHERE id != ?1")?;
270 let rows = stmt.query_map(params![id], |r| {
271 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?))
272 })?;
273 for row in rows {
274 let (slug, name, instance_name) = row?;
275 let fallback_of = |n: &str| {
276 !n.trim().is_empty() && (agent_open_fallback_id(n) == folded || frontend_fallback_id(n) == folded)
277 };
278 if slug.to_lowercase() == folded || fallback_of(&name) || fallback_of(&instance_name) {
279 return Ok(true);
280 }
281 }
282 // A live agent's former names too: its running session may still sign
283 // under an earlier name's fallback until it relaunches.
284 let mut stmt = conn.prepare("SELECT name FROM db_agent_former_names WHERE agent_id != ?1")?;
285 let formers = stmt.query_map(params![id], |r| r.get::<_, String>(0))?;
286 for n in formers {
287 let n = n?;
288 if n.trim().to_lowercase() == folded
289 || agent_open_fallback_id(&n) == folded
290 || frontend_fallback_id(&n) == folded
291 {
292 return Ok(true);
293 }
294 }
295 Ok(false)
296}
297
298/// Delete one name's rows from the three name-keyed key tables.
299pub(super) fn delete_key_rows(conn: &rusqlite::Connection, name: &str) -> Result<usize, StoreError> {
300 let mut removed = 0;
301 for table in [
302 "db_agent_jekt_keys",
303 "db_agent_lan_keys",
304 "db_agent_wan_keys",
305 ] {
306 let present: bool = conn.query_row(
307 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
308 params![table],
309 |r| r.get(0),
310 )?;
311 if present {
312 // Table names are the constants above, never caller input.
313 removed += conn.execute(
314 &format!("DELETE FROM {table} WHERE agent_id = ?1"),
315 params![name],
316 )?;
317 }
318 }
319 Ok(removed)
320}
321
322/// Tombstoned names, for tests (M4d is the production reader).
323#[cfg(test)]
324pub(crate) fn key_tombstones_for_tests(conn: &rusqlite::Connection) -> Vec<(String, String)> {
325 let mut stmt = conn
326 .prepare("SELECT name, deleted_uid FROM db_agent_key_tombstones ORDER BY name")
327 .unwrap();
328 stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
329 .unwrap()
330 .map(Result::unwrap)
331 .collect()
332}
333
334impl Store {
335 /// The names deleted agents were known by, lowercased as they were
336 /// tombstoned before each delete. A fork's automatic number counts them,
337 /// so a deleted fork's number is never handed out again, even the
338 /// highest one (`suggest_fork_name`).
339 pub fn retired_agent_names(&self) -> Result<Vec<String>, StoreError> {
340 let conn = self.conn.lock().unwrap();
341 let mut stmt = conn.prepare("SELECT name FROM db_agent_key_tombstones")?;
342 let names = stmt.query_map([], |r| r.get::<_, String>(0))?.collect::<Result<Vec<_>, _>>()?;
343 Ok(names)
344 }
345
346 /// The names agent `id` had before its renames. A lineage root's former
347 /// names keep its deleted forks' numbers counted after it is renamed
348 /// (`suggest_fork_name`).
349 pub fn agent_former_names(&self, id: &str) -> Result<Vec<String>, StoreError> {
350 let conn = self.conn.lock().unwrap();
351 former_names(&conn, id)
352 }
353
354 /// The slugs of every row whose slug equals `name` trimmed and folded
355 /// (identity M4d-3's drift count; slug uniqueness is case-sensitive, so
356 /// "Aria" and "aria" are two rows).
357 pub fn agent_slugs_folded(&self, name: &str) -> Result<Vec<String>, StoreError> {
358 // Folded in Rust as the key tables fold, never SQLite's ASCII-only lower().
359 let folded = name.trim().to_lowercase();
360 let conn = self.conn.lock().unwrap();
361 let mut stmt = conn.prepare("SELECT slug FROM db_agents WHERE slug IS NOT NULL AND slug <> ''")?;
362 let slugs = stmt.query_map([], |r| r.get::<_, String>(0))?.collect::<Result<Vec<_>, _>>()?;
363 Ok(slugs.into_iter().filter(|s| s.trim().to_lowercase() == folded).collect())
364 }
365}
366
367impl Store {
368 /// The ids of every row whose slug is `name`, trimmed and folded with
369 /// Rust's `to_lowercase` (identity M4d-6: which agent this instance knows
370 /// by a name a jekt claims).
371 pub fn agent_ids_for_slug_folded(&self, name: &str) -> Result<Vec<String>, StoreError> {
372 let folded = name.trim().to_lowercase();
373 let conn = self.conn.lock().unwrap();
374 let mut stmt = conn.prepare("SELECT id, slug FROM db_agents WHERE slug IS NOT NULL AND slug <> ''")?;
375 let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?.collect::<Result<Vec<_>, _>>()?;
376 Ok(rows.into_iter().filter(|(_, slug)| slug.trim().to_lowercase() == folded).map(|(id, _)| id).collect())
377 }
378}
379
380impl Store {
381 /// The definitions a message's target names: the one whose slug it is,
382 /// else every one whose display name routes to it (`agent_route_name`: a
383 /// fork "Loap #3" is `Loap-3`, and a root `Claude` may hold the slug
384 /// `claude-2`) (#4744). Folded like the key tables.
385 pub fn agent_defs_for_target(&self, target: &str) -> Result<Vec<AgentDefinition>, StoreError> {
386 let fold = |s: &str| s.trim().to_lowercase();
387 let target = fold(target);
388 let defs = self.agent_def_list()?;
389 if let Some(def) = defs.iter().find(|d| fold(&d.slug) == target) {
390 return Ok(vec![def.clone()]);
391 }
392 Ok(defs.into_iter().filter(|d| fold(&agentmux_common::slug::agent_route_name(&d.name)) == target).collect())
393 }
394}