Release XO (v0.51.659): one-click extension install works with no env config (#4935, closes #4933)

Release XO (v0.51.659): one-click extension install works with no env config (#4935, closes #4933)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-25 11:59:25 -07:00
commit ccab526528
6 files changed
+199 -27

No files matched your search

+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6 -1
View File
@@ -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
+11 -4
View File
@@ -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
+72
View File
@@ -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