Skip to main content

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 &params.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(&params.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, &params).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, &params.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(&params.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, &params.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;