Skip to main content

agentmux_srv/server/
browser_allowlist.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The sites an agent limited its browser pane to with
5//! `OpenBrowser({allowed_origins})`
6//! (docs/specs/SPEC_BROWSER_PANE_ALLOWED_ORIGINS_2026_10_09.md).
7//!
8//! One list per chain of panes: the pane `OpenBrowser` opened (the root) and
9//! every popup pane opened from it, or from those. Like the owner map, this
10//! is srv's own record, in memory: the block's `browser:allowed_origins`
11//! meta key mirrors it for the pane's header, and clients can't write it. The
12//! host keeps a copy of every member's list, pushed with the owned-pane set
13//! (`browser_host_sync`), and stops navigations off it.
14
15use std::collections::HashMap;
16use std::sync::{Mutex, OnceLock};
17
18use agentmux_common::allowed_origins;
19use axum::extract::State;
20use axum::http::StatusCode;
21use axum::response::IntoResponse;
22use axum::Json;
23use serde_json::{json, Value};
24
25use crate::server::ui_handlers as ui;
26use crate::server::AppState;
27
28/// Block meta key mirroring a pane's list, for its header.
29pub(crate) const ALLOWED_META_KEY: &str = "browser:allowed_origins";
30
31#[derive(Default)]
32struct Lists {
33    /// Root pane → its chain's list (normal form, `allowed_origins::parse`).
34    lists: HashMap<String, Vec<String>>,
35    /// Every member pane, the root included → its root.
36    members: HashMap<String, String>,
37}
38
39fn lists() -> &'static Mutex<Lists> {
40    static L: OnceLock<Mutex<Lists>> = OnceLock::new();
41    L.get_or_init(Default::default)
42}
43
44fn lock() -> std::sync::MutexGuard<'static, Lists> {
45    lists().lock().unwrap_or_else(|p| p.into_inner())
46}
47
48/// Limit `root` (a pane `OpenBrowser` just opened) to `list`.
49pub(crate) fn set(root: &str, list: Vec<String>) {
50    let mut l = lock();
51    l.lists.insert(root.to_string(), list);
52    l.members.insert(root.to_string(), root.to_string());
53    drop(l);
54    crate::server::browser_owner::changed().notify_one();
55}
56
57/// `pane`, a popup pane `opener` just opened, joins `opener`'s chain, if it
58/// has a list. Returns the list it now shares.
59pub(crate) fn join(pane: &str, opener: &str) -> Option<Vec<String>> {
60    let mut l = lock();
61    let root = l.members.get(opener)?.clone();
62    let list = l.lists.get(&root)?.clone();
63    l.members.insert(pane.to_string(), root);
64    drop(l);
65    crate::server::browser_owner::changed().notify_one();
66    Some(list)
67}
68
69/// `pane`'s list, if its chain has one.
70pub(crate) fn list_for(pane: &str) -> Option<Vec<String>> {
71    let l = lock();
72    let root = l.members.get(pane)?;
73    l.lists.get(root).cloned()
74}
75
76/// Add `origin` to `pane`'s chain's list: the person allowed it. Returns the
77/// list after, and the chain's members, whose header shows it.
78pub(crate) fn allow(pane: &str, origin: &str) -> Option<(Vec<String>, Vec<String>)> {
79    let mut l = lock();
80    let root = l.members.get(pane)?.clone();
81    let list = l.lists.get_mut(&root)?;
82    if !list.iter().any(|o| o == origin) {
83        list.push(origin.to_string());
84    }
85    let list = list.clone();
86    let members = l.members.iter().filter(|(_, r)| **r == root).map(|(m, _)| m.clone()).collect();
87    drop(l);
88    crate::server::browser_owner::changed().notify_one();
89    Some((list, members))
90}
91
92/// `pane` was closed or taken over: it leaves its chain. The others keep the
93/// list, even when `pane` was the root: a popup pane can outlive its opener
94/// and still be the agent's, and must not lose its limit with it. The list
95/// goes when its last pane does. Returns whether `pane` had a list.
96pub(crate) fn drop_pane(pane: &str) -> bool {
97    let mut l = lock();
98    let Some(root) = l.members.remove(pane) else { return false };
99    if !l.members.values().any(|r| *r == root) {
100        l.lists.remove(&root);
101    }
102    drop(l);
103    crate::server::browser_owner::changed().notify_one();
104    true
105}
106
107/// Every member pane's list, for the host.
108pub(crate) fn snapshot() -> HashMap<String, Vec<String>> {
109    let l = lock();
110    l.members
111        .iter()
112        .filter_map(|(m, r)| l.lists.get(r).map(|list| (m.clone(), list.clone())))
113        .collect()
114}
115
116/// Write `list` (or nothing) to each of `panes`' headers.
117pub(crate) fn publish(state: &AppState, panes: &[String], list: Option<&[String]>) {
118    let value = list.map(|l| json!(l)).unwrap_or(Value::Null);
119    for pane in panes {
120        let mut meta = crate::backend::obj::MetaMapType::new();
121        meta.insert(ALLOWED_META_KEY.to_string(), value.clone());
122        if let Err(e) = crate::server::http_shell::broadcast_meta_update(state, pane, &meta) {
123            tracing::debug!(pane = %pane, error = %e, "[browser-allowlist] pane header not updated");
124        }
125    }
126}
127
128/// Check `OpenBrowser`'s `allowed_origins`: `None` when not given, the list
129/// in its normal form when it is, or why it can't be used.
130pub(crate) fn list_for_open(entries: Option<&[String]>, url: &str) -> Result<Option<Vec<String>>, String> {
131    let Some(entries) = entries else { return Ok(None) };
132    let list = allowed_origins::parse_list(entries).map_err(|e| format!("allowed_origins: {e}"))?;
133    if list.is_empty() {
134        return Err("allowed_origins is empty: leave it out, or list the sites the pane may go to".to_string());
135    }
136    if !allowed_origins::allows(&list, url) {
137        return Err(format!("url {url:?} isn't on allowed_origins ({})", list.join(", ")));
138    }
139    Ok(Some(list))
140}
141
142/// Mirror a new pane's list (if any) into the meta it's created with.
143pub(crate) fn mirror(meta: &mut crate::backend::obj::MetaMapType, list: Option<&[String]>) {
144    if let Some(list) = list {
145        meta.insert(ALLOWED_META_KEY.to_string(), json!(list));
146    }
147}
148
149/// Limit `pane`, which `OpenBrowser` just opened, to `list` if one was given,
150/// and send it to the host at once, not on the next sync: the host must have
151/// it before the pane's first navigation can leave it.
152pub(crate) async fn limit_new_pane(state: &AppState, pane: &str, list: Option<Vec<String>>) {
153    if let Some(list) = list {
154        set(pane, list);
155        crate::server::browser_host_sync::push(state).await;
156    }
157}
158
159/// Why a popup pane to `url` from `opener` isn't opened, if it isn't: the
160/// host asks before offering one off the opener's list, and this checks again
161/// from srv's own record.
162pub(crate) fn refuse_popup(opener: &str, url: &str) -> Option<&'static str> {
163    list_for(opener)
164        .is_some_and(|l| !allowed_origins::allows(&l, url))
165        .then_some("it isn't on the sites the pane that opened it is limited to")
166}
167
168/// `pane`, a popup pane that `join`ed its opener's chain (`list`) as soon as
169/// it opened, once its ownership is settled: with an owner it shares the
170/// chain's limit, in its header and on the host; with none (the opener was
171/// taken over meanwhile) it leaves the chain. The host push waits until here,
172/// so nothing yields between opening the pane and settling who owns it.
173pub(crate) async fn settle_popup_pane(state: &AppState, pane: &str, list: Option<Vec<String>>, owned: bool) {
174    let Some(list) = list else { return };
175    if owned {
176        publish(state, &[pane.to_string()], Some(&list));
177    } else {
178        drop_pane(pane);
179        publish(state, &[pane.to_string()], None);
180    }
181    crate::server::browser_host_sync::push(state).await;
182}
183
184/// Why an agent's own `BrowserNavigate` of `pane` to `url` is refused, if it
185/// is: the pane is limited to some sites and `url` isn't on them (§3).
186pub(crate) fn refuse_agent_navigation(pane: &str, url: &str) -> Option<String> {
187    let list = list_for(pane)?;
188    (!allowed_origins::allows(&list, url)).then(|| {
189        format!(
190            "{url:?} isn't on the sites this pane is limited to ({}): open another pane with OpenBrowser \
191             for it, or ask the user",
192            list.join(", ")
193        )
194    })
195}
196
197/// `POST /api/v1/host/browser_navigation` — the host stopped a navigation off
198/// a pane's site list (allowed-origins spec §4). Body `{pane, target, url,
199/// kind}`: `pane` is the pane the list belongs to, `target` the pane or popup
200/// window that was navigating, `kind` `navigate` (replayed on Allow) or
201/// `popup` (not: the person or agent clicks again). Only the host calls it.
202/// Answers at once; the question runs on its own.
203pub(crate) async fn handle_host_browser_navigation(
204    State(state): State<AppState>,
205    headers: axum::http::HeaderMap,
206    Json(body): Json<serde_json::Value>,
207) -> impl IntoResponse {
208    use crate::server::{browser_allowlist, browser_attention};
209    if !ui::from_host(&state, &headers).await {
210        return ui::err_response(StatusCode::FORBIDDEN, "only the AgentMux host can report a navigation".to_string());
211    }
212    let s = |k: &str| body.get(k).and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
213    let (pane, target, url, kind) = (s("pane"), s("target"), s("url"), s("kind"));
214    let answered = |asked: bool, reason: &str| {
215        (StatusCode::OK, Json(json!({ "ok": true, "data": { "asked": asked, "reason": reason } }))).into_response()
216    };
217    let list = browser_allowlist::list_for(&pane).or_else(|| opener_list(&state, &pane));
218    let Some(list) = list else {
219        // Taken over or closed since: the host's copy was behind. Send it
220        // the current one, and let the navigation it stopped go on.
221        crate::server::browser_host_sync::push(&state).await;
222        if kind == "navigate" {
223            replay_navigation(&state, &target, &url).await;
224        }
225        return answered(false, "the pane isn't limited to any sites");
226    };
227    if allowed_origins::allows(&list, &url) {
228        // Allowed already (the host's copy was behind): send it the list, and go on.
229        crate::server::browser_host_sync::push(&state).await;
230        if kind == "navigate" {
231            replay_navigation(&state, &target, &url).await;
232        }
233        return answered(false, "it is on the pane's sites");
234    }
235    let Some(origin) = allowed_origins::target_origin(&url) else {
236        return answered(false, "not a web address");
237    };
238    if browser_attention::waiting_on_user(&pane).is_some() {
239        return answered(false, "the pane is already waiting for the user");
240    }
241    let agent = crate::server::browser_owner::owner_of(&pane).unwrap_or_default();
242    let window = crate::server::browser_popup::is_window_id(&target)
243        .then(|| crate::server::browser_popup::window(&target).map(|w| w.url))
244        .flatten();
245    tracing::info!(pane = %pane, target = %target, origin = %origin, kind = %kind, "[browser-allowlist] asking the user");
246    let st = state.clone();
247    tokio::spawn(async move {
248        let asked = browser_attention::ask(
249            &st,
250            &pane,
251            &agent,
252            browser_attention::Kind::Navigation,
253            json!({ "url": url, "origin": origin, "window": window, "popup": kind == "popup" }),
254            std::time::Duration::from_secs(10 * 60),
255        )
256        .await;
257        let (answer, waiting) = match asked {
258            Ok(a) => a,
259            Err(e) => {
260                tracing::debug!(pane = %pane, error = %e, "[browser-allowlist] couldn't ask");
261                return;
262            }
263        };
264        if answer != browser_attention::Answer::Yes {
265            tracing::info!(pane = %pane, origin = %origin, answer = ?answer, "[browser-allowlist] not allowed");
266        } else if let Some((list, members)) = browser_allowlist::allow(&pane, &origin) {
267            // (None: taken over or closed meanwhile, no list left to add to.)
268            tracing::info!(pane = %pane, origin = %origin, "[browser-allowlist] allowed by the user");
269            browser_allowlist::publish(&st, &members, Some(&list));
270            crate::server::browser_host_sync::push(&st).await;
271            if kind == "navigate" {
272                replay_navigation(&st, &target, &url).await;
273            }
274        }
275        // The pane stays locked until the load is on its way. Then the host
276        // hears the question is over, and reports this pane's attempts again.
277        drop(waiting);
278        crate::server::browser_host_sync::push(&st).await;
279    });
280    answered(true, "")
281}
282
283/// The site list of the pane `pane` was opened from as a popup, from srv's
284/// own records (`browser:popup_of`, which only srv writes). Covers the moment
285/// between a popup pane opening and joining its chain: a navigation the host
286/// stopped then is still asked about, not replayed.
287fn opener_list(state: &AppState, pane: &str) -> Option<Vec<String>> {
288    let block = state.mstore.get::<crate::backend::obj::Block>(pane).ok().flatten()?;
289    let opener = block.meta.get(crate::server::browser_popup::POPUP_OF_META_KEY)?.as_str()?;
290    list_for(opener)
291}
292
293/// Load `url` in `target` (a pane or popup window), as the navigation the
294/// host stopped would have. A plain load: a form's data isn't sent again.
295async fn replay_navigation(state: &AppState, target: &str, url: &str) {
296    let Ok(host) = ui::get_host_ipc(state).await else { return };
297    if ui::proxy_ack(state, &host, "navigate", json!({ "block_id": target, "url": url })).await.is_err() {
298        tracing::warn!(target = %target, "[browser-allowlist] couldn't load the allowed address");
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn a_chain_shares_one_list_and_keeps_it_past_its_root() {
308        set("al-root", vec!["https://a.example".into()]);
309        assert_eq!(join("al-popup", "al-root").unwrap(), vec!["https://a.example"]);
310        assert_eq!(join("al-popup2", "al-popup").unwrap(), vec!["https://a.example"]);
311        // Allowing on any member adds to the whole chain.
312        let (list, mut members) = allow("al-popup2", "https://b.example").unwrap();
313        members.sort();
314        assert_eq!(list, vec!["https://a.example", "https://b.example"]);
315        assert_eq!(members, vec!["al-popup", "al-popup2", "al-root"]);
316        assert_eq!(list_for("al-root").unwrap().len(), 2);
317        // A member leaving keeps the rest.
318        assert!(drop_pane("al-popup"));
319        assert!(list_for("al-popup").is_none() && list_for("al-popup2").is_some());
320        // The root leaving too: a popup pane that outlives it stays limited.
321        assert!(drop_pane("al-root"));
322        assert!(list_for("al-root").is_none());
323        assert_eq!(list_for("al-popup2").unwrap().len(), 2);
324        // A popup of that popup still joins the same list.
325        assert_eq!(join("al-popup3", "al-popup2").unwrap().len(), 2);
326        // The list goes with its last pane.
327        assert!(drop_pane("al-popup2") && drop_pane("al-popup3"));
328        assert!(!snapshot().keys().any(|k| k.starts_with("al-")));
329        assert!(!lock().lists.contains_key("al-root"));
330    }
331
332    #[test]
333    fn a_pane_without_a_list_has_nothing_to_join_or_allow() {
334        assert!(join("al-x-popup", "al-x-unlisted").is_none());
335        assert!(allow("al-x-unlisted", "https://b.example").is_none());
336        assert!(!drop_pane("al-x-unlisted"));
337    }
338
339    #[test]
340    fn the_snapshot_lists_every_member() {
341        set("al-s-root", vec!["https://a.example".into()]);
342        join("al-s-popup", "al-s-root");
343        let snap = snapshot();
344        assert_eq!(snap.get("al-s-root"), Some(&vec!["https://a.example".to_string()]));
345        assert_eq!(snap.get("al-s-popup"), Some(&vec!["https://a.example".to_string()]));
346        drop_pane("al-s-root");
347        drop_pane("al-s-popup");
348    }
349}