From 5f17ba55ed688065653d36489ef2357cb0e68b19 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Thu, 25 Jun 2026 14:35:26 +0800 Subject: [PATCH] Fix gallery-installed extension activation --- CHANGELOG.md | 4 + api/extensions.py | 117 ++++++++++++++++++++-- docs/EXTENSIONS.md | 16 +++ tests/test_extension_hooks.py | 33 ++++++ tests/test_issue4746_extension_gallery.py | 47 +++++++++ 5 files changed, 210 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e230522..6e068f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- **Gallery-installed extensions are now activated after install instead of only being marked installed.** When an extension was installed from Settings -> Extensions without also preconfiguring `HERMES_WEBUI_EXTENSION_MANIFEST`, the gallery wrote files and showed the Installed state but the app shell did not inject the extension scripts. A second failure hit subdirectory manifests such as `desktop-companion/manifest.json`: relative assets like `assets/companion-adapter.js` were resolved as `/extensions/assets/...` instead of `/extensions/desktop-companion/assets/...`, producing a 404 and MIME-type console error. Gallery installs now build a runtime manifest from each installed extension's `manifest.json`, and explicit subdirectory manifests resolve relative assets from their manifest directory. + ## [v0.51.648] — 2026-06-25 — Release XD (settled tool cards keep their full output) ### Fixed diff --git a/api/extensions.py b/api/extensions.py index b2498689..77327063 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -417,7 +417,7 @@ def _manifest_path(root: Path) -> Optional[Path]: return manifest -def _manifest_asset_url(value: object) -> str: +def _manifest_asset_url(value: object, asset_base: str = "") -> str: """Normalize a manifest asset entry to the existing same-origin URL format.""" if not isinstance(value, str): return "" @@ -432,7 +432,32 @@ def _manifest_asset_url(value: object) -> str: # are still allowed and go through the same validator as env-configured URLs. if item.startswith("/"): return item - return EXTENSION_ROUTE_PREFIX + item + base = asset_base.strip("/") + rel = f"{base}/{item}" if base else item + return EXTENSION_ROUTE_PREFIX + rel + + +def _manifest_asset_value_with_base(value: object, asset_base: str) -> object: + """Rewrite a manifest asset value so it remains relative to its manifest file.""" + if not isinstance(value, str): + return value + item = value.strip() + if not item: + return item + parsed = urlsplit(item) + if parsed.scheme or parsed.netloc or item.startswith("//") or item.startswith("/"): + return item + base = asset_base.strip("/") + return f"{base}/{item}" if base else item + + +def _copy_manifest_entry_with_asset_base(entry: Dict[str, object], asset_base: str) -> Dict[str, object]: + copied = dict(entry) + for key in ("scripts", "stylesheets"): + values = copied.get(key) + if isinstance(values, list): + copied[key] = [_manifest_asset_value_with_base(value, asset_base) for value in values] + return copied def _manifest_entry_text(entry: Dict[str, object], key: str) -> str: @@ -606,6 +631,7 @@ def _empty_manifest_status(path_status: str) -> Dict[str, Any]: "configured": path_status != "not_configured", "loaded": False, "status": path_status, + "_asset_base": "", "entry_count": 0, "script_count": 0, "stylesheet_count": 0, @@ -613,6 +639,62 @@ def _empty_manifest_status(path_status: str) -> Dict[str, Any]: } +def _manifest_asset_base(root: Path, manifest_file: Path) -> str: + try: + rel_parent = manifest_file.parent.relative_to(root).as_posix() + except ValueError: + return "" + return "" if rel_parent == "." else rel_parent + + +def _gallery_installed_runtime_manifest( + root: Path, diagnostics: Optional[Dict[str, Any]] = None +) -> Optional[Dict[str, object]]: + """Build a runtime manifest from gallery-installed extension manifests.""" + install_manifest = _load_install_manifest() + installed = install_manifest.get("installed", {}) + if not isinstance(installed, dict): + return None + entries: List[Dict[str, object]] = [] + for ext_id in sorted(installed): + if not _valid_extension_id(ext_id): + continue + manifest_file = root / ext_id / "manifest.json" + try: + if not manifest_file.exists() or not manifest_file.is_file(): + _add_diagnostic_warning(diagnostics, "gallery_manifest_missing", "gallery") + continue + manifest = json.loads(_read_manifest_text(manifest_file)) + except _ManifestTooLarge: + _add_diagnostic_warning(diagnostics, "gallery_manifest_oversized", "gallery") + continue + except json.JSONDecodeError: + _add_diagnostic_warning(diagnostics, "gallery_manifest_malformed", "gallery") + continue + except RecursionError: + _add_diagnostic_warning(diagnostics, "gallery_manifest_too_deeply_nested", "gallery") + continue + except (OSError, UnicodeDecodeError): + _add_diagnostic_warning(diagnostics, "gallery_manifest_unreadable", "gallery") + continue + asset_base = ext_id + if isinstance(manifest, dict): + top_entry: Dict[str, object] = {"id": ext_id} + for key in ("name", "enabled", "scripts", "stylesheets", "sidecar"): + if key in manifest: + top_entry[key] = manifest[key] + if any(key in top_entry for key in ("scripts", "stylesheets", "sidecar")): + entries.append(_copy_manifest_entry_with_asset_base(top_entry, asset_base)) + for _source, _index, entry in _manifest_extension_entries(manifest): + copied = _copy_manifest_entry_with_asset_base(entry, asset_base) + if not _valid_extension_id(copied.get("id")): + copied["id"] = ext_id + entries.append(copied) + if not entries: + return None + return {"extensions": entries} + + def _load_manifest_with_status( root: Path, diagnostics: Optional[Dict[str, Any]] = None ) -> Tuple[Optional[object], Dict[str, Any]]: @@ -622,6 +704,17 @@ def _load_manifest_with_status( if manifest_file is None: if path_status == "invalid_path": _add_diagnostic_warning(diagnostics, "manifest_invalid_path", "manifest") + elif path_status == "not_configured": + manifest = _gallery_installed_runtime_manifest(root, diagnostics) + if manifest is not None: + manifest_status.update( + { + "loaded": True, + "status": "gallery_installed", + "_asset_base": "", + } + ) + return manifest, manifest_status return None, manifest_status try: if not manifest_file.exists() or not manifest_file.is_file(): @@ -630,7 +723,13 @@ def _load_manifest_with_status( _add_diagnostic_warning(diagnostics, "manifest_missing", "manifest") return None, manifest_status manifest = json.loads(_read_manifest_text(manifest_file)) - manifest_status.update({"loaded": True, "status": "loaded"}) + manifest_status.update( + { + "loaded": True, + "status": "loaded", + "_asset_base": _manifest_asset_base(root, manifest_file), + } + ) return manifest, manifest_status except _ManifestTooLarge: _log.warning("Configured extension manifest exceeds %d bytes", _MAX_MANIFEST_BYTES) @@ -729,6 +828,7 @@ def _read_manifest_urls_with_diagnostics( scripts: List[str] = [] stylesheets: List[str] = [] sidecars: List[Dict[str, str]] = [] + asset_base = str(manifest_status.get("_asset_base", "") or "") entries = _iter_manifest_entries(manifest, disabled_ids=disabled_ids) manifest_status["entry_count"] = len(entries) scripts_full = False @@ -751,7 +851,7 @@ def _read_manifest_urls_with_diagnostics( for value in _entry_asset_values(entry, "scripts"): if not _append_safe_asset_url( scripts, - _manifest_asset_url(value), + _manifest_asset_url(value, asset_base), script_source, diagnostics=diagnostics, ): @@ -761,7 +861,7 @@ def _read_manifest_urls_with_diagnostics( for value in _entry_asset_values(entry, "stylesheets"): if not _append_safe_asset_url( stylesheets, - _manifest_asset_url(value), + _manifest_asset_url(value, asset_base), stylesheet_source, diagnostics=diagnostics, ): @@ -770,7 +870,7 @@ def _read_manifest_urls_with_diagnostics( manifest_status.update( { "loaded": True, - "status": "loaded", + "status": manifest_status.get("status") or "loaded", "script_count": len(scripts), "stylesheet_count": len(stylesheets), "sidecar_count": len(sidecars), @@ -873,6 +973,9 @@ def get_extension_status() -> Dict[str, Any]: manifest_stylesheets or None, diagnostics=diagnostics, ) + public_manifest_status = { + key: value for key, value in manifest_status.items() if not key.startswith("_") + } return { "enabled": True, "extension_dir_configured": True, @@ -887,7 +990,7 @@ def get_extension_status() -> Dict[str, Any]: "manifest_extensions": len(extensions), "user_disabled": user_disabled_count, }, - "manifest": manifest_status, + "manifest": public_manifest_status, "extensions": extensions, "gallery_installed": _load_install_manifest().get("installed", {}), "warnings": diagnostics["warnings"], diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 38a3a023..b1499d93 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -95,6 +95,22 @@ may be kept in the manifest with the JSON boolean `"enabled": false`. Explicit `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` still work and are appended after manifest assets, with duplicates ignored. +When an extension is installed from Settings -> Extensions, WebUI records the +installed package and loads that package's `manifest.json` automatically on the +next app-shell render. In this gallery-installed mode, a manifest located at +`HERMES_WEBUI_EXTENSION_DIR//manifest.json` resolves bare relative +assets relative to that package directory. For example, +`"scripts": ["assets/companion-adapter.js"]` in +`desktop-companion/manifest.json` injects +`/extensions/desktop-companion/assets/companion-adapter.js`. + +Manual manifests configured with `HERMES_WEBUI_EXTENSION_MANIFEST` follow the +same rule: relative assets resolve from the manifest file's directory. A root +manifest such as `extensions.json` keeps the existing +`/extensions/` behavior, while a subdirectory manifest such as +`desktop-companion/manifest.json` resolves relative assets under +`/extensions/desktop-companion/`. + Extension entries may also declare a read-only loopback sidecar for diagnostics: ```json diff --git a/tests/test_extension_hooks.py b/tests/test_extension_hooks.py index e3daed63..b8c3b752 100644 --- a/tests/test_extension_hooks.py +++ b/tests/test_extension_hooks.py @@ -196,6 +196,39 @@ def test_extension_manifest_adds_bundled_assets_before_env_urls(tmp_path, monkey } +def test_extension_manifest_relative_assets_resolve_from_manifest_directory(tmp_path, monkeypatch): + root = tmp_path / "extensions" + root.mkdir() + ext_dir = root / "desktop-companion" + ext_dir.mkdir() + (ext_dir / "manifest.json").write_text( + """ + { + "extensions": [ + { + "id": "desktop-companion", + "scripts": ["assets/companion-adapter.js"], + "stylesheets": ["assets/companion-adapter.css"] + } + ] + } + """, + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "desktop-companion/manifest.json") + monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False) + monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False) + + from api.extensions import get_extension_config + + assert get_extension_config() == { + "enabled": True, + "script_urls": ["/extensions/desktop-companion/assets/companion-adapter.js"], + "stylesheet_urls": ["/extensions/desktop-companion/assets/companion-adapter.css"], + } + + def test_extension_manifest_reuses_url_safety_rules(tmp_path, monkeypatch): monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False) monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False) diff --git a/tests/test_issue4746_extension_gallery.py b/tests/test_issue4746_extension_gallery.py index 5a5c0b50..2478a2be 100644 --- a/tests/test_issue4746_extension_gallery.py +++ b/tests/test_issue4746_extension_gallery.py @@ -93,6 +93,53 @@ def test_install_prefixed_zip(monkeypatch, tmp_path): assert "manifest.json" in manifest["installed"]["my-ext"]["files"] +def test_gallery_installed_extension_becomes_runtime_manifest(monkeypatch, tmp_path): + """Installed gallery extensions are injected without a separate manifest env var.""" + ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path) + monkeypatch.delenv("HERMES_WEBUI_EXTENSION_MANIFEST", raising=False) + monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False) + monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False) + import api.extensions as ext_mod + + files = { + "my-ext/manifest.json": json.dumps( + { + "version": "1.0.0", + "extensions": [ + { + "id": "my-ext", + "name": "My Extension", + "scripts": ["assets/app.js"], + "stylesheets": ["assets/app.css"], + } + ], + } + ), + "my-ext/assets/app.js": "console.log('gallery runtime');", + "my-ext/assets/app.css": "body{}", + } + zip_bytes = _make_zip(files) + sha = hashlib.sha256(zip_bytes).hexdigest() + + monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes) + + ext_mod.install_extension( + "my-ext", + "https://hermes-webui.github.io/exts/my-ext.zip", + sha, + ) + + assert ext_mod.get_extension_config() == { + "enabled": True, + "script_urls": ["/extensions/my-ext/assets/app.js"], + "stylesheet_urls": ["/extensions/my-ext/assets/app.css"], + } + status = ext_mod.get_extension_status() + assert status["manifest"]["status"] == "gallery_installed" + assert status["counts"]["manifest_extensions"] == 1 + assert status["extensions"][0]["id"] == "my-ext" + + def test_install_bad_hash(monkeypatch, tmp_path): ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path) import api.extensions as ext_mod