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}