Harden internal Browser against bot detection
Run Chromium headful through Patchright on a private Xvfb display while keeping Agent Zero's page helpers in Patchright's isolated world. Reconcile the pinned Patchright package and matching architecture-specific Chromium from Browser plugin hooks so fresh Docker images and self-updated installations converge on the same runtime.
Alessandro committed
Aug 13, 2026 at 00:38 UTC
e2f43a3fb8df5811f2234c26337436fe5719e618
13 files changed
+460
-84
docker/run/fs/ins/install_additional.sh
+1
@@ -99,6 +99,7 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
99
x11-xserver-utils \
100
xdotool \
101
xauth \
102
+ xvfb \
103
dbus-x11 \
104
fonts-dejavu \
105
fonts-liberation \
docker/run/fs/ins/install_playwright.sh
+2
-5
@@ -4,13 +4,10 @@ set -e
4
# activate venv
5
. "/ins/setup_venv.sh" "$@"
6
7
-# install playwright if not installed (should be from requirements.txt)
8
-uv pip install playwright
9
-
7
# set PW installation path to temporary Browser runtime storage
8
export PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright
9
mkdir -p "$PLAYWRIGHT_BROWSERS_PATH"
10
14
-# install chromium with dependencies
11
+# preinstall Chromium for fresh images; the Browser hook also reconciles self-updated installs
12
apt-get install -y fonts-unifont libnss3 libnspr4 libatk1.0-0 libatspi2.0-0 libxcomposite1 libxdamage1 libatk-bridge2.0-0 libcups2
16
-playwright install chromium
13
+patchright install chromium --no-shell
docs/guides/browser.md
+1
-1
@@ -234,7 +234,7 @@ See [MCP Setup](mcp-setup.md) for MCP setup.
234
235
## Troubleshooting
236
237
-- **Browser says Playwright is missing:** Docker installs already include the browser. In local development, let Agent Zero install it on first use or preinstall it with `PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium`.
237
+- **Browser says Chromium is missing:** Docker installs already include the browser. In local development, let Agent Zero install it on first use or preinstall it with `PLAYWRIGHT_BROWSERS_PATH=tmp/playwright patchright install chromium --no-shell`.
238
- **The Browser surface does not open automatically:** That is expected. Open the Browser surface manually or ask the agent to show it.
239
- **The Canvas does not follow the agent:** Enable **Autofocus active page** in Browser settings.
240
- **Bring Your Own Browser cannot start:** Keep A0 CLI connected, verify Browser location is **Bring Your Own Browser**, and check `/browser status` in A0 CLI.
docs/guides/troubleshooting.md
+2
-2
@@ -26,7 +26,7 @@ Refer to the [Choosing your LLMs](../setup/installation.md#installing-and-using-
26
**7. How can I make Agent Zero retain memory between sessions?**
27
Use **Settings -> Backup & Restore** and avoid mapping the entire `/a0` directory. See [How to update Agent Zero](../setup/installation.md#how-to-update-agent-zero).
28
29
-**8. My browser tool fails or says Playwright is missing. What now?**
29
+**8. My browser tool fails or says Chromium is missing. What now?**
30
31
In normal Docker installs, the Browser already includes what it needs.
32
@@ -35,7 +35,7 @@ browser the first time it is needed. To install it ahead of time, run this from
35
the project root after installing Python requirements:
36
37
```bash
38
-PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium
38
+PLAYWRIGHT_BROWSERS_PATH=tmp/playwright patchright install chromium --no-shell
39
```
40
41
If **Bring Your Own Browser** mode fails:
docs/setup/dev-setup.md
+2
-2
@@ -68,12 +68,12 @@ Now when you select one of the python files in the project, you should see prope
68
69
```bash
70
pip install -r requirements.txt
71
-PLAYWRIGHT_BROWSERS_PATH=./tmp/playwright playwright install chromium
71
+PLAYWRIGHT_BROWSERS_PATH=./tmp/playwright patchright install chromium --no-shell
72
```
73
74
The first command installs Python dependencies.
75
76
-The second command installs full Playwright Chromium into `./tmp/playwright`,
76
+The second command installs full Patchright Chromium into `./tmp/playwright`,
77
relative to the project root. Docker images use the absolute path
78
`/a0/tmp/playwright` and ship Chromium preinstalled.
79
plugins/_browser/AGENTS.md
+3
@@ -32,6 +32,9 @@
32
- Do not hardcode user-specific browser paths or secrets.
33
- Browser model-preset selection resolves omitted preset fields from `_model_config`'s global `Default` preset, not from an unrelated currently scoped model selection.
34
- Internal-browser proxy settings map directly to Playwright's persistent-context proxy option, never to Bring Your Own Browser, and changes must restart active internal runtimes.
35
+- Run internal Chromium headful through Patchright on the private virtual display; do not add user-agent or header spoofing on top of the patched driver.
36
+- Browser startup and on-demand launch must converge on the Chromium revision declared by Patchright; let its installer select the host architecture rather than hardcoding x64 or ARM downloads.
37
+- `hooks.prepare_playwright_cache()` owns reconciliation of the pinned Patchright package and Chromium binary so repository self-updates and fresh images use the same setup path.
38
39
## Work Guidance
40
plugins/_browser/extensions/python/startup_migration/_20_browser_playwright_cache.py
+1
-1
@@ -33,7 +33,7 @@ def _start_background_cache_migration() -> threading.Thread:
33
34
def _migrate_cache_safely() -> None:
35
try:
36
- _log_cache_migration_result(hooks.cleanup_playwright_cache())
36
+ _log_cache_migration_result(hooks.prepare_playwright_cache())
37
except Exception as exc:
38
PrintStyle.warning("Browser Playwright cache migration failed:", exc)
39
plugins/_browser/helpers/config.py
+1
-6
@@ -30,11 +30,6 @@ DEFAULT_MAX_OPEN_TABS = 32
30
MIN_MAX_OPEN_TABS = 1
31
HARD_MAX_OPEN_TABS = 50
32
DEFAULT_HOST_BROWSER_PRIVACY_POLICY = "allow"
33
-BASE_BROWSER_ARGS = [
34
- "--no-sandbox",
35
- "--disable-dev-shm-usage",
36
- "--disable-gpu",
37
-]
33
34
35
def _normalize_extension_paths(value: Any) -> list[str]:
@@ -388,7 +383,7 @@ def describe_browser_extensions(settings: dict[str, Any] | None) -> dict[str, An
383
def build_browser_launch_config(settings: dict[str, Any] | None) -> dict[str, Any]:
384
config = normalize_browser_config(settings)
385
extensions = describe_browser_extensions(config)
391
- args = list(BASE_BROWSER_ARGS)
386
+ args: list[str] = []
387
channel: str | None = None
388
browser_mode = "chromium"
389
proxy = None
plugins/_browser/helpers/playwright.py
+142
-24
@@ -1,12 +1,20 @@
1
+import atexit
2
+import json
3
import os
4
+import re
5
+import select
6
+import shutil
7
import subprocess
8
+import sys
9
+import threading
10
+from importlib import resources
11
from pathlib import Path
12
13
from helpers import files
14
15
FULL_CHROMIUM_PATTERNS = (
8
- "chromium-*/chrome-linux/chrome",
9
- "chromium-*/chrome-win/chrome.exe",
16
+ "chromium-*/chrome-linux*/chrome",
17
+ "chromium-*/chrome-win*/chrome.exe",
18
)
19
PLAYWRIGHT_CACHE_ENV = "A0_BROWSER_PLAYWRIGHT_CACHE_DIR"
20
PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright")
@@ -14,6 +22,10 @@ RETIRED_PLAYWRIGHT_CACHE_DIRS = (
22
("usr", "plugins", "_browser", "playwright"),
23
("usr", "browser", "playwright"),
24
)
25
+_INSTALL_LOCK = threading.Lock()
26
+_DISPLAY_LOCK = threading.Lock()
27
+_DISPLAY_PROCESS: subprocess.Popen | None = None
28
+_DISPLAY_NAME = ""
29
30
31
def _primary_cache_dir() -> Path:
@@ -52,33 +64,139 @@ def configure_playwright_env() -> str:
64
return cache_dir
65
66
55
-def find_playwright_binary(cache_dir: Path) -> Path | None:
56
- for pattern in FULL_CHROMIUM_PATTERNS:
57
- binary = next(cache_dir.glob(pattern), None)
58
- if binary and binary.exists():
59
- return binary
60
- return None
67
+def ensure_browser_display() -> str:
68
+ global _DISPLAY_NAME, _DISPLAY_PROCESS
69
+
70
+ with _DISPLAY_LOCK:
71
+ if _DISPLAY_PROCESS and _DISPLAY_PROCESS.poll() is None:
72
+ return _DISPLAY_NAME
73
+
74
+ xvfb = shutil.which("Xvfb")
75
+ if not xvfb:
76
+ return ""
77
+
78
+ read_fd, write_fd = os.pipe()
79
+ try:
80
+ process = subprocess.Popen(
81
+ [
82
+ xvfb,
83
+ "-displayfd",
84
+ str(write_fd),
85
+ "-screen",
86
+ "0",
87
+ "1365x768x24",
88
+ "+extension",
89
+ "GLX",
90
+ "-nolisten",
91
+ "tcp",
92
+ "-noreset",
93
+ "-ac",
94
+ ],
95
+ stdin=subprocess.DEVNULL,
96
+ stdout=subprocess.DEVNULL,
97
+ stderr=subprocess.DEVNULL,
98
+ pass_fds=(write_fd,),
99
+ )
100
+ except OSError:
101
+ os.close(read_fd)
102
+ return ""
103
+ finally:
104
+ os.close(write_fd)
105
+
106
+ try:
107
+ ready, _, _ = select.select([read_fd], [], [], 5)
108
+ display_number = os.read(read_fd, 32).decode().strip() if ready else ""
109
+ finally:
110
+ os.close(read_fd)
111
+
112
+ if not display_number.isdigit() or process.poll() is not None:
113
+ _terminate_browser_display(process)
114
+ return ""
115
+
116
+ _DISPLAY_PROCESS = process
117
+ _DISPLAY_NAME = f":{display_number}"
118
+ return _DISPLAY_NAME
119
+
120
+
121
+def close_browser_display() -> None:
122
+ global _DISPLAY_NAME, _DISPLAY_PROCESS
123
+
124
+ with _DISPLAY_LOCK:
125
+ process = _DISPLAY_PROCESS
126
+ _DISPLAY_PROCESS = None
127
+ _DISPLAY_NAME = ""
128
+ if process:
129
+ _terminate_browser_display(process)
130
+
131
+
132
+def _terminate_browser_display(process: subprocess.Popen) -> None:
133
+ if process.poll() is not None:
134
+ return
135
+ process.terminate()
136
+ try:
137
+ process.wait(timeout=2)
138
+ except subprocess.TimeoutExpired:
139
+ process.kill()
140
+ process.wait(timeout=2)
141
+
142
+
143
+def find_playwright_binary(cache_dir: Path, revision: str = "") -> Path | None:
144
+ prefix = f"chromium-{revision}" if revision.isdigit() else "chromium-*"
145
+ binaries = [
146
+ binary
147
+ for pattern in FULL_CHROMIUM_PATTERNS
148
+ for binary in cache_dir.glob(pattern.replace("chromium-*", prefix))
149
+ if binary.exists()
150
+ ]
151
+ return max(binaries, key=_chromium_revision) if binaries else None
152
+
153
+
154
+def _chromium_revision(binary: Path) -> int:
155
+ match = re.search(r"chromium-(\d+)", binary.as_posix())
156
+ return int(match.group(1)) if match else -1
157
158
159
def get_playwright_binary() -> Path | None:
64
- return find_playwright_binary(_primary_cache_dir())
160
+ cache_dir = _primary_cache_dir()
161
+ binary = find_playwright_binary(_primary_cache_dir())
162
+ revision = get_playwright_chromium_revision()
163
+ if revision and (not binary or _chromium_revision(binary) != int(revision)):
164
+ return find_playwright_binary(cache_dir, revision=revision)
165
+ return binary
166
+
167
+
168
+def get_playwright_chromium_revision() -> str:
169
+ try:
170
+ manifest = resources.files("patchright").joinpath("driver/package/browsers.json")
171
+ browsers = json.loads(manifest.read_text(encoding="utf-8"))["browsers"]
172
+ revision = next(
173
+ str(browser.get("revision", ""))
174
+ for browser in browsers
175
+ if browser.get("name") == "chromium"
176
+ )
177
+ except (ImportError, FileNotFoundError, KeyError, StopIteration, TypeError, ValueError):
178
+ return ""
179
+ return revision if revision.isdigit() else ""
180
181
182
def ensure_playwright_binary() -> Path:
68
- binary = get_playwright_binary()
69
- if binary:
183
+ with _INSTALL_LOCK:
184
+ binary = get_playwright_binary()
185
+ if binary:
186
+ return binary
187
+
188
+ cache_dir = configure_playwright_env()
189
+ env = os.environ.copy()
190
+ env["PLAYWRIGHT_BROWSERS_PATH"] = cache_dir
191
+ subprocess.check_call(
192
+ [sys.executable, "-m", "patchright", "install", "chromium", "--no-shell"],
193
+ env=env,
194
+ )
195
+
196
+ binary = get_playwright_binary()
197
+ if not binary:
198
+ raise RuntimeError("Patchright Chromium binary not found after installation")
199
return binary
200
72
- cache_dir = configure_playwright_env()
73
- env = os.environ.copy()
74
- env["PLAYWRIGHT_BROWSERS_PATH"] = cache_dir
75
- install_command = ["playwright", "install", "chromium"]
76
- subprocess.check_call(
77
- install_command,
78
- env=env,
79
- )
80
-
81
- binary = get_playwright_binary()
82
- if not binary:
83
- raise RuntimeError("Playwright Chromium binary not found after installation")
84
- return binary
201
+
202
+atexit.register(close_browser_display)
plugins/_browser/helpers/runtime.py
+50
-18
@@ -27,7 +27,9 @@ from plugins._browser.helpers.config import (
27
build_browser_launch_config,
28
get_browser_config,
29
)
30
-from plugins._browser.helpers.playwright import configure_playwright_env, ensure_playwright_binary
30
+from plugins._browser.helpers.playwright import (
31
+ ensure_browser_display,
32
+)
33
from plugins._browser.helpers.url import normalize_url
34
35
@@ -591,6 +593,7 @@ class _BrowserRuntimeCore:
593
self._closing = False
594
self._pending_popups: list[asyncio.Future[int]] = []
595
self._background_popup_pages: set[int] = set()
596
+ self._bootstrap_page: Any | None = None
597
598
def _ensure_registry_lock(self) -> asyncio.Lock:
599
if self._registry_lock is None:
@@ -773,6 +776,7 @@ class _BrowserRuntimeCore:
776
waiter.set_exception(RuntimeError("Browser context closed."))
777
self._pending_popups.clear()
778
self._background_popup_pages.clear()
779
+ self._bootstrap_page = None
780
self.pages.clear()
781
self.last_interacted_browser_id = None
782
for screencast in self.screencasts.values():
@@ -797,20 +801,26 @@ class _BrowserRuntimeCore:
801
self.playwright = None
802
803
async def _start(self) -> None:
800
- from playwright.async_api import async_playwright
804
+ from plugins._browser import hooks
805
+
806
+ preparation = hooks.prepare_playwright_cache()
807
+ if preparation.get("errors") or not preparation.get("binary"):
808
+ problem = preparation.get("errors") or "missing binary"
809
+ raise RuntimeError(f"Browser setup failed: {problem}")
810
+ from patchright.async_api import async_playwright
811
812
self.profile_dir.mkdir(parents=True, exist_ok=True)
813
self.downloads_dir.mkdir(parents=True, exist_ok=True)
814
self._release_orphaned_profile_singleton()
815
browser_config = get_browser_config()
816
launch_config = build_browser_launch_config(browser_config)
807
- configure_playwright_env()
808
- browser_binary = ensure_playwright_binary()
817
+ browser_binary = Path(preparation["binary"])
818
+ browser_display = ensure_browser_display()
819
820
self.playwright = await async_playwright().start()
821
launch_kwargs: dict[str, Any] = {
822
"user_data_dir": str(self.profile_dir),
813
- "headless": True,
823
+ "headless": not bool(browser_display),
824
"accept_downloads": True,
825
"downloads_path": str(self.downloads_dir),
826
"viewport": DEFAULT_VIEWPORT,
@@ -818,6 +828,8 @@ class _BrowserRuntimeCore:
828
"no_viewport": False,
829
"args": launch_config["args"],
830
}
831
+ if browser_display:
832
+ launch_kwargs["env"] = {**os.environ, "DISPLAY": browser_display}
833
if launch_config["channel"]:
834
launch_kwargs["channel"] = launch_config["channel"]
835
else:
@@ -840,11 +852,12 @@ class _BrowserRuntimeCore:
852
self.context.set_default_navigation_timeout(30000)
853
self.context.on("close", self._on_context_closed)
854
self.context.on("page", self._on_new_page_sync)
843
- await self.context.add_init_script(path=str(DOM_HELPER_PATH))
844
- await self.context.add_init_script(path=str(CONTENT_HELPER_PATH))
855
856
for page in list(self.context.pages):
857
if page.url == "about:blank":
858
+ if browser_display and self._bootstrap_page is None:
859
+ self._bootstrap_page = page
860
+ continue
861
try:
862
await page.close()
863
except Exception:
@@ -915,7 +928,10 @@ class _BrowserRuntimeCore:
928
async def open(self, url: str = "") -> dict[str, Any]:
929
await self.ensure_started()
930
self._ensure_can_open_page()
918
- page = await self.context.new_page()
931
+ page = self._bootstrap_page
932
+ self._bootstrap_page = None
933
+ if not page or page.is_closed():
934
+ page = await self.context.new_page()
935
browser_page = await self._register_page(page)
936
self.last_interacted_browser_id = browser_page.id
937
target_url = self._initial_url(url)
@@ -1296,6 +1312,7 @@ class _BrowserRuntimeCore:
1312
result = await page.evaluate(
1313
"(payload) => globalThis.__spaceBrowserPageContent__.capture(payload || null)",
1314
payload or None,
1315
+ isolated_context=True,
1316
)
1317
self._maybe_promote(resolved_id)
1318
return result or {}
@@ -1308,6 +1325,7 @@ class _BrowserRuntimeCore:
1325
result = await page.evaluate(
1326
"(ref) => globalThis.__spaceBrowserPageContent__.detail(ref)",
1327
reference_id,
1328
+ isolated_context=True,
1329
)
1330
self._maybe_promote(resolved_id)
1331
return result or {}
@@ -1324,6 +1342,7 @@ class _BrowserRuntimeCore:
1342
result = await page.evaluate(
1343
"(payload) => globalThis.__spaceBrowserPageContent__.annotate(payload || null)",
1344
payload or None,
1345
+ isolated_context=True,
1346
)
1347
self._maybe_promote(resolved_id)
1348
return result or {}
@@ -1332,7 +1351,7 @@ class _BrowserRuntimeCore:
1351
await self.ensure_started()
1352
resolved_id = self._resolve_browser_id(browser_id)
1353
page = self._page(resolved_id)
1335
- result = await page.evaluate(str(script or "undefined"))
1354
+ result = await page.evaluate(str(script or "undefined"), isolated_context=False)
1355
self._maybe_promote(resolved_id)
1356
return {"result": result, "state": await self._state(resolved_id)}
1357
@@ -1364,6 +1383,7 @@ class _BrowserRuntimeCore:
1383
box = await page.evaluate(
1384
"(ref) => globalThis.__spaceBrowserPageContent__.boundingBoxFor(ref)",
1385
reference_id,
1386
+ isolated_context=True,
1387
)
1388
1389
background = focus_popup is False or (
@@ -1515,6 +1535,7 @@ class _BrowserRuntimeCore:
1535
"action": normalized_action,
1536
"text": str(text or ""),
1537
},
1538
+ isolated_context=False,
1539
) or {}
1540
except Exception as exc:
1541
clipboard_result = {
@@ -1779,6 +1800,7 @@ class _BrowserRuntimeCore:
1800
"useOffsets": bool(offset_x or offset_y),
1801
},
1802
},
1803
+ isolated_context=True,
1804
)
1805
if not point or not isinstance(point, dict):
1806
raise ValueError(f"Could not resolve Browser ref {reference_id!r} to a viewport point")
@@ -1995,6 +2017,7 @@ class _BrowserRuntimeCore:
2017
"ref": ref,
2018
"values": values if values is not None else value,
2019
},
2020
+ isolated_context=True,
2021
)
2022
await self._settle(page, short=True)
2023
self._maybe_promote(resolved_id)
@@ -2016,6 +2039,7 @@ class _BrowserRuntimeCore:
2039
"ref": ref,
2040
"checked": bool(checked),
2041
},
2042
+ isolated_context=True,
2043
)
2044
await self._settle(page, short=True)
2045
self._maybe_promote(resolved_id)
@@ -2036,12 +2060,14 @@ class _BrowserRuntimeCore:
2060
metadata = await page.evaluate(
2061
"(ref) => globalThis.__spaceBrowserPageContent__.fileInputFor(ref)",
2062
ref,
2063
+ isolated_context=True,
2064
)
2065
handle = None
2066
try:
2067
handle = await page.evaluate_handle(
2068
"(ref) => globalThis.__spaceBrowserPageContent__.fileInputElementFor(ref)",
2069
ref,
2070
+ isolated_context=True,
2071
)
2072
element = handle.as_element() if handle else None
2073
if element:
@@ -2198,11 +2224,13 @@ class _BrowserRuntimeCore:
2224
action = await page.evaluate(
2225
"(args) => globalThis.__spaceBrowserPageContent__[args.method](args.ref)",
2226
{"method": helper_method, "ref": reference_id},
2227
+ isolated_context=True,
2228
)
2229
else:
2230
action = await page.evaluate(
2231
"(args) => globalThis.__spaceBrowserPageContent__[args.method](args.ref, args.text)",
2232
{"method": helper_method, "ref": reference_id, "text": text},
2233
+ isolated_context=True,
2234
)
2235
await self._settle(page, short=False)
2236
self._maybe_promote(resolved_id)
@@ -2215,8 +2243,8 @@ class _BrowserRuntimeCore:
2243
*,
2244
wait_until: str = "domcontentloaded",
2245
) -> None:
2218
- from playwright.async_api import Error as PlaywrightError
2219
- from playwright.async_api import TimeoutError as PlaywrightTimeoutError
2246
+ from patchright.async_api import Error as PlaywrightError
2247
+ from patchright.async_api import TimeoutError as PlaywrightTimeoutError
2248
2249
try:
2250
await page.goto(url, wait_until=wait_until, timeout=30000)
@@ -2227,8 +2255,8 @@ class _BrowserRuntimeCore:
2255
await self._settle(page, short=wait_until == "commit")
2256
2257
async def _settle(self, page: Any, short: bool = False) -> None:
2230
- from playwright.async_api import Error as PlaywrightError
2231
- from playwright.async_api import TimeoutError as PlaywrightTimeoutError
2258
+ from patchright.async_api import Error as PlaywrightError
2259
+ from patchright.async_api import TimeoutError as PlaywrightTimeoutError
2260
2261
try:
2262
await page.wait_for_load_state(
@@ -2249,7 +2277,10 @@ class _BrowserRuntimeCore:
2277
except Exception:
2278
title = ""
2279
try:
2252
- history_length = await page.evaluate("() => globalThis.history?.length || 0")
2280
+ history_length = await page.evaluate(
2281
+ "() => globalThis.history?.length || 0",
2282
+ isolated_context=False,
2283
+ )
2284
except Exception:
2285
history_length = 0
2286
return {
@@ -2384,13 +2415,14 @@ class _BrowserRuntimeCore:
2415
async def _ensure_content_helper(self, page: Any) -> None:
2416
await self._ensure_dom_helper(page)
2417
has_helper = await page.evaluate(
2387
- "() => Boolean(globalThis.__spaceBrowserPageContent__?.ready?.())"
2418
+ "() => Boolean(globalThis.__spaceBrowserPageContent__?.ready?.())",
2419
+ isolated_context=True,
2420
)
2421
if has_helper:
2422
return
2423
if self._content_helper_source is None:
2424
self._content_helper_source = CONTENT_HELPER_PATH.read_text(encoding="utf-8")
2393
- await page.evaluate(self._content_helper_source)
2425
+ await page.evaluate(self._content_helper_source, isolated_context=True)
2426
2427
async def _ensure_dom_helper(self, page: Any) -> None:
2428
if self._dom_helper_source is None:
@@ -2408,13 +2440,13 @@ class _BrowserRuntimeCore:
2440
targets = frames
2441
for target in targets:
2442
try:
2411
- has_helper = await target.evaluate(ready_script)
2443
+ has_helper = await target.evaluate(ready_script, isolated_context=True)
2444
except Exception:
2445
continue
2446
if has_helper:
2447
continue
2448
with contextlib.suppress(Exception):
2417
- await target.evaluate(source)
2449
+ await target.evaluate(source, isolated_context=True)
2450
2451
_runtimes: dict[str, BrowserRuntime] = {}
2452
_runtime_lock = threading.RLock()
plugins/_browser/hooks.py
+65
@@ -1,6 +1,12 @@
1
from __future__ import annotations
2
3
+import importlib
4
+import importlib.metadata
5
+import importlib.util
6
import shutil
7
+import subprocess
8
+import sys
9
+import threading
10
from pathlib import Path
11
12
from helpers import files, plugins, yaml as yaml_helper
@@ -10,6 +16,7 @@ from plugins._browser.helpers.config import (
16
normalize_browser_config,
17
)
18
from plugins._browser.helpers.playwright import (
19
+ ensure_playwright_binary,
20
find_playwright_binary,
21
get_playwright_cache_dir,
22
get_retired_playwright_cache_dirs,
@@ -17,6 +24,11 @@ from plugins._browser.helpers.playwright import (
24
from plugins._browser.helpers.runtime import close_all_runtimes_sync
25
26
27
+_SETUP_LOCK = threading.Lock()
28
+_PLUGIN_DIR = Path(__file__).resolve().parent
29
+_ROOT_REQUIREMENTS_FILE = _PLUGIN_DIR.parents[1] / "requirements.txt"
30
+
31
+
32
def _load_saved_browser_config(project_name: str = "", agent_profile: str = "") -> dict:
33
entries = plugins.find_plugin_assets(
34
plugins.CONFIG_FILE_NAME,
@@ -95,6 +107,59 @@ def cleanup_playwright_cache() -> dict:
107
return result
108
109
110
+def prepare_playwright_cache() -> dict:
111
+ with _SETUP_LOCK:
112
+ _ensure_patchright_dependency()
113
+ result = cleanup_playwright_cache()
114
+ if result["errors"]:
115
+ return result
116
+ result["binary"] = str(ensure_playwright_binary())
117
+ return result
118
+
119
+
120
+def install() -> dict:
121
+ return prepare_playwright_cache()
122
+
123
+
124
+def _ensure_patchright_dependency() -> None:
125
+ requirement = _patchright_requirement()
126
+ if _patchright_is_current(requirement):
127
+ return
128
+
129
+ uv = shutil.which("uv")
130
+ if not uv:
131
+ raise RuntimeError("Browser plugin requires 'uv' to install Patchright automatically")
132
+
133
+ subprocess.check_call(
134
+ [uv, "pip", "install", "--python", sys.executable, requirement],
135
+ cwd=str(_PLUGIN_DIR),
136
+ )
137
+ importlib.invalidate_caches()
138
+ if not _patchright_is_current(requirement):
139
+ raise RuntimeError(
140
+ f"Browser dependency {requirement!r} is unavailable after installation"
141
+ )
142
+
143
+
144
+def _patchright_requirement() -> str:
145
+ if _ROOT_REQUIREMENTS_FILE.is_file():
146
+ for line in _ROOT_REQUIREMENTS_FILE.read_text(encoding="utf-8").splitlines():
147
+ requirement = line.strip()
148
+ if requirement.startswith("patchright=="):
149
+ return requirement
150
+ raise RuntimeError(f"Browser Patchright requirement not found in {_ROOT_REQUIREMENTS_FILE}")
151
+
152
+
153
+def _patchright_is_current(requirement: str) -> bool:
154
+ expected_version = requirement.partition("==")[2]
155
+ if not expected_version or importlib.util.find_spec("patchright") is None:
156
+ return False
157
+ try:
158
+ return importlib.metadata.version("patchright") == expected_version
159
+ except importlib.metadata.PackageNotFoundError:
160
+ return False
161
+
162
+
163
def _best_playwright_cache(candidates: list[Path]) -> Path | None:
164
valid = [path for path in candidates if path.is_dir() and find_playwright_binary(path)]
165
if not valid:
requirements.txt
+1
-1
@@ -26,7 +26,7 @@ markdown==3.7
26
mcp==1.27.0
27
newspaper3k==0.2.8
28
paramiko==3.5.0
29
-playwright==1.52.0
29
+patchright==1.61.2
30
pypdf==6.0.0
31
python-dotenv==1.1.0
32
pytz==2024.2
tests/test_browser_agent_regressions.py
+189
-24
@@ -110,6 +110,7 @@ from plugins._browser.helpers.runtime import (
110
)
111
import plugins._browser.helpers.runtime as browser_runtime_module
112
from plugins._browser.helpers.playwright import (
113
+ ensure_playwright_binary,
114
get_playwright_binary,
115
get_playwright_cache_dir,
116
)
@@ -331,6 +332,9 @@ def test_browser_launch_config_uses_full_chromium_for_all_sessions(tmp_path):
332
assert default_launch["requires_full_browser"] is True
333
assert default_launch["proxy"] is None
334
assert not any(arg.startswith("--load-extension=") for arg in default_launch["args"])
335
+ assert "--no-sandbox" not in default_launch["args"]
336
+ assert "--disable-dev-shm-usage" not in default_launch["args"]
337
+ assert "--disable-gpu" not in default_launch["args"]
338
assert "--headless=new" not in default_launch["args"]
339
340
extension_dir = tmp_path / "extension"
@@ -357,6 +361,102 @@ def _patch_playwright_cache_root(monkeypatch, tmp_path):
361
"get_abs_path",
362
lambda *parts: str(tmp_path.joinpath(*parts)),
363
)
364
+ monkeypatch.setattr(
365
+ browser_playwright_module,
366
+ "get_playwright_chromium_revision",
367
+ lambda: "1169",
368
+ )
369
+
370
+
371
+def test_browser_uses_patchright_revision_when_newer_playwright_cache_exists(
372
+ monkeypatch, tmp_path
373
+):
374
+ _patch_playwright_cache_root(monkeypatch, tmp_path)
375
+ monkeypatch.setattr(
376
+ browser_playwright_module,
377
+ "get_playwright_chromium_revision",
378
+ lambda: "1228",
379
+ )
380
+ cache_dir = Path(get_playwright_cache_dir())
381
+ expected = cache_dir / "chromium-1228" / "chrome-linux64" / "chrome"
382
+ other = cache_dir / "chromium-1234" / "chrome-linux64" / "chrome"
383
+ expected.parent.mkdir(parents=True)
384
+ other.parent.mkdir(parents=True)
385
+ expected.touch()
386
+ other.touch()
387
+
388
+ assert get_playwright_binary() == expected
389
+
390
+
391
+@pytest.mark.parametrize("platform_dir", ["chrome-linux", "chrome-linux64"])
392
+def test_browser_installs_current_patchright_chromium_for_host_architecture(
393
+ monkeypatch, tmp_path, platform_dir
394
+):
395
+ _patch_playwright_cache_root(monkeypatch, tmp_path)
396
+ monkeypatch.setattr(
397
+ browser_playwright_module,
398
+ "get_playwright_chromium_revision",
399
+ lambda: "1234",
400
+ )
401
+ old_binary = (
402
+ tmp_path / "tmp" / "playwright" / "chromium-1169" / "chrome-linux" / "chrome"
403
+ )
404
+ old_binary.parent.mkdir(parents=True)
405
+ old_binary.touch()
406
+ expected = (
407
+ tmp_path / "tmp" / "playwright" / "chromium-1234" / platform_dir / "chrome"
408
+ )
409
+
410
+ def install(command, *, env):
411
+ assert command == [
412
+ sys.executable,
413
+ "-m",
414
+ "patchright",
415
+ "install",
416
+ "chromium",
417
+ "--no-shell",
418
+ ]
419
+ assert env["PLAYWRIGHT_BROWSERS_PATH"] == str(tmp_path / "tmp" / "playwright")
420
+ expected.parent.mkdir(parents=True)
421
+ expected.touch()
422
+
423
+ monkeypatch.setattr(browser_playwright_module.subprocess, "check_call", install)
424
+
425
+ assert ensure_playwright_binary() == expected
426
+
427
+
428
+def test_browser_hook_installs_patchright_for_existing_self_updated_runtime(monkeypatch):
429
+ current = False
430
+
431
+ def is_current(requirement):
432
+ assert requirement == "patchright==1.61.2"
433
+ return current
434
+
435
+ def install(command, *, cwd):
436
+ nonlocal current
437
+ assert command == [
438
+ "/usr/local/bin/uv",
439
+ "pip",
440
+ "install",
441
+ "--python",
442
+ sys.executable,
443
+ "patchright==1.61.2",
444
+ ]
445
+ assert cwd == str(PROJECT_ROOT / "plugins" / "_browser")
446
+ current = True
447
+
448
+ monkeypatch.setattr(
449
+ browser_hooks_module,
450
+ "_patchright_requirement",
451
+ lambda: "patchright==1.61.2",
452
+ )
453
+ monkeypatch.setattr(browser_hooks_module, "_patchright_is_current", is_current)
454
+ monkeypatch.setattr(browser_hooks_module.shutil, "which", lambda name: "/usr/local/bin/uv")
455
+ monkeypatch.setattr(browser_hooks_module.subprocess, "check_call", install)
456
+
457
+ browser_hooks_module._ensure_patchright_dependency()
458
+
459
+ assert current is True
460
461
462
def _write_playwright_binary(cache_dir: Path) -> Path:
@@ -1705,23 +1805,18 @@ def test_browser_runtime_requires_current_content_helper_for_modifier_clicks():
1805
).read_text(encoding="utf-8")
1806
1807
assert "__spaceBrowserPageContent__?.ready?.()" in runtime
1808
+ assert "context.add_init_script" not in runtime
1809
+ assert "isolated_context=False" in runtime
1810
1811
1812
@pytest.mark.anyio
1813
async def test_browser_dom_helper_clicks_content_ref_inside_iframe():
1712
- pytest.importorskip("playwright.async_api")
1713
- from playwright.async_api import async_playwright
1814
+ pytest.importorskip("patchright.async_api")
1815
+ from patchright.async_api import async_playwright
1816
1817
browser_binary = get_playwright_binary()
1818
if not browser_binary:
1717
- pytest.skip("Playwright Chromium binary is not installed")
1718
-
1719
- dom_helper = (
1720
- PROJECT_ROOT / "plugins" / "_browser" / "assets" / "browser-dom-helper.js"
1721
- ).read_text(encoding="utf-8")
1722
- content_helper = (
1723
- PROJECT_ROOT / "plugins" / "_browser" / "assets" / "browser-page-content.js"
1724
- ).read_text(encoding="utf-8")
1819
+ pytest.skip("Patchright Chromium binary is not installed")
1820
1821
async with async_playwright() as playwright:
1822
try:
@@ -1731,12 +1826,10 @@ async def test_browser_dom_helper_clicks_content_ref_inside_iframe():
1826
args=["--no-sandbox"],
1827
)
1828
except Exception as exc:
1734
- pytest.skip(f"Playwright Chromium could not launch: {exc}")
1829
+ pytest.skip(f"Patchright Chromium could not launch: {exc}")
1830
1831
try:
1832
context = await browser.new_context()
1738
- await context.add_init_script(dom_helper)
1739
- await context.add_init_script(content_helper)
1833
page = await context.new_page()
1834
await page.set_content(
1835
"""
@@ -1755,9 +1848,8 @@ async def test_browser_dom_helper_clicks_content_ref_inside_iframe():
1848
</html>
1849
"""
1850
)
1758
- await page.wait_for_function(
1759
- "() => Boolean(document.querySelector('iframe')?.contentWindow?.__spaceBrowserDomHelper__)"
1760
- )
1851
+ core = _BrowserRuntimeCore("patchright-helper")
1852
+ await core._ensure_content_helper(page)
1853
1854
captured = await page.evaluate(
1855
"(payload) => globalThis.__spaceBrowserPageContent__.capture(payload || null)",
@@ -2093,13 +2185,32 @@ def test_browser_docker_installs_full_chromium_to_tmp_cache():
2185
script = (
2186
PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_playwright.sh"
2187
).read_text(encoding="utf-8")
2188
+ requirements = (PROJECT_ROOT / "requirements.txt").read_text(encoding="utf-8")
2189
2190
assert "PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright" in script
2098
- assert "playwright install chromium" in script
2191
+ assert "patchright install chromium --no-shell" in script
2192
+ assert "playwright install chromium" not in script
2193
+ assert "uv pip install" not in script
2194
+ assert "patchright==1.61.2" in requirements
2195
assert "--only-shell" not in script
2196
2197
+ runtime = (PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py").read_text(
2198
+ encoding="utf-8"
2199
+ )
2200
+ assert "from patchright.async_api import async_playwright" in runtime
2201
+ assert "from playwright.async_api import async_playwright" not in runtime
2202
+ assert runtime.index("hooks.prepare_playwright_cache()") < runtime.index(
2203
+ "from patchright.async_api import async_playwright"
2204
+ )
2205
+ install_additional = (
2206
+ PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_additional.sh"
2207
+ ).read_text(encoding="utf-8")
2208
+ assert '"headless": not bool(browser_display)' in runtime
2209
+ assert 'launch_kwargs["env"] = {**os.environ, "DISPLAY": browser_display}' in runtime
2210
+ assert " xvfb \\" in install_additional
2211
+
2212
2102
-def test_browser_startup_migration_runs_playwright_cache_cleanup():
2213
+def test_browser_startup_migration_prepares_current_playwright_binary():
2214
extension = (
2215
PROJECT_ROOT
2216
/ "plugins"
@@ -2111,7 +2222,7 @@ def test_browser_startup_migration_runs_playwright_cache_cleanup():
2222
).read_text(encoding="utf-8")
2223
2224
assert "class BrowserPlaywrightCacheMigration(Extension)" in extension
2114
- assert "hooks.cleanup_playwright_cache()" in extension
2225
+ assert "hooks.prepare_playwright_cache()" in extension
2226
assert "PrintStyle.warning" in extension
2227
2228
@@ -2135,6 +2246,55 @@ def test_browser_runtime_removes_stale_profile_singletons(monkeypatch, tmp_path)
2246
)
2247
2248
2249
+@pytest.mark.anyio
2250
+async def test_browser_first_open_reuses_headful_bootstrap_page(monkeypatch):
2251
+ class BootstrapPage:
2252
+ url = "about:blank"
2253
+
2254
+ @staticmethod
2255
+ def is_closed():
2256
+ return False
2257
+
2258
+ page = BootstrapPage()
2259
+
2260
+ class Context:
2261
+ pages = [page]
2262
+
2263
+ @staticmethod
2264
+ async def new_page():
2265
+ raise AssertionError("The first headful tab must reuse Chrome's bootstrap page")
2266
+
2267
+ core = _BrowserRuntimeCore("headful")
2268
+ core.context = Context()
2269
+ core._bootstrap_page = page
2270
+
2271
+ async def register(registered_page):
2272
+ assert registered_page is page
2273
+ browser_page = BrowserPage(id=1, page=registered_page)
2274
+ core.pages[1] = browser_page
2275
+ return browser_page
2276
+
2277
+ async def settle(_page, short=False):
2278
+ return None
2279
+
2280
+ async def state(browser_id):
2281
+ return {"id": browser_id, "currentUrl": "about:blank"}
2282
+
2283
+ core._register_page = register
2284
+ core._settle = settle
2285
+ core._state = state
2286
+ monkeypatch.setattr(
2287
+ browser_runtime_module,
2288
+ "get_browser_config",
2289
+ lambda: {"default_homepage": "about:blank", "max_open_tabs": 8},
2290
+ )
2291
+
2292
+ result = await core.open()
2293
+
2294
+ assert result == {"id": 1, "state": {"id": 1, "currentUrl": "about:blank"}}
2295
+ assert core._bootstrap_page is None
2296
+
2297
+
2298
@pytest.mark.anyio
2299
async def test_browser_runtime_restarts_when_cached_context_is_stale():
2300
starts = []
@@ -2780,7 +2940,12 @@ async def test_browser_viewer_subscribe_returns_initial_snapshot(monkeypatch):
2940
2941
result = await handler.process(
2942
"browser_viewer_subscribe",
2783
- {"context_id": "ctx", "browser_id": 1, "viewport_width": 900, "viewport_height": 600},
2943
+ {
2944
+ "context_id": "ctx",
2945
+ "browser_id": 1,
2946
+ "viewport_width": 900,
2947
+ "viewport_height": 600,
2948
+ },
2949
"sid-snapshot",
2950
)
2951
@@ -3202,7 +3367,7 @@ async def test_browser_runtime_screenshot_file_defaults_to_chat_scoped_artifact(
3367
async def title(self):
3368
return "Blank"
3369
3205
- async def evaluate(self, script, payload=None):
3370
+ async def evaluate(self, script, payload=None, **kwargs):
3371
return 1
3372
3373
core = _BrowserRuntimeCore("ctx/id")
@@ -3321,7 +3486,7 @@ async def test_browser_runtime_ref_point_resolution_applies_offsets():
3486
def __init__(self):
3487
self.mouse = FakeMouse()
3488
3324
- async def evaluate(self, script, payload=None):
3489
+ async def evaluate(self, script, payload=None, **kwargs):
3490
eval_payloads.append((script, payload))
3491
if payload and "offsets" in payload:
3492
return {
@@ -3392,7 +3557,7 @@ async def test_browser_runtime_clipboard_paste_uses_dom_bridge():
3557
def __init__(self):
3558
self.keyboard = FakeKeyboard()
3559
3395
- async def evaluate(self, script, payload=None):
3560
+ async def evaluate(self, script, payload=None, **kwargs):
3561
if payload is not None:
3562
eval_payloads.append((script, payload))
3563
return {
@@ -3442,7 +3607,7 @@ async def test_browser_runtime_clipboard_paste_falls_back_to_keyboard_insert_tex
3607
def __init__(self):
3608
self.keyboard = FakeKeyboard()
3609
3445
- async def evaluate(self, script, payload=None):
3610
+ async def evaluate(self, script, payload=None, **kwargs):
3611
if payload is not None:
3612
return {
3613
"action": "paste",