Skip to main content

clawft_kernel/
agent_registry.rs

1//! Agent identity registry.
2//!
3//! Maps opaque `agent_id` strings (UUID v4) to their Ed25519 public
4//! keys. Registration requires a proof-of-possession signature so a
5//! hostile client cannot register someone else's key.
6//!
7//! The registry is in-memory: restart-resets by design. Each kernel
8//! boot chain-appends `agent.registered` events, so the audit trail
9//! survives even though the registry itself does not.
10
11use std::sync::Arc;
12
13use chrono::{DateTime, Utc};
14use dashmap::DashMap;
15
16/// A registered agent: name + public key + when it registered.
17#[derive(Debug, Clone)]
18pub struct RegisteredAgent {
19    /// Public identifier assigned by the kernel (UUID v4).
20    pub agent_id: String,
21    /// Human-readable name supplied at registration.
22    pub name: String,
23    /// Ed25519 public key (32 bytes).
24    pub pubkey: [u8; 32],
25    /// When the agent registered.
26    pub registered_at: DateTime<Utc>,
27}
28
29/// Agent identity registry.
30///
31/// Cheap to clone — the inner map is wrapped in `Arc`/`DashMap`.
32///
33/// Indexed by both `agent_id` (UUID) and registration `name` so
34/// multi-tenant chat can lazily `get_or_register` a per-caller
35/// principal (WEFT-332) without minting a new UUID every turn.
36#[derive(Debug, Default, Clone)]
37pub struct AgentRegistry {
38    inner: Arc<DashMap<String, RegisteredAgent>>,
39    /// Secondary index: registration name → agent_id.
40    /// First registration wins for a given name; subsequent
41    /// `get_or_register` calls reuse that principal.
42    by_name: Arc<DashMap<String, String>>,
43}
44
45impl AgentRegistry {
46    /// Create a new empty registry.
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Register a new agent with the given name and public key.
52    ///
53    /// Returns the freshly-minted `agent_id`. This function does not
54    /// verify the proof-of-possession — the caller (daemon RPC
55    /// handler) does that against `register_payload` before calling
56    /// this method.
57    ///
58    /// Always mints a new UUID even if `name` was previously
59    /// registered (legacy `agent.register` RPC semantics). For
60    /// idempotent multi-tenant principals prefer
61    /// [`Self::get_or_register`].
62    pub fn register(&self, name: String, pubkey: [u8; 32]) -> RegisteredAgent {
63        let agent_id = uuid::Uuid::new_v4().to_string();
64        let entry = RegisteredAgent {
65            agent_id: agent_id.clone(),
66            name: name.clone(),
67            pubkey,
68            registered_at: Utc::now(),
69        };
70        self.inner.insert(agent_id.clone(), entry.clone());
71        // First name wins for the secondary index so a later
72        // `get_or_register` still resolves a stable principal.
73        self.by_name
74            .entry(name)
75            .or_insert_with(|| agent_id);
76        entry
77    }
78
79    /// Look up an agent by id.
80    pub fn get(&self, agent_id: &str) -> Option<RegisteredAgent> {
81        self.inner.get(agent_id).map(|e| e.clone())
82    }
83
84    /// Look up the first agent registered under `name`.
85    pub fn get_by_name(&self, name: &str) -> Option<RegisteredAgent> {
86        let id = self.by_name.get(name)?;
87        self.get(id.value())
88    }
89
90    /// Return the existing principal for `name`, or register a new
91    /// one (WEFT-332 multi-tenant chat).
92    ///
93    /// Idempotent for a given name: concurrent callers that lose the
94    /// insert race re-read the winner so only one UUID is live.
95    pub fn get_or_register(&self, name: String, pubkey: [u8; 32]) -> RegisteredAgent {
96        if let Some(existing) = self.get_by_name(&name) {
97            return existing;
98        }
99        let agent_id = uuid::Uuid::new_v4().to_string();
100        let entry = RegisteredAgent {
101            agent_id: agent_id.clone(),
102            name: name.clone(),
103            pubkey,
104            registered_at: Utc::now(),
105        };
106        // Insert name → id first under entry API so concurrent
107        // get_or_register for the same name share one id.
108        let winner_id = match self.by_name.entry(name.clone()) {
109            dashmap::mapref::entry::Entry::Occupied(o) => o.get().clone(),
110            dashmap::mapref::entry::Entry::Vacant(v) => {
111                v.insert(agent_id.clone());
112                agent_id.clone()
113            }
114        };
115        if winner_id == agent_id {
116            self.inner.insert(agent_id, entry.clone());
117            entry
118        } else {
119            // Lost the race — return the winner (must already be in
120            // inner, or will be momentarily; fall back to rebuilding).
121            self.get(&winner_id).unwrap_or(entry)
122        }
123    }
124
125    /// Number of registered agents.
126    pub fn len(&self) -> usize {
127        self.inner.len()
128    }
129
130    /// Whether the registry is empty.
131    pub fn is_empty(&self) -> bool {
132        self.inner.is_empty()
133    }
134
135    /// List all registered agents.
136    pub fn list(&self) -> Vec<RegisteredAgent> {
137        self.inner.iter().map(|e| e.value().clone()).collect()
138    }
139}
140
141/// Compose the canonical byte payload an agent must sign as the
142/// proof-of-possession during `agent.register`.
143///
144/// Layout: `b"register\0" || name || b"\0" || pubkey || b"\0" || ts_le`.
145///
146/// The nonce (`ts`, unix-millis) binds the proof to a specific
147/// instant so a replayed signature from an earlier session cannot be
148/// re-used to register the same name by a different actor.
149pub fn register_payload(name: &str, pubkey: &[u8; 32], ts: u64) -> Vec<u8> {
150    let mut buf = Vec::with_capacity(9 + name.len() + 1 + 32 + 1 + 8);
151    buf.extend_from_slice(b"register\0");
152    buf.extend_from_slice(name.as_bytes());
153    buf.push(0);
154    buf.extend_from_slice(pubkey);
155    buf.push(0);
156    buf.extend_from_slice(&ts.to_le_bytes());
157    buf
158}
159
160/// Compose the canonical byte payload signed for an `ipc.publish`.
161///
162/// Layout:
163/// `b"ipc.publish\0" || topic || b"\0" || message || b"\0" || ts_le || b"\0" || actor_id`.
164pub fn publish_payload(topic: &str, message: &str, ts: u64, actor_id: &str) -> Vec<u8> {
165    let mut buf =
166        Vec::with_capacity(12 + topic.len() + 1 + message.len() + 1 + 8 + 1 + actor_id.len());
167    buf.extend_from_slice(b"ipc.publish\0");
168    buf.extend_from_slice(topic.as_bytes());
169    buf.push(0);
170    buf.extend_from_slice(message.as_bytes());
171    buf.push(0);
172    buf.extend_from_slice(&ts.to_le_bytes());
173    buf.push(0);
174    buf.extend_from_slice(actor_id.as_bytes());
175    buf
176}
177
178/// Compose the canonical byte payload signed for an
179/// `ipc.subscribe_stream` request.
180///
181/// Layout: `b"ipc.subscribe\0" || topic || b"\0" || ts_le || b"\0" || actor_id`.
182pub fn subscribe_payload(topic: &str, ts: u64, actor_id: &str) -> Vec<u8> {
183    let mut buf = Vec::with_capacity(14 + topic.len() + 1 + 8 + 1 + actor_id.len());
184    buf.extend_from_slice(b"ipc.subscribe\0");
185    buf.extend_from_slice(topic.as_bytes());
186    buf.push(0);
187    buf.extend_from_slice(&ts.to_le_bytes());
188    buf.push(0);
189    buf.extend_from_slice(actor_id.as_bytes());
190    buf
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn register_and_lookup() {
199        let reg = AgentRegistry::new();
200        let entry = reg.register("python-bridge".into(), [7u8; 32]);
201        assert_eq!(reg.len(), 1);
202        let fetched = reg.get(&entry.agent_id).unwrap();
203        assert_eq!(fetched.name, "python-bridge");
204        assert_eq!(fetched.pubkey, [7u8; 32]);
205    }
206
207    #[test]
208    fn register_assigns_unique_ids() {
209        let reg = AgentRegistry::new();
210        let a = reg.register("a".into(), [1u8; 32]);
211        let b = reg.register("b".into(), [2u8; 32]);
212        assert_ne!(a.agent_id, b.agent_id);
213    }
214
215    #[test]
216    fn get_or_register_is_idempotent_by_name() {
217        let reg = AgentRegistry::new();
218        let a = reg.get_or_register("agent.chat:user:alice".into(), [1u8; 32]);
219        let b = reg.get_or_register("agent.chat:user:alice".into(), [1u8; 32]);
220        assert_eq!(a.agent_id, b.agent_id);
221        assert_eq!(reg.len(), 1);
222    }
223
224    #[test]
225    fn get_or_register_isolates_callers() {
226        let reg = AgentRegistry::new();
227        let a = reg.get_or_register("agent.chat:user:alice".into(), [1u8; 32]);
228        let b = reg.get_or_register("agent.chat:user:bob".into(), [1u8; 32]);
229        assert_ne!(a.agent_id, b.agent_id);
230        assert_eq!(reg.len(), 2);
231        assert_eq!(
232            reg.get_by_name("agent.chat:user:alice")
233                .unwrap()
234                .agent_id,
235            a.agent_id
236        );
237        assert_eq!(
238            reg.get_by_name("agent.chat:user:bob").unwrap().agent_id,
239            b.agent_id
240        );
241    }
242
243    #[test]
244    fn payload_layouts_are_stable() {
245        let pk = [0xAAu8; 32];
246        let p = register_payload("foo", &pk, 42);
247        // Prefix + name + sep
248        assert_eq!(&p[..9], b"register\0");
249        assert_eq!(&p[9..12], b"foo");
250        assert_eq!(p[12], 0);
251        assert_eq!(&p[13..45], &pk);
252    }
253
254    #[test]
255    fn publish_payload_round() {
256        let p1 = publish_payload("t1", "hi", 100, "aid");
257        let p2 = publish_payload("t1", "hi", 100, "aid");
258        assert_eq!(p1, p2);
259        let p3 = publish_payload("t1", "hi", 101, "aid");
260        assert_ne!(p1, p3, "ts must affect the payload");
261    }
262}