Skip to main content

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}