fix(dashboard): keep custom themes visible after embedded chat starts (#60601)

* 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.
This commit is contained in:
xxxigm 2026-07-18 11:34:54 +07:00 committed by GitHub
parent d59b79fadd
commit bf517f9301
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 175 additions and 39 deletions

View file

@ -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:

View file

@ -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"),
]

View file

@ -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:

View file

@ -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", {

View file

@ -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"