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.

Alessandro committed May 2, 2026 at 20:02 UTC c553e91c0310c5683efa422b0a6cfeda0c58070e
4 files changed +250 -7
plugins/_browser/helpers/extension_manager.py
+124
@@ -1,5 +1,7 @@
1 from __future__ import annotations
2
3 +import base64
4 +import hashlib
5 import json
6 import os
7 import re
@@ -22,6 +24,7 @@ WEB_STORE_ID_RE = re.compile(r"(?<![a-p])([a-p]{32})(?![a-p])")
24 CHROME_VERSION_RE = re.compile(r"(\d+(?:\.\d+){0,3})")
25 CHROME_I18N_MESSAGE_RE = re.compile(r"__MSG_([A-Za-z0-9_@.-]+)__")
26 DEFAULT_CHROME_PRODVERSION = "140.0.0.0"
27 +EXTENSION_ID_ALPHABET = "abcdefghijklmnop"
28 CHROME_VERSION_COMMANDS = (
29 ("google-chrome", "--version"),
30 ("chromium", "--version"),
@@ -301,12 +304,17 @@ def _extension_entry(extension_dir: Path, enabled_paths: set[str]) -> dict[str,
304 manifest = _read_manifest(extension_dir)
305 extension_path = str(extension_dir)
306 can_delete = _is_managed_extension_dir(extension_dir)
307 + extension_id = _extension_runtime_id(extension_dir, manifest)
308 + source_id = _extension_source_id(extension_dir)
309 + ui = _extension_ui(extension_id, manifest)
310 name = (
311 _manifest_label(extension_dir, manifest, "name")
312 or _manifest_label(extension_dir, manifest, "short_name")
313 or extension_dir.name
314 )
315 return {
316 + "id": extension_id,
317 + "source_id": source_id,
318 "name": name,
319 "raw_name": manifest.get("name") or "",
320 "description": _manifest_label(extension_dir, manifest, "description"),
@@ -315,6 +323,10 @@ def _extension_entry(extension_dir: Path, enabled_paths: set[str]) -> dict[str,
323 "enabled": extension_path in enabled_paths,
324 "managed": can_delete,
325 "can_delete": can_delete,
326 + "has_ui": bool(ui["open_url"]),
327 + "open_url": ui["open_url"],
328 + "open_label": ui["open_label"],
329 + "ui": ui,
330 }
331
332
@@ -335,6 +347,118 @@ def _read_manifest(extension_path: Path) -> dict[str, Any]:
347 return {}
348
349
350 +def _extension_runtime_id(extension_dir: Path, manifest: dict[str, Any]) -> str:
351 + key_id = _extension_id_from_manifest_key(str(manifest.get("key") or ""))
352 + if key_id:
353 + return key_id
354 + return _extension_id_from_path(extension_dir)
355 +
356 +
357 +def _extension_source_id(extension_dir: Path) -> str:
358 + name = extension_dir.name
359 + if not EXTENSION_ID_RE.fullmatch(name):
360 + return ""
361 + try:
362 + if extension_dir.parent.name == "chrome-web-store":
363 + return name
364 + except OSError:
365 + return ""
366 + return ""
367 +
368 +
369 +def _extension_id_from_manifest_key(key: str) -> str:
370 + raw_key = str(key or "").strip()
371 + if not raw_key:
372 + return ""
373 + try:
374 + padding = "=" * (-len(raw_key) % 4)
375 + public_key = base64.b64decode(raw_key + padding, validate=True)
376 + except Exception:
377 + return ""
378 + return _extension_id_from_hash_input(public_key)
379 +
380 +
381 +def _extension_id_from_path(extension_dir: Path) -> str:
382 + return _extension_id_from_hash_input(str(extension_dir).encode("utf-8"))
383 +
384 +
385 +def _extension_id_from_hash_input(value: bytes) -> str:
386 + digest = hashlib.sha256(value).hexdigest()[:32]
387 + return "".join(EXTENSION_ID_ALPHABET[int(char, 16)] for char in digest)
388 +
389 +
390 +def _extension_ui(extension_id: str, manifest: dict[str, Any]) -> dict[str, Any]:
391 + targets = _extension_ui_targets(extension_id, manifest)
392 + primary = targets[0] if targets else {}
393 + return {
394 + "open_url": primary.get("url", ""),
395 + "open_label": primary.get("label", ""),
396 + "targets": targets,
397 + }
398 +
399 +
400 +def _extension_ui_targets(extension_id: str, manifest: dict[str, Any]) -> list[dict[str, str]]:
401 + candidates: list[tuple[str, str, Any]] = [
402 + ("options", "Options", _manifest_nested_value(manifest, "options_ui", "page")),
403 + ("options", "Options", manifest.get("options_page")),
404 + ("popup", "Popup", _manifest_nested_value(manifest, "action", "default_popup")),
405 + ("popup", "Popup", _manifest_nested_value(manifest, "browser_action", "default_popup")),
406 + ("popup", "Popup", _manifest_nested_value(manifest, "page_action", "default_popup")),
407 + ("side_panel", "Side panel", _manifest_nested_value(manifest, "side_panel", "default_path")),
408 + ("devtools", "DevTools", manifest.get("devtools_page")),
409 + ]
410 +
411 + chrome_url_overrides = manifest.get("chrome_url_overrides")
412 + if isinstance(chrome_url_overrides, dict):
413 + candidates.extend(
414 + (
415 + (f"chrome_url_override_{name}", _extension_override_label(name), page)
416 + for name, page in chrome_url_overrides.items()
417 + )
418 + )
419 +
420 + targets: list[dict[str, str]] = []
421 + seen_urls: set[str] = set()
422 + for kind, label, page in candidates:
423 + url = _extension_page_url(extension_id, page)
424 + if not url or url in seen_urls:
425 + continue
426 + seen_urls.add(url)
427 + targets.append(
428 + {
429 + "kind": kind,
430 + "label": label,
431 + "page": str(page or "").strip(),
432 + "url": url,
433 + }
434 + )
435 + return targets
436 +
437 +
438 +def _manifest_nested_value(manifest: dict[str, Any], key: str, nested_key: str) -> Any:
439 + value = manifest.get(key)
440 + if not isinstance(value, dict):
441 + return ""
442 + return value.get(nested_key)
443 +
444 +
445 +def _extension_override_label(name: str) -> str:
446 + normalized = str(name or "").replace("_", " ").strip()
447 + return normalized[:1].upper() + normalized[1:] if normalized else "Extension page"
448 +
449 +
450 +def _extension_page_url(extension_id: str, page: Any) -> str:
451 + page_path = str(page or "").strip()
452 + if not extension_id or not page_path:
453 + return ""
454 + if re.match(r"^[a-z][a-z0-9+.-]*:", page_path, flags=re.IGNORECASE):
455 + return ""
456 + page_path = page_path.lstrip("/")
457 + if not page_path:
458 + return ""
459 + return f"chrome-extension://{extension_id}/{page_path}"
460 +
461 +
462 def _manifest_label(extension_dir: Path, manifest: dict[str, Any], key: str) -> str:
463 value = str(manifest.get(key) or "").strip()
464 if not value:
plugins/_browser/webui/browser-panel.html
+56 -7
@@ -117,19 +117,30 @@
117 x-show="$store.browserPage.extensionsListLoading">progress_activity</span>
118 </div>
119 <template x-for="extension in $store.browserPage.extensionsList" :key="extension.path">
120 - <label class="browser-extension-row" :title="extension.path">
120 + <div class="browser-extension-row" :title="extension.path">
121 <span class="browser-extension-row-text">
122 <span class="browser-extension-name" x-text="extension.name || 'Unnamed extension'"></span>
123 <span class="browser-extension-meta"
124 x-text="$store.browserPage.extensionVersionLabel(extension)"></span>
125 </span>
126 - <span class="browser-extension-toggle">
127 - <input type="checkbox" :checked="extension.enabled"
128 - :disabled="$store.browserPage.extensionToggleLoadingPath === extension.path"
129 - @change="$store.browserPage.setExtensionEnabled(extension, $event.target.checked, $event.target)" />
130 - <span class="browser-extension-switch"></span>
126 + <span class="browser-extension-row-actions">
127 + <button type="button" class="browser-extension-open"
128 + x-show="$store.browserPage.extensionHasOpenUi(extension)"
129 + :title="$store.browserPage.extensionOpenTitle(extension)"
130 + :aria-label="$store.browserPage.extensionOpenTitle(extension)"
131 + :disabled="!extension.enabled || $store.browserPage.isBusy()"
132 + @click.stop="$store.browserPage.openExtensionUi(extension)">
133 + <span class="material-symbols-outlined">open_in_new</span>
134 + <span>Open</span>
135 + </button>
136 + <label class="browser-extension-toggle">
137 + <input type="checkbox" :checked="extension.enabled"
138 + :disabled="$store.browserPage.extensionToggleLoadingPath === extension.path"
139 + @change="$store.browserPage.setExtensionEnabled(extension, $event.target.checked, $event.target)" />
140 + <span class="browser-extension-switch"></span>
141 + </label>
142 </span>
132 - </label>
143 + </div>
144 </template>
145 </div>
146 <button type="button" class="dropdown-item" @click="$store.browserPage.openExtensionsSettings()">
@@ -849,6 +860,44 @@
860 color: var(--color-text);
861 }
862
863 + .browser-extension-row-actions {
864 + display: inline-flex;
865 + align-items: center;
866 + gap: 7px;
867 + }
868 +
869 + .browser-extension-open {
870 + display: inline-flex;
871 + align-items: center;
872 + justify-content: center;
873 + gap: 4px;
874 + min-width: 0;
875 + min-height: 28px;
876 + padding: 0 8px;
877 + border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
878 + border-radius: 6px;
879 + background: color-mix(in srgb, var(--color-panel) 70%, transparent);
880 + color: var(--color-text);
881 + font: inherit;
882 + font-size: 0.76rem;
883 + font-weight: 650;
884 + cursor: pointer;
885 + }
886 +
887 + .browser-extension-open:hover:not(:disabled) {
888 + border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
889 + background: color-mix(in srgb, var(--color-primary) 13%, var(--color-background));
890 + }
891 +
892 + .browser-extension-open:disabled {
893 + cursor: not-allowed;
894 + opacity: 0.52;
895 + }
896 +
897 + .browser-extension-open .material-symbols-outlined {
898 + font-size: 15px;
899 + }
900 +
901 .browser-extension-toggle {
902 position: relative;
903 display: inline-flex;
plugins/_browser/webui/browser-store.js
+30
@@ -526,6 +526,36 @@ const model = {
526 return version ? `v${version}` : "Unpacked extension";
527 },
528
529 + extensionOpenUrl(extension) {
530 + return String(extension?.open_url || extension?.ui?.open_url || "").trim();
531 + },
532 +
533 + extensionHasOpenUi(extension) {
534 + return Boolean(this.extensionOpenUrl(extension));
535 + },
536 +
537 + extensionOpenTitle(extension) {
538 + const label = String(extension?.open_label || extension?.ui?.open_label || "Extension UI").trim();
539 + const name = String(extension?.name || "extension").trim();
540 + if (!extension?.enabled) {
541 + return `Enable ${name} before opening ${label}.`;
542 + }
543 + return `Open ${label} for ${name}`;
544 + },
545 +
546 + async openExtensionUi(extension) {
547 + const url = this.extensionOpenUrl(extension);
548 + if (!url) return;
549 + this.extensionActionMessage = "";
550 + this.extensionActionError = "";
551 + if (!extension?.enabled) {
552 + this.extensionActionError = `Enable ${extension?.name || "this extension"} before opening it.`;
553 + return;
554 + }
555 + this.closeExtensionsMenu();
556 + await this.command("open", { url });
557 + },
558 +
559 _prefillAgentPrompt(prompt) {
560 chatInputStore.message = prompt;
561 chatInputStore.adjustTextareaHeight?.();
tests/test_browser_agent_regressions.py
+40
@@ -344,6 +344,43 @@ def test_browser_extension_manager_uninstalls_only_managed_extensions(monkeypatc
344 assert external_extension.exists()
345
346
347 +def test_browser_extension_manager_exposes_openable_manifest_ui(monkeypatch, tmp_path):
348 + monkeypatch.setattr(
349 + browser_extension_manager_module.files,
350 + "get_abs_path",
351 + lambda *parts: str(tmp_path.joinpath(*parts)),
352 + )
353 + extension_dir = get_extensions_root() / "local-options"
354 + extension_dir.mkdir(parents=True)
355 + (extension_dir / "manifest.json").write_text(
356 + json.dumps(
357 + {
358 + "manifest_version": 3,
359 + "name": "Openable Extension",
360 + "version": "1.0.0",
361 + "options_ui": {"page": "options/index.html"},
362 + "action": {"default_popup": "popup.html"},
363 + }
364 + ),
365 + encoding="utf-8",
366 + )
367 +
368 + monkeypatch.setattr(
369 + browser_extension_manager_module,
370 + "get_browser_config",
371 + lambda: {"extension_paths": [str(extension_dir)]},
372 + )
373 +
374 + entry = browser_extension_manager_module.list_browser_extensions()[0]
375 + expected_id = browser_extension_manager_module._extension_id_from_path(extension_dir)
376 +
377 + assert entry["id"] == expected_id
378 + assert entry["has_ui"] is True
379 + assert entry["open_label"] == "Options"
380 + assert entry["open_url"] == f"chrome-extension://{expected_id}/options/index.html"
381 + assert entry["ui"]["targets"][1]["url"] == f"chrome-extension://{expected_id}/popup.html"
382 +
383 +
384 def test_browser_extension_manager_parses_web_store_urls():
385 extension_id = "a" * 32
386
@@ -402,6 +439,9 @@ def test_browser_extension_menu_exposes_agent_and_url_paths():
439 assert "No extensions installed yet." not in html
440 assert "Browser Extension Settings" not in html
441 assert "<span>Settings</span>" in html
442 + assert "extensionHasOpenUi(extension)" in html
443 + assert "openExtensionUi(extension)" in html
444 + assert "<span>Open</span>" in html
445 assert "hasExtensionInstallUrl()" in html
446 assert "malicious or buggy extensions" in html
447 assert skill.exists()