Skip to main content

agentmux_srv/server/
ui_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent-facing UI automation (`UIScreenshot` / `UIClick` / `UIQuery`, and
5//! the browser-pane deep-control tools `BrowserNavigate` / `BrowserBack` /
6//! `BrowserForward` / `BrowserReload` / `BrowserEval` / `BrowserDispatchKey`
7//! / `BrowserFocusElement` / `BrowserFocusInfo`) — proxies to the paired CEF
8//! host's `/agentmux/browser/*` CDP routes (`crates/cef/src/browser_api/`).
9//! See docs/specs/SPEC_AGENT_BROWSER_PANE_DEEP_CONTROL_2026_09_20.md for the
10//! browser-pane tools' own design (they additionally require the caller's
11//! own pane to be a dedicated browser pane — enforced host-side, not here).
12//!
13//! **`block_id` is never a client-supplied field on any request here**
14//! (2026-08-19, reagent + Codex review, PR #2662 — a bare client-supplied
15//! `block_id` was a real cross-agent content-disclosure vulnerability:
16//! `/api/v1/ui/*` shares the same instance-wide `X-AuthKey` every App-API
17//! route trusts, and any agent can read that key from its own environment,
18//! so no amount of "the MCP schema never exposes it" convention actually
19//! stopped a bypassing agent from supplying a different pane's real
20//! `block_id`). Every request instead carries `UiAutomationAuth` — an
21//! HMAC-SHA256 signature over the caller's own agent_id, using that
22//! agent's own `AGENTMUX_JEKT_KEY` (the same per-agent key already used
23//! for jekt sender authentication, see `agentmux_common::jekt_sign`).
24//! [`verified_block_id`] verifies that signature against the claimed
25//! agent's key on file, and — only once verification succeeds — looks up
26//! that agent's actual current block_id server-side via the global
27//! `ReactiveHandler` registry. The block_id a call actually operates on is
28//! never taken from the client at all, so there's nothing left to spoof:
29//! an agent without a valid signature for identity X cannot act as X, full
30//! stop, regardless of what it claims in the request body.
31//!
32//! See `agentmux_common::api_types`'s "UI automation" section and
33//! `docs/specs/SPEC_AGENT_UI_AUTOMATION_CLICK_SCREENSHOT_2026_08_18.md`.
34
35use axum::extract::State;
36use axum::http::StatusCode;
37use axum::response::{IntoResponse, Response};
38use axum::Json;
39use base64::Engine as _;
40use serde_json::json;
41
42use agentmux_common::api_types::{
43    UiAutomationAuth, UiBrowserDispatchKeyRequest, UiBrowserEvalRequest, UiBrowserFocusElementRequest,
44    UiBrowserFocusInfoRequest, UiBrowserHistoryRequest, UiBrowserNavigateRequest, UiBrowserOpenRequest, UiClickRequest,
45    UiQueryRequest, UiScreenshotRequest, UiScreenshotResponse,
46};
47
48use super::{AppState, HostIpc};
49
50/// Signatures older than this are rejected — bounds replay of a leaked/
51/// logged signature to a short window. Mirrors `server/reactive.rs`'s
52/// `JEKT_SIG_MAX_AGE_SECS` (same threat model: a host-tier HMAC signature
53/// verified locally, not a one-time nonce scheme).
54const UI_AUTOMATION_SIG_MAX_AGE_SECS: i64 = 300;
55
56use agentmux_common::time::now_secs as now_unix_secs;
57
58/// Verify `auth` proves the caller genuinely is `auth.agent_id` (via that
59/// agent's own jekt key — an attacker without agent_id's key cannot
60/// produce a valid signature no matter what it claims), then derive that
61/// agent's ACTUAL current block_id server-side. Never trusts a block_id
62/// from the client — there isn't one to trust. See this module's own doc
63/// comment for the full rationale.
64/// `pub(crate)`: also reused by `app_api::pane::handle_close_pane` (and any
65/// future own-pane-resolving handler) — the identity-verification mechanism
66/// isn't UI-automation-specific, just first built for it. See
67/// docs/specs/SPEC_AGENT_PANE_LIFECYCLE_CONTROL_2026_09_10.md §5.0.
68///
69/// Identity M4a-2: `caller` is the request's [`Caller`](crate::server::caller::Caller);
70/// `auth.agent_id` is counted against it (`m4.actor_*.ui_auth`), never refused
71/// on it — M4d moves this check onto `Caller` (spec §6.5.4).
72pub(crate) fn verified_block_id(
73    state: &AppState,
74    caller: Option<&crate::server::caller::Caller>,
75    auth: &UiAutomationAuth,
76) -> Result<String, String> {
77    crate::server::actor::check_actor(
78        state,
79        caller,
80        crate::server::actor::ActorSite::UiAuth,
81        Some(&auth.agent_id),
82    );
83    if let Some(block) = crate::server::actor::own_pane_by_token(&state.mstore, caller, &auth.agent_id) {
84        return Ok(block); // identity M4d-4
85    }
86    crate::backend::agent_resolve::record_uid_fallback("m4d.ui_auth_by_name");
87    if auth.ts_secs <= 0 || (now_unix_secs() - auth.ts_secs).abs() > UI_AUTOMATION_SIG_MAX_AGE_SECS
88    {
89        return Err("signature timestamp missing or outside the freshness window".to_string());
90    }
91    let key = match state.mstore.agent_jekt_key_load(&auth.agent_id) {
92        Ok(Some(k)) => k,
93        Ok(None) => {
94            return Err(format!(
95                "no signing key on file for agent_id={:?} — respawn this agent to get one",
96                auth.agent_id
97            ))
98        }
99        Err(e) => return Err(format!("load signing key: {e}")),
100    };
101    let ok = agentmux_common::jekt_sign::verify_jekt(
102        &key,
103        "ui-automation-identity",
104        &auth.agent_id,
105        "__srv__",
106        auth.ts_secs,
107        "",
108        &auth.sig,
109    );
110    if !ok {
111        tracing::warn!(
112            agent_id = %auth.agent_id,
113            "[ui-automation] REJECTED an invalid identity signature — either a forged \
114             request or a stale/corrupted key"
115        );
116        return Err("identity signature verification failed".to_string());
117    }
118
119    crate::backend::reactive::handler::get_global_handler()
120        .get_agent(&auth.agent_id)
121        .map(|reg| reg.block_id)
122        .ok_or_else(|| {
123            format!(
124                "agent_id={:?} verified but is not currently registered with a pane \
125                 (not spawned via AgentMux, or not yet registered)",
126                auth.agent_id
127            )
128        })
129}
130
131/// The block a UI-automation request acts on: the caller's own pane, or,
132/// when `pane` is given, a browser pane the caller opened with `OpenBrowser`
133/// (docs/specs/SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §3). The caller's
134/// identity is verified first either way; `pane` is then checked against
135/// `browser_owner`'s map, never trusted on its own.
136pub(crate) fn target_block_id(
137    state: &AppState,
138    caller: Option<&crate::server::caller::Caller>,
139    auth: &UiAutomationAuth,
140    pane: Option<&str>,
141) -> Result<String, (StatusCode, String)> {
142    use crate::server::browser_owner::{self, Denied};
143    let own = verified_block_id(state, caller, auth).map_err(|e| (StatusCode::UNAUTHORIZED, e))?;
144    let Some(pane) = pane.map(str::trim).filter(|p| !p.is_empty()) else {
145        return not_waiting_on_user(&own).map(|()| own);
146    };
147    if crate::server::browser_popup::is_window_id(pane) {
148        return popup_window_target(state, pane, &auth.agent_id);
149    }
150    let block = state
151        .mstore
152        .get::<crate::backend::obj::Block>(pane)
153        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("load pane {pane:?}: {e}")))?;
154    match browser_owner::check(block.as_ref(), browser_owner::owner_of(pane).as_deref(), &auth.agent_id, pane) {
155        Ok(()) => {
156            opener_allows(state, block.as_ref(), &auth.agent_id, pane)?;
157            not_waiting_on_user(pane).map(|()| pane.to_string())
158        }
159        Err(Denied::NotFound(m)) => {
160            // The pane is gone: drop any owner entry it left behind.
161            browser_owner::forget(pane);
162            Err((StatusCode::NOT_FOUND, m))
163        }
164        Err(Denied::Forbidden(m)) => Err((StatusCode::FORBIDDEN, m)),
165    }
166}
167
168pub(crate) async fn get_host_ipc(state: &AppState) -> Result<HostIpc, String> {
169    state.host_ipc.lock().await.clone().ok_or_else(|| {
170        "this AgentMux instance's CEF host has not registered its UI-automation \
171         credentials yet (host_ipc.Register) — try again in a moment"
172            .to_string()
173    })
174}
175
176/// POST `body` to `/agentmux/browser/{route}` on the paired host and return
177/// its parsed JSON response (the host's own `{ok, data}` / `{ok, error}`
178/// envelope — see `browser_api::types::ApiResponse`).
179async fn proxy_to_host(
180    state: &AppState,
181    host: &HostIpc,
182    route: &str,
183    body: serde_json::Value,
184) -> Result<serde_json::Value, String> {
185    proxy_to_host_timeout(state, host, route, body, None).await
186}
187
188/// `proxy_to_host` with a request timeout of its own, for host routes that
189/// legitimately take longer than the shared client's default (a snapshot of
190/// a large page, `wait_for`).
191pub(crate) async fn proxy_to_host_timeout(
192    state: &AppState,
193    host: &HostIpc,
194    route: &str,
195    body: serde_json::Value,
196    timeout: Option<std::time::Duration>,
197) -> Result<serde_json::Value, String> {
198    super::host_http::post_to_host(&state.http_client, host, route, body, timeout).await
199}
200
201pub(crate) fn err_response(status: StatusCode, e: String) -> Response {
202    (status, Json(json!({ "ok": false, "error": e }))).into_response()
203}
204
205/// Delete `*.png` files in `dir` older than [`SCREENSHOT_RETENTION`]. Called
206/// on every `UIScreenshot` write since this directory only ever grows
207/// otherwise (reagent P2, PR #2662) — no background scheduler needed for a
208/// directory nothing else writes to. Best-effort: any I/O error for an
209/// individual entry (permissions, a concurrent delete, a non-UTF8 name) is
210/// skipped rather than failing the screenshot request that triggered it.
211const SCREENSHOT_RETENTION: std::time::Duration = std::time::Duration::from_secs(60 * 60);
212
213fn prune_old_screenshots(dir: &std::path::Path) {
214    let Ok(entries) = std::fs::read_dir(dir) else {
215        return;
216    };
217    let now = std::time::SystemTime::now();
218    for entry in entries.flatten() {
219        let path = entry.path();
220        if path.extension().and_then(|e| e.to_str()) != Some("png") {
221            continue;
222        }
223        let Ok(metadata) = entry.metadata() else { continue };
224        let Ok(modified) = metadata.modified() else { continue };
225        let Ok(age) = now.duration_since(modified) else { continue };
226        if age > SCREENSHOT_RETENTION {
227            let _ = std::fs::remove_file(&path);
228        }
229    }
230}
231
232/// `POST /api/v1/ui/screenshot` — backs the `UIScreenshot` MCP tool.
233/// Captures a PNG clipped to the caller's own pane, writes it to
234/// `<mux_data_dir>/tmp/ui-screenshots/<uuid>.png`, and returns both the
235/// path (openable via `OpenMedia`) and the base64 bytes inline.
236pub(crate) async fn handle_ui_screenshot(
237    State(state): State<AppState>,
238    caller: Option<axum::Extension<crate::server::caller::Caller>>,
239    Json(req): Json<UiScreenshotRequest>,
240) -> impl IntoResponse {
241    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
242        Ok(b) => b,
243        Err((code, e)) => return err_response(code, e),
244    };
245    let host = match get_host_ipc(&state).await {
246        Ok(h) => h,
247        Err(e) => return err_response(StatusCode::SERVICE_UNAVAILABLE, e),
248    };
249
250    let host_resp = match proxy_to_host(
251        &state,
252        &host,
253        "screenshot",
254        json!({ "block_id": block_id }),
255    )
256    .await
257    {
258        Ok(v) => v,
259        Err(e) => return err_response(StatusCode::BAD_GATEWAY, e),
260    };
261    if host_resp.get("ok").and_then(|v| v.as_bool()) != Some(true) {
262        let err = host_resp
263            .get("error")
264            .and_then(|v| v.as_str())
265            .unwrap_or("unknown host error")
266            .to_string();
267        return err_response(StatusCode::BAD_REQUEST, err);
268    }
269    let png_base64 = host_resp
270        .get("data")
271        .and_then(|d| d.get("png_base64"))
272        .and_then(|v| v.as_str())
273        .unwrap_or("")
274        .to_string();
275    if png_base64.is_empty() {
276        return err_response(
277            StatusCode::BAD_GATEWAY,
278            "host returned no png_base64".to_string(),
279        );
280    }
281
282    let bytes = match base64::engine::general_purpose::STANDARD.decode(&png_base64) {
283        Ok(b) => b,
284        Err(e) => {
285            return err_response(StatusCode::BAD_GATEWAY, format!("decode png_base64: {e}"))
286        }
287    };
288
289    let dir = crate::backend::base::get_mux_data_dir().join("tmp/ui-screenshots");
290    if let Err(e) = std::fs::create_dir_all(&dir) {
291        return err_response(
292            StatusCode::INTERNAL_SERVER_ERROR,
293            format!("create screenshots dir: {e}"),
294        );
295    }
296    let path = dir.join(format!("{}.png", uuid::Uuid::new_v4()));
297    if let Err(e) = std::fs::write(&path, &bytes) {
298        return err_response(
299            StatusCode::INTERNAL_SERVER_ERROR,
300            format!("write screenshot: {e}"),
301        );
302    }
303    // Unbounded growth over repeated/looped verification calls otherwise
304    // (reagent P2, PR #2662, 2026-08-19) — no expiry mechanism existed at
305    // all. Best-effort, on the write path rather than a background task:
306    // simple and sufficient for a directory that's only ever written here.
307    prune_old_screenshots(&dir);
308
309    tracing::info!(
310        agent_id = %req.auth.agent_id,
311        block_id = %block_id,
312        path = %path.display(),
313        "[ui-automation] screenshot"
314    );
315
316    (
317        StatusCode::OK,
318        Json(UiScreenshotResponse {
319            path: path.to_string_lossy().to_string(),
320            png_base64,
321        }),
322    )
323        .into_response()
324}
325
326/// `POST /api/v1/ui/click` — backs the `UIClick` MCP tool. Synthesizes a
327/// real mouse click at the first `selector` match within the caller's own
328/// pane subtree.
329pub(crate) async fn handle_ui_click(
330    State(state): State<AppState>,
331    caller: Option<axum::Extension<crate::server::caller::Caller>>,
332    Json(req): Json<UiClickRequest>,
333) -> impl IntoResponse {
334    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
335        Ok(b) => b,
336        Err((code, e)) => return err_response(code, e),
337    };
338    if let Err(resp) = refuse_on_driven_pane(&block_id, "UIClick", "BrowserSnapshot, then BrowserClick on a reference") {
339        return resp;
340    }
341    let host = match get_host_ipc(&state).await {
342        Ok(h) => h,
343        Err(e) => return err_response(StatusCode::SERVICE_UNAVAILABLE, e),
344    };
345
346    let host_resp = match proxy_to_host(
347        &state,
348        &host,
349        "click_element",
350        json!({ "block_id": block_id, "selector": req.selector }),
351    )
352    .await
353    {
354        Ok(v) => v,
355        Err(e) => return err_response(StatusCode::BAD_GATEWAY, e),
356    };
357    if host_resp.get("ok").and_then(|v| v.as_bool()) != Some(true) {
358        let err = host_resp
359            .get("error")
360            .and_then(|v| v.as_str())
361            .unwrap_or("unknown host error")
362            .to_string();
363        return err_response(StatusCode::BAD_REQUEST, err);
364    }
365
366    tracing::info!(
367        agent_id = %req.auth.agent_id,
368        block_id = %block_id,
369        selector = %req.selector,
370        "[ui-automation] click"
371    );
372    (StatusCode::OK, Json(json!({ "ok": true }))).into_response()
373}
374
375/// `POST /api/v1/ui/query` — backs the `UIQuery` MCP tool. Returns matched
376/// elements (tag/text/attrs/rect/focused) within the caller's own pane
377/// subtree — the same shape `browser_api::types::QueryData` returns.
378pub(crate) async fn handle_ui_query(
379    State(state): State<AppState>,
380    caller: Option<axum::Extension<crate::server::caller::Caller>>,
381    Json(req): Json<UiQueryRequest>,
382) -> impl IntoResponse {
383    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
384        Ok(b) => b,
385        Err((code, e)) => return err_response(code, e),
386    };
387    let host = match get_host_ipc(&state).await {
388        Ok(h) => h,
389        Err(e) => return err_response(StatusCode::SERVICE_UNAVAILABLE, e),
390    };
391
392    let host_resp = match proxy_to_host(
393        &state,
394        &host,
395        "query",
396        json!({ "block_id": block_id, "selector": req.selector, "limit": req.limit }),
397    )
398    .await
399    {
400        Ok(v) => v,
401        Err(e) => return err_response(StatusCode::BAD_GATEWAY, e),
402    };
403    if host_resp.get("ok").and_then(|v| v.as_bool()) != Some(true) {
404        let err = host_resp
405            .get("error")
406            .and_then(|v| v.as_str())
407            .unwrap_or("unknown host error")
408            .to_string();
409        return err_response(StatusCode::BAD_REQUEST, err);
410    }
411
412    tracing::info!(
413        agent_id = %req.auth.agent_id,
414        block_id = %block_id,
415        selector = %req.selector,
416        "[ui-automation] query"
417    );
418    let data = host_resp.get("data").cloned().unwrap_or(json!({ "matches": [] }));
419    (StatusCode::OK, Json(json!({ "ok": true, "data": data }))).into_response()
420}
421
422/// Proxies `route` with `body` and checks the host's `{ok, ...}` envelope.
423/// Shared by the write-only browser-pane tools (navigate/back/forward/
424/// reload/focus_element/dispatch_key) — they differ only in which
425/// route/body they send, not in how they interpret an ack-only reply.
426pub(crate) async fn proxy_ack(
427    state: &AppState,
428    host: &HostIpc,
429    route: &str,
430    body: serde_json::Value,
431) -> Result<(), Response> {
432    let host_resp = proxy_to_host(state, host, route, body)
433        .await
434        .map_err(|e| err_response(StatusCode::BAD_GATEWAY, e))?;
435    if host_resp.get("ok").and_then(|v| v.as_bool()) != Some(true) {
436        let err = host_resp
437            .get("error")
438            .and_then(|v| v.as_str())
439            .unwrap_or("unknown host error")
440            .to_string();
441        return Err(err_response(StatusCode::BAD_REQUEST, err));
442    }
443    Ok(())
444}
445
446/// `POST /api/v1/ui/browser/navigate` — backs `BrowserNavigate`. Own pane
447/// only, and only when it's a dedicated browser pane (checked host-side).
448pub(crate) async fn handle_ui_browser_navigate(
449    State(state): State<AppState>,
450    caller: Option<axum::Extension<crate::server::caller::Caller>>,
451    Json(req): Json<UiBrowserNavigateRequest>,
452) -> impl IntoResponse {
453    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
454        Ok(b) => b,
455        Err((code, e)) => return err_response(code, e),
456    };
457    // Off the pane's site list: refused outright, without asking the person.
458    if let Some(e) = crate::server::browser_allowlist::refuse_agent_navigation(&banner_for(&block_id).0, &req.url) {
459        return err_response(StatusCode::FORBIDDEN, e);
460    }
461    let host = match get_host_ipc(&state).await {
462        Ok(h) => h,
463        Err(e) => return err_response(StatusCode::SERVICE_UNAVAILABLE, e),
464    };
465    if let Err(resp) = proxy_ack(
466        &state,
467        &host,
468        "navigate",
469        json!({ "block_id": block_id, "url": req.url }),
470    )
471    .await
472    {
473        return resp;
474    }
475    tracing::info!(
476        agent_id = %req.auth.agent_id, block_id = %block_id, url = %req.url,
477        "[ui-automation] browser navigate"
478    );
479    (StatusCode::OK, Json(json!({ "ok": true }))).into_response()
480}
481
482/// `POST /api/v1/ui/browser/back` — backs `BrowserBack`.
483pub(crate) async fn handle_ui_browser_back(
484    State(state): State<AppState>,
485    caller: Option<axum::Extension<crate::server::caller::Caller>>,
486    Json(req): Json<UiBrowserHistoryRequest>,
487) -> impl IntoResponse {
488    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
489        Ok(b) => b,
490        Err((code, e)) => return err_response(code, e),
491    };
492    let host = match get_host_ipc(&state).await {
493        Ok(h) => h,
494        Err(e) => return err_response(StatusCode::SERVICE_UNAVAILABLE, e),
495    };
496    if let Err(resp) = proxy_ack(&state, &host, "back", json!({ "block_id": block_id })).await {
497        return resp;
498    }
499    tracing::info!(agent_id = %req.auth.agent_id, block_id = %block_id, "[ui-automation] browser back");
500    (StatusCode::OK, Json(json!({ "ok": true }))).into_response()
501}
502
503/// `POST /api/v1/ui/browser/forward` — backs `BrowserForward`.
504pub(crate) async fn handle_ui_browser_forward(
505    State(state): State<AppState>,
506    caller: Option<axum::Extension<crate::server::caller::Caller>>,
507    Json(req): Json<UiBrowserHistoryRequest>,
508) -> impl IntoResponse {
509    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
510        Ok(b) => b,
511        Err((code, e)) => return err_response(code, e),
512    };
513    let host = match get_host_ipc(&state).await {
514        Ok(h) => h,
515        Err(e) => return err_response(StatusCode::SERVICE_UNAVAILABLE, e),
516    };
517    if let Err(resp) = proxy_ack(&state, &host, "forward", json!({ "block_id": block_id })).await {
518        return resp;
519    }
520    tracing::info!(agent_id = %req.auth.agent_id, block_id = %block_id, "[ui-automation] browser forward");
521    (StatusCode::OK, Json(json!({ "ok": true }))).into_response()
522}
523
524/// `POST /api/v1/ui/browser/reload` — backs `BrowserReload`.
525pub(crate) async fn handle_ui_browser_reload(
526    State(state): State<AppState>,
527    caller: Option<axum::Extension<crate::server::caller::Caller>>,
528    Json(req): Json<UiBrowserHistoryRequest>,
529) -> impl IntoResponse {
530    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
531        Ok(b) => b,
532        Err((code, e)) => return err_response(code, e),
533    };
534    let host = match get_host_ipc(&state).await {
535        Ok(h) => h,
536        Err(e) => return err_response(StatusCode::SERVICE_UNAVAILABLE, e),
537    };
538    if let Err(resp) = proxy_ack(
539        &state,
540        &host,
541        "reload",
542        json!({ "block_id": block_id, "ignore_cache": req.ignore_cache.unwrap_or(false) }),
543    )
544    .await
545    {
546        return resp;
547    }
548    tracing::info!(agent_id = %req.auth.agent_id, block_id = %block_id, "[ui-automation] browser reload");
549    (StatusCode::OK, Json(json!({ "ok": true }))).into_response()
550}
551
552/// `POST /api/v1/ui/browser/eval` — backs `BrowserEval`. Returns the
553/// host's `{result, type, exception}` shape verbatim under `data`, same
554/// passthrough pattern `handle_ui_query` uses for `matches`.
555pub(crate) async fn handle_ui_browser_eval(
556    State(state): State<AppState>,
557    caller: Option<axum::Extension<crate::server::caller::Caller>>,
558    Json(req): Json<UiBrowserEvalRequest>,
559) -> impl IntoResponse {
560    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
561        Ok(b) => b,
562        Err((code, e)) => return err_response(code, e),
563    };
564    if let Err(resp) = refuse_on_driven_pane(
565        &block_id,
566        "BrowserEval",
567        "BrowserSnapshot to read the page, and BrowserClick/Fill/Select/Check to act on it",
568    ) {
569        return resp;
570    }
571    let host = match get_host_ipc(&state).await {
572        Ok(h) => h,
573        Err(e) => return err_response(StatusCode::SERVICE_UNAVAILABLE, e),
574    };
575    let host_resp = match proxy_to_host(
576        &state,
577        &host,
578        "eval",
579        json!({
580            "block_id": block_id,
581            "script": req.script,
582            "await_promise": req.await_promise.unwrap_or(false),
583        }),
584    )
585    .await
586    {
587        Ok(v) => v,
588        Err(e) => return err_response(StatusCode::BAD_GATEWAY, e),
589    };
590    if host_resp.get("ok").and_then(|v| v.as_bool()) != Some(true) {
591        let err = host_resp
592            .get("error")
593            .and_then(|v| v.as_str())
594            .unwrap_or("unknown host error")
595            .to_string();
596        return err_response(StatusCode::BAD_REQUEST, err);
597    }
598    tracing::info!(agent_id = %req.auth.agent_id, block_id = %block_id, "[ui-automation] browser eval");
599    let data = host_resp
600        .get("data")
601        .cloned()
602        .unwrap_or(json!({ "result": null, "type": "undefined", "exception": null }));
603    (StatusCode::OK, Json(json!({ "ok": true, "data": data }))).into_response()
604}
605
606/// `POST /api/v1/ui/browser/dispatch_key` — backs `BrowserDispatchKey`.
607pub(crate) async fn handle_ui_browser_dispatch_key(
608    State(state): State<AppState>,
609    caller: Option<axum::Extension<crate::server::caller::Caller>>,
610    Json(req): Json<UiBrowserDispatchKeyRequest>,
611) -> impl IntoResponse {
612    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
613        Ok(b) => b,
614        Err((code, e)) => return err_response(code, e),
615    };
616    // Enter submits a form and Space presses the focused button: neither
617    // would pass the approval banner, so on a driven pane they're refused.
618    let presses = matches!(req.key.as_deref(), Some("Enter") | Some("NumpadEnter") | Some("Space"))
619        || req.text.as_deref().is_some_and(|t| t.contains('\n') || t.contains('\r'));
620    if presses {
621        if let Err(resp) = refuse_on_driven_pane(
622            &block_id,
623            "BrowserDispatchKey with Enter or Space",
624            "BrowserClick on the button (it asks the user when the click submits something)",
625        ) {
626            return resp;
627        }
628    }
629    let host = match get_host_ipc(&state).await {
630        Ok(h) => h,
631        Err(e) => return err_response(StatusCode::SERVICE_UNAVAILABLE, e),
632    };
633    if let Err(resp) = proxy_ack(
634        &state,
635        &host,
636        "dispatch_key",
637        json!({
638            "block_id": block_id,
639            "selector": req.selector,
640            "text": req.text,
641            "key": req.key,
642        }),
643    )
644    .await
645    {
646        return resp;
647    }
648    tracing::info!(agent_id = %req.auth.agent_id, block_id = %block_id, "[ui-automation] browser dispatch_key");
649    (StatusCode::OK, Json(json!({ "ok": true }))).into_response()
650}
651
652/// `POST /api/v1/ui/browser/focus_element` — backs `BrowserFocusElement`.
653pub(crate) async fn handle_ui_browser_focus_element(
654    State(state): State<AppState>,
655    caller: Option<axum::Extension<crate::server::caller::Caller>>,
656    Json(req): Json<UiBrowserFocusElementRequest>,
657) -> impl IntoResponse {
658    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
659        Ok(b) => b,
660        Err((code, e)) => return err_response(code, e),
661    };
662    let host = match get_host_ipc(&state).await {
663        Ok(h) => h,
664        Err(e) => return err_response(StatusCode::SERVICE_UNAVAILABLE, e),
665    };
666    if let Err(resp) = proxy_ack(
667        &state,
668        &host,
669        "focus_element",
670        json!({ "block_id": block_id, "selector": req.selector }),
671    )
672    .await
673    {
674        return resp;
675    }
676    tracing::info!(
677        agent_id = %req.auth.agent_id, block_id = %block_id, selector = %req.selector,
678        "[ui-automation] browser focus_element"
679    );
680    (StatusCode::OK, Json(json!({ "ok": true }))).into_response()
681}
682
683/// `POST /api/v1/ui/browser/focus_info` — backs `BrowserFocusInfo`. Returns
684/// the host's `{focused}` shape verbatim under `data`.
685pub(crate) async fn handle_ui_browser_focus_info(
686    State(state): State<AppState>,
687    caller: Option<axum::Extension<crate::server::caller::Caller>>,
688    Json(req): Json<UiBrowserFocusInfoRequest>,
689) -> impl IntoResponse {
690    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
691        Ok(b) => b,
692        Err((code, e)) => return err_response(code, e),
693    };
694    let host = match get_host_ipc(&state).await {
695        Ok(h) => h,
696        Err(e) => return err_response(StatusCode::SERVICE_UNAVAILABLE, e),
697    };
698    let host_resp = match proxy_to_host(&state, &host, "focus_info", json!({ "block_id": block_id })).await {
699        Ok(v) => v,
700        Err(e) => return err_response(StatusCode::BAD_GATEWAY, e),
701    };
702    if host_resp.get("ok").and_then(|v| v.as_bool()) != Some(true) {
703        let err = host_resp
704            .get("error")
705            .and_then(|v| v.as_str())
706            .unwrap_or("unknown host error")
707            .to_string();
708        return err_response(StatusCode::BAD_REQUEST, err);
709    }
710    tracing::info!(agent_id = %req.auth.agent_id, block_id = %block_id, "[ui-automation] browser focus_info");
711    let data = host_resp.get("data").cloned().unwrap_or(json!({ "focused": null }));
712    (StatusCode::OK, Json(json!({ "ok": true, "data": data }))).into_response()
713}
714
715/// `POST /api/v1/ui/browser/open` — backs `OpenBrowser`. Opens a browser pane
716/// next to the caller's own pane and records the caller as its owner, so the
717/// `Browser*`/`UI*` tools accept it as `pane`
718/// (docs/specs/SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §3).
719pub(crate) async fn handle_ui_browser_open(
720    State(state): State<AppState>,
721    caller: Option<axum::Extension<crate::server::caller::Caller>>,
722    Json(req): Json<UiBrowserOpenRequest>,
723) -> impl IntoResponse {
724    let own = match verified_block_id(&state, caller.as_deref(), &req.auth) {
725        Ok(b) => b,
726        Err(e) => return err_response(StatusCode::UNAUTHORIZED, e),
727    };
728    let url = req.url.trim();
729    if !(url.starts_with("https://") || url.starts_with("http://")) {
730        return err_response(
731            StatusCode::BAD_REQUEST,
732            format!("OpenBrowser takes an http:// or https:// URL, not {url:?}"),
733        );
734    }
735    let split = req.split.as_deref().unwrap_or("right");
736    if !matches!(split, "right" | "left" | "up" | "down") {
737        return err_response(
738            StatusCode::BAD_REQUEST,
739            format!("split must be right, left, up or down, not {split:?}"),
740        );
741    }
742    let allowed = match crate::server::browser_allowlist::list_for_open(req.allowed_origins.as_deref(), url) {
743        Ok(a) => a,
744        Err(e) => return err_response(StatusCode::BAD_REQUEST, e),
745    };
746    let mut cmd = crate::backend::rpc_types::CommandPaneOpenData {
747        view: "browser".to_string(),
748        file: None,
749        url: Some(url.to_string()),
750        cwd: None,
751        title: req.title.clone(),
752        tab_id: None,
753        split_direction: Some(split.to_string()),
754        split_reference_block_id: Some(own.clone()),
755        focus: Some(false),
756        tree_expanded: None,
757        floating: None,
758        meta: None,
759        skip_placement: None,
760        stack_onto_block_id: None,
761        connection: None,
762        auth: None,
763        reuse_editor_pane: None,
764        select: None,
765        line: None,
766    };
767    let mut meta = match crate::server::app_api::pane::build_pane_meta(&cmd) {
768        Ok(m) => m,
769        Err(e) => return err_response(StatusCode::BAD_REQUEST, e),
770    };
771    meta.insert(crate::server::browser_owner::OWNER_META_KEY.to_string(), json!(req.auth.agent_id));
772    crate::server::browser_allowlist::mirror(&mut meta, allowed.as_deref());
773    if let Err(e) = crate::server::browser_identity::for_agent_open(&state, &mut meta, req.profile.as_deref()) {
774        return err_response(StatusCode::BAD_REQUEST, e);
775    }
776    cmd.meta = Some(meta);
777    let result = match crate::server::app_api::open_pane(&state, cmd).await {
778        Ok(r) => r,
779        Err(e) => return err_response(StatusCode::INTERNAL_SERVER_ERROR, e),
780    };
781    crate::server::browser_allowlist::limit_new_pane(&state, &result.block_id, allowed).await;
782    crate::server::browser_owner::record(&result.block_id, &req.auth.agent_id);
783    tracing::info!(
784        agent_id = %req.auth.agent_id, own_block = %own, pane = %result.block_id, url = %url,
785        "[ui-automation] browser open (agent-owned)"
786    );
787    (
788        StatusCode::OK,
789        Json(json!({ "ok": true, "data": { "pane": result.block_id } })),
790    )
791        .into_response()
792}
793
794/// Proxy a browser-pane request to the host and pass its `data` through:
795/// the shared tail of the snapshot and act handlers.
796async fn proxy_value(
797    state: &AppState,
798    route: &str,
799    body: serde_json::Value,
800    timeout: std::time::Duration,
801) -> Result<serde_json::Value, (StatusCode, String)> {
802    let host = get_host_ipc(state).await.map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, e))?;
803    let host_resp = proxy_to_host_timeout(state, &host, route, body, Some(timeout))
804        .await
805        .map_err(|e| (StatusCode::BAD_GATEWAY, e))?;
806    if host_resp.get("ok").and_then(|v| v.as_bool()) != Some(true) {
807        let err = host_resp
808            .get("error")
809            .and_then(|v| v.as_str())
810            .unwrap_or("unknown host error")
811            .to_string();
812        return Err((StatusCode::BAD_REQUEST, err));
813    }
814    Ok(host_resp.get("data").cloned().unwrap_or(serde_json::Value::Null))
815}
816
817pub(crate) async fn proxy_data(
818    state: &AppState,
819    route: &str,
820    body: serde_json::Value,
821    timeout: std::time::Duration,
822) -> axum::response::Response {
823    match proxy_value(state, route, body, timeout).await {
824        Ok(data) => (StatusCode::OK, Json(json!({ "ok": true, "data": data }))).into_response(),
825        Err((code, e)) => err_response(code, e),
826    }
827}
828
829/// `POST /api/v1/ui/browser/snapshot` — backs `BrowserSnapshot`
830/// (docs/specs/SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §4.1).
831pub(crate) async fn handle_ui_browser_snapshot(
832    State(state): State<AppState>,
833    caller: Option<axum::Extension<crate::server::caller::Caller>>,
834    Json(req): Json<agentmux_common::api_types::UiBrowserSnapshotRequest>,
835) -> impl IntoResponse {
836    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
837        Ok(b) => b,
838        Err((code, e)) => return err_response(code, e),
839    };
840    tracing::info!(agent_id = %req.auth.agent_id, block_id = %block_id, "[ui-automation] browser snapshot");
841    let mut data = match proxy_value(
842        &state,
843        "snapshot",
844        json!({ "block_id": block_id, "scope": req.scope }),
845        std::time::Duration::from_secs(30),
846    )
847    .await
848    {
849        Ok(d) => d,
850        Err((code, e)) => return err_response(code, e),
851    };
852    // The popups this page opened, which an agent finds no other way
853    // (SPEC_BROWSER_PANE_POPUPS_ADOPTED_2026_10_08.md §3.4).
854    let popups = popup_listing(&state, &block_id, &req.auth.agent_id);
855    if let Some(obj) = data.as_object_mut() {
856        obj.insert("popups".to_string(), json!(popups));
857    }
858    (StatusCode::OK, Json(json!({ "ok": true, "data": data }))).into_response()
859}
860
861/// `opener`'s open popups for its snapshot: each one's pane id, address, and
862/// whether `agent_id` may drive it (not if the person took it over).
863pub(crate) fn popup_listing(state: &AppState, opener: &str, agent_id: &str) -> Vec<serde_json::Value> {
864    use crate::backend::obj::Block;
865    let load = |id: &str| state.mstore.get::<Block>(id).ok().flatten();
866    crate::server::browser_popup::open_popups(opener, |id| load(id).is_some())
867        .into_iter()
868        .filter_map(|pane| {
869            let block = load(&pane)?;
870            let url = block.meta.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
871            let yours = owning_agent(&block, &pane).is_some_and(|a| a.eq_ignore_ascii_case(agent_id));
872            Some(json!({ "pane": pane, "url": url, "yours": yours, "kind": "pane" }))
873        })
874        .chain(crate::server::browser_popup::windows_of(opener).into_iter().map(|(id, w)| {
875            let yours = popup_window_target(state, &id, agent_id).is_ok();
876            json!({ "pane": id, "url": w.url, "yours": yours, "kind": "window" })
877        }))
878        .collect()
879}
880
881/// `POST /api/v1/ui/browser/set_files` — backs `BrowserSetFiles`. Only files
882/// inside the calling agent's own workspace (its block's launch directory,
883/// host agents only) are allowed (spec §5.3).
884pub(crate) async fn handle_ui_browser_set_files(
885    State(state): State<AppState>,
886    caller: Option<axum::Extension<crate::server::caller::Caller>>,
887    Json(req): Json<agentmux_common::api_types::UiBrowserSetFilesRequest>,
888) -> impl IntoResponse {
889    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
890        Ok(b) => b,
891        Err((code, e)) => return err_response(code, e),
892    };
893    let own = match verified_block_id(&state, caller.as_deref(), &req.auth) {
894        Ok(b) => b,
895        Err(e) => return err_response(StatusCode::UNAUTHORIZED, e),
896    };
897    // Where srv launched the agent, not its block's `cmd:cwd`, which any
898    // client can rewrite (spec §5.3).
899    let workspace = crate::server::browser_uploads::agent_workspace(&own);
900    let Some(workspace) = workspace else {
901        return err_response(
902            StatusCode::FORBIDDEN,
903            "uploads come only from an agent's own workspace, and this agent has none \
904             (a container agent, or no launch directory)"
905                .to_string(),
906        );
907    };
908    // The page gets the bytes srv read through each file's checked handle,
909    // never a path the browser would open later (spec §5.3). Up to 25 MB of
910    // file I/O and base64: off the async workers.
911    let requested = req.paths.clone();
912    let read = tokio::task::spawn_blocking(move || {
913        use base64::Engine as _;
914        let paths = crate::server::browser_uploads::check_upload_paths(&workspace, &requested)?;
915        let files = crate::server::browser_uploads::read_upload_files(&workspace, &paths)?;
916        Ok::<_, String>(
917            files
918                .iter()
919                .map(|f| {
920                    json!({
921                        "name": f.name,
922                        "type": f.mime,
923                        "data": base64::engine::general_purpose::STANDARD.encode(&f.bytes),
924                    })
925                })
926                .collect::<Vec<serde_json::Value>>(),
927        )
928    })
929    .await;
930    let files = match read {
931        Ok(Ok(f)) => f,
932        Ok(Err(e)) => return err_response(StatusCode::FORBIDDEN, e),
933        Err(e) => return err_response(StatusCode::INTERNAL_SERVER_ERROR, format!("reading the files failed: {e}")),
934    };
935    let names: Vec<&str> = files.iter().filter_map(|f| f["name"].as_str()).collect();
936    tracing::info!(
937        agent_id = %req.auth.agent_id, block_id = %block_id, r#ref = %req.ref_, files = ?names,
938        "[ui-automation] browser upload"
939    );
940    proxy_data(
941        &state,
942        "set_files",
943        json!({ "block_id": block_id, "ref": req.ref_, "files": files }),
944        std::time::Duration::from_secs(60),
945    )
946    .await
947}
948
949/// `POST /api/v1/ui/browser/wait_for` — backs `BrowserWaitFor`.
950pub(crate) async fn handle_ui_browser_wait_for(
951    State(state): State<AppState>,
952    caller: Option<axum::Extension<crate::server::caller::Caller>>,
953    Json(req): Json<agentmux_common::api_types::UiBrowserWaitForRequest>,
954) -> impl IntoResponse {
955    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
956        Ok(b) => b,
957        Err((code, e)) => return err_response(code, e),
958    };
959    let timeout_ms = req.timeout_ms.unwrap_or(10_000).clamp(250, 60_000);
960    proxy_data(
961        &state,
962        "wait_for",
963        json!({
964            "block_id": block_id,
965            "text": req.text,
966            "url_contains": req.url_contains,
967            "gone": req.gone,
968            "timeout_ms": timeout_ms,
969        }),
970        std::time::Duration::from_millis(timeout_ms + 10_000),
971    )
972    .await
973}
974
975/// On a browser pane an agent drives (opened with `OpenBrowser`), refuse the
976/// general-purpose routes that could submit, send or delete without the
977/// approval banner (spec §5.4): arbitrary script, clicks by CSS selector,
978/// and the keys that press buttons. The reference tools do the same work
979/// with the approval check in front of committing clicks.
980#[allow(clippy::result_large_err)]
981fn refuse_on_driven_pane(block_id: &str, what: &str, instead: &str) -> Result<(), axum::response::Response> {
982    // A popup window has no owner record of its own (it is driven through the
983    // pane that opened it), and it is always driven: the same refusal holds.
984    if crate::server::browser_popup::is_window_id(block_id) {
985        return Err(err_response(
986            StatusCode::FORBIDDEN,
987            format!(
988                "{what} isn't allowed on a popup window: it could submit a form without the \
989                 user's approval. Use {instead}."
990            ),
991        ));
992    }
993    if crate::server::browser_owner::owner_of(block_id).is_none() {
994        return Ok(());
995    }
996    Err(err_response(
997        StatusCode::FORBIDDEN,
998        format!(
999            "{what} isn't allowed on a browser pane you opened with OpenBrowser: it could submit \
1000             a form without the user's approval. Use {instead}."
1001        ),
1002    ))
1003}
1004
1005/// A popup pane (`browser:popup_of`) is driven as part of its opener: while
1006/// the opener exists, the agent must still own it (the person's Take over on
1007/// the opener ends its hold on the popups it opened too), and the opener
1008/// must not be waiting on the person (a hand-off there pauses its popups).
1009/// A popup whose opener was closed stands on its own.
1010fn opener_allows(
1011    state: &AppState,
1012    block: Option<&crate::backend::obj::Block>,
1013    agent_id: &str,
1014    pane: &str,
1015) -> Result<(), (StatusCode, String)> {
1016    // Every pane up the chain, not just the one that opened this popup: a
1017    // popup opened by a popup is still driven as part of the pane the chain
1018    // started in, so Take over or a hand-off anywhere above stops it.
1019    let mut next = block.and_then(popup_of);
1020    for _ in 0..POPUP_CHAIN_LIMIT {
1021        let Some(opener) = next else {
1022            return Ok(());
1023        };
1024        let Ok(Some(opener_block)) = state.mstore.get::<crate::backend::obj::Block>(&opener) else {
1025            // The rest of the chain was closed: from here the popup stands on its own.
1026            return Ok(());
1027        };
1028        if !owning_agent(&opener_block, &opener).is_some_and(|a| a.eq_ignore_ascii_case(agent_id)) {
1029            return Err((
1030                StatusCode::FORBIDDEN,
1031                format!(
1032                    "the user took over pane {opener:?}, which opened popup {pane:?} (directly or \
1033                     through other popups); open a new pane with OpenBrowser if you still need a browser"
1034                ),
1035            ));
1036        }
1037        not_waiting_on_user(&opener)?;
1038        next = popup_of(&opener_block);
1039    }
1040    if next.is_none() {
1041        return Ok(());
1042    }
1043    Err((StatusCode::FORBIDDEN, format!("popup {pane:?} is too many popups deep to drive")))
1044}
1045
1046/// How many openers up a popup's chain the checks follow. A chain this deep
1047/// is a page opening popups from popups on purpose; driving stops there.
1048const POPUP_CHAIN_LIMIT: usize = 8;
1049
1050/// The pane that opened `block` as a popup pane, if it is one.
1051fn popup_of(block: &crate::backend::obj::Block) -> Option<String> {
1052    block
1053        .meta
1054        .get(crate::server::browser_popup::POPUP_OF_META_KEY)
1055        .and_then(|v| v.as_str())
1056        .filter(|o| !o.is_empty())
1057        .map(str::to_string)
1058}
1059
1060/// The pane a chain of popups started in: `pane` itself unless it is a popup
1061/// pane, else its opener's root, as far up as the panes still exist. Popups
1062/// are counted over its whole tree, so a popup opening popups shares its
1063/// root's cap rather than getting its own.
1064fn chain_root(state: &AppState, pane: &str) -> String {
1065    let mut root = pane.to_string();
1066    for _ in 0..POPUP_CHAIN_LIMIT {
1067        let Some(up) = state.mstore.get::<crate::backend::obj::Block>(&root).ok().flatten().as_ref().and_then(popup_of) else {
1068            break;
1069        };
1070        if !matches!(state.mstore.get::<crate::backend::obj::Block>(&up), Ok(Some(_))) {
1071            break;
1072        }
1073        root = up;
1074    }
1075    root
1076}
1077
1078/// A popup window (a native window a pane's page opened, id `popup-…`) may be
1079/// driven by the agent recorded when it opened, while that agent still owns
1080/// the pane that opened it and that pane isn't waiting on the person
1081/// (SPEC_BROWSER_PANE_NATIVE_POPUPS_AGENT_DRIVEN_2026_10_08.md §5).
1082fn popup_window_target(state: &AppState, id: &str, agent_id: &str) -> Result<String, (StatusCode, String)> {
1083    let Some(w) = crate::server::browser_popup::window(id) else {
1084        return Err((StatusCode::NOT_FOUND, format!("no popup window {id:?} (it may have been closed)")));
1085    };
1086    if !w.owner.as_deref().is_some_and(|o| o.eq_ignore_ascii_case(agent_id)) {
1087        return Err((
1088            StatusCode::FORBIDDEN,
1089            format!(
1090                "popup window {id:?} isn't yours: a popup window belongs to the agent that owned \
1091                 the pane that opened it"
1092            ),
1093        ));
1094    }
1095    let Ok(Some(opener)) = state.mstore.get::<crate::backend::obj::Block>(&w.opener) else {
1096        return Err((StatusCode::NOT_FOUND, format!("the pane that opened popup window {id:?} is gone")));
1097    };
1098    if !owning_agent(&opener, &w.opener).is_some_and(|a| a.eq_ignore_ascii_case(agent_id)) {
1099        return Err((
1100            StatusCode::FORBIDDEN,
1101            format!(
1102                "the user took over pane {:?}, which opened popup window {id:?}; open a new pane \
1103                 with OpenBrowser if you still need a browser",
1104                w.opener
1105            ),
1106        ));
1107    }
1108    not_waiting_on_user(&w.opener)?;
1109    // The opener may itself be a popup pane: the whole chain above it counts.
1110    opener_allows(state, Some(&opener), agent_id, id)?;
1111    Ok(id.to_string())
1112}
1113
1114/// Where a request to the person about `target` shows: in its own banner,
1115/// or, for a popup window (which has no AgentMux header), in its opener's,
1116/// with the window's address so the banner can say which window it means.
1117pub(crate) fn banner_for(target: &str) -> (String, Option<String>) {
1118    if crate::server::browser_popup::is_window_id(target) {
1119        if let Some(w) = crate::server::browser_popup::window(target) {
1120            return (w.opener, Some(w.url));
1121        }
1122    }
1123    (target.to_string(), None)
1124}
1125
1126/// Refuse a tool call on a pane that is waiting for the user: a hand-off
1127/// (they're working in it) or an approval (they're reading the form the
1128/// approved click will send, which mustn't change under them).
1129fn not_waiting_on_user(pane: &str) -> Result<(), (StatusCode, String)> {
1130    use crate::server::browser_attention::{waiting_on_user, Kind};
1131    match waiting_on_user(pane) {
1132        None => Ok(()),
1133        Some(Kind::Handoff) => Err((
1134            StatusCode::CONFLICT,
1135            format!(
1136                "the user is working in pane {pane:?} (your BrowserHandoff); your tools on it are \
1137                 paused until they click Done or Cancel"
1138            ),
1139        )),
1140        Some(Kind::Approval) => Err((
1141            StatusCode::CONFLICT,
1142            format!(
1143                "pane {pane:?} is waiting for the user to approve your click; your tools on it are \
1144                 paused until they answer"
1145            ),
1146        )),
1147        Some(Kind::Navigation) => Err((
1148            StatusCode::CONFLICT,
1149            format!("the page in pane {pane:?} tried to leave its allowed_origins and the user is being asked whether to allow it; your tools on it are paused until they answer"),
1150        )),
1151    }
1152}
1153
1154/// `POST /api/v1/ui/browser/handoff` — backs `BrowserHandoff` (spec §5.2):
1155/// a banner in the pane asks the user to do something the agent mustn't
1156/// (sign in, a CAPTCHA), and the call waits for their Done or Cancel. The
1157/// agent's tools on that pane are refused meanwhile (`target_block_id`).
1158pub(crate) async fn handle_ui_browser_handoff(
1159    State(state): State<AppState>,
1160    caller: Option<axum::Extension<crate::server::caller::Caller>>,
1161    Json(req): Json<agentmux_common::api_types::UiBrowserHandoffRequest>,
1162) -> impl IntoResponse {
1163    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
1164        Ok(b) => b,
1165        Err((code, e)) => return err_response(code, e),
1166    };
1167    // A popup window's request shows on its opener's banner.
1168    let (banner_at, in_window) = banner_for(&block_id);
1169    // Only a browser pane shows the banner; anywhere else the request would
1170    // lock the agent's own tools with nothing for the user to click.
1171    let is_browser = state
1172        .mstore
1173        .get::<crate::backend::obj::Block>(&banner_at)
1174        .ok()
1175        .flatten()
1176        .is_some_and(|b| b.meta.get("view").and_then(|v| v.as_str()) == Some("browser"));
1177    if !is_browser {
1178        return err_response(
1179            StatusCode::BAD_REQUEST,
1180            "BrowserHandoff needs a browser pane: pass the `pane` OpenBrowser returned".to_string(),
1181        );
1182    }
1183    let reason: String = req.reason.trim().chars().take(300).collect();
1184    if reason.is_empty() {
1185        return err_response(StatusCode::BAD_REQUEST, "say what the user should do (`reason`)".to_string());
1186    }
1187    // Only the host relays the user's Done or Cancel (`/api/v1/host/browser_attention`).
1188    // With none connected nobody can answer: refuse now rather than hold the
1189    // agent's tools on this pane until the timeout.
1190    if let Err(e) = crate::server::app_api::connections::host_to_ask_user(&state).await {
1191        return err_response(StatusCode::SERVICE_UNAVAILABLE, e);
1192    }
1193    let minutes = req.timeout_minutes.unwrap_or(15).clamp(1, 60);
1194    tracing::info!(agent_id = %req.auth.agent_id, block_id = %block_id, "[ui-automation] browser hand-off");
1195    let answer = match crate::server::browser_attention::ask(
1196        &state,
1197        &banner_at,
1198        &req.auth.agent_id,
1199        crate::server::browser_attention::Kind::Handoff,
1200        json!({ "reason": reason, "window": in_window }),
1201        std::time::Duration::from_secs(minutes * 60),
1202    )
1203    .await
1204    {
1205        Ok((a, _waiting)) => a,
1206        Err(e) => return err_response(StatusCode::CONFLICT, e),
1207    };
1208    let answer = match answer {
1209        crate::server::browser_attention::Answer::Yes => "done",
1210        crate::server::browser_attention::Answer::Cancelled => "cancelled",
1211        crate::server::browser_attention::Answer::TimedOut => "timed_out",
1212    };
1213    (StatusCode::OK, Json(json!({ "ok": true, "data": { "answer": answer } }))).into_response()
1214}
1215
1216/// `POST /api/v1/host/browser_attention` — the user's answer to a hand-off
1217/// or approval banner, relayed by the CEF host. Only the host can call it:
1218/// `X-Host-Token` must be the IPC token the host registered with
1219/// (`host_ipc.Register`), which agents never see; the instance auth key
1220/// alone, which every agent has, isn't enough.
1221pub(crate) async fn handle_host_browser_attention(
1222    State(state): State<AppState>,
1223    headers: axum::http::HeaderMap,
1224    Json(body): Json<serde_json::Value>,
1225) -> impl IntoResponse {
1226    if !from_host(&state, &headers).await {
1227        return err_response(StatusCode::FORBIDDEN, "only the AgentMux host can answer a pane's request".to_string());
1228    }
1229    let s = |k: &str| body.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
1230    let (block_id, id, decision) = (s("block_id"), s("id"), s("decision"));
1231    let Some(answer) = crate::server::browser_attention::Answer::parse(&decision) else {
1232        return err_response(StatusCode::BAD_REQUEST, format!("unknown decision {decision:?}"));
1233    };
1234    match crate::server::browser_attention::resolve(&id, &block_id, answer) {
1235        Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))).into_response(),
1236        Err(e) => {
1237            // Nothing waits on that banner any more: take it down.
1238            crate::server::browser_attention::clear_stale(&state, &block_id, &id);
1239            err_response(StatusCode::NOT_FOUND, e)
1240        }
1241    }
1242}
1243
1244/// Is this request from the CEF host? `X-Host-Token` must be the IPC token
1245/// the host registered with (`host_ipc.Register`), which agents never see;
1246/// the instance auth key alone, which every agent has, isn't enough.
1247pub(crate) async fn from_host(state: &AppState, headers: &axum::http::HeaderMap) -> bool {
1248    let presented = headers.get("x-host-token").and_then(|v| v.to_str().ok()).unwrap_or("");
1249    let registered = state.host_ipc.lock().await.clone().map(|h| h.token).unwrap_or_default();
1250    !registered.is_empty()
1251        && !presented.is_empty()
1252        && agentmux_common::secret_eq::secret_eq(presented.as_bytes(), registered.as_bytes())
1253}
1254
1255/// The agent that owns `block_id`, if one does and the person hasn't taken
1256/// the pane over: the same test every `pane` argument passes.
1257fn owning_agent(block: &crate::backend::obj::Block, block_id: &str) -> Option<String> {
1258    let agent = crate::server::browser_owner::owner_of(block_id)?;
1259    crate::server::browser_owner::check(Some(block), Some(agent.as_str()), &agent, block_id)
1260        .ok()
1261        .map(|()| agent)
1262}
1263
1264/// `POST /api/v1/host/browser_popup` — a browser pane's page opened a popup
1265/// (docs/specs/SPEC_BROWSER_PANE_POPUPS_ADOPTED_2026_10_08.md). Only the host
1266/// calls it, from `on_before_popup`. Answers `{admitted: true, pane}` when
1267/// srv opened the popup as a pane beside its opener, or `{admitted: false,
1268/// reason}`, and the host opens it in the system browser as it always did.
1269pub(crate) async fn handle_host_browser_popup(
1270    State(state): State<AppState>,
1271    headers: axum::http::HeaderMap,
1272    Json(body): Json<serde_json::Value>,
1273) -> impl IntoResponse {
1274    use crate::server::browser_popup as popup;
1275    if !from_host(&state, &headers).await {
1276        return err_response(StatusCode::FORBIDDEN, "only the AgentMux host can report a popup".to_string());
1277    }
1278    let s = |k: &str| body.get(k).and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
1279    let (opener, url, opener_url) = (s("opener"), s("url"), s("opener_url"));
1280    let user_gesture = body.get("user_gesture").and_then(|v| v.as_bool()).unwrap_or(false);
1281    let refused = |reason: &str| {
1282        (StatusCode::OK, Json(json!({ "ok": true, "data": { "admitted": false, "reason": reason } }))).into_response()
1283    };
1284    let block = match state.mstore.get::<crate::backend::obj::Block>(&opener) {
1285        Ok(Some(b)) => b,
1286        Ok(None) => return refused("the pane that opened it is gone"),
1287        Err(e) => return err_response(StatusCode::INTERNAL_SERVER_ERROR, format!("load pane {opener:?}: {e}")),
1288    };
1289    if block.meta.get("view").and_then(|v| v.as_str()) != Some("browser") {
1290        return refused("it was not opened by a browser pane");
1291    }
1292    // The opener's owner, if the whole chain above it (when the opener is a
1293    // popup pane itself) is still that agent's: a popup from a popup of a pane
1294    // the person took over belongs to nobody.
1295    let owner = owning_agent(&block, &opener)
1296        .filter(|agent| opener_allows(&state, Some(&block), agent, &opener).is_ok());
1297    if let Some(why) = crate::server::browser_allowlist::refuse_popup(&opener, &url) { return refused(why); }
1298    // The count and the slot are taken together: two popups reported at once
1299    // can't both fit under the cap. The slot is given back if the pane
1300    // doesn't open.
1301    let root = chain_root(&state, &opener);
1302    let reservation = match popup::reserve(
1303        &opener,
1304        &root,
1305        |id| matches!(state.mstore.get::<crate::backend::obj::Block>(id), Ok(Some(_))),
1306        |taken| popup::decide(&url, &opener_url, user_gesture, owner.is_some(), taken),
1307    ) {
1308        Ok(r) => r,
1309        Err(r) => {
1310            tracing::info!(opener = %opener, url = %url, reason = r.reason(), "[browser-popup] not opened as a pane");
1311            return refused(r.reason());
1312        }
1313    };
1314
1315    let mut cmd = crate::backend::rpc_types::CommandPaneOpenData {
1316        view: "browser".to_string(),
1317        file: None,
1318        url: Some(url.clone()),
1319        cwd: None,
1320        title: None,
1321        tab_id: None,
1322        split_direction: Some("right".to_string()),
1323        split_reference_block_id: Some(opener.clone()),
1324        // The person clicked: show them the popup. An agent's popup opens
1325        // beside its pane without taking the person's focus, like OpenBrowser.
1326        focus: Some(owner.is_none()),
1327        tree_expanded: None,
1328        floating: None,
1329        meta: None,
1330        skip_placement: None,
1331        stack_onto_block_id: None,
1332        connection: None,
1333        auth: None,
1334        reuse_editor_pane: None,
1335        select: None,
1336        line: None,
1337    };
1338    let mut meta = match crate::server::app_api::pane::build_pane_meta(&cmd) {
1339        Ok(m) => m,
1340        Err(e) => return err_response(StatusCode::BAD_REQUEST, e),
1341    };
1342    popup::mark_popup_meta(&mut meta, &opener, &opener_url);
1343    crate::server::browser_identity::inherit(&mut meta, &block);
1344    // On the new pane from the start, so the host has it before the first load.
1345    crate::server::browser_allowlist::mirror(&mut meta, crate::server::browser_allowlist::list_for(&opener).as_deref());
1346    if let Some(agent) = &owner {
1347        meta.insert(crate::server::browser_owner::OWNER_META_KEY.to_string(), json!(agent));
1348    }
1349    cmd.meta = Some(meta);
1350    let result = match crate::server::app_api::open_pane(&state, cmd).await {
1351        Ok(r) => r,
1352        Err(e) => return err_response(StatusCode::INTERNAL_SERVER_ERROR, e),
1353    };
1354    let joined = crate::server::browser_allowlist::join(&result.block_id, &opener);
1355    // The opener's owner owns its popup, from srv's own record, never from the
1356    // page or a client. Checked again now the pane is open: the person may have
1357    // taken the opener over meanwhile, and then the agent gets neither.
1358    let mut owner = owner;
1359    if let Some(agent) = owner.clone() {
1360        let still = state
1361            .mstore
1362            .get::<crate::backend::obj::Block>(&opener)
1363            .ok()
1364            .flatten()
1365            .and_then(|b| owning_agent(&b, &opener))
1366            .is_some_and(|a| a.eq_ignore_ascii_case(&agent));
1367        if still {
1368            crate::server::browser_owner::record(&result.block_id, &agent);
1369        } else {
1370            owner = None;
1371            let mut clear = crate::backend::obj::MetaMapType::new();
1372            clear.insert(crate::server::browser_owner::OWNER_META_KEY.to_string(), serde_json::Value::Null);
1373            if let Err(e) = crate::server::http_shell::broadcast_meta_update(&state, &result.block_id, &clear) {
1374                tracing::warn!(pane = %result.block_id, error = %e, "[browser-popup] couldn't clear the popup's owner");
1375            }
1376        }
1377    }
1378    reservation.commit(&result.block_id);
1379    crate::server::browser_allowlist::settle_popup_pane(&state, &result.block_id, joined, owner.is_some()).await;
1380    tracing::info!(
1381        opener = %opener, pane = %result.block_id, url = %url, owner = ?owner,
1382        "[browser-popup] popup opened as a pane"
1383    );
1384    (
1385        StatusCode::OK,
1386        Json(json!({ "ok": true, "data": { "admitted": true, "pane": result.block_id } })),
1387    )
1388        .into_response()
1389}
1390
1391/// `POST /api/v1/host/browser_popup_window` — the host created, navigated
1392/// or closed a popup window (a native window a browser pane's page opened;
1393/// SPEC_BROWSER_PANE_NATIVE_POPUPS_AGENT_DRIVEN_2026_10_08.md §4). Body
1394/// `{event: "opened"|"navigated"|"closed", popup, opener, url}`. Only the
1395/// host can call it. The window's owner is the opener's, from srv's own
1396/// record; the opener's popup-windows strip is kept current.
1397pub(crate) async fn handle_host_browser_popup_window(
1398    State(state): State<AppState>,
1399    headers: axum::http::HeaderMap,
1400    Json(body): Json<serde_json::Value>,
1401) -> impl IntoResponse {
1402    use crate::server::browser_popup as popup;
1403    if !from_host(&state, &headers).await {
1404        return err_response(StatusCode::FORBIDDEN, "only the AgentMux host can report a popup window".to_string());
1405    }
1406    let s = |k: &str| body.get(k).and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
1407    let (event, id, opener, url) = (s("event"), s("popup"), s("opener"), s("url"));
1408    if !popup::is_window_id(&id) {
1409        return err_response(StatusCode::BAD_REQUEST, format!("not a popup window id: {id:?}"));
1410    }
1411    let touched = match event.as_str() {
1412        "opened" => {
1413            let block = match state.mstore.get::<crate::backend::obj::Block>(&opener) {
1414                Ok(Some(b)) if b.meta.get("view").and_then(|v| v.as_str()) == Some("browser") => b,
1415                // Not from a browser pane srv knows: nobody drives it.
1416                _ => return (StatusCode::OK, Json(json!({ "ok": true }))).into_response(),
1417            };
1418            let owner = owning_agent(&block, &opener)
1419                .filter(|agent| opener_allows(&state, Some(&block), agent, &opener).is_ok());
1420            tracing::info!(popup = %id, opener = %opener, url = %url, owner = ?owner, "[browser-popup] popup window opened");
1421            popup::window_opened(&id, &opener, &url, owner);
1422            Some(opener)
1423        }
1424        "navigated" => popup::window_navigated(&id, &url),
1425        "closed" => {
1426            tracing::info!(popup = %id, "[browser-popup] popup window closed");
1427            popup::window_closed(&id)
1428        }
1429        other => return err_response(StatusCode::BAD_REQUEST, format!("unknown event {other:?}")),
1430    };
1431    if let Some(opener) = touched {
1432        update_popup_windows_strip(&state, &opener);
1433    }
1434    (StatusCode::OK, Json(json!({ "ok": true }))).into_response()
1435}
1436
1437/// Forget every popup window and clear every opener's strip. Called when the
1438/// host registers: after either side restarts, the windows srv knew of are
1439/// gone or no longer known, and the strip, which is saved with the pane, must
1440/// not keep listing them.
1441pub(crate) fn reset_popup_windows(state: &AppState) {
1442    use crate::server::browser_popup as popup;
1443    // The windows opened from this srv's panes (in a test, other srvs share
1444    // the record).
1445    popup::clear_windows(|opener| matches!(state.mstore.get::<crate::backend::obj::Block>(opener), Ok(Some(_))));
1446    let blocks = match state.mstore.get_all::<crate::backend::obj::Block>() {
1447        Ok(b) => b,
1448        Err(e) => {
1449            tracing::warn!(error = %e, "[browser-popup] couldn't list panes to clear popup-window strips");
1450            return;
1451        }
1452    };
1453    for b in blocks.into_iter().filter(|b| b.meta.get(popup::POPUP_WINDOWS_META_KEY).is_some_and(|v| !v.is_null())) {
1454        let mut meta = crate::backend::obj::MetaMapType::new();
1455        meta.insert(popup::POPUP_WINDOWS_META_KEY.to_string(), serde_json::Value::Null);
1456        if let Err(e) = crate::server::http_shell::broadcast_meta_update(state, &b.oid, &meta) {
1457            tracing::debug!(block = %b.oid, error = %e, "[browser-popup] stale popup-window strip not cleared");
1458        }
1459    }
1460}
1461
1462/// Rewrite `opener`'s popup-windows strip (`browser:popup_windows`). The
1463/// opener may be closing; then there's nothing to update.
1464fn update_popup_windows_strip(state: &AppState, opener: &str) {
1465    use crate::server::browser_popup as popup;
1466    let value = popup::strip_value(opener);
1467    let value = if value.as_array().is_some_and(|a| a.is_empty()) { serde_json::Value::Null } else { value };
1468    let mut meta = crate::backend::obj::MetaMapType::new();
1469    meta.insert(popup::POPUP_WINDOWS_META_KEY.to_string(), value);
1470    if let Err(e) = crate::server::http_shell::broadcast_meta_update(state, opener, &meta) {
1471        tracing::debug!(opener = %opener, error = %e, "[browser-popup] popup-windows strip not updated");
1472    }
1473}
1474
1475/// `POST /api/v1/ui/browser/act` — backs `BrowserClick`, `BrowserFill`,
1476/// `BrowserSelect` and `BrowserCheck` (spec §4.2).
1477pub(crate) async fn handle_ui_browser_act(
1478    State(state): State<AppState>,
1479    caller: Option<axum::Extension<crate::server::caller::Caller>>,
1480    Json(req): Json<agentmux_common::api_types::UiBrowserActRequest>,
1481) -> impl IntoResponse {
1482    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, req.pane.as_deref()) {
1483        Ok(b) => b,
1484        Err((code, e)) => return err_response(code, e),
1485    };
1486    // The typed text isn't logged: it can be anything the agent was given.
1487    tracing::info!(
1488        agent_id = %req.auth.agent_id, block_id = %block_id, action = %req.action, r#ref = %req.ref_,
1489        "[ui-automation] browser act"
1490    );
1491    // `approved` carries what the user was shown: the host checks the form
1492    // still matches it before clicking (the page's own script could have
1493    // changed it while they read the banner).
1494    let body = |approved: Option<&serde_json::Value>| {
1495        json!({
1496            "block_id": block_id,
1497            "ref": req.ref_,
1498            "action": req.action,
1499            "text": req.text,
1500            "option": req.option,
1501            "checked": req.checked,
1502            "approved": approved.is_some(),
1503            "approved_as": approved,
1504        })
1505    };
1506    let first = match proxy_value(&state, "act", body(None), std::time::Duration::from_secs(20)).await {
1507        Ok(d) => d,
1508        Err((code, e)) => return err_response(code, e),
1509    };
1510    // A committing click (submit, send, pay, delete, ...) waits for the
1511    // user's approval in the pane; only their click there lets it through
1512    // (spec §5.4).
1513    let Some(ask) = first.pointer("/after/needs_approval").cloned() else {
1514        return (StatusCode::OK, Json(json!({ "ok": true, "data": first }))).into_response();
1515    };
1516    tracing::info!(agent_id = %req.auth.agent_id, block_id = %block_id, "[ui-automation] browser act waits for approval");
1517    // `_waiting` keeps the pane locked until the approved click is done, so
1518    // the form can't change between what the user approved and what's sent.
1519    // The banner shows the summary, not the fingerprint of everything the
1520    // click sends (hidden controls included): that stays here, for the check
1521    // the host makes before the approved click.
1522    let (banner_at, in_window) = banner_for(&block_id);
1523    let mut banner = ask.clone();
1524    if let Some(b) = banner.as_object_mut() {
1525        b.remove("fingerprint");
1526        b.insert("window".to_string(), json!(in_window));
1527    }
1528    let owner_before = crate::server::browser_owner::owner_of(&banner_at);
1529    let (answer, _waiting) = match crate::server::browser_attention::ask(
1530        &state,
1531        &banner_at,
1532        &req.auth.agent_id,
1533        crate::server::browser_attention::Kind::Approval,
1534        banner,
1535        std::time::Duration::from_secs(10 * 60),
1536    )
1537    .await
1538    {
1539        Ok(a) => a,
1540        Err(e) => return err_response(StatusCode::CONFLICT, e),
1541    };
1542    match answer {
1543        // The user may have taken the pane over while the banner was up:
1544        // their Take over wins over an Approve that comes after it.
1545        crate::server::browser_attention::Answer::Yes
1546            if crate::server::browser_owner::owner_of(&banner_at) != owner_before =>
1547        {
1548            err_response(
1549                StatusCode::FORBIDDEN,
1550                "the user took over this pane, so the approved click wasn't made".to_string(),
1551            )
1552        }
1553        crate::server::browser_attention::Answer::Yes => {
1554            proxy_data(&state, "act", body(Some(&ask)), std::time::Duration::from_secs(20)).await
1555        }
1556        crate::server::browser_attention::Answer::Cancelled => err_response(
1557            StatusCode::FORBIDDEN,
1558            "the user didn't approve this action: don't retry it; ask them what to do".to_string(),
1559        ),
1560        crate::server::browser_attention::Answer::TimedOut => err_response(
1561            StatusCode::REQUEST_TIMEOUT,
1562            "the user didn't answer the approval within 10 minutes; ask them in chat before trying again".to_string(),
1563        ),
1564    }
1565}
1566
1567#[cfg(test)]
1568mod tests {
1569    use super::{prune_old_screenshots, SCREENSHOT_RETENTION};
1570
1571    #[test]
1572    fn prune_old_screenshots_deletes_only_stale_pngs() {
1573        let dir = tempfile::tempdir().unwrap();
1574
1575        let fresh = dir.path().join("fresh.png");
1576        std::fs::write(&fresh, b"png").unwrap();
1577
1578        let stale = dir.path().join("stale.png");
1579        std::fs::write(&stale, b"png").unwrap();
1580        let old_time = std::time::SystemTime::now() - (SCREENSHOT_RETENTION * 2);
1581        let file = std::fs::File::options().write(true).open(&stale).unwrap();
1582        file.set_times(std::fs::FileTimes::new().set_modified(old_time))
1583            .unwrap();
1584
1585        // Non-PNG files must never be touched, however old.
1586        let other = dir.path().join("notes.txt");
1587        std::fs::write(&other, b"keep me").unwrap();
1588        let file = std::fs::File::options().write(true).open(&other).unwrap();
1589        file.set_times(std::fs::FileTimes::new().set_modified(old_time))
1590            .unwrap();
1591
1592        prune_old_screenshots(dir.path());
1593
1594        assert!(fresh.exists(), "fresh screenshot must survive pruning");
1595        assert!(!stale.exists(), "stale screenshot must be pruned");
1596        assert!(other.exists(), "non-png files must never be pruned");
1597    }
1598}