Skip to main content

agentmux_srv/server/
browser_owner.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Which agent owns which agent-opened browser pane
5//! (docs/specs/SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §3).
6//!
7//! An agent may drive a browser pane only if it opened that pane with
8//! `OpenBrowser`. The authority is this in-memory map, written only by
9//! `ui_handlers::handle_ui_browser_open` after verifying the caller's
10//! signed identity. The block's `browser:owner_agent` meta key mirrors it
11//! for the "Driven by" badge, and both must agree:
12//!
13//! - a client that writes the meta key itself gains nothing, since the map
14//!   has no entry for it;
15//! - a duplicated pane copies the meta key but gets a new block id, which
16//!   the map doesn't know;
17//! - the human's Take over clears the meta key; the client-write guard
18//!   (`guard_client_meta_write`) then drops the map entry, so ownership ends
19//!   for good, and no client can set the key at all;
20//! - deleting the block drops its entry (`wcore::delete_block`);
21//! - after srv restarts the map is empty, so agents reopen their panes.
22
23use std::collections::HashMap;
24use std::sync::{Mutex, OnceLock};
25
26use crate::backend::obj::{Block, MetaMapType};
27
28/// Block meta key mirroring the owner, for the frontend.
29pub(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
36/// Record `agent_id` as the owner of `block_id`. Only `OpenBrowser` calls this.
37pub(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
45/// Drop `block_id`'s owner: the pane was deleted or taken over.
46pub(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
57/// Every pane an agent owns. The host keeps a copy, to decide inside
58/// `on_before_popup` which of a pane's popups open in-app
59/// (SPEC_BROWSER_PANE_NATIVE_POPUPS_AGENT_DRIVEN_2026_10_08.md §3); it is
60/// only a hint there, since who may drive what is always checked here.
61pub(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
72/// Woken whenever the set of owned panes changes, or the host needs the
73/// whole set again (it registered). See `browser_host_sync`.
74pub(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/// Why a caller may not drive a pane. Not an authentication failure: the
88/// caller's identity was already verified.
89#[derive(Debug, PartialEq, Eq)]
90pub(crate) enum Denied {
91    /// No such block (closed, or never existed).
92    NotFound(String),
93    /// The block exists, but this caller may not drive it.
94    Forbidden(String),
95}
96
97/// May `agent_id` drive `pane`? `block` is the pane's block as stored (None if
98/// it no longer exists) and `recorded` its entry in the owner map.
99pub(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
136/// Meta keys only srv writes, from its own records: who drives a pane, the
137/// banner that asks the person something, what opened a popup, and the sites
138/// a pane is limited to.
139pub(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
150/// Remove srv-only keys from the meta a client gives a NEW block
151/// (`pane.open` over RPC or HTTP, `object.CreateBlock`). Removed rather than
152/// refused, so duplicating a pane still works: the copy is an ordinary pane,
153/// without an owner, a banner, or a claim to be a popup. srv's own callers
154/// (`OpenBrowser`, the popup route) set these keys after this check, through
155/// `open_pane` directly. Returns the keys it removed.
156pub(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
160/// Guard for meta updates that come from a client (the `setmeta` WebSocket
161/// command and the `UpdateObjectMeta` service call), not from srv itself.
162/// `browser:owner_agent` is srv's to write: a client may only clear it, which
163/// is the human's Take over and ends that pane's ownership for good. Setting
164/// or changing it is refused, so a taken-over agent can't write itself back.
165/// `browser:attention` (the hand-off and approval banner) is srv's alone:
166/// a client that could write it could keep a real request's id and change
167/// what the banner tells the human they're approving.
168pub(crate) fn guard_client_meta_write(oref: &str, meta: &MetaMapType) -> Result<(), String> {
169    // The popup keys name the pane that opened this one and its site, shown
170    // to the person as "Popup from …": a client that could write them could
171    // make any pane claim to come from a site it doesn't.
172    // The site list is the agent's limit on its pane: a client that could
173    // write it could widen it.
174    // A tab's identity is fixed when it is opened: changing it would move a
175    // live tab to another jar under the person's feet.
176    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        // Take over also answers any hand-off or approval still up on the
200        // pane: an Approve clicked after it must not go through.
201        crate::server::browser_attention::cancel_for(block_id);
202        // And ends the site limit: the person browses freely. The header
203        // shows the limit only while an agent drives the pane, so the meta
204        // key left behind shows nothing.
205        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        // Agent ids compare case-insensitively, as elsewhere.
229        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        // A client wrote the meta key, or the pane was duplicated: no map entry.
245        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        // The taken-over agent writing itself back, or any client faking an owner.
293        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        // Even if the key were somehow written back, the map no longer agrees.
309        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        // Rewriting a real request's banner (same id, harmless-looking text)
322        // would make the human approve something they weren't shown.
323        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}