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()