agentmux_srv/server/reactive.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use axum::{
5 extract::{Extension, Query, State},
6 http::StatusCode,
7 response::{IntoResponse, Json, Response},
8};
9use serde_json::json;
10
11use crate::backend::blockcontroller;
12use crate::backend::reactive::{AgentRegistration, InjectionRequest, SupervisorAction};
13use crate::backend::reactive::registry as agent_registry;
14use crate::backend::subagent_watcher;
15use crate::backend::base;
16
17use super::AppState;
18
19/// Max cross-instance HTTP forwards a single inject request may go through
20/// (Tier 2a/2b/3 each increment before forwarding). A legitimate delivery
21/// is always exactly one hop (caller's instance → the owning instance);
22/// this only exists to bound a pathological cycle — two channels each
23/// holding a stale-but-PID-alive shared-registry entry pointing at the
24/// other for the same agent name would otherwise forward back and forth
25/// indefinitely, hanging the original request (reagent P1 on PR #2350).
26const MAX_FORWARD_HOPS: u8 = 3;
27
28/// Echo a successfully-sent jekt into the SENDER's own pane
29/// (SPEC_JEKT_SECURITY_AND_VISIBILITY §3.2).
30///
31/// Appends a `{"type":"user",...}` NDJSON line carrying the same
32/// `[JEKT:...]` marker block the receiver got (re-wrapped with identical
33/// fields) to the sender's `output` blockfile — live MPS append (renders
34/// immediately in an open agent view), persisted history
35/// (`parseHistoryLines` rebuilds on reopen), and global transcript mirror.
36/// The frontend's `tryParseJekt` sees FROM == this pane's agent and renders
37/// it as an *outgoing* JektBubble (stream-parser.ts direction detection —
38/// this is the producer that comment says doesn't exist yet).
39///
40/// No-op when the sender isn't a registered agent on this instance (cron,
41/// external callers) or is messaging itself (the incoming marker already
42/// lands in the same pane).
43///
44/// `reagent_verified`/`lan_verified` should be the SAME values the original
45/// delivery's `Handler::inject_message_inner` computed `effective_tier`/
46/// `requires_stop` from (not re-derived) — see the call site's own doc
47/// comment inline below for why passing anything else produces a
48/// self-contradictory echoed marker (reagentx P1 on PR #2623).
49/// The delivery-tier + verification signals an echoed marker must carry.
50///
51/// Grouped into a named struct rather than left as positionals because
52/// `sig_verified`, `reagent_verified` and `lan_verified` are three consecutive
53/// `Option<bool>`s: any two of them can be transposed at a call site and the
54/// compiler cannot say a word. The failure mode is a silently mislabelled TRUST
55/// field on a security marker, which is not the kind of bug to leave to
56/// argument order.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub(super) struct EchoTrust<'a> {
59 pub delivery_tier: &'a str,
60 pub sig_verified: Option<bool>,
61 pub reagent_verified: Option<bool>,
62 pub lan_verified: Option<bool>,
63 pub channel_verified: Option<bool>,
64}
65
66/// The trust an echoed marker should carry after a forward, given the
67/// peer's response body. The RECEIVING instance is the one that computes
68/// `channel_verified` (the forwarder holds the sender's HMAC key, so §D2
69/// step 2 of SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md makes it skip
70/// cross-channel verification) and threads it back via
71/// `InjectionResponse::channel_verified`, exactly as it already does for
72/// `effective_tier`/`requires_stop`. Taking it from the body keeps the
73/// sender's echoed `TRUST=` consistent with the `ESCALATE=` the same body
74/// supplies — otherwise a sensitive cross-channel message could echo as
75/// `TRUST=self-declared … ESCALATE=none`, which contradicts itself (codex
76/// P2 on #3064). A body without the field (an older peer) leaves the
77/// caller's value alone.
78fn echo_trust_from_peer_body<'a>(body: &serde_json::Value, base: EchoTrust<'a>) -> EchoTrust<'a> {
79 EchoTrust {
80 channel_verified: body
81 .get("channel_verified")
82 .and_then(|v| v.as_bool())
83 .or(base.channel_verified),
84 ..base
85 }
86}
87
88pub(super) fn echo_jekt_to_sender(
89 state: &AppState,
90 source_agent: Option<&str>,
91 target_agent: &str,
92 message: &str,
93 msgid: &str,
94 effective_tier: Option<&str>,
95 requires_stop: Option<bool>,
96 trust: EchoTrust<'_>,
97 priority: &str,
98) {
99 let EchoTrust {
100 delivery_tier,
101 sig_verified,
102 reagent_verified,
103 lan_verified,
104 channel_verified,
105 } = trust;
106 let Some(src) = source_agent.filter(|s| !s.is_empty()) else {
107 return;
108 };
109 if src.eq_ignore_ascii_case(target_agent) {
110 return;
111 }
112 let Some(sender_reg) = state.reactive_handler.get_agent(src) else {
113 return;
114 };
115
116 let sanitized = crate::backend::reactive::sanitize::sanitize_message(message);
117 let wrapped = crate::backend::reactive::sanitize::wrap_jekt_message(
118 &sanitized,
119 Some(&sender_reg.agent_id),
120 target_agent,
121 effective_tier.unwrap_or("coord"),
122 delivery_tier,
123 sig_verified,
124 // reagentx P1 on PR #2623: these used to be hardcoded `None, None`
125 // ("sender-echo is inherently host-tier, so WAN/LAN verification is
126 // meaningless here") — true for TRUST/SIG rendering alone, but as of
127 // SPEC_JEKT_SENSITIVE_TIER_VERIFIED_SENDER_NO_STOP_2026_08_17.md
128 // that assumption broke: `requires_stop` (just above) already
129 // reflects the REAL delivery's verification, so hardcoding these to
130 // `None` produced a self-contradictory echoed marker — `ESCALATE=none`
131 // (implying a verified sender) next to `TRUST=network-claimed` with
132 // no `SIG=` field (implying an unverified one). Passing the same
133 // verification signals the original decision was made from keeps
134 // the echoed marker internally consistent with its own `ESCALATE=`.
135 reagent_verified,
136 lan_verified,
137 channel_verified,
138 // The sender-side echo never has a WAN verdict: WAN verification
139 // happens only at the receiving install (W3-S §2.3), after the echo.
140 None,
141 // Defaults to `true` (STOP) when the caller couldn't tell us —
142 // matches `effective_tier` defaulting to the more-cautious "coord"
143 // rather than assuming "info" above; never silently downgrades a
144 // sensitive echo to tag-only just because this hop lost the signal.
145 requires_stop.unwrap_or(true),
146 msgid,
147 priority,
148 None,
149 );
150 let line = serde_json::json!({
151 "type": "user",
152 "message": { "role": "user", "content": wrapped }
153 });
154 let data = format!("{line}\n");
155 let global_zone = crate::backend::blockcontroller::shell::resolve_global_output_zone(
156 &Some(state.mstore.clone()),
157 &sender_reg.block_id,
158 );
159 crate::backend::blockcontroller::shell::handle_append_block_file(
160 &state.broker,
161 &sender_reg.block_id,
162 crate::backend::agent_session::OUTPUT_FILE,
163 data.as_bytes(),
164 Some(&state.filestore),
165 global_zone.as_deref(),
166 );
167}
168
169/// One forward candidate: where to POST, how to authenticate, and what the
170/// echoed marker should claim about the hop if it lands.
171pub(super) struct ForwardPeer<'a> {
172 /// Base URL of the peer srv (no path), e.g. `http://127.0.0.1:1234`.
173 pub url: &'a str,
174 /// `X-AuthKey` value; skipped entirely when empty.
175 pub auth_key: &'a str,
176 /// Which tier resolved this candidate — log field only.
177 pub kind: &'a str,
178 /// Channel the candidate came from, for the cross-channel registry tier.
179 /// `None` where the notion doesn't apply.
180 pub channel: Option<&'a str>,
181 pub trust: EchoTrust<'a>,
182}
183
184/// What one forward attempt concluded. The distinction that matters is
185/// [`Stale`](ForwardOutcome::Stale) vs
186/// [`Inconclusive`](ForwardOutcome::Inconclusive): only the former is evidence
187/// about the *candidate*, and only the former may drive an eviction.
188pub(super) enum ForwardOutcome {
189 /// The peer delivered it. The sender echo has already been emitted; the
190 /// caller should return this body verbatim.
191 Delivered(serde_json::Value),
192 /// The peer answered without delivering (`success` was not `true`), or was
193 /// unreachable at the transport level. Both are consistent with a registry
194 /// entry or discovery-cache entry pointing at something that is no longer
195 /// there — but neither *proves* it (a startup race looks identical), so
196 /// each tier applies its own eviction policy on top of this.
197 Stale,
198 /// Something failed that says nothing about whether the candidate is
199 /// alive — a non-2xx status, or a body that wasn't JSON. **Never evict on
200 /// this**; fall through to the next candidate/tier and leave the entry be.
201 Inconclusive,
202}
203
204/// The single implementation of "POST this injection to a peer srv and echo it
205/// to the sender if it lands", shared by every forwarding tier.
206///
207/// This existed as three hand-maintained copies (same-host same-channel,
208/// same-host cross-channel, LAN peer) differing only in how the URL and key
209/// were resolved and what they did on failure. That is the shape that has
210/// already produced drift bugs elsewhere in this repo: a tier that forgets the
211/// echo, or passes `delivery_tier` wrong, fails silently and differently from
212/// its siblings. See
213/// `docs/reports/REPORT_NETWORK_ARCHITECTURE_DRYNESS_AND_ROBUST_LAN_2026_09_06.md` §4.
214///
215/// **One behaviour was unified rather than preserved:** the LAN tier used to
216/// treat "anything but `success: false`" as delivered, while both host tiers
217/// required `success: true`. A 200 response whose body omits `success`
218/// entirely therefore counted as a successful LAN delivery — echoing a
219/// delivery confirmation to the sender for a message that may never have
220/// arrived. All three now require `success: true`. No real peer is affected:
221/// `InjectionResponse::success` is a plain `bool` with no
222/// `skip_serializing_if`, so a genuine AgentMux srv always sends it.
223async fn forward_inject_to_peer(
224 state: &AppState,
225 req: &InjectionRequest,
226 forwarded_req: &InjectionRequest,
227 peer: ForwardPeer<'_>,
228) -> ForwardOutcome {
229 let forward_url = format!("{}/agentmux/reactive/inject", peer.url);
230 let channel = peer.channel.unwrap_or("-");
231 tracing::debug!(
232 target = %req.target_agent,
233 url = %forward_url,
234 kind = peer.kind,
235 channel,
236 "inject forward"
237 );
238
239 let mut fwd = state.http_client.post(&forward_url).json(forwarded_req);
240 if !peer.auth_key.is_empty() {
241 fwd = fwd.header(AUTH_KEY_HEADER, peer.auth_key);
242 }
243
244 match fwd.send().await {
245 Ok(r) if r.status().is_success() => {
246 let Ok(body) = r.json::<serde_json::Value>().await else {
247 tracing::warn!(
248 target = %req.target_agent,
249 url = %forward_url,
250 kind = peer.kind,
251 channel,
252 "inject forward: 2xx with an unparseable body — not evicting"
253 );
254 return ForwardOutcome::Inconclusive;
255 };
256 if body.get("success").and_then(|v| v.as_bool()) != Some(true) {
257 tracing::warn!(
258 target = %req.target_agent,
259 url = %forward_url,
260 kind = peer.kind,
261 channel,
262 "inject forward: peer did not deliver — candidate may be stale"
263 );
264 return ForwardOutcome::Stale;
265 }
266 echo_jekt_to_sender(
267 state,
268 req.source_agent.as_deref(),
269 &req.target_agent,
270 &req.message,
271 body.get("request_id").and_then(|v| v.as_str()).unwrap_or(""),
272 body.get("effective_tier").and_then(|v| v.as_str()),
273 body.get("requires_stop").and_then(|v| v.as_bool()),
274 echo_trust_from_peer_body(&body, peer.trust),
275 req.priority.as_deref().unwrap_or("normal"),
276 );
277 ForwardOutcome::Delivered(body)
278 }
279 Ok(r) => {
280 tracing::warn!(
281 target = %req.target_agent,
282 status = %r.status(),
283 url = %forward_url,
284 kind = peer.kind,
285 channel,
286 "inject forward: non-success HTTP status — not evicting"
287 );
288 ForwardOutcome::Inconclusive
289 }
290 Err(e) => {
291 tracing::warn!(
292 target = %req.target_agent,
293 error = %e,
294 url = %forward_url,
295 kind = peer.kind,
296 channel,
297 "inject forward: transport failure — candidate may be stale"
298 );
299 ForwardOutcome::Stale
300 }
301 }
302}
303
304/// Max age (seconds) a signed jekt's `ts_secs` may be from "now" and still
305/// verify (SPEC_JEKT_TRUST_LAYER_COMPLETION_2026_08_13.md §2.2, anti-replay
306/// — reagentx P1 on PR #2565: `ts_secs` was bound into the signed material
307/// specifically for this purpose per `jekt_sign.rs`'s own doc comments, but
308/// nothing actually checked it, so a captured valid signature verified
309/// forever). Generous enough for normal host-tier delivery latency and
310/// modest clock skew between the signing agent process and this srv
311/// instance (same machine, but not guaranteed same clock read down to the
312/// second); tight enough to bound replay to a narrow window instead of
313/// indefinite reuse.
314const JEKT_SIG_MAX_AGE_SECS: i64 = 300;
315
316use agentmux_common::{time::now_secs as now_unix_secs, AUTH_KEY_HEADER};
317
318/// Host-tier jekt sender verification (SPEC_JEKT_TRUST_LAYER_COMPLETION_2026_08_13.md
319/// §2.2). Mutates `req.sig_verified` in place based on whether the claimed
320/// `source_agent`'s stored signing key (if any) verifies `req.jekt_sig`,
321/// within the anti-replay freshness window.
322///
323/// **Every entry point that can build a host-tier `InjectionRequest` from
324/// client-supplied fields MUST call this before handing it to
325/// `Handler::inject_message`** — reagentx P0 on PR #2565 found two call
326/// sites (`messagebus.rs::handle_inject`, `websocket.rs`'s `bus:inject`
327/// message handling) that built `InjectionRequest` with a fully
328/// client-controlled `source_agent` and called `inject_message` directly,
329/// bypassing this entirely — those messages rendered `TRUST=self-declared`
330/// (unescalated) exactly as if this feature didn't exist. Both now call
331/// this too.
332///
333/// **Deliberately NOT gated on `delivery_tier == "host"`** (reagentx P0 on
334/// the LAN signing PR — this WAS gated that way originally, and it was the
335/// bug): `delivery_tier` is a value this instance largely trusts as
336/// self-declared by whoever authenticated with the full local `auth_key`
337/// (needed so legitimate same-host forwarding can carry an already-`"lan"`
338/// jekt through unmolested — see `resolve_delivery_tier`'s doc comment).
339/// That means a request claiming `delivery_tier: "lan"` (or `"wan"`) was
340/// otherwise a way to dodge THIS check entirely — impersonate a real,
341/// locally-known agent by simply not calling it "host," since
342/// `verify_lan_signature`/`verify_reagent_signature` only fire for their
343/// own tiers and leave an unsigned claim unforced by design. Running this
344/// check unconditionally closes that: it only ever does anything when
345/// `agent_jekt_key_load` finds a LOCAL key for the claimed `source_agent` —
346/// which is `None` for any genuinely-remote LAN/WAN sender this instance
347/// never spawned (no behavior change for real network traffic), but
348/// catches an unsigned/wrong-signature impersonation of an agent THIS
349/// instance actually knows, regardless of what tier the request claims.
350pub(super) fn verify_jekt_signature(state: &AppState, req: &mut InjectionRequest) {
351 let Some(claimed) = req.source_agent.clone().filter(|s| !s.is_empty()) else {
352 return;
353 };
354 if super::actor::jekt_sender_by_token(&state.mstore, req, &claimed) {
355 req.sig_verified = Some(true); // identity M4d-4
356 return;
357 }
358 let Ok(Some(key)) = state.mstore.agent_jekt_key_load(&claimed) else {
359 return;
360 };
361 crate::backend::agent_resolve::record_uid_fallback("m4d.host_hmac_by_name");
362 let msgid = req.request_id.clone().unwrap_or_default();
363 let ts = req.ts_secs.unwrap_or(0);
364 let within_freshness_window =
365 ts > 0 && (now_unix_secs() - ts).abs() <= JEKT_SIG_MAX_AGE_SECS;
366 let verified = within_freshness_window
367 && req.jekt_sig.as_deref().map_or(false, |sig| {
368 agentmux_common::jekt_sign::verify_jekt(
369 &key, &msgid, &claimed, &req.target_agent, ts, &req.message, sig,
370 )
371 });
372 req.sig_verified = Some(verified);
373}
374
375/// Anti-replay window for `req.reagent_ts_secs`, same purpose as
376/// `JEKT_SIG_MAX_AGE_SECS` above but WAN-scoped: wider than host-tier's
377/// 300s because this covers real network delivery latency, not a
378/// same-machine call — matches `cloud_subscriber::REAGENT_SIG_MAX_AGE_SECS`
379/// (the WS delivery path's own constant of the same value) and the review
380/// notifications' own delivery window.
381const REAGENT_SIG_MAX_AGE_SECS: i64 = 600;
382
383/// WAN-tier reagent-signature verification for the HTTP
384/// `/agentmux/reactive/inject` path — mirrors
385/// `cloud_subscriber::sync_agent_reactive`'s in-process verification of the
386/// same four fields for the desktop app's WS delivery path, but for callers
387/// that deliver over HTTP instead (`@agentmuxai/muxbus-client`'s
388/// `pollAndDeliverInjections`, and any future standalone poller). Before
389/// this, `InjectionRequest` declared `reagent_sig`/`reagent_key_id` as
390/// deserializable input fields but nothing on the HTTP path ever read them
391/// — a reagent-signed notification delivered through this path arrived
392/// unsigned in effect, `reagent_verified` always `None`, and could never
393/// render `SIG=verified` (reagentx P1 on PR #41).
394///
395/// Only meaningful for `delivery_tier == "wan"` — same scoping as
396/// `reagent_verified`'s doc comment ("meaningless off the WAN tier"). A
397/// partial set of the four fields (e.g. a sig but no key_id) is treated the
398/// same as "not signed" (`reagent_verified` stays `None`), not "signed but
399/// broken" — matches `cloud_subscriber.rs`'s identical policy: a legitimate
400/// sender always sends all four together, and this field never affects
401/// `TIER`/`TRUST` escalation either way, so a stripped signature can't buy
402/// an attacker anything a fully-absent one couldn't already.
403///
404/// Takes `now` explicitly (rather than calling `now_unix_secs()` itself),
405/// same reasoning as `cloud_subscriber::reagent_sig_is_fresh`: the pinned
406/// Ed25519 key's matching private half isn't in this repo (it lives only in
407/// the cloud service), so tests can't mint a fresh signature
408/// on demand the way the host-tier HMAC tests below do — only a fixed
409/// offline-signed fixture at a fixed `ts_secs`. Injecting `now` lets a test
410/// hold that fixture inside the freshness window without mocking the clock.
411pub(super) fn verify_reagent_signature(req: &mut InjectionRequest, now: i64) {
412 if req.delivery_tier.as_deref() != Some("wan") {
413 return;
414 }
415 let (Some(sig), Some(key_id), Some(msg_id), Some(ts_secs)) =
416 (req.reagent_sig.as_deref(), req.reagent_key_id.as_deref(), req.reagent_msg_id.as_deref(), req.reagent_ts_secs)
417 else {
418 return;
419 };
420 let within_freshness_window = ts_secs > 0 && (now - ts_secs).abs() <= REAGENT_SIG_MAX_AGE_SECS;
421 let verified = within_freshness_window
422 && agentmux_common::jekt_sign::verify_trusted_reagent_jekt(
423 key_id,
424 msg_id,
425 req.source_agent.as_deref().unwrap_or(""),
426 &req.target_agent,
427 ts_secs,
428 &req.message,
429 sig,
430 );
431 req.reagent_verified = Some(verified);
432}
433
434/// Anti-replay window for LAN `lan_sig` — reuses the WAN reasoning
435/// (`REAGENT_SIG_MAX_AGE_SECS`) rather than host-tier's tighter
436/// `JEKT_SIG_MAX_AGE_SECS`: LAN crosses an actual network hop (mDNS
437/// discovery + an HTTP round trip through a peer instance), not a
438/// same-process call, so it needs the wider real-network-delivery-latency
439/// margin WAN already established rather than host-tier's same-machine one.
440const LAN_SIG_MAX_AGE_SECS: i64 = REAGENT_SIG_MAX_AGE_SECS;
441
442/// LAN-tier per-agent Ed25519 signature verification —
443/// docs/specs/SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.4. Async, unlike
444/// `verify_jekt_signature`'s synchronous local-only key lookup: the claimed
445/// sender's public key lives on WHICHEVER peer instance actually hosts that
446/// agent, so finding it costs a LAN round trip
447/// (`LanDiscoveryController::find_agent_lan_pubkey`).
448///
449/// Only meaningful for `delivery_tier == "lan"` — by this point
450/// `req.delivery_tier` has already been through the server-side override in
451/// `handle_reactive_inject` (§3 of the spec), so this is never reachable
452/// off a genuinely `lan_key`-authenticated request.
453///
454/// No `lan_sig` at all, or a claimed sender whose public key no peer has on
455/// file → `lan_verified` stays `None` — "nothing to check against," not a
456/// red flag on its own, same semantics as `sig_verified`/`reagent_verified`'s
457/// `None` case. A `lan_sig` present with a public key found but the
458/// signature doesn't verify → `Some(false)`, forced `TIER=sensitive`
459/// unconditionally in `handler.rs` — an active attempt to forge a specific
460/// agent's identity.
461pub(super) async fn verify_lan_signature(state: &AppState, req: &mut InjectionRequest) {
462 if req.delivery_tier.as_deref() != Some("lan") {
463 return;
464 }
465 let Some(sig) = req.lan_sig.as_deref() else {
466 return;
467 };
468 let Some(claimed) = req.source_agent.clone().filter(|s| !s.is_empty()) else {
469 return;
470 };
471 let observed_key = match state.lan_discovery.find_agent_lan_pubkey(&claimed, &state.http_client).await {
472 crate::backend::lan_discovery::LanPubkeyLookup::Found(key) => key,
473 // Genuinely unknown sender — nothing to check against, not a red
474 // flag on its own (same treatment self-declared senders get
475 // elsewhere).
476 crate::backend::lan_discovery::LanPubkeyLookup::NotFound => return,
477 // reagentx P0 follow-up: the lookup was SKIPPED (rate-limited), not
478 // genuinely absent — a lan_sig WAS presented (checked above), so
479 // "we didn't check" must not collapse into the same benign outcome
480 // as "nothing to check." Treat conservatively as a failure, same as
481 // an active forgery attempt — the alternative lets an attacker
482 // exhaust the rate limiter to slip a forged identity claim through
483 // unverified instead of forced-sensitive.
484 crate::backend::lan_discovery::LanPubkeyLookup::RateLimited => {
485 tracing::warn!(
486 agent_id = %claimed,
487 "LAN pubkey lookup was rate-limited while verifying a signed jekt — \
488 treating as unverified/failed rather than silently passing it through"
489 );
490 req.lan_verified = Some(false);
491 return;
492 }
493 };
494
495 // Trust-on-first-use pin (reagentx P0 — mDNS peer discovery is
496 // unauthenticated, so "whichever peer answers first" is not a safe
497 // trust anchor on its own; see lan_peer_pubkey_pins.rs's module doc and
498 // docs/specs/SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.2). The first
499 // key ever observed for a claimed sender is pinned; a LATER lookup
500 // returning a DIFFERENT key is itself treated as an active red flag —
501 // someone is now claiming a different identity than what was already
502 // established — not silently trusted as an update.
503 use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
504 let observed_key_b64 = BASE64.encode(&observed_key);
505 let Ok(pinned_key_b64) = state.mstore.lan_peer_pubkey_pin_get_or_set(&claimed, &observed_key_b64) else {
506 return;
507 };
508 if pinned_key_b64 != observed_key_b64 {
509 tracing::warn!(
510 agent_id = %claimed,
511 "LAN pubkey mismatch against pinned key — possible identity spoofing attempt"
512 );
513 req.lan_verified = Some(false);
514 return;
515 }
516
517 let msgid = req.request_id.clone().unwrap_or_default();
518 let ts = req.ts_secs.unwrap_or(0);
519 let within_freshness_window = ts > 0 && (now_unix_secs() - ts).abs() <= LAN_SIG_MAX_AGE_SECS;
520 let verified = within_freshness_window
521 && agentmux_common::jekt_sign::verify_lan_jekt(
522 &observed_key,
523 &msgid,
524 &claimed,
525 &req.target_agent,
526 ts,
527 &req.message,
528 sig,
529 );
530 req.lan_verified = Some(verified);
531 lan_v2::claim_uid(state, req, &claimed);
532}
533
534mod channel_v2;
535mod lan_v2;
536
537/// Anti-replay window for cross-channel `channel_sig` —
538/// SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md §D6: host-tier's tighter
539/// `JEKT_SIG_MAX_AGE_SECS`, NOT LAN/WAN's wider one. A cross-channel forward
540/// is a same-machine HTTP call to `127.0.0.1`; it has no real-network
541/// latency budget to accommodate, for the same reason host-tier doesn't.
542const CHANNEL_SIG_MAX_AGE_SECS: i64 = JEKT_SIG_MAX_AGE_SECS;
543
544/// Cross-channel (same machine, different AgentMux instance) per-agent
545/// Ed25519 signature verification —
546/// docs/specs/SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md §D2, Phase B.
547///
548/// Mirrors `verify_lan_signature`'s structure but resolves the claimed
549/// sender's public key from the host-global shared registry
550/// (`AgentEntry::jekt_public_key`, published at registration by whichever
551/// instance actually spawned that agent — §D1) instead of a LAN round trip.
552/// A local file read, so this is synchronous and has none of the LAN
553/// lookup's rate-limiter `Skipped` hazard.
554///
555/// Only meaningful for `delivery_tier == "channel"` — the label a same-host
556/// forward now carries (§D4, set by the forwarding instance in
557/// `handle_reactive_inject`'s Tier 2a/2b). Off that tier the field stays
558/// `None`, same scoping as `lan_verified`.
559///
560/// Ordering guarantee (§D2 step 2): a claimed sender THIS instance holds an
561/// HMAC key for is a same-instance agent, and `verify_jekt_signature` owns
562/// it entirely — this function returns without touching `channel_verified`,
563/// so the two verifiers can never disagree about one message.
564///
565/// Outcomes, all three-state like its siblings:
566/// - `None` — no shared-registry entry for the claimed sender anywhere (a
567/// genuine bridge, or an agent that never registered), OR every entry
568/// found has an empty `jekt_public_key` (written before Phase A shipped —
569/// "cannot check," never "failed the check," §6). Nothing to verify
570/// against; not a red flag on its own.
571/// - `Some(true)` — `channel_sig` present, fresh, and verified against the
572/// published key of ANY of that agent's entries (one agent name can
573/// legitimately be live in several channels at once — §D2 step 5).
574/// - `Some(false)` — a published key WAS found but the signature was
575/// missing, stale, or wrong. The §D3 red flag; **not escalated in Phase
576/// B** (see `InjectionRequest::channel_verified`'s doc comment).
577pub(super) fn verify_cross_channel_signature(state: &AppState, req: &mut InjectionRequest) {
578 let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() else {
579 return;
580 };
581 verify_cross_channel_signature_in(state, req, &shared_dir, now_unix_secs());
582}
583
584/// [`verify_cross_channel_signature`] with the shared registry dir and clock
585/// injected — the testable core; the wrapper above only resolves the real
586/// dir. Keeps tests off the process-global `AGENTMUX_SHARED_DIR` env var.
587pub(super) fn verify_cross_channel_signature_in(
588 state: &AppState,
589 req: &mut InjectionRequest,
590 shared_dir: &std::path::Path,
591 now_secs: i64,
592) {
593 if req.delivery_tier.as_deref() != Some("channel") {
594 return;
595 }
596 let Some(claimed) = req.source_agent.clone().filter(|s| !s.is_empty()) else {
597 return;
598 };
599 // Identity M4d-6: a v2 signature proves which agent sent it, by UID. When
600 // that is the agent this instance knows by the claimed name, the name's
601 // checks are settled, including a failed HMAC check against a key it kept
602 // from when that agent ran here (a key outlives a move between
603 // instances). A different agent of the same name is not the name's.
604 if channel_v2::settle_by_uid(state, req, &claimed, shared_dir, now_secs) {
605 return;
606 }
607 // §D2 step 2: a same-instance sender is the HMAC path's to judge.
608 if matches!(state.mstore.agent_jekt_key_load(&claimed), Ok(Some(_))) {
609 return;
610 }
611 // Only entries that actually published a key count — a pre-Phase-A
612 // entry (empty key) is "cannot check," and must not turn a missing
613 // signature into `Some(false)` on a mixed-version machine (§6).
614 let published_keys: Vec<Vec<u8>> = agent_registry::lookup_all_shared(shared_dir, &claimed)
615 .into_iter()
616 .filter(|e| !e.jekt_public_key.is_empty())
617 .filter_map(|e| agentmux_common::jekt_sign::decode_key(&e.jekt_public_key))
618 .collect();
619 if published_keys.is_empty() {
620 return;
621 }
622
623 let msgid = req.request_id.clone().unwrap_or_default();
624 let ts = req.ts_secs.unwrap_or(0);
625 let within_freshness_window = ts > 0 && (now_secs - ts).abs() <= CHANNEL_SIG_MAX_AGE_SECS;
626 let source_channel = req.source_channel.as_deref().unwrap_or("");
627 let verified = within_freshness_window
628 && req.channel_sig.as_deref().is_some_and(|sig| {
629 published_keys.iter().any(|key| {
630 agentmux_common::jekt_sign::verify_channel_jekt(
631 key,
632 &msgid,
633 &claimed,
634 source_channel,
635 &req.target_agent,
636 ts,
637 &req.message,
638 sig,
639 )
640 })
641 });
642 if !verified {
643 tracing::warn!(
644 agent_id = %claimed,
645 source_channel = %source_channel,
646 has_signature = req.channel_sig.is_some(),
647 fresh = within_freshness_window,
648 "cross-channel jekt: claimed sender has a published key but the signature did not verify"
649 );
650 }
651 req.channel_verified = Some(verified);
652}
653
654/// Server-derived `delivery_tier` for the LAN-key case only —
655/// docs/specs/SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §3, revised after
656/// reagentx P0 on the implementing PR: an earlier version of this override
657/// also force-downgraded a "lan" claim to "host" whenever auth was via the
658/// full `auth_key`, reasoning that "nothing authenticated via the full key
659/// could have genuinely crossed the LAN boundary." That's false once
660/// same-host forwarding (Tier 2a/2b below in `handle_reactive_inject`) is
661/// considered: a jekt legitimately authenticated via `lan_key` at its FIRST
662/// hop, then relayed to a sibling channel/instance on this same machine,
663/// authenticates that SECOND hop with the sibling's own full `auth_key`
664/// (not `lan_key` — that credential is peer-to-peer between distinct
665/// machines, never shared across same-host channels). Downgrading there
666/// silently discarded an already-detected LAN signature failure's
667/// forced-sensitive escalation (`lan_verified`/`reagent_verified` are
668/// `#[serde(skip_deserializing)]`, so they reset to `None` on every hop and
669/// must be free to re-derive from whatever `delivery_tier` the forward
670/// legitimately carries).
671///
672/// The actual gap this closes is narrower than "full auth_key can never
673/// claim lan": a `lan_key` holder is the ONLY credential that can FORCE
674/// `delivery_tier = "lan"` regardless of what the body says (closing the
675/// original bypass — claim "host" instead to dodge LAN scrutiny entirely).
676/// A full-`auth_key` caller's own claim (host/wan/lan) is otherwise trusted
677/// as-is: holding the full local key already grants complete control over
678/// this instance, so which tier it self-labels a request as grants nothing
679/// extra — at worst it triggers MORE verification
680/// (`verify_lan_signature` running), never less.
681///
682/// `"channel"` (SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md §D4) rides the
683/// same rule: it's set by the FORWARDING instance on a same-host Tier 2a/2b
684/// hop and authenticates here with this instance's full `auth_key`, so a
685/// full-key caller's own `"channel"` claim is honoured as-is. Claiming it
686/// gains nothing — it only adds `verify_cross_channel_signature` on top of
687/// the unconditional `verify_jekt_signature`, never removes a check.
688fn resolve_delivery_tier(auth_via: super::ReactiveAuthVia, claimed: Option<&str>) -> String {
689 if auth_via == super::ReactiveAuthVia::LanKey {
690 "lan".to_string()
691 } else {
692 claimed.unwrap_or("host").to_string()
693 }
694}
695
696/// The `delivery_tier` a same-host Tier 2a/2b forward carries to the peer
697/// instance — SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md §D4. See the call
698/// site in `handle_reactive_inject` for the full reasoning; in short: a
699/// `host`-tier (or unlabelled) request becomes `channel` on the hop, and
700/// anything already carrying a network tier keeps it so the peer re-derives
701/// that tier's own verification.
702fn same_host_forward_tier(current: Option<&str>) -> &str {
703 match current {
704 None | Some("host") => "channel",
705 Some(other) => other,
706 }
707}
708
709/// Server-side resolution of `InjectionRequest::is_transcript_request` /
710/// `transcript_request_escalate_forced` — see both fields' own doc comments
711/// (`backend/reactive/types.rs`) and
712/// `SPEC_JEKT_TRANSCRIPT_REQUEST_TIER_RULES_2026_08_22.md`. Lives here (not
713/// `Handler::inject_message_inner`) because it needs `Store` access —
714/// `Handler` has no `Store` access "by design," same reason
715/// `sig_verified`/`lan_verified` are resolved by this same caller before
716/// the request reaches the handler.
717///
718/// Takes `mstore: &Arc<Store>` directly (not `&AppState`) so
719/// `muxbus::cloud_subscriber::sync_agent_reactive` — the WAN delivery path,
720/// which calls `Handler::inject_message` directly and never goes through
721/// `handle_reactive_inject`/HTTP at all — can call this exact same
722/// resolution too (`pub(crate)`). Phase C (WAN) needs the identical rule 1/
723/// rule 2 computation this function already does; without also wiring it
724/// in there, a WAN-delivered `transcript_request` would silently skip both
725/// rules entirely, not just get weaker ones.
726///
727/// Always re-parses `req.message` itself — never trusts anything the
728/// client might have set on these two fields (impossible anyway, since
729/// both are `#[serde(skip_deserializing)]`, but this function is the one
730/// place that actually computes their real value from scratch).
731pub(crate) fn resolve_transcript_request_tier_fields(mstore: &std::sync::Arc<crate::backend::storage::store::Store>, req: &mut InjectionRequest) {
732 let Some(transcript_req) = agentmux_common::transcript_request::parse_transcript_request(&req.message) else {
733 return;
734 };
735 let _ = transcript_req; // request_id/max_lines belong to the (not-yet-built) auto-responder, not tier resolution.
736 req.is_transcript_request = true;
737
738 // The RESPONDING agent's (target_agent's) own definition: by its slug,
739 // the stable AGENTMUX_AGENT_ID-derived identifier, else by its display
740 // name's route name (a fork targeted as `Loap-3`, #4744). A target naming
741 // several agents fails closed, as `ask`.
742 let defs = mstore.agent_defs_for_target(&req.target_agent).unwrap_or_default();
743 let (slug, visibility) = match defs.as_slice() {
744 [def] => (def.slug.clone(), def.conversation_visibility.clone()),
745 [] => (req.target_agent.clone(), crate::backend::storage::agents::default_conversation_visibility()),
746 _ => (req.target_agent.clone(), "ask".to_string()),
747 };
748
749 let tier = req.delivery_tier.as_deref().unwrap_or("host");
750 req.transcript_request_escalate_forced = match visibility.as_str() {
751 "ask" => true,
752 "trusted_peers" => {
753 let requester = req.source_agent.as_deref().unwrap_or("");
754 let granted = mstore
755 .conversation_trust_grant_check(&slug, requester, tier)
756 .unwrap_or(false);
757 !granted
758 }
759 // "private" and any unrecognized value: fail-closed on the
760 // ESCALATE-forcing question too — an agent def loaded from a
761 // channel/registry state older than this feature (or IS
762 // genuinely "private") never had a chance to opt into
763 // relaxation, so it gets none. Rule 1's forced TIER=sensitive
764 // still applies regardless (set above) — only this ADDITIONAL
765 // escalate-forcing rule reads "private" as "no need to force it
766 // beyond the ordinary verified-sender relaxation," since a
767 // "private" agent auto-denies (once the responder auto-resolve
768 // is built) and was never going to disclose anything either way.
769 _ => false,
770 };
771}
772
773#[cfg(test)]
774#[path = "tests/reactive/transcript_request_tier_resolution_tests.rs"]
775mod transcript_request_tier_resolution_tests;
776
777pub(super) async fn handle_reactive_inject(
778 State(state): State<AppState>,
779 Extension(auth_via): Extension<super::ReactiveAuthVia>,
780 caller: Option<Extension<super::caller::Caller>>,
781 Json(req): Json<InjectionRequest>,
782) -> Json<serde_json::Value> {
783 super::actor::check_actor(
784 &state,
785 caller.as_deref(),
786 super::actor::ActorSite::Inject,
787 req.source_agent.as_deref(),
788 );
789 tracing::info!(
790 target_agent = %req.target_agent,
791 source_agent = ?req.source_agent,
792 msg_len = req.message.len(),
793 "reactive inject request received"
794 );
795 let caller_uid = super::caller::attributed_uid(caller.as_deref());
796 Json(deliver(&state, auth_via, &caller_uid, req).await)
797}
798
799/// Deliver an inject — verify, try this instance, then forward
800/// cross-instance, cross-channel, LAN and cloud relay — exactly as
801/// `POST /agentmux/reactive/inject` does. Shared with the cron scheduler's
802/// in-process fire (identity M4c-3, spec §6.5.9), which passes its tier
803/// explicitly (`auth_via`) in place of the route's `ReactiveAuthVia`
804/// extension. `caller_uid` is the sender's attributed UID, audited on this
805/// instance only (`InjectionRequest::audit_source_uid` never rides a hop);
806/// `""` when Unattributed.
807pub(crate) async fn deliver(
808 state: &AppState,
809 auth_via: super::ReactiveAuthVia,
810 caller_uid: &str,
811 mut req: InjectionRequest,
812) -> serde_json::Value {
813 req.delivery_tier = Some(resolve_delivery_tier(auth_via, req.delivery_tier.as_deref()));
814 // Identity M4c-2d: the sender's UID, for the audit entry only — never
815 // forwarded (`#[serde(skip)]`).
816 req.audit_source_uid = caller_uid.to_string();
817 // Identity M4d-6: the signed source_uid is the sender's to set; this srv
818 // only counts one that isn't the UID its token named.
819 if !caller_uid.is_empty() && req.source_uid.as_deref().is_some_and(|s| s != caller_uid) {
820 crate::backend::agent_resolve::record_uid_fallback("m4.source_uid_mismatch");
821 }
822
823 verify_jekt_signature(&state, &mut req);
824 verify_reagent_signature(&mut req, now_unix_secs());
825 verify_lan_signature(&state, &mut req).await;
826 verify_cross_channel_signature(&state, &mut req);
827 req.route_unsigned_target();
828 resolve_transcript_request_tier_fields(&state.mstore, &mut req);
829
830 // 1. Try local ReactiveHandler first (fast path — same instance).
831 let resp = state.reactive_handler.inject_message(req.clone());
832 if resp.success {
833 echo_local_delivery(state, &req, &resp);
834 return serde_json::to_value(&resp).unwrap_or_default();
835 }
836
837 // The target is here but its spawn gate refused (not signed in). That is
838 // recoverable, so hold it for sign-in instead of failing it
839 // (SPEC_JEKT_DELIVERY_STATES_AND_MAILBOX_2026_10_01.md Phase 0 item 4).
840 // No other tier can run it either: the agent lives on this instance.
841 if resp.error.as_deref().is_some_and(super::jekt_held::is_spawn_gate_refusal) {
842 if let Some(held) = hold_for_absent_target(state, auth_via, &req, &resp, HoldReason::NeedsLogin).await {
843 return held;
844 }
845 return serde_json::to_value(&resp).unwrap_or_default();
846 }
847
848 // 2. On "agent not found", check cross-instance file registry and forward.
849 let is_not_found = resp
850 .error
851 .as_deref()
852 .map(|e| e.starts_with("agent not found"))
853 .unwrap_or(false);
854
855 if is_not_found && req.forward_hops >= MAX_FORWARD_HOPS {
856 tracing::warn!(
857 target = %req.target_agent,
858 hops = req.forward_hops,
859 "reactive inject: forward-hop limit reached, not forwarding further"
860 );
861 return serde_json::to_value(&resp).unwrap_or_default();
862 }
863
864 // Every forward below sends this hop-incremented request, not the
865 // original `req` — a peer that also fails to find the agent locally
866 // and forwards onward needs to see the accumulated hop count too.
867 let mut forwarded_req = req.clone();
868 forwarded_req.forward_hops = req.forward_hops.saturating_add(1);
869
870 // SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md §D4: a same-host hop to a
871 // DIFFERENT instance (Tier 2a/2b below) is labelled `channel`, not
872 // `host` — `host` is reserved for genuinely same-instance traffic, so
873 // the label finally matches the guarantee (the receiving instance holds
874 // no HMAC key for a sender it didn't spawn; §2.3 of the spec). The
875 // receiver can't tell a forward from a local call by auth alone (the
876 // forward authenticates with the receiver's own full `auth_key`), so
877 // the forwarding side says so here, and `resolve_delivery_tier` on the
878 // far side honours a full-key caller's claim as it always has.
879 //
880 // An already-`lan`/`wan` hop keeps its label: that's what lets the peer
881 // re-derive its own tier-specific verification on the second hop
882 // (`resolve_delivery_tier`'s doc comment, reagentx P0 on the LAN signing
883 // PR). A `channel` hop forwarded again stays `channel`. Tier 3 (LAN)
884 // sends `forwarded_req` untouched — the peer authenticates it via
885 // `lan_key` and forces `lan` regardless of the body.
886 let same_host_tier = same_host_forward_tier(req.delivery_tier.as_deref());
887 let mut same_host_req = forwarded_req.clone();
888 same_host_req.delivery_tier = Some(same_host_tier.to_string());
889
890 // Whether any forwarding tier found the target alive elsewhere. A target
891 // that is alive but refused is not "absent" and is never held here
892 // (durable jekt spec §2.1, condition 3); a same-host entry whose process
893 // is dead — every entry after an srv restart, which changes its port —
894 // is not a candidate, so the first message after a restart is held.
895 let mut candidate_seen = false;
896 if is_not_found {
897 // Tier 2: same-host, different sidecar (file registry → HTTP loopback)
898 let data_dir = base::get_mux_data_dir();
899 if let Some(entry) = agent_registry::lookup(&data_dir, &req.target_agent) {
900 // Guard against self-forwarding loops.
901 if entry.local_url != state.local_web_url {
902 match forward_inject_to_peer(
903 &state,
904 &req,
905 &same_host_req,
906 ForwardPeer {
907 url: &entry.local_url,
908 auth_key: &entry.auth_key,
909 kind: "cross-instance",
910 channel: None,
911 trust: EchoTrust {
912 delivery_tier: same_host_tier,
913 sig_verified: req.sig_verified,
914 reagent_verified: req.reagent_verified,
915 lan_verified: req.lan_verified,
916 channel_verified: req.channel_verified,
917 },
918 },
919 )
920 .await
921 {
922 ForwardOutcome::Delivered(body) => return body,
923 // A non-delivery here is ambiguous: this entry may be
924 // stale (agent unregistered without a clean shutdown), OR
925 // the owning process may be alive and simply not have
926 // registered this specific agent yet — e.g. right after
927 // that channel's srv came up. Connection-level failure is
928 // at least as plausible a transient trigger as a parsed
929 // success:false body, which is why both land here.
930 // `should_evict_on_forward_failure` combines PID-liveness
931 // with the entry's age: a dead process always evicts; a
932 // live process only protects a FRESH entry (the actual
933 // startup race), not an old one — an old entry whose
934 // process happens to still be alive for OTHER agents is
935 // presumed to be its own genuinely-dead agent (reagent P1
936 // round 2 on #2640: PID alone over-protects, since it
937 // identifies the whole srv process, not this one agent).
938 // See docs/retro/retro-cross-channel-jekt-eviction-2026-08-17.md.
939 //
940 // Falls through to Tier 2b/3 either way (reagent P1 on
941 // #2350 — this previously returned unconditionally
942 // whenever the body parsed, regardless of success, so a
943 // stale same-channel entry never reached a later tier).
944 ForwardOutcome::Stale => {
945 if agent_registry::should_evict_on_forward_failure(&entry) {
946 tracing::warn!(
947 target = %req.target_agent,
948 pid = entry.pid,
949 "cross-instance forward: entry presumed dead — evicting and falling through"
950 );
951 agent_registry::remove(&data_dir, &req.target_agent);
952 } else {
953 // Alive but did not take it: the target exists
954 // elsewhere, so it is not held here.
955 candidate_seen = true;
956 tracing::warn!(
957 target = %req.target_agent,
958 pid = entry.pid,
959 "cross-instance forward: entry is fresh and owning process is alive — NOT evicting, falling through"
960 );
961 }
962 }
963 // Says nothing about this entry's liveness — leave it be,
964 // and do not treat the target as absent.
965 ForwardOutcome::Inconclusive => candidate_seen = true,
966 }
967 }
968 }
969
970 // Tier 2b: same host, DIFFERENT channel (host-global shared registry).
971 // Runs when Tier 2a had no same-channel entry or its forward already
972 // failed above — closes the gap issue #1916 tracked (Tier 2 previously
973 // only ever reached agents in the caller's own channel). Candidates are
974 // tried freshest-first (§4.3 of the cross-channel delivery spec);
975 // a failed forward evicts just that channel's entry and falls through
976 // to the next candidate, same evict-on-fail shape Tier 3 already uses.
977 if let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() {
978 let candidates = agent_registry::lookup_all_shared(&shared_dir, &req.target_agent);
979 for entry in candidates {
980 // Self-forward guard, matching Tier 2a. Also loopback-only
981 // (§5 of the spec): a poisoned registry entry can't redirect
982 // a forward off-box, since resolve_shared_reactive_dir() is a
983 // same-user local file, but defense in depth costs nothing here.
984 let is_loopback = entry.local_url.starts_with("http://127.0.0.1")
985 || entry.local_url.starts_with("http://localhost")
986 || entry.local_url.starts_with("http://[::1]");
987 if !is_loopback || entry.local_url == state.local_web_url {
988 continue;
989 }
990
991 match forward_inject_to_peer(
992 &state,
993 &req,
994 &same_host_req,
995 ForwardPeer {
996 url: &entry.local_url,
997 auth_key: &entry.auth_key,
998 kind: "cross-channel",
999 channel: Some(&entry.channel),
1000 trust: EchoTrust {
1001 delivery_tier: same_host_tier,
1002 sig_verified: req.sig_verified,
1003 reagent_verified: req.reagent_verified,
1004 lan_verified: req.lan_verified,
1005 channel_verified: req.channel_verified,
1006 },
1007 },
1008 )
1009 .await
1010 {
1011 ForwardOutcome::Delivered(body) => return body,
1012 // Same ambiguity and same should_evict_on_forward_failure
1013 // policy as Tier 2a — PID-liveness alone over-protects an
1014 // old, genuinely-dead individual agent whose srv process
1015 // another agent keeps alive (reagent P1 round 2 on #2640),
1016 // and a connection failure can be the same startup-race
1017 // transient (target channel's srv not listening yet). See
1018 // docs/retro/retro-cross-channel-jekt-eviction-2026-08-17.md.
1019 // Evicting only this channel's entry lets the loop try the
1020 // next candidate.
1021 ForwardOutcome::Stale => {
1022 if agent_registry::should_evict_on_forward_failure(&entry) {
1023 tracing::warn!(
1024 target = %req.target_agent,
1025 channel = %entry.channel,
1026 pid = entry.pid,
1027 "cross-channel forward: entry presumed dead — evicting and trying next candidate"
1028 );
1029 agent_registry::remove_shared(&shared_dir, &req.target_agent, &entry.channel);
1030 } else {
1031 candidate_seen = true;
1032 tracing::warn!(
1033 target = %req.target_agent,
1034 channel = %entry.channel,
1035 pid = entry.pid,
1036 "cross-channel forward: entry is fresh and owning process is alive — NOT evicting, trying next candidate"
1037 );
1038 }
1039 }
1040 ForwardOutcome::Inconclusive => candidate_seen = true,
1041 }
1042 }
1043 }
1044
1045 // Tier 3: LAN peer (mDNS lookup → HTTP). Runs when tier 2 had no registry
1046 // entry or its forward failed. Queries each discovered LAN peer for the
1047 // agent; result is cached for 60s to avoid per-inject mDNS fan-out.
1048 if let Some((peer_url, peer_auth_key)) = state
1049 .lan_discovery
1050 .find_agent(&req.target_agent, &state.http_client)
1051 .await
1052 {
1053 match forward_inject_to_peer(
1054 &state,
1055 &req,
1056 &forwarded_req,
1057 ForwardPeer {
1058 url: &peer_url,
1059 auth_key: &peer_auth_key,
1060 kind: "lan-peer",
1061 channel: None,
1062 trust: EchoTrust {
1063 delivery_tier: "lan",
1064 sig_verified: None,
1065 // reagent signing is WAN-only; it never applies on the
1066 // LAN forward path.
1067 reagent_verified: None,
1068 lan_verified: req.lan_verified,
1069 channel_verified: req.channel_verified,
1070 },
1071 },
1072 )
1073 .await
1074 {
1075 ForwardOutcome::Delivered(body) => return body,
1076 // The peer answered "not mine" (agent migrated away since we
1077 // cached it) or was unreachable — either way the discovery
1078 // cache entry is wrong. Unlike the registry tiers there is no
1079 // freshness/PID guard to weigh: the cache is cheap to refill
1080 // from mDNS, so evicting on any non-delivery is the right
1081 // trade rather than an oversight.
1082 // A stale route is evicted as wrong, so it is not a live
1083 // candidate (Codex P1 on #3632): the message may still be
1084 // held. Only an inconclusive answer leaves the target
1085 // possibly alive there.
1086 ForwardOutcome::Stale => {
1087 state.lan_discovery.evict_agent(&req.target_agent);
1088 }
1089 ForwardOutcome::Inconclusive => candidate_seen = true,
1090 }
1091 }
1092
1093 // Tier 4: cloud relay (muxbus store-and-forward). Runs when no local,
1094 // same-host or LAN tier could place the message.
1095 //
1096 // This tier used to be a comment here delegating to a "muxbus-client"
1097 // that the callers agents actually use are not — the MCP `SendMessage`
1098 // tool POSTs to this very endpoint and bails on `success != true`, so
1099 // the chain silently stopped at three while that tool's description
1100 // promised "local → LAN → cloud". Owning it here means every caller
1101 // inherits it. See `crate::muxbus::relay` and
1102 // REPORT_NETWORK_ARCHITECTURE_DRYNESS_AND_ROBUST_LAN_2026_09_06.md §5.
1103 //
1104 // Except for an agent defined here that no tier found live: that one
1105 // is held here unless the relay says another install of the account
1106 // runs it (PLAN_JEKT_LOCAL_FIRST_ROUTING_2026_10_02.md §5). Before
1107 // this, the relay came first for it too, so a same-computer agent got
1108 // the relay's 30 min expiry instead of the 24 h hold, and only a
1109 // sender NOT signed in to the cloud ever reached the hold.
1110 if !candidate_seen && route_for_absent(state, auth_via, &req, &resp).await == AbsentRoute::HoldHere {
1111 if let Some(held) = hold_for_absent_target(state, auth_via, &req, &resp, HoldReason::NotRunning).await {
1112 return held;
1113 }
1114 }
1115 if let Some(body) = try_cloud_relay(&state, &req).await {
1116 return body;
1117 }
1118 // 5. No tier knew the target: hold it for the agent's return, when
1119 // the durable jekt conditions hold.
1120 if !candidate_seen {
1121 if let Some(held) = hold_for_absent_target(state, auth_via, &req, &resp, HoldReason::NotRunning).await {
1122 return held;
1123 }
1124 }
1125 }
1126
1127 // 6. Every tier declined — return the original local error.
1128 serde_json::to_value(&resp).unwrap_or_default()
1129}
1130
1131/// Echo a locally delivered jekt into the sender's own pane, as every
1132/// successful delivery path does.
1133fn echo_local_delivery(
1134 state: &AppState,
1135 req: &InjectionRequest,
1136 resp: &crate::backend::reactive::types::InjectionResponse,
1137) {
1138 echo_jekt_to_sender(
1139 state,
1140 req.source_agent.as_deref(),
1141 &req.target_agent,
1142 &req.message,
1143 &resp.request_id,
1144 resp.effective_tier.as_deref(),
1145 resp.requires_stop,
1146 EchoTrust {
1147 delivery_tier: req.delivery_tier.as_deref().unwrap_or("host"),
1148 // Same `req` this call's own `effective_tier`/`requires_stop`
1149 // were computed from (via `inject_message`) — not hardcoded, so
1150 // the echoed marker's TRUST/SIG stays consistent with its own
1151 // ESCALATE= (reagentx P1 on PR #2623).
1152 sig_verified: req.sig_verified,
1153 reagent_verified: req.reagent_verified,
1154 lan_verified: req.lan_verified,
1155 channel_verified: req.channel_verified,
1156 },
1157 req.priority.as_deref().unwrap_or("normal"),
1158 );
1159}
1160
1161/// Hold a jekt no tier could take, when every condition of
1162/// `SPEC_DURABLE_JEKT_DELIVERY_2026_09_24.md` §2.1 holds: a full-key,
1163/// host-tier request (a LAN caller never fills the hold); not a periodic
1164/// `cron` fire; and a target that is a known agent of this channel —
1165/// resolved to its row's UID, so a typo or a name registered later by
1166/// another block never receives the backlog. `None` keeps today's error.
1167///
1168/// The same rules hold a jekt the target's spawn gate refused
1169/// ([`HoldReason::NeedsLogin`]); the replay then waits for sign-in
1170/// (`jekt_held::still_needs_login`).
1171pub(crate) async fn hold_for_absent_target(
1172 state: &AppState,
1173 auth_via: super::ReactiveAuthVia,
1174 req: &InjectionRequest,
1175 resp: &crate::backend::reactive::types::InjectionResponse,
1176 reason: HoldReason,
1177) -> Option<serde_json::Value> {
1178 use crate::backend::storage::jekt_held::{HeldJekt, HoldOutcome, HELD_TTL_MS};
1179 if !hold_applies(auth_via, req, resp) {
1180 return None;
1181 }
1182 let now = agentmux_common::time::now_ms();
1183 let mut held = HeldJekt {
1184 request_id: resp.request_id.clone(),
1185 target_uid: String::new(),
1186 target_agent: req.target_agent.clone(),
1187 source_agent: req.source_agent.clone().unwrap_or_default(),
1188 audit_source_uid: req.audit_source_uid.clone(),
1189 // What the recipient would receive — sanitized and truncated to the
1190 // delivery limit — never the unbounded body (Codex P2 on #3632).
1191 message: crate::backend::reactive::sanitize::sanitize_message(&req.message),
1192 priority: req.priority.clone().unwrap_or_default(),
1193 jekt_tier: req
1194 .jekt_tier
1195 .as_ref()
1196 .and_then(|t| serde_json::to_value(t).ok())
1197 .and_then(|v| v.as_str().map(str::to_string))
1198 .unwrap_or_default(),
1199 delivery_tier: "host".to_string(),
1200 sig_verified: req.sig_verified,
1201 reagent_verified: req.reagent_verified,
1202 lan_verified: req.lan_verified,
1203 channel_verified: req.channel_verified,
1204 is_transcript_request: req.is_transcript_request,
1205 transcript_request_escalate_forced: req.transcript_request_escalate_forced,
1206 sent_at_ms: now,
1207 expires_at_ms: now + HELD_TTL_MS,
1208 attempts: 0,
1209 last_error: String::new(),
1210 };
1211 let mstore = state.mstore.clone();
1212 let outcome = tokio::task::spawn_blocking(move || {
1213 let (uid, _slug) = defined_here(&mstore, &held.target_agent)?;
1214 held.target_uid = uid;
1215 Some(held)
1216 })
1217 .await
1218 .ok()
1219 .flatten()?;
1220 // Registered under another of its names (the handler binds only the
1221 // display and stable ones): it is running, so deliver by UID now rather
1222 // than tell the sender it is not. Not for a spawn-gate refusal: that
1223 // target is registered, and a retry now would only be refused again.
1224 if reason == HoldReason::NotRunning && state.reactive_handler.has_uid_registration(&outcome.target_uid) {
1225 let mut by_uid = req.clone();
1226 by_uid.target_agent = outcome.target_uid.clone();
1227 let resp = state.reactive_handler.inject_message(by_uid);
1228 if resp.success {
1229 // As addressed, so the sender's echo names whom it wrote to
1230 // (ReAgent P1 on #3632: this path skipped the echo).
1231 echo_local_delivery(state, req, &resp);
1232 }
1233 // A refusal is the target's answer; but if it unregistered between
1234 // the check and the delivery, fall through and hold it (Codex P2).
1235 let raced_away = resp.error.as_deref().is_some_and(|e| e.starts_with("agent not found"));
1236 if !raced_away {
1237 return Some(serde_json::to_value(&resp).unwrap_or_default());
1238 }
1239 }
1240 let target_uid = outcome.target_uid.clone();
1241 let mstore = state.mstore.clone();
1242 let outcome = tokio::task::spawn_blocking(move || mstore.jekt_held_insert(&outcome))
1243 .await
1244 .ok()?;
1245 let target = &req.target_agent;
1246 match outcome {
1247 Ok(HoldOutcome::Held) | Ok(HoldOutcome::AlreadyHeld) => {
1248 crate::backend::agent_resolve::record_uid_fallback("jekt.held");
1249 let error = match reason {
1250 HoldReason::NotRunning => format!(
1251 "agent {target} is not running — held for delivery on this AgentMux instance for up to 24 h"
1252 ),
1253 HoldReason::NeedsLogin => {
1254 // The send itself was refused just now: the replay waits
1255 // for sign-in rather than retrying at once.
1256 super::jekt_held::mark_needs_login(&target_uid);
1257 format!(
1258 "agent {target} is not signed in — held for delivery on this AgentMux instance for up to 24 h, delivered when it signs in"
1259 )
1260 }
1261 };
1262 Some(json!({
1263 "success": false,
1264 "held": true,
1265 "held_reason": reason.as_str(),
1266 "request_id": resp.request_id,
1267 "error": error,
1268 }))
1269 }
1270 Ok(HoldOutcome::Full) => Some(json!({
1271 "success": false,
1272 "request_id": resp.request_id,
1273 "error": format!(
1274 "{} (hold full: too many messages already held for {target})",
1275 resp.error.clone().unwrap_or_default()
1276 ),
1277 })),
1278 Err(e) => {
1279 tracing::warn!(error = %e, target = %target, "durable jekt: hold failed");
1280 None
1281 }
1282 }
1283}
1284
1285/// The conditions under which this instance may hold a jekt at all (durable
1286/// jekt spec §2.1): a full-key, host-tier request (a LAN caller never fills
1287/// the hold); not a periodic `cron` fire; and an id to key the row by.
1288fn hold_applies(
1289 auth_via: super::ReactiveAuthVia,
1290 req: &InjectionRequest,
1291 resp: &crate::backend::reactive::types::InjectionResponse,
1292) -> bool {
1293 auth_via == super::ReactiveAuthVia::FullAuthKey
1294 && req.delivery_tier.as_deref() == Some("host")
1295 && req.source_agent.as_deref() != Some("cron")
1296 && !resp.request_id.is_empty()
1297}
1298
1299/// The UID and slug of the agent defined in this channel that `target`
1300/// names, as the hold resolves it (`hold_for_absent_target`). The slug is
1301/// the agent's `AGENTMUX_AGENT_ID`, which its relay lease is keyed by.
1302fn defined_here(mstore: &crate::backend::storage::store::Store, target: &str) -> Option<(String, String)> {
1303 let target = target.trim();
1304 let uid = match mstore.instance_get(target) {
1305 Ok(Some(row)) => row.id,
1306 _ => match mstore.agents_matching_name(target) {
1307 Ok(rows) if rows.len() == 1 => rows[0].id.clone(),
1308 _ => return None,
1309 },
1310 };
1311 let slug = match mstore.agent_def_get(&uid) {
1312 Ok(Some(def)) if !def.slug.is_empty() => def.slug,
1313 _ => target.to_string(),
1314 };
1315 Some((uid, slug))
1316}
1317
1318/// Where a jekt goes when no tier found its target live
1319/// (`PLAN_JEKT_LOCAL_FIRST_ROUTING_2026_10_02.md` §5-§6).
1320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1321pub(crate) enum AbsentRoute {
1322 /// Hold it on this instance (24 h) and deliver when the agent opens here.
1323 HoldHere,
1324 /// Leave it to the cloud relay, then the hold as a fallback (the order
1325 /// before this plan).
1326 Relay,
1327}
1328
1329/// Decide [`AbsentRoute`] for a target no tier found live. Holding here is
1330/// the default for an agent defined in this channel, so messaging works
1331/// without the relay; the relay overrides it only when it says another
1332/// install of the account holds the agent's lease. Anything the relay
1333/// cannot answer plainly keeps the old order (relay first), so an older
1334/// relay changes nothing.
1335pub(crate) async fn route_for_absent(
1336 state: &AppState,
1337 auth_via: super::ReactiveAuthVia,
1338 req: &InjectionRequest,
1339 resp: &crate::backend::reactive::types::InjectionResponse,
1340) -> AbsentRoute {
1341 use crate::muxbus::wan_lease;
1342 if !hold_applies(auth_via, req, resp) {
1343 return AbsentRoute::Relay;
1344 }
1345 let mstore = state.mstore.clone();
1346 let target = req.target_agent.clone();
1347 let Some((_uid, slug)) = tokio::task::spawn_blocking(move || defined_here(&mstore, &target)).await.ok().flatten() else {
1348 // Not an agent of this channel: it can only be on another install.
1349 return AbsentRoute::Relay;
1350 };
1351 // This instance itself recently heard the lease is held elsewhere.
1352 if wan_lease::held_elsewhere(&slug).is_some() {
1353 return AbsentRoute::Relay;
1354 }
1355 // No relay for this sender (no source agent, not signed in): hold.
1356 let Some(source) = req.source_agent.as_deref().filter(|s| !s.is_empty()) else {
1357 return AbsentRoute::HoldHere;
1358 };
1359 let Some(credential) = crate::muxbus::relay::relay_token(source, &state.id_store, &state.http_client).await else {
1360 return AbsentRoute::HoldHere;
1361 };
1362 let answer = wan_lease::holder(
1363 &crate::muxbus::relay::rest_base_url(),
1364 &slug,
1365 source,
1366 &credential.token,
1367 &state.http_client,
1368 )
1369 .await;
1370 let route = absent_route_for(&answer);
1371 tracing::debug!(target = %req.target_agent, lease = ?answer, ?route, "jekt: routing an absent local agent");
1372 route
1373}
1374
1375/// The relay's lease answer as a route: hold here unless it says another
1376/// install runs the agent or cannot say; an unreachable relay holds here
1377/// (there is no relay to send through anyway).
1378fn absent_route_for(answer: &crate::muxbus::wan_lease::Holder) -> AbsentRoute {
1379 use crate::muxbus::wan_lease::Holder;
1380 match answer {
1381 Holder::Free | Holder::Yours | Holder::Unreachable => AbsentRoute::HoldHere,
1382 Holder::Other(_) | Holder::Unknown | Holder::Unsupported => AbsentRoute::Relay,
1383 }
1384}
1385
1386/// Why a jekt is held (`held_reason` in the inject answer;
1387/// SPEC_JEKT_DELIVERY_STATES_AND_MAILBOX_2026_10_01.md §5.1).
1388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1389pub(crate) enum HoldReason {
1390 /// A known agent with no process here.
1391 NotRunning,
1392 /// The agent is here but its spawn gate refused: not signed in.
1393 NeedsLogin,
1394}
1395
1396impl HoldReason {
1397 pub(crate) fn as_str(self) -> &'static str {
1398 match self {
1399 HoldReason::NotRunning => "not_running",
1400 HoldReason::NeedsLogin => "needs_login",
1401 }
1402 }
1403}
1404
1405/// Tier 4. `Some(body)` when the cloud accepted the injection (and the sender
1406/// echo has been emitted); `None` when tier 4 doesn't apply or didn't take,
1407/// leaving the caller to return its original error.
1408///
1409/// **Queued is not delivered.** The cloud persists the message and wakes
1410/// subscribed sidecars; the recipient's srv picks it up on its next sync, which
1411/// may be seconds away or never if that instance is offline. `success: true`
1412/// here therefore means "handed to the relay" — which is the strongest claim
1413/// any WAN tier can make, and is what `DELIVERY=wan` on the echoed marker
1414/// already tells the sender.
1415async fn try_cloud_relay(state: &AppState, req: &InjectionRequest) -> Option<serde_json::Value> {
1416 // Never re-relay something that ARRIVED over the cloud. An agent unknown to
1417 // every instance would otherwise bounce between the relay and each
1418 // subscriber indefinitely, re-queueing on every hop. `forward_hops` cannot
1419 // catch this: the cloud hands the message to a fresh inbound request with
1420 // the count reset, so this tier needs its own guard.
1421 if req.delivery_tier.as_deref() == Some("wan") {
1422 return None;
1423 }
1424
1425 // The cloud route derives the sender from `X-Agent-ID` and 400s without it,
1426 // so a caller with no source agent (cron, external bridge) has no tier 4.
1427 let source = req.source_agent.as_deref().filter(|s| !s.is_empty())?;
1428
1429 let credential = crate::muxbus::relay::relay_token(source, &state.id_store, &state.http_client).await;
1430 let Some(credential) = credential else {
1431 tracing::debug!(
1432 target = %req.target_agent,
1433 "cloud relay skipped: not logged in to muxbus"
1434 );
1435 return None;
1436 };
1437
1438 let priority = req.priority.as_deref().unwrap_or("normal");
1439 // W3-S (SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md §2.1): carry the
1440 // sender's WAN signature as signed, but only through the carry gate —
1441 // any unmet condition relays unsigned, exactly as before.
1442 // The gate's refusal reason rides on the "queued" line below: a
1443 // signature that silently fails to ride is otherwise invisible, and the
1444 // receiver can't tell it from a sender that never signed.
1445 // The directory this send goes into: this relay plus the account of the
1446 // credential it's sent with. The publisher binds each publication to the
1447 // same id.
1448 let directory = Some(credential.account_sub.as_str())
1449 .filter(|sub| !sub.is_empty())
1450 .map(|sub| crate::muxbus::wan_publish::directory_id(&crate::muxbus::relay::rest_base_url(), sub));
1451 let (carried, unsigned_reason) = match crate::muxbus::relay::wan_carry_gate(
1452 req,
1453 state.mstore.wan_identity().as_deref(),
1454 directory.as_deref(),
1455 &crate::backend::reactive::registry::local_channel_id(),
1456 ) {
1457 Ok(carried) => (Some(carried), ""),
1458 Err(reason) => (None, reason),
1459 };
1460 let outcome = crate::muxbus::relay::relay_inject(
1461 &crate::muxbus::relay::rest_base_url(),
1462 &state.http_client,
1463 &credential.token,
1464 source,
1465 &req.target_agent,
1466 &req.message,
1467 priority,
1468 carried.as_ref(),
1469 )
1470 .await;
1471
1472 match outcome {
1473 crate::muxbus::relay::RelayOutcome::Queued { injection_id, target_in_account } => {
1474 let request_id = injection_id.unwrap_or_default();
1475 tracing::info!(
1476 target = %req.target_agent,
1477 injection_id = %request_id,
1478 signed = carried.is_some(),
1479 unsigned_reason,
1480 // The relay answers with an `inj-w-` id when it kept a valid
1481 // carried tuple; any other id means the message travels
1482 // unsigned, including when it dropped a tuple as invalid.
1483 cloud_kept_signature = request_id.starts_with("inj-w-"),
1484 "cloud relay: queued for WAN delivery"
1485 );
1486 echo_jekt_to_sender(
1487 state,
1488 Some(source),
1489 &req.target_agent,
1490 &req.message,
1491 &request_id,
1492 None, // no receiver-side escalation to mirror: nobody has
1493 None, // seen the message yet, so neither field is known
1494 EchoTrust {
1495 delivery_tier: "wan",
1496 // Signing on this path is issue #2586's unbuilt WAN half —
1497 // an outbound relay carries no proof of sender identity, and
1498 // claiming otherwise here would put a TRUST value on the
1499 // echoed marker that the receiving side will not agree with.
1500 sig_verified: None,
1501 reagent_verified: None,
1502 lan_verified: None,
1503 channel_verified: None,
1504 },
1505 priority,
1506 );
1507 let body = crate::backend::reactive::types::InjectionResponse {
1508 deferred: None,
1509 success: true,
1510 request_id,
1511 block_id: None,
1512 error: None,
1513 timestamp: now_unix_secs().max(0) as u64,
1514 // No receiver has seen the message yet, so there is no applied
1515 // tier or STOP decision to report — deliberately left unset
1516 // rather than guessed at.
1517 effective_tier: None,
1518 requires_stop: None,
1519 channel_verified: None,
1520 };
1521 let mut body = serde_json::to_value(&body).unwrap_or_default();
1522 // Passed through for `SendMessage`, which warns of a likely typo
1523 // on `false` (PLAN_JEKT_LOCAL_FIRST_ROUTING_2026_10_02.md R-3).
1524 if let (Some(in_account), Some(obj)) = (target_in_account, body.as_object_mut()) {
1525 obj.insert("target_in_account".to_string(), serde_json::Value::Bool(in_account));
1526 }
1527 Some(body)
1528 }
1529 crate::muxbus::relay::RelayOutcome::Failed(e) => {
1530 tracing::warn!(
1531 target = %req.target_agent,
1532 error = %e,
1533 signed = carried.is_some(),
1534 unsigned_reason,
1535 "cloud relay failed"
1536 );
1537 None
1538 }
1539 }
1540}
1541
1542pub(super) async fn handle_reactive_agents(
1543 State(state): State<AppState>,
1544) -> Json<serde_json::Value> {
1545 let agents = state.reactive_handler.list_agents();
1546 Json(serde_json::to_value(&agents).unwrap_or(json!([])))
1547}
1548
1549/// Agent NAMES only, for LAN peers: not [`handle_reactive_agents`], whose
1550/// full `AgentRegistration` records (`block_id`, `tab_id`, `registration_nonce`,
1551/// ...) are routing details that stay behind full auth.
1552///
1553/// Reachable with the broadcast `lan_key` (`lan_or_full_auth_middleware`), so
1554/// its output is public to anyone on the local network. **Do not extend it**
1555/// without the owner's decision. Each field so far had one:
1556/// - `agents`, enumerating names (2026-09-08): a `lan_key` holder could
1557/// already confirm a specific name via `/agentmux/reactive/agent?id=…`.
1558/// - `agent_kinds` (`{name: "host" | "container"}`, 2026-10-06, agentmux-mobile's
1559/// SPEC_FLEET_HOST_TAGS_AND_CLOUD_HOSTS_2026_10_06 §5.2, §11).
1560/// - `agent_status` (`{name: {state, since_ms}}`) and `now_ms`, decision D1 of
1561/// 2026-10-07 (agentmux-mobile's SPEC_AGENT_STATUS_AND_LIVE_PANE_FEED_2026_10_07
1562/// §3.4, §11), also on the fleet feed this route is the fallback for.
1563/// - `agent_lines` (`{name: line}`, 2026-10-13): content, each agent's Swarm
1564/// summary line (`backend::agent_line`); absent with `swarm:sharesummaries` off.
1565///
1566/// An agent with no kind, state or line is left out of that map.
1567pub(super) async fn handle_reactive_agent_names(
1568 State(state): State<AppState>,
1569) -> Json<serde_json::Value> {
1570 let agents = state.reactive_handler.list_agents();
1571 let kinds = crate::backend::fleet_source::kinds_by_name(
1572 agents.iter().map(|a| (a.agent_id.as_str(), a.block_id.as_str())),
1573 |block_id| crate::backend::operator_config_seed::agent_kind_of_block(&state.mstore, block_id),
1574 );
1575 let status: std::collections::BTreeMap<&str, _> = agents
1576 .iter()
1577 .filter_map(|a| {
1578 crate::backend::agent_state::agent_status_of_block(&state.mstore, &a.block_id)
1579 .map(|s| (a.agent_id.as_str(), s))
1580 })
1581 .collect();
1582 let status = serde_json::to_value(status).unwrap_or_else(|_| json!({}));
1583 let names: Vec<String> = agents.iter().map(|a| a.agent_id.clone()).collect();
1584 let mut body = json!({
1585 "agents": names,
1586 "agent_kinds": kinds,
1587 "now_ms": agentmux_common::time::now_ms_u64(),
1588 "agent_status": status,
1589 });
1590 let settings = state.config_watcher.get_full_config();
1591 let pairs = agents.iter().map(|a| (a.agent_id.as_str(), a.block_id.as_str()));
1592 if let Some(lines) = crate::backend::agent_line::lines_by_name(&state.mstore, &settings.settings, pairs) {
1593 body["agent_lines"] = json!(lines);
1594 }
1595 Json(body)
1596}
1597
1598#[derive(serde::Deserialize)]
1599pub(super) struct AgentQuery {
1600 id: Option<String>,
1601}
1602
1603pub(super) async fn handle_reactive_agent(
1604 State(state): State<AppState>,
1605 Query(params): Query<AgentQuery>,
1606) -> Response {
1607 let id = match ¶ms.id {
1608 Some(id) if !id.is_empty() => id.as_str(),
1609 _ => {
1610 return (
1611 StatusCode::BAD_REQUEST,
1612 Json(json!({"error": "missing id param"})),
1613 )
1614 .into_response()
1615 }
1616 };
1617 match state.reactive_handler.get_agent(id) {
1618 Some(agent) => {
1619 // Merged in, not part of AgentRegistration's own serialization —
1620 // this is the LAN pubkey-lookup half of
1621 // docs/specs/SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.2. The
1622 // public half of an Ed25519 keypair is not secret; handing it to
1623 // whichever peer already holds a valid lan_key (this whole route
1624 // is gated by lan_or_full_auth_middleware) costs nothing and
1625 // lets that peer verify this agent's future outgoing LAN
1626 // signatures. `None` when the agent has no LAN key minted yet
1627 // (never sent a LAN jekt since this shipped) — omitted from the
1628 // JSON entirely rather than rendered `null`, so existing callers
1629 // of this endpoint that don't know about this field see no
1630 // change in shape.
1631 let mut value = serde_json::to_value(&agent).unwrap_or_default();
1632 if let Ok(Some(pubkey)) = state.mstore.agent_lan_public_key_load(id) {
1633 if let Some(obj) = value.as_object_mut() {
1634 obj.insert("lan_public_key".to_string(), json!(pubkey));
1635 }
1636 }
1637 // Identity M4d-5: the registration's UID key, separately; the
1638 // name-keyed key above stays the query id's (spec §6.5.10).
1639 if let Some(Ok(Some(pubkey))) = agent.uid.as_deref().map(|uid| state.mstore.agent_uid_lan_public_key_load(uid)) {
1640 if let Some(obj) = value.as_object_mut() {
1641 obj.insert("uid_public_key".to_string(), json!(pubkey));
1642 }
1643 }
1644 Json(value).into_response()
1645 }
1646 None => (
1647 StatusCode::NOT_FOUND,
1648 Json(json!({"error": "agent not found"})),
1649 )
1650 .into_response(),
1651 }
1652}
1653
1654#[derive(serde::Deserialize)]
1655pub(super) struct AuditQuery {
1656 #[serde(default = "default_audit_limit")]
1657 limit: usize,
1658}
1659fn default_audit_limit() -> usize {
1660 100
1661}
1662
1663pub(super) async fn handle_reactive_audit(
1664 State(state): State<AppState>,
1665 Query(params): Query<AuditQuery>,
1666) -> Json<serde_json::Value> {
1667 let log = state.reactive_handler.get_audit_log(params.limit);
1668 Json(serde_json::to_value(&log).unwrap_or(json!([])))
1669}
1670
1671#[derive(serde::Deserialize)]
1672pub(super) struct RegisterRequest {
1673 agent_id: String,
1674 block_id: String,
1675 tab_id: Option<String>,
1676}
1677
1678pub(super) async fn handle_reactive_register(
1679 State(state): State<AppState>,
1680 Json(mut req): Json<RegisterRequest>,
1681) -> Response {
1682 req.agent_id = crate::backend::reactive::sanitize::route_name(&req.agent_id).unwrap_or_default();
1683 tracing::info!(agent_id = %req.agent_id, block_id = %req.block_id, "reactive register request");
1684 // Identity M2 (spec §4.4.1 row 3): this is a presence signal keyed by
1685 // block. The name the frontend sends, as its route name (above), becomes
1686 // the display binding (and the cross-instance registries' name), but
1687 // identity is NOT taken from it — it may be a stale meta value or an
1688 // id the agent process itself emitted over OSC. The UID comes from the
1689 // row the server created for this block, read on `spawn_blocking` like
1690 // every other store read in an async handler (incident #1782).
1691 let uid = {
1692 let mstore = state.mstore.clone();
1693 let block_id = req.block_id.clone();
1694 tokio::task::spawn_blocking(move || crate::backend::agent_resolve::uid_for_block(&mstore, &block_id))
1695 .await
1696 .unwrap_or_else(|e| {
1697 tracing::warn!(error = %e, "reactive register: uid lookup task failed — registering by name");
1698 None
1699 })
1700 };
1701 // One live instance per agent (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24
1702 // Phase 1, I9): a pane whose agent runs in another AgentMux instance on
1703 // this host must not register it — the cross-instance registries written
1704 // below are how other senders route this agent's jekts, and delivery
1705 // prefers the freshest entry, so registering here would pull the live
1706 // instance's messages to a pane that is refused its turns. The frontend
1707 // only logs a failed registration; it does not retry.
1708 if let Some(uid) = uid.as_deref() {
1709 if let Err(refusal) = crate::backend::agent_admission::check_before_spawn(
1710 crate::backend::agent_admission::lease_store_for(state.mstore.shared_agent_registry()),
1711 uid,
1712 &req.agent_id,
1713 &state.boot_id,
1714 )
1715 .await
1716 {
1717 tracing::warn!(
1718 agent_id = %req.agent_id,
1719 block_id = %req.block_id,
1720 uid,
1721 "reactive register refused: agent is live in another AgentMux instance on this host"
1722 );
1723 return (
1724 StatusCode::CONFLICT,
1725 Json(json!({ "success": false, "error": refusal })),
1726 )
1727 .into_response();
1728 }
1729 }
1730 match state.reactive_handler.register_agent_full(
1731 &req.agent_id,
1732 &req.block_id,
1733 req.tab_id.as_deref(),
1734 0,
1735 None,
1736 uid.as_deref(),
1737 "registration.no_uid.http_register",
1738 ) {
1739 Ok(()) => {
1740 // Refresh the block's OWN captured identity too (reagentx P1 on
1741 // #2697): this HTTP path can (re-)register an existing block_id
1742 // under a different agent_id (a rename, or a reconfigured
1743 // cmd:env) — without this, the controller's `agent_id()` stays
1744 // stale at whatever it was captured as at spawn time, and
1745 // `inject_message_inner`'s recipient-identity check (#2695)
1746 // would then falsely reject the agent's own, correctly-addressed
1747 // messages as a mismatch. `get_controller` returning `None`
1748 // (block not tracked, or a controller type that doesn't
1749 // implement `agent_id()`/`set_agent_id()`) is a harmless no-op.
1750 if let Some(ctrl) = blockcontroller::get_controller(&req.block_id) {
1751 ctrl.set_agent_id(Some(req.agent_id.clone()));
1752 }
1753
1754 // Also write to cross-instance file registry so other AgentMux
1755 // instances can forward inject requests to this one.
1756 let data_dir = base::get_mux_data_dir();
1757 agent_registry::write(&data_dir, &req.agent_id, &state.local_web_url, &req.block_id);
1758
1759 // And to the host-global shared registry (Tier 2b) so instances
1760 // running in OTHER channels on this host can reach this agent
1761 // too — closes issue #1916 (Tier 2 previously only ever reached
1762 // the caller's own channel).
1763 agent_registry::write_shared_from_env(
1764 &req.agent_id,
1765 &state.local_web_url,
1766 &req.block_id,
1767 );
1768
1769 // Auto-watch this agent's Claude Code config dir for subagent JSONL files.
1770 // Pass block_id so subagent events are stamped with the owning pane,
1771 // letting the frontend route ⚡ panels to that pane only. See
1772 // `resolve_claude_config_dir`'s doc comment for why this must read
1773 // the block's own `cmd:env`, not just guess a path convention.
1774 let block = state.mstore.get::<crate::backend::obj::Block>(&req.block_id).ok().flatten();
1775 let empty_meta = crate::backend::obj::MetaMapType::new();
1776 // Identity-bound agents' real CLAUDE_CONFIG_DIR is never the
1777 // stale `cmd:env` snapshot below — see
1778 // `resolve_claude_config_dir`'s doc comment and
1779 // SPEC_SUBAGENT_WATCHER_IDENTITY_BOUND_CONFIG_DIR_2026_08_22.md.
1780 let bound_dir = crate::identity::resolver::resolve_bound_oauth_config_dir(
1781 &state.mstore,
1782 &state.id_store,
1783 &state.identity_store,
1784 &req.block_id,
1785 );
1786 let config_dir = subagent_watcher::resolve_claude_config_dir(
1787 block.as_ref().map(|b| &b.meta).unwrap_or(&empty_meta),
1788 &req.agent_id,
1789 bound_dir,
1790 );
1791 if let Some(config_dir) = config_dir {
1792 state.subagent_watcher.watch_agent(&req.agent_id, &req.block_id, config_dir.clone());
1793
1794 // Codex P1 on PR #2980: the resolution above and the
1795 // `watch_agent` call are not atomic with an
1796 // `agent_identity_link` write landing on a different
1797 // thread — a bind can commit in the narrow window between
1798 // computing `config_dir` and `watch_agent` actually
1799 // installing the watch. If that happens, the bind's own
1800 // `recheck_all_watched_agents` call can run and find
1801 // nothing yet in `watched_agents` to correct, and no LATER
1802 // bind event is guaranteed to retrigger it. Immediately
1803 // re-resolving fresh right here — after the install has
1804 // definitely committed — closes that window: if the bind
1805 // already landed, this catches it on the spot instead of
1806 // depending on an external trigger that might never come
1807 // again. Safe/cheap to do unconditionally: `recheck_config_dir`
1808 // is a same-ref no-op when nothing actually changed. Scoped
1809 // to THIS call site specifically (not inside `watch_agent`
1810 // itself) because this is the one place `config_dir` is
1811 // actually derived from identity/binding resolution in the
1812 // first place — see `watch_agent`'s own doc comment for why
1813 // baking this into the shared primitive broke its other
1814 // callers.
1815 let fresh_bound_dir = crate::identity::resolver::resolve_bound_oauth_config_dir(
1816 &state.mstore,
1817 &state.id_store,
1818 &state.identity_store,
1819 &req.block_id,
1820 );
1821 if let Some(fresh_dir) = subagent_watcher::resolve_claude_config_dir(
1822 block.as_ref().map(|b| &b.meta).unwrap_or(&empty_meta),
1823 &req.agent_id,
1824 fresh_bound_dir,
1825 ) {
1826 state.subagent_watcher.recheck_config_dir(&req.agent_id, fresh_dir);
1827 }
1828
1829 // If this block already has a persisted session id, it's
1830 // resuming a prior conversation (not starting fresh) —
1831 // backfill just THAT session's own subagents, so a
1832 // reopened pane shows what it already had without
1833 // flooding in every OTHER session this agent identity has
1834 // ever run. A brand-new session has nothing to backfill;
1835 // watch_agent's live watcher picks up subagents as the
1836 // Task tool spawns them.
1837 let session_id = block.as_ref().map(|b| {
1838 crate::backend::obj::meta_get_string(
1839 &b.meta,
1840 crate::backend::blockcontroller::core::META_SESSION_ID,
1841 "",
1842 )
1843 }).unwrap_or_default();
1844 if !session_id.is_empty() {
1845 state.subagent_watcher.scan_session_subagents(
1846 &req.agent_id,
1847 &req.block_id,
1848 &config_dir,
1849 &session_id,
1850 );
1851 }
1852 }
1853
1854 // Subscribe the display and stable names; cloud senders address the stable one.
1855 if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
1856 state.reactive_handler.names_for_block(&req.block_id).iter().for_each(|n| sub.add_agent(n));
1857 }
1858
1859 // Notify the Swarm view so it calls AgentTrackedBlocksCommand and
1860 // shows this pane. We use a dedicated event name so useProcessCount
1861 // (which subscribes to agent:process-added / agent:process-exited)
1862 // doesn't treat this as a phantom OS process and show a spurious ⚙ N
1863 // badge or trigger the kill-tree modal on pane close.
1864 state.broker.publish_event::<crate::backend::mps_events::AgentReactiveRegistered>(&crate::backend::mps_events::BlockRefEvent { block_id: req.block_id.clone() }, vec![format!("block:{}", req.block_id)], 0);
1865
1866 Json(json!({"success": true})).into_response()
1867 }
1868 Err(e) => (
1869 StatusCode::BAD_REQUEST,
1870 Json(json!({"error": e})),
1871 )
1872 .into_response(),
1873 }
1874}
1875
1876#[derive(serde::Deserialize)]
1877pub(super) struct EnsureSigningKeyRequest {
1878 agent_id: String,
1879}
1880
1881/// Mint-or-reuse `agent_id`'s host-tier jekt signing key and return it,
1882/// base64-encoded — the same value `agent_config::inject_jekt_signing_keys_into_mcp_json`
1883/// writes into a real agent's `.mcp.json` at spawn time, exposed directly
1884/// here instead of only reachable through `agent.open`'s WebSocket
1885/// (`WshRpcEngine`) path. That path also spawns a real provider session,
1886/// which makes it unusable for anything that just wants to exercise
1887/// host-tier jekt signing/verification (tests, external harnesses) without
1888/// the cost and side effects of a live LLM session.
1889///
1890/// reagentx P0 on this PR's first pass: gating this behind the same
1891/// `X-AuthKey` as `register`/`unregister` is NOT the same trust level as
1892/// those routes — `X-AuthKey`/`AGENTMUX_AUTH_KEY` is injected into every
1893/// spawned agent's own subprocess env for its bashwrap/MCP tool calls
1894/// (`agent_handlers/input.rs`), so any agent already holds it. Returning
1895/// another agent's raw signing key to any caller with that shared key is
1896/// secret-key exfiltration enabling cryptographic impersonation — it
1897/// directly breaks the invariant `agent_jekt_keys.rs` documents ("never
1898/// returned over any RPC ... only the agent it claims to be from ever held
1899/// the key") and the entire premise `TRUST=host-verified` depends on.
1900///
1901/// There is no way to additionally verify "the HTTP caller genuinely IS
1902/// `agent_id`" from this request alone — that's the exact problem host-tier
1903/// signing exists to solve, so this endpoint can't lean on it without being
1904/// circular. Instead: **disabled unless `AGENTMUX_ENABLE_TEST_ENDPOINTS=1`
1905/// was set in the srv process's own environment before it started** — set
1906/// by whoever launches the instance, not settable by a running agent's tool
1907/// calls (unlike `X-AuthKey`, which every spawned agent already holds
1908/// regardless of what's set here). Off by default, so no real user's real
1909/// instance with real agents ever exposes it; on only for a deliberately
1910/// isolated test/verification instance where every "agent" is synthetic.
1911pub(super) async fn handle_reactive_ensure_signing_key(
1912 State(state): State<AppState>,
1913 Json(req): Json<EnsureSigningKeyRequest>,
1914) -> Response {
1915 if std::env::var("AGENTMUX_ENABLE_TEST_ENDPOINTS").as_deref() != Ok("1") {
1916 return (
1917 StatusCode::NOT_FOUND,
1918 Json(json!({"error": "not found"})),
1919 )
1920 .into_response();
1921 }
1922 match state.mstore.agent_jekt_key_ensure(&req.agent_id) {
1923 Ok(key) => {
1924 use base64::Engine as _;
1925 let key_b64 = base64::engine::general_purpose::STANDARD.encode(&key);
1926 Json(json!({ "agent_id": req.agent_id, "jekt_key_b64": key_b64 })).into_response()
1927 }
1928 Err(e) => (
1929 StatusCode::INTERNAL_SERVER_ERROR,
1930 Json(json!({"error": e.to_string()})),
1931 )
1932 .into_response(),
1933 }
1934}
1935
1936#[derive(serde::Deserialize)]
1937pub(super) struct UnregisterRequest {
1938 agent_id: String,
1939 /// Identity M2 (spec §4.4.4 Q7): optional, but both frontend callers
1940 /// send it. With it, teardown is by block. Without it, a name held by
1941 /// several live blocks is refused with HTTP 409 and the candidates —
1942 /// never a `success: true` that tore down nothing (§10 constraint 3).
1943 #[serde(default)]
1944 block_id: String,
1945}
1946
1947pub(super) async fn handle_reactive_unregister(
1948 State(state): State<AppState>,
1949 Json(req): Json<UnregisterRequest>,
1950) -> Response {
1951 use crate::backend::reactive::handler::UnregisterOutcome;
1952
1953 let (block_id, names): (Option<String>, Vec<String>) = if !req.block_id.trim().is_empty() {
1954 let names = state.reactive_handler.unregister_block(req.block_id.trim());
1955 (Some(req.block_id.trim().to_string()), names)
1956 } else {
1957 match state.reactive_handler.unregister_agent(&req.agent_id) {
1958 UnregisterOutcome::Removed { block_id, names } => (Some(block_id), names),
1959 UnregisterOutcome::NotFound => (None, Vec::new()),
1960 UnregisterOutcome::Ambiguous(candidates) => {
1961 let candidates: Vec<serde_json::Value> = candidates
1962 .iter()
1963 .map(
1964 |c| json!({ "uid": c.uid, "block_id": c.block_id, "agent_id": c.agent_id }),
1965 )
1966 .collect();
1967 return (
1968 StatusCode::CONFLICT,
1969 Json(json!({
1970 "success": false,
1971 "error": format!(
1972 "ambiguous agent name: '{}' is held by {} live agents; pass block_id",
1973 req.agent_id,
1974 candidates.len()
1975 ),
1976 "candidates": candidates,
1977 })),
1978 )
1979 .into_response();
1980 }
1981 }
1982 };
1983
1984 // Name-keyed side state is torn down for EVERY name the block held
1985 // (display and stable). The name the caller asked by is used only when
1986 // no block was found at all (the pre-M2 behaviour for an unknown name):
1987 // when a block WAS torn down, its own bindings are authoritative — the
1988 // caller's name may be a stale display value that another live block
1989 // now legitimately holds, and removing that block's file entry and cloud
1990 // subscription would knock it off cross-instance routing.
1991 let mut all_names = names.clone();
1992 if block_id.is_none() {
1993 all_names.push(req.agent_id.clone());
1994 }
1995 let data_dir = base::get_mux_data_dir();
1996 for name in &all_names {
1997 // Also remove from cross-instance file registry.
1998 agent_registry::remove(&data_dir, name);
1999 // And from the host-global shared registry (Tier 2b).
2000 agent_registry::remove_shared_from_env(name);
2001 // Drop the subagent filesystem watcher (handle + channel + task) — the
2002 // symmetric teardown for the watch_agent() call in the register handler.
2003 // Passes block_id so a shared-agent-id watcher with another still-open
2004 // dependent block survives this one's teardown.
2005 state.subagent_watcher.unwatch_agent(name, block_id.as_deref());
2006 // Notify cloud subscriber so it stops subscribing for this agent
2007 if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
2008 sub.remove_agent(name);
2009 }
2010 }
2011
2012 // Symmetric refresh: tell the Swarm view this pane is gone.
2013 if let Some(bid) = block_id {
2014 state.broker.publish_event::<crate::backend::mps_events::AgentReactiveUnregistered>(&crate::backend::mps_events::BlockRefEvent { block_id: bid.to_string() }, vec![format!("block:{}", bid)], 0);
2015 }
2016
2017 Json(json!({"success": true})).into_response()
2018}
2019
2020pub(super) async fn handle_reactive_poller_stats(
2021 State(state): State<AppState>,
2022) -> Json<serde_json::Value> {
2023 let stats = state.poller.stats();
2024 Json(serde_json::to_value(&stats).unwrap_or(json!({})))
2025}
2026
2027#[derive(serde::Deserialize)]
2028pub(super) struct PollerConfigRequest {
2029 url: Option<String>,
2030 token: Option<String>,
2031}
2032
2033pub(super) async fn handle_reactive_poller_config(
2034 State(state): State<AppState>,
2035 Json(req): Json<PollerConfigRequest>,
2036) -> Json<serde_json::Value> {
2037 state.poller.reconfigure(req.url, req.token);
2038 Json(json!({"success": true}))
2039}
2040
2041pub(super) async fn handle_reactive_poller_status(
2042 State(state): State<AppState>,
2043) -> Json<serde_json::Value> {
2044 let status = state.poller.status();
2045 Json(serde_json::to_value(&status).unwrap_or(json!({})))
2046}
2047
2048/// Server-side ceiling on `max_lines` — protects a Supervisor's transcript
2049/// pull (and this route in general) from an unbounded read of a huge
2050/// session file. Callers wanting more must paginate some other way; this
2051/// route is a "recent tail" primitive, not a full-history export.
2052const TRANSCRIPT_MAX_LINES_CAP: usize = 500;
2053
2054#[derive(serde::Deserialize)]
2055pub(super) struct TranscriptQuery {
2056 agent: String,
2057 #[serde(default = "default_transcript_max_lines")]
2058 max_lines: usize,
2059 /// Set ONLY by [`handle_reactive_transcript_cross_channel`] on the
2060 /// single forwarded request it ever sends — caps cross-channel
2061 /// resolution at exactly one hop. Without this, a stale-but-PID-alive
2062 /// shared-registry entry (pointing back at this same instance, or at a
2063 /// second instance whose own entry for the same agent points back
2064 /// here) would forward indefinitely — the exact failure mode
2065 /// `handle_reactive_inject`'s `MAX_FORWARD_HOPS`/`forward_hops` guard
2066 /// exists to prevent for jekt delivery (reagent P1, codex P1 on
2067 /// PR #2715). A bare bool is sufficient here (unlike inject's integer
2068 /// hop counter) because this route is architecturally single-hop by
2069 /// design — the owning instance found via `lookup_all_shared` always
2070 /// has the agent on ITS OWN host tier, never a further cross-channel
2071 /// hop of its own — so "already forwarded once" and "hop limit
2072 /// reached" are the same condition.
2073 #[serde(default)]
2074 forwarded: bool,
2075}
2076fn default_transcript_max_lines() -> usize {
2077 100
2078}
2079
2080/// `GET /agentmux/reactive/transcript?agent=<name>&max_lines=<n>` — read the
2081/// tail of a registered agent's session output, for a Warden Supervisor
2082/// watcher agent to inspect on its own poll interval (v1 is pull/poll, not
2083/// push — see
2084/// docs/analysis/ANALYSIS_WARDEN_AUTO_CONTROLLER_CONTINUATION_WATCHER_2026_08_12.md).
2085///
2086/// As of `SPEC_MUXSPECT_CROSS_TIER_CONVERSATION_VISIBILITY_2026_08_21.md`
2087/// Phase A, a miss on this instance's own host-tier registry falls back to
2088/// the host-global cross-channel shared registry and forwards a single-hop
2089/// HTTP GET to the owning channel's own instance — same auth
2090/// (`entry.auth_key` as `X-AuthKey`) and loopback-only pattern
2091/// `handle_reactive_inject`'s Tier 2b already uses (see that handler for
2092/// the security rationale). Response carries `"tier"` so callers can tell
2093/// which tier answered.
2094pub(super) async fn handle_reactive_transcript(
2095 State(state): State<AppState>,
2096 Query(params): Query<TranscriptQuery>,
2097) -> Response {
2098 if params.agent.is_empty() {
2099 return (
2100 StatusCode::BAD_REQUEST,
2101 Json(json!({"error": "missing agent param"})),
2102 )
2103 .into_response();
2104 }
2105
2106 let reg = match state.reactive_handler.lookup_by_name(¶ms.agent) {
2107 crate::backend::reactive::handler::LookupOutcome::One(reg) => reg,
2108 // Identity M2 (spec §4.4.3): a name several live blocks hold is a
2109 // purely LOCAL collision — refuse it with the candidates, never
2110 // fall through to cross-channel forwarding as if it were unknown.
2111 crate::backend::reactive::handler::LookupOutcome::Ambiguous(candidates) => {
2112 let candidates: Vec<serde_json::Value> = candidates
2113 .iter()
2114 .map(|c| json!({ "uid": c.uid, "block_id": c.block_id, "agent_id": c.agent_id }))
2115 .collect();
2116 return (
2117 StatusCode::CONFLICT,
2118 Json(json!({
2119 "error": format!(
2120 "ambiguous agent name: '{}' is held by {} live agents; address one by uid",
2121 params.agent,
2122 candidates.len()
2123 ),
2124 "candidates": candidates,
2125 })),
2126 )
2127 .into_response();
2128 }
2129 crate::backend::reactive::handler::LookupOutcome::NotFound => {
2130 if params.forwarded {
2131 // Already one hop in — see TranscriptQuery::forwarded's doc
2132 // comment. The owning instance's own host-tier lookup just
2133 // missed too, so this agent genuinely isn't registered
2134 // anywhere reachable; 404, do not attempt a second forward.
2135 return (
2136 StatusCode::NOT_FOUND,
2137 Json(json!({"error": "agent not found"})),
2138 )
2139 .into_response();
2140 }
2141 return handle_reactive_transcript_cross_channel(&state, ¶ms).await;
2142 }
2143 };
2144 let block_id = reg.block_id.clone();
2145
2146 let (raw_bytes, _total_line_count) = match crate::backend::session_archive::read_session_output(
2147 &state.mstore,
2148 &state.filestore,
2149 &block_id,
2150 ) {
2151 Ok(v) => v,
2152 Err(e) => {
2153 return (
2154 StatusCode::INTERNAL_SERVER_ERROR,
2155 Json(json!({"error": format!("read_session_output: {e}")})),
2156 )
2157 .into_response();
2158 }
2159 };
2160
2161 let text = String::from_utf8_lossy(&raw_bytes);
2162 let all_lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
2163 let requested = params.max_lines.min(TRANSCRIPT_MAX_LINES_CAP).max(1);
2164 let truncated = all_lines.len() > requested;
2165 let lines: Vec<String> = all_lines
2166 .iter()
2167 .rev()
2168 .take(requested)
2169 .rev()
2170 .map(|l| l.to_string())
2171 .collect();
2172
2173 let turn_active = crate::backend::blockcontroller::get_block_controller_status(&block_id)
2174 .map(|s| s.turn_active)
2175 .unwrap_or(false);
2176
2177 Json(json!({
2178 "agent": reg.agent_id,
2179 "block_id": block_id,
2180 "tier": "host",
2181 "turn_active": turn_active,
2182 "lines": lines,
2183 "truncated": truncated,
2184 }))
2185 .into_response()
2186}
2187
2188/// Fallback path for [`handle_reactive_transcript`] when the target agent
2189/// isn't on this instance's own host-tier registry — checks the host-global
2190/// cross-channel shared registry and, on a hit, forwards to the owning
2191/// channel's own instance. A miss here (not found on this channel OR any
2192/// other channel on this host) 404s exactly as the host-only lookup always
2193/// did — this does not reach LAN or WAN (Phase A scope; see spec Phase B/C).
2194async fn handle_reactive_transcript_cross_channel(
2195 state: &AppState,
2196 params: &TranscriptQuery,
2197) -> Response {
2198 let not_found = || {
2199 (
2200 StatusCode::NOT_FOUND,
2201 Json(json!({"error": "agent not found"})),
2202 )
2203 .into_response()
2204 };
2205
2206 let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() else {
2207 return not_found();
2208 };
2209 // Skip any entry pointing back at THIS instance before picking one —
2210 // a stale-but-PID-alive self-registration (the registration race /
2211 // incomplete-cleanup case codex flagged on PR #2715) would otherwise
2212 // forward a request to ourselves, which re-enters this exact function
2213 // and repeats. Filtering here (not just checking the single freshest
2214 // pick) also handles a self-entry merely being the FRESHEST of several
2215 // candidates — same defense-in-depth `handle_reactive_inject`'s Tier 2b
2216 // applies. Combined with `TranscriptQuery::forwarded` above (which
2217 // still caps this at one hop even in an exotic multi-instance cycle
2218 // this filter alone wouldn't catch — e.g. instance A's entry points to
2219 // B and B's own entry for the same agent points back to A).
2220 let Some(entry) = crate::backend::reactive::registry::lookup_all_shared(&shared_dir, ¶ms.agent)
2221 .into_iter()
2222 .find(|e| !is_self_registration(&e.local_url, &state.local_web_url))
2223 else {
2224 return not_found();
2225 };
2226
2227 let query: Vec<(&str, String)> = vec![
2228 ("agent", params.agent.clone()),
2229 ("max_lines", params.max_lines.to_string()),
2230 ("forwarded", "true".to_string()),
2231 ];
2232 let resp = state
2233 .http_client
2234 .get(format!("{}/agentmux/reactive/transcript", entry.local_url))
2235 .header(AUTH_KEY_HEADER, &entry.auth_key)
2236 .query(&query)
2237 .send()
2238 .await;
2239
2240 let Ok(resp) = resp else {
2241 return not_found();
2242 };
2243 if !resp.status().is_success() {
2244 return not_found();
2245 }
2246 let Ok(mut body) = resp.json::<serde_json::Value>().await else {
2247 return not_found();
2248 };
2249 if let Some(obj) = body.as_object_mut() {
2250 obj.insert("tier".to_string(), json!("cross-channel"));
2251 obj.insert("channel".to_string(), json!(entry.channel));
2252 }
2253 Json(body).into_response()
2254}
2255
2256#[cfg(test)]
2257#[path = "tests/reactive/transcript_cross_channel_tests.rs"]
2258mod transcript_cross_channel_tests;
2259
2260#[derive(serde::Deserialize)]
2261pub(super) struct SupervisorDecisionRequest {
2262 target_agent: String,
2263 /// "nudge" | "decline".
2264 action: String,
2265 #[serde(default)]
2266 reason: Option<String>,
2267 #[serde(default)]
2268 request_id: Option<String>,
2269 /// The calling Supervisor agent's own identity — same shape as
2270 /// `InjectRequest::source_agent` (`SendMessage`/`Loop`). `None` for
2271 /// callers that omit it (e.g. cron-driven).
2272 #[serde(default)]
2273 source_agent: Option<String>,
2274}
2275
2276/// Default/cap for how many of an agent's sessions a single search may open.
2277///
2278/// Full-parsing every session of a long-lived agent is not viable: the session
2279/// that motivated `SPEC_AGENT_HISTORY_SEARCH_2026_09_17.md` was 14.7 MB /
2280/// 6682 records and was one of several. Candidates are taken newest-first, so
2281/// the default answers the common "what did I do recently" question without
2282/// reading years of history.
2283const HISTORY_SEARCH_DEFAULT_MAX_SESSIONS: usize = 20;
2284const HISTORY_SEARCH_MAX_SESSIONS_CAP: usize = 100;
2285const HISTORY_SEARCH_DEFAULT_LIMIT: usize = 50;
2286const HISTORY_SEARCH_LIMIT_CAP: usize = 200;
2287
2288/// SearchHistory's answer to a request that carries no per-agent token.
2289const HISTORY_SEARCH_NEEDS_IDENTITY: &str = "history.search: this request carries no agent identity \
2290 (X-Agent-Token), so whose history to search is unknown. Every agent AgentMux spawns carries \
2291 one; a process started some other way, or an agent from before identity tokens, must be \
2292 reopened from AgentMux.";
2293
2294fn default_history_max_sessions() -> usize {
2295 HISTORY_SEARCH_DEFAULT_MAX_SESSIONS
2296}
2297fn default_history_limit() -> usize {
2298 HISTORY_SEARCH_DEFAULT_LIMIT
2299}
2300
2301#[derive(serde::Deserialize)]
2302pub(super) struct HistorySearchQuery {
2303 /// The caller's name, as the MCP sends it. **Never whose history is
2304 /// searched**: that is the row of the caller's per-agent token
2305 /// (`X-Agent-Token`, identity M4c-2c,
2306 /// SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md §6.5.9), and a
2307 /// request without one is refused. A name is self-declared — every local
2308 /// agent shares the instance `auth_key` — so resolving the owner from it
2309 /// could only guess, and a guess answered "no history" with confidence.
2310 /// Kept for the actor counters (M4a-2).
2311 #[serde(default)]
2312 agent: String,
2313 query: String,
2314 #[serde(default)]
2315 role: Option<String>,
2316 #[serde(default)]
2317 tool: Option<String>,
2318 #[serde(default)]
2319 since: Option<i64>,
2320 #[serde(default)]
2321 until: Option<i64>,
2322 #[serde(default = "default_history_max_sessions")]
2323 max_sessions: usize,
2324 #[serde(default = "default_history_limit")]
2325 limit: usize,
2326 /// Also search sessions found only by working directory or account,
2327 /// which the agent's own record doesn't name.
2328 #[serde(default)]
2329 include_inferred: bool,
2330}
2331
2332/// `GET /agentmux/reactive/history/search` — search an agent's own past
2333/// conversations (`SPEC_AGENT_HISTORY_SEARCH_2026_09_17.md`).
2334///
2335/// Distinct from `/reactive/transcript`, which returns the tail of the LIVE
2336/// session only. This reads sessions already persisted on disk, including
2337/// sessions that ended — which is the point: an agent's memory of what it did
2338/// is bounded by its context window while its actual actions are not, so after
2339/// a compaction or session reset it will answer questions about its own past
2340/// confidently and wrongly, with nothing marking the boundary.
2341pub(super) async fn handle_reactive_history_search(
2342 State(state): State<AppState>,
2343 caller: Option<Extension<super::caller::Caller>>,
2344 Query(params): Query<HistorySearchQuery>,
2345) -> Response {
2346 super::actor::check_actor(
2347 &state,
2348 caller.as_deref(),
2349 super::actor::ActorSite::HistorySearch,
2350 Some(params.agent.as_str()).filter(|a| !a.trim().is_empty()),
2351 );
2352 // An empty query is only meaningful alongside a `tool` filter ("every call
2353 // to X"); on its own it would match everything and return a truncated
2354 // firehose, which reads as a real answer.
2355 if params.query.trim().is_empty() && params.tool.is_none() {
2356 return (
2357 StatusCode::BAD_REQUEST,
2358 Json(json!({"error": "query must be non-empty unless a tool filter is given"})),
2359 )
2360 .into_response();
2361 }
2362 if let Some(role) = params.role.as_deref() {
2363 if role != "user" && role != "assistant" {
2364 return (
2365 StatusCode::BAD_REQUEST,
2366 Json(json!({"error": "role must be 'user' or 'assistant'"})),
2367 )
2368 .into_response();
2369 }
2370 }
2371
2372 let opts = crate::backend::history::index::HistorySearchOptions {
2373 query: params.query.clone(),
2374 role: params.role.clone(),
2375 tool: params.tool.clone(),
2376 limit: params.limit.clamp(1, HISTORY_SEARCH_LIMIT_CAP),
2377 since_ms: params.since.map(crate::backend::history::unix_time_to_ms),
2378 until_ms: params.until.map(crate::backend::history::unix_time_to_ms),
2379 max_sessions: Some(params.max_sessions.clamp(1, HISTORY_SEARCH_MAX_SESSIONS_CAP)),
2380 include_inferred: params.include_inferred,
2381 };
2382
2383 // Identity M4c-2c (spec §6.5.9): the owner is the caller's own row, from
2384 // its per-agent token — never a name. Every agent AgentMux spawns carries
2385 // one; a request without it can't say whose history it is, and resolving
2386 // a self-declared name instead could only guess (on 2026-09-24 a guess
2387 // answered "no history" for an agent whose sessions were on disk).
2388 let Some(uid) = caller.as_deref().and_then(super::caller::Caller::uid) else {
2389 crate::backend::agent_resolve::record_uid_fallback("history.search_refused_unattributed");
2390 return (
2391 StatusCode::FORBIDDEN,
2392 Json(json!({"error": HISTORY_SEARCH_NEEDS_IDENTITY})),
2393 )
2394 .into_response();
2395 };
2396 let row = match super::app_api::SelfOwner::caller_row(uid, &state.mstore) {
2397 Ok(row) => row,
2398 Err(e) => {
2399 // A gone row is the caller's problem; a store fault is ours.
2400 let status = if e.starts_with("store:") {
2401 StatusCode::INTERNAL_SERVER_ERROR
2402 } else {
2403 StatusCode::BAD_REQUEST
2404 };
2405 return (status, Json(json!({"error": format!("history.search: {e}")})))
2406 .into_response();
2407 }
2408 };
2409
2410 // Parsing sessions is blocking filesystem work; keep it off the async
2411 // runtime's worker threads, same posture as other disk-heavy handlers.
2412 let history = state.history_service.clone();
2413 let store = state.identity_store.clone();
2414 let result = tokio::task::spawn_blocking(move || {
2415 let owner = crate::backend::history::HistoryOwner::of_row(&row);
2416 history.search_for_agent(&store, &owner, &opts)
2417 })
2418 .await;
2419
2420 match result {
2421 Ok(Ok(outcome)) => (StatusCode::OK, Json(json!(outcome))).into_response(),
2422 // Retryable, and distinct from a fault: an index still building is
2423 // not an empty history.
2424 Ok(Err(crate::backend::history::HistorySearchError::IndexBuilding)) => (
2425 StatusCode::SERVICE_UNAVAILABLE,
2426 [(axum::http::header::RETRY_AFTER, "30")],
2427 Json(json!({"error": crate::backend::history::HISTORY_INDEX_BUILDING})),
2428 )
2429 .into_response(),
2430 Ok(Err(e)) => (
2431 StatusCode::INTERNAL_SERVER_ERROR,
2432 Json(json!({"error": e.to_string()})),
2433 )
2434 .into_response(),
2435 Err(e) => (
2436 StatusCode::INTERNAL_SERVER_ERROR,
2437 Json(json!({"error": format!("history search task failed: {e}")})),
2438 )
2439 .into_response(),
2440 }
2441}
2442
2443/// `POST /agentmux/reactive/supervisor-decision` — a Warden Supervisor
2444/// watcher agent's decision about a target agent it just polled (see
2445/// `GetAgentTranscript`). `action: "nudge"` delivers a fixed continuation
2446/// message (not caller-supplied text — see `SupervisorAction::Nudge`'s
2447/// doc) to `target_agent` through the same path `SendMessage`/`Loop` use
2448/// and audits it as a Supervisor-originated entry; `action: "decline"`
2449/// sends nothing and just audits the decision. A nudge that would exceed
2450/// the consecutive-nudge ceiling is refused with HTTP 429 — the calling
2451/// agent should treat that as a signal to stop and escalate to a human
2452/// instead of retrying.
2453pub(super) async fn handle_reactive_supervisor_decision(
2454 State(state): State<AppState>,
2455 caller: Option<Extension<super::caller::Caller>>,
2456 Json(req): Json<SupervisorDecisionRequest>,
2457) -> Response {
2458 super::actor::check_actor(
2459 &state,
2460 caller.as_deref(),
2461 super::actor::ActorSite::SupervisorDecision,
2462 req.source_agent.as_deref(),
2463 );
2464 if req.target_agent.is_empty() {
2465 return (
2466 StatusCode::BAD_REQUEST,
2467 Json(json!({"error": "missing target_agent"})),
2468 )
2469 .into_response();
2470 }
2471
2472 let action = match req.action.as_str() {
2473 "nudge" => SupervisorAction::Nudge,
2474 "decline" => SupervisorAction::Decline,
2475 other => {
2476 return (
2477 StatusCode::BAD_REQUEST,
2478 Json(json!({"error": format!("unknown action: {other} (expected \"nudge\" or \"decline\")")})),
2479 )
2480 .into_response();
2481 }
2482 };
2483
2484 // Entitlement gate (reagentx P1 on PR #2557): a Nudge must not deliver
2485 // unless the target has actually opted in via `auto_continue_enabled`.
2486 // `Handler` (backend::reactive) has no `Store` access by design — this
2487 // check belongs at the HTTP boundary where `state.mstore` is available,
2488 // not inside `record_supervisor_decision`. Decline never delivers
2489 // anything, so it isn't gated.
2490 //
2491 // Match on `d.slug`, NOT `d.name` (reagentx P0, round 3 — every
2492 // delivery path keys registration off `AGENTMUX_AGENT_ID`, which
2493 // `agent_open.rs` sets to the agent's stable `slug`, not its
2494 // renameable display `name`. Matching on `name` here let a renamed
2495 // agent's own opt-in go unrecognized, and — worse — let one agent's
2496 // slug collide with an unrelated agent's current display name,
2497 // authorizing a nudge off the wrong definition's flag. Same
2498 // name/slug cross-namespace hazard `storage/agents/mod.rs`'s
2499 // `instance_get_by_name_and_by_slug_never_cross_the_others_namespace`
2500 // regression-tests for the read path.)
2501 if matches!(action, SupervisorAction::Nudge) {
2502 let opted_in = state
2503 .mstore
2504 .agent_def_list()
2505 .ok()
2506 .and_then(|defs| {
2507 defs.into_iter()
2508 .find(|d| d.slug.eq_ignore_ascii_case(&req.target_agent))
2509 })
2510 .map(|d| d.auto_continue_enabled != 0)
2511 .unwrap_or(false);
2512 if !opted_in {
2513 return (
2514 StatusCode::FORBIDDEN,
2515 Json(json!({
2516 "error": format!(
2517 "target agent '{}' has not opted in to auto_continue_enabled",
2518 req.target_agent
2519 )
2520 })),
2521 )
2522 .into_response();
2523 }
2524 }
2525
2526 let reason = req.reason.unwrap_or_default();
2527 let request_id = req
2528 .request_id
2529 .filter(|s| !s.is_empty())
2530 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
2531
2532 match state.reactive_handler.record_supervisor_decision(
2533 &req.target_agent,
2534 action,
2535 &reason,
2536 &request_id,
2537 req.source_agent.as_deref(),
2538 &super::caller::attributed_uid(caller.as_deref()),
2539 ) {
2540 Ok(resp) => Json(serde_json::to_value(&resp).unwrap_or_default()).into_response(),
2541 Err(e) => (
2542 StatusCode::TOO_MANY_REQUESTS,
2543 Json(json!({"error": e})),
2544 )
2545 .into_response(),
2546 }
2547}
2548
2549// ---- WS RPC: reactive.registrations (issue #2696, Stash UI indicator) ----
2550
2551/// Whether a host-global shared-registry entry is THIS instance's own
2552/// registration (as opposed to a genuinely different instance/channel on
2553/// the same host) — every agent unconditionally writes itself into that
2554/// same registry, so a "remote"/"elsewhere" listing must exclude its own
2555/// entry or it fires on every healthy agent (reagentx P1 on #2698). Same
2556/// comparison this file already uses for Tier 2a/2b forwarding.
2557pub(super) fn is_self_registration(entry_local_url: &str, this_instances_local_url: &str) -> bool {
2558 entry_local_url == this_instances_local_url
2559}
2560
2561/// One cross-instance/channel entry from the host-global shared registry
2562/// (`backend::reactive::registry::AgentEntry`), narrowed to what the
2563/// frontend actually needs to render a "registered elsewhere too" badge —
2564/// deliberately excludes `local_url`/`auth_key`, which are internal
2565/// forwarding plumbing, not UI-relevant.
2566#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)]
2567#[ts(export, export_to = "../../../frontend/types/rpc/")]
2568pub(super) struct RemoteRegistrationEntry {
2569 channel: String,
2570 // u32 is < 2^53 so ts-rs maps it to `number` already; only 64-bit
2571 // integers become `bigint`. See updated_at below.
2572 pid: u32,
2573 #[ts(type = "number")]
2574 updated_at: u64,
2575}
2576
2577/// Summary of the most recent `identity-mismatch` audit entry for this
2578/// agent_id, if any (see #2695's `Handler::inject_message_inner` check) —
2579/// narrowed from `AuditLogEntry` to what the Stash badge needs.
2580#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)]
2581#[ts(export, export_to = "../../../frontend/types/rpc/")]
2582pub(super) struct MismatchAuditSummary {
2583 #[ts(type = "number")]
2584 timestamp: u64,
2585 block_id: String,
2586 error_message: Option<String>,
2587}
2588
2589#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)]
2590#[ts(export, export_to = "../../../frontend/types/rpc/")]
2591pub(super) struct ReactiveRegistrationsResult {
2592 /// This instance's own registration for the agent, if any — same data
2593 /// `GET /agentmux/reactive/agent` exposes, reused here so the frontend
2594 /// makes one call instead of two.
2595 local: Option<AgentRegistration>,
2596 /// Every OTHER instance/channel on this host currently claiming this
2597 /// same agent_id, freshest first — a non-empty list here is the actual
2598 /// risk signal the Stash badge exists to surface (issue #2694's root
2599 /// cause was exactly two panes racing to hold the same agent_id).
2600 remote: Vec<RemoteRegistrationEntry>,
2601 recent_mismatch: Option<MismatchAuditSummary>,
2602}
2603
2604#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)]
2605#[ts(export, export_to = "../../../frontend/types/rpc/")]
2606pub(super) struct ReactiveRegistrationsParams {
2607 pub agent_id: String,
2608}
2609
2610/// Registers `reactive.registrations`, called by the Stash "Registration"
2611/// tab (`AgentIdentityLinksPanel`'s sibling — see `frontend/app/store/
2612/// rpc-api/reactive.ts`) to answer "is this agent's jekt identity healthy
2613/// right now": where it's registered locally, whether any OTHER
2614/// instance/channel on this host also claims the same agent_id (the
2615/// collision shape #2694 fixed one specific cause of), and whether a
2616/// recent delivery hit the #2695 identity-mismatch guard.
2617pub fn register_reactive_ws_handlers(engine: &std::sync::Arc<crate::backend::rpc::engine::WshRpcEngine>, state: &AppState) {
2618 let state = state.clone();
2619 // Typed registration (SPEC_RPC_BINDINGS_CODEGEN_2026_09_07.md §3.1): the
2620 // params/result structs carry `#[derive(ts_rs::TS)]`, so the frontend
2621 // consumes generated bindings instead of four hand-written interfaces
2622 // that each said "mirrors agentmux-srv's ...".
2623 engine.register_typed(
2624 "reactive.registrations",
2625 move |params: ReactiveRegistrationsParams, _ctx| {
2626 let state = state.clone();
2627 async move {
2628
2629 let local = state.reactive_handler.get_agent(¶ms.agent_id);
2630
2631 // Every agent (including this instance's own) unconditionally
2632 // writes itself into the same host-global shared registry
2633 // (write_shared_from_env, called from both the PTY-shell and
2634 // persistent auto-register paths and from handle_reactive_
2635 // register above) — so without filtering, `remote` always
2636 // includes THIS instance's own entry alongside any genuinely
2637 // other instance, and the "registered elsewhere too" badge
2638 // would fire on every healthy agent (reagentx P1). Same
2639 // self-filter this file already uses for Tier 2a/2b forwarding
2640 // (`entry.local_url == state.local_web_url` in
2641 // `handle_reactive_inject`).
2642 let remote = crate::registry::resolve_shared_reactive_dir()
2643 .map(|shared_dir| {
2644 agent_registry::lookup_all_shared(&shared_dir, ¶ms.agent_id)
2645 .into_iter()
2646 .filter(|e| !is_self_registration(&e.local_url, &state.local_web_url))
2647 // A crashed sibling instance's entry otherwise
2648 // lingers until the next startup-only
2649 // cleanup_stale_shared sweep (bootstrap/network.rs) —
2650 // up to hours later — showing a false "Also
2651 // registered elsewhere" badge in the meantime
2652 // (reagentx P2). PID-liveness is authoritative
2653 // here (same-host by construction, per
2654 // pid_alive's own doc comment), so check it live
2655 // instead of waiting for that sweep.
2656 .filter(|e| agent_registry::pid_alive(e.pid))
2657 .map(|e| RemoteRegistrationEntry {
2658 channel: e.channel,
2659 pid: e.pid,
2660 updated_at: e.updated_at,
2661 })
2662 .collect::<Vec<_>>()
2663 })
2664 .unwrap_or_default();
2665
2666 let target_lower = params.agent_id.to_lowercase();
2667 let recent_mismatch = state
2668 .reactive_handler
2669 .get_audit_log(100)
2670 .into_iter()
2671 .find(|e| {
2672 e.outcome.as_deref() == Some("identity-mismatch")
2673 && e.target_agent.to_lowercase() == target_lower
2674 })
2675 .map(|e| MismatchAuditSummary {
2676 timestamp: e.timestamp,
2677 block_id: e.block_id,
2678 error_message: e.error_message,
2679 });
2680
2681 let result = ReactiveRegistrationsResult {
2682 local,
2683 remote,
2684 recent_mismatch,
2685 };
2686 Ok(result)
2687 }
2688 },
2689 );
2690}
2691
2692/// `verify_jekt_signature` unit tests (SPEC_JEKT_TRUST_LAYER_COMPLETION_2026_08_13.md
2693/// §2.2, reagentx review on PR #2565). Deliberately test the extracted
2694/// function directly rather than the full `handle_inject`/websocket
2695/// handlers it's now called from: `server::tests::test_state()`'s
2696/// `reactive_handler` is a *global* singleton shared across every test in
2697/// the binary (`backend_reactive::get_global_handler()`), so exercising it
2698/// end-to-end here risks cross-test interference on shared agent
2699/// registrations. `verify_jekt_signature` itself only touches `state.mstore`
2700/// (key lookup), not the handler, so it's safe to test in isolation with no
2701/// such risk — and it's the one piece of logic actually being fixed here;
2702/// the two call sites (messagebus.rs, websocket.rs) are a one-line "call
2703/// this before inject_message" wiring, visible directly in their diffs.
2704#[cfg(test)]
2705#[path = "tests/reactive/verify_jekt_signature_tests.rs"]
2706mod verify_jekt_signature_tests;
2707
2708/// `verify_reagent_signature` unit tests (reagentx P1 on PR #41 —
2709/// `InjectionRequest` declared `reagent_sig`/`reagent_key_id` as
2710/// deserializable input fields but nothing on the HTTP
2711/// `/agentmux/reactive/inject` path ever verified them, so a reagent-signed
2712/// notification delivered through `@agentmuxai/muxbus-client`'s
2713/// `pollAndDeliverInjections` arrived unsigned in effect).
2714#[cfg(test)]
2715#[path = "tests/reactive/verify_reagent_signature_tests.rs"]
2716mod verify_reagent_signature_tests;
2717
2718#[cfg(test)]
2719#[path = "tests/reactive/verify_lan_signature_tests.rs"]
2720mod verify_lan_signature_tests;
2721
2722/// SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md §9 — the unit half of the
2723/// test plan (items 1–8 and the D5 replay cases 9–10, driven through the
2724/// verifier rather than the raw primitives). Entries are written straight to
2725/// a temp shared-registry dir with an explicit public key, bypassing the
2726/// process-global pubkey resolver (a `OnceLock` another test binary-wide
2727/// test owns), so each test is hermetic and order-independent.
2728#[cfg(test)]
2729#[path = "tests/reactive/verify_cross_channel_signature_tests.rs"]
2730mod verify_cross_channel_signature_tests;
2731
2732#[cfg(test)]
2733#[path = "tests/reactive/resolve_delivery_tier_tests.rs"]
2734mod resolve_delivery_tier_tests;
2735
2736#[cfg(test)]
2737#[path = "tests/reactive/is_self_registration_tests.rs"]
2738mod is_self_registration_tests;
2739
2740/// `forward_inject_to_peer` unit tests.
2741///
2742/// These exercise the real HTTP path against a throwaway loopback peer rather
2743/// than mocking the client, because the behaviour under test *is* the
2744/// response-classification: which peer answers count as delivery, which count
2745/// as evidence the candidate is stale, and which must never evict.
2746///
2747/// `source_agent` is left `None` throughout so `echo_jekt_to_sender` returns at
2748/// its first guard. That keeps these tests off the *global* singleton
2749/// `reactive_handler` (`test_state()` shares it across the whole test binary —
2750/// see the `verify_jekt_signature_tests` module comment), so nothing here can
2751/// interfere with another test's agent registrations.
2752#[cfg(test)]
2753#[path = "tests/reactive/forward_inject_tests.rs"]
2754mod forward_inject_tests;
2755
2756/// PLAN_JEKT_LOCAL_FIRST_ROUTING_2026_10_02.md §5-§6: the relay's lease
2757/// answer as a route. Holding here is the default; only "another install
2758/// runs it" or an answer the relay cannot give plainly sends it to the relay.
2759#[cfg(test)]
2760mod absent_route_tests {
2761 use super::*;
2762 use crate::muxbus::wan_lease::Holder;
2763
2764 #[test]
2765 fn the_lease_answer_picks_the_route() {
2766 assert_eq!(absent_route_for(&Holder::Free), AbsentRoute::HoldHere);
2767 assert_eq!(absent_route_for(&Holder::Yours), AbsentRoute::HoldHere);
2768 // No relay to send through: keep it here.
2769 assert_eq!(absent_route_for(&Holder::Unreachable), AbsentRoute::HoldHere);
2770 assert_eq!(absent_route_for(&Holder::Other("computer area54".into())), AbsentRoute::Relay);
2771 // An older relay, or one that won't say: the order before this plan.
2772 assert_eq!(absent_route_for(&Holder::Unsupported), AbsentRoute::Relay);
2773 assert_eq!(absent_route_for(&Holder::Unknown), AbsentRoute::Relay);
2774 }
2775}
2776
2777/// Tier-4 (cloud relay) gating tests.
2778///
2779/// These cover the decisions `try_cloud_relay` makes *before* it would touch
2780/// the network — the guards that keep it from firing when it must not. The
2781/// wire contract itself (headers, body shape, status handling) is tested
2782/// against a stub relay in `crate::muxbus::relay`'s own test module.
2783///
2784/// A `test_state()` has no muxbus credential, so any path that reaches token
2785/// resolution returns `None` and no request is ever made — which is also why
2786/// these assert `None` rather than mocking the relay: the point is *which*
2787/// guard stopped it, and each test isolates one.
2788#[cfg(test)]
2789#[path = "tests/reactive/cloud_relay_gate_tests.rs"]
2790mod cloud_relay_gate_tests;
2791
2792/// Request-shape guard for `reactive.registrations`, added as part of the
2793/// typed-registration migration.
2794///
2795/// `register_typed` deserializes the payload BEFORE the handler runs, so the
2796/// Req type has to accept exactly what the frontend stub sends. On
2797/// `bookmarks.list` that distinction shipped a P0 — a unit Req compiled fine
2798/// and then rejected the stub's `{}` at runtime, because serde accepts `()`
2799/// only from `null` (PR #3293). Neither `tsc` nor `check-rpc-bindings.sh`
2800/// catches that class: they verify generated-type drift, not runtime payload
2801/// shape. So every migrated command gets a test fed the real payload.
2802#[cfg(test)]
2803#[path = "tests/reactive/reactive_registrations_req_tests.rs"]
2804mod reactive_registrations_req_tests;