From bf517f930144704c586be6f9deca30b072586c76 Mon Sep 17 00:00:00 2001 From: xxxigm <54813621+xxxigm@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:34:54 +0700 Subject: [PATCH] fix(dashboard): keep custom themes visible after embedded chat starts (#60601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): resolve dashboard-owned assets from the process launch home Profile-scoped chat / ?profile= requests install a context-local HERMES_HOME override, which made custom dashboard themes AND user dashboard-plugin extensions disappear once the embedded /chat started under a different profile than the dashboard process. Add get_process_hermes_home() (sharing _hermes_home_from_env() with get_hermes_home() so the two can't drift, and splitting the profile fallback warning into _warn_profile_fallback_once()) and use it for both the theme YAML scan and the user dashboard-plugin scan — machine-level assets that belong to the server's launch home and must not follow a transient per-request override. Genuinely profile-scoped callers (memories/backups/checkpoints/provider config) and the paired _merged_plugins_hub classification are left untouched so they keep following the override. * test(dashboard): cover process-home asset discovery under profile override - get_process_hermes_home(): env set returns that path, unset falls back to the platform default, and an active context-local override is ignored. - _discover_user_themes() and _discover_dashboard_plugins() keep returning launch-home assets while a profile override scopes the request elsewhere. --- hermes_cli/config.py | 2 +- hermes_cli/web_server.py | 13 +++- hermes_constants.py | 113 +++++++++++++++++++--------- tests/hermes_cli/test_web_server.py | 51 +++++++++++++ tests/test_hermes_constants.py | 35 +++++++++ 5 files changed, 175 insertions(+), 39 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index ac3b0aaf82fc..aa44f79fb10b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -743,7 +743,7 @@ def get_container_exec_info() -> Optional[dict]: # ============================================================================= # Re-export from hermes_constants — canonical definition lives there. -from hermes_constants import get_hermes_home # noqa: F811,E402 +from hermes_constants import get_hermes_home, get_process_hermes_home # noqa: F811,E402 from utils import atomic_replace, fast_safe_load def get_config_path() -> Path: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 1ba9a0bd1112..5b3f4482a4d8 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -61,6 +61,7 @@ from hermes_cli.config import ( get_config_path, get_env_path, get_hermes_home, + get_process_hermes_home, load_config, load_env, read_raw_config, @@ -16936,8 +16937,12 @@ def _discover_user_themes() -> list: Returns a list of fully-normalised theme definitions ready to ship to the frontend, so the client can apply them without a secondary round-trip or a built-in stub. + + Uses the dashboard process launch home, not ``get_hermes_home()``, so a + transient profile override from embedded chat does not hide themes that + live under the server's own ``HERMES_HOME``. """ - themes_dir = get_hermes_home() / "dashboard-themes" + themes_dir = get_process_hermes_home() / "dashboard-themes" if not themes_dir.is_dir(): return [] result = [] @@ -17098,8 +17103,12 @@ def _discover_dashboard_plugins() -> list: from hermes_cli.plugins import get_bundled_plugins_dir bundled_root = get_bundled_plugins_dir() + # User dashboard plugins are a dashboard-owned asset (same category as + # theme YAML): resolve them from the process launch home so they don't + # vanish when a request is scoped to another profile via a context-local + # HERMES_HOME override (e.g. embedded /chat under --open-profile). search_dirs = [ - (get_hermes_home() / "plugins", "user"), + (get_process_hermes_home() / "plugins", "user"), (bundled_root / "memory", "bundled"), (bundled_root, "bundled"), ] diff --git a/hermes_constants.py b/hermes_constants.py index 6e344844812d..639d6d48f000 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -52,11 +52,65 @@ def _get_platform_default_hermes_home() -> Path: return Path.home() / ".hermes" +def _hermes_home_from_env() -> Path: + """Resolve HERMES_HOME from the process environment only. + + Reads the ``HERMES_HOME`` env var, falling back to the platform-native + default. Deliberately ignores the context-local override installed by + :func:`set_hermes_home_override`, so this reflects the process/launch + scope rather than a per-task profile. Shared by :func:`get_hermes_home` + and :func:`get_process_hermes_home` so the two never drift. + """ + val = os.environ.get("HERMES_HOME", "").strip() + if val: + return Path(val) + return _get_platform_default_hermes_home() + + +def _warn_profile_fallback_once() -> None: + """Warn once when falling back to the default home while a profile is active. + + Guard: if a non-default profile is sticky-active but ``HERMES_HOME`` is + unset, the fallback to the default profile is almost certainly wrong. + """ + global _profile_fallback_warned + if _profile_fallback_warned: + return + try: + fallback_home = _get_platform_default_hermes_home() + active_path = fallback_home / "active_profile" + active = active_path.read_text().strip() if active_path.exists() else "" + except (UnicodeDecodeError, OSError): + active = "" + if active and active != "default": + _profile_fallback_warned = True + # Write directly to stderr. We intentionally do NOT route this + # through ``logging`` because (a) this function is called at + # module-import time from 30+ sites, often before logging is + # configured, and (b) root-logger propagation would double-emit + # on consoles where a StreamHandler is already attached. + msg = ( + f"[HERMES_HOME fallback] HERMES_HOME is unset but active " + f"profile is {active!r}. Falling back to {fallback_home}, which " + f"is the DEFAULT profile — not {active!r}. Any data this " + f"process writes will land in the wrong profile. The " + f"subprocess spawner should pass HERMES_HOME explicitly " + f"(see issue #18594)." + ) + try: + sys.stderr.write(msg + "\n") + sys.stderr.flush() + except Exception: + pass + + def get_hermes_home() -> Path: """Return the Hermes home directory (default: platform-native path). - Reads HERMES_HOME env var, falls back to the platform-native default. - This is the single source of truth — all other copies should import this. + Resolution order: context-local override (see + :func:`set_hermes_home_override`) → ``HERMES_HOME`` env var → the + platform-native default. This is the single source of truth — all other + copies should import this. When ``HERMES_HOME`` is unset but an ``active_profile`` file indicates a non-default profile is active, logs a loud one-shot warning to @@ -72,42 +126,29 @@ def get_hermes_home() -> Path: if override: return Path(override) - val = os.environ.get("HERMES_HOME", "").strip() - if val: - return Path(val) + if not os.environ.get("HERMES_HOME", "").strip(): + _warn_profile_fallback_once() - # Guard: if a non-default profile is sticky-active, warn once that - # the fallback to the default profile is almost certainly wrong. - global _profile_fallback_warned - if not _profile_fallback_warned: - try: - fallback_home = _get_platform_default_hermes_home() - active_path = fallback_home / "active_profile" - active = active_path.read_text().strip() if active_path.exists() else "" - except (UnicodeDecodeError, OSError): - active = "" - if active and active != "default": - _profile_fallback_warned = True - # Write directly to stderr. We intentionally do NOT route this - # through ``logging`` because (a) this function is called at - # module-import time from 30+ sites, often before logging is - # configured, and (b) root-logger propagation would double-emit - # on consoles where a StreamHandler is already attached. - msg = ( - f"[HERMES_HOME fallback] HERMES_HOME is unset but active " - f"profile is {active!r}. Falling back to {fallback_home}, which " - f"is the DEFAULT profile — not {active!r}. Any data this " - f"process writes will land in the wrong profile. The " - f"subprocess spawner should pass HERMES_HOME explicitly " - f"(see issue #18594)." - ) - try: - sys.stderr.write(msg + "\n") - sys.stderr.flush() - except Exception: - pass + return _hermes_home_from_env() - return _get_platform_default_hermes_home() + +def get_process_hermes_home() -> Path: + """Return the Hermes home for the running process, ignoring task overrides. + + Unlike :func:`get_hermes_home`, this never follows the context-local + override set by :func:`set_hermes_home_override`. It resolves only the + process ``HERMES_HOME`` env var (falling back to the platform default), + so it reflects the scope the process was launched under **as long as + nothing mutates ``os.environ`` in-process**. + + Use this for machine/process-level dashboard-owned assets — theme YAML, + dashboard plugin manifests — that live under the server's launch home and + must stay visible even while a request is scoped to another profile (e.g. + the embedded ``/chat`` running under ``--open-profile``). Do NOT use it + for genuinely profile-scoped data (memories, backups, checkpoints, + provider config) — those should keep following the override. + """ + return _hermes_home_from_env() def get_default_hermes_root() -> Path: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 220fd3537ce9..3f370f14ecf1 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -6129,6 +6129,29 @@ class TestDiscoverUserThemes: assert "bad" not in names # malformed YAML assert len(results) == 1 # only the valid one + def test_ignores_transient_profile_override(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + themes_dir = tmp_path / "dashboard-themes" + themes_dir.mkdir() + (themes_dir / "mine.yaml").write_text("name: mine\n") + + other = tmp_path / "other-profile" + other.mkdir() + + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + from hermes_cli import web_server + + token = set_hermes_home_override(str(other)) + try: + results = web_server._discover_user_themes() + finally: + reset_hermes_home_override(token) + + assert [r["name"] for r in results] == ["mine"] + class TestThemeBootstrapCSS: """Tests for _render_active_theme_bootstrap_css() and its injection @@ -6928,6 +6951,34 @@ class TestDashboardPluginManifestExtensions: assert entry["tab"]["hidden"] is True assert entry["slots"] == ["sidebar", "header-left"] + def test_user_plugins_ignore_profile_home_override(self, tmp_path, monkeypatch): + """Regression: user dashboard extensions are a dashboard-owned asset + (like theme YAML), so they must stay visible after a context-local + HERMES_HOME override scopes a request to another profile.""" + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + launch_home = tmp_path / "launch" + launch_home.mkdir() + self._write_plugin(launch_home, "skin-home", { + "name": "skin-home", + "label": "Skin Home", + "tab": {"path": "/skin-home"}, + "entry": "dist/index.js", + }) + other = tmp_path / "other-profile" + other.mkdir() + + monkeypatch.setenv("HERMES_HOME", str(launch_home)) + from hermes_cli import web_server + token = set_hermes_home_override(str(other)) + try: + plugins = web_server._discover_dashboard_plugins() + finally: + reset_hermes_home_override(token) + assert any(p["name"] == "skin-home" for p in plugins) + def test_override_requires_leading_slash(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) self._write_plugin(tmp_path, "bad-override", { diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 8eddceb70eaf..435d25c5c6e6 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -16,13 +16,16 @@ from hermes_constants import ( get_default_hermes_root, get_hermes_dir, get_hermes_home, + get_process_hermes_home, heal_hermes_managed_node, hermes_managed_node_tree_present, iter_hermes_node_dirs, is_container, node_tool_runnable, parse_reasoning_effort, + reset_hermes_home_override, secure_parent_dir, + set_hermes_home_override, with_hermes_node_path, ) @@ -116,6 +119,38 @@ class TestGetHermesHome: assert get_hermes_home() == local_appdata / "hermes" +class TestGetProcessHermesHome: + """Tests for get_process_hermes_home() — process launch scope. + + Contract: resolve only the process env / platform default, and never + follow the context-local override that per-task profile scoping installs + via set_hermes_home_override(). + """ + + def test_env_set_returns_that_path(self, tmp_path, monkeypatch): + home = tmp_path / "launch-home" + monkeypatch.setenv("HERMES_HOME", str(home)) + assert get_process_hermes_home() == home + + def test_env_unset_returns_platform_default(self, tmp_path, monkeypatch): + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + assert get_process_hermes_home() == tmp_path / ".hermes" + + def test_ignores_context_local_override(self, tmp_path, monkeypatch): + launch_home = tmp_path / "launch-home" + profile_home = tmp_path / "profiles" / "coder" + monkeypatch.setenv("HERMES_HOME", str(launch_home)) + token = set_hermes_home_override(profile_home) + try: + # get_hermes_home() follows the override; the process-scoped + # variant must not. + assert get_hermes_home() == profile_home + assert get_process_hermes_home() == launch_home + finally: + reset_hermes_home_override(token) + + class TestHermesManagedNode: def test_windows_node_dir_prefers_portable_root(self, tmp_path, monkeypatch): home = tmp_path / "hermes"