Skip to main content

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}