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