Move Browser Playwright cache to tmp

Use /a0/tmp/playwright as the Browser plugin Chromium cache and Docker install target while preserving full Chromium installs. Add startup migration cleanup for retired usr Playwright caches, update Browser status/runtime references and docs, and cover migration behavior with focused regressions.

Alessandro committed May 7, 2026 at 18:43 UTC 8b921a8ded95093083a751d164f19ca8e09fca81
11 files changed +251 -63
docker/run/fs/ins/install_playwright.sh
+2 -2
@@ -7,8 +7,8 @@ set -e
7 # install playwright if not installed (should be from requirements.txt)
8 uv pip install playwright
9
10 -# set PW installation path to persistent Browser plugin user storage
11 -export PLAYWRIGHT_BROWSERS_PATH=/a0/usr/plugins/_browser/playwright
10 +# set PW installation path to temporary Browser runtime storage
11 +export PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright
12 mkdir -p "$PLAYWRIGHT_BROWSERS_PATH"
13
14 # install chromium with dependencies
docs/guides/troubleshooting.md
+1 -1
@@ -27,7 +27,7 @@ Refer to the [Choosing your LLMs](../setup/installation.md#installing-and-using-
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?**
30 -The built-in browser is provided by the `_browser` plugin and the direct `browser` tool. **Docker:** the Chromium headless shell is shipped preinstalled (typically under `/a0/usr/plugins/_browser/playwright`). **Local development:** if the binary is missing, `ensure_playwright_binary()` in `plugins/_browser/helpers/playwright.py` runs `playwright install chromium --only-shell` into `usr/plugins/_browser/playwright` on first browser use (you may see UI notifications). To install ahead of time, run `PLAYWRIGHT_BROWSERS_PATH=usr/plugins/_browser/playwright playwright install chromium --only-shell` after `pip install -r requirements.txt`. If you prefer an external browser stack, use MCP alternatives such as Browser OS, Chrome DevTools, or Playwright MCP. See [MCP Setup](mcp-setup.md).
30 +The built-in browser is provided by the `_browser` plugin and the direct `browser` tool. **Docker:** full Playwright Chromium is shipped preinstalled under `/a0/tmp/playwright`. **Local development:** if the binary is missing, `ensure_playwright_binary()` in `plugins/_browser/helpers/playwright.py` runs `playwright install chromium` into `tmp/playwright` on first browser use (you may see UI notifications). To install ahead of time, run `PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium` after `pip install -r requirements.txt`. If you prefer an external browser stack, use MCP alternatives such as Browser OS, Chrome DevTools, or Playwright MCP. See [MCP Setup](mcp-setup.md).
31
32 **9. My secrets disappeared after a backup restore.**
33 Secrets are stored in `/a0/usr/secrets.env` and are not always included in backup archives. Copy them manually.
docs/setup/dev-setup.md
+2 -2
@@ -67,9 +67,9 @@ Now when you select one of the python files in the project, you should see prope
67 3. Install dependencies. Run these two commands in the terminal:
68 ```bash
69 pip install -r requirements.txt
70 -PLAYWRIGHT_BROWSERS_PATH=usr/plugins/_browser/playwright playwright install chromium --only-shell
70 +PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium
71 ```
72 -The first command installs Python dependencies. The second installs the Chromium headless shell into `usr/plugins/_browser/playwright` ahead of time (same path in Docker: `/a0/usr/plugins/_browser/playwright`). If you skip the second command, **local development** still downloads the shell on first browser use through `ensure_playwright_binary()` in `plugins/_browser/helpers/playwright.py`. Pre-installing avoids that wait. **Docker** images ship the shell preinstalled; runtime install is for local dev when the binary is missing.
72 +The first command installs Python dependencies. The second installs full Playwright Chromium into `tmp/playwright` ahead of time (same path in Docker: `/a0/tmp/playwright`). If you skip the second command, **local development** still downloads Chromium on first browser use through `ensure_playwright_binary()` in `plugins/_browser/helpers/playwright.py`. Pre-installing avoids that wait. **Docker** images ship Chromium preinstalled; runtime install is for local dev when the binary is missing.
73 Errors in the code editor caused by missing packages should now be gone. If not, try reloading the window.
74
75
plugins/_browser/api/status.py
+2 -4
@@ -12,10 +12,8 @@ class Status(ApiHandler):
12 async def process(self, input: dict, request: Request) -> dict:
13 browser_config = get_browser_config()
14 launch_config = build_browser_launch_config(browser_config)
15 - runtime_binary = get_playwright_binary(
16 - full_browser=launch_config["requires_full_browser"]
17 - )
18 - chromium_binary = get_playwright_binary(full_browser=True)
15 + runtime_binary = get_playwright_binary()
16 + chromium_binary = runtime_binary
17 return {
18 "plugin": "_browser",
19 "playwright": {
plugins/_browser/extensions/python/startup_migration/_20_browser_playwright_cache.py new
+45
@@ -0,0 +1,45 @@
1 +from __future__ import annotations
2 +
3 +import threading
4 +from typing import Any
5 +
6 +from helpers.extension import Extension
7 +from helpers.print_style import PrintStyle
8 +from plugins._browser import hooks
9 +
10 +
11 +_startup_migration_thread: threading.Thread | None = None
12 +
13 +
14 +class BrowserPlaywrightCacheMigration(Extension):
15 + def execute(self, **kwargs):
16 + _start_background_cache_migration()
17 +
18 +
19 +def _start_background_cache_migration() -> threading.Thread:
20 + global _startup_migration_thread
21 +
22 + if _startup_migration_thread and _startup_migration_thread.is_alive():
23 + return _startup_migration_thread
24 +
25 + _startup_migration_thread = threading.Thread(
26 + target=_migrate_cache_safely,
27 + name="a0-browser-playwright-cache-migration",
28 + daemon=True,
29 + )
30 + _startup_migration_thread.start()
31 + return _startup_migration_thread
32 +
33 +
34 +def _migrate_cache_safely() -> None:
35 + try:
36 + _log_cache_migration_result(hooks.cleanup_playwright_cache())
37 + except Exception as exc:
38 + PrintStyle.warning("Browser Playwright cache migration failed:", exc)
39 +
40 +
41 +def _log_cache_migration_result(result: dict[str, Any]) -> None:
42 + if result.get("errors"):
43 + PrintStyle.warning("Browser Playwright cache migration reported errors:", result["errors"])
44 + elif result.get("migrated") or result.get("removed"):
45 + PrintStyle.info("Browser Playwright cache prepared:", result)
plugins/_browser/helpers/playwright.py
+22 -17
@@ -9,9 +9,11 @@ FULL_CHROMIUM_PATTERNS = (
9 "chromium-*/chrome-win/chrome.exe",
10 )
11 PLAYWRIGHT_CACHE_ENV = "A0_BROWSER_PLAYWRIGHT_CACHE_DIR"
12 -PLAYWRIGHT_CACHE_DIR = ("usr", "plugins", "_browser", "playwright")
13 -PREVIOUS_PLAYWRIGHT_CACHE_DIR = ("usr", "browser", "playwright")
14 -LEGACY_PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright")
12 +PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright")
13 +RETIRED_PLAYWRIGHT_CACHE_DIRS = (
14 + ("usr", "plugins", "_browser", "playwright"),
15 + ("usr", "browser", "playwright"),
16 +)
17
18
19 def _primary_cache_dir() -> Path:
@@ -27,11 +29,7 @@ def get_playwright_cache_dir() -> str:
29
30 def get_playwright_cache_dirs() -> list[Path]:
31 primary = _primary_cache_dir()
30 - candidates = [
31 - primary,
32 - Path(files.get_abs_path(*PREVIOUS_PLAYWRIGHT_CACHE_DIR)),
33 - Path(files.get_abs_path(*LEGACY_PLAYWRIGHT_CACHE_DIR)),
34 - ]
32 + candidates = [primary, *get_retired_playwright_cache_dirs()]
33 seen: set[str] = set()
34 unique: list[Path] = []
35 for candidate in candidates:
@@ -43,6 +41,10 @@ def get_playwright_cache_dirs() -> list[Path]:
41 return unique
42
43
44 +def get_retired_playwright_cache_dirs() -> list[Path]:
45 + return [Path(files.get_abs_path(*parts)) for parts in RETIRED_PLAYWRIGHT_CACHE_DIRS]
46 +
47 +
48 def configure_playwright_env() -> str:
49 cache_dir = get_playwright_cache_dir()
50 Path(cache_dir).mkdir(parents=True, exist_ok=True)
@@ -50,17 +52,20 @@ def configure_playwright_env() -> str:
52 return cache_dir
53
54
53 -def get_playwright_binary(*, full_browser: bool = False) -> Path | None:
54 - for cache_dir in get_playwright_cache_dirs():
55 - for pattern in FULL_CHROMIUM_PATTERNS:
56 - binary = next(cache_dir.glob(pattern), None)
57 - if binary and binary.exists():
58 - return binary
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
61
62
62 -def ensure_playwright_binary(*, full_browser: bool = False) -> Path:
63 - binary = get_playwright_binary(full_browser=full_browser)
63 +def get_playwright_binary() -> Path | None:
64 + return find_playwright_binary(_primary_cache_dir())
65 +
66 +
67 +def ensure_playwright_binary() -> Path:
68 + binary = get_playwright_binary()
69 if binary:
70 return binary
71
@@ -73,7 +78,7 @@ def ensure_playwright_binary(*, full_browser: bool = False) -> Path:
78 env=env,
79 )
80
76 - binary = get_playwright_binary(full_browser=full_browser)
81 + binary = get_playwright_binary()
82 if not binary:
83 raise RuntimeError("Playwright Chromium binary not found after installation")
84 return binary
plugins/_browser/helpers/runtime.py
+1 -3
@@ -783,9 +783,7 @@ class _BrowserRuntimeCore:
783 browser_config = get_browser_config()
784 launch_config = build_browser_launch_config(browser_config)
785 configure_playwright_env()
786 - browser_binary = ensure_playwright_binary(
787 - full_browser=launch_config["requires_full_browser"]
788 - )
786 + browser_binary = ensure_playwright_binary()
787
788 self.playwright = await async_playwright().start()
789 launch_kwargs: dict[str, Any] = {
plugins/_browser/hooks.py
+83
@@ -1,11 +1,19 @@
1 from __future__ import annotations
2
3 +import shutil
4 +from pathlib import Path
5 +
6 from helpers import files, plugins, yaml as yaml_helper
7 from plugins._browser.helpers.config import (
8 PLUGIN_NAME,
9 browser_runtime_config,
10 normalize_browser_config,
11 )
12 +from plugins._browser.helpers.playwright import (
13 + find_playwright_binary,
14 + get_playwright_cache_dir,
15 + get_retired_playwright_cache_dirs,
16 +)
17 from plugins._browser.helpers.runtime import close_all_runtimes_sync
18
19
@@ -45,3 +53,78 @@ def save_plugin_config(settings=None, project_name="", agent_profile="", **kwarg
53 if browser_runtime_config(normalized) != browser_runtime_config(current):
54 close_all_runtimes_sync()
55 return normalized
56 +
57 +
58 +def cleanup_playwright_cache() -> dict:
59 + primary = Path(get_playwright_cache_dir())
60 + retired_dirs = [
61 + path for path in get_retired_playwright_cache_dirs() if path.resolve() != primary.resolve()
62 + ]
63 + result = {"primary": str(primary), "migrated": "", "removed": [], "errors": []}
64 +
65 + if find_playwright_binary(primary):
66 + _remove_cache_dirs(retired_dirs, result)
67 + return result
68 +
69 + source = _best_playwright_cache(retired_dirs)
70 + if not source:
71 + return result
72 +
73 + backup = _next_backup_path(primary) if primary.exists() else None
74 + try:
75 + if backup:
76 + primary.rename(backup)
77 + primary.parent.mkdir(parents=True, exist_ok=True)
78 + shutil.move(str(source), str(primary))
79 + result["migrated"] = str(source)
80 + except Exception as exc:
81 + if backup and backup.exists() and not primary.exists():
82 + backup.rename(primary)
83 + result["errors"].append(f"Failed to migrate {source} to {primary}: {exc}")
84 + return result
85 +
86 + if not find_playwright_binary(primary):
87 + result["errors"].append(f"Migrated Playwright cache is not valid: {primary}")
88 + if backup:
89 + result["errors"].append(f"Previous primary Playwright cache retained at {backup}")
90 + return result
91 +
92 + if backup:
93 + _remove_cache_dirs([backup], result)
94 + _remove_cache_dirs(retired_dirs, result)
95 + return result
96 +
97 +
98 +def _best_playwright_cache(candidates: list[Path]) -> Path | None:
99 + valid = [path for path in candidates if path.is_dir() and find_playwright_binary(path)]
100 + if not valid:
101 + return None
102 +
103 + def modified_at(path: Path) -> float:
104 + binary = find_playwright_binary(path)
105 + try:
106 + return binary.stat().st_mtime if binary else path.stat().st_mtime
107 + except OSError:
108 + return 0
109 +
110 + return max(valid, key=modified_at)
111 +
112 +
113 +def _next_backup_path(path: Path) -> Path:
114 + backup = path.with_name(f"{path.name}.migration-backup")
115 + counter = 2
116 + while backup.exists():
117 + backup = path.with_name(f"{path.name}.migration-backup-{counter}")
118 + counter += 1
119 + return backup
120 +
121 +
122 +def _remove_cache_dirs(paths: list[Path], result: dict) -> None:
123 + for path in paths:
124 + if not path.exists():
125 + continue
126 + try:
127 + shutil.rmtree(path)
128 + result["removed"].append(str(path))
129 + except Exception as exc:
130 + result["errors"].append(f"Failed to remove Playwright cache {path}: {exc}")
plugins/_browser/webui/browser-store.js
+1 -1
@@ -2757,7 +2757,7 @@ const model = {
2757
2758 loadingMessage() {
2759 if (this.browserInstallExpected) {
2760 - const cacheDir = this.status?.playwright?.cache_dir || "/a0/usr/plugins/_browser/playwright";
2760 + const cacheDir = this.status?.playwright?.cache_dir || "/a0/tmp/playwright";
2761 return `Installing Chromium for the first Browser run. This can take a few minutes; future starts reuse ${cacheDir}.`;
2762 }
2763 return "Loading";
tests/test_browser_agent_regressions.py
+88 -31
@@ -257,56 +257,97 @@ def test_browser_launch_config_uses_full_chromium_for_all_sessions(tmp_path):
257 assert "--headless=new" not in launch["args"]
258
259
260 -def test_browser_playwright_cache_uses_persistent_usr_path(monkeypatch, tmp_path):
260 +def _patch_playwright_cache_root(monkeypatch, tmp_path):
261 monkeypatch.delenv("A0_BROWSER_PLAYWRIGHT_CACHE_DIR", raising=False)
262 monkeypatch.setattr(
263 browser_playwright_module.files,
264 "get_abs_path",
265 lambda *parts: str(tmp_path.joinpath(*parts)),
266 )
267 - browser_binary = (
268 - tmp_path
269 - / "usr"
270 - / "plugins"
271 - / "_browser"
272 - / "playwright"
273 - / "chromium-1169"
274 - / "chrome-linux"
275 - / "chrome"
276 - )
267 +
268 +
269 +def _write_playwright_binary(cache_dir: Path) -> Path:
270 + browser_binary = cache_dir / "chromium-1169" / "chrome-linux" / "chrome"
271 browser_binary.parent.mkdir(parents=True)
272 browser_binary.write_text("#!/bin/sh\n", encoding="utf-8")
273 + return browser_binary
274 +
275 +
276 +def test_browser_playwright_cache_uses_tmp_path(monkeypatch, tmp_path):
277 + _patch_playwright_cache_root(monkeypatch, tmp_path)
278 + primary_cache = tmp_path / "tmp" / "playwright"
279 + browser_binary = _write_playwright_binary(primary_cache)
280
281 assert get_playwright_cache_dir() == str(
281 - tmp_path / "usr" / "plugins" / "_browser" / "playwright"
282 + tmp_path / "tmp" / "playwright"
283 )
284 + assert browser_playwright_module.get_playwright_cache_dirs() == [
285 + tmp_path / "tmp" / "playwright",
286 + tmp_path / "usr" / "plugins" / "_browser" / "playwright",
287 + tmp_path / "usr" / "browser" / "playwright",
288 + ]
289 assert get_playwright_binary() == browser_binary
290
291
286 -def test_browser_playwright_cache_falls_back_to_existing_legacy_install(monkeypatch, tmp_path):
287 - monkeypatch.delenv("A0_BROWSER_PLAYWRIGHT_CACHE_DIR", raising=False)
288 - monkeypatch.setattr(
289 - browser_playwright_module.files,
290 - "get_abs_path",
291 - lambda *parts: str(tmp_path.joinpath(*parts)),
292 +def test_browser_playwright_binary_ignores_retired_usr_cache(monkeypatch, tmp_path):
293 + _patch_playwright_cache_root(monkeypatch, tmp_path)
294 + _write_playwright_binary(
295 + tmp_path / "usr" / "plugins" / "_browser" / "playwright"
296 + )
297 +
298 + assert get_playwright_binary() is None
299 +
300 +
301 +def test_browser_playwright_cache_migrates_valid_retired_usr_cache(monkeypatch, tmp_path):
302 + _patch_playwright_cache_root(monkeypatch, tmp_path)
303 + retired_cache = tmp_path / "usr" / "plugins" / "_browser" / "playwright"
304 + _write_playwright_binary(retired_cache)
305 +
306 + result = browser_hooks_module.cleanup_playwright_cache()
307 +
308 + assert result["errors"] == []
309 + assert result["migrated"] == str(retired_cache)
310 + assert get_playwright_binary() == (
311 + tmp_path / "tmp" / "playwright" / "chromium-1169" / "chrome-linux" / "chrome"
312 )
293 - legacy_binary = (
294 - tmp_path
295 - / "tmp"
296 - / "playwright"
297 - / "chromium-1169"
298 - / "chrome-linux"
299 - / "chrome"
313 + assert not retired_cache.exists()
314 +
315 +
316 +def test_browser_playwright_cache_removes_retired_usr_cache_when_tmp_valid(
317 + monkeypatch, tmp_path
318 +):
319 + _patch_playwright_cache_root(monkeypatch, tmp_path)
320 + primary_cache = tmp_path / "tmp" / "playwright"
321 + retired_cache = tmp_path / "usr" / "plugins" / "_browser" / "playwright"
322 + _write_playwright_binary(primary_cache)
323 + _write_playwright_binary(retired_cache)
324 +
325 + result = browser_hooks_module.cleanup_playwright_cache()
326 +
327 + assert result["errors"] == []
328 + assert result["migrated"] == ""
329 + assert str(retired_cache) in result["removed"]
330 + assert get_playwright_binary() == (
331 + primary_cache / "chromium-1169" / "chrome-linux" / "chrome"
332 )
301 - legacy_binary.parent.mkdir(parents=True)
302 - legacy_binary.write_text("#!/bin/sh\n", encoding="utf-8")
333 + assert not retired_cache.exists()
334 +
335 +
336 +def test_browser_playwright_cache_missing_dirs_do_not_raise(monkeypatch, tmp_path):
337 + _patch_playwright_cache_root(monkeypatch, tmp_path)
338 +
339 + result = browser_hooks_module.cleanup_playwright_cache()
340
341 + assert result["errors"] == []
342 + assert result["migrated"] == ""
343 + assert result["removed"] == []
344 + assert not (tmp_path / "tmp" / "playwright").exists()
345 assert browser_playwright_module.get_playwright_cache_dirs() == [
346 + tmp_path / "tmp" / "playwright",
347 tmp_path / "usr" / "plugins" / "_browser" / "playwright",
348 tmp_path / "usr" / "browser" / "playwright",
307 - tmp_path / "tmp" / "playwright",
349 ]
309 - assert get_playwright_binary() == legacy_binary
350 + assert get_playwright_binary() is None
351
352
353 def test_browser_extension_storage_uses_plugin_user_path(monkeypatch, tmp_path):
@@ -1308,16 +1349,32 @@ async def test_browser_screencast_passes_wrong_viewport_frames_to_frontend_valid
1349 await screencast.stop()
1350
1351
1311 -def test_browser_docker_installs_full_chromium_to_persistent_cache():
1352 +def test_browser_docker_installs_full_chromium_to_tmp_cache():
1353 script = (
1354 PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_playwright.sh"
1355 ).read_text(encoding="utf-8")
1356
1316 - assert "PLAYWRIGHT_BROWSERS_PATH=/a0/usr/plugins/_browser/playwright" in script
1357 + assert "PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright" in script
1358 assert "playwright install chromium" in script
1359 assert "--only-shell" not in script
1360
1361
1362 +def test_browser_startup_migration_runs_playwright_cache_cleanup():
1363 + extension = (
1364 + PROJECT_ROOT
1365 + / "plugins"
1366 + / "_browser"
1367 + / "extensions"
1368 + / "python"
1369 + / "startup_migration"
1370 + / "_20_browser_playwright_cache.py"
1371 + ).read_text(encoding="utf-8")
1372 +
1373 + assert "class BrowserPlaywrightCacheMigration(Extension)" in extension
1374 + assert "hooks.cleanup_playwright_cache()" in extension
1375 + assert "PrintStyle.warning" in extension
1376 +
1377 +
1378 def test_browser_runtime_removes_stale_profile_singletons(monkeypatch, tmp_path):
1379 monkeypatch.setattr(
1380 browser_runtime_module.files,
tests/test_office_canvas_setup.py
+4 -2
@@ -199,10 +199,12 @@ def test_plugin_owned_runtime_state_paths_are_declared():
199
200 assert 'PLUGIN_NAME = "_office"' in office_documents
201 assert 'STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME, "documents"))' in office_documents
202 - assert 'PLAYWRIGHT_CACHE_DIR = ("usr", "plugins", "_browser", "playwright")' in browser_playwright
202 + assert 'PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright")' in browser_playwright
203 + assert '"usr", "plugins", "_browser", "playwright"' in browser_playwright
204 assert "Path(files.get_abs_path(*PLAYWRIGHT_CACHE_DIR))" in browser_playwright
205 + assert "find_playwright_binary(_primary_cache_dir())" in browser_playwright
206 assert "Path(files.get_abs_path(*EXTENSIONS_ROOT_DIR))" in browser_extensions
205 - assert "PLAYWRIGHT_BROWSERS_PATH=/a0/usr/plugins/_browser/playwright" in docker_playwright
207 + assert "PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright" in docker_playwright
208
209
210 def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests():