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):