Skip to main content

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}