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}