mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-18 14:53:36 +00:00
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.
This commit is contained in:
parent
39a96012f9
commit
9fc3ff20a4
5 changed files with 258 additions and 10 deletions
|
|
@ -16,6 +16,7 @@ from plugins._browser.helpers.extension_manager import (
|
|||
install_chrome_web_store_extension,
|
||||
list_browser_extensions,
|
||||
set_browser_extension_enabled,
|
||||
uninstall_browser_extension,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -47,6 +48,16 @@ class Extensions(ApiHandler):
|
|||
return {"ok": False, "error": str(exc)}
|
||||
return self._browser_extension_payload(agent=agent)
|
||||
|
||||
if action == "uninstall_extension":
|
||||
try:
|
||||
result = uninstall_browser_extension(str(input.get("path", "")))
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
return {
|
||||
**self._browser_extension_payload(agent=agent),
|
||||
**result,
|
||||
}
|
||||
|
||||
if action == "set_model_preset":
|
||||
preset_name = str(input.get(MODEL_PRESET_KEY, "") or "").strip()
|
||||
available_presets = {
|
||||
|
|
@ -83,6 +94,7 @@ class Extensions(ApiHandler):
|
|||
"ok": True,
|
||||
"root": str(get_extensions_root()),
|
||||
"extensions": list_browser_extensions(),
|
||||
"extension_paths": config["extension_paths"],
|
||||
DEFAULT_HOMEPAGE_KEY: config[DEFAULT_HOMEPAGE_KEY],
|
||||
AUTOFOCUS_ACTIVE_PAGE_KEY: config[AUTOFOCUS_ACTIVE_PAGE_KEY],
|
||||
MODEL_PRESET_KEY: config[MODEL_PRESET_KEY],
|
||||
|
|
|
|||
|
|
@ -134,6 +134,45 @@ def set_browser_extension_enabled(extension_path: str, enabled: bool) -> dict[st
|
|||
return config
|
||||
|
||||
|
||||
def uninstall_browser_extension(extension_path: str) -> dict[str, Any]:
|
||||
raw_path = str(extension_path or "").strip()
|
||||
if not raw_path:
|
||||
raise ValueError("Choose an extension first.")
|
||||
|
||||
root = get_extensions_root().resolve()
|
||||
extension_dir = Path(raw_path).expanduser().resolve()
|
||||
if extension_dir == root or not extension_dir.is_relative_to(root):
|
||||
raise ValueError("Only Browser-managed extension folders can be deleted.")
|
||||
if not extension_dir.is_dir():
|
||||
raise ValueError("Extension folder was not found.")
|
||||
|
||||
manifest = _read_manifest(extension_dir)
|
||||
name = (
|
||||
_manifest_label(extension_dir, manifest, "name")
|
||||
or _manifest_label(extension_dir, manifest, "short_name")
|
||||
or extension_dir.name
|
||||
)
|
||||
config = get_browser_config()
|
||||
config["extension_paths"] = [
|
||||
path
|
||||
for path in config["extension_paths"]
|
||||
if Path(path).expanduser().resolve() != extension_dir
|
||||
]
|
||||
|
||||
try:
|
||||
shutil.rmtree(extension_dir)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"Could not delete extension folder: {exc}") from exc
|
||||
|
||||
plugins.save_plugin_config(PLUGIN_NAME, "", "", config)
|
||||
return {
|
||||
"ok": True,
|
||||
"name": name,
|
||||
"path": str(extension_dir),
|
||||
"extension_paths": config["extension_paths"],
|
||||
}
|
||||
|
||||
|
||||
def _download_crx(extension_id: str, archive_path: Path) -> None:
|
||||
prodversion = _detect_chrome_prodversion()
|
||||
url = _build_web_store_download_url(extension_id, prodversion=prodversion)
|
||||
|
|
@ -261,6 +300,7 @@ def _enable_extension_path(extension_path: Path) -> dict[str, Any]:
|
|||
def _extension_entry(extension_dir: Path, enabled_paths: set[str]) -> dict[str, Any]:
|
||||
manifest = _read_manifest(extension_dir)
|
||||
extension_path = str(extension_dir)
|
||||
can_delete = _is_managed_extension_dir(extension_dir)
|
||||
name = (
|
||||
_manifest_label(extension_dir, manifest, "name")
|
||||
or _manifest_label(extension_dir, manifest, "short_name")
|
||||
|
|
@ -273,9 +313,20 @@ def _extension_entry(extension_dir: Path, enabled_paths: set[str]) -> dict[str,
|
|||
"version": manifest.get("version") or "",
|
||||
"path": extension_path,
|
||||
"enabled": extension_path in enabled_paths,
|
||||
"managed": can_delete,
|
||||
"can_delete": can_delete,
|
||||
}
|
||||
|
||||
|
||||
def _is_managed_extension_dir(extension_dir: Path) -> bool:
|
||||
try:
|
||||
root = get_extensions_root().resolve()
|
||||
path = extension_dir.expanduser().resolve()
|
||||
except OSError:
|
||||
return False
|
||||
return path != root and path.is_relative_to(root)
|
||||
|
||||
|
||||
def _read_manifest(extension_path: Path) -> dict[str, Any]:
|
||||
manifest_path = extension_path / "manifest.json"
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ export const store = createStore("browserConfig", {
|
|||
extensionsList: [],
|
||||
extensionsLoading: false,
|
||||
extensionsError: "",
|
||||
extensionsMessage: "",
|
||||
extensionDeleteLoadingPath: "",
|
||||
|
||||
async init(config) {
|
||||
this.bindConfig(config);
|
||||
|
|
@ -53,6 +55,8 @@ export const store = createStore("browserConfig", {
|
|||
this.config = null;
|
||||
this.extensionsList = [];
|
||||
this.extensionsError = "";
|
||||
this.extensionsMessage = "";
|
||||
this.extensionDeleteLoadingPath = "";
|
||||
},
|
||||
|
||||
bindConfig(config) {
|
||||
|
|
@ -99,7 +103,7 @@ export const store = createStore("browserConfig", {
|
|||
if (!response?.ok) {
|
||||
throw new Error(response?.error || "Could not load browser extensions.");
|
||||
}
|
||||
this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
|
||||
this.applyExtensionPayload(response);
|
||||
} catch (error) {
|
||||
this.extensionsList = [];
|
||||
this.extensionsError = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -108,6 +112,13 @@ export const store = createStore("browserConfig", {
|
|||
}
|
||||
},
|
||||
|
||||
applyExtensionPayload(response = {}) {
|
||||
this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
|
||||
if (Array.isArray(response.extension_paths) && this.config) {
|
||||
this.config.extension_paths = normalizePathList(response.extension_paths);
|
||||
}
|
||||
},
|
||||
|
||||
extensionEnabled(extension) {
|
||||
const path = typeof extension === "string" ? extension : extension?.path;
|
||||
return normalizePathList(this.config?.extension_paths).includes(String(path || ""));
|
||||
|
|
@ -128,6 +139,48 @@ export const store = createStore("browserConfig", {
|
|||
safeConfig.extension_paths = paths;
|
||||
},
|
||||
|
||||
extensionCanDelete(extension) {
|
||||
return Boolean(extension?.can_delete);
|
||||
},
|
||||
|
||||
extensionDeleteTitle(extension) {
|
||||
return this.extensionCanDelete(extension)
|
||||
? "Delete extension"
|
||||
: "Only Browser-managed extensions can be deleted";
|
||||
},
|
||||
|
||||
async deleteExtension(extension) {
|
||||
const path = String(extension?.path || "").trim();
|
||||
if (!path) return;
|
||||
this.extensionsError = "";
|
||||
this.extensionsMessage = "";
|
||||
if (!this.extensionCanDelete(extension)) {
|
||||
this.extensionsError = "Only Browser-managed extensions can be deleted.";
|
||||
return;
|
||||
}
|
||||
const name = String(extension?.name || "this extension").trim();
|
||||
if (globalThis.confirm && !globalThis.confirm(`Delete ${name}? This removes the extension folder from Browser.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.extensionDeleteLoadingPath = path;
|
||||
try {
|
||||
const response = await callJsonApi(BROWSER_EXTENSIONS_API, {
|
||||
action: "uninstall_extension",
|
||||
path,
|
||||
});
|
||||
if (!response?.ok) {
|
||||
throw new Error(response?.error || "Could not delete extension.");
|
||||
}
|
||||
this.applyExtensionPayload(response);
|
||||
this.extensionsMessage = `Deleted ${response.name || name}.`;
|
||||
} catch (error) {
|
||||
this.extensionsError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.extensionDeleteLoadingPath = "";
|
||||
}
|
||||
},
|
||||
|
||||
extensionVersionLabel(extension) {
|
||||
const version = String(extension?.version || "").trim();
|
||||
return version ? `v${version}` : "Unpacked extension";
|
||||
|
|
|
|||
|
|
@ -73,21 +73,42 @@
|
|||
<div class="browser-config-empty">No installed extensions found.</div>
|
||||
</template>
|
||||
<template x-for="extension in $store.browserConfig.extensionsList" :key="extension.path">
|
||||
<label class="browser-config-extension-row" :title="extension.path">
|
||||
<div class="browser-config-extension-row" :title="extension.path">
|
||||
<span class="browser-config-extension-text">
|
||||
<span class="browser-config-extension-name" x-text="extension.name || 'Unnamed extension'"></span>
|
||||
<span class="browser-config-extension-meta" x-text="$store.browserConfig.extensionVersionLabel(extension)"></span>
|
||||
</span>
|
||||
<span class="browser-config-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="$store.browserConfig.extensionEnabled(extension)"
|
||||
@change="$store.browserConfig.setExtensionEnabled(extension, $event.target.checked)"
|
||||
/>
|
||||
<span class="browser-config-switch"></span>
|
||||
<span class="browser-config-extension-actions">
|
||||
<label class="browser-config-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="$store.browserConfig.extensionEnabled(extension)"
|
||||
:disabled="$store.browserConfig.extensionDeleteLoadingPath === extension.path"
|
||||
@change="$store.browserConfig.setExtensionEnabled(extension, $event.target.checked)"
|
||||
/>
|
||||
<span class="browser-config-switch"></span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="browser-config-extension-delete"
|
||||
:disabled="!$store.browserConfig.extensionCanDelete(extension) || $store.browserConfig.extensionDeleteLoadingPath === extension.path"
|
||||
:title="$store.browserConfig.extensionDeleteTitle(extension)"
|
||||
:aria-label="'Delete ' + (extension.name || 'extension')"
|
||||
@click.stop="$store.browserConfig.deleteExtension(extension)"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-outlined"
|
||||
:class="{ spinning: $store.browserConfig.extensionDeleteLoadingPath === extension.path }"
|
||||
x-text="$store.browserConfig.extensionDeleteLoadingPath === extension.path ? 'progress_activity' : 'delete'"
|
||||
></span>
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<div class="browser-config-note browser-config-success" x-show="$store.browserConfig.extensionsMessage">
|
||||
<span class="material-symbols-outlined">check_circle</span>
|
||||
<span x-text="$store.browserConfig.extensionsMessage"></span>
|
||||
</div>
|
||||
<div class="browser-config-note" x-show="$store.browserConfig.extensionsError">
|
||||
<span class="material-symbols-outlined">error</span>
|
||||
<span x-text="$store.browserConfig.extensionsError"></span>
|
||||
|
|
@ -238,6 +259,12 @@
|
|||
font-weight: 650;
|
||||
}
|
||||
|
||||
.browser-config-extension-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.browser-config-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
|
@ -255,6 +282,10 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browser-config-toggle input:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.browser-config-switch {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
|
@ -285,6 +316,40 @@
|
|||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.browser-config-toggle input:disabled + .browser-config-switch {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.browser-config-extension-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, #be123c 30%, var(--color-border));
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, #be123c 78%, var(--color-text));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browser-config-extension-delete:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #be123c 12%, var(--color-background));
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.browser-config-extension-delete:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.browser-config-extension-delete .material-symbols-outlined {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.browser-config-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -296,6 +361,11 @@
|
|||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.browser-config-note.browser-config-success {
|
||||
background: color-mix(in srgb, #15803d 12%, var(--color-background));
|
||||
color: color-mix(in srgb, var(--color-text) 88%, #166534);
|
||||
}
|
||||
|
||||
.browser-config-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
|
@ -92,6 +93,7 @@ from plugins._browser.helpers.extension_manager import (
|
|||
_normalize_chrome_prodversion,
|
||||
get_extensions_root,
|
||||
parse_chrome_web_store_extension_id,
|
||||
uninstall_browser_extension,
|
||||
)
|
||||
import plugins._browser.helpers.extension_manager as browser_extension_manager_module
|
||||
from plugins._browser.helpers.runtime import (
|
||||
|
|
@ -289,6 +291,59 @@ def test_browser_extension_storage_uses_plugin_user_path(monkeypatch, tmp_path):
|
|||
assert get_extensions_root() == tmp_path / "usr" / "plugins" / "_browser" / "extensions"
|
||||
|
||||
|
||||
def test_browser_extension_manager_uninstalls_only_managed_extensions(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(
|
||||
browser_extension_manager_module.files,
|
||||
"get_abs_path",
|
||||
lambda *parts: str(tmp_path.joinpath(*parts)),
|
||||
)
|
||||
managed_extension = get_extensions_root() / "chrome-web-store" / ("a" * 32)
|
||||
external_extension = tmp_path / "external-extension"
|
||||
for extension_dir, name in (
|
||||
(managed_extension, "Managed Extension"),
|
||||
(external_extension, "External Extension"),
|
||||
):
|
||||
extension_dir.mkdir(parents=True)
|
||||
(extension_dir / "manifest.json").write_text(
|
||||
json.dumps({"name": name, "version": "1.0.0"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
saved_configs = []
|
||||
monkeypatch.setattr(
|
||||
browser_extension_manager_module,
|
||||
"get_browser_config",
|
||||
lambda: {
|
||||
"extension_paths": [str(managed_extension), str(external_extension)],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
browser_extension_manager_module.plugins,
|
||||
"save_plugin_config",
|
||||
lambda _plugin, _project, _agent, config: saved_configs.append(config.copy()),
|
||||
)
|
||||
|
||||
entries = browser_extension_manager_module.list_browser_extensions()
|
||||
managed_entry = next(item for item in entries if item["path"] == str(managed_extension))
|
||||
external_entry = next(item for item in entries if item["path"] == str(external_extension))
|
||||
|
||||
assert managed_entry["can_delete"] is True
|
||||
assert managed_entry["managed"] is True
|
||||
assert external_entry["can_delete"] is False
|
||||
|
||||
result = uninstall_browser_extension(str(managed_extension))
|
||||
|
||||
assert result["name"] == "Managed Extension"
|
||||
assert result["extension_paths"] == [str(external_extension)]
|
||||
assert not managed_extension.exists()
|
||||
assert external_extension.exists()
|
||||
assert saved_configs[-1]["extension_paths"] == [str(external_extension)]
|
||||
|
||||
with pytest.raises(ValueError, match="Only Browser-managed"):
|
||||
uninstall_browser_extension(str(external_extension))
|
||||
assert external_extension.exists()
|
||||
|
||||
|
||||
def test_browser_extension_manager_parses_web_store_urls():
|
||||
extension_id = "a" * 32
|
||||
|
||||
|
|
@ -343,6 +398,7 @@ def test_browser_extension_menu_exposes_agent_and_url_paths():
|
|||
assert "Browser LLM Preset" in html
|
||||
assert "Chrome Extensions" in html
|
||||
assert "Installed extensions" in html
|
||||
assert "deleteExtension(extension)" not in html
|
||||
assert "No extensions installed yet." not in html
|
||||
assert "Browser Extension Settings" not in html
|
||||
assert "<span>Settings</span>" in html
|
||||
|
|
@ -640,9 +696,15 @@ def test_browser_extension_settings_stay_user_facing():
|
|||
config_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "config.html").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
config_store = (
|
||||
PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-config-store.js"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "Choose which installed Chrome extensions Browser loads." in config_html
|
||||
assert "Installed extensions" in config_html
|
||||
assert "extensionDeleteTitle(extension)" in config_html
|
||||
assert "deleteExtension(extension)" in config_html
|
||||
assert "Delete extension" in config_store
|
||||
assert "<textarea" not in config_html
|
||||
assert "Enabled extension directories" not in config_html
|
||||
assert "Chrome Web Store URL installs" not in config_html
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue