agentmux_srv/server/service/host_ipc.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `host_ipc` service — the paired CEF host pushes its own CDP-automation
5//! credentials (`ipc_port` + `ipc_token`, gating `/agentmux/browser/*` on
6//! the host's own IPC server) here once, at the host's own startup, right
7//! after it learns this srv instance's address. srv has no other way to
8//! learn these values — the host generates `ipc_token` for itself and is
9//! the sole source of truth.
10//!
11//! Backs the `/api/v1/ui/{screenshot,click,query}` proxy routes (agent-
12//! facing UI automation). See `AppState::host_ipc`,
13//! `crates/cef/src/client/helpers.rs::register_ipc_with_backend`, and
14//! `docs/specs/SPEC_AGENT_UI_AUTOMATION_CLICK_SCREENSHOT_2026_08_18.md`.
15//!
16//! `Register` requires a third argument, a shared secret
17//! (`AppState::host_reg_secret` / `AGENTMUX_HOST_REG_SECRET`) known only to
18//! srv and the paired host — never to any agent — proving the caller is
19//! really the host and not an agent process riding the same instance-wide
20//! `X-AuthKey`. See `handle_register`'s doc comments for the full threat
21//! model (reagent P0, PR #2662, 2026-08-19).
22
23use agentmux_common::secret_eq::secret_eq;
24
25use crate::backend::service::{WebCallType, WebReturnType};
26
27use super::super::{AppState, HostIpc};
28
29pub(super) async fn handle_host_ipc_service(state: &AppState, call: &WebCallType) -> WebReturnType {
30 match call.method.as_str() {
31 "Register" => handle_register(state, call).await,
32 _ => WebReturnType::error(format!("unknown host_ipc method: {}", call.method)),
33 }
34}
35
36/// Constant-time comparison of the caller-supplied secret against
37/// `state.host_reg_secret`, via the same "HMAC then `Mac::verify_slice`"
38/// idiom already used for the WhatsApp webhook signature check
39/// (`messaging/whatsapp/webhook.rs`) rather than a manual `==`, so a
40/// mismatch can't leak timing information about how many leading bytes
41/// matched. Not signing anything meaningful — both sides already hold the
42/// same static secret out-of-band (spawn-time env), so this is just a
43/// constant-time equality check dressed as a one-shot MAC.
44/// `pub(super)` so the `credential` service can reuse this exact check
45/// rather than growing a second constant-time comparison — it gates on the
46/// same `AGENTMUX_HOST_REG_SECRET` for the same reason (proving the caller
47/// is the paired host, not an agent riding the shared `X-AuthKey`). The
48/// NONCE below names host_ipc only because that was the first caller; it's
49/// a locally-computed comparison salt on both sides of a single `==`, never
50/// a wire format, so sharing it across callers is safe.
51pub(super) fn secret_matches(known: &str, supplied: &str) -> bool {
52 use hmac::{Hmac, Mac};
53 use sha2::Sha256;
54
55 const NONCE: &[u8] = b"agentmux-host-ipc-register-secret-compare-v1";
56 let Ok(mut expected_mac) = Hmac::<Sha256>::new_from_slice(known.as_bytes()) else {
57 return false;
58 };
59 expected_mac.update(NONCE);
60 let expected = expected_mac.finalize().into_bytes();
61
62 let Ok(mut supplied_mac) = Hmac::<Sha256>::new_from_slice(supplied.as_bytes()) else {
63 return false;
64 };
65 supplied_mac.update(NONCE);
66 supplied_mac.verify_slice(&expected).is_ok()
67}
68
69async fn handle_register(state: &AppState, call: &WebCallType) -> WebReturnType {
70 let port = match call.args.first().and_then(|v| v.as_u64()) {
71 Some(p) if p <= u16::MAX as u64 => p as u16,
72 _ => return WebReturnType::error("host_ipc.Register: args[0] must be a valid port number"),
73 };
74 let token = match call.args.get(1).and_then(|v| v.as_str()) {
75 Some(t) if !t.is_empty() => t.to_string(),
76 _ => return WebReturnType::error("host_ipc.Register: args[1] must be a non-empty token string"),
77 };
78
79 // `/agentmux/service` shares ONE instance-wide `X-AuthKey` across every
80 // caller, including agent-spawned processes (an agent can read that key
81 // from its own environment via a shell command — it's injected there for
82 // other legitimate purposes). host_ipc.Register is different from every
83 // other method on this route: it's meant to be a host-only bootstrap
84 // call, and a spoofed re-registration would silently redirect every
85 // agent's UIScreenshot/UIClick/UIQuery to an attacker-controlled
86 // endpoint for the rest of the session (reagent P0, PR #2662,
87 // 2026-08-19).
88 //
89 // First line of defense: `AGENTMUX_HOST_REG_SECRET`, a credential known
90 // only to srv and the paired host (launcher-mediated in launcher-managed
91 // mode, self-generated-and-passed-at-spawn in host-owned-spawn/dev mode
92 // — see `Config::host_reg_secret`'s doc comment) — never given to any
93 // agent. This is checked BEFORE the `state.host_ipc` None/Some branching
94 // below, so it closes the race reagent found in the same P0 re-review:
95 // when `state.host_ipc` is `None` (every srv startup, and the window
96 // after any `restart_backend` before the host re-registers), there is no
97 // existing registration to probe liveness against, so ANY caller used to
98 // win outright. An agent has no way to learn this secret, so it can never
99 // win that race regardless of `state.host_ipc`'s current value.
100 let supplied_secret = call.args.get(2).and_then(|v| v.as_str()).unwrap_or("");
101 match state.host_reg_secret.as_deref() {
102 None => {
103 tracing::error!(
104 "[host_ipc] REJECTED a Register call — this srv instance has no \
105 AGENTMUX_HOST_REG_SECRET configured, so it cannot verify the caller \
106 is really the paired host. This should never happen in a normal \
107 launcher-managed or host-owned-spawn boot; check that whichever \
108 process spawned this srv instance set that env var."
109 );
110 return WebReturnType::error(
111 "host_ipc.Register: srv has no host-registration secret configured — \
112 refusing to accept any registration",
113 );
114 }
115 Some(known) if secret_matches(known, supplied_secret) => {}
116 Some(_) => {
117 tracing::error!(
118 "[host_ipc] REJECTED a Register call — args[2] did not match this srv \
119 instance's AGENTMUX_HOST_REG_SECRET. Either a caller without this \
120 credential (never an agent — always never given one) is attempting to \
121 spoof the host, or the caller is a genuine host that's out of sync with \
122 srv's current secret (e.g. after a srv recycle, which mints a fresh one)."
123 );
124 return WebReturnType::error(
125 "host_ipc.Register: args[2] does not match this srv instance's \
126 host-registration secret",
127 );
128 }
129 }
130
131 // A conflicting re-registration (different port/token than what's
132 // stored) is NOT rejected outright, though — an earlier version of this
133 // fix did that unconditionally, which broke a real, already-supported
134 // recovery path (reagent P1, same PR, re-review): srv survives a CEF
135 // host crash as a Job Object sibling, and the launcher's crash-budget
136 // relaunch restarts just the host — which legitimately gets a fresh
137 // ipc_port/ipc_token and needs to overwrite the dead registration.
138 // Instead: probe whether the CURRENTLY-registered host is still alive
139 // (its own `/health` route, unauthenticated, same as `/agentmux/browser/*`'s
140 // sibling routes). Still alive → reject (a live legitimate host is not
141 // something a conflicting claim should ever override — this is the
142 // actual hijack case). Unreachable → accept the new registration (the
143 // old host is provably gone; this is crash-restart recovery, not an
144 // attack). An IDENTICAL re-registration (same port/token) is always
145 // accepted as a harmless no-op without needing a liveness probe.
146 let mut guard = state.host_ipc.lock().await;
147 if let Some(existing) = guard.as_ref() {
148 if existing.port == port && secret_eq(existing.token.as_bytes(), token.as_bytes()) {
149 return WebReturnType::success_empty();
150 }
151
152 let still_alive = state
153 .http_client
154 .get(format!("http://127.0.0.1:{}/health", existing.port))
155 .timeout(std::time::Duration::from_millis(750))
156 .send()
157 .await
158 .map(|r| r.status().is_success())
159 .unwrap_or(false);
160
161 if still_alive {
162 tracing::error!(
163 existing_port = existing.port,
164 attempted_port = port,
165 "[host_ipc] REJECTED a conflicting Register call — the currently \
166 registered host is still alive and responding to /health. This is \
167 expected at most once as a benign startup race; if it recurs against a \
168 live host, something is attempting to hijack UI-automation credentials."
169 );
170 return WebReturnType::error(
171 "host_ipc.Register: credentials already registered by a different, \
172 currently-live host",
173 );
174 }
175 tracing::warn!(
176 old_port = existing.port,
177 new_port = port,
178 "[host_ipc] previous registration's host is unreachable — accepting the new \
179 registration (host crash-restart recovery)"
180 );
181 }
182
183 *guard = Some(HostIpc { port, token });
184 tracing::info!(port, "[host_ipc] registered CEF host CDP-automation credentials");
185 drop(guard);
186 // A new (or restarted) host knows nothing of which panes agents own: send
187 // it the whole set; and the popup windows srv knew of are gone or
188 // unknown, so their records and strips go
189 // (SPEC_BROWSER_PANE_NATIVE_POPUPS_AGENT_DRIVEN_2026_10_08.md §3–§4).
190 crate::server::browser_owner::changed().notify_one();
191 crate::server::ui_handlers::reset_popup_windows(state);
192 WebReturnType::success_empty()
193}