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}