agentmux_srv/backend/reactive/registry.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! File-based cross-instance agent registry.
5//!
6//! Each AgentMux instance writes agent registrations to
7//! `{data_dir}/agents/{agent_id}.json`. When a local inject fails with
8//! "agent not found", the inject handler looks up this registry and
9//! HTTP-forwards the request to the owning instance.
10//!
11//! Lifecycle:
12//! - Register: write file (on HTTP register endpoint + shell auto-register)
13//! - Unregister: delete file (on HTTP unregister endpoint + process exit)
14//! - Cleanup: TTL-based removal of stale files at startup
15
16use serde::{Deserialize, Serialize};
17use std::path::{Path, PathBuf};
18use std::sync::OnceLock;
19
20use super::now_unix_millis;
21
22/// One entry per registered agent in the shared data dir.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct AgentEntry {
25 pub agent_id: String,
26 /// Local HTTP URL of the owning AgentMux instance (e.g. http://127.0.0.1:PORT).
27 pub local_url: String,
28 pub block_id: String,
29 /// OS PID of the owning agentmux-srv process.
30 pub pid: u32,
31 /// Unix milliseconds of last update.
32 pub updated_at: u64,
33 /// Per-launch auth key of the owning instance. Required by peers
34 /// performing cross-instance HTTP forward of `/agentmux/reactive/inject`
35 /// after the route moved under auth_middleware. Optional in the
36 /// struct (serde default) so older on-disk entries still deserialize;
37 /// a missing or empty value means a forward to this entry will be
38 /// rejected by the peer's auth layer (graceful — falls back to cloud
39 /// muxbus).
40 #[serde(default)]
41 pub auth_key: String,
42 /// The writing instance's channel id (`AGENTMUX_CHANNEL`, default
43 /// "stable"). Empty for entries in the per-channel registry (where a
44 /// channel tag is redundant — the whole file is already channel-
45 /// scoped by its directory); populated for entries in the host-global
46 /// shared registry (§ below), where multiple channels' entries for
47 /// the same agent name coexist and need distinguishing. MuxBus Tier 2b
48 /// same-host cross-channel delivery, issue #1916.
49 ///
50 /// (This previously cited a `SPEC_MUXBUS_CROSS_CHANNEL_DELIVERY` spec dated
51 /// 2026-07-02 that has never existed in any commit — `check-spec-citations`
52 /// flags it the moment this file is touched. Repointed at the issue rather
53 /// than at a plausible-looking neighbour, since the similarly-named
54 /// duplicate-delivery spec is a different document about duplicate
55 /// suppression, not this registry's shape. `registry/paths.rs` carries the
56 /// same dead pointer and is deliberately left alone here: it is unchanged
57 /// by this PR so the gate does not see it, and widening a security PR to
58 /// sweep unrelated files is its own risk. Worth a follow-up.)
59 #[serde(default)]
60 pub channel: String,
61 /// Process-wide unique nonce of the persistent-controller spawn this
62 /// entry was written for; 0 = not recorded (HTTP register handler,
63 /// PTY shell auto-register, pre-existing entries). Real nonces are
64 /// always ≥ 1, drawn from a single srv-wide counter — NOT the
65 /// controller-local spawn generation, which restarts for a
66 /// replacement controller and could collide across controller
67 /// instances (codex P1 on PR #2500). Lets an exit-handler's cleanup
68 /// compare-and-remove ([`remove_if_nonce`]) instead of deleting a
69 /// fallback respawn's fresh entry (issue #2363).
70 #[serde(default)]
71 pub registration_nonce: u64,
72 /// Base64 Ed25519 **public** key for this agent, for cross-channel jekt
73 /// signature verification —
74 /// `docs/specs/SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md` §D1.
75 ///
76 /// The public half only. That is what makes publishing it here safe, and
77 /// is the whole reason this tier uses the agent's asymmetric LAN keypair
78 /// rather than its symmetric host-tier HMAC key: a shared file containing
79 /// HMAC secrets would let every channel on the machine *mint* signatures
80 /// for every agent (spec §5).
81 ///
82 /// Empty for entries written before this shipped, and for an agent whose
83 /// LAN keypair has not been minted yet. **An empty value means "cannot
84 /// check," never "failed the check"** — the verifier must map it to
85 /// `None`, not `Some(false)`, or every pre-upgrade sender on a
86 /// mixed-version machine gets escalated (spec §6).
87 #[serde(default)]
88 pub jekt_public_key: String,
89 /// Identity M4d-5 (`SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md`
90 /// §6.5.10): the registered block's agent UID, and that UID's own public
91 /// key (M4d-2), published beside the name-keyed `jekt_public_key`, which
92 /// is unchanged so v1 verification is the same for every peer. M4d-6
93 /// verifies v2 signatures (`source_uid`) against it, by UID. Empty for an
94 /// entry written before this shipped or for a block with no agent row;
95 /// like `jekt_public_key`, empty means "cannot check".
96 #[serde(default, skip_serializing_if = "String::is_empty")]
97 pub uid: String,
98 #[serde(default, skip_serializing_if = "String::is_empty")]
99 pub uid_public_key: String,
100}
101
102/// Serializes this process's own read-compare-remove sequences
103/// ([`remove_if_nonce`] / [`remove_shared_if_nonce`]) against
104/// its own writes. Both the racing writer (a fallback respawn's
105/// re-registration) and the racing remover (the dying spawn's
106/// exit-handler) live in this same agentmux-srv process — each per-agent
107/// registry file has exactly one writing instance — so a process-local
108/// mutex is sufficient to make the compare-and-remove atomic; no file
109/// locking needed. Plain removes/writes for other controller types don't
110/// need the guard but take it anyway for uniformity (it's uncontended).
111static REGISTRY_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
112
113/// Process-wide auth key for the local AgentMux instance.
114///
115/// Initialised once by `main.rs` after `Config::from_env_and_args` reads
116/// `AGENTMUX_AUTH_KEY` and removes it from the env. The registry write
117/// path reads this to populate `AgentEntry::auth_key`, which lets peers
118/// authenticate cross-instance inject forwards.
119///
120/// Tests and the `register` HTTP handler (which has `state.auth_key`)
121/// can both pre-set this safely — the first `set` wins.
122static LOCAL_AUTH_KEY: OnceLock<String> = OnceLock::new();
123
124/// Initialise the process's local auth key. Idempotent — first call wins.
125pub fn init_local_auth_key(key: impl Into<String>) {
126 let _ = LOCAL_AUTH_KEY.set(key.into());
127}
128
129fn local_auth_key() -> &'static str {
130 LOCAL_AUTH_KEY.get().map(String::as_str).unwrap_or("")
131}
132
133/// Resolves an agent's published Ed25519 public key for
134/// [`AgentEntry::jekt_public_key`].
135///
136/// Indirection rather than a `Store` handle because this module is
137/// deliberately pure filesystem — it is called from PTY auto-register,
138/// persistent-controller auto-register, and the HTTP register handler, none of
139/// which share a `Store` argument. Mirrors [`LOCAL_AUTH_KEY`]'s
140/// set-once-from-bootstrap pattern, except the value is per-agent so it has to
141/// be a lookup rather than a constant.
142type PubkeyResolver = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;
143static JEKT_PUBLIC_KEY_RESOLVER: OnceLock<PubkeyResolver> = OnceLock::new();
144
145/// Resolves a registered block's agent UID and that UID's public key, for
146/// [`AgentEntry::uid`] / [`AgentEntry::uid_public_key`] (identity M4d-5). By
147/// block, because the block is what every registration path has. A load,
148/// never a mint, like [`jekt_public_key_for`]: M4d-2 mints the key at spawn.
149type UidKeyResolver = Box<dyn Fn(&str) -> Option<(String, String)> + Send + Sync>;
150static UID_KEY_RESOLVER: OnceLock<UidKeyResolver> = OnceLock::new();
151
152/// Install the process's block → (UID, UID public key) resolver. First call
153/// wins. Until it is called, entries are written without a UID.
154pub fn init_uid_key_resolver<F>(resolver: F)
155where
156 F: Fn(&str) -> Option<(String, String)> + Send + Sync + 'static,
157{
158 let _ = UID_KEY_RESOLVER.set(Box::new(resolver));
159}
160
161fn uid_key_for_block(block_id: &str) -> (String, String) {
162 UID_KEY_RESOLVER.get().and_then(|resolve| resolve(block_id)).unwrap_or_default()
163}
164
165/// Install the process's agent-public-key resolver. Idempotent — first call
166/// wins, same contract as [`init_local_auth_key`].
167///
168/// Called from bootstrap once the Store is open. Until it is called (and for
169/// any agent with no LAN keypair minted yet) entries are written with an empty
170/// `jekt_public_key`, which readers treat as "cannot check" — see that field's
171/// doc comment.
172pub fn init_jekt_public_key_resolver<F>(resolver: F)
173where
174 F: Fn(&str) -> Option<String> + Send + Sync + 'static,
175{
176 let _ = JEKT_PUBLIC_KEY_RESOLVER.set(Box::new(resolver));
177}
178
179/// The agent's published public key, or empty if unavailable.
180///
181/// Deliberately a *load*, never an ensure/mint: registry writes happen on
182/// every registration and re-registration, and minting key material as a side
183/// effect of a bookkeeping write would be a surprising place to put it. Key
184/// creation stays where it already is, at config-injection time
185/// (`agent_config::inject_jekt_signing_keys_into_mcp_json`).
186fn jekt_public_key_for(agent_id: &str) -> String {
187 JEKT_PUBLIC_KEY_RESOLVER
188 .get()
189 .and_then(|resolve| resolve(agent_id))
190 .unwrap_or_default()
191}
192
193fn agents_dir(data_dir: &Path) -> PathBuf {
194 data_dir.join("agents")
195}
196
197fn agent_path(data_dir: &Path, agent_id: &str) -> PathBuf {
198 // Lowercase first so this matches `ReactiveHandler`'s own
199 // `agent_id.to_lowercase()` key convention (backend/reactive/handler.rs)
200 // — every other muxbus identity path is already case-insensitive, and a
201 // channel registering "AgentX" vs. a peer injecting to "agentx" must
202 // land on the same file, not two different ones (reagent P1 on #2350,
203 // caught in Tier 2b but pre-existing here for Tier 2a too).
204 // Sanitize: only allow alphanumeric, dash, underscore to prevent path traversal.
205 let safe = agentmux_common::slug::file_stem(agent_id);
206 agents_dir(data_dir).join(format!("{}.json", safe))
207}
208
209/// Write (create or update) an agent entry in the shared registry.
210///
211/// The entry includes the writing instance's auth_key (from
212/// `LOCAL_AUTH_KEY`) so a peer performing an HTTP forward of a missed
213/// inject can authenticate. On Unix the file is created with mode 0600
214/// **at open time** (not write-then-chmod, which would briefly expose
215/// the file at the default umask — same security boundary as the
216/// existing `authkey.dev` file). On Windows, default ACLs inherit
217/// user-only on user-owned directories.
218pub fn write(data_dir: &Path, agent_id: &str, local_url: &str, block_id: &str) {
219 write_with_nonce(data_dir, agent_id, local_url, block_id, 0);
220}
221
222/// [`write`], recording the registering persistent-controller spawn's
223/// registration nonce — see `AgentEntry::registration_nonce` / issue #2363.
224pub fn write_with_nonce(
225 data_dir: &Path,
226 agent_id: &str,
227 local_url: &str,
228 block_id: &str,
229 registration_nonce: u64,
230) {
231 let _guard = REGISTRY_OP_LOCK.lock().unwrap();
232 let dir = agents_dir(data_dir);
233 let _ = std::fs::create_dir_all(&dir);
234 let entry = AgentEntry {
235 agent_id: agent_id.to_string(),
236 local_url: local_url.to_string(),
237 block_id: block_id.to_string(),
238 pid: std::process::id(),
239 updated_at: now_unix_millis(),
240 auth_key: local_auth_key().to_string(),
241 // Empty for entries in the per-channel registry — see the field's
242 // doc comment on `AgentEntry`.
243 channel: String::new(),
244 registration_nonce,
245 // Deliberately EMPTY in the per-channel registry. Only the shared
246 // registry's copy is load-bearing — a same-instance sender is verified
247 // by the host-tier HMAC path, which never consults this file — so
248 // resolving it here would mean a synchronous SQLite lookup, held under
249 // `REGISTRY_OP_LOCK`, on every local agent registration and
250 // re-registration, for a value nothing reads (reagent P2 on PR #2959).
251 jekt_public_key: String::new(),
252 uid: String::new(),
253 uid_public_key: String::new(),
254 };
255 let path = agent_path(data_dir, agent_id);
256 let Ok(json) = serde_json::to_string(&entry) else { return };
257
258 let mut opts = std::fs::OpenOptions::new();
259 opts.write(true).create(true).truncate(true);
260 #[cfg(unix)]
261 {
262 use std::os::unix::fs::OpenOptionsExt;
263 opts.mode(0o600);
264 }
265 if let Ok(mut f) = opts.open(&path) {
266 use std::io::Write;
267 let _ = f.write_all(json.as_bytes());
268 }
269}
270
271/// Remove an agent entry from the shared registry.
272pub fn remove(data_dir: &Path, agent_id: &str) {
273 let _guard = REGISTRY_OP_LOCK.lock().unwrap();
274 let _ = std::fs::remove_file(agent_path(data_dir, agent_id));
275}
276
277/// Remove an agent entry **only if** it was written by the spawn with
278/// `expected_nonce` — compare-and-remove for persistent-controller
279/// exit-handlers (issue #2363; nonce is process-wide unique, so the
280/// guard holds across controller replacement too — codex P1 on PR
281/// #2500). An entry with no recorded nonce
282/// (0) is never removed by this variant: leaving a stale file to the
283/// TTL sweep ([`cleanup_stale`]) is strictly safer than deleting a live
284/// registration. Atomic w.r.t. this process's own writes via
285/// [`REGISTRY_OP_LOCK`].
286pub fn remove_if_nonce(data_dir: &Path, agent_id: &str, expected_nonce: u64) {
287 let _guard = REGISTRY_OP_LOCK.lock().unwrap();
288 let matches = lookup(data_dir, agent_id)
289 .is_some_and(|e| expected_nonce != 0 && e.registration_nonce == expected_nonce);
290 if !matches {
291 tracing::info!(
292 agent_id = %agent_id,
293 expected_nonce = expected_nonce,
294 "registry: entry changed hands since this spawn registered — skipping remove"
295 );
296 return;
297 }
298 let _ = std::fs::remove_file(agent_path(data_dir, agent_id));
299}
300
301/// Look up an agent entry. Returns None if not found or file is malformed.
302pub fn lookup(data_dir: &Path, agent_id: &str) -> Option<AgentEntry> {
303 let content = std::fs::read_to_string(agent_path(data_dir, agent_id)).ok()?;
304 serde_json::from_str(&content).ok()
305}
306
307/// Remove stale entries at startup.
308///
309/// An entry is considered stale if `updated_at` is older than `max_age_ms`.
310/// The default is 4 hours — well beyond any reasonable agent session.
311/// Entries are also removed if their JSON is malformed.
312pub fn cleanup_stale(data_dir: &Path, max_age_ms: u64) {
313 let dir = agents_dir(data_dir);
314 let Ok(entries) = std::fs::read_dir(&dir) else { return };
315 let cutoff = now_unix_millis().saturating_sub(max_age_ms);
316 for entry in entries.flatten() {
317 let path = entry.path();
318 if path.extension().and_then(|e| e.to_str()) != Some("json") {
319 continue;
320 }
321 let Ok(content) = std::fs::read_to_string(&path) else {
322 let _ = std::fs::remove_file(&path);
323 continue;
324 };
325 match serde_json::from_str::<AgentEntry>(&content) {
326 Ok(agent) if agent.updated_at >= cutoff => {} // still fresh
327 _ => {
328 let _ = std::fs::remove_file(&path);
329 }
330 }
331 }
332}
333
334// ---------------------------------------------------------------------
335// Host-global shared registry (MuxBus Tier 2b — same-host, cross-channel
336// delivery). Sibling API to the per-channel functions above, rooted at a
337// DIFFERENT directory (`registry::resolve_shared_reactive_dir()`, passed
338// in by the caller rather than resolved here to keep this module free of
339// a dependency on the `registry` crate module).
340//
341// Layout: `<shared_dir>/<agent_name>/<channel>.json` (`shared_dir` here is
342// already `resolve_shared_reactive_dir()`'s output, i.e.
343// `~/.agentmux/shared/agents/reactive/`, so the full real path is
344// `~/.agentmux/shared/agents/reactive/<agent_name>/<channel>.json`), one
345// file per (agent, channel) pair — NOT a single JSON array shared across
346// channels.
347// Each channel only ever reads/writes its OWN file, so two channels
348// registering the same agent name concurrently never contend on the same
349// file: no read-modify-write race is possible by construction, and no
350// locking is needed (reagent P1 on PR #2350 — the earlier one-array-per-
351// name design let a concurrent register/unregister from a different
352// channel silently clobber this channel's entry, since two writers could
353// each read the pre-write array before either wrote back, with no
354// periodic repair since a channel only ever writes once at agent spawn).
355// §4.2 of the cross-channel delivery spec.
356// ---------------------------------------------------------------------
357
358/// Lowercase + sanitize a single path component (agent name or channel
359/// id) to alphanumeric/dash/underscore only, preventing path traversal
360/// and matching `ReactiveHandler`'s lowercase key convention.
361fn sanitize_path_component(raw: &str) -> String {
362 agentmux_common::slug::file_stem(raw)
363}
364
365/// Directory holding one file per channel currently registering
366/// `agent_id` in the host-global shared registry.
367///
368/// Deliberately does NOT go through `agents_dir()` (which appends an
369/// `agents` segment for the per-channel registry, where the caller passes
370/// a bare data dir) — `shared_dir` here is already
371/// `registry::resolve_shared_reactive_dir()`'s output
372/// (`.../shared/agents/reactive`), so entries land at
373/// `.../shared/agents/reactive/<agent>/<channel>.json` as documented,
374/// not double-nested under an extra `agents/` (reagent P2 on #2350 —
375/// harmless since read/write agreed internally, but contradicted the
376/// documented layout).
377fn shared_agent_dir(shared_dir: &Path, agent_id: &str) -> PathBuf {
378 shared_dir.join(sanitize_path_component(agent_id))
379}
380
381/// Path to one (agent, channel) pair's entry file.
382fn shared_channel_path(shared_dir: &Path, agent_id: &str, channel: &str) -> PathBuf {
383 shared_agent_dir(shared_dir, agent_id).join(format!("{}.json", sanitize_path_component(channel)))
384}
385
386/// True if a process with this PID is currently running on this host.
387/// Meaningful here specifically because the shared registry is always
388/// same-host by construction (a local file only readable by local
389/// processes) — unlike a LAN/cloud registry, PID-liveness is an
390/// authoritative staleness signal, not just a heuristic.
391pub(crate) fn pid_alive(pid: u32) -> bool {
392 let target = sysinfo::Pid::from(pid as usize);
393 let mut sys = sysinfo::System::new();
394 // Targeted refresh, no CPU/memory/exe/cmdline needed — existence alone
395 // is what matters. `true` = drop entries for since-exited PIDs, same
396 // as the whole-machine attribution pass in backend/sysinfo.rs.
397 sys.refresh_processes_specifics(
398 sysinfo::ProcessesToUpdate::Some(&[target]),
399 true,
400 sysinfo::ProcessRefreshKind::nothing(),
401 );
402 sys.process(target).is_some()
403}
404
405/// Grace period during which a forward failure against a live-process entry
406/// is presumed transient rather than proof the specific agent is gone — see
407/// [`should_evict_on_forward_failure`]. `pub(crate)` so `muxspect_handlers`'s
408/// `verify-sender` route can reuse the same real, already-tested staleness
409/// judgment for shared-registry entries instead of inventing its own
410/// threshold (see that module's own doc comment for why).
411pub(crate) const FORWARD_FAILURE_GRACE_MS: u64 = 60_000;
412
413/// Should `server/reactive.rs`'s evict-on-forward-failure sites remove this
414/// entry after a failed forward (`success:false` or a connection error)?
415///
416/// `entry.pid` identifies the OWNING SRV PROCESS (`AgentEntry::pid`'s own
417/// doc comment: "OS PID of the owning agentmux-srv process"), not the
418/// individual agent — every agent registered under the same channel/srv
419/// shares one `pid` value. A first version of this function (PR #2640,
420/// reagent P1 round 2) checked ONLY `pid_alive(entry.pid)`: alive process ⇒
421/// never evict. That over-corrects — it can only ever prove "the whole
422/// process died," never "this ONE agent's controller died while its srv
423/// process kept running for other agents," which is at least as common a
424/// failure mode as a whole-process death on a host running multiple agents
425/// under one shared srv (the default topology — see the retro). Under that
426/// version, a genuinely-dead individual agent's entry would linger in the
427/// shared registry forever as long as ANY other agent kept that same srv
428/// process alive, with no self-healing path short of a full srv restart —
429/// strictly worse than the pre-PR-2640 behavior (unconditional eviction) for
430/// that case.
431///
432/// Evict when EITHER signal indicates death:
433/// - the owning process is confirmed dead (definitive), OR
434/// - the entry is older than [`FORWARD_FAILURE_GRACE_MS`] — a fresh entry
435/// gets one grace window to account for the specific race PR #2640 fixes
436/// (the registering agent's controller hasn't finished its own
437/// registration yet, even though its srv process and this file both
438/// already exist); anything older that still fails a forward is presumed
439/// genuinely gone, matching pre-PR-2640 behavior for everything but a
440/// just-registered entry.
441///
442/// See docs/retro/retro-cross-channel-jekt-eviction-2026-08-17.md.
443pub fn should_evict_on_forward_failure(entry: &AgentEntry) -> bool {
444 if !pid_alive(entry.pid) {
445 return true;
446 }
447 let age_ms = now_unix_millis().saturating_sub(entry.updated_at);
448 age_ms > FORWARD_FAILURE_GRACE_MS
449}
450
451/// Write (create or update) this channel's entry for `agent_id` in the
452/// host-global shared registry. Writes only this channel's own file
453/// (`shared_channel_path`) — never touches another channel's file, so
454/// concurrent writers for different channels of the same agent name
455/// cannot race or clobber each other.
456pub fn write_shared(shared_dir: &Path, agent_id: &str, local_url: &str, block_id: &str, channel: &str) {
457 write_shared_with_nonce(shared_dir, agent_id, local_url, block_id, channel, 0);
458}
459
460/// [`write_shared`], recording the registering persistent-controller
461/// spawn's registration nonce — see `AgentEntry::registration_nonce` / issue #2363.
462pub fn write_shared_with_nonce(
463 shared_dir: &Path,
464 agent_id: &str,
465 local_url: &str,
466 block_id: &str,
467 channel: &str,
468 registration_nonce: u64,
469) {
470 // Resolved BEFORE taking the guard. This is a synchronous SQLite read
471 // serialized on the store's single `Mutex<Connection>`, and it can itself
472 // queue behind an unrelated slow store operation. Holding the process-wide
473 // REGISTRY_OP_LOCK across it would block every other registry file
474 // operation for every agent in the process — writes, removes, and the
475 // nonce compare-and-remove — behind a DB query (reagent P1 on PR #2959).
476 //
477 // Safe to hoist: the key depends only on `agent_id`, not on anything
478 // REGISTRY_OP_LOCK protects. The lock serialises this process's own
479 // read-compare-remove sequences against its own writes; a stale key here
480 // would be no worse than the entry being rewritten a moment later, which
481 // re-registration does anyway.
482 let jekt_public_key = jekt_public_key_for(agent_id);
483 // Same hoisting, same reason: a store read, kept out of the guard.
484 let (uid, uid_public_key) = uid_key_for_block(block_id);
485
486 let _guard = REGISTRY_OP_LOCK.lock().unwrap();
487 let dir = shared_agent_dir(shared_dir, agent_id);
488 let _ = std::fs::create_dir_all(&dir);
489 let path = shared_channel_path(shared_dir, agent_id, channel);
490 let entry = AgentEntry {
491 agent_id: agent_id.to_string(),
492 local_url: local_url.to_string(),
493 block_id: block_id.to_string(),
494 pid: std::process::id(),
495 updated_at: now_unix_millis(),
496 auth_key: local_auth_key().to_string(),
497 channel: channel.to_string(),
498 registration_nonce,
499 jekt_public_key,
500 uid,
501 uid_public_key,
502 };
503 write_entry_file(&path, &entry);
504}
505
506/// Remove this channel's entry for `agent_id` from the shared registry.
507/// Best-effort removes the now-possibly-empty agent directory too (a
508/// concurrent writer recreating it a moment later is harmless — the next
509/// write just re-creates the directory via `create_dir_all`).
510pub fn remove_shared(shared_dir: &Path, agent_id: &str, channel: &str) {
511 let _guard = REGISTRY_OP_LOCK.lock().unwrap();
512 let path = shared_channel_path(shared_dir, agent_id, channel);
513 let _ = std::fs::remove_file(&path);
514 let _ = std::fs::remove_dir(shared_agent_dir(shared_dir, agent_id));
515}
516
517/// [`remove_shared`] **only if** this channel's entry was written by the
518/// spawn with `expected_nonce` — see [`remove_if_nonce`]
519/// (issue #2363). Cross-channel note: only this channel's own file is
520/// read and removed, matching [`write_shared`]'s single-writer contract,
521/// so the process-local [`REGISTRY_OP_LOCK`] still suffices.
522pub fn remove_shared_if_nonce(
523 shared_dir: &Path,
524 agent_id: &str,
525 channel: &str,
526 expected_nonce: u64,
527) {
528 let _guard = REGISTRY_OP_LOCK.lock().unwrap();
529 let path = shared_channel_path(shared_dir, agent_id, channel);
530 let matches = std::fs::read_to_string(&path)
531 .ok()
532 .and_then(|content| serde_json::from_str::<AgentEntry>(&content).ok())
533 .is_some_and(|e| expected_nonce != 0 && e.registration_nonce == expected_nonce);
534 if !matches {
535 tracing::info!(
536 agent_id = %agent_id,
537 channel = %channel,
538 expected_nonce = expected_nonce,
539 "registry: shared entry changed hands since this spawn registered — skipping remove"
540 );
541 return;
542 }
543 let _ = std::fs::remove_file(&path);
544 let _ = std::fs::remove_dir(shared_agent_dir(shared_dir, agent_id));
545}
546
547/// This process's own channel id — `AGENTMUX_CHANNEL`, default `"stable"`.
548///
549/// The single source of the `channel` string written into every shared
550/// registry entry ([`write_shared_from_env`] and siblings), and — as of
551/// `SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md` Phase B — the string
552/// injected into each agent's MCP env as `AGENTMUX_CHANNEL` so the
553/// `source_channel` a sender binds into its cross-channel signature (§D5)
554/// is byte-identical to the `channel` a receiver finds on the entry it
555/// verifies against. Two independent reads of the env var would agree
556/// today; one function guarantees they keep agreeing.
557pub fn local_channel_id() -> String {
558 std::env::var("AGENTMUX_CHANNEL").unwrap_or_else(|_| "stable".to_string())
559}
560
561/// This machine's host label — the `hostname` half of an agent's WAN instance
562/// identity, paired with
563/// [`local_channel_id`] to identify *which* AgentMux instance under an account
564/// owns a given agent's WAN keypair.
565///
566/// Lowercased and trimmed so the value a sender binds into its signature and
567/// the value an instance publishes alongside its public key can't differ by
568/// case alone — the same normalization discipline `agent_id` already has, and
569/// for the same reason: a mismatch here doesn't fail loudly, it selects a
570/// different key and renders a valid message as an active forgery.
571///
572/// Falls back to `"unknown"` exactly as `bootstrap/network.rs` already does for
573/// the LAN advertisement, rather than erroring — an unresolvable hostname must
574/// not block spawning an agent.
575pub fn local_host_label() -> String {
576 whoami::fallible::hostname()
577 .unwrap_or_else(|_| "unknown".to_string())
578 .trim()
579 .to_lowercase()
580}
581
582/// Convenience wrapper over [`write_shared`]: resolves the shared dir and
583/// this process's channel itself, no-op if the shared root can't be
584/// resolved. Exists so call sites outside `server/reactive.rs`'s explicit
585/// register/unregister handlers (PTY shell auto-register, persistent
586/// stream-json controller auto-register — both call the per-channel
587/// `write`/`remove` directly, not through the HTTP handler) don't each
588/// repeat the same resolve-dir-then-read-env dance.
589pub fn write_shared_from_env(agent_id: &str, local_url: &str, block_id: &str) {
590 if let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() {
591 let channel = local_channel_id();
592 write_shared(&shared_dir, agent_id, local_url, block_id, &channel);
593 }
594}
595
596/// Convenience wrapper over [`remove_shared`] — see [`write_shared_from_env`].
597pub fn remove_shared_from_env(agent_id: &str) {
598 if let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() {
599 let channel = local_channel_id();
600 remove_shared(&shared_dir, agent_id, &channel);
601 }
602}
603
604/// Convenience wrapper over [`write_shared_with_nonce`] — see
605/// [`write_shared_from_env`].
606pub fn write_shared_from_env_with_nonce(
607 agent_id: &str,
608 local_url: &str,
609 block_id: &str,
610 registration_nonce: u64,
611) {
612 if let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() {
613 let channel = local_channel_id();
614 write_shared_with_nonce(&shared_dir, agent_id, local_url, block_id, &channel, registration_nonce);
615 }
616}
617
618/// Convenience wrapper over [`remove_shared_if_nonce`] — see
619/// [`write_shared_from_env`].
620pub fn remove_shared_from_env_if_nonce(agent_id: &str, expected_nonce: u64) {
621 if let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() {
622 let channel = local_channel_id();
623 remove_shared_if_nonce(&shared_dir, agent_id, &channel, expected_nonce);
624 }
625}
626
627/// Every channel's shared entry that published a key for `uid` (identity
628/// M4d-6). Scans the whole registry: entries are filed by name, and one UID
629/// live in several channels has an entry, and a key, in each.
630pub fn lookup_shared_by_uid(shared_dir: &Path, uid: &str) -> Vec<AgentEntry> {
631 let Ok(dirs) = std::fs::read_dir(shared_dir) else { return Vec::new() };
632 dirs.flatten()
633 .filter(|d| d.path().is_dir())
634 .flat_map(|d| read_entry_files_in_dir(&d.path()))
635 .filter(|e| e.uid == uid && !e.uid_public_key.is_empty())
636 .collect()
637}
638
639/// Return every live candidate for `agent_id` across all channels,
640/// freshest-first (§4.3: Tier 2b prefers the freshest candidate). Does
641/// NOT filter by staleness/pid-liveness — callers needing that should use
642/// [`cleanup_stale_shared`] (startup sweep) plus the existing
643/// evict-on-forward-failure pattern already used by Tier 2a/3 in
644/// `server/reactive.rs`, matching how this codebase already handles
645/// registry staleness elsewhere rather than re-validating on every read.
646pub fn lookup_all_shared(shared_dir: &Path, agent_id: &str) -> Vec<AgentEntry> {
647 let dir = shared_agent_dir(shared_dir, agent_id);
648 let mut list = read_entry_files_in_dir(&dir);
649 list.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
650 list
651}
652
653/// List every entry currently in the host-global shared registry, across
654/// every agent name and channel. Used by the `/agentmux/discovery` endpoint
655/// to populate `host.cross_channel[]` (§4.5 of the cross-channel delivery
656/// spec) — unlike [`lookup_all_shared`], which targets one known agent
657/// name, this enumerates the whole directory.
658pub fn list_all_shared(shared_dir: &Path) -> Vec<AgentEntry> {
659 // Not agents_dir(shared_dir) -- see shared_agent_dir's doc comment.
660 let Ok(entries) = std::fs::read_dir(shared_dir) else {
661 return Vec::new();
662 };
663 let mut out = Vec::new();
664 for entry in entries.flatten() {
665 let path = entry.path();
666 if !path.is_dir() {
667 continue;
668 }
669 out.extend(read_entry_files_in_dir(&path));
670 }
671 out
672}
673
674/// Startup sweep over the host-global registry: remove any entry whose
675/// `updated_at` is past `max_age_ms` OR whose `pid` is no longer alive on
676/// this host (§4.4) — the dead-`Agent3` case from the spec's problem
677/// statement, where per-channel TTL cleanup never reached an entry
678/// belonging to a channel that hadn't restarted.
679pub fn cleanup_stale_shared(shared_dir: &Path, max_age_ms: u64) {
680 // Not agents_dir(shared_dir) -- see shared_agent_dir's doc comment.
681 let Ok(agent_dirs) = std::fs::read_dir(shared_dir) else { return };
682 let cutoff = now_unix_millis().saturating_sub(max_age_ms);
683 for agent_dir_entry in agent_dirs.flatten() {
684 let agent_dir_path = agent_dir_entry.path();
685 if !agent_dir_path.is_dir() {
686 continue;
687 }
688 let Ok(channel_files) = std::fs::read_dir(&agent_dir_path) else { continue };
689 for channel_entry in channel_files.flatten() {
690 let channel_path = channel_entry.path();
691 if channel_path.extension().and_then(|e| e.to_str()) != Some("json") {
692 continue;
693 }
694 let keep = read_entry_file(&channel_path)
695 .map(|e| e.updated_at >= cutoff && pid_alive(e.pid))
696 .unwrap_or(false);
697 if !keep {
698 let _ = std::fs::remove_file(&channel_path);
699 }
700 }
701 // Best-effort: only actually removes the directory if it's now
702 // empty (every channel file was stale) — a concurrent writer
703 // recreating it right after is harmless, same as `remove_shared`.
704 let _ = std::fs::remove_dir(&agent_dir_path);
705 }
706}
707
708/// Best-effort read of one (agent, channel) entry file. Missing,
709/// unreadable, or malformed files are treated as absent — same
710/// graceful-degradation posture as the per-channel `lookup`.
711fn read_entry_file(path: &Path) -> Option<AgentEntry> {
712 let content = std::fs::read_to_string(path).ok()?;
713 serde_json::from_str(&content).ok()
714}
715
716/// Read every valid `.json` entry file directly inside `dir` (one level,
717/// not recursive) — used for both `lookup_all_shared` (one agent's
718/// per-channel dir) and `list_all_shared` (one agent-name dir at a time).
719fn read_entry_files_in_dir(dir: &Path) -> Vec<AgentEntry> {
720 let Ok(entries) = std::fs::read_dir(dir) else {
721 return Vec::new();
722 };
723 entries
724 .flatten()
725 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json"))
726 .filter_map(|e| read_entry_file(&e.path()))
727 .collect()
728}
729
730/// Write one (agent, channel) entry file, atomically (temp file + rename)
731/// so a concurrent reader never observes a partially-written file. Mode
732/// 0600 on Unix at open time — same auth_key exposure boundary as the
733/// per-channel registry (§5 of the cross-channel spec: same-user trust
734/// boundary, not cross-user).
735fn write_entry_file(path: &Path, entry: &AgentEntry) {
736 let Ok(json) = serde_json::to_string(entry) else { return };
737 let tmp_path = path.with_extension("json.tmp");
738
739 let mut opts = std::fs::OpenOptions::new();
740 opts.write(true).create(true).truncate(true);
741 #[cfg(unix)]
742 {
743 use std::os::unix::fs::OpenOptionsExt;
744 opts.mode(0o600);
745 }
746 let Ok(mut f) = opts.open(&tmp_path) else { return };
747 use std::io::Write;
748 if f.write_all(json.as_bytes()).is_err() {
749 let _ = std::fs::remove_file(&tmp_path);
750 return;
751 }
752 drop(f);
753 let _ = std::fs::rename(&tmp_path, path);
754}
755
756/// Write a fully-specified shared-registry entry, bypassing the
757/// process-global pubkey resolver and `local_auth_key()`. For tests that
758/// need a deterministic `jekt_public_key` regardless of which other test in
759/// the binary happened to install the `OnceLock` resolver first
760/// (`server/reactive.rs`'s cross-channel verification tests).
761#[cfg(test)]
762pub fn write_shared_entry_for_test(shared_dir: &Path, entry: &AgentEntry) {
763 let _guard = REGISTRY_OP_LOCK.lock().unwrap();
764 let _ = std::fs::create_dir_all(shared_agent_dir(shared_dir, &entry.agent_id));
765 write_entry_file(&shared_channel_path(shared_dir, &entry.agent_id, &entry.channel), entry);
766}
767
768#[cfg(test)]
769mod shared_tests {
770 use super::*;
771
772 #[test]
773 fn write_then_lookup_round_trip() {
774 let dir = tempfile::tempdir().unwrap();
775 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block1", "dev-a");
776 let found = lookup_all_shared(dir.path(), "agentx");
777 assert_eq!(found.len(), 1);
778 assert_eq!(found[0].channel, "dev-a");
779 assert_eq!(found[0].local_url, "http://127.0.0.1:9001");
780 assert_eq!(found[0].block_id, "block1");
781 }
782
783 #[test]
784 fn write_and_lookup_are_case_insensitive() {
785 let dir = tempfile::tempdir().unwrap();
786 write_shared(dir.path(), "AgentX", "http://127.0.0.1:9001", "block1", "dev-a");
787 // A peer looking up the lowercase form (as ReactiveHandler's
788 // Tier-1 in-memory map always does) must land on the same entry —
789 // reagent P1 on #2350.
790 let found = lookup_all_shared(dir.path(), "agentx");
791 assert_eq!(found.len(), 1);
792 assert_eq!(found[0].local_url, "http://127.0.0.1:9001");
793
794 // And a second write under a different-cased spelling of the same
795 // name must overwrite, not create a sibling file.
796 write_shared(dir.path(), "AGENTX", "http://127.0.0.1:9099", "block9", "dev-a");
797 let found = lookup_all_shared(dir.path(), "agentx");
798 assert_eq!(found.len(), 1);
799 assert_eq!(found[0].local_url, "http://127.0.0.1:9099");
800 }
801
802 #[test]
803 fn multiple_channels_coexist_for_same_name() {
804 let dir = tempfile::tempdir().unwrap();
805 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block1", "dev-a");
806 write_shared(dir.path(), "agentx", "http://127.0.0.1:9002", "block2", "dev-b");
807 let found = lookup_all_shared(dir.path(), "agentx");
808 assert_eq!(found.len(), 2);
809 let channels: std::collections::HashSet<_> =
810 found.iter().map(|e| e.channel.clone()).collect();
811 assert_eq!(
812 channels,
813 ["dev-a", "dev-b"].into_iter().map(String::from).collect()
814 );
815 }
816
817 #[test]
818 fn write_same_channel_twice_replaces_not_duplicates() {
819 let dir = tempfile::tempdir().unwrap();
820 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block1", "dev-a");
821 write_shared(dir.path(), "agentx", "http://127.0.0.1:9099", "block9", "dev-a");
822 let found = lookup_all_shared(dir.path(), "agentx");
823 assert_eq!(found.len(), 1);
824 assert_eq!(found[0].local_url, "http://127.0.0.1:9099");
825 }
826
827 // ---- identity M4d-5: the UID and its key published beside the name's ----
828
829 #[test]
830 fn a_shared_entry_publishes_the_block_s_uid_and_its_public_key() {
831 // The resolver is a process-wide OnceLock: this one test owns it. The
832 // key is a real M4d-2 key from a real store row; only the block→UID
833 // step is a fixed map here (bootstrap reads the block's active
834 // instance).
835 let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
836 let mut def = crate::backend::storage::agents::test_agent_def("uid-x", "agentx", "claude", "agent", 1, "");
837 def.slug = "agentx".into();
838 store.agent_def_insert(&mut def).unwrap();
839 let (keys, _) = store.agent_uid_keys_ensure("uid-x").unwrap().unwrap();
840 let public = keys.lan.public_key.clone();
841 init_uid_key_resolver(move |block_id| {
842 (block_id == "block-uid").then(|| ("uid-x".to_string(), store.agent_uid_lan_public_key_load("uid-x").ok().flatten())).and_then(|(u, k)| Some((u, k?)))
843 });
844
845 let dir = tempfile::tempdir().unwrap();
846 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block-uid", "dev-a");
847 write_shared(dir.path(), "agenty", "http://127.0.0.1:9001", "block-none", "dev-a");
848 let x = &lookup_all_shared(dir.path(), "agentx")[0];
849 assert_eq!((x.uid.as_str(), x.uid_public_key.as_str()), ("uid-x", public.as_str()));
850 assert_ne!(x.uid_public_key, keys.lan.private_key, "never the private half");
851 let y = &lookup_all_shared(dir.path(), "agenty")[0];
852 assert_eq!((y.uid.as_str(), y.uid_public_key.as_str()), ("", ""), "no row: nothing to check against");
853 let raw = std::fs::read_to_string(shared_channel_path(dir.path(), "agenty", "dev-a")).unwrap();
854 assert!(!raw.contains("uid"), "empty fields stay off the wire for older readers");
855 }
856
857 // ---- jekt_public_key publication (SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md §D1) ----
858
859 #[test]
860 fn a_shared_entry_publishes_the_agents_real_store_public_key() {
861 // Store-backed, not a stub: the closure installed here is the SAME
862 // shape bootstrap/stores.rs installs, so this exercises the real
863 // resolver → Store → entry wiring rather than just the plumbing
864 // around it. That distinction matters — the last time a jekt key
865 // feature shipped, the code was correct and the real call path simply
866 // never ran it (REPORT_JEKT_SIGNING_KEY_INJECTION_GAP_2026_08_16.md).
867 //
868 // The resolver is a process-wide OnceLock, so this one test owns it
869 // for the whole test binary — hence both the resolvable and the
870 // unresolvable agent are covered here rather than in two tests that
871 // would race to set it.
872 // In memory: the resolver keeps the store for the process's life,
873 // so a file-backed one would outlive its temp file and leave its
874 // -wal/-shm behind (#3815).
875 let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
876 let minted = store.agent_lan_key_ensure("agentx").unwrap();
877
878 init_jekt_public_key_resolver(move |agent_id| {
879 store.agent_lan_public_key_load(agent_id).ok().flatten()
880 });
881
882 let dir = tempfile::tempdir().unwrap();
883 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block1", "dev-a");
884 let found = lookup_all_shared(dir.path(), "agentx");
885 assert_eq!(found.len(), 1);
886 assert_eq!(
887 found[0].jekt_public_key, minted.public_key,
888 "the shared entry must publish the agent's real public key from the store"
889 );
890 assert_ne!(
891 found[0].jekt_public_key, minted.private_key,
892 "the PRIVATE half must never reach the shared registry"
893 );
894
895 // Case-insensitivity: registry ids are lowercased on the path, the
896 // store lowercases on lookup — a display-cased name must still resolve.
897 write_shared(dir.path(), "AgentX", "http://127.0.0.1:9001", "block1", "dev-b");
898 let cased = lookup_all_shared(dir.path(), "agentx");
899 assert!(cased.iter().all(|e| e.jekt_public_key == minted.public_key));
900
901 // An agent with no LAN keypair minted yet publishes an empty key
902 // rather than failing the write. Empty means "cannot check" (§6) —
903 // the registry write itself must stay infallible.
904 write_shared(dir.path(), "agenty", "http://127.0.0.1:9002", "block2", "dev-a");
905 let other = lookup_all_shared(dir.path(), "agenty");
906 assert_eq!(other.len(), 1);
907 assert!(other[0].jekt_public_key.is_empty());
908 }
909
910 #[test]
911 fn an_entry_written_before_this_shipped_still_deserializes_with_an_empty_key() {
912 // §6 backward compatibility: a pre-upgrade file has no
913 // `jekt_public_key` member at all. It must load (not error), and it
914 // must load as EMPTY — which the verifier maps to "cannot check",
915 // never to "failed the check". Getting this wrong escalates every
916 // sender on a mixed-version machine.
917 let dir = tempfile::tempdir().unwrap();
918 let agent_dir = shared_agent_dir(dir.path(), "agentz");
919 std::fs::create_dir_all(&agent_dir).unwrap();
920 let legacy = r#"{"agent_id":"agentz","local_url":"http://127.0.0.1:9003",
921 "block_id":"b3","pid":1,"updated_at":1,"auth_key":"k","channel":"dev-a",
922 "registration_nonce":0}"#;
923 std::fs::write(shared_channel_path(dir.path(), "agentz", "dev-a"), legacy).unwrap();
924
925 let found = lookup_all_shared(dir.path(), "agentz");
926 assert_eq!(found.len(), 1, "a pre-upgrade entry must still deserialize");
927 assert!(found[0].jekt_public_key.is_empty());
928 assert_eq!(found[0].auth_key, "k", "the other fields must survive intact");
929 }
930
931 #[test]
932 fn lookup_all_shared_sorts_freshest_first() {
933 let dir = tempfile::tempdir().unwrap();
934 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block1", "dev-a");
935 write_shared(dir.path(), "agentx", "http://127.0.0.1:9002", "block2", "dev-b");
936 // Directly bump dev-b's own file's updated_at so it's unambiguously
937 // freshest, independent of how fast the two writes above happened
938 // to run. Only touches dev-b's own file -- exactly the point of
939 // the one-file-per-channel layout.
940 let path = shared_channel_path(dir.path(), "agentx", "dev-b");
941 let mut entry = read_entry_file(&path).unwrap();
942 entry.updated_at += 10_000;
943 write_entry_file(&path, &entry);
944
945 let found = lookup_all_shared(dir.path(), "agentx");
946 assert_eq!(found[0].channel, "dev-b");
947 assert_eq!(found[1].channel, "dev-a");
948 }
949
950 #[test]
951 fn remove_shared_drops_only_that_channel() {
952 let dir = tempfile::tempdir().unwrap();
953 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block1", "dev-a");
954 write_shared(dir.path(), "agentx", "http://127.0.0.1:9002", "block2", "dev-b");
955 remove_shared(dir.path(), "agentx", "dev-a");
956 let found = lookup_all_shared(dir.path(), "agentx");
957 assert_eq!(found.len(), 1);
958 assert_eq!(found[0].channel, "dev-b");
959 }
960
961 #[test]
962 fn remove_shared_last_channel_deletes_file_and_dir() {
963 let dir = tempfile::tempdir().unwrap();
964 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block1", "dev-a");
965 remove_shared(dir.path(), "agentx", "dev-a");
966 assert!(lookup_all_shared(dir.path(), "agentx").is_empty());
967 assert!(!shared_channel_path(dir.path(), "agentx", "dev-a").exists());
968 assert!(!shared_agent_dir(dir.path(), "agentx").exists());
969 }
970
971 #[test]
972 fn list_all_shared_covers_multiple_agent_names() {
973 let dir = tempfile::tempdir().unwrap();
974 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block1", "dev-a");
975 write_shared(dir.path(), "agenty", "http://127.0.0.1:9002", "block2", "dev-b");
976 let all = list_all_shared(dir.path());
977 assert_eq!(all.len(), 2);
978 let names: std::collections::HashSet<_> =
979 all.iter().map(|e| e.agent_id.clone()).collect();
980 assert_eq!(
981 names,
982 ["agentx", "agenty"].into_iter().map(String::from).collect()
983 );
984 }
985
986 #[test]
987 fn cleanup_stale_shared_evicts_by_ttl() {
988 let dir = tempfile::tempdir().unwrap();
989 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block1", "dev-a");
990 // Backdate updated_at well past the TTL, but keep pid as our own
991 // (alive) so only the TTL check can be responsible for eviction.
992 let path = shared_channel_path(dir.path(), "agentx", "dev-a");
993 let mut entry = read_entry_file(&path).unwrap();
994 entry.updated_at = 0;
995 entry.pid = std::process::id();
996 write_entry_file(&path, &entry);
997
998 cleanup_stale_shared(dir.path(), 4 * 60 * 60 * 1000);
999 assert!(lookup_all_shared(dir.path(), "agentx").is_empty());
1000 }
1001
1002 #[test]
1003 fn cleanup_stale_shared_evicts_dead_pid_even_if_fresh() {
1004 let dir = tempfile::tempdir().unwrap();
1005 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block1", "dev-a");
1006 // Fresh updated_at, but a PID essentially guaranteed not to exist —
1007 // exercises the dead-channel-that-never-restarted case from the
1008 // spec's problem statement, which TTL alone wouldn't catch for
1009 // another 4 hours.
1010 let path = shared_channel_path(dir.path(), "agentx", "dev-a");
1011 let mut entry = read_entry_file(&path).unwrap();
1012 entry.pid = u32::MAX;
1013 write_entry_file(&path, &entry);
1014
1015 cleanup_stale_shared(dir.path(), 4 * 60 * 60 * 1000);
1016 assert!(lookup_all_shared(dir.path(), "agentx").is_empty());
1017 }
1018
1019 #[test]
1020 fn cleanup_stale_shared_keeps_fresh_live_entries() {
1021 let dir = tempfile::tempdir().unwrap();
1022 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block1", "dev-a");
1023 let path = shared_channel_path(dir.path(), "agentx", "dev-a");
1024 let mut entry = read_entry_file(&path).unwrap();
1025 entry.pid = std::process::id();
1026 write_entry_file(&path, &entry);
1027
1028 cleanup_stale_shared(dir.path(), 4 * 60 * 60 * 1000);
1029 assert_eq!(lookup_all_shared(dir.path(), "agentx").len(), 1);
1030 }
1031
1032 #[test]
1033 fn cleanup_stale_shared_evicts_one_channel_keeps_sibling() {
1034 // The whole point of the redesign: cleanup for one channel's
1035 // stale/dead entry must not disturb a sibling channel's live one,
1036 // since they're now genuinely separate files, not shared array
1037 // elements.
1038 let dir = tempfile::tempdir().unwrap();
1039 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block1", "dev-a");
1040 write_shared(dir.path(), "agentx", "http://127.0.0.1:9002", "block2", "dev-b");
1041 let stale_path = shared_channel_path(dir.path(), "agentx", "dev-a");
1042 let mut stale = read_entry_file(&stale_path).unwrap();
1043 stale.pid = u32::MAX;
1044 write_entry_file(&stale_path, &stale);
1045
1046 cleanup_stale_shared(dir.path(), 4 * 60 * 60 * 1000);
1047 let found = lookup_all_shared(dir.path(), "agentx");
1048 assert_eq!(found.len(), 1);
1049 assert_eq!(found[0].channel, "dev-b");
1050 }
1051
1052 #[test]
1053 fn agent_id_path_traversal_is_sanitized() {
1054 let dir = tempfile::tempdir().unwrap();
1055 write_shared(dir.path(), "../../evil", "http://127.0.0.1:9001", "block1", "dev-a");
1056 // Sanitization (shared_agent_dir) must keep the write confined to
1057 // the shared registry root — no directory with `.` path segments
1058 // should escape it. Reading `dir.path()` directly (not
1059 // `agents_dir(dir.path())`) since shared_agent_dir no longer nests
1060 // under an extra `agents/` level.
1061 for entry in std::fs::read_dir(dir.path()).unwrap() {
1062 let name = entry.unwrap().file_name();
1063 assert!(!name.to_string_lossy().contains(".."));
1064 }
1065 }
1066
1067 #[test]
1068 fn channel_path_traversal_is_sanitized() {
1069 let dir = tempfile::tempdir().unwrap();
1070 write_shared(dir.path(), "agentx", "http://127.0.0.1:9001", "block1", "../../evil");
1071 // Sanitization must apply to the CHANNEL component too, not just
1072 // the agent name — a channel string is caller-controlled the same
1073 // way an agent_id is.
1074 let agent_dir = shared_agent_dir(dir.path(), "agentx");
1075 for entry in std::fs::read_dir(&agent_dir).unwrap() {
1076 let name = entry.unwrap().file_name();
1077 assert!(!name.to_string_lossy().contains(".."));
1078 }
1079 }
1080
1081 #[test]
1082 fn concurrent_writes_from_different_channels_never_clobber() {
1083 // Direct regression test for reagent's P1 on PR #2350: the old
1084 // one-array-per-agent-name design let two channels' concurrent
1085 // writes race (each reads the pre-write array, second writer's
1086 // save clobbers the first). Spawns real OS threads hammering
1087 // write_shared for N distinct channels of the same agent name
1088 // concurrently and asserts every single one survives.
1089 let dir = tempfile::tempdir().unwrap();
1090 let shared_dir = dir.path().to_path_buf();
1091 const N: usize = 16;
1092
1093 let handles: Vec<_> = (0..N)
1094 .map(|i| {
1095 let shared_dir = shared_dir.clone();
1096 std::thread::spawn(move || {
1097 let channel = format!("dev-{i}");
1098 for _ in 0..5 {
1099 write_shared(
1100 &shared_dir,
1101 "agentx",
1102 &format!("http://127.0.0.1:{}", 9000 + i),
1103 "block1",
1104 &channel,
1105 );
1106 }
1107 })
1108 })
1109 .collect();
1110 for h in handles {
1111 h.join().unwrap();
1112 }
1113
1114 let found = lookup_all_shared(&shared_dir, "agentx");
1115 assert_eq!(found.len(), N, "every channel's entry must survive concurrent writes");
1116 let channels: std::collections::HashSet<_> = found.iter().map(|e| e.channel.clone()).collect();
1117 assert_eq!(channels.len(), N);
1118 }
1119
1120 #[test]
1121 fn pid_alive_true_for_self() {
1122 assert!(pid_alive(std::process::id()));
1123 }
1124
1125 #[test]
1126 fn pid_alive_false_for_max_pid() {
1127 assert!(!pid_alive(u32::MAX));
1128 }
1129
1130 fn entry_with(pid: u32, updated_at: u64) -> AgentEntry {
1131 AgentEntry {
1132 agent_id: "agentx".to_string(),
1133 local_url: "http://127.0.0.1:9001".to_string(),
1134 block_id: "block1".to_string(),
1135 pid,
1136 updated_at,
1137 auth_key: String::new(),
1138 channel: "dev-a".to_string(),
1139 registration_nonce: 0,
1140 jekt_public_key: String::new(),
1141 uid: String::new(),
1142 uid_public_key: String::new(),
1143 }
1144 }
1145
1146 #[test]
1147 fn should_evict_on_forward_failure_true_for_dead_pid_even_when_fresh() {
1148 // Dead process is definitive regardless of age — the original,
1149 // pre-PR-2640 case this whole mechanism exists for.
1150 let entry = entry_with(u32::MAX, now_unix_millis());
1151 assert!(should_evict_on_forward_failure(&entry));
1152 }
1153
1154 #[test]
1155 fn should_evict_on_forward_failure_false_for_alive_pid_and_fresh_entry() {
1156 // The race PR #2640 actually fixes: owning srv process is alive, the
1157 // entry was JUST written (this agent's own registration is still
1158 // settling) — a forward failure right now is presumed transient.
1159 let entry = entry_with(std::process::id(), now_unix_millis());
1160 assert!(!should_evict_on_forward_failure(&entry));
1161 }
1162
1163 #[test]
1164 fn should_evict_on_forward_failure_true_for_alive_pid_but_old_entry() {
1165 // reagent P1 round 2 on PR #2640: a genuinely-dead INDIVIDUAL agent
1166 // whose srv process stays alive for other agents (the shared-srv
1167 // topology this host actually runs) must still be evictable once
1168 // the entry is old enough that a startup race is no longer a
1169 // plausible explanation — pid-liveness alone would wrongly protect
1170 // this forever.
1171 let stale_updated_at = now_unix_millis().saturating_sub(FORWARD_FAILURE_GRACE_MS + 1_000);
1172 let entry = entry_with(std::process::id(), stale_updated_at);
1173 assert!(should_evict_on_forward_failure(&entry));
1174 }
1175}