mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
parent
d59b79fadd
commit
bf517f9301
5 changed files with 175 additions and 39 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue