Release WZ (v0.51.644): extensions gallery browse/install/uninstall (#4879, closes #4746)

Release WZ (v0.51.644): extensions gallery browse/install/uninstall (#4879, closes #4746)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-24 21:49:39 -07:00
commit 09b19152ab
9 files changed
+1159 -11

No files matched your search

+6
View File
@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.644] — 2026-06-25 — Release WZ (browse, install, and uninstall extensions from a gallery)
### Added
- **A new Extensions gallery (Settings → Extensions) to browse, install, and uninstall WebUI extensions from the curated registry.** Previously the extension infrastructure (path-safety, manifest loading, static serving, enable/disable) existed but there was no way to discover or install extensions from the UI. The gallery fetches the curated registry, shows each extension's description, capabilities, and a permissions disclosure, and installs with a verified download: HTTPS + host-allowlist (redirects to other hosts are rejected), SHA-256 verification of the downloaded bytes before extraction, zip-bomb and zip-slip guards, symlinked-target rejection, and a tracked file manifest so uninstall removes exactly what was installed (and cleans up empty directories). An Installed tab and a Diagnostics tab (with an explicit trust-model note) round out the surface. Thanks @rodboev, with review by @franksong2702. (#4879, closes #4746)
## [v0.51.643] — 2026-06-25 — Release WX (unassigned tasks show up on the mobile Kanban board)
### Fixed
+319
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 HTTPRedirectHandler, build_opener, 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,37 @@ _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_HOSTS = frozenset({"hermes-webui.github.io"})
_REGISTRY_CACHE: dict = {}
_REGISTRY_LOCK = threading.Lock()
_REGISTRY_TTL_SECONDS = 300
class _AllowlistRedirectHandler(HTTPRedirectHandler):
"""Reject redirects to hosts not in the download allowlist."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
parsed = urlsplit(newurl)
if parsed.scheme != "https" or parsed.hostname not in _REGISTRY_ALLOWED_DOWNLOAD_HOSTS:
raise ExtensionInstallError("Download redirected to disallowed host")
return super().redirect_request(req, fp, code, msg, headers, newurl)
def _safe_download(url: str, max_bytes: int, timeout: int = 30) -> bytes:
"""Download from an allowlisted host, rejecting cross-host redirects."""
opener = build_opener(_AllowlistRedirectHandler)
resp = opener.open(url, timeout=timeout)
try:
return resp.read(max_bytes + 1)
finally:
resp.close()
_EXTENSION_MIME = {
"css": "text/css",
"js": "application/javascript",
@@ -845,6 +889,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 +930,280 @@ 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 not in _REGISTRY_ALLOWED_DOWNLOAD_HOSTS:
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 = _safe_download(download_url, _MAX_ZIP_DOWNLOAD_BYTES)
except ExtensionInstallError:
raise
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")
file_members = [n for n in member_names if n and not n.endswith("/")]
if len(file_members) > 1024:
raise ExtensionInstallError("Archive contains too many files")
# Detect and strip a single top-level directory prefix matching the extension id.
# Registry artifacts root files under <id>/ (e.g. desktop-companion/manifest.json).
strip_prefix = ""
candidate = ext_id + "/"
if all(n.startswith(candidate) for n in file_members):
strip_prefix = candidate
def _stripped(name: str) -> str:
if strip_prefix and name.startswith(strip_prefix):
return name[len(strip_prefix):]
return name
root_resolved = root.resolve()
ext_dir_resolved = ext_dir.resolve()
for member_name in file_members:
decoded = _fully_unquote_path(_stripped(member_name))
if not decoded or not _is_safe_relative_path(decoded):
raise ExtensionInstallError("Unsafe archive member")
resolved = (ext_dir / decoded).resolve()
try:
resolved.relative_to(root_resolved)
except ValueError as exc:
raise ExtensionInstallError("Zip-slip detected") from exc
try:
resolved.relative_to(ext_dir_resolved)
except ValueError as exc:
raise ExtensionInstallError("Zip-slip detected") from exc
# Determine version from extension.json or manifest.json in zip
version = "unknown"
for vfile in ("extension.json", "manifest.json"):
candidate_name = strip_prefix + vfile if strip_prefix else vfile
if candidate_name in member_names:
try:
mdata = json.loads(zf.read(candidate_name).decode("utf-8"))
if isinstance(mdata, dict) and isinstance(mdata.get("version"), str):
version = mdata["version"]
break
except Exception:
pass
with _EXTENSION_STATE_LOCK:
ext_dir.mkdir(parents=True, exist_ok=True)
if ext_dir.is_symlink():
raise ExtensionInstallError("Extension directory is a symlink", 400)
rollback: List[Path] = []
try:
for member_name in file_members:
decoded = _fully_unquote_path(_stripped(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:
manifest = _load_install_manifest()
from datetime import datetime, timezone
rel_files = [p.relative_to(ext_dir_resolved).as_posix() for p in rollback]
manifest["installed"][ext_id] = {
"version": version,
"files": rel_files,
"installed_at": datetime.now(timezone.utc).isoformat(),
}
encoded = json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")
if len(encoded) > _MAX_INSTALL_MANIFEST_BYTES:
raise ExtensionInstallError("Install manifest would exceed size limit")
_write_install_manifest(manifest)
except ExtensionInstallError:
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
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
# Remove empty directories bottom-up
if ext_dir.exists():
for dirpath in sorted(
(d for d in ext_dir.rglob("*") if d.is_dir()),
key=lambda p: len(p.parts),
reverse=True,
):
try:
if not any(dirpath.iterdir()):
dirpath.rmdir()
except OSError:
pass
try:
if 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."""
with _REGISTRY_LOCK:
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 isinstance(data, list):
entries = data
elif isinstance(data, dict):
entries = data.get("extensions") or data.get("entries") or []
else:
entries = []
if not isinstance(entries, list):
entries = []
_REGISTRY_CACHE["data"] = entries
_REGISTRY_CACHE["fetched_at"] = now
return {"entries": entries}
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||entry.download,
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-bg);color:var(--accent-text);border-color:var(--accent);}
.extension-gallery-install-btn:hover:not(:disabled){background:var(--accent-bg-strong);color:var(--accent-text);}
.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()
+333
View File
@@ -0,0 +1,333 @@
"""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
mock_resp.close = MagicMock()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
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_prefixed_zip(monkeypatch, tmp_path):
"""Zip members rooted under <id>/ are stripped so files land at ext_dir/<id>/."""
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
files = {
"my-ext/manifest.json": json.dumps({"version": "2.0.0"}),
"my-ext/index.js": "console.log('prefixed');",
"my-ext/sub/style.css": "body{}",
}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
result = ext_mod.install_extension(
"my-ext",
"https://hermes-webui.github.io/exts/my-ext.zip",
sha,
)
assert result["installed"] is True
assert result["version"] == "2.0.0"
assert (ext_dir / "my-ext" / "manifest.json").exists()
assert (ext_dir / "my-ext" / "index.js").exists()
assert (ext_dir / "my-ext" / "sub" / "style.css").exists()
# Verify NO double-nested directory
assert not (ext_dir / "my-ext" / "my-ext").exists()
manifest = ext_mod._load_install_manifest()
assert "manifest.json" 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
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
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
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()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
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()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
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()
assert not (ext_dir / "rm-ext").exists()
manifest = ext_mod._load_install_manifest()
assert "rm-ext" not in manifest["installed"]
def test_uninstall_cleans_nested_dirs(monkeypatch, tmp_path):
"""Uninstall removes empty subdirectories left by nested files."""
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
files = {
"deep-ext/manifest.json": "{}",
"deep-ext/sub/a/b.js": "code",
"deep-ext/sub/c.css": "body{}",
}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
ext_mod.install_extension(
"deep-ext",
"https://hermes-webui.github.io/exts/deep-ext.zip",
sha,
)
assert (ext_dir / "deep-ext" / "sub" / "a" / "b.js").exists()
ext_mod.uninstall_extension("deep-ext")
assert not (ext_dir / "deep-ext").exists()
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()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
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_registry_list_format(monkeypatch, tmp_path):
"""Registry response as a top-level list (original format)."""
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")
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"
def test_gallery_registry_extensions_format(monkeypatch, tmp_path):
"""Registry response wrapping extensions under {"extensions": [...]} (live format)."""
import api.extensions as ext_mod
registry_data = {
"version": 1,
"generated_at": "2026-06-24T18:30:40.265Z",
"extensions": [
{"id": "desktop-companion", "name": "Desktop Companion", "version": "0.1.0"},
],
}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(registry_data).encode("utf-8")
ext_mod._REGISTRY_CACHE.clear()
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
result = ext_mod.get_extension_registry()
assert len(result["entries"]) == 1
assert result["entries"][0]["id"] == "desktop-companion"
def test_install_rejects_symlinked_ext_dir_outside_root(monkeypatch, tmp_path):
"""Installation rejects a symlinked extension directory pointing outside root."""
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
target_dir = tmp_path / "symlink_target"
target_dir.mkdir()
link = ext_dir / "linked-ext"
try:
link.symlink_to(target_dir)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported")
files = {"index.js": "code"}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
with pytest.raises(ext_mod.ExtensionInstallError):
ext_mod.install_extension(
"linked-ext",
"https://hermes-webui.github.io/exts/linked-ext.zip",
sha,
)
assert not (target_dir / "index.js").exists()
def test_install_rejects_symlinked_ext_dir_inside_root(monkeypatch, tmp_path):
"""Installation rejects a pre-existing symlink even when target is within root."""
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
target_dir = ext_dir / "real-target"
target_dir.mkdir()
link = ext_dir / "linked-ext"
try:
link.symlink_to(target_dir)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported")
files = {"index.js": "code"}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
with pytest.raises(ext_mod.ExtensionInstallError, match="symlink"):
ext_mod.install_extension(
"linked-ext",
"https://hermes-webui.github.io/exts/linked-ext.zip",
sha,
)
def test_install_rejects_redirect_to_disallowed_host(monkeypatch, tmp_path):
"""Download rejects redirects to disallowed hosts."""
import api.extensions as ext_mod
def fake_download(url, max_bytes, timeout=30):
raise ext_mod.ExtensionInstallError("Download redirected to disallowed host")
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
monkeypatch.setattr(ext_mod, "_safe_download", fake_download)
with pytest.raises(ext_mod.ExtensionInstallError, match="disallowed host"):
ext_mod.install_extension(
"redir-ext",
"https://hermes-webui.github.io/exts/redir-ext.zip",
"a" * 64,
)