Fix Chrome Web Store extension installs
Skip redundant same-version reinstalls and stage updates before replacing extension files. Show clear install progress, offload blocking work, and restart active browser runtimes safely.
Alessandro committed
Jul 21, 2026 at 16:28 UTC
b6d6c1126ad0e399636ada4851331cb53bc28dcd
5 files changed
+165
-12
plugins/_browser/api/extensions.py
+5
-1
@@ -1,3 +1,4 @@
1
+import asyncio
2
from types import SimpleNamespace
3
4
from helpers import plugins
@@ -30,7 +31,10 @@ class Extensions(ApiHandler):
31
32
if action == "install_web_store":
33
try:
33
- result = install_chrome_web_store_extension(str(input.get("url", "")))
34
+ result = await asyncio.to_thread(
35
+ install_chrome_web_store_extension,
36
+ str(input.get("url", "")),
37
+ )
38
except ValueError as exc:
39
return {"ok": False, "error": str(exc)}
40
return {
plugins/_browser/helpers/extension_manager.py
+47
-9
@@ -16,6 +16,7 @@ from typing import Any
16
17
from helpers import files, plugins
18
from plugins._browser.helpers.config import PLUGIN_NAME, get_browser_config
19
+from plugins._browser.helpers.runtime import close_all_runtimes_sync
20
21
22
EXTENSIONS_ROOT_DIR = ("usr", "_browser", "extensions")
@@ -65,6 +66,8 @@ def list_browser_extensions() -> list[dict[str, Any]]:
66
root = get_extensions_root()
67
if root.exists():
68
for manifest_path in sorted(root.glob("**/manifest.json")):
69
+ if any(part.startswith(".") for part in manifest_path.relative_to(root).parts):
70
+ continue
71
entry = _extension_entry(manifest_path.parent, enabled_paths)
72
seen.add(entry["path"])
73
entries.append(entry)
@@ -89,16 +92,33 @@ def install_chrome_web_store_extension(source: str) -> dict[str, Any]:
92
_download_crx(extension_id, archive_path)
93
payload_path = Path(tmp) / f"{extension_id}.zip"
94
payload_path.write_bytes(_crx_zip_payload(archive_path.read_bytes()))
92
- extracted_path = Path(tmp) / "extracted"
93
- _safe_extract_zip(payload_path, extracted_path)
94
-
95
- if not (extracted_path / "manifest.json").is_file():
96
- raise ValueError("Downloaded extension did not contain a manifest.json file.")
97
-
98
- if target.exists():
99
- shutil.rmtree(target)
95
target.parent.mkdir(parents=True, exist_ok=True)
101
- shutil.copytree(extracted_path, target)
96
+ try:
97
+ with zipfile.ZipFile(payload_path) as archive:
98
+ manifest = json.loads(archive.read("manifest.json"))
99
+ except KeyError as exc:
100
+ raise ValueError("Downloaded extension did not contain a manifest.json file.") from exc
101
+ except (json.JSONDecodeError, UnicodeDecodeError, zipfile.BadZipFile) as exc:
102
+ raise ValueError("Downloaded extension contained an invalid manifest.json file.") from exc
103
+ if not isinstance(manifest, dict):
104
+ raise ValueError("Downloaded extension contained an invalid manifest.json file.")
105
+
106
+ current_manifest = _read_manifest(target)
107
+ if not (
108
+ target.is_dir()
109
+ and manifest.get("version")
110
+ and manifest.get("version") == current_manifest.get("version")
111
+ ):
112
+ with tempfile.TemporaryDirectory(
113
+ prefix=f".{extension_id}-install-",
114
+ dir=target.parent,
115
+ ) as extracted:
116
+ extracted_path = Path(extracted)
117
+ _safe_extract_zip(payload_path, extracted_path)
118
+ config = get_browser_config()
119
+ if target.exists() and str(target) in config["extension_paths"]:
120
+ close_all_runtimes_sync()
121
+ _replace_extension_dir(extracted_path, target)
122
123
config = _enable_extension_path(target)
124
manifest = _read_manifest(target)
@@ -112,6 +132,24 @@ def install_chrome_web_store_extension(source: str) -> dict[str, Any]:
132
}
133
134
135
+def _replace_extension_dir(source: Path, target: Path) -> None:
136
+ if not target.exists():
137
+ source.rename(target)
138
+ return
139
+
140
+ with tempfile.TemporaryDirectory(
141
+ prefix=f".{target.name}-previous-",
142
+ dir=target.parent,
143
+ ) as backup_dir:
144
+ backup = Path(backup_dir) / target.name
145
+ target.rename(backup)
146
+ try:
147
+ source.rename(target)
148
+ except BaseException:
149
+ backup.rename(target)
150
+ raise
151
+
152
+
153
def set_browser_extension_enabled(extension_path: str, enabled: bool) -> dict[str, Any]:
154
raw_path = str(extension_path or "").strip()
155
if not raw_path:
plugins/_browser/webui/browser-panel.html
+3
-2
@@ -106,11 +106,12 @@
106
placeholder="https://chromewebstore.google.com/detail/..." />
107
<div class="browser-extension-url-actions">
108
<button type="button" class="btn btn-ok" @click="$store.browserPage.installExtensionFromUrl()"
109
- :disabled="$store.browserPage.extensionActionLoading">
109
+ :disabled="$store.browserPage.extensionActionLoading"
110
+ :aria-busy="$store.browserPage.extensionActionLoading.toString()">
111
<span class="material-symbols-outlined"
112
:class="{ spinning: $store.browserPage.extensionActionLoading }"
113
x-text="$store.browserPage.extensionActionLoading ? 'progress_activity' : 'download'"></span>
113
- <span>Install URL</span>
114
+ <span x-text="$store.browserPage.extensionActionLoading ? 'Installing…' : 'Install URL'">Install URL</span>
115
</button>
116
<button type="button" class="btn btn-field"
117
@click="$store.browserPage.askAgentInstallExtension()">
plugins/_browser/webui/browser-store.js
+2
@@ -535,6 +535,7 @@ const model = {
535
}
536
537
this.extensionActionLoading = true;
538
+ this.extensionActionMessage = "Installing extension… Large packages may take a few minutes.";
539
try {
540
const response = await callJsonApi("/plugins/_browser/extensions", {
541
action: "install_web_store",
@@ -549,6 +550,7 @@ const model = {
550
this.extensionActionMessage = `Installed ${response.name || response.id}.`;
551
await this.refreshAfterSettingsClose();
552
} catch (error) {
553
+ this.extensionActionMessage = "";
554
this.extensionActionError = error instanceof Error ? error.message : String(error);
555
} finally {
556
this.extensionActionLoading = false;
tests/test_browser_agent_regressions.py
+108
@@ -4,6 +4,7 @@ import json
4
import re
5
import sys
6
import threading
7
+import zipfile
8
from pathlib import Path
9
from types import ModuleType, SimpleNamespace
10
@@ -545,6 +546,108 @@ def test_browser_extension_manager_extracts_crx3_zip_payload():
546
assert _crx_zip_payload(crx) == payload
547
548
549
+def test_browser_extension_manager_skips_same_version_reinstall(monkeypatch, tmp_path):
550
+ extension_id = "a" * 32
551
+ monkeypatch.setattr(
552
+ browser_extension_manager_module.files,
553
+ "get_abs_path",
554
+ lambda *parts: str(tmp_path.joinpath(*parts)),
555
+ )
556
+ target = get_extensions_root() / "chrome-web-store" / extension_id
557
+ target.mkdir(parents=True)
558
+ (target / "manifest.json").write_text(
559
+ json.dumps({"name": "Current", "version": "1.0.0"}),
560
+ encoding="utf-8",
561
+ )
562
+ (target / "keep.txt").write_text("current", encoding="utf-8")
563
+
564
+ def download(_extension_id, archive_path):
565
+ with zipfile.ZipFile(archive_path, "w") as archive:
566
+ archive.writestr("manifest.json", json.dumps({"name": "Current", "version": "1.0.0"}))
567
+ archive.writestr("replacement.txt", "should not be extracted")
568
+
569
+ monkeypatch.setattr(browser_extension_manager_module, "_download_crx", download)
570
+ monkeypatch.setattr(
571
+ browser_extension_manager_module,
572
+ "get_browser_config",
573
+ lambda: {"extension_paths": [str(target)]},
574
+ )
575
+ monkeypatch.setattr(
576
+ browser_extension_manager_module.plugins,
577
+ "save_plugin_config",
578
+ lambda *_args, **_kwargs: None,
579
+ )
580
+ monkeypatch.setattr(
581
+ browser_extension_manager_module,
582
+ "close_all_runtimes_sync",
583
+ lambda: pytest.fail("same-version reinstall restarted Browser runtimes"),
584
+ )
585
+ monkeypatch.setattr(
586
+ browser_extension_manager_module,
587
+ "_safe_extract_zip",
588
+ lambda *_args: pytest.fail("same-version reinstall extracted the package"),
589
+ )
590
+
591
+ result = browser_extension_manager_module.install_chrome_web_store_extension(extension_id)
592
+
593
+ assert result["version"] == "1.0.0"
594
+ assert (target / "keep.txt").read_text(encoding="utf-8") == "current"
595
+ assert not (target / "replacement.txt").exists()
596
+
597
+
598
+def test_browser_extension_manager_stages_updates_and_restarts_runtime(monkeypatch, tmp_path):
599
+ extension_id = "a" * 32
600
+ monkeypatch.setattr(
601
+ browser_extension_manager_module.files,
602
+ "get_abs_path",
603
+ lambda *parts: str(tmp_path.joinpath(*parts)),
604
+ )
605
+ target = get_extensions_root() / "chrome-web-store" / extension_id
606
+ target.mkdir(parents=True)
607
+ (target / "manifest.json").write_text(
608
+ json.dumps({"name": "Old", "version": "1.0.0"}),
609
+ encoding="utf-8",
610
+ )
611
+ (target / "old.txt").write_text("old", encoding="utf-8")
612
+
613
+ def download(_extension_id, archive_path):
614
+ with zipfile.ZipFile(archive_path, "w") as archive:
615
+ archive.writestr("manifest.json", json.dumps({"name": "Updated", "version": "2.0.0"}))
616
+ archive.writestr("new.txt", "new")
617
+
618
+ saved_configs = []
619
+ restarts = []
620
+ monkeypatch.setattr(browser_extension_manager_module, "_download_crx", download)
621
+ monkeypatch.setattr(
622
+ browser_extension_manager_module,
623
+ "get_browser_config",
624
+ lambda: {"extension_paths": [str(target)]},
625
+ )
626
+ monkeypatch.setattr(
627
+ browser_extension_manager_module.plugins,
628
+ "save_plugin_config",
629
+ lambda _plugin, _project, _agent, config: saved_configs.append(config.copy()),
630
+ )
631
+ def restart():
632
+ listed_paths = [
633
+ extension["path"]
634
+ for extension in browser_extension_manager_module.list_browser_extensions()
635
+ ]
636
+ restarts.append(((target / "old.txt").exists(), listed_paths))
637
+
638
+ monkeypatch.setattr(browser_extension_manager_module, "close_all_runtimes_sync", restart)
639
+
640
+ result = browser_extension_manager_module.install_chrome_web_store_extension(extension_id)
641
+
642
+ assert result["name"] == "Updated"
643
+ assert result["version"] == "2.0.0"
644
+ assert restarts == [(True, [str(target)])]
645
+ assert saved_configs[-1]["extension_paths"] == [str(target)]
646
+ assert (target / "new.txt").read_text(encoding="utf-8") == "new"
647
+ assert not (target / "old.txt").exists()
648
+ assert list(target.parent.iterdir()) == [target]
649
+
650
+
651
def test_browser_extension_manager_uses_modern_chrome_prodversion(monkeypatch):
652
extension_id = "a" * 32
653
@@ -589,6 +692,11 @@ def test_browser_extension_menu_exposes_agent_and_url_paths():
692
assert "<span>Open</span>" in html
693
assert "hasExtensionInstallUrl()" in html
694
assert "malicious or buggy extensions" in html
695
+ assert "'Installing…' : 'Install URL'" in html
696
+ assert ':aria-busy="$store.browserPage.extensionActionLoading.toString()"' in html
697
+ assert "Large packages may take a few minutes." in (
698
+ PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
699
+ ).read_text(encoding="utf-8")
700
assert skill.exists()
701
702