Skip to main content

agentmux_srv/server/
browser_attention.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! An agent's browser pane asking the human for something: a hand-off
5//! (sign in, a CAPTCHA, anything the agent mustn't do) or approval of a
6//! committing action (submit, send, pay, delete)
7//! (docs/specs/SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §5.2, §5.4).
8//!
9//! The request is shown as a banner in the pane through the block's
10//! `browser:attention` meta key. The human's answer comes back only through
11//! the CEF host (`/api/v1/host/browser_attention`, authenticated with the
12//! host's IPC token, which agents never see): an agent can't answer its own
13//! request, even though it holds the instance auth key every other route
14//! trusts.
15
16use std::collections::HashMap;
17use std::sync::{Mutex, OnceLock};
18use std::time::Duration;
19
20use serde_json::{json, Value};
21use tokio::sync::oneshot;
22
23use crate::server::AppState;
24
25/// Block meta key the browser pane's banner reads.
26pub(crate) const ATTENTION_META_KEY: &str = "browser:attention";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub(crate) enum Kind {
30    Handoff,
31    Approval,
32    /// The page tried to leave the sites the pane is limited to
33    /// (SPEC_BROWSER_PANE_ALLOWED_ORIGINS_2026_10_09.md §4).
34    Navigation,
35}
36
37impl Kind {
38    fn as_str(self) -> &'static str {
39        match self {
40            Kind::Handoff => "handoff",
41            Kind::Approval => "approval",
42            Kind::Navigation => "navigation",
43        }
44    }
45}
46
47/// The human's answer, or the lack of one.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub(crate) enum Answer {
50    /// Done (hand-off) or Approve (approval).
51    Yes,
52    Cancelled,
53    TimedOut,
54}
55
56impl Answer {
57    pub(crate) fn parse(decision: &str) -> Option<Answer> {
58        match decision {
59            "done" | "approve" => Some(Answer::Yes),
60            "cancel" => Some(Answer::Cancelled),
61            _ => None,
62        }
63    }
64}
65
66struct Pending {
67    block_id: String,
68    kind: Kind,
69    /// Taken by the first answer. The entry itself stays until the asker's
70    /// [`Waiting`] is dropped, so the pane stays locked until it's done.
71    tx: Option<oneshot::Sender<Answer>>,
72}
73
74fn pending() -> &'static Mutex<HashMap<String, Pending>> {
75    static P: OnceLock<Mutex<HashMap<String, Pending>>> = OnceLock::new();
76    P.get_or_init(Default::default)
77}
78
79/// What `block_id` is waiting on the human for, if anything. While it
80/// waits, the agent's tools on that pane are refused: during a hand-off so
81/// it can't watch what the human types (§5.2), during an approval so it
82/// can't change the form the human is reading (§5.4).
83pub(crate) fn waiting_on_user(block_id: &str) -> Option<Kind> {
84    pending()
85        .lock()
86        .unwrap_or_else(|p| p.into_inner())
87        .values()
88        .find(|p| p.block_id == block_id)
89        .map(|p| p.kind)
90}
91
92/// The panes with a request of `kind` up.
93pub(crate) fn asking(kind: Kind) -> Vec<String> {
94    pending()
95        .lock()
96        .unwrap_or_else(|p| p.into_inner())
97        .values()
98        .filter(|p| p.kind == kind)
99        .map(|p| p.block_id.clone())
100        .collect()
101}
102
103/// Resolve request `id` on `block_id` with the human's answer. Called only
104/// by the host-authenticated route.
105pub(crate) fn resolve(id: &str, block_id: &str, answer: Answer) -> Result<(), String> {
106    let mut map = pending().lock().unwrap_or_else(|p| p.into_inner());
107    let tx = match map.get_mut(id) {
108        Some(p) if p.block_id == block_id => p.tx.take(),
109        Some(_) => return Err("that request belongs to another pane".to_string()),
110        None => return Err("no such request (it may have timed out)".to_string()),
111    };
112    let Some(tx) = tx else {
113        return Err("that request was already answered".to_string());
114    };
115    let _ = tx.send(answer);
116    Ok(())
117}
118
119/// Answer every request waiting on `block_id` with Cancelled: the user took
120/// the pane over, which ends whatever the agent was waiting for there.
121pub(crate) fn cancel_for(block_id: &str) {
122    let mut map = pending().lock().unwrap_or_else(|p| p.into_inner());
123    for p in map.values_mut().filter(|p| p.block_id == block_id) {
124        if let Some(tx) = p.tx.take() {
125            let _ = tx.send(Answer::Cancelled);
126        }
127    }
128}
129
130/// A request that is still up. Dropping it takes the request down: it
131/// leaves the pending map, which unlocks the pane, and the banner is
132/// cleared. That happens on every path, including when the waiting HTTP
133/// handler is cancelled because its caller went away.
134pub(crate) struct Waiting {
135    state: AppState,
136    id: String,
137    block_id: String,
138}
139
140impl Drop for Waiting {
141    fn drop(&mut self) {
142        // Removing the entry and clearing the banner happen under one hold of
143        // the lock, so a new request on the pane (which needs the lock, and
144        // this entry gone) can't publish its banner in between and lose it.
145        let mut map = pending().lock().unwrap_or_else(|p| p.into_inner());
146        map.remove(&self.id);
147        clear_banner_if(&self.state, &self.block_id, &self.id);
148    }
149}
150
151/// Clear `block_id`'s banner if it is still request `id`'s.
152fn clear_banner_if(state: &AppState, block_id: &str, id: &str) {
153    let shown = state
154        .mstore
155        .get::<crate::backend::obj::Block>(block_id)
156        .ok()
157        .flatten()
158        .and_then(|b| b.meta.get(ATTENTION_META_KEY).cloned())
159        .unwrap_or(Value::Null);
160    if shown.get("id").and_then(|v| v.as_str()) != Some(id) {
161        return;
162    }
163    let mut clear = crate::backend::obj::MetaMapType::new();
164    clear.insert(ATTENTION_META_KEY.to_string(), Value::Null);
165    let _ = crate::server::http_shell::broadcast_meta_update(state, block_id, &clear);
166}
167
168/// The human answered a banner srv has no request for (srv restarted, so its
169/// requests are gone but the block meta persisted): take that banner down,
170/// so its buttons don't sit there doing nothing. Host-authenticated callers only.
171pub(crate) fn clear_stale(state: &AppState, block_id: &str, id: &str) {
172    let map = pending().lock().unwrap_or_else(|p| p.into_inner());
173    if map.contains_key(id) {
174        return;
175    }
176    clear_banner_if(state, block_id, id);
177}
178
179/// Show `payload` in `block_id`'s banner and wait for the human, up to
180/// `timeout`. One request per pane at a time. The pane stays locked until
181/// the returned [`Waiting`] is dropped, so a caller that acts on a Yes
182/// holds it until that action is done.
183pub(crate) async fn ask(
184    state: &AppState,
185    block_id: &str,
186    agent_id: &str,
187    kind: Kind,
188    payload: Value,
189    timeout: Duration,
190) -> Result<(Answer, Waiting), String> {
191    let id = uuid::Uuid::new_v4().to_string();
192    let (tx, rx) = oneshot::channel();
193    {
194        let mut map = pending().lock().unwrap_or_else(|p| p.into_inner());
195        if map.values().any(|p| p.block_id == block_id) {
196            return Err("this pane is already waiting for the user".to_string());
197        }
198        map.insert(id.clone(), Pending { block_id: block_id.to_string(), kind, tx: Some(tx) });
199    }
200    let waiting = Waiting { state: state.clone(), id: id.clone(), block_id: block_id.to_string() };
201    let mut banner = json!({ "id": id, "kind": kind.as_str(), "agent": agent_id });
202    if let (Some(b), Some(p)) = (banner.as_object_mut(), payload.as_object()) {
203        for (k, v) in p {
204            b.insert(k.clone(), v.clone());
205        }
206    }
207    // The pane waits on the user until they answer or it times out: the
208    // waiting tone and notification for it
209    // (REPORT_AGENT_ATTENTION_CTA_CONTRAST_AND_TONE_2026_10_10.md §2).
210    let what = ["reason", "what", "origin"]
211        .iter()
212        .find_map(|k| banner.get(*k).and_then(|v| v.as_str()))
213        .unwrap_or("")
214        .to_string();
215    let who = if agent_id.is_empty() { "An agent" } else { agent_id };
216    let text = if what.is_empty() { format!("{who} needs you") } else { format!("{who} needs you: {what}") };
217    let _asking = crate::backend::user_attention::Asking::start(&state.broker, &state.mstore, block_id, "browser", &text);
218    let mut meta = crate::backend::obj::MetaMapType::new();
219    meta.insert(ATTENTION_META_KEY.to_string(), banner);
220    if let Err(e) = crate::server::http_shell::broadcast_meta_update(state, block_id, &meta) {
221        return Err(format!("couldn't show the request in the pane: {e}"));
222    }
223    let answer = match tokio::time::timeout(timeout, rx).await {
224        Ok(Ok(a)) => a,
225        Ok(Err(_)) => Answer::Cancelled,
226        Err(_) => Answer::TimedOut,
227    };
228    Ok((answer, waiting))
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn park(id: &str, block: &str, kind: Kind) -> oneshot::Receiver<Answer> {
236        let (tx, rx) = oneshot::channel();
237        pending()
238            .lock()
239            .unwrap()
240            .insert(id.to_string(), Pending { block_id: block.to_string(), kind, tx: Some(tx) });
241        rx
242    }
243
244    #[test]
245    fn decisions_parse() {
246        assert_eq!(Answer::parse("done"), Some(Answer::Yes));
247        assert_eq!(Answer::parse("approve"), Some(Answer::Yes));
248        assert_eq!(Answer::parse("cancel"), Some(Answer::Cancelled));
249        assert_eq!(Answer::parse("yes please"), None);
250    }
251
252    #[tokio::test]
253    async fn resolve_answers_the_waiting_request_once() {
254        let rx = park("attn-test-1", "pane-a", Kind::Approval);
255        resolve("attn-test-1", "pane-a", Answer::Yes).unwrap();
256        assert_eq!(rx.await.unwrap(), Answer::Yes);
257        assert!(resolve("attn-test-1", "pane-a", Answer::Yes).is_err(), "single use");
258        // Answered, but the asker hasn't finished acting on it: still locked.
259        assert_eq!(waiting_on_user("pane-a"), Some(Kind::Approval));
260        pending().lock().unwrap().remove("attn-test-1");
261    }
262
263    #[tokio::test]
264    async fn take_over_cancels_what_the_pane_waits_for() {
265        let rx = park("attn-test-5", "pane-f", Kind::Approval);
266        let _other = park("attn-test-6", "pane-g", Kind::Approval);
267        cancel_for("pane-f");
268        assert_eq!(rx.await.unwrap(), Answer::Cancelled);
269        // A late Approve finds the request already answered.
270        assert!(resolve("attn-test-5", "pane-f", Answer::Yes).is_err());
271        // Another pane's request is untouched.
272        assert!(resolve("attn-test-6", "pane-g", Answer::Yes).is_ok());
273        pending().lock().unwrap().remove("attn-test-5");
274        pending().lock().unwrap().remove("attn-test-6");
275    }
276
277    #[test]
278    fn a_request_is_answered_only_for_its_own_pane() {
279        let _rx = park("attn-test-2", "pane-b", Kind::Approval);
280        assert!(resolve("attn-test-2", "pane-other", Answer::Yes).is_err());
281        pending().lock().unwrap().remove("attn-test-2");
282    }
283
284    #[test]
285    fn a_pending_request_of_either_kind_locks_the_pane() {
286        let _rx = park("attn-test-3", "pane-c", Kind::Handoff);
287        assert_eq!(waiting_on_user("pane-c"), Some(Kind::Handoff));
288        assert_eq!(waiting_on_user("pane-d"), None);
289        pending().lock().unwrap().remove("attn-test-3");
290        assert_eq!(waiting_on_user("pane-c"), None);
291        let _rx = park("attn-test-4", "pane-e", Kind::Approval);
292        assert_eq!(waiting_on_user("pane-e"), Some(Kind::Approval));
293        pending().lock().unwrap().remove("attn-test-4");
294    }
295}