mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-19 07:59:34 +00:00
Add Browser extension UI open action
Detect openable Chrome extension UI pages from manifests and expose resolved chrome-extension URLs to the Browser UI. Render an Open button in the compact Browser extension dropdown and cover manifest UI metadata with regression tests.
This commit is contained in:
parent
92ae20da2c
commit
c553e91c03
4 changed files with 250 additions and 7 deletions
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
|
@ -22,6 +24,7 @@ WEB_STORE_ID_RE = re.compile(r"(?<![a-p])([a-p]{32})(?![a-p])")
|
|||
CHROME_VERSION_RE = re.compile(r"(\d+(?:\.\d+){0,3})")
|
||||
CHROME_I18N_MESSAGE_RE = re.compile(r"__MSG_([A-Za-z0-9_@.-]+)__")
|
||||
DEFAULT_CHROME_PRODVERSION = "140.0.0.0"
|
||||
EXTENSION_ID_ALPHABET = "abcdefghijklmnop"
|
||||
CHROME_VERSION_COMMANDS = (
|
||||
("google-chrome", "--version"),
|
||||
("chromium", "--version"),
|
||||
|
|
@ -301,12 +304,17 @@ def _extension_entry(extension_dir: Path, enabled_paths: set[str]) -> dict[str,
|
|||
manifest = _read_manifest(extension_dir)
|
||||
extension_path = str(extension_dir)
|
||||
can_delete = _is_managed_extension_dir(extension_dir)
|
||||
extension_id = _extension_runtime_id(extension_dir, manifest)
|
||||
source_id = _extension_source_id(extension_dir)
|
||||
ui = _extension_ui(extension_id, manifest)
|
||||
name = (
|
||||
_manifest_label(extension_dir, manifest, "name")
|
||||
or _manifest_label(extension_dir, manifest, "short_name")
|
||||
or extension_dir.name
|
||||
)
|
||||
return {
|
||||
"id": extension_id,
|
||||
"source_id": source_id,
|
||||
"name": name,
|
||||
"raw_name": manifest.get("name") or "",
|
||||
"description": _manifest_label(extension_dir, manifest, "description"),
|
||||
|
|
@ -315,6 +323,10 @@ def _extension_entry(extension_dir: Path, enabled_paths: set[str]) -> dict[str,
|
|||
"enabled": extension_path in enabled_paths,
|
||||
"managed": can_delete,
|
||||
"can_delete": can_delete,
|
||||
"has_ui": bool(ui["open_url"]),
|
||||
"open_url": ui["open_url"],
|
||||
"open_label": ui["open_label"],
|
||||
"ui": ui,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -335,6 +347,118 @@ def _read_manifest(extension_path: Path) -> dict[str, Any]:
|
|||
return {}
|
||||
|
||||
|
||||
def _extension_runtime_id(extension_dir: Path, manifest: dict[str, Any]) -> str:
|
||||
key_id = _extension_id_from_manifest_key(str(manifest.get("key") or ""))
|
||||
if key_id:
|
||||
return key_id
|
||||
return _extension_id_from_path(extension_dir)
|
||||
|
||||
|
||||
def _extension_source_id(extension_dir: Path) -> str:
|
||||
name = extension_dir.name
|
||||
if not EXTENSION_ID_RE.fullmatch(name):
|
||||
return ""
|
||||
try:
|
||||
if extension_dir.parent.name == "chrome-web-store":
|
||||
return name
|
||||
except OSError:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def _extension_id_from_manifest_key(key: str) -> str:
|
||||
raw_key = str(key or "").strip()
|
||||
if not raw_key:
|
||||
return ""
|
||||
try:
|
||||
padding = "=" * (-len(raw_key) % 4)
|
||||
public_key = base64.b64decode(raw_key + padding, validate=True)
|
||||
except Exception:
|
||||
return ""
|
||||
return _extension_id_from_hash_input(public_key)
|
||||
|
||||
|
||||
def _extension_id_from_path(extension_dir: Path) -> str:
|
||||
return _extension_id_from_hash_input(str(extension_dir).encode("utf-8"))
|
||||
|
||||
|
||||
def _extension_id_from_hash_input(value: bytes) -> str:
|
||||
digest = hashlib.sha256(value).hexdigest()[:32]
|
||||
return "".join(EXTENSION_ID_ALPHABET[int(char, 16)] for char in digest)
|
||||
|
||||
|
||||
def _extension_ui(extension_id: str, manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
targets = _extension_ui_targets(extension_id, manifest)
|
||||
primary = targets[0] if targets else {}
|
||||
return {
|
||||
"open_url": primary.get("url", ""),
|
||||
"open_label": primary.get("label", ""),
|
||||
"targets": targets,
|
||||
}
|
||||
|
||||
|
||||
def _extension_ui_targets(extension_id: str, manifest: dict[str, Any]) -> list[dict[str, str]]:
|
||||
candidates: list[tuple[str, str, Any]] = [
|
||||
("options", "Options", _manifest_nested_value(manifest, "options_ui", "page")),
|
||||
("options", "Options", manifest.get("options_page")),
|
||||
("popup", "Popup", _manifest_nested_value(manifest, "action", "default_popup")),
|
||||
("popup", "Popup", _manifest_nested_value(manifest, "browser_action", "default_popup")),
|
||||
("popup", "Popup", _manifest_nested_value(manifest, "page_action", "default_popup")),
|
||||
("side_panel", "Side panel", _manifest_nested_value(manifest, "side_panel", "default_path")),
|
||||
("devtools", "DevTools", manifest.get("devtools_page")),
|
||||
]
|
||||
|
||||
chrome_url_overrides = manifest.get("chrome_url_overrides")
|
||||
if isinstance(chrome_url_overrides, dict):
|
||||
candidates.extend(
|
||||
(
|
||||
(f"chrome_url_override_{name}", _extension_override_label(name), page)
|
||||
for name, page in chrome_url_overrides.items()
|
||||
)
|
||||
)
|
||||
|
||||
targets: list[dict[str, str]] = []
|
||||
seen_urls: set[str] = set()
|
||||
for kind, label, page in candidates:
|
||||
url = _extension_page_url(extension_id, page)
|
||||
if not url or url in seen_urls:
|
||||
continue
|
||||
seen_urls.add(url)
|
||||
targets.append(
|
||||
{
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"page": str(page or "").strip(),
|
||||
"url": url,
|
||||
}
|
||||
)
|
||||
return targets
|
||||
|
||||
|
||||
def _manifest_nested_value(manifest: dict[str, Any], key: str, nested_key: str) -> Any:
|
||||
value = manifest.get(key)
|
||||
if not isinstance(value, dict):
|
||||
return ""
|
||||
return value.get(nested_key)
|
||||
|
||||
|
||||
def _extension_override_label(name: str) -> str:
|
||||
normalized = str(name or "").replace("_", " ").strip()
|
||||
return normalized[:1].upper() + normalized[1:] if normalized else "Extension page"
|
||||
|
||||
|
||||
def _extension_page_url(extension_id: str, page: Any) -> str:
|
||||
page_path = str(page or "").strip()
|
||||
if not extension_id or not page_path:
|
||||
return ""
|
||||
if re.match(r"^[a-z][a-z0-9+.-]*:", page_path, flags=re.IGNORECASE):
|
||||
return ""
|
||||
page_path = page_path.lstrip("/")
|
||||
if not page_path:
|
||||
return ""
|
||||
return f"chrome-extension://{extension_id}/{page_path}"
|
||||
|
||||
|
||||
def _manifest_label(extension_dir: Path, manifest: dict[str, Any], key: str) -> str:
|
||||
value = str(manifest.get(key) or "").strip()
|
||||
if not value:
|
||||
|
|
|
|||
|
|
@ -117,19 +117,30 @@
|
|||
x-show="$store.browserPage.extensionsListLoading">progress_activity</span>
|
||||
</div>
|
||||
<template x-for="extension in $store.browserPage.extensionsList" :key="extension.path">
|
||||
<label class="browser-extension-row" :title="extension.path">
|
||||
<div class="browser-extension-row" :title="extension.path">
|
||||
<span class="browser-extension-row-text">
|
||||
<span class="browser-extension-name" x-text="extension.name || 'Unnamed extension'"></span>
|
||||
<span class="browser-extension-meta"
|
||||
x-text="$store.browserPage.extensionVersionLabel(extension)"></span>
|
||||
</span>
|
||||
<span class="browser-extension-toggle">
|
||||
<input type="checkbox" :checked="extension.enabled"
|
||||
:disabled="$store.browserPage.extensionToggleLoadingPath === extension.path"
|
||||
@change="$store.browserPage.setExtensionEnabled(extension, $event.target.checked, $event.target)" />
|
||||
<span class="browser-extension-switch"></span>
|
||||
<span class="browser-extension-row-actions">
|
||||
<button type="button" class="browser-extension-open"
|
||||
x-show="$store.browserPage.extensionHasOpenUi(extension)"
|
||||
:title="$store.browserPage.extensionOpenTitle(extension)"
|
||||
:aria-label="$store.browserPage.extensionOpenTitle(extension)"
|
||||
:disabled="!extension.enabled || $store.browserPage.isBusy()"
|
||||
@click.stop="$store.browserPage.openExtensionUi(extension)">
|
||||
<span class="material-symbols-outlined">open_in_new</span>
|
||||
<span>Open</span>
|
||||
</button>
|
||||
<label class="browser-extension-toggle">
|
||||
<input type="checkbox" :checked="extension.enabled"
|
||||
:disabled="$store.browserPage.extensionToggleLoadingPath === extension.path"
|
||||
@change="$store.browserPage.setExtensionEnabled(extension, $event.target.checked, $event.target)" />
|
||||
<span class="browser-extension-switch"></span>
|
||||
</label>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item" @click="$store.browserPage.openExtensionsSettings()">
|
||||
|
|
@ -849,6 +860,44 @@
|
|||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.browser-extension-row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.browser-extension-open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-panel) 70%, transparent);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browser-extension-open:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-primary) 13%, var(--color-background));
|
||||
}
|
||||
|
||||
.browser-extension-open:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
.browser-extension-open .material-symbols-outlined {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.browser-extension-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
|
|
|||
|
|
@ -526,6 +526,36 @@ const model = {
|
|||
return version ? `v${version}` : "Unpacked extension";
|
||||
},
|
||||
|
||||
extensionOpenUrl(extension) {
|
||||
return String(extension?.open_url || extension?.ui?.open_url || "").trim();
|
||||
},
|
||||
|
||||
extensionHasOpenUi(extension) {
|
||||
return Boolean(this.extensionOpenUrl(extension));
|
||||
},
|
||||
|
||||
extensionOpenTitle(extension) {
|
||||
const label = String(extension?.open_label || extension?.ui?.open_label || "Extension UI").trim();
|
||||
const name = String(extension?.name || "extension").trim();
|
||||
if (!extension?.enabled) {
|
||||
return `Enable ${name} before opening ${label}.`;
|
||||
}
|
||||
return `Open ${label} for ${name}`;
|
||||
},
|
||||
|
||||
async openExtensionUi(extension) {
|
||||
const url = this.extensionOpenUrl(extension);
|
||||
if (!url) return;
|
||||
this.extensionActionMessage = "";
|
||||
this.extensionActionError = "";
|
||||
if (!extension?.enabled) {
|
||||
this.extensionActionError = `Enable ${extension?.name || "this extension"} before opening it.`;
|
||||
return;
|
||||
}
|
||||
this.closeExtensionsMenu();
|
||||
await this.command("open", { url });
|
||||
},
|
||||
|
||||
_prefillAgentPrompt(prompt) {
|
||||
chatInputStore.message = prompt;
|
||||
chatInputStore.adjustTextareaHeight?.();
|
||||
|
|
|
|||
|
|
@ -344,6 +344,43 @@ def test_browser_extension_manager_uninstalls_only_managed_extensions(monkeypatc
|
|||
assert external_extension.exists()
|
||||
|
||||
|
||||
def test_browser_extension_manager_exposes_openable_manifest_ui(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(
|
||||
browser_extension_manager_module.files,
|
||||
"get_abs_path",
|
||||
lambda *parts: str(tmp_path.joinpath(*parts)),
|
||||
)
|
||||
extension_dir = get_extensions_root() / "local-options"
|
||||
extension_dir.mkdir(parents=True)
|
||||
(extension_dir / "manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Openable Extension",
|
||||
"version": "1.0.0",
|
||||
"options_ui": {"page": "options/index.html"},
|
||||
"action": {"default_popup": "popup.html"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
browser_extension_manager_module,
|
||||
"get_browser_config",
|
||||
lambda: {"extension_paths": [str(extension_dir)]},
|
||||
)
|
||||
|
||||
entry = browser_extension_manager_module.list_browser_extensions()[0]
|
||||
expected_id = browser_extension_manager_module._extension_id_from_path(extension_dir)
|
||||
|
||||
assert entry["id"] == expected_id
|
||||
assert entry["has_ui"] is True
|
||||
assert entry["open_label"] == "Options"
|
||||
assert entry["open_url"] == f"chrome-extension://{expected_id}/options/index.html"
|
||||
assert entry["ui"]["targets"][1]["url"] == f"chrome-extension://{expected_id}/popup.html"
|
||||
|
||||
|
||||
def test_browser_extension_manager_parses_web_store_urls():
|
||||
extension_id = "a" * 32
|
||||
|
||||
|
|
@ -402,6 +439,9 @@ def test_browser_extension_menu_exposes_agent_and_url_paths():
|
|||
assert "No extensions installed yet." not in html
|
||||
assert "Browser Extension Settings" not in html
|
||||
assert "<span>Settings</span>" in html
|
||||
assert "extensionHasOpenUi(extension)" in html
|
||||
assert "openExtensionUi(extension)" in html
|
||||
assert "<span>Open</span>" in html
|
||||
assert "hasExtensionInstallUrl()" in html
|
||||
assert "malicious or buggy extensions" in html
|
||||
assert skill.exists()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue