Fix gallery-installed extension activation

This commit is contained in:
Frank Song authored and nesquena-hermes committed 2026-06-25 07:10:17 +00:00
1 parent cea9244460
commit 5f17ba55ed
5 files changed
+210 -7

No files matched your search

+4
View File
@@ -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
+110 -7
View File
@@ -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"],
+16
View File
@@ -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/<extension-id>/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/<asset-path>` 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
+33
View File
@@ -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)
+47
View File
@@ -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