Release XO (v0.51.659): one-click extension install works with no env config (#4935, closes #4933)
This commit is contained in:
6 files changed
+199
-27
No files matched your search
+6
-1
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.659] — 2026-06-25 — Release XO (one-click extension install works with no setup)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **One-click extension install now works out of the box with no configuration (#4933).** Previously the Settings → Extensions gallery offered an **Install** button even when extensions weren't configured, then dead-ended on `Install failed: Extensions not configured` because the install path required `HERMES_WEBUI_EXTENSION_DIR` to be set to an existing directory before the server started — documented nowhere user-facing. Install now bootstraps a WebUI-managed default extension directory under the state dir (`STATE_DIR/extensions`, e.g. `~/.hermes/webui/extensions/`) on first use and installs into it, so any user can open Settings, pick an extension, and click Install with zero environment setup; the extension loads automatically on the next app-shell render. `HERMES_WEBUI_EXTENSION_DIR` remains an optional override for operators who want a specific path (WebUI never auto-creates an admin-specified path). The managed directory lives in the WebUI-owned state dir alongside sessions/settings — a different trust domain from a user-writable directory on a shared box — and the trust model is unchanged (installed code still runs with full session authority, so only install vetted/trusted extensions). `docs/EXTENSIONS.md` documents the zero-config flow.
|
||||
|
||||
## [v0.51.658] — 2026-06-25 — Release XN (tool cards keep long commands and reconstruct diffs)
|
||||
|
||||
### Fixed
|
||||
@@ -14,7 +20,6 @@
|
||||
### Fixed
|
||||
|
||||
- **Expanding a shell/terminal tool card now shows the full multi-line command, not just the first line.** In the transparent activity stream, a shell card's collapsed header correctly shows only the first line (compact metadata), but the expanded card reused that same first-line-only value — so a multi-line script still rendered as line 1 when you expanded it. The expanded detail lead now shows the complete command. As part of this, command redaction was broadened to mask common secret forms (env/colon assignments for `*TOKEN`/`*API_KEY`/`*SECRET`/`*ACCESS_KEY`/`*PRIVATE_KEY`/`*CREDENTIAL`/`*CLIENT_SECRET`/`*SESSION_KEY`/`*AUTH*`, `--token`/`--api-key`/`--secret`-style flags, `Authorization:` headers, and secret-looking URL query params) across the whole command, so exposing lines beyond the first never leaks a key. (#4926, part of #4925)
|
||||
|
||||
## [v0.51.656] — 2026-06-25 — Release XL (stream writeback timing diagnostics)
|
||||
|
||||
### Fixed
|
||||
|
||||
+77
-17
@@ -126,30 +126,85 @@ _EXTENSION_MIME = {
|
||||
_TEXT_MIME_TYPES = {"text/css", "application/javascript", "text/html", "image/svg+xml", "text/plain"}
|
||||
|
||||
|
||||
def _extension_root() -> Optional[Path]:
|
||||
"""Return the configured extension directory, or None when disabled.
|
||||
def _default_extension_root() -> Path:
|
||||
"""WebUI-managed default extension directory under the state dir.
|
||||
|
||||
A missing or non-directory path disables extensions instead of failing open.
|
||||
The startup docs encourage users to point this at a directory they control.
|
||||
Used when ``HERMES_WEBUI_EXTENSION_DIR`` is unset so one-click gallery
|
||||
install works out of the box on a single-user self-hosted instance with no
|
||||
environment setup. It lives alongside sessions/settings in the WebUI-owned
|
||||
state dir, which is a different trust domain from "a user-writable directory
|
||||
on a shared box" — the loaded code still runs with full session authority,
|
||||
so the trust model is unchanged (see docs/EXTENSIONS.md).
|
||||
"""
|
||||
return _extension_state_dir() / "extensions"
|
||||
|
||||
|
||||
def _extension_root() -> Optional[Path]:
|
||||
"""Return the active extension directory, or None when none is available.
|
||||
|
||||
Resolution order:
|
||||
1. ``HERMES_WEBUI_EXTENSION_DIR`` when set — must be an existing directory,
|
||||
otherwise None (the admin owns that path; we never auto-create it).
|
||||
2. Otherwise the WebUI-managed default (``STATE_DIR/extensions``) when it
|
||||
already exists. The first gallery install creates it on demand
|
||||
(see ``_writable_extension_root``); until then this stays None and the
|
||||
UI reports the same "nothing installed yet" state as before.
|
||||
"""
|
||||
raw = os.getenv(_EXTENSION_DIR_ENV, "").strip()
|
||||
if not raw:
|
||||
if raw:
|
||||
root = Path(raw).expanduser().resolve()
|
||||
if not root.exists() or not root.is_dir():
|
||||
return None
|
||||
return root
|
||||
default_root = _default_extension_root()
|
||||
try:
|
||||
if default_root.is_dir() and not default_root.is_symlink():
|
||||
return default_root.resolve()
|
||||
except OSError:
|
||||
return None
|
||||
root = Path(raw).expanduser().resolve()
|
||||
if not root.exists() or not root.is_dir():
|
||||
return None
|
||||
|
||||
|
||||
def _writable_extension_root() -> Optional[Path]:
|
||||
"""Resolve the extension root for writes, bootstrapping the managed default.
|
||||
|
||||
When ``HERMES_WEBUI_EXTENSION_DIR`` is set we use it as-is (the admin owns
|
||||
it; it must already exist). When unset we create and return the
|
||||
WebUI-managed default so a fresh install can install an extension with zero
|
||||
configuration — plug and play.
|
||||
"""
|
||||
raw = os.getenv(_EXTENSION_DIR_ENV, "").strip()
|
||||
if raw:
|
||||
return _extension_root()
|
||||
default_root = _default_extension_root()
|
||||
try:
|
||||
default_root.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
if default_root.is_symlink() or not default_root.is_dir():
|
||||
return None
|
||||
return default_root.resolve()
|
||||
except OSError:
|
||||
return None
|
||||
return root
|
||||
|
||||
|
||||
def _extension_root_status() -> Tuple[Optional[Path], bool, bool]:
|
||||
"""Return (root, configured, valid) without exposing the configured path."""
|
||||
"""Return (root, configured, valid) without exposing the configured path.
|
||||
|
||||
With no ``HERMES_WEBUI_EXTENSION_DIR`` the WebUI-managed default is always
|
||||
available as an install target, so ``configured`` is True (extensions are
|
||||
no longer "not configured" out of the box). ``valid`` reflects whether that
|
||||
managed directory currently exists — it is created on the first install.
|
||||
"""
|
||||
raw = os.getenv(_EXTENSION_DIR_ENV, "").strip()
|
||||
if not raw:
|
||||
return None, False, False
|
||||
root = Path(raw).expanduser().resolve()
|
||||
if not root.exists() or not root.is_dir():
|
||||
return None, True, False
|
||||
return root, True, True
|
||||
if raw:
|
||||
root = Path(raw).expanduser().resolve()
|
||||
if not root.exists() or not root.is_dir():
|
||||
return None, True, False
|
||||
return root, True, True
|
||||
root = _extension_root()
|
||||
return root, True, root is not None
|
||||
|
||||
|
||||
def _new_diagnostics() -> Dict[str, Any]:
|
||||
@@ -924,7 +979,12 @@ def get_extension_status() -> Dict[str, Any]:
|
||||
"stylesheet_count": 0,
|
||||
"sidecar_count": 0,
|
||||
}
|
||||
if dir_configured and not dir_valid:
|
||||
# Only warn about an unavailable directory when the admin explicitly set
|
||||
# HERMES_WEBUI_EXTENSION_DIR to a path that is missing/not-a-dir. The
|
||||
# WebUI-managed default simply not existing yet (pre-first-install) is the
|
||||
# normal opt-in state, not a misconfiguration worth surfacing.
|
||||
env_dir_set = bool(os.getenv(_EXTENSION_DIR_ENV, "").strip())
|
||||
if env_dir_set and dir_configured and not dir_valid:
|
||||
_add_diagnostic_warning(diagnostics, "extension_dir_unavailable", "extension_dir")
|
||||
|
||||
if root is None:
|
||||
@@ -1107,7 +1167,7 @@ def install_extension(id: object, download_url: object, sha256: object) -> Dict[
|
||||
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()
|
||||
root = _writable_extension_root()
|
||||
if root is None:
|
||||
raise ExtensionInstallError("Extensions not configured", 404)
|
||||
try:
|
||||
|
||||
+27
-4
@@ -10,7 +10,8 @@ JavaScript into the app shell without editing the WebUI source tree.
|
||||
> and triggering tool actions. **Only enable extensions you wrote yourself or
|
||||
> from sources you trust as much as the WebUI source itself.** If your WebUI is
|
||||
> shared with users you do not fully trust, do not enable extensions.
|
||||
> Do not point `HERMES_WEBUI_EXTENSION_DIR` at a user-writable directory.
|
||||
> If you set `HERMES_WEBUI_EXTENSION_DIR` yourself, do not point it at a
|
||||
> user-writable directory on a shared host.
|
||||
|
||||
This is intentionally not a plugin marketplace or dependency system. It is a
|
||||
safe escape hatch for local dashboards, internal tooling, and workflow-specific
|
||||
@@ -39,9 +40,31 @@ Extensions cannot, by themselves:
|
||||
|
||||
## Configuration
|
||||
|
||||
Extensions are disabled by default. Configure them with environment variables
|
||||
before starting the WebUI server. `HERMES_WEBUI_EXTENSION_DIR` must point to an
|
||||
existing directory before any script or stylesheet URLs are injected:
|
||||
### One-click install (no configuration required)
|
||||
|
||||
For a single-user self-hosted instance you do not need to configure anything.
|
||||
Open **Settings → Extensions**, pick an extension from the gallery, and click
|
||||
**Install** — it just works. The first install creates a WebUI-managed
|
||||
extension directory under your state dir (`STATE_DIR/extensions`, e.g.
|
||||
`~/.hermes/webui/extensions/`) and installs into it; gallery-installed
|
||||
extensions load automatically on the next app-shell render with no environment
|
||||
variables and no restart of your shell.
|
||||
|
||||
The managed directory lives alongside your sessions and settings in the
|
||||
WebUI-owned state dir. That is a different trust domain from "a world-writable
|
||||
directory on a shared box": only the WebUI process (and whoever can already
|
||||
write your `~/.hermes` state) can place code there. The trust model below still
|
||||
applies — installed extension code runs with full session authority — so only
|
||||
install extensions from the vetted gallery or sources you trust as much as the
|
||||
WebUI source itself.
|
||||
|
||||
### Manual / advanced configuration (optional)
|
||||
|
||||
`HERMES_WEBUI_EXTENSION_DIR` is **optional** and overrides the managed default.
|
||||
Set it when you want extensions to live in a specific directory you control
|
||||
(e.g. a checked-out bundle, or a path mounted into a container). When set it
|
||||
must point to an existing directory before any script or stylesheet URLs are
|
||||
injected; WebUI never auto-creates an admin-specified path:
|
||||
|
||||
```bash
|
||||
export HERMES_WEBUI_EXTENSION_DIR=/path/to/my-extension/static
|
||||
|
||||
@@ -50,10 +50,15 @@ class FakeHandler:
|
||||
return None
|
||||
|
||||
|
||||
def test_extension_config_disabled_by_default(monkeypatch):
|
||||
def test_extension_config_disabled_by_default(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_DIR", raising=False)
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False)
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
|
||||
# Point the managed state dir at an empty temp dir so the default extension
|
||||
# root does not exist yet — config stays disabled until the first install.
|
||||
import api.extensions as extensions
|
||||
|
||||
monkeypatch.setattr(extensions, "_extension_state_dir", lambda: tmp_path)
|
||||
|
||||
from api.extensions import get_extension_config
|
||||
|
||||
|
||||
@@ -70,12 +70,16 @@ def _status_counts_empty():
|
||||
}
|
||||
|
||||
|
||||
def test_extension_status_disabled_by_default():
|
||||
def test_extension_status_disabled_by_default(tmp_path, monkeypatch):
|
||||
# With no HERMES_WEBUI_EXTENSION_DIR and no managed default dir yet, the
|
||||
# gallery is "configured" (a managed install target is always available)
|
||||
# but not yet valid/enabled until the first install creates the directory.
|
||||
_use_extension_state_dir(monkeypatch, tmp_path)
|
||||
from api.extensions import get_extension_status
|
||||
|
||||
assert get_extension_status() == {
|
||||
"enabled": False,
|
||||
"extension_dir_configured": False,
|
||||
"extension_dir_configured": True,
|
||||
"extension_dir_valid": False,
|
||||
"script_urls": [],
|
||||
"stylesheet_urls": [],
|
||||
@@ -264,14 +268,17 @@ def test_extension_status_reports_unreadable_manifest_safely(tmp_path, monkeypat
|
||||
assert "bad-utf8.json" not in repr(status)
|
||||
|
||||
|
||||
def test_extension_status_reports_manifest_disabled_when_dir_unconfigured(monkeypatch):
|
||||
def test_extension_status_reports_manifest_disabled_when_dir_unconfigured(tmp_path, monkeypatch):
|
||||
# No managed default dir exists yet, so even though the gallery target is
|
||||
# "configured", a manifest env points at a directory that isn't valid yet.
|
||||
_use_extension_state_dir(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json")
|
||||
|
||||
from api.extensions import get_extension_status
|
||||
|
||||
status = get_extension_status()
|
||||
assert status["enabled"] is False
|
||||
assert status["extension_dir_configured"] is False
|
||||
assert status["extension_dir_configured"] is True
|
||||
assert status["extension_dir_valid"] is False
|
||||
assert status["manifest"]["status"] == "extension_disabled"
|
||||
assert status["manifest"]["configured"] is True
|
||||
|
||||
@@ -140,6 +140,78 @@ def test_gallery_installed_extension_becomes_runtime_manifest(monkeypatch, tmp_p
|
||||
assert status["extensions"][0]["id"] == "my-ext"
|
||||
|
||||
|
||||
def test_install_bootstraps_managed_default_root_without_env(monkeypatch, tmp_path):
|
||||
"""Plug-and-play (#4933): one-click install works with NO env configured.
|
||||
|
||||
With HERMES_WEBUI_EXTENSION_DIR unset and no extension dir on disk, the
|
||||
first install must bootstrap the WebUI-managed default
|
||||
(STATE_DIR/extensions), land the files there, and make the extension load
|
||||
via get_extension_config() — all without any environment setup.
|
||||
"""
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir()
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_DIR", raising=False)
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_MANIFEST", raising=False)
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False)
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
|
||||
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)
|
||||
|
||||
default_root = state_dir / "extensions"
|
||||
# Pre-install: nothing exists yet, gallery is "configured" but not valid.
|
||||
assert not default_root.exists()
|
||||
pre = ext_mod.get_extension_status()
|
||||
assert pre["enabled"] is False
|
||||
assert pre["extension_dir_configured"] is True
|
||||
assert pre["extension_dir_valid"] is False
|
||||
assert pre["warnings"] == []
|
||||
|
||||
files = {
|
||||
"plug-ext/manifest.json": json.dumps(
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"extensions": [
|
||||
{
|
||||
"id": "plug-ext",
|
||||
"name": "Plug And Play",
|
||||
"scripts": ["app.js"],
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
"plug-ext/app.js": "console.log('plug and play');",
|
||||
}
|
||||
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(
|
||||
"plug-ext",
|
||||
"https://hermes-webui.github.io/exts/plug-ext.zip",
|
||||
sha,
|
||||
)
|
||||
assert result["installed"] is True
|
||||
|
||||
# The managed default root was created on demand and holds the files.
|
||||
assert default_root.is_dir()
|
||||
assert (default_root / "plug-ext" / "app.js").exists()
|
||||
|
||||
# And the extension now loads with zero env configuration.
|
||||
assert ext_mod.get_extension_config() == {
|
||||
"enabled": True,
|
||||
"script_urls": ["/extensions/plug-ext/app.js"],
|
||||
"stylesheet_urls": [],
|
||||
}
|
||||
status = ext_mod.get_extension_status()
|
||||
assert status["enabled"] is True
|
||||
assert status["extension_dir_configured"] is True
|
||||
assert status["extension_dir_valid"] is True
|
||||
assert status["manifest"]["status"] == "gallery_installed"
|
||||
assert status["extensions"][0]["id"] == "plug-ext"
|
||||
|
||||
|
||||
def test_install_bad_hash(monkeypatch, tmp_path):
|
||||
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
|
||||
import api.extensions as ext_mod
|
||||
|
||||
Reference in new issue
Block a user