1use std::collections::HashMap;
24use std::sync::{Mutex, OnceLock};
25
26use crate::backend::obj::{Block, MetaMapType};
27
28pub(crate) const OWNER_META_KEY: &str = "browser:owner_agent";
30
31fn owners() -> &'static Mutex<HashMap<String, String>> {
32 static OWNERS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
33 OWNERS.get_or_init(Default::default)
34}
35
36pub(crate) fn record(block_id: &str, agent_id: &str) {
38 owners()
39 .lock()
40 .unwrap_or_else(|p| p.into_inner())
41 .insert(block_id.to_string(), agent_id.to_string());
42 changed().notify_one();
43}
44
45pub(crate) fn forget(block_id: &str) {
47 let removed = owners()
48 .lock()
49 .unwrap_or_else(|p| p.into_inner())
50 .remove(block_id)
51 .is_some();
52 if removed {
53 changed().notify_one();
54 }
55}
56
57pub(crate) fn owned_panes() -> Vec<String> {
62 let mut v: Vec<String> = owners()
63 .lock()
64 .unwrap_or_else(|p| p.into_inner())
65 .keys()
66 .cloned()
67 .collect();
68 v.sort();
69 v
70}
71
72pub(crate) fn changed() -> &'static tokio::sync::Notify {
75 static CHANGED: OnceLock<tokio::sync::Notify> = OnceLock::new();
76 CHANGED.get_or_init(tokio::sync::Notify::new)
77}
78
79pub(crate) fn owner_of(block_id: &str) -> Option<String> {
80 owners()
81 .lock()
82 .unwrap_or_else(|p| p.into_inner())
83 .get(block_id)
84 .cloned()
85}
86
87#[derive(Debug, PartialEq, Eq)]
90pub(crate) enum Denied {
91 NotFound(String),
93 Forbidden(String),
95}
96
97pub(crate) fn check(
100 block: Option<&Block>,
101 recorded: Option<&str>,
102 agent_id: &str,
103 pane: &str,
104) -> Result<(), Denied> {
105 let block = block
106 .ok_or_else(|| Denied::NotFound(format!("no pane {pane:?} (it may have been closed)")))?;
107 let view = block
108 .meta
109 .get("view")
110 .and_then(|v| v.as_str())
111 .unwrap_or("");
112 if view != "browser" {
113 return Err(Denied::Forbidden(format!(
114 "pane {pane:?} is not a browser pane"
115 )));
116 }
117 if !recorded.is_some_and(|r| r.eq_ignore_ascii_case(agent_id)) {
118 return Err(Denied::Forbidden(format!(
119 "pane {pane:?} is not a browser pane you own: an agent can drive only browser \
120 panes it opened itself with OpenBrowser, and loses one the user takes over"
121 )));
122 }
123 let mirrored = block
124 .meta
125 .get(OWNER_META_KEY)
126 .and_then(|v| v.as_str())
127 .unwrap_or("");
128 if !mirrored.eq_ignore_ascii_case(agent_id) {
129 return Err(Denied::Forbidden(format!(
130 "the user took over pane {pane:?}; open a new one with OpenBrowser if you still need a browser"
131 )));
132 }
133 Ok(())
134}
135
136pub(crate) fn srv_only_keys() -> [&'static str; 6] {
140 [
141 OWNER_META_KEY,
142 crate::server::browser_allowlist::ALLOWED_META_KEY,
143 crate::server::browser_attention::ATTENTION_META_KEY,
144 crate::server::browser_popup::POPUP_OF_META_KEY,
145 crate::server::browser_popup::POPUP_FROM_META_KEY,
146 crate::server::browser_popup::POPUP_WINDOWS_META_KEY,
147 ]
148}
149
150pub(crate) fn strip_srv_only_keys(meta: &mut MetaMapType) -> Vec<&'static str> {
157 srv_only_keys().into_iter().filter(|k| meta.remove(*k).is_some()).collect()
158}
159
160pub(crate) fn guard_client_meta_write(oref: &str, meta: &MetaMapType) -> Result<(), String> {
169 for key in [
177 crate::server::browser_attention::ATTENTION_META_KEY,
178 crate::server::browser_allowlist::ALLOWED_META_KEY,
179 crate::server::browser_identity::IDENTITY_META_KEY,
180 crate::server::browser_popup::POPUP_OF_META_KEY,
181 crate::server::browser_popup::POPUP_FROM_META_KEY,
182 crate::server::browser_popup::POPUP_WINDOWS_META_KEY,
183 ] {
184 if meta.contains_key(key) {
185 return Err(format!("{key} is written only by AgentMux itself"));
186 }
187 }
188 let Some(value) = meta.get(OWNER_META_KEY) else {
189 return Ok(());
190 };
191 if !value.is_null() {
192 return Err(format!(
193 "{OWNER_META_KEY} is written by AgentMux itself when an agent opens a browser pane; \
194 a client can only clear it (Take over)"
195 ));
196 }
197 if let Some(block_id) = oref.strip_prefix("block:") {
198 forget(block_id);
199 crate::server::browser_attention::cancel_for(block_id);
202 crate::server::browser_allowlist::drop_pane(block_id);
206 }
207 Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use serde_json::json;
214
215 fn block(view: &str, owner: Option<&str>) -> Block {
216 let mut b = Block::default();
217 b.meta.insert("view".to_string(), json!(view));
218 if let Some(o) = owner {
219 b.meta.insert(OWNER_META_KEY.to_string(), json!(o));
220 }
221 b
222 }
223
224 #[test]
225 fn the_opener_may_drive_its_pane() {
226 let b = block("browser", Some("lark"));
227 assert!(check(Some(&b), Some("lark"), "lark", "p1").is_ok());
228 assert!(check(Some(&b), Some("Lark"), "lark", "p1").is_ok());
230 }
231
232 #[test]
233 fn another_agent_may_not() {
234 let b = block("browser", Some("lark"));
235 let e = check(Some(&b), Some("lark"), "korp", "p1").unwrap_err();
236 assert!(
237 matches!(&e, Denied::Forbidden(m) if m.contains("not a browser pane you own")),
238 "{e:?}"
239 );
240 }
241
242 #[test]
243 fn a_forged_meta_key_without_a_recorded_owner_is_refused() {
244 let b = block("browser", Some("lark"));
246 assert!(check(Some(&b), None, "lark", "p1").is_err());
247 }
248
249 #[test]
250 fn take_over_clears_the_meta_key_and_ends_ownership() {
251 let b = block("browser", None);
252 let e = check(Some(&b), Some("lark"), "lark", "p1").unwrap_err();
253 assert!(
254 matches!(&e, Denied::Forbidden(m) if m.contains("took over")),
255 "{e:?}"
256 );
257 }
258
259 #[test]
260 fn only_browser_panes_and_only_existing_ones() {
261 let b = block("term", Some("lark"));
262 assert!(matches!(
263 check(Some(&b), Some("lark"), "lark", "p1"),
264 Err(Denied::Forbidden(_))
265 ));
266 assert!(matches!(
267 check(None, Some("lark"), "lark", "p1"),
268 Err(Denied::NotFound(_))
269 ));
270 }
271
272 #[test]
273 fn record_owner_of_and_forget() {
274 record("test-block-browser-owner-rt", "lark");
275 assert_eq!(
276 owner_of("test-block-browser-owner-rt").as_deref(),
277 Some("lark")
278 );
279 assert_eq!(owner_of("test-block-browser-owner-unknown"), None);
280 forget("test-block-browser-owner-rt");
281 assert_eq!(owner_of("test-block-browser-owner-rt"), None);
282 }
283
284 fn meta(key: &str, v: serde_json::Value) -> MetaMapType {
285 let mut m = MetaMapType::new();
286 m.insert(key.to_string(), v);
287 m
288 }
289
290 #[test]
291 fn a_client_cannot_set_the_owner_key() {
292 let e =
294 guard_client_meta_write("block:test-guard-set", &meta(OWNER_META_KEY, json!("lark")))
295 .unwrap_err();
296 assert!(e.contains("only clear it"), "{e}");
297 }
298
299 #[test]
300 fn a_client_clearing_the_owner_key_ends_ownership_for_good() {
301 record("test-guard-clear", "lark");
302 guard_client_meta_write(
303 "block:test-guard-clear",
304 &meta(OWNER_META_KEY, serde_json::Value::Null),
305 )
306 .unwrap();
307 assert_eq!(owner_of("test-guard-clear"), None);
308 let b = block("browser", Some("lark"));
310 assert!(check(
311 Some(&b),
312 owner_of("test-guard-clear").as_deref(),
313 "lark",
314 "test-guard-clear"
315 )
316 .is_err());
317 }
318
319 #[test]
320 fn a_client_cannot_write_the_banner_at_all() {
321 let key = crate::server::browser_attention::ATTENTION_META_KEY;
324 let forged = json!({ "id": "real-id", "kind": "approval", "what": "Click \"Cancel\"" });
325 assert!(guard_client_meta_write("block:test-guard-attn", &meta(key, forged)).is_err());
326 assert!(guard_client_meta_write("block:test-guard-attn", &meta(key, serde_json::Value::Null)).is_err());
327 }
328
329 #[test]
330 fn a_client_cannot_write_the_popup_keys() {
331 use crate::server::browser_popup::{POPUP_FROM_META_KEY, POPUP_OF_META_KEY, POPUP_WINDOWS_META_KEY};
332 for key in [POPUP_OF_META_KEY, POPUP_FROM_META_KEY, POPUP_WINDOWS_META_KEY] {
333 assert!(guard_client_meta_write("block:test-guard-popup", &meta(key, json!("x"))).is_err());
334 assert!(guard_client_meta_write("block:test-guard-popup", &meta(key, serde_json::Value::Null)).is_err());
335 }
336 }
337
338 #[test]
339 fn a_new_block_from_a_client_starts_without_srv_only_keys() {
340 let mut m = MetaMapType::new();
341 m.insert("view".into(), json!("browser"));
342 m.insert("url".into(), json!("https://example.com"));
343 m.insert(OWNER_META_KEY.into(), json!("lark"));
344 m.insert(crate::server::browser_popup::POPUP_FROM_META_KEY.into(), json!("https://bank.example"));
345 m.insert(crate::server::browser_popup::POPUP_OF_META_KEY.into(), json!("some-pane"));
346 let mut removed = strip_srv_only_keys(&mut m);
347 removed.sort();
348 assert_eq!(removed, vec!["browser:owner_agent", "browser:popup_from", "browser:popup_of"]);
349 assert_eq!(m.len(), 2);
350 assert!(strip_srv_only_keys(&mut m).is_empty());
351 }
352
353 #[test]
354 fn other_meta_writes_pass_untouched() {
355 record("test-guard-other", "lark");
356 guard_client_meta_write(
357 "block:test-guard-other",
358 &meta("url", json!("https://example.com")),
359 )
360 .unwrap();
361 assert_eq!(owner_of("test-guard-other").as_deref(), Some("lark"));
362 forget("test-guard-other");
363 }
364}