Release WZ (v0.51.644): extensions gallery browse/install/uninstall (#4879, closes #4746)
This commit is contained in:
9 files changed
+1159
-11
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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){
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user