Share one internal Browser runtime
Route every chat through one persistent Chromium, Xvfb, and Xpra runtime while keeping tab ownership and per-chat limits context-bound. Share sign-ins across chats, adopt the first requesting legacy profile on upgrade, and close only the owning chat's tabs during reset or removal. Keep separate and shared tab-strip modes as visibility choices and cover cross-context access, popups, screenshots, startup ownership, and cleanup.
Alessandro committed
Aug 16, 2026 at 15:51 UTC
227b47f53d4032c783e3176bae472937753ce4a7
6 files changed
+415
-69
plugins/_browser/AGENTS.md
+5
-4
@@ -25,14 +25,15 @@
25
- Keep exactly one interactive viewer iframe connected during canvas/modal handoff so hidden surfaces cannot compete to resize the same display.
26
- Notify the active Xpra client of its new frame geometry before resizing the backing display; after an interactive canvas/modal handoff, reconcile once after Xpra's deferred resize so Chromium cannot retain the previous surface size.
27
- Present the Xpra shadow window as the raw browser canvas: remove its HTML decoration and shadow pointer while preserving exact viewport geometry.
28
-- Give every internal Browser runtime its own Xvfb display and unguessable Xpra gateway token; never expose another chat context's display through a shared viewer.
28
+- Keep one internal Chromium, Xvfb, and Xpra runtime per Agent Zero process with one unguessable gateway token.
29
- Bind Browser Xpra endpoints to loopback, route them through the authenticated virtual-desktop gateway, and keep file transfer, URL opening, printing, and audio disabled.
30
- Paint live screencast frames through the Browser panel canvas/ImageBitmap path when available; keep the `<img>`/data URL path for snapshots and fallback rendering.
31
- Push internal screencast frames from the runtime to the WebSocket consumer after subscription; keep `read/pop_screencast_frame` as fallback/tooling APIs, not the WebUI hot path.
32
- Keep Browser viewer frame transport capability-negotiated: updated clients may request binary/slim screencast frames, while older clients must keep the base64/full-metadata fallback. Do not let the WebUI advertise binary frames unless its Socket.IO client reconstructs attachments as real `Blob`, `ArrayBuffer`, or typed-array values.
33
-- Keep WebUI Browser tabs scoped to the active chat context by default; aggregate tabs from other AgentContext runtimes only when the Browser settings tab scope is `shared`.
34
-- Keep Chromium processes and persistent sign-in profiles isolated per chat even when the tab strip is shared; reset/removal may delete only that chat's profile.
35
-- Show an accessible in-panel startup state while an on-demand Browser runtime is cold-starting; do not create an idle Chromium/Xpra pair for every chat at Agent Zero startup.
33
+- Keep WebUI Browser tabs scoped to the active chat context by default; aggregate tabs from other context handles only when the Browser settings tab scope is `shared`.
34
+- Share one persistent internal-Browser sign-in profile across chats while enforcing tab ownership through context-bound runtime handles; resetting or removing a chat closes only its tabs and never deletes the shared profile.
35
+- On first shared-profile use after an upgrade, adopt the first requesting chat's legacy Browser profile when one exists.
36
+- Show an accessible in-panel startup state while the on-demand shared Browser runtime is cold-starting; keep that one runtime warm until Browser configuration changes or Agent Zero shuts down.
37
- Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar.
38
- For Bring Your Own Browser with an existing host profile, `host_browser_selection` may target automatic CLI selection, a browser family/id, an HTTP CDP discovery address, or a full DevTools WebSocket endpoint and must be forwarded to the connector runtime as `browser_selection`.
39
- Browser Settings must refresh connected A0 CLI host-browser inventory while the settings view is open so newly authorized endpoints appear without saving or reopening.
plugins/_browser/default_config.yaml
+2
-1
@@ -8,7 +8,8 @@ default_homepage: "about:blank"
8
# When the Browser surface is already open, keep it synced to agent Browser tool results.
9
autofocus_active_page: true
10
11
-# Browser tab visibility in the WebUI:
11
+# Browser tab visibility in the WebUI. Both modes use the same internal
12
+# Chromium runtime and sign-in profile:
13
# - per_context: each chat shows only its own Browser tabs.
14
# - shared: show Browser tabs from all active chats.
15
browser_tab_scope: "per_context"
plugins/_browser/helpers/runtime.py
+207
-54
@@ -4,6 +4,7 @@ import atexit
4
import asyncio
5
import base64
6
import contextlib
7
+import contextvars
8
import os
9
import re
10
import shutil
@@ -35,6 +36,7 @@ PLUGIN_DIR = Path(__file__).resolve().parents[1]
36
DOM_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-dom-helper.js"
37
CONTENT_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-page-content.js"
38
RUNTIME_DATA_KEY = "_browser_runtime"
39
+SHARED_RUNTIME_ID = "shared"
40
DEFAULT_VIEWPORT = {"width": 1024, "height": 768}
41
CHROME_SINGLETON_FILES = ("SingletonLock", "SingletonCookie", "SingletonSocket")
42
SCREENCAST_MAX_WIDTH = 4096
@@ -289,6 +291,7 @@ def _safe_context_id(context_id: str) -> str:
291
class BrowserPage:
292
id: int
293
page: Any
294
+ context_id: str = ""
295
296
297
class _BrowserScreencast:
@@ -540,12 +543,25 @@ class BrowserRuntime:
543
self._closed = False
544
545
async def call(self, method: str, *args: Any, **kwargs: Any) -> Any:
546
+ return await self.call_for(self.context_id, method, *args, **kwargs)
547
+
548
+ async def call_for(
549
+ self,
550
+ context_id: str,
551
+ method: str,
552
+ *args: Any,
553
+ **kwargs: Any,
554
+ ) -> Any:
555
if self._closed and method != "close":
556
raise RuntimeError("Browser runtime is closed.")
557
558
async def runner():
547
- fn = getattr(self._core, method)
548
- return await fn(*args, **kwargs)
559
+ token = self._core.request_context_id.set(str(context_id or self.context_id))
560
+ try:
561
+ fn = getattr(self._core, method)
562
+ return await fn(*args, **kwargs)
563
+ finally:
564
+ self._core.request_context_id.reset(token)
565
566
return await self._worker.execute_inside(runner)
567
@@ -562,6 +578,15 @@ class BrowserRuntime:
578
self._core.interactive_view.close()
579
580
581
+class BrowserRuntimeSession:
582
+ def __init__(self, context_id: str, runtime: BrowserRuntime):
583
+ self.context_id = str(context_id)
584
+ self._runtime = runtime
585
+
586
+ async def call(self, method: str, *args: Any, **kwargs: Any) -> Any:
587
+ return await self._runtime.call_for(self.context_id, method, *args, **kwargs)
588
+
589
+
590
class _BrowserRuntimeCore:
591
_VALID_MODIFIERS = {"Control", "Shift", "Alt", "Meta"}
592
_KEY_ALIASES = {
@@ -581,18 +606,23 @@ class _BrowserRuntimeCore:
606
def __init__(self, context_id: str):
607
self.context_id = context_id
608
self.safe_context_id = _safe_context_id(context_id)
609
+ self.request_context_id: contextvars.ContextVar[str] = contextvars.ContextVar(
610
+ f"browser_context_{id(self)}",
611
+ default=context_id,
612
+ )
613
self.playwright = None
614
self.context = None
615
self.pages: dict[int, BrowserPage] = {}
616
self.screencasts: dict[str, _BrowserScreencast] = {}
617
self.next_browser_id = 1
589
- self.last_interacted_browser_id: int | None = None
618
+ self._last_interacted_browser_ids: dict[str, int] = {}
619
self._dom_helper_source: str | None = None
620
self._content_helper_source: str | None = None
621
self._start_lock: asyncio.Lock | None = None
622
self._registry_lock: asyncio.Lock | None = None
623
self._closing = False
624
self._pending_popups: list[asyncio.Future[int]] = []
625
+ self._pending_popup_contexts: dict[asyncio.Future[int], str] = {}
626
self._background_popup_pages: set[int] = set()
627
self._bootstrap_page: Any | None = None
628
self._browser_chrome_height: int | None = None
@@ -601,6 +631,36 @@ class _BrowserRuntimeCore:
631
self._browser_window_id: int | None = None
632
self.interactive_view = BrowserInteractiveView(context_id)
633
634
+ @property
635
+ def current_context_id(self) -> str:
636
+ return str(self.request_context_id.get() or self.context_id)
637
+
638
+ @property
639
+ def last_interacted_browser_id(self) -> int | None:
640
+ return self._last_interacted_browser_ids.get(self.current_context_id)
641
+
642
+ @last_interacted_browser_id.setter
643
+ def last_interacted_browser_id(self, browser_id: int | None) -> None:
644
+ self._set_last_interacted(self.current_context_id, browser_id)
645
+
646
+ def _set_last_interacted(self, context_id: str, browser_id: int | None) -> None:
647
+ context_id = str(context_id or self.context_id)
648
+ if browser_id is None:
649
+ self._last_interacted_browser_ids.pop(context_id, None)
650
+ else:
651
+ self._last_interacted_browser_ids[context_id] = int(browser_id)
652
+
653
+ def _page_context_id(self, browser_page: BrowserPage) -> str:
654
+ return str(browser_page.context_id or self.context_id)
655
+
656
+ def _context_browser_ids(self, context_id: str | None = None) -> list[int]:
657
+ target = str(context_id or self.current_context_id)
658
+ return sorted(
659
+ browser_id
660
+ for browser_id, browser_page in self.pages.items()
661
+ if self._page_context_id(browser_page) == target
662
+ )
663
+
664
def _ensure_registry_lock(self) -> asyncio.Lock:
665
if self._registry_lock is None:
666
self._registry_lock = asyncio.Lock()
@@ -619,11 +679,12 @@ class _BrowserRuntimeCore:
679
previous_focus: int | None,
680
fallback_id: int,
681
) -> int | None:
622
- if previous_focus in self.pages:
682
+ browser_ids = self._context_browser_ids()
683
+ if previous_focus in browser_ids:
684
return int(previous_focus)
624
- if fallback_id in self.pages:
685
+ if fallback_id in browser_ids:
686
return int(fallback_id)
626
- return next(iter(sorted(self.pages)), None)
687
+ return next(iter(browser_ids), None)
688
689
def _normalize_modifiers(self, modifiers: list[str] | str | None) -> list[str] | None:
690
if modifiers is None:
@@ -781,6 +842,7 @@ class _BrowserRuntimeCore:
842
if not waiter.done():
843
waiter.set_exception(RuntimeError("Browser context closed."))
844
self._pending_popups.clear()
845
+ self._pending_popup_contexts.clear()
846
self._background_popup_pages.clear()
847
self._bootstrap_page = None
848
self._browser_chrome_height = None
@@ -788,7 +850,7 @@ class _BrowserRuntimeCore:
850
self._browser_window_session = None
851
self._browser_window_id = None
852
self.pages.clear()
791
- self.last_interacted_browser_id = None
853
+ self._last_interacted_browser_ids.clear()
854
for screencast in self.screencasts.values():
855
screencast.stopped = True
856
screencast._drop_queued_frames()
@@ -819,6 +881,8 @@ class _BrowserRuntimeCore:
881
raise RuntimeError(f"Browser setup failed: {problem}")
882
from patchright.async_api import async_playwright
883
884
+ self.profile_dir.parent.mkdir(parents=True, exist_ok=True)
885
+ self._adopt_legacy_profile(self.current_context_id)
886
self.profile_dir.mkdir(parents=True, exist_ok=True)
887
self.downloads_dir.mkdir(parents=True, exist_ok=True)
888
self._release_orphaned_profile_singleton()
@@ -888,6 +952,22 @@ class _BrowserRuntimeCore:
952
continue
953
await self._register_page(page)
954
955
+ def _adopt_legacy_profile(self, context_id: str) -> None:
956
+ if self.safe_context_id != SHARED_RUNTIME_ID or self.profile_dir.exists():
957
+ return
958
+ legacy_profile = Path(
959
+ files.get_abs_path("tmp/browser/sessions", _safe_context_id(context_id))
960
+ )
961
+ if legacy_profile == self.profile_dir or not legacy_profile.is_dir():
962
+ return
963
+ try:
964
+ legacy_profile.rename(self.profile_dir)
965
+ PrintStyle.info(
966
+ f"Browser adopted the existing profile for context {context_id}."
967
+ )
968
+ except OSError as exc:
969
+ PrintStyle.warning(f"Browser profile migration failed: {exc}")
970
+
971
def _release_orphaned_profile_singleton(self) -> None:
972
lock_path = self.profile_dir / "SingletonLock"
973
owner_pid = self._profile_singleton_owner_pid(lock_path)
@@ -951,11 +1031,12 @@ class _BrowserRuntimeCore:
1031
async def open(self, url: str = "") -> dict[str, Any]:
1032
await self.ensure_started()
1033
self._ensure_can_open_page()
1034
+ context_id = self.current_context_id
1035
page = self._bootstrap_page
1036
self._bootstrap_page = None
1037
if not page or page.is_closed():
1038
page = await self.context.new_page()
958
- browser_page = await self._register_page(page)
1039
+ browser_page = await self._register_page(page, context_id)
1040
self.last_interacted_browser_id = browser_page.id
1041
target_url = self._initial_url(url)
1042
if target_url and target_url != "about:blank":
@@ -977,20 +1058,21 @@ class _BrowserRuntimeCore:
1058
value = DEFAULT_MAX_OPEN_TABS
1059
return max(1, value)
1060
980
- def _tab_limit_error(self) -> RepairableException:
1061
+ def _tab_limit_error(self, context_id: str | None = None) -> RepairableException:
1062
max_open_tabs = self._max_open_tabs()
1063
+ open_tabs = len(self._context_browser_ids(context_id))
1064
return RepairableException(
983
- f"Browser tab limit reached ({len(self.pages)}/{max_open_tabs}). "
1065
+ f"Browser tab limit reached ({open_tabs}/{max_open_tabs}). "
1066
"Navigate an existing browser_id or close tabs with close/close_all before opening more."
1067
)
1068
1069
def _ensure_can_open_page(self) -> None:
988
- if len(self.pages) >= self._max_open_tabs():
1070
+ if len(self._context_browser_ids()) >= self._max_open_tabs():
1071
raise self._tab_limit_error()
1072
1073
async def list(self, include_content: bool = False) -> dict[str, Any]:
1074
await self.ensure_started()
993
- ids = sorted(self.pages)
1075
+ ids = self._context_browser_ids()
1076
if not ids:
1077
return {
1078
"browsers": [],
@@ -1420,6 +1502,7 @@ class _BrowserRuntimeCore:
1502
loop = asyncio.get_running_loop()
1503
waiter: asyncio.Future[int] = loop.create_future()
1504
self._pending_popups.append(waiter)
1505
+ self._pending_popup_contexts[waiter] = self.current_context_id
1506
1507
warning: str | None = None
1508
opened_id: int | None = None
@@ -1463,6 +1546,7 @@ class _BrowserRuntimeCore:
1546
finally:
1547
if waiter in self._pending_popups:
1548
self._pending_popups.remove(waiter)
1549
+ self._pending_popup_contexts.pop(waiter, None)
1550
if not waiter.done():
1551
waiter.cancel()
1552
@@ -1477,6 +1561,7 @@ class _BrowserRuntimeCore:
1561
finally:
1562
if waiter in self._pending_popups:
1563
self._pending_popups.remove(waiter)
1564
+ self._pending_popup_contexts.pop(waiter, None)
1565
1566
if background:
1567
# Background-mode click: preserve the pre-click focus even when
@@ -1604,20 +1689,23 @@ class _BrowserRuntimeCore:
1689
await page.close()
1690
self.pages.pop(resolved_id, None)
1691
if self.last_interacted_browser_id == resolved_id:
1607
- self.last_interacted_browser_id = next(iter(sorted(self.pages)), None)
1692
+ self.last_interacted_browser_id = next(iter(self._context_browser_ids()), None)
1693
return await self.list()
1694
1695
async def close_all_browsers(self) -> dict[str, Any]:
1696
await self.ensure_started()
1612
- await self._stop_all_screencasts()
1613
- for browser_id in list(self.pages):
1697
+ await self.close_context()
1698
+ return {"browsers": [], "last_interacted_browser_id": None}
1699
+
1700
+ async def close_context(self) -> None:
1701
+ for browser_id in self._context_browser_ids():
1702
+ await self._stop_screencasts_for_browser(browser_id)
1703
try:
1704
await self.pages[browser_id].page.close()
1705
except Exception:
1706
pass
1618
- self.pages.clear()
1707
+ self.pages.pop(browser_id, None)
1708
self.last_interacted_browser_id = None
1620
- return {"browsers": [], "last_interacted_browser_id": None}
1709
1710
async def screenshot(
1711
self,
@@ -1647,6 +1735,7 @@ class _BrowserRuntimeCore:
1735
await self.ensure_started()
1736
resolved_id = self._resolve_browser_id(browser_id)
1737
page = self._page(resolved_id)
1738
+ page_context_id = self._page_context_id(self.pages[resolved_id])
1739
raw_path = str(path or "").strip()
1740
if not raw_path:
1741
image = await page.screenshot(
@@ -1655,7 +1744,7 @@ class _BrowserRuntimeCore:
1744
full_page=bool(full_page),
1745
)
1746
saved = chat_media.save_image_bytes(
1658
- context_id=self.context_id,
1747
+ context_id=page_context_id,
1748
payload=image,
1749
mime_type="image/jpeg",
1750
category="screenshots",
@@ -1664,7 +1753,7 @@ class _BrowserRuntimeCore:
1753
)
1754
return {
1755
"browser_id": resolved_id,
1667
- "context_id": self.context_id,
1756
+ "context_id": page_context_id,
1757
"path": saved.path,
1758
"a0_path": saved.a0_path,
1759
"mime": "image/jpeg",
@@ -1693,7 +1782,7 @@ class _BrowserRuntimeCore:
1782
local_path = str(output_path)
1783
return {
1784
"browser_id": resolved_id,
1696
- "context_id": self.context_id,
1785
+ "context_id": page_context_id,
1786
"path": local_path,
1787
"a0_path": files.normalize_a0_path(local_path),
1788
"mime": mime,
@@ -2277,6 +2366,7 @@ class _BrowserRuntimeCore:
2366
if not waiter.done():
2367
waiter.set_exception(RuntimeError("Browser runtime is closing."))
2368
self._pending_popups.clear()
2369
+ self._pending_popup_contexts.clear()
2370
self._background_popup_pages.clear()
2371
await self._stop_all_screencasts()
2372
await self._reset_browser_window_session()
@@ -2298,7 +2388,7 @@ class _BrowserRuntimeCore:
2388
except Exception as exc:
2389
PrintStyle.warning(f"Playwright stop failed: {exc}")
2390
self.playwright = None
2301
- self.last_interacted_browser_id = None
2391
+ self._last_interacted_browser_ids.clear()
2392
if delete_profile:
2393
shutil.rmtree(self.profile_dir, ignore_errors=True)
2394
@@ -2383,7 +2473,7 @@ class _BrowserRuntimeCore:
2473
history_length = 0
2474
return {
2475
"id": browser_page.id,
2386
- "context_id": self.context_id,
2476
+ "context_id": self._page_context_id(browser_page),
2477
"currentUrl": page.url,
2478
"title": title,
2479
"canGoBack": bool(history_length and int(history_length) > 1),
@@ -2391,13 +2481,31 @@ class _BrowserRuntimeCore:
2481
"loading": False,
2482
}
2483
2394
- def _register_page_locked(self, page: Any) -> BrowserPage:
2484
+ def _register_page_locked(
2485
+ self,
2486
+ page: Any,
2487
+ context_id: str | None = None,
2488
+ ) -> BrowserPage:
2489
+ requested_context_id = str(context_id or self.current_context_id)
2490
existing = self._browser_id_for_page(page)
2491
if existing is not None:
2397
- return self.pages[existing]
2492
+ browser_page = self.pages[existing]
2493
+ if (
2494
+ context_id is not None
2495
+ and self._page_context_id(browser_page) != requested_context_id
2496
+ ):
2497
+ previous_context_id = self._page_context_id(browser_page)
2498
+ browser_page.context_id = requested_context_id
2499
+ if self._last_interacted_browser_ids.get(previous_context_id) == existing:
2500
+ self._set_last_interacted(previous_context_id, None)
2501
+ return browser_page
2502
browser_id = self.next_browser_id
2503
self.next_browser_id += 1
2400
- browser_page = BrowserPage(id=browser_id, page=page)
2504
+ browser_page = BrowserPage(
2505
+ id=browser_id,
2506
+ page=page,
2507
+ context_id=requested_context_id,
2508
+ )
2509
self.pages[browser_id] = browser_page
2510
2511
def on_close() -> None:
@@ -2410,10 +2518,14 @@ class _BrowserRuntimeCore:
2518
page.on("close", on_close)
2519
return browser_page
2520
2413
- async def _register_page(self, page: Any) -> BrowserPage:
2521
+ async def _register_page(
2522
+ self,
2523
+ page: Any,
2524
+ context_id: str | None = None,
2525
+ ) -> BrowserPage:
2526
lock = self._ensure_registry_lock()
2527
async with lock:
2416
- browser_page = self._register_page_locked(page)
2528
+ browser_page = self._register_page_locked(page, context_id)
2529
await self._fit_browser_window(page)
2530
return browser_page
2531
@@ -2479,9 +2591,15 @@ class _BrowserRuntimeCore:
2591
try:
2592
lock = self._ensure_registry_lock()
2593
async with lock:
2482
- self.pages.pop(browser_id, None)
2483
- if self.last_interacted_browser_id == browser_id:
2484
- self.last_interacted_browser_id = next(iter(sorted(self.pages)), None)
2594
+ browser_page = self.pages.pop(browser_id, None)
2595
+ if browser_page:
2596
+ context_id = self._page_context_id(browser_page)
2597
+ if self._last_interacted_browser_ids.get(context_id) == browser_id:
2598
+ remaining = self._context_browser_ids(context_id)
2599
+ self._set_last_interacted(
2600
+ context_id,
2601
+ next(iter(remaining), None),
2602
+ )
2603
self._background_popup_pages.discard(browser_id)
2604
except Exception as exc:
2605
PrintStyle.warning(f"Page unregister failed: {exc}")
@@ -2502,29 +2620,26 @@ class _BrowserRuntimeCore:
2620
return
2621
lock = self._ensure_registry_lock()
2622
close_over_limit = False
2623
+ context_id = await self._new_page_context_id(page)
2624
async with lock:
2625
if self._closing:
2626
return
2627
if self._browser_id_for_page(page) is not None:
2628
return
2510
- if len(self.pages) >= self._max_open_tabs():
2511
- limit_error = self._tab_limit_error()
2512
- while self._pending_popups:
2513
- waiter = self._pending_popups.pop(0)
2514
- if not waiter.done():
2515
- waiter.set_exception(limit_error)
2516
- break
2629
+ if len(self._context_browser_ids(context_id)) >= self._max_open_tabs():
2630
+ limit_error = self._tab_limit_error(context_id)
2631
+ waiter = self._pop_pending_popup(context_id)
2632
+ if waiter:
2633
+ waiter.set_exception(limit_error)
2634
close_over_limit = True
2635
else:
2519
- browser_page = self._register_page_locked(page)
2636
+ browser_page = self._register_page_locked(page, context_id)
2637
new_id = browser_page.id
2521
- while self._pending_popups:
2522
- waiter = self._pending_popups.pop(0)
2523
- if not waiter.done():
2524
- waiter.set_result(new_id)
2525
- break
2638
+ waiter = self._pop_pending_popup(context_id)
2639
+ if waiter:
2640
+ waiter.set_result(new_id)
2641
if new_id not in self._background_popup_pages:
2527
- self.last_interacted_browser_id = new_id
2642
+ self._set_last_interacted(context_id, new_id)
2643
else:
2644
self._background_popup_pages.discard(new_id)
2645
if close_over_limit:
@@ -2535,6 +2650,33 @@ class _BrowserRuntimeCore:
2650
except Exception as exc:
2651
PrintStyle.warning(f"Popup registration failed: {exc}")
2652
2653
+ async def _new_page_context_id(self, page: Any) -> str:
2654
+ opener_fn = getattr(page, "opener", None)
2655
+ if callable(opener_fn):
2656
+ with contextlib.suppress(Exception):
2657
+ opener = await opener_fn()
2658
+ opener_id = self._browser_id_for_page(opener)
2659
+ if opener_id is not None:
2660
+ return self._page_context_id(self.pages[opener_id])
2661
+ for waiter in self._pending_popups:
2662
+ context_id = self._pending_popup_contexts.get(waiter)
2663
+ if context_id and not waiter.done():
2664
+ return context_id
2665
+ return self.current_context_id
2666
+
2667
+ def _pop_pending_popup(self, context_id: str) -> asyncio.Future[int] | None:
2668
+ for waiter in list(self._pending_popups):
2669
+ if waiter.done():
2670
+ self._pending_popups.remove(waiter)
2671
+ self._pending_popup_contexts.pop(waiter, None)
2672
+ continue
2673
+ if self._pending_popup_contexts.get(waiter) != context_id:
2674
+ continue
2675
+ self._pending_popups.remove(waiter)
2676
+ self._pending_popup_contexts.pop(waiter, None)
2677
+ return waiter
2678
+ return None
2679
+
2680
def _browser_id_for_page(self, page: Any) -> int | None:
2681
for browser_id, browser_page in self.pages.items():
2682
if browser_page.page == page:
@@ -2542,17 +2684,18 @@ class _BrowserRuntimeCore:
2684
return None
2685
2686
def _resolve_browser_id(self, browser_id: int | str | None = None) -> int:
2687
+ browser_ids = self._context_browser_ids()
2688
if browser_id is None or str(browser_id).strip() == "":
2546
- if self.last_interacted_browser_id in self.pages:
2689
+ if self.last_interacted_browser_id in browser_ids:
2690
return int(self.last_interacted_browser_id)
2548
- if self.pages:
2549
- return sorted(self.pages)[0]
2691
+ if browser_ids:
2692
+ return browser_ids[0]
2693
raise KeyError("No browser is open. Use action=open first.")
2694
value = str(browser_id).strip()
2695
if value.startswith("browser-"):
2696
value = value.split("-", 1)[1]
2697
resolved = int(value)
2555
- if resolved not in self.pages:
2698
+ if resolved not in browser_ids:
2699
raise KeyError(f"Browser {resolved} is not open.")
2700
return resolved
2701
@@ -2608,18 +2751,26 @@ class _BrowserRuntimeCore:
2751
with contextlib.suppress(Exception):
2752
await target.evaluate(source, isolated_context=True)
2753
2611
-_runtimes: dict[str, BrowserRuntime] = {}
2754
+_runtimes: dict[str, BrowserRuntimeSession] = {}
2755
+_shared_runtime: BrowserRuntime | None = None
2756
_runtime_lock = threading.RLock()
2757
2758
2615
-async def get_runtime(context_id: str, *, create: bool = True) -> BrowserRuntime | None:
2759
+async def get_runtime(
2760
+ context_id: str,
2761
+ *,
2762
+ create: bool = True,
2763
+) -> BrowserRuntimeSession | None:
2764
+ global _shared_runtime
2765
context_id = str(context_id or "").strip()
2766
if not context_id:
2767
raise ValueError("context_id is required")
2768
with _runtime_lock:
2769
runtime = _runtimes.get(context_id)
2770
if runtime is None and create:
2622
- runtime = BrowserRuntime(context_id)
2771
+ if _shared_runtime is None:
2772
+ _shared_runtime = BrowserRuntime(SHARED_RUNTIME_ID)
2773
+ runtime = BrowserRuntimeSession(context_id, _shared_runtime)
2774
_runtimes[context_id] = runtime
2775
return runtime
2776
@@ -2631,7 +2782,7 @@ async def close_runtime(context_id: str, *, delete_profile: bool = True) -> None
2782
with _runtime_lock:
2783
runtime = _runtimes.pop(context_id, None)
2784
if runtime:
2634
- await runtime.close(delete_profile=delete_profile)
2785
+ await runtime.call("close_context")
2786
2787
2788
def close_runtime_sync(context_id: str, *, delete_profile: bool = True) -> None:
@@ -2644,10 +2795,12 @@ def close_runtime_sync(context_id: str, *, delete_profile: bool = True) -> None:
2795
2796
2797
async def close_all_runtimes(*, delete_profiles: bool = False) -> None:
2798
+ global _shared_runtime
2799
with _runtime_lock:
2648
- runtimes = list(_runtimes.values())
2800
_runtimes.clear()
2650
- for runtime in runtimes:
2801
+ runtime = _shared_runtime
2802
+ _shared_runtime = None
2803
+ if runtime:
2804
try:
2805
await runtime.close(delete_profile=delete_profiles)
2806
except Exception as exc:
plugins/_browser/webui/browser-store.js
+5
@@ -442,6 +442,11 @@ const model = {
442
},
443
444
async contextIdForNewBrowser() {
445
+ const selectedContextId = this.normalizeContextId(chatsStore.selected);
446
+ if (selectedContextId) {
447
+ this.contextId = selectedContextId;
448
+ return selectedContextId;
449
+ }
450
return await this.ensureContextId();
451
},
452
plugins/_browser/webui/config.html
+2
-2
@@ -218,13 +218,13 @@
218
class="browser-config-field-help"
219
x-show="$store.browserConfig.config.browser_tab_scope !== 'shared'"
220
>
221
- Each chat has its own Browser tabs and sign-in profile. Resetting or removing the chat clears that profile.
221
+ Each chat shows only its own tabs. Browser sign-ins are shared across chats.
222
</span>
223
<span
224
class="browser-config-field-help"
225
x-show="$store.browserConfig.config.browser_tab_scope === 'shared'"
226
>
227
- The tab strip shows tabs from every active chat. Sign-ins and browser profiles remain isolated per chat.
227
+ The tab strip shows tabs from every active chat. Browser sign-ins are shared across chats.
228
</span>
229
</label>
230
tests/test_browser_agent_regressions.py
+194
-8
@@ -1432,8 +1432,8 @@ def test_browser_extension_settings_stay_user_facing():
1432
assert "Separate per chat" in config_html
1433
assert "Shared across chats" in config_html
1434
assert "Maximum tabs per chat" in config_html
1435
- assert "Each chat has its own Browser tabs and sign-in profile." in config_html
1436
- assert "Sign-ins and browser profiles remain isolated per chat." in config_html
1435
+ assert "Each chat shows only its own tabs. Browser sign-ins are shared across chats." in config_html
1436
+ assert "The tab strip shows tabs from every active chat. Browser sign-ins are shared across chats." in config_html
1437
assert 'x-model="$store.browserConfig.config.browser_tab_scope"' in config_html
1438
assert 'x-model.number="$store.browserConfig.config.max_open_tabs"' in config_html
1439
assert 'x-model="$store.browserConfig.config.proxy_server"' in config_html
@@ -1495,6 +1495,8 @@ def test_browser_viewer_uses_tabs_for_session_switching():
1495
assert "applyBrowserListing" in browser_store
1496
assert "syncViewerToSelectedContext(selectedContextId)" in browser_store
1497
assert "async syncViewerToSelectedContext" in browser_store
1498
+ assert "const selectedContextId = this.normalizeContextId(chatsStore.selected);" in browser_store
1499
+ assert "this.contextId = selectedContextId;" in browser_store
1500
assert "isVisibleBrowserSurface()" in browser_store
1501
assert "firstBrowserInContext(selectedContextId)" in browser_store
1502
assert "visibleBrowsers()" in browser_store
@@ -2651,9 +2653,10 @@ async def test_browser_first_open_reuses_headful_bootstrap_page(monkeypatch):
2653
core.context = Context()
2654
core._bootstrap_page = page
2655
2654
- async def register(registered_page):
2656
+ async def register(registered_page, context_id=None):
2657
assert registered_page is page
2656
- browser_page = BrowserPage(id=1, page=registered_page)
2658
+ assert context_id == "headful"
2659
+ browser_page = BrowserPage(id=1, page=registered_page, context_id=context_id or "")
2660
core.pages[1] = browser_page
2661
return browser_page
2662
@@ -3448,6 +3451,177 @@ async def test_browser_runtime_sessions_are_context_qualified(monkeypatch):
3451
]
3452
3453
3454
+@pytest.mark.anyio
3455
+async def test_browser_context_handles_share_one_runtime(monkeypatch):
3456
+ class FakeSharedRuntime:
3457
+ instances = []
3458
+
3459
+ def __init__(self, context_id):
3460
+ self.context_id = context_id
3461
+ self.calls = []
3462
+ self.closed = []
3463
+ self.instances.append(self)
3464
+
3465
+ async def call_for(self, context_id, method, *args, **kwargs):
3466
+ self.calls.append((context_id, method, args, kwargs))
3467
+ return {"context_id": context_id, "method": method}
3468
+
3469
+ async def close(self, delete_profile=False):
3470
+ self.closed.append(delete_profile)
3471
+
3472
+ monkeypatch.setattr(browser_runtime_module, "BrowserRuntime", FakeSharedRuntime)
3473
+ with browser_runtime_module._runtime_lock:
3474
+ previous_runtimes = dict(browser_runtime_module._runtimes)
3475
+ previous_shared = browser_runtime_module._shared_runtime
3476
+ browser_runtime_module._runtimes.clear()
3477
+ browser_runtime_module._shared_runtime = None
3478
+ try:
3479
+ first = await browser_runtime_module.get_runtime("ctx-a")
3480
+ second = await browser_runtime_module.get_runtime("ctx-b")
3481
+
3482
+ assert first is not second
3483
+ assert first._runtime is second._runtime
3484
+ assert len(FakeSharedRuntime.instances) == 1
3485
+ assert FakeSharedRuntime.instances[0].context_id == browser_runtime_module.SHARED_RUNTIME_ID
3486
+ assert await first.call("list") == {"context_id": "ctx-a", "method": "list"}
3487
+ assert await second.call("open", "https://example.org/") == {
3488
+ "context_id": "ctx-b",
3489
+ "method": "open",
3490
+ }
3491
+
3492
+ await browser_runtime_module.close_runtime("ctx-a", delete_profile=True)
3493
+ assert browser_runtime_module._shared_runtime is FakeSharedRuntime.instances[0]
3494
+ assert FakeSharedRuntime.instances[0].calls[-1][:2] == ("ctx-a", "close_context")
3495
+
3496
+ await browser_runtime_module.close_all_runtimes()
3497
+ assert FakeSharedRuntime.instances[0].closed == [False]
3498
+ finally:
3499
+ with browser_runtime_module._runtime_lock:
3500
+ browser_runtime_module._runtimes.clear()
3501
+ browser_runtime_module._runtimes.update(previous_runtimes)
3502
+ browser_runtime_module._shared_runtime = previous_shared
3503
+
3504
+
3505
+@pytest.mark.anyio
3506
+async def test_shared_browser_runtime_keeps_tabs_context_scoped():
3507
+ class FakePage:
3508
+ def __init__(self, url):
3509
+ self.url = url
3510
+ self.closed = False
3511
+
3512
+ async def title(self):
3513
+ return self.url
3514
+
3515
+ async def evaluate(self, script, **kwargs):
3516
+ return 1
3517
+
3518
+ async def close(self):
3519
+ self.closed = True
3520
+
3521
+ core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
3522
+ page_a = FakePage("https://example.com/")
3523
+ page_b = FakePage("https://example.org/")
3524
+ core.context = object()
3525
+ core.pages = {
3526
+ 1: BrowserPage(1, page_a, "ctx-a"),
3527
+ 2: BrowserPage(2, page_b, "ctx-b"),
3528
+ }
3529
+ core._set_last_interacted("ctx-a", 1)
3530
+ core._set_last_interacted("ctx-b", 2)
3531
+
3532
+ token = core.request_context_id.set("ctx-a")
3533
+ try:
3534
+ listing = await core.list()
3535
+ assert [browser["id"] for browser in listing["browsers"]] == [1]
3536
+ assert listing["last_interacted_browser_id"] == 1
3537
+ with pytest.raises(KeyError, match="Browser 2 is not open"):
3538
+ await core.state(2)
3539
+ await core.close_context()
3540
+ finally:
3541
+ core.request_context_id.reset(token)
3542
+
3543
+ assert page_a.closed is True
3544
+ assert page_b.closed is False
3545
+ token = core.request_context_id.set("ctx-b")
3546
+ try:
3547
+ listing = await core.list()
3548
+ assert [browser["id"] for browser in listing["browsers"]] == [2]
3549
+ assert listing["browsers"][0]["context_id"] == "ctx-b"
3550
+ finally:
3551
+ core.request_context_id.reset(token)
3552
+
3553
+
3554
+@pytest.mark.anyio
3555
+async def test_shared_browser_runtime_matches_popup_to_its_opener_context():
3556
+ core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
3557
+ opener_a = object()
3558
+ opener_b = object()
3559
+ core.pages = {
3560
+ 1: BrowserPage(1, opener_a, "ctx-a"),
3561
+ 2: BrowserPage(2, opener_b, "ctx-b"),
3562
+ }
3563
+ loop = asyncio.get_running_loop()
3564
+ waiter_a = loop.create_future()
3565
+ waiter_b = loop.create_future()
3566
+ core._pending_popups = [waiter_a, waiter_b]
3567
+ core._pending_popup_contexts = {
3568
+ waiter_a: "ctx-a",
3569
+ waiter_b: "ctx-b",
3570
+ }
3571
+
3572
+ class Popup:
3573
+ async def opener(self):
3574
+ return opener_b
3575
+
3576
+ context_id = await core._new_page_context_id(Popup())
3577
+
3578
+ assert context_id == "ctx-b"
3579
+ assert core._pop_pending_popup(context_id) is waiter_b
3580
+ assert core._pending_popups == [waiter_a]
3581
+
3582
+
3583
+def test_explicit_open_claims_page_registered_by_playwright_event():
3584
+ class Page:
3585
+ @staticmethod
3586
+ def on(event, callback):
3587
+ assert event == "close"
3588
+
3589
+ core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
3590
+ page = Page()
3591
+ registered = core._register_page_locked(
3592
+ page,
3593
+ "ctx-a",
3594
+ )
3595
+ core._set_last_interacted("ctx-a", registered.id)
3596
+
3597
+ token = core.request_context_id.set("ctx-b")
3598
+ try:
3599
+ claimed = core._register_page_locked(page, "ctx-b")
3600
+ finally:
3601
+ core.request_context_id.reset(token)
3602
+
3603
+ assert claimed is registered
3604
+ assert claimed.context_id == "ctx-b"
3605
+ assert core._last_interacted_browser_ids.get("ctx-a") is None
3606
+
3607
+
3608
+def test_shared_browser_runtime_adopts_first_requesting_legacy_profile(monkeypatch, tmp_path):
3609
+ monkeypatch.setattr(
3610
+ browser_runtime_module.files,
3611
+ "get_abs_path",
3612
+ lambda *parts: str(tmp_path.joinpath(*parts)),
3613
+ )
3614
+ legacy_profile = tmp_path / "tmp" / "browser" / "sessions" / "ctx-a"
3615
+ (legacy_profile / "Default").mkdir(parents=True)
3616
+ (legacy_profile / "Default" / "Cookies").write_bytes(b"legacy-session")
3617
+
3618
+ core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
3619
+ core._adopt_legacy_profile("ctx-a")
3620
+
3621
+ assert not legacy_profile.exists()
3622
+ assert (core.profile_dir / "Default" / "Cookies").read_bytes() == b"legacy-session"
3623
+
3624
+
3625
@pytest.mark.anyio
3626
async def test_browser_runtime_refuses_new_tabs_when_context_limit_is_reached(monkeypatch):
3627
core = _BrowserRuntimeCore("ctx-limit")
@@ -3856,11 +4030,19 @@ async def test_browser_runtime_screenshot_file_defaults_to_chat_scoped_artifact(
4030
async def evaluate(self, script, payload=None, **kwargs):
4031
return 1
4032
3859
- core = _BrowserRuntimeCore("ctx/id")
4033
+ core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
4034
core.context = object()
3861
- core.pages[5] = browser_runtime_module.BrowserPage(id=5, page=FakePage())
4035
+ core.pages[5] = browser_runtime_module.BrowserPage(
4036
+ id=5,
4037
+ page=FakePage(),
4038
+ context_id="ctx/id",
4039
+ )
4040
+ token = core.request_context_id.set("ctx/id")
4041
3863
- result = await core.screenshot_file(5, quality=500)
4042
+ try:
4043
+ result = await core.screenshot_file(5, quality=500)
4044
+ finally:
4045
+ core.request_context_id.reset(token)
4046
4047
assert Path(result["path"]).read_bytes() == b"image-bytes"
4048
assert result["a0_path"].startswith("/a0/usr/chats/ctx_id/screenshots/browser/browser-5-")
@@ -3880,7 +4062,11 @@ async def test_browser_runtime_screenshot_file_defaults_to_chat_scoped_artifact(
4062
assert "path" not in screenshot_calls[-1]
4063
4064
png_path = tmp_path / "custom.png"
3883
- png_result = await core.screenshot_file(5, quality=1, full_page=True, path=str(png_path))
4065
+ token = core.request_context_id.set("ctx/id")
4066
+ try:
4067
+ png_result = await core.screenshot_file(5, quality=1, full_page=True, path=str(png_path))
4068
+ finally:
4069
+ core.request_context_id.reset(token)
4070
4071
assert png_result["path"] == str(png_path)
4072
assert png_result["mime"] == "image/png"