From 8b921a8ded95093083a751d164f19ca8e09fca81 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 7 May 2026 18:43:24 +0200 Subject: [PATCH] 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. --- docker/run/fs/ins/install_playwright.sh | 4 +- docs/guides/troubleshooting.md | 2 +- docs/setup/dev-setup.md | 4 +- plugins/_browser/api/status.py | 6 +- .../_20_browser_playwright_cache.py | 45 +++++++ plugins/_browser/helpers/playwright.py | 39 +++--- plugins/_browser/helpers/runtime.py | 4 +- plugins/_browser/hooks.py | 83 ++++++++++++ plugins/_browser/webui/browser-store.js | 2 +- tests/test_browser_agent_regressions.py | 121 +++++++++++++----- tests/test_office_canvas_setup.py | 6 +- 11 files changed, 252 insertions(+), 64 deletions(-) create mode 100644 plugins/_browser/extensions/python/startup_migration/_20_browser_playwright_cache.py diff --git a/docker/run/fs/ins/install_playwright.sh b/docker/run/fs/ins/install_playwright.sh index 2d89d685c..ca257004a 100644 --- a/docker/run/fs/ins/install_playwright.sh +++ b/docker/run/fs/ins/install_playwright.sh @@ -7,8 +7,8 @@ set -e # install playwright if not installed (should be from requirements.txt) uv pip install playwright -# set PW installation path to persistent Browser plugin user storage -export PLAYWRIGHT_BROWSERS_PATH=/a0/usr/plugins/_browser/playwright +# set PW installation path to temporary Browser runtime storage +export PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright mkdir -p "$PLAYWRIGHT_BROWSERS_PATH" # install chromium with dependencies diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 74dcc3812..133b6eddb 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -27,7 +27,7 @@ Refer to the [Choosing your LLMs](../setup/installation.md#installing-and-using- 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). **8. My browser tool fails or says Playwright is missing. What now?** -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). +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). **9. My secrets disappeared after a backup restore.** Secrets are stored in `/a0/usr/secrets.env` and are not always included in backup archives. Copy them manually. diff --git a/docs/setup/dev-setup.md b/docs/setup/dev-setup.md index dfd9e0043..f7fa6d260 100644 --- a/docs/setup/dev-setup.md +++ b/docs/setup/dev-setup.md @@ -67,9 +67,9 @@ Now when you select one of the python files in the project, you should see prope 3. Install dependencies. Run these two commands in the terminal: ```bash pip install -r requirements.txt -PLAYWRIGHT_BROWSERS_PATH=usr/plugins/_browser/playwright playwright install chromium --only-shell +PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium ``` -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. +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. Errors in the code editor caused by missing packages should now be gone. If not, try reloading the window. diff --git a/plugins/_browser/api/status.py b/plugins/_browser/api/status.py index d39931da5..229c8a84c 100644 --- a/plugins/_browser/api/status.py +++ b/plugins/_browser/api/status.py @@ -12,10 +12,8 @@ class Status(ApiHandler): async def process(self, input: dict, request: Request) -> dict: browser_config = get_browser_config() launch_config = build_browser_launch_config(browser_config) - runtime_binary = get_playwright_binary( - full_browser=launch_config["requires_full_browser"] - ) - chromium_binary = get_playwright_binary(full_browser=True) + runtime_binary = get_playwright_binary() + chromium_binary = runtime_binary return { "plugin": "_browser", "playwright": { diff --git a/plugins/_browser/extensions/python/startup_migration/_20_browser_playwright_cache.py b/plugins/_browser/extensions/python/startup_migration/_20_browser_playwright_cache.py new file mode 100644 index 000000000..c30aa4b57 --- /dev/null +++ b/plugins/_browser/extensions/python/startup_migration/_20_browser_playwright_cache.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import threading +from typing import Any + +from helpers.extension import Extension +from helpers.print_style import PrintStyle +from plugins._browser import hooks + + +_startup_migration_thread: threading.Thread | None = None + + +class BrowserPlaywrightCacheMigration(Extension): + def execute(self, **kwargs): + _start_background_cache_migration() + + +def _start_background_cache_migration() -> threading.Thread: + global _startup_migration_thread + + if _startup_migration_thread and _startup_migration_thread.is_alive(): + return _startup_migration_thread + + _startup_migration_thread = threading.Thread( + target=_migrate_cache_safely, + name="a0-browser-playwright-cache-migration", + daemon=True, + ) + _startup_migration_thread.start() + return _startup_migration_thread + + +def _migrate_cache_safely() -> None: + try: + _log_cache_migration_result(hooks.cleanup_playwright_cache()) + except Exception as exc: + PrintStyle.warning("Browser Playwright cache migration failed:", exc) + + +def _log_cache_migration_result(result: dict[str, Any]) -> None: + if result.get("errors"): + PrintStyle.warning("Browser Playwright cache migration reported errors:", result["errors"]) + elif result.get("migrated") or result.get("removed"): + PrintStyle.info("Browser Playwright cache prepared:", result) diff --git a/plugins/_browser/helpers/playwright.py b/plugins/_browser/helpers/playwright.py index a0d6bce07..f1c91014f 100644 --- a/plugins/_browser/helpers/playwright.py +++ b/plugins/_browser/helpers/playwright.py @@ -9,9 +9,11 @@ FULL_CHROMIUM_PATTERNS = ( "chromium-*/chrome-win/chrome.exe", ) PLAYWRIGHT_CACHE_ENV = "A0_BROWSER_PLAYWRIGHT_CACHE_DIR" -PLAYWRIGHT_CACHE_DIR = ("usr", "plugins", "_browser", "playwright") -PREVIOUS_PLAYWRIGHT_CACHE_DIR = ("usr", "browser", "playwright") -LEGACY_PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright") +PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright") +RETIRED_PLAYWRIGHT_CACHE_DIRS = ( + ("usr", "plugins", "_browser", "playwright"), + ("usr", "browser", "playwright"), +) def _primary_cache_dir() -> Path: @@ -27,11 +29,7 @@ def get_playwright_cache_dir() -> str: def get_playwright_cache_dirs() -> list[Path]: primary = _primary_cache_dir() - candidates = [ - primary, - Path(files.get_abs_path(*PREVIOUS_PLAYWRIGHT_CACHE_DIR)), - Path(files.get_abs_path(*LEGACY_PLAYWRIGHT_CACHE_DIR)), - ] + candidates = [primary, *get_retired_playwright_cache_dirs()] seen: set[str] = set() unique: list[Path] = [] for candidate in candidates: @@ -43,6 +41,10 @@ def get_playwright_cache_dirs() -> list[Path]: return unique +def get_retired_playwright_cache_dirs() -> list[Path]: + return [Path(files.get_abs_path(*parts)) for parts in RETIRED_PLAYWRIGHT_CACHE_DIRS] + + def configure_playwright_env() -> str: cache_dir = get_playwright_cache_dir() Path(cache_dir).mkdir(parents=True, exist_ok=True) @@ -50,17 +52,20 @@ def configure_playwright_env() -> str: return cache_dir -def get_playwright_binary(*, full_browser: bool = False) -> Path | None: - for cache_dir in get_playwright_cache_dirs(): - for pattern in FULL_CHROMIUM_PATTERNS: - binary = next(cache_dir.glob(pattern), None) - if binary and binary.exists(): - return binary +def find_playwright_binary(cache_dir: Path) -> Path | None: + for pattern in FULL_CHROMIUM_PATTERNS: + binary = next(cache_dir.glob(pattern), None) + if binary and binary.exists(): + return binary return None -def ensure_playwright_binary(*, full_browser: bool = False) -> Path: - binary = get_playwright_binary(full_browser=full_browser) +def get_playwright_binary() -> Path | None: + return find_playwright_binary(_primary_cache_dir()) + + +def ensure_playwright_binary() -> Path: + binary = get_playwright_binary() if binary: return binary @@ -73,7 +78,7 @@ def ensure_playwright_binary(*, full_browser: bool = False) -> Path: env=env, ) - binary = get_playwright_binary(full_browser=full_browser) + binary = get_playwright_binary() if not binary: raise RuntimeError("Playwright Chromium binary not found after installation") return binary diff --git a/plugins/_browser/helpers/runtime.py b/plugins/_browser/helpers/runtime.py index 6d443bf4b..b00743e8a 100644 --- a/plugins/_browser/helpers/runtime.py +++ b/plugins/_browser/helpers/runtime.py @@ -783,9 +783,7 @@ class _BrowserRuntimeCore: browser_config = get_browser_config() launch_config = build_browser_launch_config(browser_config) configure_playwright_env() - browser_binary = ensure_playwright_binary( - full_browser=launch_config["requires_full_browser"] - ) + browser_binary = ensure_playwright_binary() self.playwright = await async_playwright().start() launch_kwargs: dict[str, Any] = { diff --git a/plugins/_browser/hooks.py b/plugins/_browser/hooks.py index dc4c54208..257917f2b 100644 --- a/plugins/_browser/hooks.py +++ b/plugins/_browser/hooks.py @@ -1,11 +1,19 @@ from __future__ import annotations +import shutil +from pathlib import Path + from helpers import files, plugins, yaml as yaml_helper from plugins._browser.helpers.config import ( PLUGIN_NAME, browser_runtime_config, normalize_browser_config, ) +from plugins._browser.helpers.playwright import ( + find_playwright_binary, + get_playwright_cache_dir, + get_retired_playwright_cache_dirs, +) from plugins._browser.helpers.runtime import close_all_runtimes_sync @@ -45,3 +53,78 @@ def save_plugin_config(settings=None, project_name="", agent_profile="", **kwarg if browser_runtime_config(normalized) != browser_runtime_config(current): close_all_runtimes_sync() return normalized + + +def cleanup_playwright_cache() -> dict: + primary = Path(get_playwright_cache_dir()) + retired_dirs = [ + path for path in get_retired_playwright_cache_dirs() if path.resolve() != primary.resolve() + ] + result = {"primary": str(primary), "migrated": "", "removed": [], "errors": []} + + if find_playwright_binary(primary): + _remove_cache_dirs(retired_dirs, result) + return result + + source = _best_playwright_cache(retired_dirs) + if not source: + return result + + backup = _next_backup_path(primary) if primary.exists() else None + try: + if backup: + primary.rename(backup) + primary.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(primary)) + result["migrated"] = str(source) + except Exception as exc: + if backup and backup.exists() and not primary.exists(): + backup.rename(primary) + result["errors"].append(f"Failed to migrate {source} to {primary}: {exc}") + return result + + if not find_playwright_binary(primary): + result["errors"].append(f"Migrated Playwright cache is not valid: {primary}") + if backup: + result["errors"].append(f"Previous primary Playwright cache retained at {backup}") + return result + + if backup: + _remove_cache_dirs([backup], result) + _remove_cache_dirs(retired_dirs, result) + return result + + +def _best_playwright_cache(candidates: list[Path]) -> Path | None: + valid = [path for path in candidates if path.is_dir() and find_playwright_binary(path)] + if not valid: + return None + + def modified_at(path: Path) -> float: + binary = find_playwright_binary(path) + try: + return binary.stat().st_mtime if binary else path.stat().st_mtime + except OSError: + return 0 + + return max(valid, key=modified_at) + + +def _next_backup_path(path: Path) -> Path: + backup = path.with_name(f"{path.name}.migration-backup") + counter = 2 + while backup.exists(): + backup = path.with_name(f"{path.name}.migration-backup-{counter}") + counter += 1 + return backup + + +def _remove_cache_dirs(paths: list[Path], result: dict) -> None: + for path in paths: + if not path.exists(): + continue + try: + shutil.rmtree(path) + result["removed"].append(str(path)) + except Exception as exc: + result["errors"].append(f"Failed to remove Playwright cache {path}: {exc}") diff --git a/plugins/_browser/webui/browser-store.js b/plugins/_browser/webui/browser-store.js index b7a3c847b..c6d9cbfcd 100644 --- a/plugins/_browser/webui/browser-store.js +++ b/plugins/_browser/webui/browser-store.js @@ -2757,7 +2757,7 @@ const model = { loadingMessage() { if (this.browserInstallExpected) { - const cacheDir = this.status?.playwright?.cache_dir || "/a0/usr/plugins/_browser/playwright"; + const cacheDir = this.status?.playwright?.cache_dir || "/a0/tmp/playwright"; return `Installing Chromium for the first Browser run. This can take a few minutes; future starts reuse ${cacheDir}.`; } return "Loading"; diff --git a/tests/test_browser_agent_regressions.py b/tests/test_browser_agent_regressions.py index deed48aca..0f773df7c 100644 --- a/tests/test_browser_agent_regressions.py +++ b/tests/test_browser_agent_regressions.py @@ -257,56 +257,97 @@ def test_browser_launch_config_uses_full_chromium_for_all_sessions(tmp_path): assert "--headless=new" not in launch["args"] -def test_browser_playwright_cache_uses_persistent_usr_path(monkeypatch, tmp_path): +def _patch_playwright_cache_root(monkeypatch, tmp_path): monkeypatch.delenv("A0_BROWSER_PLAYWRIGHT_CACHE_DIR", raising=False) monkeypatch.setattr( browser_playwright_module.files, "get_abs_path", lambda *parts: str(tmp_path.joinpath(*parts)), ) - browser_binary = ( - tmp_path - / "usr" - / "plugins" - / "_browser" - / "playwright" - / "chromium-1169" - / "chrome-linux" - / "chrome" - ) + + +def _write_playwright_binary(cache_dir: Path) -> Path: + browser_binary = cache_dir / "chromium-1169" / "chrome-linux" / "chrome" browser_binary.parent.mkdir(parents=True) browser_binary.write_text("#!/bin/sh\n", encoding="utf-8") + return browser_binary + + +def test_browser_playwright_cache_uses_tmp_path(monkeypatch, tmp_path): + _patch_playwright_cache_root(monkeypatch, tmp_path) + primary_cache = tmp_path / "tmp" / "playwright" + browser_binary = _write_playwright_binary(primary_cache) assert get_playwright_cache_dir() == str( - tmp_path / "usr" / "plugins" / "_browser" / "playwright" + tmp_path / "tmp" / "playwright" ) + assert browser_playwright_module.get_playwright_cache_dirs() == [ + tmp_path / "tmp" / "playwright", + tmp_path / "usr" / "plugins" / "_browser" / "playwright", + tmp_path / "usr" / "browser" / "playwright", + ] assert get_playwright_binary() == browser_binary -def test_browser_playwright_cache_falls_back_to_existing_legacy_install(monkeypatch, tmp_path): - monkeypatch.delenv("A0_BROWSER_PLAYWRIGHT_CACHE_DIR", raising=False) - monkeypatch.setattr( - browser_playwright_module.files, - "get_abs_path", - lambda *parts: str(tmp_path.joinpath(*parts)), +def test_browser_playwright_binary_ignores_retired_usr_cache(monkeypatch, tmp_path): + _patch_playwright_cache_root(monkeypatch, tmp_path) + _write_playwright_binary( + tmp_path / "usr" / "plugins" / "_browser" / "playwright" ) - legacy_binary = ( - tmp_path - / "tmp" - / "playwright" - / "chromium-1169" - / "chrome-linux" - / "chrome" - ) - legacy_binary.parent.mkdir(parents=True) - legacy_binary.write_text("#!/bin/sh\n", encoding="utf-8") + assert get_playwright_binary() is None + + +def test_browser_playwright_cache_migrates_valid_retired_usr_cache(monkeypatch, tmp_path): + _patch_playwright_cache_root(monkeypatch, tmp_path) + retired_cache = tmp_path / "usr" / "plugins" / "_browser" / "playwright" + _write_playwright_binary(retired_cache) + + result = browser_hooks_module.cleanup_playwright_cache() + + assert result["errors"] == [] + assert result["migrated"] == str(retired_cache) + assert get_playwright_binary() == ( + tmp_path / "tmp" / "playwright" / "chromium-1169" / "chrome-linux" / "chrome" + ) + assert not retired_cache.exists() + + +def test_browser_playwright_cache_removes_retired_usr_cache_when_tmp_valid( + monkeypatch, tmp_path +): + _patch_playwright_cache_root(monkeypatch, tmp_path) + primary_cache = tmp_path / "tmp" / "playwright" + retired_cache = tmp_path / "usr" / "plugins" / "_browser" / "playwright" + _write_playwright_binary(primary_cache) + _write_playwright_binary(retired_cache) + + result = browser_hooks_module.cleanup_playwright_cache() + + assert result["errors"] == [] + assert result["migrated"] == "" + assert str(retired_cache) in result["removed"] + assert get_playwright_binary() == ( + primary_cache / "chromium-1169" / "chrome-linux" / "chrome" + ) + assert not retired_cache.exists() + + +def test_browser_playwright_cache_missing_dirs_do_not_raise(monkeypatch, tmp_path): + _patch_playwright_cache_root(monkeypatch, tmp_path) + + result = browser_hooks_module.cleanup_playwright_cache() + + assert result["errors"] == [] + assert result["migrated"] == "" + assert result["removed"] == [] + assert not (tmp_path / "tmp" / "playwright").exists() assert browser_playwright_module.get_playwright_cache_dirs() == [ + tmp_path / "tmp" / "playwright", tmp_path / "usr" / "plugins" / "_browser" / "playwright", tmp_path / "usr" / "browser" / "playwright", - tmp_path / "tmp" / "playwright", ] - assert get_playwright_binary() == legacy_binary + assert get_playwright_binary() is None 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 await screencast.stop() -def test_browser_docker_installs_full_chromium_to_persistent_cache(): +def test_browser_docker_installs_full_chromium_to_tmp_cache(): script = ( PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_playwright.sh" ).read_text(encoding="utf-8") - assert "PLAYWRIGHT_BROWSERS_PATH=/a0/usr/plugins/_browser/playwright" in script + assert "PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright" in script assert "playwright install chromium" in script assert "--only-shell" not in script +def test_browser_startup_migration_runs_playwright_cache_cleanup(): + extension = ( + PROJECT_ROOT + / "plugins" + / "_browser" + / "extensions" + / "python" + / "startup_migration" + / "_20_browser_playwright_cache.py" + ).read_text(encoding="utf-8") + + assert "class BrowserPlaywrightCacheMigration(Extension)" in extension + assert "hooks.cleanup_playwright_cache()" in extension + assert "PrintStyle.warning" in extension + + def test_browser_runtime_removes_stale_profile_singletons(monkeypatch, tmp_path): monkeypatch.setattr( browser_runtime_module.files, diff --git a/tests/test_office_canvas_setup.py b/tests/test_office_canvas_setup.py index 984262c3c..d676a67f1 100644 --- a/tests/test_office_canvas_setup.py +++ b/tests/test_office_canvas_setup.py @@ -199,10 +199,12 @@ def test_plugin_owned_runtime_state_paths_are_declared(): assert 'PLUGIN_NAME = "_office"' in office_documents assert 'STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME, "documents"))' in office_documents - assert 'PLAYWRIGHT_CACHE_DIR = ("usr", "plugins", "_browser", "playwright")' in browser_playwright + assert 'PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright")' in browser_playwright + assert '"usr", "plugins", "_browser", "playwright"' in browser_playwright assert "Path(files.get_abs_path(*PLAYWRIGHT_CACHE_DIR))" in browser_playwright + assert "find_playwright_binary(_primary_cache_dir())" in browser_playwright assert "Path(files.get_abs_path(*EXTENSIONS_ROOT_DIR))" in browser_extensions - assert "PLAYWRIGHT_BROWSERS_PATH=/a0/usr/plugins/_browser/playwright" in docker_playwright + assert "PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright" in docker_playwright def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests():