Skip to main content

agentmux_common/
popup_rules.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Which new windows a browser pane's page may open inside AgentMux
5//! (docs/specs/SPEC_BROWSER_PANE_POPUPS_ADOPTED_2026_10_08.md §3.1,
6//! docs/specs/SPEC_BROWSER_PANE_NATIVE_POPUPS_AGENT_DRIVEN_2026_10_08.md §3).
7//!
8//! Shared by the CEF host, which must decide inside `on_before_popup`
9//! (creating a native popup is that callback's return value, so it can't
10//! wait on srv), and srv, which re-checks before it opens a pane.
11
12/// New windows one pane may have open at once, per kind (popup windows,
13/// popup panes). Beyond it, they go to the system browser.
14pub const MAX_POPUPS_PER_PANE: usize = 8;
15
16/// Why a request is not honored. The caller does what it did before this
17/// existed (the system browser, or the pane's own frame for a link).
18#[derive(Debug, PartialEq, Eq)]
19pub enum Refusal {
20    NotWeb,
21    NoUserAction,
22    TooMany,
23    AnotherSite,
24}
25
26impl Refusal {
27    pub fn reason(&self) -> &'static str {
28        match self {
29            Refusal::NotWeb => "not an http(s) page",
30            Refusal::NoUserAction => "the page opened it without a click or key press",
31            Refusal::TooMany => "the pane already has as many popups open as it may",
32            Refusal::AnotherSite => "it is on another site, and no agent owns the pane that opened it",
33        }
34    }
35}
36
37/// Is this request honored?
38///
39/// - only one the user (or the agent driving the pane, whose clicks are real
40///   input events) caused: a script with no gesture opens nothing new;
41/// - an http(s) page;
42/// - fewer than [`MAX_POPUPS_PER_PANE`] of its kind already open;
43/// - and either an agent owns the opener, so the window is part of what that
44///   agent is doing and must stay where it can drive it, or the target is on
45///   the opener's own site.
46pub fn decide(
47    url: &str,
48    opener_url: &str,
49    user_gesture: bool,
50    opener_owned: bool,
51    open_popups: usize,
52) -> Result<(), Refusal> {
53    if !is_web_url(url) {
54        return Err(Refusal::NotWeb);
55    }
56    if !user_gesture {
57        return Err(Refusal::NoUserAction);
58    }
59    if open_popups >= MAX_POPUPS_PER_PANE {
60        return Err(Refusal::TooMany);
61    }
62    if opener_owned || same_site(url, opener_url) {
63        Ok(())
64    } else {
65        Err(Refusal::AnotherSite)
66    }
67}
68
69fn is_web_url(url: &str) -> bool {
70    url::Url::parse(url).is_ok_and(|u| matches!(u.scheme(), "http" | "https"))
71}
72
73fn is_ip_host(url: &str) -> bool {
74    url::Url::parse(url).is_ok_and(|u| matches!(u.host(), Some(url::Host::Ipv4(_)) | Some(url::Host::Ipv6(_))))
75}
76
77fn host_of(url: &str) -> Option<String> {
78    let u = url::Url::parse(url).ok()?;
79    if !matches!(u.scheme(), "http" | "https") {
80        return None;
81    }
82    u.host_str().map(|h| h.trim_end_matches('.').to_ascii_lowercase())
83}
84
85/// Do both URLs belong to the same site: the same registrable domain under
86/// the public suffix list (`console.example.com` and `signin.example.com`
87/// do; `a.github.io` and `b.github.io` don't)? A host with no registrable
88/// domain (an IP address, `localhost`) matches only itself.
89pub fn same_site(a: &str, b: &str) -> bool {
90    // An IP address is a site of its own: the public suffix list would read
91    // its last octet as an unknown top-level domain and call `192.0.2.5` and
92    // `198.51.2.5` one site.
93    if is_ip_host(a) || is_ip_host(b) {
94        return host_of(a).is_some() && host_of(a) == host_of(b);
95    }
96    let (Some(a), Some(b)) = (host_of(a), host_of(b)) else {
97        return false;
98    };
99    if a == b {
100        return true;
101    }
102    match (psl::domain_str(&a), psl::domain_str(&b)) {
103        (Some(da), Some(db)) => da == db,
104        _ => false,
105    }
106}
107
108/// `url`'s origin (`scheme://host[:port]`), or empty if it isn't http(s).
109pub fn origin_of(url: &str) -> String {
110    url::Url::parse(url)
111        .ok()
112        .filter(|u| matches!(u.scheme(), "http" | "https"))
113        .map(|u| u.origin().ascii_serialization())
114        .unwrap_or_default()
115}
116
117/// Is `url` on this machine's loopback (`localhost`, `127.0.0.1`, `[::1]`)?
118pub fn is_loopback(url: &str) -> bool {
119    let Ok(u) = url::Url::parse(url) else {
120        return false;
121    };
122    match u.host() {
123        Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
124        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
125        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
126        None => false,
127    }
128}
129
130/// May a page on loopback open a window to `url`, also on loopback? Only
131/// on its own origin (a local dev site opening its own popup), and never one
132/// of AgentMux's own (`own_origins`: its frontend and srv), which a page
133/// must not be able to put in front of the person
134/// (native-popups spec §3).
135pub fn loopback_allowed(url: &str, opener_url: &str, own_origins: &[String]) -> bool {
136    if !is_loopback(url) {
137        return false;
138    }
139    let origin = origin_of(url);
140    !origin.is_empty()
141        && origin == origin_of(opener_url)
142        && !own_origins.iter().any(|o| o.trim_end_matches('/').eq_ignore_ascii_case(&origin))
143}
144
145/// The title for a popup window, which has no address bar: where it is,
146/// then the page's own title, e.g. `accounts.google.com — Sign in`
147/// (native-popups spec §8.4, N2). A page can set its title to anything, so
148/// the part it can't set comes first. `None` for an address with nothing to
149/// show (`devtools:` and the like): leave the title as the page set it.
150pub fn popup_window_title(url: &str, page_title: &str) -> Option<String> {
151    let place = popup_place(url)?;
152    let page_title = page_title.trim();
153    Some(if page_title.is_empty() { place } else { format!("{place} — {page_title}") })
154}
155
156/// Where a popup is, as its title shows it: the host (a non-default port
157/// added, `http://` kept so an insecure page reads as one; an
158/// internationalised name in its ASCII form, so look-alike letters can't
159/// pass for another site), or the scheme for a page with no host.
160fn popup_place(url: &str) -> Option<String> {
161    let u = url::Url::parse(url).ok()?;
162    match u.scheme() {
163        "http" | "https" | "blob" => match u.origin() {
164            url::Origin::Tuple(scheme, host, port) => {
165                let default_port = if scheme == "https" { 443 } else { 80 };
166                let mut place = if scheme == "http" { format!("http://{host}") } else { host.to_string() };
167                if port != default_port {
168                    place.push_str(&format!(":{port}"));
169                }
170                Some(place)
171            }
172            url::Origin::Opaque(_) => Some(format!("{}:", u.scheme())),
173        },
174        "about" => Some(format!("about:{}", u.path())),
175        "data" | "file" => Some(format!("{}:", u.scheme())),
176        _ => None,
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn same_site_follows_the_public_suffix_list() {
186        assert!(same_site("https://console.example.com/x", "https://signin.example.com/y"));
187        assert!(same_site("https://example.com", "https://www.example.com"));
188        // Hosting platforms where every customer has their own subdomain.
189        assert!(!same_site("https://alice.github.io", "https://bob.github.io"));
190        assert!(!same_site("https://a.example.co.uk", "https://b.other.co.uk"));
191        assert!(same_site("https://a.example.co.uk", "https://b.example.co.uk"));
192        assert!(!same_site("https://example.com", "https://example.org"));
193        // Case and a trailing dot don't matter.
194        assert!(same_site("https://Console.Example.COM.", "https://signin.example.com"));
195    }
196
197    #[test]
198    fn hosts_without_a_registrable_domain_match_only_themselves() {
199        assert!(same_site("http://localhost:3000/a", "http://localhost:4000/b"));
200        assert!(!same_site("http://localhost", "http://127.0.0.1"));
201        assert!(same_site("http://192.0.2.5/a", "http://192.0.2.5/b"));
202        assert!(!same_site("http://192.0.2.5", "http://192.0.2.6"));
203        // Not the same site because their last two octets match.
204        assert!(!same_site("http://192.0.2.5/", "http://198.51.2.5/"));
205        assert!(!same_site("http://[2001:db8::1]/", "http://[2001:db8::2]/"));
206        assert!(same_site("http://[2001:db8::1]:80/a", "http://[2001:db8::1]/b"));
207        // A name and an address are never one site.
208        assert!(!same_site("http://example.com/", "http://192.0.2.5/"));
209    }
210
211    #[test]
212    fn non_web_urls_are_never_the_same_site() {
213        assert!(!same_site("file:///c:/x", "file:///c:/y"));
214        assert!(!same_site("https://example.com", "not a url"));
215    }
216
217    const OPENER: &str = "https://console.example.com/home";
218
219    #[test]
220    fn a_clicked_popup_on_the_openers_site_is_admitted() {
221        assert_eq!(decide("https://signin.example.com/x", OPENER, true, false, 0), Ok(()));
222    }
223
224    #[test]
225    fn another_site_needs_an_owned_opener() {
226        let other = "https://idp.other.net/login";
227        assert_eq!(decide(other, OPENER, true, false, 0), Err(Refusal::AnotherSite));
228        assert_eq!(decide(other, OPENER, true, true, 0), Ok(()));
229    }
230
231    #[test]
232    fn no_gesture_no_pane_even_for_an_owned_opener() {
233        assert_eq!(
234            decide("https://signin.example.com/x", OPENER, false, true, 0),
235            Err(Refusal::NoUserAction)
236        );
237    }
238
239    #[test]
240    fn the_cap_holds_for_owned_openers_too() {
241        let url = "https://signin.example.com/x";
242        assert_eq!(decide(url, OPENER, true, true, MAX_POPUPS_PER_PANE - 1), Ok(()));
243        assert_eq!(decide(url, OPENER, true, true, MAX_POPUPS_PER_PANE), Err(Refusal::TooMany));
244    }
245
246    #[test]
247    fn only_web_pages() {
248        assert_eq!(decide("file:///c:/secrets", OPENER, true, true, 0), Err(Refusal::NotWeb));
249        assert_eq!(decide("javascript:alert(1)", OPENER, true, true, 0), Err(Refusal::NotWeb));
250    }
251
252    #[test]
253    fn origin_of_keeps_scheme_host_and_port_only() {
254        assert_eq!(origin_of("https://console.example.com/home?x=1"), "https://console.example.com");
255        assert_eq!(origin_of("http://localhost:3000/a"), "http://localhost:3000");
256        assert_eq!(origin_of("file:///c:/x"), "");
257    }
258
259    #[test]
260    fn loopback_is_localhost_and_the_loopback_addresses() {
261        assert!(is_loopback("http://localhost:3000/"));
262        assert!(is_loopback("http://127.0.0.1/"));
263        assert!(is_loopback("http://127.0.0.2:8766/"));
264        assert!(is_loopback("http://[::1]:8080/"));
265        assert!(!is_loopback("https://example.com/"));
266        assert!(!is_loopback("http://192.0.2.5/"));
267    }
268
269    #[test]
270    fn loopback_popups_stay_on_their_own_origin_and_off_agentmuxs() {
271        let own = vec!["http://localhost:5300".to_string(), "http://127.0.0.1:29705/".to_string()];
272        // A local dev site opening its own popup.
273        assert!(loopback_allowed("http://localhost:3000/popup", "http://localhost:3000/", &own));
274        // Another port is another origin.
275        assert!(!loopback_allowed("http://localhost:3001/popup", "http://localhost:3000/", &own));
276        // A remote page can't open one on this machine.
277        assert!(!loopback_allowed("http://localhost:3000/", "https://example.com/", &own));
278        // Never AgentMux's own frontend or srv, even from a page on them.
279        assert!(!loopback_allowed("http://localhost:5300/x", "http://localhost:5300/", &own));
280        assert!(!loopback_allowed("http://127.0.0.1:29705/x", "http://127.0.0.1:29705/", &own));
281        // Not a loopback target at all.
282        assert!(!loopback_allowed("https://example.com/", "https://example.com/", &own));
283    }
284
285    #[test]
286    fn a_popup_title_starts_with_where_the_page_is() {
287        let t = |url, title| popup_window_title(url, title);
288        assert_eq!(t("https://accounts.example.com/signin?x=1", "Sign in").as_deref(), Some("accounts.example.com — Sign in"));
289        // The page's title can't push the real host out of first place.
290        assert_eq!(
291            t("https://evil.example/login", "accounts.google.com — Sign in").as_deref(),
292            Some("evil.example — accounts.google.com — Sign in")
293        );
294        // No title: just the place.
295        assert_eq!(t("https://example.com/", "  ").as_deref(), Some("example.com"));
296        // Insecure and non-default ports show.
297        assert_eq!(t("http://example.com/", "A").as_deref(), Some("http://example.com — A"));
298        assert_eq!(t("https://example.com:8443/", "A").as_deref(), Some("example.com:8443 — A"));
299        assert_eq!(t("http://127.0.0.2:8000/p", "A").as_deref(), Some("http://127.0.0.2:8000 — A"));
300        assert_eq!(t("http://[::1]:3000/", "A").as_deref(), Some("http://[::1]:3000 — A"));
301        // A look-alike name shows in its ASCII form.
302        assert_eq!(t("https://аpple.com/", "A").as_deref(), Some("xn--pple-43d.com — A"));
303        // A blob shows the origin it belongs to.
304        assert_eq!(t("blob:https://example.com/0f0e", "A").as_deref(), Some("example.com — A"));
305        // Pages with no host still say what they are.
306        assert_eq!(t("about:blank", "Sign in").as_deref(), Some("about:blank — Sign in"));
307        assert_eq!(t("data:text/html,hi", "A").as_deref(), Some("data: — A"));
308        // Nothing to show: leave the title alone.
309        assert_eq!(t("devtools://devtools/bundled/inspector.html", "DevTools"), None);
310        assert_eq!(t("not a url", "A"), None);
311    }
312}