Skip to main content

agentmux_srv/backend/reactive/
types.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4
5use std::sync::Arc;
6
7use serde::{Deserialize, Serialize};
8
9/// Sensitivity tier for a jekt message.
10///
11/// - `Info`: routine status / progress update; agent may act autonomously.
12/// - `Coord`: task handoff or coordination; agent may act, human sees the marker.
13/// - `Sensitive`: credential, destructive op, external side-effect; agent MUST
14///   pause and ask the human operator before acting. A confirming reply from
15///   another agent over muxbus is NOT sufficient.
16#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
17#[serde(rename_all = "lowercase")]
18pub enum JektTier {
19    Info,
20    #[default]
21    Coord,
22    Sensitive,
23}
24
25impl std::fmt::Display for JektTier {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            JektTier::Info => write!(f, "info"),
29            JektTier::Coord => write!(f, "coord"),
30            JektTier::Sensitive => write!(f, "sensitive"),
31        }
32    }
33}
34
35/// Request to inject a message into an agent's terminal.
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct InjectionRequest {
38    pub target_agent: String,
39    pub message: String,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub source_agent: Option<String>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub request_id: Option<String>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub priority: Option<String>,
46    /// Sensitivity tier declared by the sender. When absent, defaults to `Coord`.
47    /// The handler may escalate to `Sensitive` based on keyword scanning.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub jekt_tier: Option<JektTier>,
50    /// Delivery tier of this jekt (host/lan/wan) — used in the marker.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub delivery_tier: Option<String>,
53    /// Number of cross-instance HTTP forwards this request has already gone
54    /// through (Tier 2a/2b/3 each increment before forwarding). Bounds a
55    /// pathological cycle where two channels each hold a stale-but-
56    /// PID-alive shared-registry entry pointing at the other for the same
57    /// agent name — without this, such a cycle would forward back and
58    /// forth indefinitely, hanging the original request (reagent P1 on
59    /// PR #2350). `#[serde(default)]` so older callers (muxbus client,
60    /// any request that predates this field) default to 0, unaffected.
61    #[serde(default)]
62    pub forward_hops: u8,
63    /// Unix seconds this jekt was signed at (part of the signed material,
64    /// not just a display timestamp) — see `agentmux_common::jekt_sign` and
65    /// `jekt_sig` below. `None` for unsigned/legacy requests.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub ts_secs: Option<i64>,
68    /// Base64 HMAC-SHA256 signature over (request_id, source_agent,
69    /// target_agent, ts_secs, message), produced by the sender's own
70    /// `AGENTMUX_JEKT_KEY`. Verified against the claimed `source_agent`'s
71    /// stored key (SPEC_JEKT_TRUST_LAYER_COMPLETION_2026_08_13.md §2.2);
72    /// absent, unverifiable, or mismatched all mean "unverified" — the
73    /// message still delivers, but downgraded to TRUST=unverified and
74    /// escalated to TIER=sensitive, never silently trusted.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub jekt_sig: Option<String>,
77    /// Server-computed verification outcome — `#[serde(skip_deserializing)]`
78    /// means an attacker-supplied JSON body can NEVER set this field, even
79    /// by including it; only `handle_reactive_inject`
80    /// (`server/reactive.rs`, which has `AppState::mstore`) may set it,
81    /// after independently looking up the claimed `source_agent`'s stored
82    /// key and verifying `jekt_sig` against it, before handing the request
83    /// to `Handler::inject_message` (which has no `Store` access "by
84    /// design" — see `record_supervisor_decision`'s doc comment for why).
85    /// `None` = not checked (network-tier messages are already
86    /// unconditionally sensitive regardless of signature; or the claimed
87    /// `source_agent` has never had a key minted, so there's nothing to
88    /// verify against — see the rollout-safety note in
89    /// `handle_reactive_inject`, this deliberately does NOT escalate a
90    /// caller — e.g. the Slack/Discord/Telegram/WhatsApp bridges, or an
91    /// agent not yet respawned since this feature shipped — that was never
92    /// capable of signing in the first place). `Some(false)` is the real
93    /// signal: this `source_agent` DOES have a key, and either no signature
94    /// was given or it didn't match.
95    #[serde(skip_deserializing, default, skip_serializing_if = "Option::is_none")]
96    pub sig_verified: Option<bool>,
97    /// Identity M4c-2d (SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md
98    /// §6.5.9): the sender's UID, from the request's `Caller` — its
99    /// `X-Agent-Token` — set by the srv handler that received it, and copied
100    /// onto the audit entries this delivery writes. `#[serde(skip)]` both
101    /// ways: a body can never set it, and it never rides a forwarding hop —
102    /// distinct from the `source_uid` wire field §6.5.3 reserves for the MCP
103    /// to set and sign (M4d). Empty = Unattributed.
104    #[serde(skip)]
105    pub audit_source_uid: String,
106    /// Identity M4d-6: the UID a LAN v2 signature proved under the answering
107    /// peer's pinned key (`server/reactive/lan_v2.rs`). Claimed by that LAN
108    /// path only, never verified attribution; `#[serde(skip)]` like
109    /// `audit_source_uid`. Empty = none.
110    #[serde(skip)]
111    pub lan_claimed_uid: String,
112    /// When a held message was originally accepted (ms), set only by the
113    /// held-jekt replay (`SPEC_DURABLE_JEKT_DELIVERY_2026_09_24.md` §2.4):
114    /// the delivered header shows this as `TS` plus `HELD_FOR`, so a late
115    /// message never reads as a current one. `#[serde(skip)]` both ways.
116    #[serde(skip)]
117    pub held_sent_at_ms: Option<i64>,
118    /// Base64 Ed25519 signature over the same signed material as `jekt_sig`
119    /// (request_id, source_agent, target_agent, ts_secs, message), produced
120    /// by an AgentMux-operated WAN-tier service sender (currently only the
121    /// GitHub review-notification consumer, "reagent") using a private key
122    /// held only by the cloud service. Distinct from
123    /// `jekt_sig` (host-tier HMAC, symmetric, one key per local instance) —
124    /// see `agentmux_common::jekt_sign`'s module doc for why WAN needs an
125    /// asymmetric scheme instead. `None` for any non-reagent WAN sender or
126    /// any host/lan-tier request — this field is meaningless off the WAN
127    /// tier and verification is skipped entirely when absent.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub reagent_sig: Option<String>,
130    /// Which pinned public key `reagent_sig` claims to be signed under —
131    /// see `agentmux_common::jekt_sign::reagent_public_key` for the
132    /// rotation rationale. A legitimate sender always sends all four
133    /// signing fields (`reagent_sig`, `reagent_key_id`, `reagent_msg_id`,
134    /// `reagent_ts_secs`) together, so `None` here alongside a present
135    /// `reagent_sig` is treated the same as no signature having been
136    /// attempted at all (`reagent_verified` resolves to `None`, not
137    /// `Some(false)`) — see `cloud_subscriber::sync_agent_reactive`'s match
138    /// on all four fields. This does not weaken verification: dropping this
139    /// field can only ever *lose* a legitimate `SIG=verified`/possible tier
140    /// relaxation, never grant one — see `reagent_verified`'s doc comment
141    /// below for what this field, once present, is now also used for
142    /// besides rendering `SIG=`.
143    ///
144    /// Whichever caller builds the `InjectionRequest` that ultimately
145    /// reaches `Handler::inject_message` MUST carry this field through from
146    /// wherever it originated (e.g. `cloud_subscriber::sync_agent_reactive`
147    /// copying it from the `Injection` it received) — `handler.rs`'s tier-
148    /// relaxation gate checks THIS field, not just whether `reagent_sig`
149    /// verified, so dropping it silently disables relaxation on that
150    /// delivery path even when `reagent_verified` is correctly `Some(true)`
151    /// (reagentx P0 on PR #2576 — exactly this omission on the WS path).
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub reagent_key_id: Option<String>,
154    /// Message id that was part of `reagent_sig`'s signed material — the
155    /// same `id` the cloud relay gives each pending injection it hands
156    /// this client. Distinct from `request_id` above, which is
157    /// this request's own (possibly re-derived, e.g. across a cross-instance
158    /// forward) identifier — `reagent_msg_id` must stay exactly what was
159    /// signed for verification to succeed regardless of how `request_id`
160    /// is set on this particular hop.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub reagent_msg_id: Option<String>,
163    /// Unix seconds `reagent_sig` was signed at — part of the signed
164    /// material, checked for anti-replay against a max-age window (the WS
165    /// path's `cloud_subscriber::REAGENT_SIG_MAX_AGE_SECS`, or the HTTP
166    /// path's own constant of the same name in `server/reactive.rs`), same
167    /// purpose as `ts_secs`/`JEKT_SIG_MAX_AGE_SECS` for host-tier `jekt_sig`.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub reagent_ts_secs: Option<i64>,
170    /// Server-computed verification outcome for `reagent_sig` — same
171    /// `#[serde(skip_deserializing)]` guarantee as `sig_verified`: no
172    /// attacker-supplied JSON body can set this directly. Set by whichever
173    /// caller actually received this WAN injection and independently
174    /// verified `reagent_sig` against the pinned key —
175    /// `cloud_subscriber::sync_agent_reactive` for the desktop app's WS
176    /// delivery path, or `server/reactive.rs::verify_reagent_signature` for
177    /// the HTTP `/agentmux/reactive/inject` path used by standalone pollers
178    /// like `@agentmuxai/muxbus-client` (reagentx P1 on PR #41 — that path
179    /// used to receive the four reagent_* fields over HTTP with nothing on
180    /// the receiving end able to verify them at all). `Some(true)` renders
181    /// `SIG=verified` in the marker; `Some(false)` (a `reagent_sig` was present but didn't
182    /// verify) renders `SIG=invalid` — a real red flag, same spirit as
183    /// host-tier's `TRUST=unverified`. `None` (no `reagent_sig` attempted)
184    /// renders no `SIG=` field at all, keeping ordinary WAN traffic
185    /// unchanged.
186    ///
187    /// **Never affects `TRUST`** — WAN jekts stay unconditionally
188    /// `TRUST=network-claimed` regardless of this field; verification
189    /// answers "who is this really from," not "did this cross a network
190    /// boundary."
191    ///
192    /// **DOES affect `TIER`.** As of
193    /// `docs/specs/SPEC_JEKT_SENSITIVE_TIER_NARROWING_2026_08_15.md`
194    /// (superseding `SPEC_JEKT_REAGENT_TRUST_RELAXATION_2026_08_14.md`'s
195    /// narrower version of this same claim), `handler.rs`'s tier-escalation
196    /// block only forces `TIER=sensitive` by delivery tier when this is
197    /// `Some(false)` — a `reagent_sig` was present but did NOT
198    /// cryptographically verify, i.e. an active forgery attempt, or verified
199    /// only under a key other than the trusted production key (see
200    /// `agentmux_common::jekt_sign::verify_trusted_reagent_jekt`). `None` (no
201    /// signature attempted) falls through to the declared tier (default
202    /// `coord`), same as any other self-declared sender. Declared-sensitive and
203    /// keyword-match escalation still apply unconditionally on top of all
204    /// of the above.
205    #[serde(skip_deserializing, default, skip_serializing_if = "Option::is_none")]
206    pub reagent_verified: Option<bool>,
207    /// Base64 Ed25519 signature over the same signed material as `jekt_sig`/
208    /// `reagent_sig`, produced by the claimed `source_agent`'s own LAN
209    /// signing key (`AGENTMUX_LAN_KEY`, read client-side in `agentmux-mcp` —
210    /// see `agentmux_common::jekt_sign::sign_lan_jekt`). Meaningless off the
211    /// LAN tier, same scoping as `reagent_sig` being WAN-only. See
212    /// docs/specs/SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.3/§2.4.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub lan_sig: Option<String>,
215    /// Server-computed verification outcome for `lan_sig` —
216    /// `#[serde(skip_deserializing)]` guarantee identical to `sig_verified`/
217    /// `reagent_verified`: no attacker-supplied JSON body can set this
218    /// directly. Set by `handle_reactive_inject` after looking up the
219    /// claimed `source_agent`'s LAN public key (a LAN round-trip via
220    /// `LanDiscovery::find_agent_lan_pubkey` — unlike host-tier's
221    /// synchronous local lookup, this one is async) and independently
222    /// verifying `lan_sig` against it.
223    ///
224    /// `None` — no `lan_sig` attempted, OR the claimed sender has no LAN
225    /// public key this instance could find (never minted one, or genuinely
226    /// unreachable on the LAN right now) — nothing to check against, same
227    /// "don't escalate a caller that was never capable of signing"
228    /// semantics `sig_verified`'s `None` case documents. `Some(true)` renders
229    /// `TRUST=lan-verified` in the marker; `Some(false)` (a `lan_sig` was
230    /// present, a public key WAS found, but it didn't verify — someone tried
231    /// to forge a specific agent's identity) is a real red flag, same
232    /// spirit as host-tier's `TRUST=unverified` and WAN's `SIG=invalid` —
233    /// forces `TIER=sensitive` unconditionally
234    /// (`docs/specs/SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md` §2.4/§2.5).
235    #[serde(skip_deserializing, default, skip_serializing_if = "Option::is_none")]
236    pub lan_verified: Option<bool>,
237    /// The sending instance's channel id, as declared by the sender and bound
238    /// into `channel_sig`'s signed material
239    /// (`SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md` §D5). Client-supplied,
240    /// like `source_agent` — a wrong value simply fails verification, it
241    /// grants nothing. Carried through same-host forwards unchanged.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub source_channel: Option<String>,
244    /// Base64 Ed25519 signature for the cross-channel tier (same machine,
245    /// different AgentMux instance), produced by the claimed `source_agent`'s
246    /// own LAN keypair over a domain-separated payload that also binds
247    /// `source_channel` (`agentmux_common::jekt_sign::sign_channel_jekt`).
248    /// Meaningless off the `channel` tier, same scoping as `lan_sig`.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub channel_sig: Option<String>,
251    /// Identity M4d-6 (`SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md`
252    /// §6.5.10): the sending agent's UID, as its MCP fetched it with its
253    /// UID-keyed key (M4d-3). Bound into `channel_sig_v2`; a forwarding srv
254    /// never sets or changes it. Absent when the sender has no UID key.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub source_uid: Option<String>,
257    /// Base64 Ed25519 signature, made only with the sender's UID-keyed LAN
258    /// key, over the cross-channel material plus `source_uid`
259    /// (`agentmux_common::jekt_sign::sign_channel_jekt_v2`). Verified by UID
260    /// against the `uid_public_key` an instance published for it (M4d-5), so
261    /// it proves *which* agent sent the jekt, not just which name. The v1
262    /// `channel_sig` is still sent beside it.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub channel_sig_v2: Option<String>,
265    /// Identity M4d-6: base64 Ed25519 signature, made only with the sender's
266    /// UID-keyed LAN key, over the LAN material plus `source_uid`
267    /// (`agentmux_common::jekt_sign::sign_lan_jekt_v2`). A receiver checks it
268    /// only after the v1 `lan_sig` verified, and records the UID as claimed by
269    /// that LAN path, never as verified attribution.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub lan_sig_v2: Option<String>,
272    /// Base64 Ed25519 signature for the general agent-to-agent WAN tier,
273    /// produced by the claimed `source_agent`'s own **WAN** keypair
274    /// (`db_agent_wan_keys` — a different key from the LAN one) over a
275    /// domain-separated payload
276    /// (`agentmux_common::jekt_sign::sign_wan_jekt`).
277    ///
278    /// **Carried but NOT yet verified — there is deliberately no
279    /// `wan_verified` companion field yet.** Verification has two
280    /// prerequisites that do not exist: resolving `(sender_account,
281    /// source_agent)` to exactly one published key waits on relay-side work
282    /// tracked in the private cloud repo, and the
283    /// signed `msgid`/`ts_secs` must survive the cloud round trip, which
284    /// today they do not — `cloud_subscriber` replaces `request_id` with the
285    /// cloud's own injection id and leaves `ts_secs` unset. Adding a
286    /// verification-outcome field before either exists would read as wired
287    /// when it is not, so it lands with the verifier instead.
288    ///
289    /// Signing ships first on purpose: an agent only receives its key at
290    /// spawn, so minting now is what makes verification apply to a real
291    /// population of agents later rather than almost none.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub wan_sig: Option<String>,
294    /// The sending machine's host label. With `source_channel`, identifies
295    /// which AgentMux instance under the sending account owns the keypair
296    /// `wan_sig` was produced with — the same account can legitimately run one agent name on
297    /// several machines and several channels, each with its own database and
298    /// so its own key. Client-supplied like `source_channel` and bound into
299    /// the signed material, so a wrong value fails verification rather than
300    /// selecting a key it shouldn't.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub wan_source_host: Option<String>,
303    /// Server-computed verification outcome for `channel_sig` —
304    /// `#[serde(skip_deserializing)]`, the same guarantee as `sig_verified`/
305    /// `lan_verified`: no attacker-supplied JSON body can set this. Set by
306    /// `verify_cross_channel_signature` (`server/reactive.rs`) after resolving
307    /// the claimed `source_agent`'s published public key from the host-global
308    /// shared registry (`AgentEntry::jekt_public_key`, §D1) — a local file
309    /// read, so unlike `lan_verified` it's synchronous.
310    ///
311    /// `None` — no entry for the claimed sender in the shared registry (a
312    /// bridge, or an agent that has never registered), an entry with an
313    /// empty `jekt_public_key` (written before Phase A shipped — "cannot
314    /// check," never "failed the check," spec §6), or the sender is a
315    /// same-instance agent (the HMAC path owns those, §D2 step 2).
316    /// `Some(true)` renders `TRUST=channel-verified` and joins the
317    /// verified-sender list for the `ESCALATE=none` relaxation. `Some(false)`
318    /// — an entry with a key WAS found and the signature was missing, stale,
319    /// or wrong — is the §D3 red flag, **not yet escalated**: Phase B
320    /// (this) only ever moves a message from `self-declared` to
321    /// `channel-verified`; Phase C turns `Some(false)` into forced
322    /// `TIER=sensitive` once published keys have propagated (spec §10).
323    #[serde(skip_deserializing, default, skip_serializing_if = "Option::is_none")]
324    pub channel_verified: Option<bool>,
325    /// W3-S (`SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md` §2.3): the verdict on
326    /// a same-account agent's WAN signature, computed only by the cloud
327    /// subscriber (`muxbus::wan_verify`) — never settable by an HTTP caller
328    /// (`skip_deserializing`), so the HTTP entry point's self-declared
329    /// `delivery_tier: "wan"` always leaves it `None`.
330    ///
331    /// `Some(true)` renders `TRUST=wan-verified` with `wan_instance`, and
332    /// joins the verified-sender set for an `approved` or `new` instance
333    /// (same-account installs, spec §2.6 as amended 2026-09-26); a `revoked`
334    /// one is treated as `Some(false)`. `Some(false)` — the envelope, the
335    /// certificate chain, the record match, the signature or the replay check
336    /// actively failed — is forced `TIER=sensitive`. `None` covers every
337    /// "couldn't check" case: unsigned, another account, stale, no record,
338    /// directory unreachable.
339    #[serde(skip_deserializing, default, skip_serializing_if = "Option::is_none")]
340    pub wan_verified: Option<bool>,
341    /// The verified sending instance, set with `wan_verified == Some(true)`.
342    #[serde(skip_deserializing, default, skip_serializing_if = "Option::is_none")]
343    pub wan_instance: Option<WanInstanceInfo>,
344    /// Why `wan_verified` isn't `Some(true)`, when there's something to say
345    /// (`wan_sig_stale`, `wan_sig_replay`, `wan_key_unavailable`,
346    /// `wan_cert_invalid`, …) — audit only, never rendered.
347    #[serde(skip_deserializing, default, skip_serializing_if = "Option::is_none")]
348    pub wan_reason: Option<String>,
349    /// The cause behind `wan_reason`, when there is one to read (e.g.
350    /// `directory answered 403 Forbidden`) — audit only, never rendered.
351    #[serde(skip_deserializing, default, skip_serializing_if = "Option::is_none")]
352    pub wan_detail: Option<String>,
353    /// Server-computed: this jekt's `message` is a `transcript_request`
354    /// payload (`agentmux_common::transcript_request::parse_transcript_request`)
355    /// — `muxspect` Phase B/C's LAN/WAN conversation-visibility protocol.
356    /// `#[serde(skip_deserializing)]`, same guarantee as `sig_verified`
357    /// etc.: no attacker-supplied JSON body can force or suppress this —
358    /// `resolve_transcript_request_tier_fields` always recomputes it by
359    /// re-parsing `message` itself, ignoring anything the client claims.
360    /// Called from both delivery paths that reach `Handler`:
361    /// `handle_reactive_inject` for host/cross-channel/LAN, and
362    /// `muxbus::cloud_subscriber::sync_agent_reactive` for WAN. Forces `TIER=sensitive`
363    /// unconditionally when `true`, regardless of trust
364    /// (`SPEC_JEKT_TRANSCRIPT_REQUEST_TIER_RULES_2026_08_22.md` rule 1) — a
365    /// content-disclosure *request* isn't caught by the existing credential/
366    /// destructive-keyword scan at all.
367    #[serde(skip_deserializing, default)]
368    pub is_transcript_request: bool,
369    /// Server-computed: when `is_transcript_request` is also `true`, whether
370    /// the RESPONDING agent's (`target_agent`'s) own `conversation_visibility`
371    /// setting is `ask`, or `trusted_peers` with a requester not on that
372    /// agent's own allowlist — i.e. a case that genuinely needs a human
373    /// decision, per `SPEC_JEKT_TRANSCRIPT_REQUEST_TIER_RULES_2026_08_22.md`
374    /// rule 2. When `true`, `ESCALATE=required` is NOT relaxed to `none`
375    /// even for a cryptographically verified sender (the one named
376    /// exception to the 2026-08-17 relaxation) — unlike every other
377    /// `TIER=sensitive` case, where a verified sender's identity answers
378    /// the only open question. Meaningless (always `false`) when
379    /// `is_transcript_request` is `false`. Resolved by
380    /// `resolve_transcript_request_tier_fields` (has `Store` access, unlike
381    /// `Handler::inject_message`, which has none "by design") — called from
382    /// both delivery paths that reach `Handler`: `handle_reactive_inject`
383    /// for host/cross-channel/LAN, and
384    /// `muxbus::cloud_subscriber::sync_agent_reactive` for WAN — same
385    /// "resolve in the caller with Store access, pass the outcome through"
386    /// pattern `sig_verified`/`lan_verified` already establish.
387    #[serde(skip_deserializing, default)]
388    pub transcript_request_escalate_forced: bool,
389}
390
391impl InjectionRequest {
392    /// Whether a sender signature rides on this request. Each binds the
393    /// target as signed, so a signed request's target must reach every hop
394    /// unchanged; the sender's MCP already sends the route name
395    /// (`agentmux-mcp` `signed_jekt`).
396    pub fn is_signed(&self) -> bool {
397        [&self.jekt_sig, &self.reagent_sig, &self.lan_sig, &self.lan_sig_v2, &self.channel_sig, &self.channel_sig_v2, &self.wan_sig]
398            .iter()
399            .any(|s| s.is_some())
400    }
401
402    /// An unsigned request's target as its route name, so a display-name
403    /// target ("Loap #3") routes as "Loap-3" on every tier, not only the
404    /// local one. A signed one is left as it is (see `is_signed`).
405    pub fn route_unsigned_target(&mut self) {
406        if !self.is_signed() {
407            self.target_agent = super::sanitize::route_name(&self.target_agent).unwrap_or_default();
408        }
409    }
410}
411
412/// Response from a message injection attempt.
413#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct InjectionResponse {
415    pub success: bool,
416    pub request_id: String,
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub block_id: Option<String>,
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub error: Option<String>,
421    pub timestamp: u64,
422    /// Tier the handler actually applied (after keyword/network escalation).
423    /// Consumed by the sender-echo path so it doesn't re-derive escalation.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub effective_tier: Option<String>,
426    /// Whether `effective_tier == "sensitive"` should actually STOP the
427    /// receiver (vs. tag-only for a cryptographically verified sender) —
428    /// see `Handler::inject_message_inner`'s `requires_stop` doc comment
429    /// (SPEC_JEKT_SENSITIVE_TIER_VERIFIED_SENDER_NO_STOP_2026_08_17.md).
430    /// Meaningless when `effective_tier` isn't `"sensitive"`. Threaded
431    /// through so the sender-echo path (including forwarded cross-instance
432    /// hops, which only see this struct's serialized JSON, not the
433    /// original `Handler` call) renders the same STOP-or-tag-only marker
434    /// the receiver got, instead of re-deriving it from a narrower set of
435    /// fields.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub requires_stop: Option<bool>,
438    /// The `InjectionRequest::channel_verified` verdict this delivery was
439    /// judged on, echoed back so a FORWARDING instance can render the same
440    /// `TRUST=` on the sender's echoed marker that the receiver rendered
441    /// (SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md Phase B, codex P2 on
442    /// #3064). Only the receiving instance ever computes it — the forwarder
443    /// holds the sender's HMAC key and so skips cross-channel verification
444    /// by design (§D2 step 2) — so without this the echo would show the
445    /// sender-side view next to a receiver-side `ESCALATE=`. Same
446    /// thread-it-through reasoning as `requires_stop` above. `None` off the
447    /// channel tier or when nothing could be checked.
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub channel_verified: Option<bool>,
450    /// `Some(true)`: the target's process is starting up, restarting or
451    /// stopping, so the message is queued and reaches it once the process is
452    /// up — accepted, not yet seen. `Some(false)`: written to the agent now
453    /// (mid-turn included). `None` where nothing structured was attempted
454    /// (errors, PTY keystrokes, relay paths). Lets `SendMessage` tell its
455    /// caller which happened instead of claiming "injected" for both.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub deferred: Option<bool>,
458}
459
460/// Agent registration record.
461///
462/// `#[ts(type = "number")]` on the u64 timestamps: ts-rs maps 64-bit integers
463/// to `bigint`, which is not assignable to `number` in TypeScript and would
464/// break every consumer that treats these as JS millisecond timestamps. See
465/// the same annotation on `BrowserBookmark::created_at` (PR #3293).
466#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
467#[ts(export, export_to = "../../../frontend/types/rpc/")]
468pub struct AgentRegistration {
469    pub agent_id: String,
470    pub block_id: String,
471    // `#[ts(optional)]`: this field carries `skip_serializing_if`, so the key
472    // is OMITTED from the JSON entirely when None — not serialized as null.
473    // Without this ts-rs renders `tab_id: string | null`, which promises the
474    // key is always present, and a consumer narrowing on `!== null` would
475    // still get `undefined` at runtime. Note the contrast with
476    // `MismatchAuditSummary::error_message` in server/reactive.rs: that one is
477    // a plain `Option<String>` with NO skip_serializing_if, so it genuinely is
478    // present-and-null and `string | null` is correct there. Same Option<T>,
479    // two different correct bindings, decided by the serde attribute.
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    #[ts(optional)]
482    pub tab_id: Option<String>,
483    /// Identity M2: the agent's UID (`db_agents.id`) when the server knew it
484    /// at registration — the key the registry is now organised by. Absent
485    /// for blocks with no row (quick-launch panes, PTY shells), which are
486    /// reachable by name only. Same present-or-omitted treatment as `tab_id`.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    #[ts(optional)]
489    pub uid: Option<String>,
490    #[ts(type = "number")]
491    pub registered_at: u64,
492    #[ts(type = "number")]
493    pub last_seen: u64,
494    /// Process-wide unique nonce of the persistent-controller spawn this
495    /// registration belongs to; 0 = not recorded (HTTP register handler,
496    /// PTY shell auto-register). Real nonces are always ≥ 1, drawn from a
497    /// single srv-wide counter (`persistent::next_registration_nonce`) —
498    /// NOT the controller-local spawn generation, which restarts at 1
499    /// for a replacement controller (`resync_controller`) and could
500    /// collide across controller instances for the same block (codex P1
501    /// on PR #2500). Lets the exit-handler's cleanup compare-and-remove
502    /// instead of blindly wiping a fallback respawn's (or replacement
503    /// controller's) fresh registration (issue #2363).
504    #[serde(default)]
505    #[ts(type = "number")]
506    pub registration_nonce: u64,
507}
508
509/// List of registered agents.
510#[allow(dead_code)]
511#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct AgentListResponse {
513    pub agents: Vec<AgentRegistration>,
514}
515
516/// Whether a verified WAN instance is one a human approved (§2.6).
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
518#[serde(rename_all = "lowercase")]
519pub enum WanInstanceStatus {
520    /// This install, or one approved through the host-gated window.
521    Approved,
522    /// Verified, but nobody has said it's theirs: no relaxation.
523    New,
524    /// Its owner retired it: treated as an active failure.
525    Revoked,
526}
527
528impl WanInstanceStatus {
529    pub fn as_str(self) -> &'static str {
530        match self {
531            Self::Approved => "approved",
532            Self::New => "new",
533            Self::Revoked => "revoked",
534        }
535    }
536}
537
538/// The instance a WAN signature was verified against (§2.6).
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540pub struct WanInstanceInfo {
541    /// The full 26-char id the certificate chain proved.
542    pub id: String,
543    /// `<host_hint>~<id8>` — `jekt_sign::wan_instance_display_label`, the
544    /// only form ever shown to a human or an agent.
545    pub label: String,
546    pub status: WanInstanceStatus,
547}
548
549/// The WAN part of an audit entry (§2.6 "Audit and docs").
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
551pub struct WanAudit {
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub verified: Option<bool>,
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub instance: Option<String>,
556    #[serde(default, skip_serializing_if = "Option::is_none")]
557    pub status: Option<WanInstanceStatus>,
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub reason: Option<String>,
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub detail: Option<String>,
562}
563
564impl WanAudit {
565    /// `None` for a request with nothing WAN to record.
566    pub fn from_request(req: &InjectionRequest) -> Option<Self> {
567        if req.wan_verified.is_none() && req.wan_reason.is_none() {
568            return None;
569        }
570        Some(Self {
571            verified: req.wan_verified,
572            instance: req.wan_instance.as_ref().map(|i| i.id.clone()),
573            status: req.wan_instance.as_ref().map(|i| i.status),
574            reason: req.wan_reason.clone(),
575            detail: req.wan_detail.clone(),
576        })
577    }
578}
579
580/// Audit log entry for message injection.
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct AuditLogEntry {
583    pub timestamp: u64,
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub source_agent: Option<String>,
586    pub target_agent: String,
587    pub block_id: String,
588    pub message_hash: String,
589    pub message_length: usize,
590    pub success: bool,
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub error_message: Option<String>,
593    pub request_id: String,
594    /// "nudge_sent" | "nudge_failed" | "nudge_declined" — present only for
595    /// Warden Supervisor-originated entries (see
596    /// ANALYSIS_WARDEN_AUTO_CONTROLLER_CONTINUATION_WATCHER_2026_08_12.md);
597    /// absent for ordinary jekt injections. "nudge_sent" only when delivery
598    /// actually succeeded; "nudge_failed" when the Supervisor attempted a
599    /// nudge but delivery itself failed (rate limit, unavailable
600    /// controller, etc — see `entry.success`/`error_message` for why). A
601    /// "nudge_declined" entry has no corresponding delivery attempt at
602    /// all — this field is the ONLY record that decision ever existed.
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub outcome: Option<String>,
605    /// The Supervisor's stated reasoning, populated alongside `outcome`.
606    /// Not used by ordinary (non-Supervisor) jekt entries.
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub reason: Option<String>,
609    /// "delivery" (default — an inject_message attempt, the only kind this
610    /// log ever recorded before this field existed) | "register" |
611    /// "unregister". Lets `GET /agentmux/reactive/audit` reconstruct
612    /// registration/eviction history, not just delivery attempts — prior to
613    /// this, register_agent_with_nonce/unregister_agent* wrote nothing here
614    /// at all, so a same-host cross-instance identity collision left no
615    /// trace of who registered as an agent_id or who it evicted.
616    #[serde(default = "default_event_kind")]
617    pub event_kind: String,
618    /// For a "register" event: the block_id this agent_key was previously
619    /// mapped to, if registering evicted an existing mapping. `None` if this
620    /// was a fresh registration (no prior holder).
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub evicted_block: Option<String>,
623    /// For a "register" event: the agent_id this block_id was previously
624    /// registered to, if registering evicted an existing mapping. `None` if
625    /// this block had no prior tenant.
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub evicted_agent: Option<String>,
628    /// Identity M4c-2d: the sending agent's UID when its request was
629    /// attributed (`InjectionRequest::audit_source_uid`), beside the
630    /// `source_agent` name it claimed. Empty = Unattributed, or not a
631    /// delivery.
632    #[serde(default, skip_serializing_if = "String::is_empty")]
633    pub audit_source_uid: String,
634    /// Identity M4d-6: the UID a LAN v2 signature claimed
635    /// (`InjectionRequest::lan_claimed_uid`). Not verified attribution.
636    #[serde(default, skip_serializing_if = "String::is_empty")]
637    pub lan_claimed_uid: String,
638    /// W3-S: the WAN verdict for a cloud-delivered jekt, when there was one.
639    #[serde(default, skip_serializing_if = "Option::is_none")]
640    pub wan: Option<WanAudit>,
641}
642
643fn default_event_kind() -> String {
644    "delivery".to_string()
645}
646
647/// A Warden Supervisor watcher agent's decision about a target agent that
648/// just ended its turn: nudge it to continue, or decline (e.g. the target
649/// isn't opted in, or the Supervisor judged it genuinely done/blocked).
650/// See `Handler::record_supervisor_decision` and
651/// ANALYSIS_WARDEN_AUTO_CONTROLLER_CONTINUATION_WATCHER_2026_08_12.md.
652#[derive(Debug, Clone, Copy)]
653pub enum SupervisorAction {
654    /// Deliver a fixed, narrow continuation message to the target as an
655    /// ordinary jekt (via the same path `inject_message` uses), then log
656    /// the decision. Deliberately carries no caller-supplied text — the
657    /// message itself is a constant (`handler::NUDGE_MESSAGE`), not
658    /// composed by the calling Supervisor agent. See
659    /// ANALYSIS_WARDEN_AUTO_CONTROLLER_CONTINUATION_WATCHER_2026_08_12.md
660    /// §4.3 ("never a free-form instruction the watcher composes
661    /// per-situation") — reagentx P1 on PR #2557 flagged an earlier
662    /// version of this type that accepted arbitrary text.
663    Nudge,
664    /// No delivery — just log that the Supervisor decided not to nudge.
665    Decline,
666}
667
668/// Poller configuration for AgentMux cloud service.
669#[derive(Debug, Clone, Serialize, Deserialize)]
670pub struct PollerConfig {
671    // ARCH-001: the wire key is now `muxbus_url`/`muxbus_token` (matches the
672    // Rust identifier). The legacy `agentmux_*` keys are still accepted as a
673    // back-compat deserialize alias on input.
674    #[serde(rename = "muxbus_url", alias = "agentmux_url", default, skip_serializing_if = "Option::is_none")]
675    pub muxbus_url: Option<String>,
676    #[serde(rename = "muxbus_token", alias = "agentmux_token", default, skip_serializing_if = "Option::is_none")]
677    pub muxbus_token: Option<String>,
678    #[serde(default)]
679    pub poll_interval_secs: u64,
680}
681
682/// AgentMux config file format (agentmux.json).
683#[allow(dead_code)]
684#[derive(Debug, Clone, Serialize, Deserialize)]
685pub struct AgentMuxConfigFile {
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub url: Option<String>,
688    #[serde(default, skip_serializing_if = "Option::is_none")]
689    pub token: Option<String>,
690}
691
692/// Pending injection from AgentMux cloud.
693#[allow(dead_code)]
694#[derive(Debug, Clone, Serialize, Deserialize)]
695pub struct PendingInjection {
696    pub id: String,
697    pub message: String,
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    pub source_agent: Option<String>,
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub priority: Option<String>,
702    #[serde(default)]
703    pub created_at: u64,
704}
705
706/// Response from AgentMux pending endpoint.
707#[allow(dead_code)]
708#[derive(Debug, Clone, Serialize, Deserialize)]
709pub struct PendingResponse {
710    pub injections: Vec<PendingInjection>,
711}
712
713/// Acknowledgment request for delivered injections.
714#[allow(dead_code)]
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct AckRequest {
717    pub injection_ids: Vec<String>,
718}
719
720/// Poller status information.
721#[derive(Debug, Clone, Serialize, Deserialize)]
722pub struct PollerStatus {
723    pub configured: bool,
724    pub running: bool,
725    #[serde(default, skip_serializing_if = "Option::is_none")]
726    pub url: Option<String>,
727    pub has_token: bool,
728    pub poll_count: u64,
729    pub injections_count: u64,
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub last_poll: Option<u64>,
732}
733
734/// Function type for sending input bytes to a block's PTY.
735pub type InputSender = Arc<dyn Fn(&str, &[u8]) -> Result<(), String> + Send + Sync>;
736
737/// Function type for controller-aware delivery of a message to a (non-PTY) agent.
738///
739/// Given `(block_id, message)`, returns:
740/// - `Ok(SenderDelivery::Delivered)` — delivered on the controller's structured
741///   channel (persistent stream-json stdin / ACP `session/prompt`); no PTY
742///   keystrokes needed.
743/// - `Ok(SenderDelivery::Deferred)` — accepted on that channel but queued
744///   because the agent's process is starting up, restarting or stopping;
745///   written once it can take it (SPEC_JEKT_IMMEDIATE_DELIVERY_2026_09_28.md
746///   §2.1). The message is safe; the agent just hasn't seen it yet.
747/// - `Ok(SenderDelivery::Pty)` — the controller is PTY-based; the caller should
748///   fall back to keystroke injection.
749/// - `Err(_)` — a structured controller failed to accept the message (e.g. the
750///   persistent process is not running); the caller must NOT fall back to PTY,
751///   since such controllers reject raw keystrokes.
752pub type MessageSender = Arc<dyn Fn(&str, &str) -> Result<SenderDelivery, String> + Send + Sync>;
753
754/// See [`MessageSender`].
755#[derive(Debug, Clone, Copy, PartialEq, Eq)]
756pub enum SenderDelivery {
757    Delivered,
758    Deferred,
759    Pty,
760}
761
762/// Function type for querying a block's own live, spawn-time-captured jekt
763/// identity — an independent source of truth for `inject_message_inner`'s
764/// recipient-identity check, deliberately NOT derived from `Handler`'s own
765/// `uid_to_block`/`name_to_blocks`/`agent_info` maps (see
766/// `Controller::agent_id`'s doc comment for why checking a registry against
767/// itself would be a tautology). Given `block_id`, returns `Some(agent_id)` if that block's
768/// controller has a captured identity, `None` if it doesn't (a non-agent
769/// controller, or a controller type that doesn't implement `agent_id()`) —
770/// `None` is treated as "unverifiable," not "confirmed absent," so delivery
771/// proceeds unaffected when no positive check is possible.
772pub type AgentIdentityConfirmer = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
773
774/// Identity M2: is `block_id` still alive (does it have a controller)?
775/// Backs the registry's lazy sweep of dead registrations when a name
776/// resolves to more than one block — see `Handler::block_liveness`.
777pub type BlockLivenessProbe = Arc<dyn Fn(&str) -> bool + Send + Sync>;
778
779#[cfg(test)]
780mod app_api_manifest_contract_tests {
781    //! Rust half of the DRY contract check for `agent.send`, described in
782    //! docs/specs/SPEC_MUXSH_FULL_COLLECTION_2026_09_16.md §2.8 — same
783    //! mechanism as `rpc_types/block.rs`'s equivalent module for
784    //! `pane.open`, but a **subset** check (`exactMatch: false` in the
785    //! manifest), not exact-match: `InjectionRequest` carries many more
786    //! optional fields (source_agent, jekt_sig, ...) that `muxsh agent send`
787    //! never sets, and enumerating all of them in the manifest would add no
788    //! real protection against the actual risk (muxsh's own two
789    //! constructed field names drifting), just busywork every time an
790    //! unrelated jekt field is added to this struct.
791    use super::*;
792    use std::collections::HashSet;
793    use std::path::Path;
794
795    fn load_manifest() -> serde_json::Value {
796        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
797            .parent()
798            .and_then(Path::parent)
799            .expect("crates/<name> is two levels below the repo root")
800            .join("docs/specs/app-api-manifest.json");
801        let raw = std::fs::read_to_string(&path)
802            .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
803        serde_json::from_str(&raw).expect("app-api-manifest.json must be valid JSON")
804    }
805
806    #[test]
807    fn agent_send_manifest_fields_are_a_subset_of_the_real_struct() {
808        let manifest = load_manifest();
809        let manifest_fields: HashSet<String> = manifest["routes"]["agent.send"]["requestFields"]
810            .as_array()
811            .expect("routes.agent.send.requestFields must be an array")
812            .iter()
813            .map(|v| v.as_str().expect("field name must be a string").to_string())
814            .collect();
815
816        let instance = InjectionRequest {
817            target_agent: "a".to_string(),
818            message: "m".to_string(),
819            ..Default::default()
820        };
821        let value = serde_json::to_value(&instance).expect("InjectionRequest must serialize");
822        let struct_fields: HashSet<String> = value
823            .as_object()
824            .expect("serialized InjectionRequest must be a JSON object")
825            .keys()
826            .cloned()
827            .collect();
828
829        let missing: Vec<_> = manifest_fields.difference(&struct_fields).collect();
830        assert!(
831            missing.is_empty(),
832            "app-api-manifest.json's agent.send.requestFields names field(s) InjectionRequest doesn't have: {missing:?}"
833        );
834    }
835}