diff --git a/CHANGELOG.md b/CHANGELOG.md index e404cc49..42f930a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/extensions.py b/api/extensions.py index 77327063..d3d3678d 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -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: diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index b1499d93..985b5368 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -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 diff --git a/tests/test_extension_hooks.py b/tests/test_extension_hooks.py index b8c3b752..5edd2de4 100644 --- a/tests/test_extension_hooks.py +++ b/tests/test_extension_hooks.py @@ -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 diff --git a/tests/test_extension_status_endpoint.py b/tests/test_extension_status_endpoint.py index 9e8d56bc..3b14dbc8 100644 --- a/tests/test_extension_status_endpoint.py +++ b/tests/test_extension_status_endpoint.py @@ -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 diff --git a/tests/test_issue4746_extension_gallery.py b/tests/test_issue4746_extension_gallery.py index 2478a2be..5d2c2a2b 100644 --- a/tests/test_issue4746_extension_gallery.py +++ b/tests/test_issue4746_extension_gallery.py @@ -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