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"