Skip to main content

agentmux_srv/server/
browser_identity.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Which cookie jar a browser tab browses as: its block's `browser:identity`
5//! (docs/specs/SPEC_BROWSER_PANE_PROFILES_MENU_2026_10_09.md §5–§6). Absent
6//! for Personal; `incognito:<jar>` for an in-memory jar of its own, which the
7//! CEF host makes on first use. Set when the tab is opened and never changed:
8//! to browse as someone else, open another tab.
9
10use std::collections::{HashMap, HashSet};
11
12use serde_json::{json, Value};
13
14use crate::backend::browser_profiles_store::BrowserProfile;
15use crate::backend::obj::{Block, MetaMapType};
16use crate::server::AppState;
17
18pub(crate) const IDENTITY_META_KEY: &str = "browser:identity";
19
20/// Most Incognito jars open at once: each is its own Chrome profile with its
21/// own renderer (identities spec §4.3). The host keeps the same cap.
22pub(crate) const MAX_INCOGNITO_JARS: usize = 8;
23
24/// Is `identity` a well-formed Incognito identity?
25pub(crate) fn is_incognito(identity: &str) -> bool {
26    identity
27        .strip_prefix("incognito:")
28        .is_some_and(|jar| (8..=64).contains(&jar.len()) && jar.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-'))
29}
30
31/// The profile id of a `profile:<id>` identity.
32pub(crate) fn profile_id(identity: &str) -> Option<&str> {
33    identity
34        .strip_prefix("profile:")
35        .filter(|id| crate::backend::browser_profiles_store::is_profile_id(id))
36}
37
38/// The ids of the named profiles there are, or `None` when they can't be read.
39pub(crate) fn profile_ids() -> Option<HashSet<String>> {
40    match crate::server::app_api::browser_profiles_list() {
41        Ok(ps) => Some(ps.into_iter().map(|p| p.id).collect()),
42        Err(e) => {
43            tracing::warn!(error = %e, "[browser-identity] couldn't read the browser profiles");
44            None
45        }
46    }
47}
48
49fn identity_of(meta: &MetaMapType) -> Option<&str> {
50    meta.get(IDENTITY_META_KEY).and_then(Value::as_str)
51}
52
53/// The Incognito identities blocks use now: the jars the host must keep.
54/// `None` when the store can't be read: the host then keeps every jar, rather
55/// than taking an empty list as "no tab uses any" and signing them all out.
56pub(crate) fn live_jars(state: &AppState) -> Option<Vec<String>> {
57    let blocks = match state.mstore.get_all::<Block>() {
58        Ok(b) => b,
59        Err(e) => {
60            tracing::warn!(error = %e, "[browser-identity] couldn't list blocks; the host keeps its jars");
61            return None;
62        }
63    };
64    let set: HashSet<String> = blocks
65        .iter()
66        .filter_map(|b| identity_of(&b.meta))
67        .filter(|i| is_incognito(i))
68        .map(str::to_string)
69        .collect();
70    Some(set.into_iter().collect())
71}
72
73/// Check the identity a client gives a new browser tab (`pane.open`): an
74/// Incognito one that is well formed, within the cap unless it shares a jar
75/// already open. Anything else
76/// fails the open rather than quietly browsing in the shared jar.
77pub(crate) fn check_new_tab(state: &AppState, meta: &MetaMapType) -> Result<(), String> {
78    let Some(v) = meta.get(IDENTITY_META_KEY) else { return Ok(()) };
79    let identity = v.as_str().unwrap_or("");
80    if let Some(id) = profile_id(identity) {
81        let known = profile_ids().ok_or("couldn't read the browser profiles")?;
82        return if known.contains(id) { Ok(()) } else { Err(format!("there is no browser profile {id:?}")) };
83    }
84    if !is_incognito(identity) {
85        return Err(format!("{IDENTITY_META_KEY} must be incognito:<id> or profile:<id>"));
86    }
87    let open = live_jars(state).ok_or("couldn't count the Incognito tabs open")?;
88    if !open.iter().any(|j| j == identity) && open.len() >= MAX_INCOGNITO_JARS {
89        return Err(format!(
90            "at most {MAX_INCOGNITO_JARS} Incognito tabs can be open at once: close one to open another"
91        ));
92    }
93    Ok(())
94}
95
96/// A popup pane browses as the tab that opened it: same jar, so a sign-in in
97/// it signs in the tab.
98pub(crate) fn inherit(meta: &mut MetaMapType, opener: &Block) {
99    if let Some(identity) = identity_of(&opener.meta).filter(|i| is_incognito(i) || profile_id(i).is_some()) {
100        meta.insert(IDENTITY_META_KEY.to_string(), json!(identity));
101    }
102}
103
104/// How many more Incognito jars a layout replay may open: the cap less those
105/// open now (none when they can't be counted).
106pub(crate) fn jars_available(state: &AppState) -> usize {
107    live_jars(state).map_or(0, |open| MAX_INCOGNITO_JARS.saturating_sub(open.len()))
108}
109
110/// For a layout being replayed: each Incognito identity becomes a fresh one,
111/// so the replay doesn't share a jar with a tab still open. Tabs that shared
112/// a jar keep sharing one. `fresh` maps old to new across the replay; at most
113/// `available` fresh jars are made. Returns false for a tab that would need
114/// one more: it is left out of the replay, not opened as a blank pane or in
115/// the shared jar.
116pub(crate) fn refresh_for_replay(
117    meta: &mut Value,
118    fresh: &mut HashMap<String, String>,
119    available: usize,
120    profiles: Option<&HashSet<String>>,
121) -> bool {
122    let Some(obj) = meta.as_object_mut() else { return true };
123    // A tab of a profile that no longer exists is left out, not reopened in
124    // a jar the host would make anew under the old id.
125    if let Some(id) = obj.get(IDENTITY_META_KEY).and_then(Value::as_str).and_then(profile_id) {
126        return profiles.is_some_and(|known| known.contains(id));
127    }
128    let Some(old) = obj.get(IDENTITY_META_KEY).and_then(Value::as_str).filter(|i| is_incognito(i)).map(str::to_string) else {
129        return true;
130    };
131    if !fresh.contains_key(&old) && fresh.len() >= available {
132        tracing::info!("[browser-identity] a replayed Incognito tab left out: no jar to spare");
133        return false;
134    }
135    let new = fresh.entry(old).or_insert_with(|| format!("incognito:{}", uuid::Uuid::new_v4())).clone();
136    obj.insert(IDENTITY_META_KEY.to_string(), json!(new));
137    true
138}
139
140/// The identity an agent's `OpenBrowser` asked for by `profile` browses as:
141/// none (Personal) for "personal" or nothing; a fresh Incognito jar for
142/// "incognito", always allowed since it gives the agent less, not more; and a
143/// named profile (by name, any case, or id) only when the user switched on
144/// "Agents may use it" for it in Settings → Browser (profiles spec §5).
145pub(crate) fn identity_for_agent(requested: &str, profiles: &[BrowserProfile]) -> Result<Option<String>, String> {
146    let requested = requested.trim();
147    if requested.is_empty() || requested.eq_ignore_ascii_case("personal") {
148        return Ok(None);
149    }
150    if requested.eq_ignore_ascii_case("incognito") {
151        return Ok(Some(format!("incognito:{}", uuid::Uuid::new_v4())));
152    }
153    let allowed = || {
154        let names: Vec<&str> = profiles.iter().filter(|p| p.agents_allowed).map(|p| p.name.as_str()).collect();
155        if names.is_empty() {
156            "none".to_string()
157        } else {
158            names.join(", ")
159        }
160    };
161    let Some(p) = profiles.iter().find(|p| p.name.eq_ignore_ascii_case(requested) || p.id == requested) else {
162        return Err(format!(
163            "there is no browser profile called {requested:?}. Use \"incognito\", or a profile the user lets agents use: {}",
164            allowed()
165        ));
166    };
167    if !p.agents_allowed {
168        return Err(format!(
169            "the user hasn't let agents use the browser profile {:?}: they can switch on \"Agents may use it\" for it in Settings → Browser. Use \"incognito\", or a profile agents may use: {}",
170            p.name,
171            allowed()
172        ));
173    }
174    Ok(Some(format!("profile:{}", p.id)))
175}
176
177/// `OpenBrowser`'s `profile`: give the agent's new tab the identity it asked
178/// for, checked like any new tab (the platform, the Incognito cap).
179pub(crate) fn for_agent_open(state: &AppState, meta: &mut MetaMapType, requested: Option<&str>) -> Result<(), String> {
180    let Some(requested) = requested else { return Ok(()) };
181    let profiles = crate::server::app_api::browser_profiles_list()?;
182    if let Some(identity) = identity_for_agent(requested, &profiles)? {
183        meta.insert(IDENTITY_META_KEY.to_string(), json!(identity));
184        check_new_tab(state, meta)?;
185    }
186    Ok(())
187}
188
189/// An agent opening a browser tab some other way (`pane.open`, the HTTP pane
190/// open) with a `browser:identity`: a named profile only if agents may use it.
191pub(crate) fn check_agent_may_use(meta: &MetaMapType) -> Result<(), String> {
192    let Some(id) = identity_of(meta).and_then(profile_id) else { return Ok(()) };
193    let profiles = crate::server::app_api::browser_profiles_list()?;
194    match profiles.iter().find(|p| p.id == id) {
195        Some(p) if p.agents_allowed => Ok(()),
196        Some(p) => Err(format!("the user hasn't let agents use the browser profile {:?}", p.name)),
197        None => Err(format!("there is no browser profile {id:?}")),
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    fn profile(id: &str, name: &str, agents_allowed: bool) -> BrowserProfile {
206        BrowserProfile { id: id.into(), name: name.into(), color: "#3b82f6".into(), created_at: 0, agents_allowed }
207    }
208
209    #[test]
210    fn an_agent_gets_personal_incognito_or_a_profile_it_is_allowed() {
211        let ps = [profile("p-work", "Work", true), profile("p-bank", "Bank", false)];
212        assert_eq!(identity_for_agent("", &ps), Ok(None));
213        assert_eq!(identity_for_agent("Personal", &ps), Ok(None));
214        let a = identity_for_agent("incognito", &ps).unwrap().unwrap();
215        let b = identity_for_agent("Incognito", &ps).unwrap().unwrap();
216        assert!(is_incognito(&a) && is_incognito(&b) && a != b, "a fresh jar each time");
217        assert_eq!(identity_for_agent("work", &ps), Ok(Some("profile:p-work".into())));
218        assert_eq!(identity_for_agent("p-work", &ps), Ok(Some("profile:p-work".into())));
219        let refused = identity_for_agent("Bank", &ps).unwrap_err();
220        assert!(refused.contains("hasn't let agents use") && refused.contains("Work"), "{refused}");
221        assert!(refused.contains("\"Agents may use it\" for it in Settings → Browser") && !refused.contains("  "), "{refused}");
222        let unknown = identity_for_agent("Nope", &ps).unwrap_err();
223        assert!(unknown.contains("no browser profile") && !unknown.contains("Bank"), "{unknown}");
224    }
225
226    #[test]
227    fn only_well_formed_incognito_identities_pass() {
228        assert!(is_incognito("incognito:0f0e2d1c-aaaa-bbbb-cccc-0123456789ab"));
229        for bad in ["incognito:short", "incognito:has spaces x", "profile:work", "incognito:", ""] {
230            assert!(!is_incognito(bad), "{bad:?}");
231        }
232    }
233
234    #[test]
235    fn a_replayed_layout_gets_fresh_jars_and_keeps_shared_ones_shared() {
236        let mut fresh = HashMap::new();
237        let mut a = json!({ "view": "browser", IDENTITY_META_KEY: "incognito:aaaaaaaa-1111" });
238        let mut b = json!({ "view": "browser", IDENTITY_META_KEY: "incognito:aaaaaaaa-1111" });
239        let mut c = json!({ "view": "browser" });
240        assert!(refresh_for_replay(&mut a, &mut fresh, 8, None));
241        assert!(refresh_for_replay(&mut b, &mut fresh, 8, None));
242        assert!(refresh_for_replay(&mut c, &mut fresh, 8, None));
243        let ia = a[IDENTITY_META_KEY].as_str().unwrap();
244        assert_ne!(ia, "incognito:aaaaaaaa-1111");
245        assert!(is_incognito(ia));
246        assert_eq!(a[IDENTITY_META_KEY], b[IDENTITY_META_KEY]);
247        assert!(c.get(IDENTITY_META_KEY).is_none());
248        // Past the jars there are to spare, a tab needing a new one is left out;
249        // one sharing a jar already made still comes.
250        let mut d = json!({ "view": "browser", IDENTITY_META_KEY: "incognito:dddddddd-4444" });
251        assert!(!refresh_for_replay(&mut d, &mut fresh, 1, None));
252        let mut e = json!({ "view": "browser", IDENTITY_META_KEY: "incognito:aaaaaaaa-1111" });
253        assert!(refresh_for_replay(&mut e, &mut fresh, 1, None));
254        // A profile tab comes only while its profile exists, and keeps its id.
255        let known: HashSet<String> = ["p-work".to_string()].into();
256        let mut f = json!({ "view": "browser", IDENTITY_META_KEY: "profile:p-work" });
257        assert!(refresh_for_replay(&mut f, &mut fresh, 0, Some(&known)));
258        assert_eq!(f[IDENTITY_META_KEY], json!("profile:p-work"));
259        let mut g = json!({ "view": "browser", IDENTITY_META_KEY: "profile:p-gone" });
260        assert!(!refresh_for_replay(&mut g, &mut fresh, 0, Some(&known)));
261    }
262
263    #[test]
264    fn a_popup_pane_inherits_an_incognito_jar_only() {
265        let mut opener = Block::default();
266        opener.meta.insert(IDENTITY_META_KEY.into(), json!("incognito:bbbbbbbb-2222"));
267        let mut meta = MetaMapType::new();
268        inherit(&mut meta, &opener);
269        assert_eq!(meta[IDENTITY_META_KEY], json!("incognito:bbbbbbbb-2222"));
270        let mut meta = MetaMapType::new();
271        inherit(&mut meta, &Block::default());
272        assert!(meta.get(IDENTITY_META_KEY).is_none());
273        let mut work = Block::default();
274        work.meta.insert(IDENTITY_META_KEY.into(), json!("profile:p-work"));
275        let mut meta = MetaMapType::new();
276        inherit(&mut meta, &work);
277        assert_eq!(meta[IDENTITY_META_KEY], json!("profile:p-work"));
278    }
279
280    #[tokio::test]
281    async fn a_new_tab_identity_is_checked() {
282        let state = crate::server::tests::test_state();
283        let with = |v: Value| {
284            let mut m = MetaMapType::new();
285            m.insert(IDENTITY_META_KEY.into(), v);
286            m
287        };
288        assert!(check_new_tab(&state, &MetaMapType::new()).is_ok());
289        assert!(check_new_tab(&state, &with(json!("incognito:short"))).is_err());
290        // Profiles work on every platform: an unknown one is refused as unknown.
291        let unknown = check_new_tab(&state, &with(json!("profile:p-none"))).unwrap_err();
292        assert!(unknown.contains("there is no browser profile"), "{unknown}");
293        assert!(check_new_tab(&state, &with(json!("profile:Bad Id"))).is_err());
294        assert!(check_new_tab(&state, &with(json!(5))).is_err());
295        let ok = check_new_tab(&state, &with(json!("incognito:cccccccc-3333")));
296        assert!(ok.is_ok(), "{ok:?}");
297    }
298}