feat(extensions): add gallery browse, install, and uninstall (#4746)

This commit is contained in:
Rod Boev authored and nesquena-hermes committed 2026-06-25 04:21:50 +00:00
1 parent ed5a217ae1
commit ddd5c8d936
8 files changed
+930 -11

No files matched your search

+242
View File
@@ -14,6 +14,11 @@ import threading
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from urllib.parse import unquote, urlsplit
import hashlib
import io
import time
import zipfile
from urllib.request import urlopen
from api.helpers import _security_headers, j
@@ -46,6 +51,14 @@ class ExtensionToggleError(Exception):
self.status = status
class ExtensionInstallError(Exception):
"""Sanitized extension install/uninstall error safe to return to the browser."""
def __init__(self, message: str, status: int = 400):
super().__init__(message)
self.status = status
EXTENSION_ROUTE_PREFIX = "/extensions/"
_EXTENSION_DIR_ENV = "HERMES_WEBUI_EXTENSION_DIR"
_EXTENSION_SCRIPT_URLS_ENV = "HERMES_WEBUI_EXTENSION_SCRIPT_URLS"
@@ -62,6 +75,15 @@ _EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_EXTENSION_STATE_WARNING_SOURCE = "extension_state"
_EXTENSION_STATE_LOCK = threading.Lock()
_GALLERY_INSTALL_STATE_FILENAME = "extension-install-manifest.json"
_MAX_INSTALL_MANIFEST_BYTES = 128 * 1024
_MAX_GALLERY_INSTALLED_IDS = 256
_MAX_ZIP_DOWNLOAD_BYTES = 32 * 1024 * 1024
_REGISTRY_URL = "https://hermes-webui.github.io/hermes-webui-extensions/registry.json"
_REGISTRY_ALLOWED_DOWNLOAD_HOST = "hermes-webui.github.io"
_REGISTRY_CACHE: dict = {}
_REGISTRY_TTL_SECONDS = 300
_EXTENSION_MIME = {
"css": "text/css",
"js": "application/javascript",
@@ -845,6 +867,7 @@ def get_extension_status() -> Dict[str, Any]:
},
"manifest": manifest_status,
"extensions": extensions,
"gallery_installed": _load_install_manifest().get("installed", {}),
"warnings": diagnostics["warnings"],
}
@@ -885,6 +908,225 @@ def set_extension_user_enabled(extension_id: object, enabled: object) -> Dict[st
return get_extension_status()
def _install_manifest_file() -> Path:
return _extension_state_dir() / _GALLERY_INSTALL_STATE_FILENAME
def _empty_install_manifest() -> Dict[str, Any]:
return {"version": 1, "installed": {}}
def _load_install_manifest() -> Dict[str, Any]:
"""Load gallery install manifest, failing safe on any error."""
mfile = _install_manifest_file()
try:
if not mfile.exists() or not mfile.is_file():
return _empty_install_manifest()
with mfile.open("rb") as fh:
raw = fh.read(_MAX_INSTALL_MANIFEST_BYTES + 1)
if len(raw) > _MAX_INSTALL_MANIFEST_BYTES:
return _empty_install_manifest()
parsed = json.loads(raw.decode("utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError, RecursionError):
return _empty_install_manifest()
if not isinstance(parsed, dict) or not isinstance(parsed.get("installed"), dict):
return _empty_install_manifest()
installed: Dict[str, Any] = {}
for ext_id, entry in parsed["installed"].items():
if not _valid_extension_id(ext_id):
continue
if not isinstance(entry, dict):
continue
files = entry.get("files", [])
if not isinstance(files, list):
continue
installed[ext_id] = {
"version": str(entry.get("version", "unknown")),
"files": [f for f in files if isinstance(f, str)],
"installed_at": str(entry.get("installed_at", "")),
}
if len(installed) >= _MAX_GALLERY_INSTALLED_IDS:
break
return {"version": 1, "installed": installed}
def _write_install_manifest(manifest: Dict[str, Any]) -> None:
"""Persist install manifest with atomic same-directory replace."""
target = _install_manifest_file()
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_name(f".{target.name}.{os.getpid()}.{threading.get_ident()}.tmp")
data = json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")
try:
with tmp.open("wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, target)
finally:
try:
if tmp.exists():
tmp.unlink()
except OSError:
pass
def install_extension(id: object, download_url: object, sha256: object) -> Dict[str, Any]:
"""Download, verify, and extract a gallery extension."""
if not _valid_extension_id(id):
raise ExtensionInstallError("Invalid extension id")
ext_id = str(id).strip()
if not isinstance(download_url, str) or not download_url.startswith("https://"):
raise ExtensionInstallError("Invalid download URL")
parsed_url = urlsplit(download_url)
if parsed_url.hostname != _REGISTRY_ALLOWED_DOWNLOAD_HOST:
raise ExtensionInstallError("Invalid download URL")
if not isinstance(sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", sha256):
raise ExtensionInstallError("Invalid sha256")
root = _extension_root()
if root is None:
raise ExtensionInstallError("Extensions not configured", 404)
try:
raw_data = urlopen(download_url, timeout=30).read(_MAX_ZIP_DOWNLOAD_BYTES + 1)
except Exception as exc:
raise ExtensionInstallError("Download failed", 502) from exc
if len(raw_data) > _MAX_ZIP_DOWNLOAD_BYTES:
raise ExtensionInstallError("Download too large")
if hashlib.sha256(raw_data).hexdigest() != sha256:
raise ExtensionInstallError("SHA-256 mismatch")
try:
zf = zipfile.ZipFile(io.BytesIO(raw_data))
except zipfile.BadZipFile as exc:
raise ExtensionInstallError("Invalid zip archive") from exc
ext_dir = root / ext_id
member_names = zf.namelist()
total_uncompressed = sum(info.file_size for info in zf.infolist() if not info.is_dir())
if total_uncompressed > _MAX_ZIP_DOWNLOAD_BYTES * 10:
raise ExtensionInstallError("Archive uncompressed size exceeds limit")
if len([n for n in member_names if n and not n.endswith("/")]) > 1024:
raise ExtensionInstallError("Archive contains too many files")
for member_name in member_names:
if not member_name or member_name.endswith("/"):
continue
decoded = _fully_unquote_path(member_name)
if not _is_safe_relative_path(decoded):
raise ExtensionInstallError("Unsafe archive member")
resolved = (ext_dir / decoded).resolve()
try:
resolved.relative_to(ext_dir.resolve())
except ValueError as exc:
raise ExtensionInstallError("Zip-slip detected") from exc
# Determine version from manifest.json in zip if present
version = "unknown"
if "manifest.json" in member_names:
try:
mdata = json.loads(zf.read("manifest.json").decode("utf-8"))
if isinstance(mdata, dict) and isinstance(mdata.get("version"), str):
version = mdata["version"]
except Exception:
pass
ext_dir.mkdir(parents=True, exist_ok=True)
rollback: List[Path] = []
try:
for member_name in member_names:
if not member_name or member_name.endswith("/"):
continue
decoded = _fully_unquote_path(member_name)
dest = (ext_dir / decoded).resolve()
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(zf.read(member_name))
rollback.append(dest)
except Exception as exc:
for path in rollback:
try:
path.unlink(missing_ok=True)
except OSError:
pass
try:
if ext_dir.exists() and not any(ext_dir.iterdir()):
ext_dir.rmdir()
except OSError:
pass
raise ExtensionInstallError("Extraction failed", 500) from exc
try:
with _EXTENSION_STATE_LOCK:
manifest = _load_install_manifest()
from datetime import datetime, timezone
manifest["installed"][ext_id] = {
"version": version,
"files": [str(p.relative_to(ext_dir)) for p in rollback],
"installed_at": datetime.now(timezone.utc).isoformat(),
}
_write_install_manifest(manifest)
except Exception as exc:
for path in rollback:
try:
path.unlink(missing_ok=True)
except OSError:
pass
try:
if ext_dir.exists() and not any(ext_dir.iterdir()):
ext_dir.rmdir()
except OSError:
pass
raise ExtensionInstallError("Failed to record install", 500) from exc
return {"installed": True, "id": ext_id, "version": version}
def uninstall_extension(id: object) -> Dict[str, Any]:
"""Remove a gallery-installed extension's files and manifest entry."""
if not _valid_extension_id(id):
raise ExtensionInstallError("Invalid extension id")
ext_id = str(id).strip()
root = _extension_root()
if root is None:
raise ExtensionInstallError("Extensions not configured", 404)
with _EXTENSION_STATE_LOCK:
manifest = _load_install_manifest()
entry = manifest["installed"].get(ext_id)
if entry is None:
raise ExtensionInstallError("Extension not installed", 404)
ext_dir = root / ext_id
for rel_path in entry.get("files", []):
if not _is_safe_relative_path(rel_path):
continue
target = (ext_dir / rel_path).resolve()
try:
target.relative_to(ext_dir.resolve())
except ValueError:
continue
try:
target.unlink(missing_ok=True)
except OSError:
pass
try:
if ext_dir.exists() and not any(ext_dir.iterdir()):
ext_dir.rmdir()
except OSError:
pass
del manifest["installed"][ext_id]
_write_install_manifest(manifest)
return {"uninstalled": True, "id": ext_id}
def get_extension_registry() -> Dict[str, Any]:
"""Fetch the extension registry with a 5-minute TTL cache."""
now = time.monotonic()
cached = _REGISTRY_CACHE.get("data")
cached_at = _REGISTRY_CACHE.get("fetched_at", 0.0)
if cached is not None and (now - cached_at) < _REGISTRY_TTL_SECONDS:
return {"entries": cached}
try:
raw = urlopen(_REGISTRY_URL, timeout=10).read(2 * 1024 * 1024)
data = json.loads(raw.decode("utf-8"))
if not isinstance(data, list):
data = data.get("entries", []) if isinstance(data, dict) else []
_REGISTRY_CACHE["data"] = data
_REGISTRY_CACHE["fetched_at"] = now
return {"entries": data}
except Exception:
return {"entries": [], "error": "registry_unavailable"}
def inject_extension_tags(index_html: str) -> str:
"""Inject configured extension tags into the app shell.
+33
View File
@@ -9429,6 +9429,11 @@ def handle_get(handler, parsed) -> bool:
return j(handler, get_extension_status())
if parsed.path == "/api/extensions/registry":
from api.extensions import get_extension_registry
return j(handler, get_extension_registry())
if parsed.path.startswith("/extensions/"):
from api.extensions import serve_extension_static
@@ -10748,6 +10753,34 @@ def handle_post(handler, parsed) -> bool:
logger.exception("extension toggle failed")
return bad(handler, "Failed to update extension state", status=500)
if parsed.path == "/api/extensions/install":
from api.extensions import ExtensionInstallError, install_extension
try:
return j(
handler,
install_extension(body.get("id"), body.get("download_url"), body.get("sha256")),
)
except ExtensionInstallError as exc:
return bad(handler, str(exc), status=exc.status)
except Exception:
logger.exception("extension install failed")
return bad(handler, "Failed to install extension", status=500)
if parsed.path == "/api/extensions/uninstall":
from api.extensions import ExtensionInstallError, uninstall_extension
try:
return j(
handler,
uninstall_extension(body.get("id")),
)
except ExtensionInstallError as exc:
return bad(handler, str(exc), status=exc.status)
except Exception:
logger.exception("extension uninstall failed")
return bad(handler, "Failed to uninstall extension", status=500)
if parsed.path == "/api/session/recovery/repair-safe":
from api.session_recovery import repair_safe_session_recovery
result = repair_safe_session_recovery(SESSION_DIR, state_db_path=_active_state_db_path())
+266
View File
@@ -663,6 +663,25 @@ const LOCALES = {
settings_tab_preferences: 'Preferences',
settings_tab_plugins: 'Plugins',
settings_tab_extensions: 'Extensions',
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins',
plugins_enable_toggle: 'Enable',
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.',
@@ -2261,6 +2280,25 @@ const LOCALES = {
settings_tab_preferences: 'Preferenze',
settings_tab_plugins: 'Plugin',
settings_tab_extensions: 'Extensions', // TODO: translate
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins', // TODO: translate
plugins_enable_toggle: 'Abilita',
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
@@ -3850,6 +3888,25 @@ const LOCALES = {
settings_tab_preferences: '環境設定',
settings_tab_plugins: 'プラグイン',
settings_tab_extensions: '拡張機能',
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'プラグイン',
plugins_enable_toggle: '有効化',
settings_plugins_meta: 'インストール済みのHermesプラグインと登録されているライフサイクルフックを確認できます。このパネルは読み取り専用です。',
@@ -6143,6 +6200,25 @@ const LOCALES = {
settings_tab_preferences: 'Preferences',
settings_tab_plugins: 'Плагины',
settings_tab_extensions: 'Extensions', // TODO: translate
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins', // TODO: translate
plugins_enable_toggle: 'Включить',
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
@@ -7657,6 +7733,25 @@ const LOCALES = {
settings_tab_preferences: 'Preferences',
settings_tab_plugins: 'Plugins',
settings_tab_extensions: 'Extensions',
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins', // TODO: translate
plugins_enable_toggle: 'Activar',
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
@@ -8855,6 +8950,25 @@ const LOCALES = {
settings_tab_preferences: 'Preferences',
settings_tab_plugins: 'Plugins',
settings_tab_extensions: 'Extensions',
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins', // TODO: translate
plugins_enable_toggle: 'Aktivieren',
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
@@ -10720,6 +10834,25 @@ const LOCALES = {
settings_tab_preferences: '偏好',
settings_tab_plugins: '插件',
settings_tab_extensions: '扩展',
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_tab_system: '系统',
settings_tab_help: 'Help',
settings_help_meta: 'Resources and support for Hermes WebUI.',
@@ -11544,6 +11677,25 @@ const LOCALES = {
settings_tab_preferences: '偏好設定',
settings_tab_plugins: '外掛',
settings_tab_extensions: '擴充功能',
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: '外掛',
plugins_enable_toggle: '啟用',
settings_plugins_meta: '檢視已安裝的 Hermes 外掛及其註冊的生命週期掛鉤。此面板為唯讀。',
@@ -13029,6 +13181,25 @@ const LOCALES = {
settings_tab_preferences: 'Preferências',
settings_tab_plugins: 'Plugins',
settings_tab_extensions: 'Extensions',
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins', // TODO: translate
plugins_enable_toggle: 'Ativar',
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
@@ -14520,6 +14691,25 @@ const LOCALES = {
settings_tab_preferences: '환경설정',
settings_tab_plugins: '플러그인',
settings_tab_extensions: 'Extensions', // TODO: translate
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins', // TODO: translate
plugins_enable_toggle: '활성화',
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
@@ -16033,6 +16223,25 @@ const LOCALES = {
settings_tab_preferences: 'Préférences',
settings_tab_plugins: 'Plugins',
settings_tab_extensions: 'Extensions',
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins', // TODO: translate
plugins_enable_toggle: 'Activer',
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
@@ -17640,6 +17849,25 @@ const LOCALES = {
settings_tab_preferences: 'Tercihler',
settings_tab_plugins: 'Eklentiler',
settings_tab_extensions: 'Extensions', // TODO: translate
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Eklentiler',
plugins_enable_toggle: 'Etkinleştir',
settings_plugins_meta: 'Yüklü Hermes eklentilerini ve yaşam döngüsü kancalarını görüntüleyin. Bu panel salt okunurdur.',
@@ -19243,6 +19471,25 @@ const LOCALES = {
settings_tab_preferences: 'Preferencje',
settings_tab_plugins: 'Wtyczki',
settings_tab_extensions: 'Extensions', // TODO: translate
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Wtyczki',
plugins_enable_toggle: 'Włącz',
settings_plugins_meta: 'Zobacz zainstalowane wtyczki Hermes oraz rejestrowane przez nie haki cyklu życia. Ten panel jest tylko do odczytu.',
@@ -20734,6 +20981,25 @@ const LOCALES = {
settings_tab_preferences: 'Tùy chọn',
settings_tab_plugins: 'Plugin',
settings_tab_extensions: 'Tiện ích mở rộng',
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugin',
plugins_enable_toggle: 'Bật',
settings_plugins_meta: 'Xem các plugin Hermes đã cài đặt và lifecycle hook mà chúng đăng ký. Bảng này chỉ đọc.',
+36 -7
View File
@@ -1413,16 +1413,45 @@
<div class="settings-section-head">
<div>
<div class="settings-section-title" data-i18n="settings_tab_extensions">Extensions</div>
<div class="settings-section-meta">Diagnostics and enable/disable controls for configured WebUI extensions.</div>
<div class="settings-section-meta">Browse, install, and manage WebUI extensions.</div>
</div>
<button class="sm-btn extensions-copy-diagnostics-btn" id="extensionsCopyDiagnosticsBtn" type="button" onclick="copyExtensionsDiagnostics()" disabled>Copy diagnostics</button>
</div>
<div class="settings-field extensions-trust-note" id="extensionsTrustModel">
<label>Trust model</label>
<div>Extensions run in the WebUI browser origin and can access the same authenticated APIs as this session. Only load trusted local extension directories; disabling an extension takes effect after reload for already-injected assets.</div>
<div class="extensions-tab-bar" role="tablist">
<button class="extensions-tab-btn extensions-tab-active" role="tab"
data-extensions-tab="gallery"
onclick="switchExtensionsTab('gallery')" type="button"
data-i18n="settings_extensions_gallery_tab">Gallery</button>
<button class="extensions-tab-btn" role="tab"
data-extensions-tab="installed"
onclick="switchExtensionsTab('installed')" type="button"
data-i18n="settings_extensions_installed_tab">Installed</button>
<button class="extensions-tab-btn" role="tab"
data-extensions-tab="diagnostics"
onclick="switchExtensionsTab('diagnostics')" type="button"
data-i18n="settings_extensions_diagnostics_tab">Diagnostics</button>
</div>
<div id="extensionsDiagnostics" class="extensions-diagnostics" aria-live="polite">
<div class="extensions-loading">Loading extension diagnostics…</div>
<div data-extensions-pane="gallery">
<div id="extensionsGallery" class="extensions-gallery" aria-live="polite">
<div class="extensions-loading" data-i18n="settings_extensions_gallery_loading">Loading gallery…</div>
</div>
</div>
<div data-extensions-pane="installed" hidden>
<div id="extensionsInstalled" class="extensions-gallery" aria-live="polite">
<div class="extensions-loading">Loading installed extensions…</div>
</div>
</div>
<div data-extensions-pane="diagnostics" hidden>
<div class="settings-section-head" style="padding-top:8px">
<div></div>
<button class="sm-btn extensions-copy-diagnostics-btn" id="extensionsCopyDiagnosticsBtn" type="button" onclick="copyExtensionsDiagnostics()" disabled>Copy diagnostics</button>
</div>
<div class="settings-field extensions-trust-note" id="extensionsTrustModel">
<label>Trust model</label>
<div>Extensions run in the WebUI browser origin and can access the same authenticated APIs as this session. Only load trusted local extension directories; disabling an extension takes effect after reload for already-injected assets.</div>
</div>
<div id="extensionsDiagnostics" class="extensions-diagnostics" aria-live="polite">
<div class="extensions-loading">Loading extension diagnostics…</div>
</div>
</div>
</div>
<div class="settings-pane" id="settingsPaneSystem">
+140 -1
View File
@@ -6494,6 +6494,9 @@ let _settingsIndexPromise = null;
let _settingsSearchSeq = 0;
let _extensionsStatusData = null;
let _extensionsSidecarMonitorSeq = 0;
let _extensionsGalleryData = null;
let _extensionsGalleryLoaded = false;
let _extensionsActiveTab = 'gallery';
let _settingsSearchDismissListenerRegistered = false;
let _settingsAppearanceAutosaveTimer = null;
let _settingsAppearanceAutosaveRetryPayload = null;
@@ -8246,6 +8249,143 @@ async function loadExtensionsPanel(){
if(copyBtn) copyBtn.disabled=true;
target.innerHTML='<div class="extensions-error">Failed to load extension diagnostics: '+esc(e.message||String(e))+'</div>';
}
if(_extensionsActiveTab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery();
}
function switchExtensionsTab(tab){
_extensionsActiveTab=tab;
document.querySelectorAll('[data-extensions-tab]').forEach(btn=>{
btn.classList.toggle('extensions-tab-active',btn.dataset.extensionsTab===tab);
});
document.querySelectorAll('[data-extensions-pane]').forEach(pane=>{
pane.hidden=pane.dataset.extensionsPane!==tab;
});
if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery();
}
async function loadExtensionsGallery(){
_extensionsGalleryLoaded=true;
const galleryEl=$('extensionsGallery');
const installedEl=$('extensionsInstalled');
if(galleryEl) galleryEl.innerHTML='<div class="extensions-loading">Loading gallery…</div>';
if(installedEl) installedEl.innerHTML='<div class="extensions-loading">Loading installed extensions…</div>';
try{
const [regData,statusData]=await Promise.all([
api('/api/extensions/registry'),
api('/api/extensions/status'),
]);
_extensionsGalleryData={regData,statusData};
_renderExtensionsGallery(regData.entries||[],statusData);
}catch(e){
_extensionsGalleryLoaded=false;
const msg=esc(e&&e.message?e.message:String(e));
if(galleryEl) galleryEl.innerHTML='<div class="extensions-error">Failed to load gallery: '+msg+'</div>';
if(installedEl) installedEl.innerHTML='<div class="extensions-error">Failed to load extension status.</div>';
}
}
function _renderExtensionsGallery(entries,statusData){
const galleryEl=$('extensionsGallery');
const installedEl=$('extensionsInstalled');
const installedIds=new Set();
if(statusData&&statusData.gallery_installed){
Object.keys(statusData.gallery_installed).forEach(id=>installedIds.add(id));
}
if(statusData&&Array.isArray(statusData.extensions)){
statusData.extensions.forEach(e=>{ if(e&&e.id) installedIds.add(e.id); });
}
if(!Array.isArray(entries)||entries.length===0){
if(galleryEl) galleryEl.innerHTML='<div class="extensions-empty">No extensions found in the registry.</div>';
if(installedEl) installedEl.innerHTML='<div class="extensions-empty">No extensions installed from the gallery.</div>';
return;
}
const galleryCards=[];
const installedCards=[];
for(const entry of entries){
const id=esc(String(entry.id||''));
const name=esc(String(entry.name||entry.id||''));
const author=esc(String(entry.author||''));
const version=esc(String(entry.version||''));
const desc=esc(String(entry.description||''));
const caps=Array.isArray(entry.capabilities)?entry.capabilities:[];
const perms=entry.permissions||null;
const isInstalled=installedIds.has(String(entry.id||''));
const restartRequired=!!(entry.lifecycle&&entry.lifecycle.restart_required);
const badgesHtml=caps.map(c=>`<span class="extension-gallery-badge">${esc(String(c))}</span>`).join('');
const permsHtml=perms?`<details class="extension-gallery-perms"><summary data-i18n="ext_gallery_permissions_show">Permissions</summary><pre>${esc(JSON.stringify(perms,null,2))}</pre></details>`:'';
const actionBtn=isInstalled
?`<button class="extension-gallery-uninstall-btn" data-ext-uninstall-id="${id}" type="button" data-i18n="ext_gallery_uninstall">Uninstall</button>`
:`<button class="extension-gallery-install-btn" data-ext-install-id="${id}" type="button" data-i18n="ext_gallery_install">Install</button>`;
const installedBadge=isInstalled?'<span class="extension-gallery-installed-badge">Installed</span>':'';
const card=`<div class="extension-gallery-card">
<div class="extension-gallery-head">
<div class="extension-gallery-info">
<div class="extension-gallery-name">${name}${installedBadge}</div>
<div class="extension-gallery-meta">${author?'by '+author:''}${version?' · v'+version:''}</div>
</div>
</div>
<div class="extension-gallery-desc">${desc}</div>
${badgesHtml?'<div class="extension-gallery-badge-row">'+badgesHtml+'</div>':''}
${permsHtml}
<div class="extension-gallery-actions">${actionBtn}</div>
</div>`;
galleryCards.push(card);
if(isInstalled) installedCards.push(card);
}
if(galleryEl) galleryEl.innerHTML=galleryCards.length?galleryCards.join(''):'<div class="extensions-empty">No extensions found.</div>';
if(installedEl) installedEl.innerHTML=installedCards.length?installedCards.join(''):'<div class="extensions-empty">No extensions installed from the gallery.</div>';
_bindExtensionGalleryButtons(entries);
}
function _bindExtensionGalleryButtons(entries){
const entryMap=new Map();
if(Array.isArray(entries)) entries.forEach(e=>{if(e&&e.id)entryMap.set(String(e.id),e);});
document.querySelectorAll('[data-ext-install-id]').forEach(btn=>{
const entry=entryMap.get(btn.dataset.extInstallId);
if(entry) btn.addEventListener('click',()=>handleExtensionInstall(btn,entry));
});
document.querySelectorAll('[data-ext-uninstall-id]').forEach(btn=>{
btn.addEventListener('click',()=>handleExtensionUninstall(btn,btn.dataset.extUninstallId));
});
}
async function handleExtensionInstall(btn,entry){
if(!btn||btn.disabled) return;
const previousText=btn.textContent;
btn.disabled=true;
btn.textContent='Installing…';
try{
const result=await api('/api/extensions/install',{method:'POST',body:JSON.stringify({
id:entry.id,
download_url:entry.download_url,
sha256:entry.sha256,
})});
const restart=!!(entry.lifecycle&&entry.lifecycle.restart_required);
showToast(restart?'Extension installed. Restart required to apply.':'Extension installed successfully.');
_extensionsGalleryLoaded=false;
await loadExtensionsGallery();
}catch(e){
btn.disabled=false;
btn.textContent=previousText;
showToast('Install failed: '+(e&&e.message?e.message:String(e)));
}
}
async function handleExtensionUninstall(btn,id){
if(!btn||btn.disabled) return;
const previousText=btn.textContent;
btn.disabled=true;
btn.textContent='Uninstalling…';
try{
await api('/api/extensions/uninstall',{method:'POST',body:JSON.stringify({id})});
showToast('Extension uninstalled.');
_extensionsGalleryLoaded=false;
await loadExtensionsGallery();
}catch(e){
btn.disabled=false;
btn.textContent=previousText;
showToast('Uninstall failed: '+(e&&e.message?e.message:String(e)));
}
}
async function copyExtensionsDiagnostics(){
@@ -8264,7 +8404,6 @@ async function copyExtensionsDiagnostics(){
}
}
// ── Plugins panel (read-only plugin/hook visibility) ───────────────────────
async function handlePluginEnableToggle(pluginKey, checked){
+26
View File
@@ -4725,6 +4725,32 @@ main.main > #mainPlugin{display:none;}
.extension-sidecar-status-healthy{background:rgba(34,197,94,.14);color:#22c55e;}
.extension-sidecar-status-unhealthy{background:rgba(245,158,11,.14);color:#f59e0b;}
.extension-sidecar-status-blocked{background:rgba(239,68,68,.14);color:var(--error);}
.extensions-tab-bar{display:flex;gap:4px;margin:0 0 12px;border-bottom:1px solid var(--border);padding-bottom:0;}
.extensions-tab-btn{background:none;border:none;border-bottom:2px solid transparent;padding:6px 14px;font-size:13px;color:var(--muted);cursor:pointer;transition:color .15s,border-color .15s;margin-bottom:-1px;}
.extensions-tab-btn:hover{color:var(--text);}
.extensions-tab-active{color:var(--text);border-bottom-color:var(--accent,#7c3aed);font-weight:600;}
.extensions-gallery{display:flex;flex-direction:column;gap:10px;margin-top:4px;}
.extension-gallery-card{background:var(--card-bg,var(--sidebar-bg));border:1px solid var(--border);border-radius:8px;padding:12px 14px;}
.extension-gallery-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;}
.extension-gallery-info{flex:1;min-width:0;}
.extension-gallery-name{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
.extension-gallery-meta{font-size:11px;color:var(--muted);margin-top:2px;}
.extension-gallery-desc{font-size:12px;color:var(--muted);margin-top:6px;line-height:1.5;}
.extension-gallery-badge-row{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px;}
.extension-gallery-badge{display:inline-flex;align-items:center;background:var(--code-bg);color:var(--muted);border-radius:999px;padding:2px 8px;font-size:10.5px;font-weight:600;font-family:var(--font-mono);}
.extension-gallery-perms{margin-top:8px;font-size:12px;}
.extension-gallery-perms summary{cursor:pointer;color:var(--muted);user-select:none;}
.extension-gallery-perms pre{margin:6px 0 0;padding:8px;background:var(--code-bg);border-radius:4px;font-size:11px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;}
.extension-gallery-actions{display:flex;gap:8px;margin-top:10px;align-items:center;flex-wrap:wrap;}
.extension-gallery-install-btn,.extension-gallery-uninstall-btn{padding:4px 12px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;border:1px solid transparent;transition:background .15s,color .15s;}
.extension-gallery-install-btn{background:var(--accent,#7c3aed);color:#fff;border-color:var(--accent,#7c3aed);}
.extension-gallery-install-btn:hover:not(:disabled){filter:brightness(1.1);}
.extension-gallery-uninstall-btn{background:var(--card-bg,var(--sidebar-bg));color:var(--error,#ef4444);border-color:var(--error,#ef4444);}
.extension-gallery-uninstall-btn:hover:not(:disabled){background:rgba(239,68,68,.08);}
.extension-gallery-install-btn:disabled,.extension-gallery-uninstall-btn:disabled{opacity:.5;cursor:not-allowed;}
.extension-gallery-installed-badge{display:inline-flex;align-items:center;background:rgba(34,197,94,.14);color:#22c55e;border-radius:999px;padding:2px 8px;font-size:10.5px;font-weight:700;font-family:var(--font-mono);}
.extension-gallery-restart-note{font-size:11px;color:var(--muted);font-style:italic;}
.extensions-empty{padding:24px 0;text-align:center;color:var(--muted);font-size:13px;}
@media (max-width: 768px){
.extensions-copy-diagnostics-btn{width:100%;}
.extension-summary-grid{grid-template-columns:1fr;}
-3
View File
@@ -57,7 +57,6 @@ def test_extensions_panel_warns_about_trust_model_and_stays_install_free():
pane_end = INDEX_HTML.index('id="settingsPaneSystem"', pane_start)
pane = INDEX_HTML[pane_start:pane_end]
assert "Diagnostics and enable/disable controls" in pane
assert "Extensions run in the WebUI browser origin" in pane
assert "Only load trusted local extension directories" in pane
assert "takes effect after reload" in pane
@@ -65,8 +64,6 @@ def test_extensions_panel_warns_about_trust_model_and_stays_install_free():
assert "saveSettings(" not in pane
assert "api('/api/settings'" not in pane
assert "type=\"checkbox\"" not in pane
assert "Install" not in pane
assert "Uninstall" not in pane
assert "marketplace" not in pane.lower()
+187
View File
@@ -0,0 +1,187 @@
"""Behavioral tests for the extension gallery install/uninstall/registry feature."""
import hashlib
import io
import json
import zipfile
from pathlib import Path
from unittest.mock import MagicMock
import pytest
def _make_zip(files: dict) -> bytes:
"""Build an in-memory zip containing the given {name: content} mapping."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, content in files.items():
zf.writestr(name, content)
return buf.getvalue()
def _setup_ext_env(monkeypatch, tmp_path):
"""Point extension root and state dir at tmp_path subdirectories."""
ext_dir = tmp_path / "extensions"
ext_dir.mkdir()
state_dir = tmp_path / "state"
state_dir.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(ext_dir))
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(state_dir))
import api.extensions as ext_mod
monkeypatch.setattr(ext_mod, "_extension_state_dir", lambda: state_dir)
return ext_dir, state_dir
def test_install_valid(monkeypatch, tmp_path):
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
files = {
"manifest.json": json.dumps({"version": "1.2.3"}),
"index.js": "console.log('hello');",
}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
mock_resp = MagicMock()
mock_resp.read.return_value = zip_bytes
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
result = ext_mod.install_extension(
"my-ext",
"https://hermes-webui.github.io/exts/my-ext.zip",
sha,
)
assert result["installed"] is True
assert result["id"] == "my-ext"
assert result["version"] == "1.2.3"
assert (ext_dir / "my-ext" / "index.js").exists()
manifest = ext_mod._load_install_manifest()
assert "my-ext" in manifest["installed"]
assert "index.js" in manifest["installed"]["my-ext"]["files"]
def test_install_bad_hash(monkeypatch, tmp_path):
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
zip_bytes = _make_zip({"index.js": "code"})
wrong_sha = "a" * 64
mock_resp = MagicMock()
mock_resp.read.return_value = zip_bytes
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
with pytest.raises(ext_mod.ExtensionInstallError, match="SHA-256"):
ext_mod.install_extension(
"bad-ext",
"https://hermes-webui.github.io/exts/bad-ext.zip",
wrong_sha,
)
assert not (ext_dir / "bad-ext").exists()
def test_install_zipslip(monkeypatch, tmp_path):
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
# Build a zip with a path-traversal member
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("../../evil.txt", "pwned")
zip_bytes = buf.getvalue()
sha = hashlib.sha256(zip_bytes).hexdigest()
mock_resp = MagicMock()
mock_resp.read.return_value = zip_bytes
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
with pytest.raises(ext_mod.ExtensionInstallError):
ext_mod.install_extension(
"slip-ext",
"https://hermes-webui.github.io/exts/slip-ext.zip",
sha,
)
assert not (tmp_path / "evil.txt").exists()
def test_uninstall(monkeypatch, tmp_path):
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
files = {"index.js": "code", "style.css": "body{}"}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
mock_resp = MagicMock()
mock_resp.read.return_value = zip_bytes
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
ext_mod.install_extension(
"rm-ext",
"https://hermes-webui.github.io/exts/rm-ext.zip",
sha,
)
assert (ext_dir / "rm-ext" / "index.js").exists()
result = ext_mod.uninstall_extension("rm-ext")
assert result["uninstalled"] is True
assert not (ext_dir / "rm-ext" / "index.js").exists()
assert not (ext_dir / "rm-ext" / "style.css").exists()
manifest = ext_mod._load_install_manifest()
assert "rm-ext" not in manifest["installed"]
def test_install_rollback(monkeypatch, tmp_path):
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
files = {"first.js": "a", "second.js": "b"}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
mock_resp = MagicMock()
mock_resp.read.return_value = zip_bytes
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
write_count = [0]
original_write_bytes = Path.write_bytes
def patched_write_bytes(self, data):
write_count[0] += 1
if write_count[0] >= 2:
raise OSError("simulated write failure")
return original_write_bytes(self, data)
monkeypatch.setattr(Path, "write_bytes", patched_write_bytes)
with pytest.raises(ext_mod.ExtensionInstallError, match="Extraction failed"):
ext_mod.install_extension(
"roll-ext",
"https://hermes-webui.github.io/exts/roll-ext.zip",
sha,
)
remaining = list((ext_dir / "roll-ext").glob("**/*")) if (ext_dir / "roll-ext").exists() else []
assert remaining == []
def test_gallery_render(monkeypatch, tmp_path):
import api.extensions as ext_mod
registry_data = [
{"id": "ext-one", "name": "Extension One", "version": "0.1.0", "description": "First"},
{"id": "ext-two", "name": "Extension Two", "version": "0.2.0", "description": "Second"},
]
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(registry_data).encode("utf-8")
# Clear cache so we always do a fresh fetch
ext_mod._REGISTRY_CACHE.clear()
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
result = ext_mod.get_extension_registry()
assert "entries" in result
assert len(result["entries"]) == 2
assert result["entries"][0]["id"] == "ext-one"
assert result["entries"][1]["id"] == "ext-two"