1use 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
28pub(crate) const ALLOWED_META_KEY: &str = "browser:allowed_origins";
30
31#[derive(Default)]
32struct Lists {
33 lists: HashMap<String, Vec<String>>,
35 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
48pub(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
57pub(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
69pub(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
76pub(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
92pub(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
107pub(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
116pub(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
128pub(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
142pub(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
149pub(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
159pub(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
168pub(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
184pub(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
197pub(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 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 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 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 drop(waiting);
278 crate::server::browser_host_sync::push(&st).await;
279 });
280 answered(true, "")
281}
282
283fn 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
293async 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 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 assert!(drop_pane("al-popup"));
319 assert!(list_for("al-popup").is_none() && list_for("al-popup2").is_some());
320 assert!(drop_pane("al-root"));
322 assert!(list_for("al-root").is_none());
323 assert_eq!(list_for("al-popup2").unwrap().len(), 2);
324 assert_eq!(join("al-popup3", "al-popup2").unwrap().len(), 2);
326 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}