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