Skip to main content

agentmux_mcp/
identity.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent identity at the MCP boundary, jekt ids and outgoing jekt / UI-automation signatures.
5//! Split out of main.rs unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.2).
6
7use super::*;
8
9/// The calling agent's slug (its `AGENTMUX_AGENT_ID`), injected by AgentMux into
10/// this MCP server's trusted environment. MuxAPI identity/preset/memory
11/// REST endpoints stamp their `agent_id` from this — the agent's own model
12/// output cannot reach those endpoints (no auth key in the PTY) nor override
13/// this value, so the slug cannot be forged. See
14/// SPEC_AGENT_APP_API_MCP_BINDINGS_2026_06_28.md §5.
15pub(crate) fn agent_slug() -> Result<String> {
16    let slug = std::env::var("AGENTMUX_AGENT_ID").unwrap_or_default();
17    if slug.is_empty() {
18        anyhow::bail!(
19            "AGENTMUX_AGENT_ID is not set — cannot resolve this agent's identity. \
20             Is this agent pane opened via AgentMux?"
21        );
22    }
23    Ok(slug)
24}
25
26/// Identity M3 (spec §5): resolve a typed agent name at the boundary — the
27/// only place a name is turned into a UID — before a tool stores it.
28/// `Ok(Some(uid))` when exactly one identified agent matches; `Ok(None)` when
29/// none does, the match has no UID, or the srv predates the endpoint (the
30/// tool then sends the name and the server records the miss). `Err` in two
31/// cases, both of which fail the tool call: the name is ambiguous — the
32/// error lists the candidates so the model can retry by uid (§5.2) — or the
33/// resolver could not answer (a store fault, any other non-2xx, an
34/// unreadable or unexpected response), where sending the bare name would
35/// skip the ambiguity check. See `interpret_resolve_response`.
36pub(crate) async fn resolve_agent_name_at_boundary(
37    client: &reqwest::Client,
38    local_url: &str,
39    auth_key: &str,
40    name: &str,
41) -> Result<Option<String>> {
42    let url = format!("{}/agentmux/agents/resolve", local_url.trim_end_matches('/'));
43    let resp = client
44        .post(&url)
45        .header(AUTH_KEY_HEADER, auth_key)
46        .json(&serde_json::json!({ "name": name }))
47        .send()
48        .await
49        .map_err(|e| anyhow::anyhow!("agent name resolve request failed: {e}"))?;
50    let status = resp.status();
51    let text = resp.text().await.unwrap_or_default();
52    interpret_resolve_response(status, &text, name)
53}
54
55/// The response half of `resolve_agent_name_at_boundary`, separated so the
56/// status handling is testable without a server.
57pub(crate) fn interpret_resolve_response(
58    status: reqwest::StatusCode,
59    text: &str,
60    name: &str,
61) -> Result<Option<String>> {
62    // Only an srv that predates the endpoint (404/405) gets the
63    // compatibility fallback of sending the bare name. Any other failure —
64    // a store fault (503), a panicked handler (500) — fails the tool call:
65    // proceeding by name would skip the ambiguity check and could hand the
66    // item to an exact-same-name other (Codex P2 on #3563).
67    if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::METHOD_NOT_ALLOWED
68    {
69        return Ok(None);
70    }
71    if !status.is_success() {
72        anyhow::bail!("could not resolve agent \"{name}\": HTTP {status} — {text}");
73    }
74    let v: Value = serde_json::from_str(text).map_err(|e| {
75        anyhow::anyhow!("could not resolve agent \"{name}\": unreadable response ({e})")
76    })?;
77    match v.get("resolution").and_then(|r| r.as_str()).unwrap_or("") {
78        "one" => match v
79            .get("uid")
80            .and_then(|u| u.as_str())
81            .filter(|u| !u.is_empty())
82        {
83            Some(uid) => Ok(Some(uid.to_string())),
84            None => anyhow::bail!(
85                "could not resolve agent \"{name}\": a single match came back without a uid"
86            ),
87        },
88        "ambiguous" => {
89            let listed: Vec<String> = v
90                .get("candidates")
91                .and_then(|c| c.as_array())
92                .map(|arr| {
93                    arr.iter()
94                        .map(|c| {
95                            format!(
96                                "  • {} (uid {}{}{})",
97                                c.get("name").and_then(|x| x.as_str()).unwrap_or("?"),
98                                c.get("uid").and_then(|x| x.as_str()).unwrap_or("none"),
99                                c.get("block_id").and_then(|x| x.as_str()).map(|b| format!(", block {b}")).unwrap_or_default(),
100                                if c.get("live").and_then(|x| x.as_bool()).unwrap_or(false) { ", live" } else { "" },
101                            )
102                        })
103                        .collect()
104                })
105                .unwrap_or_default();
106            anyhow::bail!(
107                "Several agents match \"{name}\":\n{}\nAddress one directly by uid, or rename one.",
108                listed.join("\n")
109            )
110        }
111        "none" | "unidentified" => Ok(None),
112        other => {
113            anyhow::bail!("could not resolve agent \"{name}\": unexpected resolution {other:?}")
114        }
115    }
116}
117
118/// Process-wide counter for `generate_jekt_msgid` — millis-timestamp alone
119/// isn't guaranteed unique if two jekts are sent in the same millisecond.
120pub(crate) static JEKT_MSGID_COUNTER: AtomicU64 = AtomicU64::new(0);
121
122/// A unique-enough message id for a signed jekt: no `uuid` dependency needed
123/// (this crate doesn't otherwise pull one in) — timestamp + this process's
124/// own pid + a monotonic counter is unique enough for its one purpose (a
125/// value both this signer and the receiving srv agree to include in the
126/// signed material, so a signature can't be replayed under a different id).
127pub(crate) fn generate_jekt_msgid() -> String {
128    let n = JEKT_MSGID_COUNTER.fetch_add(1, Ordering::Relaxed);
129    format!("{}-{}-{}", agentmux_common::time::now_ms_u64(), std::process::id(), n)
130}
131
132/// `FleetBroadcast`'s block_id -> agent-name resolution
133/// (REPORT_CROSS_INSTANCE_CONTROL_ROBUSTNESS_AUDIT_2026_08_22.md): a `/agentmux/discovery`
134/// response's `host.addressable` AND `host.cross_channel` sections both
135/// carry a `block_id` — `addressable` under `agent_id`/`block_id`,
136/// `cross_channel` (a different channel on this same host) under
137/// `name`/`block_id`. A block_id is unique across channels on one host, so
138/// both are folded into one map, not kept separate. `lan`/`wan` entries
139/// carry no `block_id` at all (they're not local blocks) — a target for
140/// those tiers is never in this map, and the caller falls back to treating
141/// it as a literal agent name instead (see the `FleetBroadcast` handler).
142///
143/// Pure (no I/O) — extracted so it's unit-testable without a live discovery
144/// endpoint.
145pub(crate) fn build_block_to_agent_map(discovery: &Value) -> std::collections::HashMap<String, String> {
146    let mut map: std::collections::HashMap<String, String> = discovery
147        .get("host")
148        .and_then(|h| h.get("addressable"))
149        .and_then(|v| v.as_array())
150        .map(|arr| {
151            arr.iter()
152                .filter_map(|e| {
153                    let agent_id = e.get("agent_id")?.as_str()?.to_string();
154                    let block_id = e.get("block_id")?.as_str()?.to_string();
155                    Some((block_id, agent_id))
156                })
157                .collect()
158        })
159        .unwrap_or_default();
160    if let Some(arr) = discovery.get("host").and_then(|h| h.get("cross_channel")).and_then(|v| v.as_array()) {
161        for e in arr {
162            if let (Some(name), Some(block_id)) = (
163                e.get("name").and_then(|v| v.as_str()),
164                e.get("block_id").and_then(|v| v.as_str()),
165            ) {
166                map.insert(block_id.to_string(), name.to_string());
167            }
168        }
169    }
170    map
171}
172
173/// Build the id/timestamp/host-signature/LAN-signature quadruple for an
174/// outgoing jekt (SPEC_JEKT_TRUST_LAYER_COMPLETION_2026_08_13.md §2.2,
175/// SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.3). Reads this process's OWN
176/// `AGENTMUX_JEKT_KEY`/`AGENTMUX_LAN_KEY` — injected at spawn alongside
177/// `AGENTMUX_AGENT_ID`, into this agent's env only, never any other
178/// agent's — and signs over the same (msgid, source_agent, target_agent,
179/// ts_secs, message) material both schemes share.
180///
181/// Both signatures are computed unconditionally, regardless of which
182/// delivery tier the message actually ends up taking — this process has no
183/// reliable way to know that in advance (routing/forwarding is a
184/// server-side decision, see
185/// `docs/specs/SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md` §3). Sending a
186/// `lan_sig` alongside a message that's actually delivered host or WAN
187/// costs nothing — srv only ever consults `lan_sig` when it has
188/// independently determined `delivery_tier == "lan"` (never trusting the
189/// message body's own claim), so an irrelevant signature is simply ignored.
190///
191/// Returns `(request_id, ts_secs, jekt_sig, lan_sig)`. Either signature is
192/// `None` — not an error — when its key is unavailable (an agent whose
193/// `.mcp.json` predates this feature, or `source_agent` itself unresolved):
194/// srv treats an absent signature as "unverified," never as a reason to
195/// fail delivery, so a missing key here must never block
196/// `SendMessage`/`Loop` from sending.
197///
198/// The cross-channel signature (`channel_sig`,
199/// `SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md` §D5) rides along on the
200/// same terms as `lan_sig`: same `AGENTMUX_LAN_KEY`, but over a
201/// domain-separated payload that also binds this process's channel
202/// (`AGENTMUX_CHANNEL`, injected into this env at spawn by
203/// `inject_jekt_signing_keys_into_mcp_json`; defaults to `stable` exactly as
204/// srv's own registry writer does). srv only consults it once it has itself
205/// labelled the delivery `channel` — a same-machine forward to a different
206/// instance — so on every other tier it's simply ignored.
207///
208/// Returned as a named struct rather than a tuple: the three signatures are
209/// consecutive `Option<String>`s and a transposition at any of the call
210/// sites would compile clean and silently mislabel trust on the receiver.
211/// The WAN signature (`wan_sig`) rides along on exactly the
212/// same terms as `lan_sig`, for the same reason: this process cannot know
213/// which tier a send will take — it posts every send to the local reactive
214/// endpoint and srv decides among local, cross-channel, LAN and cloud
215/// afterwards. Computing it unconditionally is therefore the only
216/// implementable shape, and an irrelevant signature costs nothing because srv
217/// only consults `wan_sig` once it has itself determined the delivery is WAN.
218/// It signs a domain-separated payload under this agent's own
219/// `AGENTMUX_WAN_KEY`, which is a different key from `AGENTMUX_LAN_KEY`.
220pub(crate) struct OutgoingJektSignatures {
221    request_id: String,
222    ts_secs: i64,
223    jekt_sig: Option<String>,
224    lan_sig: Option<String>,
225    source_channel: Option<String>,
226    channel_sig: Option<String>,
227    source_uid: Option<String>,
228    channel_sig_v2: Option<String>,
229    lan_sig_v2: Option<String>,
230    wan_sig: Option<String>,
231    wan_source_host: Option<String>,
232}
233
234impl OutgoingJektSignatures {
235    /// The wire request these signatures were minted for — every send path
236    /// builds it through here so none can forget a field.
237    pub(crate) fn into_request(self, target_agent: String, message: String, source_agent: Option<String>) -> InjectRequest {
238        InjectRequest {
239            target_agent,
240            message,
241            source_agent,
242            request_id: Some(self.request_id),
243            ts_secs: Some(self.ts_secs),
244            jekt_sig: self.jekt_sig,
245            lan_sig: self.lan_sig,
246            source_channel: self.source_channel,
247            channel_sig: self.channel_sig,
248            source_uid: self.source_uid,
249            channel_sig_v2: self.channel_sig_v2,
250            lan_sig_v2: self.lan_sig_v2,
251            wan_sig: self.wan_sig,
252            wan_source_host: self.wan_source_host,
253        }
254    }
255}
256
257/// The request for a jekt to `target_agent`, signed: every send path builds
258/// it here. The target is its route name first (`agent_route_name`: a fork's
259/// "Loap #3" is "Loap-3"), because the signatures bind the target: every hop
260/// routes by the route name, and normalizing a signed request at a hop would
261/// break its signature. A blank target is left blank.
262pub(crate) fn signed_jekt(source_agent: Option<String>, target_agent: &str, message: &str) -> InjectRequest {
263    let target = if target_agent.trim().is_empty() {
264        target_agent.to_string()
265    } else {
266        agentmux_common::slug::agent_route_name(target_agent)
267    };
268    sign_outgoing_jekt(source_agent.as_deref(), &target, message).into_request(target, message.to_string(), source_agent)
269}
270
271pub(crate) fn sign_outgoing_jekt(
272    source_agent: Option<&str>,
273    target_agent: &str,
274    message: &str,
275) -> OutgoingJektSignatures {
276    let msgid = generate_jekt_msgid();
277    let ts_secs = agentmux_common::time::now_secs();
278    // The keys: fetched from srv when they're this agent's, else the env's
279    // (identity M4d-3, self_keys.rs).
280    let jekt_sig = (|| {
281        let key = crate::self_keys::jekt_key()?;
282        let src = source_agent?;
283        Some(agentmux_common::jekt_sign::sign_jekt(&key, &msgid, src, target_agent, ts_secs, message))
284    })();
285    let lan_key = crate::self_keys::lan_key();
286    let lan_sig = (|| {
287        let key = lan_key.as_deref()?;
288        let src = source_agent?;
289        agentmux_common::jekt_sign::sign_lan_jekt(key, &msgid, src, target_agent, ts_secs, message)
290    })();
291    let source_channel = std::env::var("AGENTMUX_CHANNEL").ok().filter(|s| !s.is_empty());
292    let channel_sig = (|| {
293        let key = lan_key.as_deref()?;
294        let src = source_agent?;
295        // Same default srv's registry writer uses (`local_channel_id`): an
296        // unset var means the stable channel, not "unknown".
297        let channel = source_channel.as_deref().unwrap_or("stable");
298        agentmux_common::jekt_sign::sign_channel_jekt(key, &msgid, src, channel, target_agent, ts_secs, message)
299    })();
300    // Identity M4d-6: the same cross-channel and LAN material plus this
301    // agent's UID, signed with its UID-keyed key (fetched, never from env), so
302    // a receiver learns which agent sent it, not just which name.
303    let (source_uid, channel_sig_v2, lan_sig_v2) = match (crate::self_keys::uid_signer(), source_agent) {
304        (Some((uid, key)), Some(src)) => {
305            let channel = source_channel.as_deref().unwrap_or("stable");
306            let sig = agentmux_common::jekt_sign::sign_channel_jekt_v2(&key, &msgid, src, &uid, channel, target_agent, ts_secs, message);
307            let lan = agentmux_common::jekt_sign::sign_lan_jekt_v2(&key, &msgid, src, &uid, target_agent, ts_secs, message);
308            (sig.is_some().then_some(uid), sig, lan)
309        }
310        _ => (None, None, None),
311    };
312    // The WAN signature uses its own key, not
313    // AGENTMUX_LAN_KEY. An agent whose `.mcp.json` predates this feature has
314    // no AGENTMUX_WAN_KEY and simply sends unsigned, exactly as it does today
315    // for every other tier.
316    //
317    // The WAN signature binds this agent's INSTANCE — host and channel — not
318    // just its name (§2.1.2): one account can run the same agent name on
319    // several machines, and on several build channels of one machine, and
320    // each instance mints its own keypair because each has its own database.
321    // The instance is what selects which published key a receiver checks
322    // against, so it has to be inside the signature.
323    let source_host = std::env::var("AGENTMUX_HOST_LABEL").ok().filter(|s| !s.is_empty());
324    let wan_sig = (|| {
325        let key = crate::self_keys::wan_key()?;
326        let src = source_agent?;
327        // Same defaulting discipline as `channel` above: an unset var must
328        // resolve to the identical string srv publishes under, never to a
329        // second spelling of "unknown".
330        let host = source_host.as_deref().unwrap_or("unknown");
331        let channel = source_channel.as_deref().unwrap_or("stable");
332        agentmux_common::jekt_sign::sign_wan_jekt(
333            &key, &msgid, src, host, channel, target_agent, ts_secs, message,
334        )
335    })();
336    // Only declare an instance component when some signature is bound to it —
337    // a bare label with nothing to verify is noise on the wire. `wan_sig` now
338    // binds the channel too, so the channel ships when EITHER signature
339    // exists, not only the cross-channel one.
340    let wan_source_host = wan_sig
341        .as_ref()
342        .map(|_| source_host.unwrap_or_else(|| "unknown".to_string()));
343    let source_channel = (channel_sig.is_some() || channel_sig_v2.is_some() || wan_sig.is_some())
344        .then(|| source_channel.unwrap_or_else(|| "stable".to_string()));
345    OutgoingJektSignatures {
346        request_id: msgid,
347        ts_secs,
348        jekt_sig,
349        lan_sig,
350        source_channel,
351        channel_sig,
352        source_uid,
353        channel_sig_v2,
354        lan_sig_v2,
355        wan_sig,
356        wan_source_host,
357    }
358}
359
360/// Build the identity proof every `/api/v1/ui/*` request carries — an
361/// HMAC-SHA256 signature over this agent's own agent_id, using this
362/// agent's own `AGENTMUX_JEKT_KEY` (the same per-agent key `sign_outgoing_jekt`
363/// above uses for jekt messages; reused rather than inventing a parallel
364/// credential system). Unlike jekt signing, a missing key here is a hard
365/// error, not a silent "send unsigned" — srv has no unverified fallback
366/// path for UI automation (see `crates/srv/src/server/ui_handlers.rs`'s
367/// module doc comment), so a tool call with no key would just 401 anyway;
368/// failing fast with a clear "respawn to get a key" message is more useful
369/// than a confusing round trip.
370pub(crate) fn sign_ui_automation_auth() -> Result<agentmux_common::api_types::UiAutomationAuth> {
371    let agent_id = agent_slug()?;
372    // The jekt key it signs v1 with (identity M4d-3): fetched when ours.
373    let key = crate::self_keys::jekt_key().ok_or_else(|| {
374        anyhow::anyhow!(
375            "AGENTMUX_JEKT_KEY is not set (or not valid base64) and srv served no key — this agent \
376             needs to be respawned to get a signing key before it can use UI automation \
377             (UIScreenshot/UIClick/UIQuery)"
378        )
379    })?;
380    let ts_secs = agentmux_common::time::now_secs();
381    let sig = agentmux_common::jekt_sign::sign_jekt(
382        &key,
383        "ui-automation-identity",
384        &agent_id,
385        "__srv__",
386        ts_secs,
387        "",
388    );
389    Ok(agentmux_common::api_types::UiAutomationAuth { agent_id, ts_secs, sig })
390}
391
392#[cfg(test)]
393mod signed_jekt_tests {
394    use super::signed_jekt;
395
396    /// The target is signed and sent as its route name, so every hop routes
397    /// by the name the fork registered under; an `@` address is left alone.
398    #[test]
399    fn a_display_name_target_is_sent_as_its_route_name() {
400        assert_eq!(signed_jekt(None, "Loap #3", "hi").target_agent, "Loap-3");
401        assert_eq!(signed_jekt(None, "agent2", "hi").target_agent, "agent2");
402        assert_eq!(signed_jekt(None, "agent2@narko", "hi").target_agent, "agent2@narko");
403    }
404}