From ddd5c8d936dbe9fc222d7d0bc5356cc8f0fe76e8 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 24 Jun 2026 20:10:32 -0400 Subject: [PATCH] feat(extensions): add gallery browse, install, and uninstall (#4746) --- api/extensions.py | 242 ++++++++++++++++++++ api/routes.py | 33 +++ static/i18n.js | 266 ++++++++++++++++++++++ static/index.html | 43 +++- static/panels.js | 141 +++++++++++- static/style.css | 26 +++ tests/test_extensions_settings_panel.py | 3 - tests/test_issue4746_extension_gallery.py | 187 +++++++++++++++ 8 files changed, 930 insertions(+), 11 deletions(-) create mode 100644 tests/test_issue4746_extension_gallery.py diff --git a/api/extensions.py b/api/extensions.py index ffa6ddfa..85c40d32 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -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. diff --git a/api/routes.py b/api/routes.py index 400c20e3..f7860ca6 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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()) diff --git a/static/i18n.js b/static/i18n.js index 8608bef7..18ae9103 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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.', diff --git a/static/index.html b/static/index.html index 19fb5843..3f142fc5 100644 --- a/static/index.html +++ b/static/index.html @@ -1413,16 +1413,45 @@
Extensions
- +
-
-
- -
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.
+
+ + +
-
-
Loading extension diagnostics…
+
+ +
+ +
diff --git a/static/panels.js b/static/panels.js index 4ace9b02..e168cfd9 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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='
Failed to load extension diagnostics: '+esc(e.message||String(e))+'
'; } + 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='
Loading gallery…
'; + if(installedEl) installedEl.innerHTML='
Loading installed extensions…
'; + 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='
Failed to load gallery: '+msg+'
'; + if(installedEl) installedEl.innerHTML='
Failed to load extension status.
'; + } +} + +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='
No extensions found in the registry.
'; + if(installedEl) installedEl.innerHTML='
No extensions installed from the gallery.
'; + 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=>`${esc(String(c))}`).join(''); + const permsHtml=perms?``:''; + const actionBtn=isInstalled + ?`` + :``; + const installedBadge=isInstalled?'Installed':''; + const card=``; + galleryCards.push(card); + if(isInstalled) installedCards.push(card); + } + if(galleryEl) galleryEl.innerHTML=galleryCards.length?galleryCards.join(''):'
No extensions found.
'; + if(installedEl) installedEl.innerHTML=installedCards.length?installedCards.join(''):'
No extensions installed from the gallery.
'; + _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){ diff --git a/static/style.css b/static/style.css index f1c2f60f..2edf3c25 100644 --- a/static/style.css +++ b/static/style.css @@ -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;} diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py index be08c5b0..0890ef2b 100644 --- a/tests/test_extensions_settings_panel.py +++ b/tests/test_extensions_settings_panel.py @@ -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() diff --git a/tests/test_issue4746_extension_gallery.py b/tests/test_issue4746_extension_gallery.py new file mode 100644 index 00000000..bc4c99be --- /dev/null +++ b/tests/test_issue4746_extension_gallery.py @@ -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"