1use 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
50const UI_AUTOMATION_SIG_MAX_AGE_SECS: i64 = 300;
55
56use agentmux_common::time::now_secs as now_unix_secs;
57
58pub(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); }
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
131pub(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 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
176async 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
188pub(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
205const 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
232pub(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 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
326pub(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
375pub(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
422pub(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
446pub(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 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
482pub(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
503pub(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
524pub(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
552pub(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
606pub(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 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
652pub(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
683pub(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
715pub(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
794async 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
829pub(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 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
861pub(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
881pub(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 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 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
949pub(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#[allow(clippy::result_large_err)]
981fn refuse_on_driven_pane(block_id: &str, what: &str, instead: &str) -> Result<(), axum::response::Response> {
982 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
1005fn opener_allows(
1011 state: &AppState,
1012 block: Option<&crate::backend::obj::Block>,
1013 agent_id: &str,
1014 pane: &str,
1015) -> Result<(), (StatusCode, String)> {
1016 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 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
1046const POPUP_CHAIN_LIMIT: usize = 8;
1049
1050fn 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
1060fn 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
1078fn 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 opener_allows(state, Some(&opener), agent_id, id)?;
1111 Ok(id.to_string())
1112}
1113
1114pub(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
1126fn 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
1154pub(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 let (banner_at, in_window) = banner_for(&block_id);
1169 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 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
1216pub(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 crate::server::browser_attention::clear_stale(&state, &block_id, &id);
1239 err_response(StatusCode::NOT_FOUND, e)
1240 }
1241 }
1242}
1243
1244pub(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
1255fn 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
1264pub(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 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 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 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 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 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
1391pub(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 _ => 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
1437pub(crate) fn reset_popup_windows(state: &AppState) {
1442 use crate::server::browser_popup as popup;
1443 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
1462fn 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
1475pub(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 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 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 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 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 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 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}