Skip to main content

agentmux_srv/muxbus/
relay.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Outbound cloud relay — jekt delivery **tier 4**.
5//!
6//! Tiers 1–3 (local handler, same-host registry, LAN peer) all live in
7//! `server/reactive.rs`. Tier 4 was, until this module existed, a comment
8//! there — `// 4. Return original error (muxbus-client will fall back to cloud
9//! relay)` — delegating to a "muxbus-client" that the callers agents actually
10//! use are not. The MCP `SendMessage` tool POSTs to `/agentmux/reactive/inject`
11//! and bails on `success != true`, so the tier chain silently stopped at three
12//! while `SendMessage`'s own description promised "local → LAN → cloud". See
13//! `docs/reports/REPORT_NETWORK_ARCHITECTURE_DRYNESS_AND_ROBUST_LAN_2026_09_06.md` §5.
14//!
15//! This module makes tier 4 real and owned by the same handler as the other
16//! three, so every caller inherits it instead of re-implementing it.
17//!
18//! ## What "success" means here
19//!
20//! Tier 4 is **store-and-forward, not delivery**. The cloud persists the
21//! injection and broadcasts a wake signal; the recipient's srv picks it up on
22//! its next sync, which may be seconds away or never (that instance may be
23//! offline). A 2xx here therefore means *queued*, and [`RelayOutcome::Queued`]
24//! is deliberately not named anything that reads as delivered — the tier-3
25//! forward path had exactly that confusion and it produced a real bug (see
26//! `forward_inject_to_peer`'s "one behaviour unified" note).
27
28use std::sync::Arc;
29
30use crate::backend::storage::store::Store;
31
32/// The cloud rejects anything larger (10 KB maximum). Checked locally so an oversized message fails with a
33/// useful error instead of a bare 400 after a round trip.
34const MAX_RELAY_MESSAGE_BYTES: usize = 10240;
35
36/// Bounds how long a failed local delivery can block on the cloud before the
37/// caller gets its answer. Tier 4 runs only after tiers 1–3 have already
38/// failed, so this is additive to a request that is already slow.
39const RELAY_TIMEOUT_SECS: u64 = 10;
40
41/// Base URL of the muxbus REST API.
42///
43/// The env override exists for tests and for running against a local muxbus
44/// server (`http://localhost:3100`, registered in the dev Cognito app client).
45/// Previously private to `pkce.rs`; promoted here because tier 4 needs the same
46/// resolution and two copies of "where is the cloud" is exactly the drift this
47/// report set out to remove.
48pub(crate) fn rest_base_url() -> String {
49    std::env::var("AGENTMUX_MUXBUS_REST_URL")
50        .ok()
51        .filter(|v| !v.is_empty())
52        .unwrap_or_else(|| crate::muxbus::cloud_subscriber::MUXBUS_REST_URL.to_string())
53}
54
55/// What the desktop's relay calls send as `User-Agent`, so the relay can tell
56/// app versions apart (reqwest sends none of its own).
57pub(crate) const USER_AGENT: &str = concat!("agentmux/", env!("CARGO_PKG_VERSION"));
58
59/// The most agents one batch request may name (the relay's own limit).
60pub(crate) const MAX_BATCH_AGENTS: usize = 50;
61
62/// How long to stop asking a relay that answered 404 to a batch route
63/// (`/reactive/pending-batch`, `/agents/lease/renew-batch`): it predates them.
64const BATCH_ROUTES_RETRY: std::time::Duration = std::time::Duration::from_secs(600);
65
66static BATCH_ROUTES_MISSING: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>> =
67    std::sync::LazyLock::new(Default::default);
68
69/// Whether the relay at `base` recently answered 404 to a batch route. Its
70/// callers then ask per agent, as before the batch routes existed. Keyed by
71/// relay, so a relay without them never switches them off for another.
72pub(crate) fn batch_routes_missing(base: &str) -> bool {
73    BATCH_ROUTES_MISSING
74        .lock()
75        .unwrap_or_else(|e| e.into_inner())
76        .get(base)
77        .is_some_and(|until| *until > std::time::Instant::now())
78}
79
80/// Note a 404 from the relay at `base` to a batch route.
81pub(crate) fn note_batch_routes_missing(base: &str) {
82    tracing::info!("relay has no batch routes yet: asking per agent for now");
83    BATCH_ROUTES_MISSING
84        .lock()
85        .unwrap_or_else(|e| e.into_inner())
86        .insert(base.to_string(), std::time::Instant::now() + BATCH_ROUTES_RETRY);
87}
88
89/// The W3-S carried tuple (`SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md` §2.1):
90/// the sender's WAN signature and the exact values it signed, so a receiver
91/// can re-check it. Field names are the cloud's; an old cloud silently
92/// drops them.
93#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
94pub(crate) struct WanCarried {
95    pub wan_sig: String,
96    pub wan_msg_id: String,
97    pub wan_ts_secs: i64,
98    pub wan_source_agent: String,
99    pub wan_target_agent: String,
100    pub wan_source_host: String,
101    pub wan_source_channel: String,
102    pub wan_key_fp: String,
103}
104
105/// The cloud's caps: signature ≤ 128 chars, identifiers ≤ 256.
106const MAX_CARRIED_SIG_CHARS: usize = 128;
107const MAX_CARRIED_ID_CHARS: usize = 256;
108
109/// The relay carry gate (§2.1): the tuple rides the relay only if **every**
110/// condition holds; otherwise the message is relayed exactly as before, with
111/// nothing carried, so every gap degrades to "unsigned", never to a false
112/// forgery alarm at the receiver. `Err` names the first condition that
113/// failed, for debug logging only.
114///
115/// 1. The sender proved itself on this host (`sig_verified == Some(true)`):
116///    a local process holding the auth key can't launder a captured
117///    `wan_sig` through the relay. Do not drop this gate.
118/// 2. The signed instance and channel are this install's own.
119/// 3. The signature verifies locally under the agent's key in `wan.db`
120///    (a key purged by an agent delete fails here).
121/// 4. The directory this relay sends into — this relay plus the signed-in
122///    account (`wan_publish::directory_id`) — is confirmed to hold that key,
123///    so a receiver can find it. A key published under another account or
124///    relay doesn't count: after a sign-in there, the signature would name a
125///    key the receiver can't fetch.
126/// 5. Both ids are well-formed and every field is within the cloud's caps.
127pub(crate) fn wan_carry_gate(
128    req: &crate::backend::reactive::types::InjectionRequest,
129    wan: Option<&crate::backend::storage::wan_identity::WanIdentityStore>,
130    directory: Option<&str>,
131    local_channel: &str,
132) -> Result<WanCarried, &'static str> {
133    use crate::backend::reactive::sanitize::validate_agent_id;
134
135    let wan = wan.ok_or("no wan.db")?;
136    let sig = req.wan_sig.as_deref().ok_or("unsigned")?;
137    if req.sig_verified != Some(true) {
138        return Err("sender not host-verified");
139    }
140    let source = req.source_agent.as_deref().ok_or("no source agent")?;
141    let msg_id = req.request_id.as_deref().ok_or("no msgid")?;
142    let ts_secs = req.ts_secs.ok_or("no signed timestamp")?;
143    let host = req.wan_source_host.as_deref().ok_or("no instance")?;
144    let channel = req.source_channel.as_deref().ok_or("no channel")?;
145
146    // 2
147    let instance = wan
148        .instance_ensure(&crate::backend::reactive::registry::local_host_label())
149        .map_err(|_| "no instance")?;
150    if host != instance.instance_id {
151        return Err("signed for another instance");
152    }
153    if channel != local_channel {
154        return Err("signed for another channel");
155    }
156    // 3
157    let key = wan.agent_key_load(source).ok().flatten().ok_or("no key in wan.db")?;
158    let public = key.public_key_bytes().ok_or("malformed key")?;
159    if !agentmux_common::jekt_sign::verify_wan_jekt(
160        &public,
161        msg_id,
162        source,
163        host,
164        channel,
165        &req.target_agent,
166        ts_secs,
167        &req.message,
168        sig,
169    ) {
170        return Err("signature does not verify locally");
171    }
172    // 4
173    let directory = directory.ok_or("no account to publish under")?;
174    if !wan.agent_key_is_published_to(source, &key, directory).unwrap_or(false) {
175        return Err("key not published to this directory");
176    }
177    // 5
178    if !validate_agent_id(source) || !validate_agent_id(&req.target_agent) {
179        return Err("malformed agent id");
180    }
181    if sig.len() > MAX_CARRIED_SIG_CHARS
182        || [msg_id, host, channel].iter().any(|s| s.len() > MAX_CARRIED_ID_CHARS)
183        || ts_secs <= 0
184    {
185        return Err("over the cloud's caps");
186    }
187    Ok(WanCarried {
188        wan_sig: sig.to_string(),
189        wan_msg_id: msg_id.to_string(),
190        wan_ts_secs: ts_secs,
191        wan_source_agent: source.to_string(),
192        wan_target_agent: req.target_agent.clone(),
193        wan_source_host: host.to_string(),
194        wan_source_channel: channel.to_string(),
195        wan_key_fp: agentmux_common::jekt_sign::wan_key_fingerprint(&public),
196    })
197}
198
199/// Outcome of a tier-4 relay attempt.
200///
201/// There is deliberately no "cloud not configured" variant: that condition is
202/// answered earlier and more cheaply by [`relay_token`] returning `None`, so
203/// reaching this function at all already means tier 4 exists here.
204#[derive(Debug)]
205pub(crate) enum RelayOutcome {
206    /// The cloud accepted and persisted the injection. **Queued, not
207    /// delivered** — see the module doc.
208    Queued {
209        injection_id: Option<String>,
210        /// Whether the target is one of the sender's own account's agents,
211        /// when the relay says. `Some(false)` is a likely
212        /// typo: the relay accepts any name.
213        target_in_account: Option<bool>,
214    },
215    /// The cloud was reachable and said no, or the request never landed.
216    Failed(String),
217}
218
219/// POST one injection to the cloud relay.
220///
221/// Deliberately takes `base_url` and `token` rather than resolving them
222/// internally: that keeps this function a pure HTTP operation with no globals,
223/// so the tests can point it at a stub relay without mutating process-wide env
224/// (which would race the rest of the test binary).
225///
226/// ## Contract (verified against the cloud relay)
227///
228/// - `source_agent` travels in the **`X-Agent-ID` header, not the body** — the
229///   route 400s without it and derives the sender from it alone.
230/// - `X-Client-Wrapped: true` is required. Without it the cloud wraps the
231///   message in its own `[JEKT:...]` marker before storing; the receiving srv
232///   then wraps it *again* in `Handler::inject_message`, so the recipient sees
233///   a doubled marker. Sending raw + telling the cloud not to wrap yields
234///   exactly one marker, applied by the receiver — identical to how tiers 2
235///   and 3 already behave.
236#[allow(clippy::too_many_arguments)]
237pub(crate) async fn relay_inject(
238    base_url: &str,
239    http: &reqwest::Client,
240    token: &str,
241    source_agent: &str,
242    target_agent: &str,
243    message: &str,
244    priority: &str,
245    wan: Option<&WanCarried>,
246) -> RelayOutcome {
247    if message.len() > MAX_RELAY_MESSAGE_BYTES {
248        return RelayOutcome::Failed(format!(
249            "message is {} bytes; the cloud relay accepts at most {}",
250            message.len(),
251            MAX_RELAY_MESSAGE_BYTES
252        ));
253    }
254
255    let url = format!("{}/reactive/inject", base_url.trim_end_matches('/'));
256    let mut body = serde_json::json!({
257        "target_agent": target_agent,
258        "message": message,
259        "priority": priority,
260    });
261    // W3-S: the carried tuple rides alongside, only when the carry gate
262    // passed. Without it the body is byte-for-byte the old shape.
263    if let (Some(wan), Some(obj)) = (wan, body.as_object_mut()) {
264        if let Ok(serde_json::Value::Object(fields)) = serde_json::to_value(wan) {
265            obj.extend(fields);
266        }
267    }
268    let resp = http
269        .post(&url)
270        .header("Authorization", format!("Bearer {token}"))
271        .header("X-Agent-ID", source_agent)
272        .header("X-Client-Wrapped", "true")
273        .timeout(std::time::Duration::from_secs(RELAY_TIMEOUT_SECS))
274        .json(&body)
275        .send()
276        .await;
277
278    let resp = match resp {
279        Ok(r) => r,
280        Err(e) => return RelayOutcome::Failed(format!("cloud relay unreachable: {e}")),
281    };
282
283    let status = resp.status();
284    if status.is_success() {
285        let body: serde_json::Value = resp.json().await.unwrap_or_default();
286        // The route returns `success: true` alongside the id; treat an
287        // explicit non-true as a failure rather than assuming 2xx means yes —
288        // the same stricter reading tier 2/3 settled on.
289        if body.get("success").and_then(|v| v.as_bool()) != Some(true) {
290            return RelayOutcome::Failed(format!(
291                "cloud relay returned {status} without success:true"
292            ));
293        }
294        return RelayOutcome::Queued {
295            injection_id: body
296                .get("injection_id")
297                .and_then(|v| v.as_str())
298                .map(str::to_string),
299            target_in_account: body.get("target_in_account").and_then(|v| v.as_bool()),
300        };
301    }
302
303    // 402 is the free-tier quota wall, which is a user-actionable condition
304    // rather than a bug — surface the cloud's own message verbatim so the
305    // upgrade URL it includes reaches the operator.
306    let detail = resp.text().await.unwrap_or_default();
307    RelayOutcome::Failed(format!("cloud relay rejected: HTTP {status} — {detail}"))
308}
309
310/// Resolve the credential to relay as, preferring one bound to this specific
311/// sender over the shared account token — the same precedence the inbound
312/// path (`cloud_subscriber::sync_agent_reactive`) already uses, so a
313/// binding-enforced account behaves consistently in both directions.
314///
315/// `None` means the account isn't logged in to muxbus at all, i.e. tier 4
316/// doesn't exist here.
317///
318/// **`store` must be the one muxbus credentials actually live in —
319/// `AppState::id_store`,** the same store `CloudSubscriber::init_global` and
320/// every `muxbus.login`/`status`/`disconnect` handler use. Passing the
321/// per-channel `mstore` finds nothing whenever the shared root resolves (the
322/// normal case), so tier 4 would silently never fire for a logged-in user
323/// (reagent #3023 P0). Note this deliberately does NOT follow `AppState`'s
324/// general steer toward `identity_store` for new muxbus call sites: the
325/// credentials are written to `id_store`, and reading from a store the writer
326/// doesn't use would reintroduce the same bug whenever
327/// `isolated_auth_enabled()` redirects one but not the other.
328///
329/// Ordering matters. The shared account token is a purely local read, so it is
330/// checked FIRST: no credential means the account isn't logged in, and there is
331/// nothing for a per-agent credential to be provisioned against. Doing it the
332/// other way round makes `ensure_agent_credential` attempt a cloud
333/// provisioning round trip (`POST /agents/provision`) on a logged-out instance
334/// — once per failed local inject, i.e. on the hot path of every message to an
335/// unknown agent.
336///
337/// Returns the account too, read from the same shared token: the carry gate
338/// needs the directory this send goes into, and reading the account
339/// separately would let a sign-in in between pair one account's token with
340/// another's directory (Codex P2 on #3994).
341pub(crate) async fn relay_token(
342    source_agent: &str,
343    store: &Arc<Store>,
344    http: &reqwest::Client,
345) -> Option<RelayCredential> {
346    let scheduler = crate::broker::get_global()?;
347    let shared = crate::muxbus::cloud_subscriber::load_valid_token(store, &scheduler).await?;
348    let account_sub = crate::muxbus::pkce::token_sub(&shared);
349
350    let token = match crate::muxbus::agent_credentials::ensure_agent_credential(source_agent, store, http).await {
351        Some(per_agent) => per_agent,
352        None => shared,
353    };
354    // `ensure_agent_credential` loads the account token itself when it
355    // provisions, so a sign-in to another account in between could hand back
356    // the new account's per-agent token while `account_sub` is the old one's
357    // (Codex P2 on #3994). Look again; if the account moved, bind no account
358    // and the send goes unsigned rather than into the wrong directory.
359    let after = crate::muxbus::cloud_subscriber::load_valid_token(store, &scheduler)
360        .await
361        .map(|t| crate::muxbus::pkce::token_sub(&t));
362    Some(RelayCredential { token, account_sub: settled_account(account_sub, after) })
363}
364
365/// The account to bind a send to: the one its credential was resolved
366/// under, if it's still the signed-in one afterwards; empty otherwise.
367fn settled_account(before: String, after: Option<String>) -> String {
368    if after.as_deref() == Some(before.as_str()) {
369        before
370    } else {
371        String::new()
372    }
373}
374
375/// What [`relay_token`] resolved: the bearer to send with, and the account
376/// (`sub`) of the shared token it was resolved under — empty if that token
377/// names none, which leaves the carry gate without a directory (unsigned).
378pub(crate) struct RelayCredential {
379    pub token: String,
380    pub account_sub: String,
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    /// A stub relay capturing the request it receives, so the tests can assert
388    /// on the wire contract (headers especially) and not just the outcome.
389    struct Captured {
390        agent_id: Option<String>,
391        client_wrapped: Option<String>,
392        authorization: Option<String>,
393        body: serde_json::Value,
394    }
395
396    async fn stub_relay(
397        status: axum::http::StatusCode,
398        response_body: &'static str,
399    ) -> (
400        String,
401        std::sync::Arc<std::sync::Mutex<Option<Captured>>>,
402        tokio_util::sync::DropGuard,
403    ) {
404        let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
405        let sink = seen.clone();
406        let app = axum::Router::new().route(
407            "/reactive/inject",
408            axum::routing::post(
409                move |headers: axum::http::HeaderMap, body: axum::Json<serde_json::Value>| {
410                    let sink = sink.clone();
411                    async move {
412                        let get = |k: &str| {
413                            headers
414                                .get(k)
415                                .and_then(|v| v.to_str().ok())
416                                .map(str::to_string)
417                        };
418                        *sink.lock().unwrap() = Some(Captured {
419                            agent_id: get("x-agent-id"),
420                            client_wrapped: get("x-client-wrapped"),
421                            authorization: get("authorization"),
422                            body: body.0,
423                        });
424                        (
425                            status,
426                            [(axum::http::header::CONTENT_TYPE, "application/json")],
427                            response_body,
428                        )
429                    }
430                },
431            ),
432        );
433        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
434        let addr = listener.local_addr().unwrap();
435        let token = tokio_util::sync::CancellationToken::new();
436        let child = token.clone();
437        tokio::spawn(async move {
438            let _ = axum::serve(listener, app)
439                .with_graceful_shutdown(async move { child.cancelled().await })
440                .await;
441        });
442        (format!("http://{addr}"), seen, token.drop_guard())
443    }
444
445    #[tokio::test]
446    async fn a_queued_injection_returns_its_id() {
447        let (url, _seen, _g) = stub_relay(
448            axum::http::StatusCode::OK,
449            r#"{"success":true,"injection_id":"inj-42"}"#,
450        )
451        .await;
452        let out = relay_inject(
453            &url,
454            &reqwest::Client::new(),
455            "tok",
456            "agent2",
457            "clare",
458            "hello",
459            "normal",
460            None,
461        )
462        .await;
463        match out {
464            RelayOutcome::Queued { injection_id, target_in_account } => {
465                assert_eq!(injection_id.as_deref(), Some("inj-42"));
466                // An older relay says nothing about the account.
467                assert_eq!(target_in_account, None)
468            }
469            other => panic!("expected Queued, got {other:?}"),
470        }
471    }
472
473    /// The wire contract the cloud route actually enforces, pinned so a future
474    /// edit can't quietly drop a header the server 400s (or double-wraps)
475    /// without a test failing.
476    #[tokio::test]
477    async fn the_sender_travels_as_a_header_and_wrapping_is_declined() {
478        let (url, seen, _g) =
479            stub_relay(axum::http::StatusCode::OK, r#"{"success":true}"#).await;
480        let _ = relay_inject(
481            &url,
482            &reqwest::Client::new(),
483            "tok",
484            "agent2",
485            "clare",
486            "hello",
487            "urgent",
488            None,
489        )
490        .await;
491
492        let c = seen.lock().unwrap();
493        let c = c.as_ref().expect("stub relay saw no request");
494        assert_eq!(c.agent_id.as_deref(), Some("agent2"), "X-Agent-ID is required by the route");
495        assert_eq!(
496            c.client_wrapped.as_deref(),
497            Some("true"),
498            "without this the cloud wraps the marker and the receiver wraps it again"
499        );
500        assert_eq!(c.authorization.as_deref(), Some("Bearer tok"));
501        // source_agent is NOT a body field — the route reads it from the header.
502        assert!(c.body.get("source_agent").is_none());
503        assert_eq!(c.body.get("target_agent").and_then(|v| v.as_str()), Some("clare"));
504        assert_eq!(c.body.get("message").and_then(|v| v.as_str()), Some("hello"));
505        assert_eq!(c.body.get("priority").and_then(|v| v.as_str()), Some("urgent"));
506    }
507
508    #[tokio::test]
509    async fn a_quota_rejection_surfaces_the_clouds_own_message() {
510        let (url, _seen, _g) = stub_relay(
511            axum::http::StatusCode::PAYMENT_REQUIRED,
512            r#"{"error":"quota_exceeded","upgrade_url":"https://cloud.agentmux.ai/billing"}"#,
513        )
514        .await;
515        let out = relay_inject(
516            &url,
517            &reqwest::Client::new(),
518            "tok",
519            "agent2",
520            "clare",
521            "hello",
522            "normal",
523            None,
524        )
525        .await;
526        match out {
527            RelayOutcome::Failed(e) => {
528                assert!(e.contains("402"), "status should be visible: {e}");
529                assert!(e.contains("upgrade_url"), "cloud's own body should pass through: {e}");
530            }
531            other => panic!("expected Failed, got {other:?}"),
532        }
533    }
534
535    /// The relay says whether the target is one of the
536    /// sender's own account's agents; `false` reaches `SendMessage` as a
537    /// likely-typo hint.
538    #[tokio::test]
539    async fn a_queued_injection_carries_whether_the_target_is_the_senders() {
540        for (body, want) in [
541            (r#"{"success":true,"injection_id":"inj-7","target_in_account":false}"#, Some(false)),
542            (r#"{"success":true,"injection_id":"inj-8","target_in_account":true}"#, Some(true)),
543        ] {
544            let (url, _seen, _g) = stub_relay(axum::http::StatusCode::OK, body).await;
545            let out = relay_inject(&url, &reqwest::Client::new(), "tok", "agent2", "agnetx", "hello", "normal", None).await;
546            match out {
547                RelayOutcome::Queued { target_in_account, .. } => assert_eq!(target_in_account, want, "{body}"),
548                other => panic!("expected Queued, got {other:?}"),
549            }
550        }
551    }
552
553    /// A 2xx that doesn't say `success: true` must not count — the same
554    /// stricter reading the tier-2/3 forward helper settled on after the LAN
555    /// tier was found treating a body without `success` as a delivery.
556    #[tokio::test]
557    async fn a_2xx_without_success_true_is_a_failure() {
558        let (url, _seen, _g) =
559            stub_relay(axum::http::StatusCode::OK, r#"{"injection_id":"inj-1"}"#).await;
560        let out = relay_inject(
561            &url,
562            &reqwest::Client::new(),
563            "tok",
564            "agent2",
565            "clare",
566            "hello",
567            "normal",
568            None,
569        )
570        .await;
571        assert!(matches!(out, RelayOutcome::Failed(_)), "got {out:?}");
572    }
573
574    /// Checked locally so the caller gets a useful message instead of a bare
575    /// 400 after a wasted round trip.
576    #[tokio::test]
577    async fn an_oversized_message_is_rejected_before_any_request() {
578        let big = "x".repeat(MAX_RELAY_MESSAGE_BYTES + 1);
579        let out = relay_inject(
580            "http://127.0.0.1:1", // never contacted
581            &reqwest::Client::new(),
582            "tok",
583            "agent2",
584            "clare",
585            &big,
586            "normal",
587            None,
588        )
589        .await;
590        match out {
591            RelayOutcome::Failed(e) => assert!(e.contains("10240"), "{e}"),
592            other => panic!("expected Failed, got {other:?}"),
593        }
594    }
595
596    #[tokio::test]
597    async fn an_unreachable_relay_fails_rather_than_hanging() {
598        let url = {
599            let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
600            format!("http://{}", l.local_addr().unwrap())
601        };
602        let out = relay_inject(
603            &url,
604            &reqwest::Client::new(),
605            "tok",
606            "agent2",
607            "clare",
608            "hello",
609            "normal",
610            None,
611        )
612        .await;
613        assert!(matches!(out, RelayOutcome::Failed(_)), "got {out:?}");
614    }
615
616    // ── W3-S carry gate (§2.1) ──
617
618    use crate::backend::reactive::types::InjectionRequest;
619    use crate::backend::storage::wan_identity::WanIdentityStore;
620
621    /// A wan.db with a published key for `camper`, and a request exactly as
622    /// the MCP would send it: signed, host-verified, this instance.
623    const TEST_DIR: &str = "https://relay.test#acct";
624
625    #[test]
626    fn a_send_is_bound_to_an_account_only_if_it_held_through_resolution() {
627        // Codex P2 on #3994: a sign-in to another account while the per-agent
628        // credential resolves can pair one account's token with the other's
629        // directory. Then the send gets no account, so it goes unsigned.
630        assert_eq!(settled_account("acct-a".into(), Some("acct-a".into())), "acct-a");
631        assert_eq!(settled_account("acct-a".into(), Some("acct-b".into())), "");
632        assert_eq!(settled_account("acct-a".into(), None), "", "logged out mid-way");
633    }
634
635    fn signed_request() -> (tempfile::TempDir, WanIdentityStore, InjectionRequest) {
636        use base64::Engine as _;
637        let dir = tempfile::tempdir().unwrap();
638        let wan = WanIdentityStore::open(&dir.path().join("wan.db")).unwrap();
639        let instance = wan.instance_ensure("narko").unwrap();
640        let key = wan.agent_key_ensure("camper", None).unwrap();
641        wan.agent_key_mark_published("camper", &key.public_key, TEST_DIR).unwrap();
642        let private = base64::engine::general_purpose::STANDARD.decode(&key.private_key).unwrap();
643        let sig = agentmux_common::jekt_sign::sign_wan_jekt(
644            &private, "msg-1", "camper", &instance.instance_id, "stable", "agent2", 1_790_000_000, "hello",
645        )
646        .unwrap();
647        let req = InjectionRequest {
648            target_agent: "agent2".into(),
649            message: "hello".into(),
650            source_agent: Some("camper".into()),
651            request_id: Some("msg-1".into()),
652            ts_secs: Some(1_790_000_000),
653            sig_verified: Some(true),
654            wan_sig: Some(sig),
655            wan_source_host: Some(instance.instance_id.clone()),
656            source_channel: Some("stable".into()),
657            ..Default::default()
658        };
659        (dir, wan, req)
660    }
661
662    #[test]
663    fn a_signed_host_verified_published_message_is_carried_as_signed() {
664        let (_dir, wan, req) = signed_request();
665        let carried = wan_carry_gate(&req, Some(&wan), Some(TEST_DIR), "stable").expect("every condition holds");
666        let key = wan.agent_key_load("camper").unwrap().unwrap();
667        assert_eq!(carried.wan_sig, req.wan_sig.clone().unwrap());
668        assert_eq!(carried.wan_msg_id, "msg-1");
669        assert_eq!(carried.wan_ts_secs, 1_790_000_000);
670        assert_eq!(carried.wan_source_agent, "camper");
671        assert_eq!(carried.wan_target_agent, "agent2");
672        assert_eq!(carried.wan_source_channel, "stable");
673        assert_eq!(
674            carried.wan_key_fp,
675            agentmux_common::jekt_sign::wan_key_fingerprint(&key.public_key_bytes().unwrap())
676        );
677        // What a receiver will reconstruct verifies against the published record.
678        let instance = wan.instance_ensure("narko").unwrap();
679        let record = crate::muxbus::wan_publish::certify(&instance, "camper", "stable", &key).unwrap();
680        let view = agentmux_common::jekt_sign::WanCarried {
681            sig: &carried.wan_sig,
682            msg_id: &carried.wan_msg_id,
683            ts_secs: carried.wan_ts_secs,
684            source_agent: &carried.wan_source_agent,
685            target_agent: &carried.wan_target_agent,
686            source_host: &carried.wan_source_host,
687            source_channel: &carried.wan_source_channel,
688            key_fp: &carried.wan_key_fp,
689        };
690        assert_eq!(agentmux_common::jekt_sign::verify_wan_against_record(&view, &record, "hello"), Ok(()));
691    }
692
693    #[test]
694    fn each_carry_condition_failing_in_turn_sends_the_message_unsigned() {
695        type Mutation = Box<dyn Fn(&mut InjectionRequest, &WanIdentityStore)>;
696        let cases: Vec<(&str, Mutation)> = vec![
697            ("unsigned", Box::new(|r, _| r.wan_sig = None)),
698            // 1: a local process with the auth key but not the agent's host key.
699            ("not host-verified", Box::new(|r, _| r.sig_verified = None)),
700            ("host verification failed", Box::new(|r, _| r.sig_verified = Some(false))),
701            // 2: an agent spawned before D1 signs its hostname, not the instance id.
702            ("hostname instead of instance id", Box::new(|r, _| r.wan_source_host = Some("narko".into()))),
703            ("another channel", Box::new(|r, _| r.source_channel = Some("beta".into()))),
704            // 3
705            ("tampered message", Box::new(|r, _| r.message = "hello!".into())),
706            ("retargeted", Box::new(|r, _| r.target_agent = "lark".into())),
707            ("key purged by an agent delete", Box::new(|_, w| {
708                w.agent_keys_delete(&["camper".to_string()]).unwrap();
709            })),
710            // 4: re-minted key, not yet published.
711            ("key not published", Box::new(|r, w| {
712                use base64::Engine as _;
713                w.agent_keys_delete(&["camper".to_string()]).unwrap();
714                let key = w.agent_key_ensure("camper", None).unwrap();
715                let private = base64::engine::general_purpose::STANDARD.decode(&key.private_key).unwrap();
716                r.wan_sig = agentmux_common::jekt_sign::sign_wan_jekt(
717                    &private, "msg-1", "camper", r.wan_source_host.as_deref().unwrap(), "stable", "agent2",
718                    1_790_000_000, "hello",
719                );
720            })),
721            ("no signed timestamp", Box::new(|r, _| r.ts_secs = None)),
722            ("no msgid", Box::new(|r, _| r.request_id = None)),
723        ];
724        for (name, mutate) in cases {
725            let (_dir, wan, mut req) = signed_request();
726            mutate(&mut req, &wan);
727            assert!(wan_carry_gate(&req, Some(&wan), Some(TEST_DIR), "stable").is_err(), "{name} was carried");
728        }
729        // And with no wan.db at all.
730        let (_dir, _wan, req) = signed_request();
731        assert_eq!(wan_carry_gate(&req, None, Some(TEST_DIR), "stable"), Err("no wan.db"));
732        // 4, after a sign-in to another account or relay: published there
733        // under the old one only, or no account at all.
734        let (_dir, wan, req) = signed_request();
735        assert_eq!(
736            wan_carry_gate(&req, Some(&wan), Some("https://relay.test#another-account"), "stable"),
737            Err("key not published to this directory")
738        );
739        assert_eq!(wan_carry_gate(&req, Some(&wan), None, "stable"), Err("no account to publish under"));
740    }
741
742    #[tokio::test]
743    async fn the_carried_tuple_rides_the_body_and_its_absence_keeps_the_old_shape() {
744        let (url, seen, _g) = stub_relay(axum::http::StatusCode::OK, r#"{"success":true}"#).await;
745        let (_dir, wan, req) = signed_request();
746        let carried = wan_carry_gate(&req, Some(&wan), Some(TEST_DIR), "stable").unwrap();
747        let _ = relay_inject(&url, &reqwest::Client::new(), "tok", "camper", "agent2", "hello", "normal", Some(&carried))
748            .await;
749        {
750            let c = seen.lock().unwrap();
751            let body = &c.as_ref().unwrap().body;
752            for field in [
753                "wan_sig", "wan_msg_id", "wan_ts_secs", "wan_source_agent", "wan_target_agent",
754                "wan_source_host", "wan_source_channel", "wan_key_fp",
755            ] {
756                assert!(body.get(field).is_some(), "{field} missing from the relay body");
757            }
758            assert_eq!(body["wan_ts_secs"], serde_json::json!(1_790_000_000));
759        }
760
761        let _ = relay_inject(&url, &reqwest::Client::new(), "tok", "camper", "agent2", "hello", "normal", None).await;
762        let c = seen.lock().unwrap();
763        let keys: std::collections::BTreeSet<_> =
764            c.as_ref().unwrap().body.as_object().unwrap().keys().cloned().collect();
765        assert_eq!(
766            keys,
767            ["message", "priority", "target_agent"].into_iter().map(String::from).collect(),
768            "unsigned relays keep exactly the old body"
769        );
770    }
771}