Add browser extension uninstall controls

Expose extension deletion from the Browser internal settings page and keep the compact Browser dropdown focused on quick enable/install actions.\n\nAdd a guarded uninstall API that only deletes Browser-managed extension folders, updates enabled extension paths, refreshes the settings UI, and covers managed versus external paths with regression tests.

Alessandro committed May 2, 2026 at 17:05 UTC 9fc3ff20a4bcbbb3fcbf2750e4581a490334098d
5 files changed +258 -10
plugins/_browser/api/extensions.py
+12
@@ -16,6 +16,7 @@ from plugins._browser.helpers.extension_manager import (
16 install_chrome_web_store_extension,
17 list_browser_extensions,
18 set_browser_extension_enabled,
19 + uninstall_browser_extension,
20 )
21
22
@@ -47,6 +48,16 @@ class Extensions(ApiHandler):
48 return {"ok": False, "error": str(exc)}
49 return self._browser_extension_payload(agent=agent)
50
51 + if action == "uninstall_extension":
52 + try:
53 + result = uninstall_browser_extension(str(input.get("path", "")))
54 + except ValueError as exc:
55 + return {"ok": False, "error": str(exc)}
56 + return {
57 + **self._browser_extension_payload(agent=agent),
58 + **result,
59 + }
60 +
61 if action == "set_model_preset":
62 preset_name = str(input.get(MODEL_PRESET_KEY, "") or "").strip()
63 available_presets = {
@@ -83,6 +94,7 @@ class Extensions(ApiHandler):
94 "ok": True,
95 "root": str(get_extensions_root()),
96 "extensions": list_browser_extensions(),
97 + "extension_paths": config["extension_paths"],
98 DEFAULT_HOMEPAGE_KEY: config[DEFAULT_HOMEPAGE_KEY],
99 AUTOFOCUS_ACTIVE_PAGE_KEY: config[AUTOFOCUS_ACTIVE_PAGE_KEY],
100 MODEL_PRESET_KEY: config[MODEL_PRESET_KEY],
plugins/_browser/helpers/extension_manager.py
+51
@@ -134,6 +134,45 @@ def set_browser_extension_enabled(extension_path: str, enabled: bool) -> dict[st
134 return config
135
136
137 +def uninstall_browser_extension(extension_path: str) -> dict[str, Any]:
138 + raw_path = str(extension_path or "").strip()
139 + if not raw_path:
140 + raise ValueError("Choose an extension first.")
141 +
142 + root = get_extensions_root().resolve()
143 + extension_dir = Path(raw_path).expanduser().resolve()
144 + if extension_dir == root or not extension_dir.is_relative_to(root):
145 + raise ValueError("Only Browser-managed extension folders can be deleted.")
146 + if not extension_dir.is_dir():
147 + raise ValueError("Extension folder was not found.")
148 +
149 + manifest = _read_manifest(extension_dir)
150 + name = (
151 + _manifest_label(extension_dir, manifest, "name")
152 + or _manifest_label(extension_dir, manifest, "short_name")
153 + or extension_dir.name
154 + )
155 + config = get_browser_config()
156 + config["extension_paths"] = [
157 + path
158 + for path in config["extension_paths"]
159 + if Path(path).expanduser().resolve() != extension_dir
160 + ]
161 +
162 + try:
163 + shutil.rmtree(extension_dir)
164 + except OSError as exc:
165 + raise ValueError(f"Could not delete extension folder: {exc}") from exc
166 +
167 + plugins.save_plugin_config(PLUGIN_NAME, "", "", config)
168 + return {
169 + "ok": True,
170 + "name": name,
171 + "path": str(extension_dir),
172 + "extension_paths": config["extension_paths"],
173 + }
174 +
175 +
176 def _download_crx(extension_id: str, archive_path: Path) -> None:
177 prodversion = _detect_chrome_prodversion()
178 url = _build_web_store_download_url(extension_id, prodversion=prodversion)
@@ -261,6 +300,7 @@ def _enable_extension_path(extension_path: Path) -> dict[str, Any]:
300 def _extension_entry(extension_dir: Path, enabled_paths: set[str]) -> dict[str, Any]:
301 manifest = _read_manifest(extension_dir)
302 extension_path = str(extension_dir)
303 + can_delete = _is_managed_extension_dir(extension_dir)
304 name = (
305 _manifest_label(extension_dir, manifest, "name")
306 or _manifest_label(extension_dir, manifest, "short_name")
@@ -273,9 +313,20 @@ def _extension_entry(extension_dir: Path, enabled_paths: set[str]) -> dict[str,
313 "version": manifest.get("version") or "",
314 "path": extension_path,
315 "enabled": extension_path in enabled_paths,
316 + "managed": can_delete,
317 + "can_delete": can_delete,
318 }
319
320
321 +def _is_managed_extension_dir(extension_dir: Path) -> bool:
322 + try:
323 + root = get_extensions_root().resolve()
324 + path = extension_dir.expanduser().resolve()
325 + except OSError:
326 + return False
327 + return path != root and path.is_relative_to(root)
328 +
329 +
330 def _read_manifest(extension_path: Path) -> dict[str, Any]:
331 manifest_path = extension_path / "manifest.json"
332 try:
plugins/_browser/webui/browser-config-store.js
+54 -1
@@ -43,6 +43,8 @@ export const store = createStore("browserConfig", {
43 extensionsList: [],
44 extensionsLoading: false,
45 extensionsError: "",
46 + extensionsMessage: "",
47 + extensionDeleteLoadingPath: "",
48
49 async init(config) {
50 this.bindConfig(config);
@@ -53,6 +55,8 @@ export const store = createStore("browserConfig", {
55 this.config = null;
56 this.extensionsList = [];
57 this.extensionsError = "";
58 + this.extensionsMessage = "";
59 + this.extensionDeleteLoadingPath = "";
60 },
61
62 bindConfig(config) {
@@ -99,7 +103,7 @@ export const store = createStore("browserConfig", {
103 if (!response?.ok) {
104 throw new Error(response?.error || "Could not load browser extensions.");
105 }
102 - this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
106 + this.applyExtensionPayload(response);
107 } catch (error) {
108 this.extensionsList = [];
109 this.extensionsError = error instanceof Error ? error.message : String(error);
@@ -108,6 +112,13 @@ export const store = createStore("browserConfig", {
112 }
113 },
114
115 + applyExtensionPayload(response = {}) {
116 + this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
117 + if (Array.isArray(response.extension_paths) && this.config) {
118 + this.config.extension_paths = normalizePathList(response.extension_paths);
119 + }
120 + },
121 +
122 extensionEnabled(extension) {
123 const path = typeof extension === "string" ? extension : extension?.path;
124 return normalizePathList(this.config?.extension_paths).includes(String(path || ""));
@@ -128,6 +139,48 @@ export const store = createStore("browserConfig", {
139 safeConfig.extension_paths = paths;
140 },
141
142 + extensionCanDelete(extension) {
143 + return Boolean(extension?.can_delete);
144 + },
145 +
146 + extensionDeleteTitle(extension) {
147 + return this.extensionCanDelete(extension)
148 + ? "Delete extension"
149 + : "Only Browser-managed extensions can be deleted";
150 + },
151 +
152 + async deleteExtension(extension) {
153 + const path = String(extension?.path || "").trim();
154 + if (!path) return;
155 + this.extensionsError = "";
156 + this.extensionsMessage = "";
157 + if (!this.extensionCanDelete(extension)) {
158 + this.extensionsError = "Only Browser-managed extensions can be deleted.";
159 + return;
160 + }
161 + const name = String(extension?.name || "this extension").trim();
162 + if (globalThis.confirm && !globalThis.confirm(`Delete ${name}? This removes the extension folder from Browser.`)) {
163 + return;
164 + }
165 +
166 + this.extensionDeleteLoadingPath = path;
167 + try {
168 + const response = await callJsonApi(BROWSER_EXTENSIONS_API, {
169 + action: "uninstall_extension",
170 + path,
171 + });
172 + if (!response?.ok) {
173 + throw new Error(response?.error || "Could not delete extension.");
174 + }
175 + this.applyExtensionPayload(response);
176 + this.extensionsMessage = `Deleted ${response.name || name}.`;
177 + } catch (error) {
178 + this.extensionsError = error instanceof Error ? error.message : String(error);
179 + } finally {
180 + this.extensionDeleteLoadingPath = "";
181 + }
182 + },
183 +
184 extensionVersionLabel(extension) {
185 const version = String(extension?.version || "").trim();
186 return version ? `v${version}` : "Unpacked extension";
plugins/_browser/webui/config.html
+79 -9
@@ -73,21 +73,42 @@
73 <div class="browser-config-empty">No installed extensions found.</div>
74 </template>
75 <template x-for="extension in $store.browserConfig.extensionsList" :key="extension.path">
76 - <label class="browser-config-extension-row" :title="extension.path">
76 + <div class="browser-config-extension-row" :title="extension.path">
77 <span class="browser-config-extension-text">
78 <span class="browser-config-extension-name" x-text="extension.name || 'Unnamed extension'"></span>
79 <span class="browser-config-extension-meta" x-text="$store.browserConfig.extensionVersionLabel(extension)"></span>
80 </span>
81 - <span class="browser-config-toggle">
82 - <input
83 - type="checkbox"
84 - :checked="$store.browserConfig.extensionEnabled(extension)"
85 - @change="$store.browserConfig.setExtensionEnabled(extension, $event.target.checked)"
86 - />
87 - <span class="browser-config-switch"></span>
81 + <span class="browser-config-extension-actions">
82 + <label class="browser-config-toggle">
83 + <input
84 + type="checkbox"
85 + :checked="$store.browserConfig.extensionEnabled(extension)"
86 + :disabled="$store.browserConfig.extensionDeleteLoadingPath === extension.path"
87 + @change="$store.browserConfig.setExtensionEnabled(extension, $event.target.checked)"
88 + />
89 + <span class="browser-config-switch"></span>
90 + </label>
91 + <button
92 + type="button"
93 + class="browser-config-extension-delete"
94 + :disabled="!$store.browserConfig.extensionCanDelete(extension) || $store.browserConfig.extensionDeleteLoadingPath === extension.path"
95 + :title="$store.browserConfig.extensionDeleteTitle(extension)"
96 + :aria-label="'Delete ' + (extension.name || 'extension')"
97 + @click.stop="$store.browserConfig.deleteExtension(extension)"
98 + >
99 + <span
100 + class="material-symbols-outlined"
101 + :class="{ spinning: $store.browserConfig.extensionDeleteLoadingPath === extension.path }"
102 + x-text="$store.browserConfig.extensionDeleteLoadingPath === extension.path ? 'progress_activity' : 'delete'"
103 + ></span>
104 + </button>
105 </span>
89 - </label>
106 + </div>
107 </template>
108 + <div class="browser-config-note browser-config-success" x-show="$store.browserConfig.extensionsMessage">
109 + <span class="material-symbols-outlined">check_circle</span>
110 + <span x-text="$store.browserConfig.extensionsMessage"></span>
111 + </div>
112 <div class="browser-config-note" x-show="$store.browserConfig.extensionsError">
113 <span class="material-symbols-outlined">error</span>
114 <span x-text="$store.browserConfig.extensionsError"></span>
@@ -238,6 +259,12 @@
259 font-weight: 650;
260 }
261
262 + .browser-config-extension-actions {
263 + display: inline-flex;
264 + align-items: center;
265 + gap: 8px;
266 + }
267 +
268 .browser-config-toggle {
269 position: relative;
270 display: inline-flex;
@@ -255,6 +282,10 @@
282 cursor: pointer;
283 }
284
285 + .browser-config-toggle input:disabled {
286 + cursor: wait;
287 + }
288 +
289 .browser-config-switch {
290 width: 100%;
291 height: 100%;
@@ -285,6 +316,40 @@
316 transform: translateX(18px);
317 }
318
319 + .browser-config-toggle input:disabled + .browser-config-switch {
320 + opacity: 0.58;
321 + }
322 +
323 + .browser-config-extension-delete {
324 + display: inline-flex;
325 + align-items: center;
326 + justify-content: center;
327 + width: 28px;
328 + min-width: 28px;
329 + height: 28px;
330 + min-height: 28px;
331 + padding: 0;
332 + border: 1px solid color-mix(in srgb, #be123c 30%, var(--color-border));
333 + border-radius: 7px;
334 + background: transparent;
335 + color: color-mix(in srgb, #be123c 78%, var(--color-text));
336 + cursor: pointer;
337 + }
338 +
339 + .browser-config-extension-delete:hover:not(:disabled) {
340 + background: color-mix(in srgb, #be123c 12%, var(--color-background));
341 + color: #be123c;
342 + }
343 +
344 + .browser-config-extension-delete:disabled {
345 + cursor: not-allowed;
346 + opacity: 0.48;
347 + }
348 +
349 + .browser-config-extension-delete .material-symbols-outlined {
350 + font-size: 17px;
351 + }
352 +
353 .browser-config-note {
354 display: flex;
355 align-items: flex-start;
@@ -296,6 +361,11 @@
361 font-size: var(--font-size-small);
362 }
363
364 + .browser-config-note.browser-config-success {
365 + background: color-mix(in srgb, #15803d 12%, var(--color-background));
366 + color: color-mix(in srgb, var(--color-text) 88%, #166534);
367 + }
368 +
369 .browser-config-warning {
370 display: flex;
371 align-items: flex-start;
tests/test_browser_agent_regressions.py
+62
@@ -1,4 +1,5 @@
1 import asyncio
2 +import json
3 import sys
4 import threading
5 from pathlib import Path
@@ -92,6 +93,7 @@ from plugins._browser.helpers.extension_manager import (
93 _normalize_chrome_prodversion,
94 get_extensions_root,
95 parse_chrome_web_store_extension_id,
96 + uninstall_browser_extension,
97 )
98 import plugins._browser.helpers.extension_manager as browser_extension_manager_module
99 from plugins._browser.helpers.runtime import (
@@ -289,6 +291,59 @@ def test_browser_extension_storage_uses_plugin_user_path(monkeypatch, tmp_path):
291 assert get_extensions_root() == tmp_path / "usr" / "plugins" / "_browser" / "extensions"
292
293
294 +def test_browser_extension_manager_uninstalls_only_managed_extensions(monkeypatch, tmp_path):
295 + monkeypatch.setattr(
296 + browser_extension_manager_module.files,
297 + "get_abs_path",
298 + lambda *parts: str(tmp_path.joinpath(*parts)),
299 + )
300 + managed_extension = get_extensions_root() / "chrome-web-store" / ("a" * 32)
301 + external_extension = tmp_path / "external-extension"
302 + for extension_dir, name in (
303 + (managed_extension, "Managed Extension"),
304 + (external_extension, "External Extension"),
305 + ):
306 + extension_dir.mkdir(parents=True)
307 + (extension_dir / "manifest.json").write_text(
308 + json.dumps({"name": name, "version": "1.0.0"}),
309 + encoding="utf-8",
310 + )
311 +
312 + saved_configs = []
313 + monkeypatch.setattr(
314 + browser_extension_manager_module,
315 + "get_browser_config",
316 + lambda: {
317 + "extension_paths": [str(managed_extension), str(external_extension)],
318 + },
319 + )
320 + monkeypatch.setattr(
321 + browser_extension_manager_module.plugins,
322 + "save_plugin_config",
323 + lambda _plugin, _project, _agent, config: saved_configs.append(config.copy()),
324 + )
325 +
326 + entries = browser_extension_manager_module.list_browser_extensions()
327 + managed_entry = next(item for item in entries if item["path"] == str(managed_extension))
328 + external_entry = next(item for item in entries if item["path"] == str(external_extension))
329 +
330 + assert managed_entry["can_delete"] is True
331 + assert managed_entry["managed"] is True
332 + assert external_entry["can_delete"] is False
333 +
334 + result = uninstall_browser_extension(str(managed_extension))
335 +
336 + assert result["name"] == "Managed Extension"
337 + assert result["extension_paths"] == [str(external_extension)]
338 + assert not managed_extension.exists()
339 + assert external_extension.exists()
340 + assert saved_configs[-1]["extension_paths"] == [str(external_extension)]
341 +
342 + with pytest.raises(ValueError, match="Only Browser-managed"):
343 + uninstall_browser_extension(str(external_extension))
344 + assert external_extension.exists()
345 +
346 +
347 def test_browser_extension_manager_parses_web_store_urls():
348 extension_id = "a" * 32
349
@@ -343,6 +398,7 @@ def test_browser_extension_menu_exposes_agent_and_url_paths():
398 assert "Browser LLM Preset" in html
399 assert "Chrome Extensions" in html
400 assert "Installed extensions" in html
401 + assert "deleteExtension(extension)" not in html
402 assert "No extensions installed yet." not in html
403 assert "Browser Extension Settings" not in html
404 assert "<span>Settings</span>" in html
@@ -640,9 +696,15 @@ def test_browser_extension_settings_stay_user_facing():
696 config_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "config.html").read_text(
697 encoding="utf-8"
698 )
699 + config_store = (
700 + PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-config-store.js"
701 + ).read_text(encoding="utf-8")
702
703 assert "Choose which installed Chrome extensions Browser loads." in config_html
704 assert "Installed extensions" in config_html
705 + assert "extensionDeleteTitle(extension)" in config_html
706 + assert "deleteExtension(extension)" in config_html
707 + assert "Delete extension" in config_store
708 assert "<textarea" not in config_html
709 assert "Enabled extension directories" not in config_html
710 assert "Chrome Web Store URL installs" not in config_html