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