Skip to main content

agentmux_srv/backend/
agent_registry_lookup.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Slug-based lookup into the global named-agent registry.
5//!
6//! **One implementation, deliberately.** This matching is subtle — the caller
7//! holds a routing slug (`AGENTMUX_AGENT_ID`, e.g. `"agenty"`) while a record
8//! stores the display-cased `instance_name` (e.g. `"AgentY"`), so both sides
9//! have to be normalized through [`derive_slug`] rather than compared
10//! directly. Raw string equality here was a real bug (reagentx, PR #2428).
11//!
12//! It lived in `server::native_memory_handlers` and was then re-implemented in
13//! `backend::history`; reagentx P2 on PR #3480 flagged the copy, citing this
14//! codebase's own history of exactly that drift — *"a future collision/matching
15//! fix applied to one copy will silently miss the other."*
16//!
17//! It lives in `backend/` rather than `registry/` on purpose: `registry/` has
18//! no dependency on `crate::backend` at all today, and [`derive_slug`] is a
19//! `backend::storage` function — homing this there would have added a
20//! registry→backend edge to a module that is currently self-contained.
21
22use crate::backend::storage::store::derive_slug;
23use crate::registry::NamedAgentRecord;
24
25/// Every active registry record whose `instance_name` normalizes to the same
26/// slug as `agent_id`. Usually zero or one; more than one means a collision.
27fn active_records_matching_slug(agent_id: &str) -> Option<Vec<NamedAgentRecord>> {
28    let registry_dir = crate::registry::resolve_shared_registry_dir()?;
29    let registry = crate::registry::Registry::open(registry_dir).ok()?;
30    let queried_slug = derive_slug(agent_id);
31    Some(
32        registry
33            .list_active()
34            .ok()?
35            .into_iter()
36            .filter(|r| derive_slug(&r.data.instance_name) == queried_slug)
37            .collect(),
38    )
39}
40
41/// The active registry record whose `instance_name` normalizes to the same
42/// slug as `agent_id` — **only if exactly one does**.
43///
44/// `None` when the registry is unavailable, unreadable, nothing matches, or
45/// **more than one record matches**. Callers treat the first three the same
46/// way (fall back to another source), so they are not distinguished.
47///
48/// **Why ambiguity returns `None` rather than the first match** (reagentx P1,
49/// PR #3480): registry records are keyed by `instance_id`, and nothing
50/// enforces a unique `instance_name` across them — `derive_slug("AgentY")` and
51/// `derive_slug("AGENTY")` are both `agenty`, which two agents on two hosts
52/// can easily produce. This function used to return whichever matched first in
53/// `list_active()` order, handing that record's `definition_id` and
54/// `working_dir` to the caller. Callers use those to resolve an agent's
55/// *conversation history* and *memory directory*, so guessing wrong does not
56/// degrade gracefully — it discloses one agent's data to another. A slug
57/// simply does not identify an agent when it collides, so the honest answer
58/// is "unknown". Same collision class as Codex P1 on #2901 and reagentx on
59/// #2428.
60///
61/// If you hold a `definition_id`, prefer
62/// [`find_active_record_by_slug_and_definition`] — it resolves the collision
63/// instead of refusing it.
64pub(crate) fn find_active_record_by_slug(agent_id: &str) -> Option<NamedAgentRecord> {
65    let mut matches = active_records_matching_slug(agent_id)?;
66    match matches.len() {
67        1 => matches.pop(),
68        0 => None,
69        n => {
70            tracing::warn!(
71                slug = %derive_slug(agent_id),
72                matches = n,
73                "registry slug collision: refusing to resolve an agent by slug alone"
74            );
75            None
76        }
77    }
78}
79
80/// The active registry record matching both `agent_id`'s slug and an exact
81/// `definition_id`.
82///
83/// For callers that already know which agent they mean. Unlike
84/// [`find_active_record_by_slug`] this stays correct under a slug collision,
85/// because `definition_id` is the disambiguator the slug lacks — so it returns
86/// a record in cases where the slug-only lookup must give up.
87pub(crate) fn find_active_record_by_slug_and_definition(
88    agent_id: &str,
89    definition_id: &str,
90) -> Option<NamedAgentRecord> {
91    active_records_matching_slug(agent_id)?
92        .into_iter()
93        .find(|r| r.data.definition_id == definition_id)
94}
95
96/// Reconstruct an agent's absolute working directory from its registry record.
97///
98/// `source_agents_base` joined with the relative `working_dir`, with legacy
99/// (v1/v2) records lacking a base falling back to the current channel's agents
100/// dir, matching the registry's own pre-P0.4 reconstruction rule.
101pub(crate) fn working_dir_from_record(rec: &NamedAgentRecord) -> Option<String> {
102    let base = rec
103        .data
104        .source_agents_base
105        .clone()
106        .or_else(|| std::env::var("AGENTMUX_AGENTS_DIR").ok())?;
107    Some(
108        std::path::Path::new(&base)
109            .join(&rec.data.working_dir)
110            .to_string_lossy()
111            .to_string(),
112    )
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::registry::{NamedAgentRecordV1, Registry, MAX_SUPPORTED_SCHEMA};
119
120    // These tests drive the real registry through `resolve_shared_registry_dir`,
121    // which reads the process-global `AGENTMUX_HOME_OVERRIDE`. Left unset it
122    // would resolve the developer's own `~/.agentmux` and merge in every real
123    // agent on the machine. Uses the CRATE-WIDE lock every other consumer of
124    // this env var already takes — a module-local mutex does not stop the
125    // races these tests would otherwise have with the migration test modules.
126    use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_GUARD;
127
128    fn with_registry<T>(records: &[(&str, &str)], f: impl FnOnce() -> T) -> T {
129        let _guard = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
130        let home = tempfile::tempdir().unwrap();
131        std::env::set_var("AGENTMUX_HOME_OVERRIDE", home.path().to_str().unwrap());
132
133        let dir = crate::registry::resolve_shared_registry_dir().unwrap();
134        std::fs::create_dir_all(&dir).unwrap();
135        let registry = Registry::open(dir).unwrap();
136        for (instance_id, instance_name) in records {
137            registry.upsert(&record(instance_id, instance_name)).unwrap();
138        }
139
140        let out = f();
141        std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
142        out
143    }
144
145    fn record(instance_id: &str, instance_name: &str) -> NamedAgentRecord {
146        NamedAgentRecord {
147            schema_version: MAX_SUPPORTED_SCHEMA,
148            data: NamedAgentRecordV1 {
149                instance_id: instance_id.to_string(),
150                instance_name: instance_name.to_string(),
151                definition_id: format!("def-{instance_id}"),
152                identity_id: None,
153                memory_id: None,
154                session_id: None,
155                working_dir: format!("{instance_id}-dir"),
156                source_agents_base: Some("/agents".to_string()),
157                created_at_ms: 1,
158                last_launched_at_ms: 1,
159                created_by_version: "test".to_string(),
160                last_launched_by_version: "test".to_string(),
161            },
162        }
163    }
164
165    #[test]
166    fn resolves_the_single_record_whose_name_normalizes_to_the_slug() {
167        with_registry(&[("i1", "AgentY")], || {
168            let found = find_active_record_by_slug("agenty").expect("one match resolves");
169            assert_eq!(found.data.instance_id, "i1");
170        });
171    }
172
173    // The module's whole reason for existing: the caller holds a routing slug
174    // while the record stores a display-cased `instance_name`, so both sides
175    // must go through `derive_slug`. Raw string equality here was a real bug
176    // (reagentx, PR #2428).
177    #[test]
178    fn matches_through_derive_slug_on_both_sides_not_raw_equality() {
179        with_registry(&[("i1", "AgentY")], || {
180            assert!(find_active_record_by_slug("AgentY").is_some(), "display casing");
181            assert!(find_active_record_by_slug("AGENTY").is_some(), "upper casing");
182            assert!(find_active_record_by_slug("agenty").is_some(), "already a slug");
183        });
184    }
185
186    #[test]
187    fn returns_none_when_nothing_matches() {
188        with_registry(&[("i1", "AgentY")], || {
189            assert!(find_active_record_by_slug("someone-else").is_none());
190        });
191    }
192
193    // The guard PR #3480 added and #3497 §2.5.3 flagged as never verified.
194    // `derive_slug` lowercases, so "AgentY" and "AGENTY" are two agents with
195    // one slug — nothing enforces a unique `instance_name` across records.
196    // Returning either one hands its `definition_id`/`working_dir` to a caller
197    // resolving conversation history or a memory directory, so the wrong pick
198    // discloses one agent's data to another.
199    #[test]
200    fn refuses_to_resolve_a_slug_two_active_records_share() {
201        with_registry(&[("i1", "AgentY"), ("i2", "AGENTY")], || {
202            assert!(
203                find_active_record_by_slug("agenty").is_none(),
204                "an ambiguous slug must resolve to nothing, never to an arbitrary winner"
205            );
206        });
207    }
208
209    // The disambiguated sibling stays correct exactly where the slug-only
210    // lookup must give up — otherwise refusing the collision would leave
211    // callers that DO know which agent they mean with no way through.
212    #[test]
213    fn definition_scoped_lookup_resolves_a_collision_the_slug_alone_cannot() {
214        with_registry(&[("i1", "AgentY"), ("i2", "AGENTY")], || {
215            let found = find_active_record_by_slug_and_definition("agenty", "def-i2")
216                .expect("definition_id disambiguates");
217            assert_eq!(found.data.instance_id, "i2");
218            assert!(
219                find_active_record_by_slug_and_definition("agenty", "def-nope").is_none(),
220                "an unmatched definition must not fall back to a slug-only guess"
221            );
222        });
223    }
224}