Fix Chrome extension install and Browser startup with extensions
- Download Chrome Web Store extensions using the detected Chrome prodversion instead of a stale hardcoded version - Update extension settings copy to reflect Chrome Web Store URL support - Serialize Browser persistent-context startup and clean stale Chromium profile singleton locks - Increase Browser viewer subscribe timeout for extension-enabled cold starts - Add regressions for Web Store download URL handling, slow viewer startup, and stale profile lock cleanup
Alessandro committed
Apr 24, 2026 at 18:12 UTC
fb98c2f89a10ad9d66d03e42c18d446efe9ea95c
5 files changed
+212
-12
plugins/_browser/helpers/extension_manager.py
+72
-5
@@ -1,9 +1,12 @@
1
from __future__ import annotations
2
3
import json
4
+import os
5
import re
6
import shutil
7
+import subprocess
8
import tempfile
9
+import urllib.error
10
import urllib.request
11
import zipfile
12
from pathlib import Path
@@ -15,10 +18,18 @@ from plugins._browser.helpers.config import PLUGIN_NAME, get_browser_config
18
19
EXTENSION_ID_RE = re.compile(r"^[a-p]{32}$")
20
WEB_STORE_ID_RE = re.compile(r"(?<![a-p])([a-p]{32})(?![a-p])")
21
+CHROME_VERSION_RE = re.compile(r"(\d+(?:\.\d+){0,3})")
22
+DEFAULT_CHROME_PRODVERSION = "140.0.0.0"
23
+CHROME_VERSION_COMMANDS = (
24
+ ("google-chrome", "--version"),
25
+ ("chromium", "--version"),
26
+ ("chromium-browser", "--version"),
27
+)
28
WEB_STORE_DOWNLOAD_URL = (
29
"https://clients2.google.com/service/update2/crx"
30
"?response=redirect"
21
- "&prodversion=120.0.0.0"
31
+ "&prod=chromecrx"
32
+ "&prodversion={prodversion}"
33
"&acceptformat=crx2,crx3"
34
"&x=id%3D{extension_id}%26installsource%3Dondemand%26uc"
35
)
@@ -101,23 +112,79 @@ def install_chrome_web_store_extension(source: str) -> dict[str, Any]:
112
113
114
def _download_crx(extension_id: str, archive_path: Path) -> None:
104
- url = WEB_STORE_DOWNLOAD_URL.format(extension_id=extension_id)
115
+ prodversion = _detect_chrome_prodversion()
116
+ url = _build_web_store_download_url(extension_id, prodversion=prodversion)
117
request = urllib.request.Request(
118
url,
119
headers={
120
"User-Agent": (
121
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
110
- "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
122
+ f"(KHTML, like Gecko) Chrome/{prodversion} Safari/537.36"
123
)
124
},
125
)
114
- with urllib.request.urlopen(request, timeout=30) as response:
126
+ try:
127
+ response = urllib.request.urlopen(request, timeout=120)
128
+ except urllib.error.HTTPError as exc:
129
+ raise ValueError(
130
+ f"Chrome Web Store download failed with HTTP {exc.code} for Chrome {prodversion}."
131
+ ) from exc
132
+ except urllib.error.URLError as exc:
133
+ reason = getattr(exc, "reason", exc)
134
+ raise ValueError(f"Chrome Web Store download failed: {reason}.") from exc
135
+
136
+ with response:
137
+ status = response.getcode()
138
data = response.read()
139
if not data:
117
- raise ValueError("Chrome Web Store returned an empty extension package.")
140
+ raise ValueError(
141
+ "Chrome Web Store did not return an extension package "
142
+ f"(HTTP {status}, Chrome {prodversion})."
143
+ )
144
archive_path.write_bytes(data)
145
146
147
+def _build_web_store_download_url(extension_id: str, *, prodversion: str | None = None) -> str:
148
+ return WEB_STORE_DOWNLOAD_URL.format(
149
+ extension_id=extension_id,
150
+ prodversion=_normalize_chrome_prodversion(prodversion or "") or DEFAULT_CHROME_PRODVERSION,
151
+ )
152
+
153
+
154
+def _detect_chrome_prodversion() -> str:
155
+ env_version = _normalize_chrome_prodversion(os.environ.get("A0_BROWSER_EXTENSION_PRODVERSION", ""))
156
+ if env_version:
157
+ return env_version
158
+
159
+ for command in CHROME_VERSION_COMMANDS:
160
+ try:
161
+ completed = subprocess.run(
162
+ command,
163
+ check=False,
164
+ capture_output=True,
165
+ text=True,
166
+ timeout=5,
167
+ )
168
+ except (OSError, subprocess.TimeoutExpired):
169
+ continue
170
+
171
+ version = _normalize_chrome_prodversion(
172
+ " ".join(part for part in (completed.stdout, completed.stderr) if part)
173
+ )
174
+ if version:
175
+ return version
176
+
177
+ return DEFAULT_CHROME_PRODVERSION
178
+
179
+
180
+def _normalize_chrome_prodversion(value: str) -> str:
181
+ match = CHROME_VERSION_RE.search(str(value or ""))
182
+ if not match:
183
+ return ""
184
+ parts = match.group(1).split(".")
185
+ return ".".join((parts + ["0", "0", "0", "0"])[:4])
186
+
187
+
188
def _crx_zip_payload(data: bytes) -> bytes:
189
if data.startswith(b"PK"):
190
return data
plugins/_browser/helpers/runtime.py
+87
-3
@@ -3,9 +3,12 @@ from __future__ import annotations
3
import atexit
4
import asyncio
5
import base64
6
+import os
7
import re
8
import shutil
9
+import signal
10
import threading
11
+import time
12
from dataclasses import dataclass
13
from pathlib import Path
14
from typing import Any
@@ -23,6 +26,7 @@ PLUGIN_DIR = Path(__file__).resolve().parents[1]
26
CONTENT_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-page-content.js"
27
RUNTIME_DATA_KEY = "_browser_runtime"
28
DEFAULT_VIEWPORT = {"width": 1024, "height": 768}
29
+CHROME_SINGLETON_FILES = ("SingletonLock", "SingletonCookie", "SingletonSocket")
30
31
_SPECIAL_SCHEME_RE = re.compile(r"^(?:about|blob|data|file|mailto|tel):", re.I)
32
_URL_SCHEME_RE = re.compile(r"^[a-z][a-z\d+\-.]*://", re.I)
@@ -117,6 +121,7 @@ class _BrowserRuntimeCore:
121
self.next_browser_id = 1
122
self.last_interacted_browser_id: int | None = None
123
self._content_helper_source: str | None = None
124
+ self._start_lock: asyncio.Lock | None = None
125
126
@property
127
def profile_dir(self) -> Path:
@@ -130,10 +135,20 @@ class _BrowserRuntimeCore:
135
if self.context:
136
return
137
138
+ if self._start_lock is None:
139
+ self._start_lock = asyncio.Lock()
140
+
141
+ async with self._start_lock:
142
+ if self.context:
143
+ return
144
+ await self._start()
145
+
146
+ async def _start(self) -> None:
147
from playwright.async_api import async_playwright
148
149
self.profile_dir.mkdir(parents=True, exist_ok=True)
150
self.downloads_dir.mkdir(parents=True, exist_ok=True)
151
+ self._release_orphaned_profile_singleton()
152
browser_config = get_browser_config()
153
launch_config = build_browser_launch_config(browser_config)
154
configure_playwright_env()
@@ -156,9 +171,18 @@ class _BrowserRuntimeCore:
171
launch_kwargs["channel"] = launch_config["channel"]
172
else:
173
launch_kwargs["executable_path"] = str(browser_binary)
159
- self.context = await self.playwright.chromium.launch_persistent_context(
160
- **launch_kwargs
161
- )
174
+ try:
175
+ self.context = await self.playwright.chromium.launch_persistent_context(
176
+ **launch_kwargs
177
+ )
178
+ except Exception:
179
+ if self.playwright:
180
+ try:
181
+ await self.playwright.stop()
182
+ except Exception:
183
+ pass
184
+ self.playwright = None
185
+ raise
186
self.context.set_default_timeout(30000)
187
self.context.set_default_navigation_timeout(30000)
188
await self.context.add_init_script(self._shadow_dom_script())
@@ -173,6 +197,66 @@ class _BrowserRuntimeCore:
197
continue
198
self._register_page(page)
199
200
+ def _release_orphaned_profile_singleton(self) -> None:
201
+ lock_path = self.profile_dir / "SingletonLock"
202
+ owner_pid = self._profile_singleton_owner_pid(lock_path)
203
+ if owner_pid and self._process_owns_profile(owner_pid):
204
+ PrintStyle.warning(
205
+ f"Stopping orphaned Chromium process {owner_pid} for Browser profile {self.safe_context_id}."
206
+ )
207
+ self._terminate_process(owner_pid)
208
+
209
+ for name in CHROME_SINGLETON_FILES:
210
+ singleton_path = self.profile_dir / name
211
+ try:
212
+ if singleton_path.exists() or singleton_path.is_symlink():
213
+ singleton_path.unlink()
214
+ except OSError as exc:
215
+ PrintStyle.warning(f"Could not remove stale Browser profile lock {singleton_path}: {exc}")
216
+
217
+ @staticmethod
218
+ def _profile_singleton_owner_pid(lock_path: Path) -> int | None:
219
+ try:
220
+ target = os.readlink(lock_path)
221
+ except OSError:
222
+ return None
223
+ raw_pid = target.rsplit("-", 1)[-1]
224
+ if not raw_pid.isdigit():
225
+ return None
226
+ return int(raw_pid)
227
+
228
+ def _process_owns_profile(self, pid: int) -> bool:
229
+ cmdline_path = Path("/proc") / str(pid) / "cmdline"
230
+ try:
231
+ raw = cmdline_path.read_bytes()
232
+ except OSError:
233
+ return False
234
+ cmdline = raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore")
235
+ return "chrome" in cmdline.lower() and str(self.profile_dir) in cmdline
236
+
237
+ @staticmethod
238
+ def _terminate_process(pid: int) -> None:
239
+ try:
240
+ os.kill(pid, signal.SIGTERM)
241
+ except ProcessLookupError:
242
+ return
243
+ except OSError as exc:
244
+ PrintStyle.warning(f"Could not stop orphaned Chromium process {pid}: {exc}")
245
+ return
246
+
247
+ deadline = time.monotonic() + 3
248
+ while time.monotonic() < deadline:
249
+ if not Path("/proc", str(pid)).exists():
250
+ return
251
+ time.sleep(0.1)
252
+
253
+ try:
254
+ os.kill(pid, signal.SIGKILL)
255
+ except ProcessLookupError:
256
+ pass
257
+ except OSError as exc:
258
+ PrintStyle.warning(f"Could not force-stop orphaned Chromium process {pid}: {exc}")
259
+
260
async def open(self, url: str = "about:blank") -> dict[str, Any]:
261
await self.ensure_started()
262
page = await self.context.new_page()
plugins/_browser/webui/browser-store.js
+2
-1
@@ -9,6 +9,7 @@ const websocket = getNamespacedClient("/ws");
9
websocket.addHandlers(["ws_webui"]);
10
11
const EXTENSIONS_ROOT_FALLBACK = "/a0/usr/browser-extensions";
12
+const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;
13
14
function firstOk(response) {
15
const result = response?.results?.find((item) => item?.ok);
@@ -215,7 +216,7 @@ const model = {
216
context_id: this.contextId,
217
browser_id: this.activeBrowserId,
218
},
218
- { timeoutMs: 10000 },
219
+ { timeoutMs: BROWSER_SUBSCRIBE_TIMEOUT_MS },
220
);
221
const data = firstOk(response);
222
this.browsers = data.browsers || [];
plugins/_browser/webui/config.html
+2
-2
@@ -114,8 +114,8 @@
114
<div class="browser-config-note">
115
<span class="material-symbols-outlined">info</span>
116
<span>
117
- This first version supports unpacked extension folders only. Chrome Web Store installs
118
- and `.crx` files are out of scope for now.
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.
119
</span>
120
</div>
121
tests/test_browser_agent_regressions.py
+49
-1
@@ -17,10 +17,14 @@ from plugins._browser.helpers.config import (
17
resolve_browser_model_selection,
18
)
19
from plugins._browser.helpers.extension_manager import (
20
+ _build_web_store_download_url,
21
_crx_zip_payload,
22
+ _detect_chrome_prodversion,
23
+ _normalize_chrome_prodversion,
24
parse_chrome_web_store_extension_id,
25
)
23
-from plugins._browser.helpers.runtime import normalize_url
26
+from plugins._browser.helpers.runtime import _BrowserRuntimeCore, normalize_url
27
+import plugins._browser.helpers.runtime as browser_runtime_module
28
import plugins._browser.hooks as browser_hooks_module
29
import plugins._browser.tools.browser as browser_tool_module
30
import plugins._browser.api.ws_browser as ws_browser_module
@@ -158,6 +162,21 @@ def test_browser_extension_manager_extracts_crx3_zip_payload():
162
assert _crx_zip_payload(crx) == payload
163
164
165
+def test_browser_extension_manager_uses_modern_chrome_prodversion(monkeypatch):
166
+ extension_id = "a" * 32
167
+
168
+ assert _normalize_chrome_prodversion("Google Chrome 147.0.7727.55") == "147.0.7727.55"
169
+ assert _normalize_chrome_prodversion("Chromium 124") == "124.0.0.0"
170
+
171
+ monkeypatch.setenv("A0_BROWSER_EXTENSION_PRODVERSION", "147.0.7727.55")
172
+ assert _detect_chrome_prodversion() == "147.0.7727.55"
173
+
174
+ url = _build_web_store_download_url(extension_id, prodversion=_detect_chrome_prodversion())
175
+ assert "prod=chromecrx" in url
176
+ assert "prodversion=147.0.7727.55" in url
177
+ assert "prodversion=120.0.0.0" not in url
178
+
179
+
180
def test_browser_extension_menu_exposes_agent_and_url_paths():
181
html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "main.html").read_text(
182
encoding="utf-8"
@@ -171,6 +190,35 @@ def test_browser_extension_menu_exposes_agent_and_url_paths():
190
assert skill.exists()
191
192
193
+def test_browser_viewer_allows_slow_extension_startup():
194
+ js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
195
+ encoding="utf-8"
196
+ )
197
+
198
+ assert "const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;" in js
199
+ assert "{ timeoutMs: BROWSER_SUBSCRIBE_TIMEOUT_MS }" in js
200
+
201
+
202
+def test_browser_runtime_removes_stale_profile_singletons(monkeypatch, tmp_path):
203
+ monkeypatch.setattr(
204
+ browser_runtime_module.files,
205
+ "get_abs_path",
206
+ lambda *parts: str(tmp_path.joinpath(*parts)),
207
+ )
208
+ core = _BrowserRuntimeCore("stale-profile")
209
+ core.profile_dir.mkdir(parents=True)
210
+
211
+ for name in ("SingletonLock", "SingletonCookie", "SingletonSocket"):
212
+ (core.profile_dir / name).symlink_to("missing-host-999999")
213
+
214
+ core._release_orphaned_profile_singleton()
215
+
216
+ assert not any(
217
+ (core.profile_dir / name).exists() or (core.profile_dir / name).is_symlink()
218
+ for name in ("SingletonLock", "SingletonCookie", "SingletonSocket")
219
+ )
220
+
221
+
222
def test_browser_save_plugin_config_restarts_runtimes_on_change(monkeypatch, tmp_path):
223
extension_dir = tmp_path / "extension"
224
extension_dir.mkdir()