Use persistent full Chromium runtime for Browser

- Always launch Browser with full Playwright Chromium instead of switching between headless shell and extension mode - Cache Chromium under /a0/usr/plugins/_browser/playwright with legacy lookup for existing installs - Store installed Browser extensions under /a0/usr/plugins/_browser/extensions with legacy extension-root compatibility - Show clearer first-run Chromium install messaging and extend the initial Browser timeout - Fix Browser spinner animation for startup and extension install states - Update Docker Playwright install script and regression coverage

Alessandro committed Apr 24, 2026 at 19:08 UTC fa7eef1919901093b117a98ad6e402d809687cf6
9 files changed +230 -58
docker/run/fs/ins/install_playwright.sh
+4 -3
@@ -7,9 +7,10 @@ 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 /a0/tmp/playwright
11 -export PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright
10 +# set PW installation path to persistent Browser plugin user storage
11 +export PLAYWRIGHT_BROWSERS_PATH=/a0/usr/plugins/_browser/playwright
12 +mkdir -p "$PLAYWRIGHT_BROWSERS_PATH"
13
14 # install chromium with dependencies
15 apt-get install -y fonts-unifont libnss3 libnspr4 libatk1.0-0 libatspi2.0-0 libxcomposite1 libxdamage1 libatk-bridge2.0-0 libcups2
15 -playwright install chromium --only-shell
16 +playwright install chromium
plugins/_browser/api/status.py
+7 -3
@@ -1,6 +1,10 @@
1 from helpers.api import ApiHandler, Request
2 from plugins._browser.helpers.config import build_browser_launch_config, get_browser_config
3 -from plugins._browser.helpers.playwright import get_playwright_binary, get_playwright_cache_dir
3 +from plugins._browser.helpers.playwright import (
4 + get_playwright_binary,
5 + get_playwright_cache_dir,
6 + get_playwright_cache_dirs,
7 +)
8 from plugins._browser.helpers.runtime import known_context_ids
9
10
@@ -11,15 +15,15 @@ class Status(ApiHandler):
15 runtime_binary = get_playwright_binary(
16 full_browser=launch_config["requires_full_browser"]
17 )
14 - shell_binary = get_playwright_binary(full_browser=False)
18 chromium_binary = get_playwright_binary(full_browser=True)
19 return {
20 "plugin": "_browser",
21 "playwright": {
22 "cache_dir": get_playwright_cache_dir(),
23 + "cache_dirs": [str(path) for path in get_playwright_cache_dirs()],
24 "binary_found": bool(runtime_binary),
25 + "install_required": not bool(runtime_binary),
26 "binary_path": str(runtime_binary) if runtime_binary else "",
22 - "headless_shell_binary_path": str(shell_binary) if shell_binary else "",
27 "chromium_binary_path": str(chromium_binary) if chromium_binary else "",
28 "launch_mode": launch_config["browser_mode"],
29 },
plugins/_browser/helpers/config.py
+2 -6
@@ -248,7 +248,7 @@ def build_browser_launch_config(settings: dict[str, Any] | None) -> dict[str, An
248 extensions = describe_browser_extensions(settings)
249 args = list(BASE_BROWSER_ARGS)
250 channel: str | None = None
251 - browser_mode = "headless_shell"
251 + browser_mode = "chromium"
252
253 if extensions["active"]:
254 joined_paths = ",".join(extensions["active_paths"])
@@ -258,15 +258,11 @@ def build_browser_launch_config(settings: dict[str, Any] | None) -> dict[str, An
258 f"--load-extension={joined_paths}",
259 ]
260 )
261 - channel = "chromium"
262 - browser_mode = "chromium_extensions"
263 - else:
264 - args.insert(0, "--headless=new")
261
262 return {
263 "args": args,
264 "browser_mode": browser_mode,
265 "channel": channel,
266 "extensions": extensions,
271 - "requires_full_browser": bool(extensions["active"]),
267 + "requires_full_browser": True,
268 }
plugins/_browser/helpers/extension_manager.py
+37 -17
@@ -16,6 +16,8 @@ from helpers import files, plugins
16 from plugins._browser.helpers.config import PLUGIN_NAME, get_browser_config
17
18
19 +EXTENSIONS_ROOT_DIR = ("usr", "plugins", PLUGIN_NAME, "extensions")
20 +LEGACY_EXTENSIONS_ROOT_DIR = ("usr", "browser-extensions")
21 EXTENSION_ID_RE = re.compile(r"^[a-p]{32}$")
22 WEB_STORE_ID_RE = re.compile(r"(?<![a-p])([a-p]{32})(?![a-p])")
23 CHROME_VERSION_RE = re.compile(r"(\d+(?:\.\d+){0,3})")
@@ -36,11 +38,27 @@ WEB_STORE_DOWNLOAD_URL = (
38
39
40 def get_extensions_root() -> Path:
39 - root = Path(files.get_abs_path("usr/browser-extensions"))
41 + root = Path(files.get_abs_path(*EXTENSIONS_ROOT_DIR))
42 root.mkdir(parents=True, exist_ok=True)
43 return root
44
45
46 +def get_extension_roots() -> list[Path]:
47 + roots = [
48 + get_extensions_root(),
49 + Path(files.get_abs_path(*LEGACY_EXTENSIONS_ROOT_DIR)),
50 + ]
51 + unique: list[Path] = []
52 + seen: set[str] = set()
53 + for root in roots:
54 + key = str(root)
55 + if key in seen:
56 + continue
57 + seen.add(key)
58 + unique.append(root)
59 + return unique
60 +
61 +
62 def parse_chrome_web_store_extension_id(value: str) -> str:
63 source = str(value or "").strip()
64 if EXTENSION_ID_RE.fullmatch(source):
@@ -54,26 +72,28 @@ def parse_chrome_web_store_extension_id(value: str) -> str:
72
73
74 def list_browser_extensions() -> list[dict[str, Any]]:
57 - root = get_extensions_root()
75 config = get_browser_config()
76 enabled_paths = {str(Path(path).expanduser()) for path in config["extension_paths"]}
77 entries: list[dict[str, Any]] = []
78
62 - for manifest_path in sorted(root.glob("**/manifest.json")):
63 - extension_dir = manifest_path.parent
64 - try:
65 - manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
66 - except Exception:
67 - manifest = {}
68 - extension_path = str(extension_dir)
69 - entries.append(
70 - {
71 - "name": manifest.get("name") or extension_dir.name,
72 - "version": manifest.get("version") or "",
73 - "path": extension_path,
74 - "enabled": extension_path in enabled_paths,
75 - }
76 - )
79 + for root in get_extension_roots():
80 + if not root.exists():
81 + continue
82 + for manifest_path in sorted(root.glob("**/manifest.json")):
83 + extension_dir = manifest_path.parent
84 + try:
85 + manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
86 + except Exception:
87 + manifest = {}
88 + extension_path = str(extension_dir)
89 + entries.append(
90 + {
91 + "name": manifest.get("name") or extension_dir.name,
92 + "version": manifest.get("version") or "",
93 + "path": extension_path,
94 + "enabled": extension_path in enabled_paths,
95 + }
96 + )
97
98 return entries
99
plugins/_browser/helpers/playwright.py
+36 -14
@@ -4,34 +4,58 @@ from pathlib import Path
4
5 from helpers import files
6
7 -HEADLESS_SHELL_PATTERNS = (
8 - "chromium_headless_shell-*/chrome-*/headless_shell",
9 - "chromium_headless_shell-*/chrome-*/headless_shell.exe",
10 -)
11 -
7 FULL_CHROMIUM_PATTERNS = (
8 "chromium-*/chrome-linux/chrome",
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")
15 +
16 +
17 +def _primary_cache_dir() -> Path:
18 + override = os.environ.get(PLAYWRIGHT_CACHE_ENV, "").strip()
19 + if override:
20 + return Path(override).expanduser()
21 + return Path(files.get_abs_path(*PLAYWRIGHT_CACHE_DIR))
22
23
24 def get_playwright_cache_dir() -> str:
19 - return files.get_abs_path("tmp/playwright")
25 + return str(_primary_cache_dir())
26 +
27 +
28 +def get_playwright_cache_dirs() -> list[Path]:
29 + 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 + ]
35 + seen: set[str] = set()
36 + unique: list[Path] = []
37 + for candidate in candidates:
38 + key = str(candidate)
39 + if key in seen:
40 + continue
41 + seen.add(key)
42 + unique.append(candidate)
43 + return unique
44
45
46 def configure_playwright_env() -> str:
47 cache_dir = get_playwright_cache_dir()
48 + Path(cache_dir).mkdir(parents=True, exist_ok=True)
49 os.environ["PLAYWRIGHT_BROWSERS_PATH"] = cache_dir
50 return cache_dir
51
52
53 def get_playwright_binary(*, full_browser: bool = False) -> Path | None:
29 - cache_dir = Path(get_playwright_cache_dir())
30 - patterns = FULL_CHROMIUM_PATTERNS if full_browser else (HEADLESS_SHELL_PATTERNS + FULL_CHROMIUM_PATTERNS)
31 - for pattern in patterns:
32 - binary = next(cache_dir.glob(pattern), None)
33 - if binary and binary.exists():
34 - return binary
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
59 return None
60
61
@@ -44,8 +68,6 @@ def ensure_playwright_binary(*, full_browser: bool = False) -> Path:
68 env = os.environ.copy()
69 env["PLAYWRIGHT_BROWSERS_PATH"] = cache_dir
70 install_command = ["playwright", "install", "chromium"]
47 - if not full_browser:
48 - install_command.append("--only-shell")
71 subprocess.check_call(
72 install_command,
73 env=env,
plugins/_browser/webui/browser-store.js
+18 -2
@@ -8,8 +8,9 @@ import { store as pluginSettingsStore } from "/components/plugins/plugin-setting
8 const websocket = getNamespacedClient("/ws");
9 websocket.addHandlers(["ws_webui"]);
10
11 -const EXTENSIONS_ROOT_FALLBACK = "/a0/usr/browser-extensions";
11 +const EXTENSIONS_ROOT_FALLBACK = "/a0/usr/plugins/_browser/extensions";
12 const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;
13 +const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
14
15 function firstOk(response) {
16 const result = response?.results?.find((item) => item?.ok);
@@ -52,9 +53,11 @@ const model = {
53 extensionActionError: "",
54 extensionsRoot: "",
55 extensionsList: [],
56 + browserInstallExpected: false,
57
58 async refreshStatus() {
59 this.status = await callJsonApi("/plugins/_browser/status", {});
60 + this.browserInstallExpected = Boolean(this.status?.playwright?.install_required);
61 },
62
63 async refreshExtensionsList() {
@@ -216,12 +219,17 @@ const model = {
219 context_id: this.contextId,
220 browser_id: this.activeBrowserId,
221 },
219 - { timeoutMs: BROWSER_SUBSCRIBE_TIMEOUT_MS },
222 + {
223 + timeoutMs: this.browserInstallExpected
224 + ? BROWSER_FIRST_INSTALL_TIMEOUT_MS
225 + : BROWSER_SUBSCRIBE_TIMEOUT_MS,
226 + },
227 );
228 const data = firstOk(response);
229 this.browsers = data.browsers || [];
230 this.setActiveBrowserId(data.active_browser_id || this.activeBrowserId || null);
231 this.connected = true;
232 + this.browserInstallExpected = false;
233 this.queueViewportSync(true);
234 },
235
@@ -592,6 +600,14 @@ const model = {
600 get activeUrl() {
601 return this.frameState?.currentUrl || this.address || "about:blank";
602 },
603 +
604 + loadingMessage() {
605 + if (this.browserInstallExpected) {
606 + const cacheDir = this.status?.playwright?.cache_dir || "/a0/usr/plugins/_browser/playwright";
607 + return `Installing Chromium for the first Browser run. This can take a few minutes; future starts reuse ${cacheDir}.`;
608 + }
609 + return "Connecting browser...";
610 + },
611 };
612
613 export const store = createStore("browserPage", model);
plugins/_browser/webui/config.html
+18 -7
@@ -64,8 +64,8 @@
64 <div class="section-title">Chrome Extensions</div>
65 <div class="section-description">
66 Load unpacked Chromium extensions into the Browser tool. When extensions are active,
67 - Browser switches from Playwright's lightweight headless shell to bundled Chromium so
68 - the extensions can actually load.
67 + Browser loads them into the same persistent full Chromium runtime used for every
68 + Browser session.
69 </div>
70
71 <div class="browser-config-warning">
@@ -106,7 +106,7 @@
106 :value="$store.browserConfig.extensionPathsText"
107 @input="$store.browserConfig.setExtensionPathsText($event.target.value)"
108 rows="6"
109 - placeholder="/a0/usr/browser-extensions/my-extension"
109 + placeholder="/a0/usr/plugins/_browser/extensions/my-extension"
110 ></textarea>
111 </div>
112 </div>
@@ -115,16 +115,15 @@
115 <span class="material-symbols-outlined">info</span>
116 <span>
117 Chrome Web Store URL installs are converted into unpacked folders under
118 - /a0/usr/browser-extensions/chrome-web-store, then loaded from the directory list above.
118 + /a0/usr/plugins/_browser/extensions/chrome-web-store, then loaded from the directory list above.
119 </span>
120 </div>
121
122 <div class="browser-config-note">
123 <span class="material-symbols-outlined">deployed_code</span>
124 <span>
125 - Playwright currently requires a persistent Chromium context for extension loading, so
126 - Browser stays in its faster headless-shell mode until valid extension folders are both
127 - configured and enabled.
125 + Browser caches Playwright Chromium under /a0/usr/plugins/_browser/playwright. The first Browser
126 + run can take a few minutes while Chromium is installed; later starts reuse that cache.
127 </span>
128 </div>
129
@@ -196,6 +195,18 @@
195 font-size: 20px;
196 }
197
198 + .browser-config-sections .spinning {
199 + display: inline-block;
200 + transform-origin: center;
201 + animation: browser-config-spin 0.8s linear infinite;
202 + }
203 +
204 + @keyframes browser-config-spin {
205 + to {
206 + transform: rotate(360deg);
207 + }
208 + }
209 +
210 .browser-config-pill-row {
211 display: flex;
212 flex-wrap: wrap;
plugins/_browser/webui/main.html
+19 -2
@@ -79,7 +79,11 @@
79 @click="$store.browserPage.installExtensionFromUrl()"
80 :disabled="$store.browserPage.extensionActionLoading"
81 >
82 - <span class="material-symbols-outlined" x-text="$store.browserPage.extensionActionLoading ? 'progress_activity' : 'download'"></span>
82 + <span
83 + class="material-symbols-outlined"
84 + :class="{ spinning: $store.browserPage.extensionActionLoading }"
85 + x-text="$store.browserPage.extensionActionLoading ? 'progress_activity' : 'download'"
86 + ></span>
87 <span>Install URL</span>
88 </button>
89 <button type="button" class="btn btn-field" @click="$store.browserPage.askAgentInstallExtension()">
@@ -135,7 +139,7 @@
139
140 <div class="browser-status" x-show="$store.browserPage.loading">
141 <span class="material-symbols-outlined spinning">progress_activity</span>
138 - <span>Connecting browser...</span>
142 + <span x-text="$store.browserPage.loadingMessage()">Connecting browser...</span>
143 </div>
144 <div class="browser-error" x-show="$store.browserPage.error" x-text="$store.browserPage.error"></div>
145
@@ -509,6 +513,19 @@
513 padding: 0 12px;
514 }
515
516 + .browser-modal .spinning,
517 + .browser-panel .spinning {
518 + display: inline-block;
519 + transform-origin: center;
520 + animation: browser-spin 0.8s linear infinite;
521 + }
522 +
523 + @keyframes browser-spin {
524 + to {
525 + transform: rotate(360deg);
526 + }
527 + }
528 +
529 .browser-error {
530 color: #9f1239;
531 }
tests/test_browser_agent_regressions.py
+89 -4
@@ -21,10 +21,18 @@ from plugins._browser.helpers.extension_manager import (
21 _crx_zip_payload,
22 _detect_chrome_prodversion,
23 _normalize_chrome_prodversion,
24 + get_extension_roots,
25 + get_extensions_root,
26 parse_chrome_web_store_extension_id,
27 )
28 +import plugins._browser.helpers.extension_manager as browser_extension_manager_module
29 from plugins._browser.helpers.runtime import _BrowserRuntimeCore, normalize_url
30 import plugins._browser.helpers.runtime as browser_runtime_module
31 +from plugins._browser.helpers.playwright import (
32 + get_playwright_binary,
33 + get_playwright_cache_dir,
34 +)
35 +import plugins._browser.helpers.playwright as browser_playwright_module
36 import plugins._browser.hooks as browser_hooks_module
37 import plugins._browser.tools.browser as browser_tool_module
38 import plugins._browser.api.ws_browser as ws_browser_module
@@ -117,7 +125,20 @@ def test_browser_model_preset_options_include_missing_selected(monkeypatch):
125 assert options[-1]["missing"] is True
126
127
120 -def test_browser_launch_config_switches_to_chromium_for_extensions(tmp_path):
128 +def test_browser_launch_config_uses_full_chromium_for_all_sessions(tmp_path):
129 + default_launch = build_browser_launch_config(
130 + {
131 + "extensions_enabled": False,
132 + "extension_paths": [],
133 + }
134 + )
135 +
136 + assert default_launch["browser_mode"] == "chromium"
137 + assert default_launch["channel"] is None
138 + assert default_launch["requires_full_browser"] is True
139 + assert not any(arg.startswith("--load-extension=") for arg in default_launch["args"])
140 + assert "--headless=new" not in default_launch["args"]
141 +
142 extension_dir = tmp_path / "extension"
143 extension_dir.mkdir()
144
@@ -128,14 +149,52 @@ def test_browser_launch_config_switches_to_chromium_for_extensions(tmp_path):
149 }
150 )
151
131 - assert launch["browser_mode"] == "chromium_extensions"
132 - assert launch["channel"] == "chromium"
152 + assert launch["browser_mode"] == "chromium"
153 + assert launch["channel"] is None
154 assert launch["requires_full_browser"] is True
155 assert launch["extensions"]["active"] is True
156 assert any(arg.startswith("--load-extension=") for arg in launch["args"])
157 assert "--headless=new" not in launch["args"]
158
159
160 +def test_browser_playwright_cache_uses_persistent_usr_path(monkeypatch, tmp_path):
161 + monkeypatch.delenv("A0_BROWSER_PLAYWRIGHT_CACHE_DIR", raising=False)
162 + monkeypatch.setattr(
163 + browser_playwright_module.files,
164 + "get_abs_path",
165 + lambda *parts: str(tmp_path.joinpath(*parts)),
166 + )
167 + legacy_binary = (
168 + tmp_path
169 + / "tmp"
170 + / "playwright"
171 + / "chromium-1169"
172 + / "chrome-linux"
173 + / "chrome"
174 + )
175 + legacy_binary.parent.mkdir(parents=True)
176 + legacy_binary.write_text("#!/bin/sh\n", encoding="utf-8")
177 +
178 + assert get_playwright_cache_dir() == str(
179 + tmp_path / "usr" / "plugins" / "_browser" / "playwright"
180 + )
181 + assert get_playwright_binary() == legacy_binary
182 +
183 +
184 +def test_browser_extension_storage_uses_plugin_user_path(monkeypatch, tmp_path):
185 + monkeypatch.setattr(
186 + browser_extension_manager_module.files,
187 + "get_abs_path",
188 + lambda *parts: str(tmp_path.joinpath(*parts)),
189 + )
190 +
191 + assert get_extensions_root() == tmp_path / "usr" / "plugins" / "_browser" / "extensions"
192 + assert get_extension_roots() == [
193 + tmp_path / "usr" / "plugins" / "_browser" / "extensions",
194 + tmp_path / "usr" / "browser-extensions",
195 + ]
196 +
197 +
198 def test_browser_extension_manager_parses_web_store_urls():
199 extension_id = "a" * 32
200
@@ -196,7 +255,33 @@ def test_browser_viewer_allows_slow_extension_startup():
255 )
256
257 assert "const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;" in js
199 - assert "{ timeoutMs: BROWSER_SUBSCRIBE_TIMEOUT_MS }" in js
258 + assert "const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;" in js
259 + assert "? BROWSER_FIRST_INSTALL_TIMEOUT_MS" in js
260 + assert ": BROWSER_SUBSCRIBE_TIMEOUT_MS" in js
261 + assert "Installing Chromium for the first Browser run" in js
262 +
263 +
264 +def test_browser_ui_spinners_have_browser_local_animation():
265 + main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "main.html").read_text(
266 + encoding="utf-8"
267 + )
268 + config_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "config.html").read_text(
269 + encoding="utf-8"
270 + )
271 +
272 + assert ":class=\"{ spinning: $store.browserPage.extensionActionLoading }\"" in main_html
273 + assert "@keyframes browser-spin" in main_html
274 + assert "@keyframes browser-config-spin" in config_html
275 +
276 +
277 +def test_browser_docker_installs_full_chromium_to_persistent_cache():
278 + script = (
279 + PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_playwright.sh"
280 + ).read_text(encoding="utf-8")
281 +
282 + assert "PLAYWRIGHT_BROWSERS_PATH=/a0/usr/plugins/_browser/playwright" in script
283 + assert "playwright install chromium" in script
284 + assert "--only-shell" not in script
285
286
287 def test_browser_runtime_removes_stale_profile_singletons(monkeypatch, tmp_path):