agentmux_cef\browser_api/
routes.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Axum route handlers for `/agentmux/browser/*`.
5
6use std::sync::Arc;
7
8use axum::extract::State;
9use axum::http::{HeaderMap, StatusCode};
10use axum::Json;
11use serde_json::json;
12
13use super::cdp::CdpSession;
14use super::types::{
15    AckData, ApiResponse, ClickElementReq, DispatchKeyReq, Element, EvalData, EvalReq,
16    FocusElementReq, FocusInfoData, FocusInfoReq, HistoryReq, NavigateReq, QueryData, QueryReq,
17    ScreenshotData, ScreenshotReq,
18};
19use crate::state::AppState;
20
21/// `POST /agentmux/browser/query` — find DOM elements matching a CSS
22/// selector in the specified pane. See `SPEC_BROWSER_DOM_API.md`
23/// §5.2 for response shape.
24pub async fn query(
25    State(state): State<Arc<AppState>>,
26    headers: HeaderMap,
27    Json(req): Json<QueryReq>,
28) -> (StatusCode, Json<ApiResponse<QueryData>>) {
29    if !authorized(&headers, &state.ipc_token) {
30        return (
31            StatusCode::UNAUTHORIZED,
32            Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
33        );
34    }
35
36    // Resolve block_id → CDP target id.
37    let target = match state
38        .browser_api
39        .target_cache
40        .resolve(&state, &req.block_id)
41        .await
42    {
43        Ok(t) => t,
44        Err(e) => return ok_body(ApiResponse::err(e)),
45    };
46
47    // Open the CDP WebSocket for that target.
48    let debug_port = *state.debug_port.lock();
49    let ws_url = format!("ws://127.0.0.1:{debug_port}/devtools/page/{target}");
50    let mut cdp = match CdpSession::connect(&ws_url).await {
51        Ok(s) => s,
52        Err(e) => {
53            // Target might be stale (pane closed underneath us).
54            state.browser_api.target_cache.forget(&req.block_id);
55            return ok_body(ApiResponse::err(format!("CDP connect: {e}")));
56        }
57    };
58
59    // Inject the helper (idempotent — it guards on `window.__amq_query`).
60    let helper = include_str!("scripts/query.js");
61    if let Err(e) = cdp
62        .call(
63            "Runtime.evaluate",
64            json!({
65                "expression": helper,
66                "returnByValue": false,
67            }),
68        )
69        .await
70    {
71        let _ = cdp.close().await;
72        return ok_body(ApiResponse::err(format!("CDP inject helper: {e}")));
73    }
74
75    // Call the helper. Serializing the selector through serde_json
76    // handles quote-escaping safely.
77    let selector_js = serde_json::to_string(&req.selector)
78        .unwrap_or_else(|_| "\"\"".to_string());
79    let call_expr = format!(
80        "__amq_query({sel}, {lim})",
81        sel = selector_js,
82        lim = req.limit.unwrap_or(0),
83    );
84    let eval_result = match cdp
85        .call(
86            "Runtime.evaluate",
87            json!({
88                "expression": call_expr,
89                "returnByValue": true,
90                "awaitPromise": false,
91            }),
92        )
93        .await
94    {
95        Ok(v) => v,
96        Err(e) => {
97            let _ = cdp.close().await;
98            return ok_body(ApiResponse::err(format!("CDP call __amq_query: {e}")));
99        }
100    };
101
102    let _ = cdp.close().await;
103
104    // CDP Runtime.evaluate reply shape (with returnByValue):
105    //   { result: { type, value } } or { result: { type, value: { error } } }
106    let value = eval_result
107        .get("result")
108        .and_then(|r| r.get("value"))
109        .cloned()
110        .unwrap_or(serde_json::Value::Null);
111
112    // Check for script-level error surfaced by the helper.
113    if let Some(err) = value.get("error").and_then(|v| v.as_str()) {
114        return ok_body(ApiResponse::err(format!("DOM query error: {err}")));
115    }
116
117    // Normal case: { matches: [...] }
118    let matches: Vec<Element> = value
119        .get("matches")
120        .and_then(|m| serde_json::from_value(m.clone()).ok())
121        .unwrap_or_default();
122
123    ok_body(ApiResponse::ok(QueryData { matches }))
124}
125
126/// `POST /agentmux/browser/focus_info` — report the current
127/// `document.activeElement` of the pane as an `Element`. See §5.2.
128pub async fn focus_info(
129    State(state): State<Arc<AppState>>,
130    headers: HeaderMap,
131    Json(req): Json<FocusInfoReq>,
132) -> (StatusCode, Json<ApiResponse<FocusInfoData>>) {
133    if !authorized(&headers, &state.ipc_token) {
134        return (
135            StatusCode::UNAUTHORIZED,
136            Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
137        );
138    }
139
140    let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
141        Ok(c) => c,
142        Err(e) => return ok_body(ApiResponse::err(e)),
143    };
144
145    // Inject helpers (idempotent).
146    let helper = include_str!("scripts/query.js");
147    if let Err(e) = cdp
148        .call(
149            "Runtime.evaluate",
150            json!({ "expression": helper, "returnByValue": false }),
151        )
152        .await
153    {
154        let _ = cdp.close().await;
155        return ok_body(ApiResponse::err(format!("CDP inject helper: {e}")));
156    }
157
158    let eval_result = match cdp
159        .call(
160            "Runtime.evaluate",
161            json!({
162                "expression": "__amq_focus_info()",
163                "returnByValue": true,
164            }),
165        )
166        .await
167    {
168        Ok(v) => v,
169        Err(e) => {
170            let _ = cdp.close().await;
171            return ok_body(ApiResponse::err(format!("CDP call __amq_focus_info: {e}")));
172        }
173    };
174
175    let _ = cdp.close().await;
176
177    let value = eval_result
178        .get("result")
179        .and_then(|r| r.get("value"))
180        .cloned()
181        .unwrap_or(serde_json::Value::Null);
182
183    // value is either null or an Element object.
184    let focused: Option<Element> = if value.is_null() {
185        None
186    } else {
187        match serde_json::from_value(value) {
188            Ok(e) => Some(e),
189            Err(e) => return ok_body(ApiResponse::err(format!("parse element: {e}"))),
190        }
191    };
192    ok_body(ApiResponse::ok(FocusInfoData { focused }))
193}
194
195/// `POST /agentmux/browser/eval` — run arbitrary JS in the pane's
196/// renderer, return the serialized value.
197///
198/// Thin wrapper over CDP `Runtime.evaluate`. The script runs in the
199/// pane's JS world (not an isolated context); treat it as arbitrary
200/// code execution in whatever origin the pane currently loads.
201pub async fn eval(
202    State(state): State<Arc<AppState>>,
203    headers: HeaderMap,
204    Json(req): Json<EvalReq>,
205) -> (StatusCode, Json<ApiResponse<EvalData>>) {
206    if !authorized(&headers, &state.ipc_token) {
207        return (
208            StatusCode::UNAUTHORIZED,
209            Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
210        );
211    }
212
213    let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
214        Ok(c) => c,
215        Err(e) => return ok_body(ApiResponse::err(e)),
216    };
217
218    let eval_result = match cdp
219        .call(
220            "Runtime.evaluate",
221            json!({
222                "expression": req.script,
223                "returnByValue": true,
224                "awaitPromise": req.await_promise,
225            }),
226        )
227        .await
228    {
229        Ok(v) => v,
230        Err(e) => {
231            let _ = cdp.close().await;
232            return ok_body(ApiResponse::err(format!("CDP eval: {e}")));
233        }
234    };
235
236    let _ = cdp.close().await;
237
238    // CDP shape: { result: { type, value } } on success,
239    //            { exceptionDetails: { ... } } on throw.
240    if let Some(exc) = eval_result.get("exceptionDetails") {
241        let msg = exc
242            .get("exception")
243            .and_then(|e| e.get("description"))
244            .and_then(|d| d.as_str())
245            .or_else(|| exc.get("text").and_then(|t| t.as_str()))
246            .unwrap_or("unknown exception")
247            .to_string();
248        return ok_body(ApiResponse::ok(EvalData {
249            result: serde_json::Value::Null,
250            type_: "undefined".to_string(),
251            exception: Some(msg),
252        }));
253    }
254
255    let result = eval_result.get("result").cloned().unwrap_or_default();
256    let type_ = result
257        .get("type")
258        .and_then(|t| t.as_str())
259        .unwrap_or("undefined")
260        .to_string();
261    let value = result.get("value").cloned().unwrap_or(serde_json::Value::Null);
262
263    ok_body(ApiResponse::ok(EvalData {
264        result: value,
265        type_,
266        exception: None,
267    }))
268}
269
270/// `POST /agentmux/browser/screenshot` — capture a PNG of the pane's
271/// rendered viewport. Uses CDP `Page.captureScreenshot`.
272pub async fn screenshot(
273    State(state): State<Arc<AppState>>,
274    headers: HeaderMap,
275    Json(req): Json<ScreenshotReq>,
276) -> (StatusCode, Json<ApiResponse<ScreenshotData>>) {
277    if !authorized(&headers, &state.ipc_token) {
278        return (
279            StatusCode::UNAUTHORIZED,
280            Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
281        );
282    }
283
284    let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
285        Ok(c) => c,
286        Err(e) => return ok_body(ApiResponse::err(e)),
287    };
288
289    let cap = match cdp
290        .call(
291            "Page.captureScreenshot",
292            json!({
293                "format": "png",
294                "fromSurface": true,
295            }),
296        )
297        .await
298    {
299        Ok(v) => v,
300        Err(e) => {
301            let _ = cdp.close().await;
302            return ok_body(ApiResponse::err(format!("CDP Page.captureScreenshot: {e}")));
303        }
304    };
305
306    let _ = cdp.close().await;
307
308    let data = match cap.get("data").and_then(|d| d.as_str()) {
309        Some(s) => s.to_string(),
310        None => {
311            return ok_body(ApiResponse::err(
312                "Page.captureScreenshot returned no `data` field".to_string(),
313            ))
314        }
315    };
316
317    ok_body(ApiResponse::ok(ScreenshotData { png_base64: data }))
318}
319
320/// `POST /agentmux/browser/click_element` — synthesize a real mouse
321/// click on the first element matching `selector`. Dispatches
322/// `Input.dispatchMouseEvent` (mousePressed + mouseReleased) at the
323/// element's centroid.
324///
325/// Note: this is a "real" mouse event, NOT a DOM `.click()` — so
326/// `:focus-visible`, pointer-related listeners, and the pane's
327/// Win32 focus-routing behave identically to a human click. That's
328/// why the stress test uses this rather than eval'ing `.click()`.
329pub async fn click_element(
330    State(state): State<Arc<AppState>>,
331    headers: HeaderMap,
332    Json(req): Json<ClickElementReq>,
333) -> (StatusCode, Json<ApiResponse<AckData>>) {
334    if !authorized(&headers, &state.ipc_token) {
335        return (
336            StatusCode::UNAUTHORIZED,
337            Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
338        );
339    }
340
341    let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
342        Ok(c) => c,
343        Err(e) => return ok_body(ApiResponse::err(e)),
344    };
345
346    // Inject helpers so __amq_centroid_of is available.
347    let helper = include_str!("scripts/query.js");
348    if let Err(e) = cdp
349        .call(
350            "Runtime.evaluate",
351            json!({ "expression": helper, "returnByValue": false }),
352        )
353        .await
354    {
355        let _ = cdp.close().await;
356        return ok_body(ApiResponse::err(format!("CDP inject helper: {e}")));
357    }
358
359    // Ask the helper for the element's centroid in viewport coords.
360    let selector_js = serde_json::to_string(&req.selector).unwrap_or_else(|_| "\"\"".into());
361    let centroid_expr = format!("__amq_centroid_of({selector_js})");
362    let cent_reply = match cdp
363        .call(
364            "Runtime.evaluate",
365            json!({
366                "expression": centroid_expr,
367                "returnByValue": true,
368            }),
369        )
370        .await
371    {
372        Ok(v) => v,
373        Err(e) => {
374            let _ = cdp.close().await;
375            return ok_body(ApiResponse::err(format!("CDP centroid query: {e}")));
376        }
377    };
378
379    let cent = cent_reply
380        .get("result")
381        .and_then(|r| r.get("value"))
382        .cloned()
383        .unwrap_or(serde_json::Value::Null);
384    if cent.is_null() {
385        let _ = cdp.close().await;
386        return ok_body(ApiResponse::err(format!(
387            "selector {:?} matched no element",
388            req.selector
389        )));
390    }
391
392    let x = cent.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
393    let y = cent.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
394
395    // Dispatch mousePressed + mouseReleased. `buttons: 1` = left
396    // primary; `button: "left"` is the CDP enum.
397    for event_type in ["mousePressed", "mouseReleased"] {
398        if let Err(e) = cdp
399            .call(
400                "Input.dispatchMouseEvent",
401                json!({
402                    "type": event_type,
403                    "x": x,
404                    "y": y,
405                    "button": "left",
406                    "buttons": 1,
407                    "clickCount": 1,
408                }),
409            )
410            .await
411        {
412            let _ = cdp.close().await;
413            return ok_body(ApiResponse::err(format!("CDP dispatchMouseEvent {event_type}: {e}")));
414        }
415    }
416
417    let _ = cdp.close().await;
418    ok_body(ApiResponse::ok(AckData::new()))
419}
420
421/// `POST /agentmux/browser/focus_element` — call `.focus()` on the
422/// first matching element. Does not synthesize a mouse event; use
423/// `click_element` when you want the full mouse-gesture semantics.
424pub async fn focus_element(
425    State(state): State<Arc<AppState>>,
426    headers: HeaderMap,
427    Json(req): Json<FocusElementReq>,
428) -> (StatusCode, Json<ApiResponse<AckData>>) {
429    if !authorized(&headers, &state.ipc_token) {
430        return (
431            StatusCode::UNAUTHORIZED,
432            Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
433        );
434    }
435
436    let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
437        Ok(c) => c,
438        Err(e) => return ok_body(ApiResponse::err(e)),
439    };
440
441    let selector_js = serde_json::to_string(&req.selector).unwrap_or_else(|_| "\"\"".into());
442    let script = format!(
443        "(() => {{ const e = document.querySelector({sel}); \
444            if (!e) return false; e.focus(); return true; }})()",
445        sel = selector_js,
446    );
447
448    let reply = match cdp
449        .call(
450            "Runtime.evaluate",
451            json!({
452                "expression": script,
453                "returnByValue": true,
454            }),
455        )
456        .await
457    {
458        Ok(v) => v,
459        Err(e) => {
460            let _ = cdp.close().await;
461            return ok_body(ApiResponse::err(format!("CDP focus_element: {e}")));
462        }
463    };
464    let _ = cdp.close().await;
465
466    let got = reply
467        .get("result")
468        .and_then(|r| r.get("value"))
469        .and_then(|v| v.as_bool())
470        .unwrap_or(false);
471    if !got {
472        return ok_body(ApiResponse::err(format!(
473            "selector {:?} matched no element",
474            req.selector
475        )));
476    }
477    ok_body(ApiResponse::ok(AckData::new()))
478}
479
480/// `POST /agentmux/browser/dispatch_key` — send text or a named key
481/// to whatever has focus in the pane. Optionally focuses a selector
482/// first.
483pub async fn dispatch_key(
484    State(state): State<Arc<AppState>>,
485    headers: HeaderMap,
486    Json(req): Json<DispatchKeyReq>,
487) -> (StatusCode, Json<ApiResponse<AckData>>) {
488    if !authorized(&headers, &state.ipc_token) {
489        return (
490            StatusCode::UNAUTHORIZED,
491            Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
492        );
493    }
494
495    // Exactly one of text / key must be set.
496    if req.text.is_some() == req.key.is_some() {
497        return ok_body(ApiResponse::err(
498            "dispatch_key requires exactly one of `text` or `key`".to_string(),
499        ));
500    }
501
502    let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
503        Ok(c) => c,
504        Err(e) => return ok_body(ApiResponse::err(e)),
505    };
506
507    // Optionally focus a selector first.
508    if let Some(sel) = &req.selector {
509        let sel_js = serde_json::to_string(sel).unwrap_or_else(|_| "\"\"".into());
510        let script = format!(
511            "(() => {{ const e = document.querySelector({sel_js}); \
512                if (!e) return false; e.focus(); return true; }})()"
513        );
514        let reply = cdp
515            .call(
516                "Runtime.evaluate",
517                json!({ "expression": script, "returnByValue": true }),
518            )
519            .await;
520        let ok_focus = reply
521            .as_ref()
522            .ok()
523            .and_then(|v| v.get("result"))
524            .and_then(|r| r.get("value"))
525            .and_then(|v| v.as_bool())
526            .unwrap_or(false);
527        if !ok_focus {
528            let _ = cdp.close().await;
529            return ok_body(ApiResponse::err(format!(
530                "dispatch_key: selector {sel:?} matched no element"
531            )));
532        }
533    }
534
535    if let Some(text) = &req.text {
536        // Input.insertText is atomic and handles IME / composition
537        // correctly. Preferred over key-by-key dispatch for strings.
538        if let Err(e) = cdp
539            .call("Input.insertText", json!({ "text": text }))
540            .await
541        {
542            let _ = cdp.close().await;
543            return ok_body(ApiResponse::err(format!("CDP Input.insertText: {e}")));
544        }
545    } else if let Some(key) = &req.key {
546        let (key_name, code, windows_virtual_key_code) = match key.as_str() {
547            "Enter" => ("Enter", "Enter", 13),
548            "Tab" => ("Tab", "Tab", 9),
549            "Escape" => ("Escape", "Escape", 27),
550            "Backspace" => ("Backspace", "Backspace", 8),
551            "ArrowUp" => ("ArrowUp", "ArrowUp", 38),
552            "ArrowDown" => ("ArrowDown", "ArrowDown", 40),
553            "ArrowLeft" => ("ArrowLeft", "ArrowLeft", 37),
554            "ArrowRight" => ("ArrowRight", "ArrowRight", 39),
555            "Space" => (" ", "Space", 32),
556            other => {
557                let _ = cdp.close().await;
558                return ok_body(ApiResponse::err(format!(
559                    "dispatch_key: unsupported key name {other:?} — supported: \
560                     Enter, Tab, Escape, Backspace, ArrowUp/Down/Left/Right, Space"
561                )));
562            }
563        };
564
565        for event_type in ["keyDown", "keyUp"] {
566            if let Err(e) = cdp
567                .call(
568                    "Input.dispatchKeyEvent",
569                    json!({
570                        "type": event_type,
571                        "key": key_name,
572                        "code": code,
573                        "windowsVirtualKeyCode": windows_virtual_key_code,
574                        "nativeVirtualKeyCode": windows_virtual_key_code,
575                    }),
576                )
577                .await
578            {
579                let _ = cdp.close().await;
580                return ok_body(ApiResponse::err(format!(
581                    "CDP Input.dispatchKeyEvent {event_type}: {e}"
582                )));
583            }
584        }
585    }
586
587    let _ = cdp.close().await;
588    ok_body(ApiResponse::ok(AckData::new()))
589}
590
591/// `POST /agentmux/browser/navigate` — navigate the pane to a new
592/// URL via CDP `Page.navigate`. (We could call
593/// `BrowserPaneManager::navigate` directly, but routing through CDP
594/// keeps the resolver's URL cache consistent — a subsequent request
595/// will re-probe `/json` if the target id changes.)
596pub async fn navigate(
597    State(state): State<Arc<AppState>>,
598    headers: HeaderMap,
599    Json(req): Json<NavigateReq>,
600) -> (StatusCode, Json<ApiResponse<AckData>>) {
601    if !authorized(&headers, &state.ipc_token) {
602        return (
603            StatusCode::UNAUTHORIZED,
604            Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
605        );
606    }
607
608    let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
609        Ok(c) => c,
610        Err(e) => return ok_body(ApiResponse::err(e)),
611    };
612
613    let reply = cdp.call("Page.navigate", json!({ "url": req.url })).await;
614    let _ = cdp.close().await;
615
616    // After navigation the target's URL changes — forget the cache
617    // entry so the next resolver probe re-matches against the new URL.
618    state.browser_api.target_cache.forget(&req.block_id);
619
620    match reply {
621        Ok(_) => ok_body(ApiResponse::ok(AckData::new())),
622        Err(e) => ok_body(ApiResponse::err(format!("CDP Page.navigate: {e}"))),
623    }
624}
625
626/// `POST /agentmux/browser/back` — walk the pane's history one step back.
627/// Routed through CDP (`Page.goBack`) rather than
628/// `BrowserPaneManager::go_back` to keep the resolver cache honest: the
629/// target URL changes after the hop, so we invalidate the cache entry.
630///
631/// Returns ack-ok even when there's no prior history — CDP is a no-op
632/// in that case, and agents should query `browser-pane-nav-state` events
633/// (or `eval("location.href")`) to confirm what happened.
634pub async fn back(
635    State(state): State<Arc<AppState>>,
636    headers: HeaderMap,
637    Json(req): Json<HistoryReq>,
638) -> (StatusCode, Json<ApiResponse<AckData>>) {
639    if !authorized(&headers, &state.ipc_token) {
640        return (
641            StatusCode::UNAUTHORIZED,
642            Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
643        );
644    }
645
646    let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
647        Ok(c) => c,
648        Err(e) => return ok_body(ApiResponse::err(e)),
649    };
650
651    // `Page.navigateToHistoryEntry` needs an entryId; simpler to call
652    // Page.goBack which CDP exposes directly.
653    let reply = cdp.call("Page.goBack", json!({})).await;
654    let _ = cdp.close().await;
655
656    state.browser_api.target_cache.forget(&req.block_id);
657
658    match reply {
659        Ok(_) => ok_body(ApiResponse::ok(AckData::new())),
660        Err(e) => ok_body(ApiResponse::err(format!("CDP Page.goBack: {e}"))),
661    }
662}
663
664/// `POST /agentmux/browser/forward` — walk the pane's history one step forward.
665/// See `back` for the target-cache rationale.
666pub async fn forward(
667    State(state): State<Arc<AppState>>,
668    headers: HeaderMap,
669    Json(req): Json<HistoryReq>,
670) -> (StatusCode, Json<ApiResponse<AckData>>) {
671    if !authorized(&headers, &state.ipc_token) {
672        return (
673            StatusCode::UNAUTHORIZED,
674            Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
675        );
676    }
677
678    let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
679        Ok(c) => c,
680        Err(e) => return ok_body(ApiResponse::err(e)),
681    };
682
683    let reply = cdp.call("Page.goForward", json!({})).await;
684    let _ = cdp.close().await;
685
686    state.browser_api.target_cache.forget(&req.block_id);
687
688    match reply {
689        Ok(_) => ok_body(ApiResponse::ok(AckData::new())),
690        Err(e) => ok_body(ApiResponse::err(format!("CDP Page.goForward: {e}"))),
691    }
692}
693
694/// `POST /agentmux/browser/reload` — reload the current page. `ignore_cache`
695/// (default false) maps to the CDP flag — true is the equivalent of Ctrl+F5
696/// (bypass the http cache). The pane's current URL is preserved, so no
697/// target-cache invalidation is needed.
698pub async fn reload(
699    State(state): State<Arc<AppState>>,
700    headers: HeaderMap,
701    Json(req): Json<HistoryReq>,
702) -> (StatusCode, Json<ApiResponse<AckData>>) {
703    if !authorized(&headers, &state.ipc_token) {
704        return (
705            StatusCode::UNAUTHORIZED,
706            Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
707        );
708    }
709
710    let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
711        Ok(c) => c,
712        Err(e) => return ok_body(ApiResponse::err(e)),
713    };
714
715    let reply = cdp
716        .call("Page.reload", json!({ "ignoreCache": req.ignore_cache }))
717        .await;
718    let _ = cdp.close().await;
719
720    match reply {
721        Ok(_) => ok_body(ApiResponse::ok(AckData::new())),
722        Err(e) => ok_body(ApiResponse::err(format!("CDP Page.reload: {e}"))),
723    }
724}
725
726// ── shared helpers ──────────────────────────────────────────────────────
727
728async fn open_cdp_for_block(
729    state: &Arc<AppState>,
730    block_id: &str,
731) -> Result<CdpSession, String> {
732    let target = state
733        .browser_api
734        .target_cache
735        .resolve(state, block_id)
736        .await?;
737    let debug_port = *state.debug_port.lock();
738    let ws_url = format!("ws://127.0.0.1:{debug_port}/devtools/page/{target}");
739    CdpSession::connect(&ws_url).await.map_err(|e| {
740        // On connect failure the cached target may be stale; drop it
741        // so the next call re-probes.
742        state.browser_api.target_cache.forget(block_id);
743        format!("CDP connect: {e}")
744    })
745}
746
747fn authorized(headers: &HeaderMap, expected: &str) -> bool {
748    headers
749        .get("authorization")
750        .and_then(|v| v.to_str().ok())
751        .and_then(|v| v.strip_prefix("Bearer "))
752        .map(|token| token == expected)
753        .unwrap_or(false)
754}
755
756fn ok_body<T>(body: ApiResponse<T>) -> (StatusCode, Json<ApiResponse<T>>) {
757    (StatusCode::OK, Json(body))
758}