From c553e91c0310c5683efa422b0a6cfeda0c58070e Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Sat, 2 May 2026 20:02:28 +0200 Subject: [PATCH] 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. --- plugins/_browser/helpers/extension_manager.py | 124 ++++++++++++++++++ plugins/_browser/webui/browser-panel.html | 63 ++++++++- plugins/_browser/webui/browser-store.js | 30 +++++ tests/test_browser_agent_regressions.py | 40 ++++++ 4 files changed, 250 insertions(+), 7 deletions(-) diff --git a/plugins/_browser/helpers/extension_manager.py b/plugins/_browser/helpers/extension_manager.py index 5af03b4fa..b3c37642e 100644 --- a/plugins/_browser/helpers/extension_manager.py +++ b/plugins/_browser/helpers/extension_manager.py @@ -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"(? 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: diff --git a/plugins/_browser/webui/browser-panel.html b/plugins/_browser/webui/browser-panel.html index c25cfa167..398cd3ad0 100644 --- a/plugins/_browser/webui/browser-panel.html +++ b/plugins/_browser/webui/browser-panel.html @@ -117,19 +117,30 @@ x-show="$store.browserPage.extensionsListLoading">progress_activity - + - - - + + + open_in_new + Open + + + + + - + @@ -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; diff --git a/plugins/_browser/webui/browser-store.js b/plugins/_browser/webui/browser-store.js index 7bf4df547..536b0b736 100644 --- a/plugins/_browser/webui/browser-store.js +++ b/plugins/_browser/webui/browser-store.js @@ -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?.(); diff --git a/tests/test_browser_agent_regressions.py b/tests/test_browser_agent_regressions.py index 71a2cbae9..e4c339468 100644 --- a/tests/test_browser_agent_regressions.py +++ b/tests/test_browser_agent_regressions.py @@ -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 "Settings" in html + assert "extensionHasOpenUi(extension)" in html + assert "openExtensionUi(extension)" in html + assert "Open" in html assert "hasExtensionInstallUrl()" in html assert "malicious or buggy extensions" in html assert skill.exists()