fix(#2698): keep isolated deployments on their own state and profile roots

This commit is contained in:
Rod Boev authored and nesquena-hermes committed 2026-06-20 00:48:19 +00:00
1 parent c00bfab9f5
commit f086158c10
9 files changed
+187 -20

No files matched your search

+1
View File
@@ -348,6 +348,7 @@ Full list of environment variables:
| `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` | *(unset)* | Optional comma-separated same-origin stylesheet URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) | | `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` | *(unset)* | Optional comma-separated same-origin stylesheet URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) |
| `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) | | `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) |
| `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file | | `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file |
| `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable |
| `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) | | `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) |
| `HERMES_WEBUI_SESSIONS_MAX` | `100` | Max compact `Session` objects held in the in-memory LRU. Lighter than the agent cache; lower it on installs with hundreds of sessions | | `HERMES_WEBUI_SESSIONS_MAX` | `100` | Max compact `Session` objects held in the in-memory LRU. Lighter than the agent cache; lower it on installs with hundreds of sessions |
+2 -1
View File
@@ -70,9 +70,10 @@ TLS_ENABLED = TLS_CERT is not None and TLS_KEY is not None
# ── State directory (env-overridable, never inside repo) ────────────────────── # ── State directory (env-overridable, never inside repo) ──────────────────────
_DEFAULT_HERMES_HOME = _platform_default_hermes_home() _DEFAULT_HERMES_HOME = _platform_default_hermes_home()
_DEFAULT_STATE_HOME = Path(os.getenv("HERMES_HOME") or _DEFAULT_HERMES_HOME).expanduser()
STATE_DIR = ( STATE_DIR = (
Path(os.getenv("HERMES_WEBUI_STATE_DIR", str(_DEFAULT_HERMES_HOME / "webui"))) Path(os.getenv("HERMES_WEBUI_STATE_DIR", str(_DEFAULT_STATE_HOME / "webui")))
.expanduser() .expanduser()
.resolve() .resolve()
) )
+20 -9
View File
@@ -133,17 +133,18 @@ def _is_isolated_profile_mode() -> bool:
This indicates a single-profile deployment where the WebUI should pin to This indicates a single-profile deployment where the WebUI should pin to
that profile and reject cross-profile operations. that profile and reject cross-profile operations.
HERMES_BASE_HOME env var, if set, overrides and forces non-isolated mode Isolation is derived only from the literal ``*/profiles/<name>`` shape of
(allows deployments to explicitly opt out of isolation detection). HERMES_HOME at startup. A normal multi-profile deployment points
HERMES_HOME at the base home (``~/.hermes``), which does not match the
shape, so isolation stays off. We deliberately do not key this off
HERMES_BASE_HOME: that variable normally points at the base home already,
so using it as an opt-out silently disables legitimate isolated-profile
deployments.
Uses _INITIAL_HERMES_HOME (snapshotted at import time) to detect isolation, Uses _INITIAL_HERMES_HOME (snapshotted at import time) to detect isolation,
not the current os.environ value. init_profile_state() overwrites HERMES_HOME not the current os.environ value. init_profile_state() overwrites HERMES_HOME
at startup, which would disable isolation detection if we read it here. at startup, which would disable isolation detection if we read it here.
""" """
# Explicit HERMES_BASE_HOME opt-out takes precedence
if os.getenv('HERMES_BASE_HOME', '').strip():
return False
hermes_home = _INITIAL_HERMES_HOME hermes_home = _INITIAL_HERMES_HOME
if not hermes_home: if not hermes_home:
return False return False
@@ -375,6 +376,10 @@ def _resolve_profile_home_for_name(name: str) -> Path:
names fall back to the base home so traversal-shaped cookie values cannot names fall back to the base home so traversal-shaped cookie values cannot
influence filesystem paths. influence filesystem paths.
""" """
# In isolated mode, the pinned profile name, including a literal
# "default", must resolve to the configured startup HERMES_HOME.
if _is_isolated_profile_mode() and name == _isolated_profile_name():
return Path(_INITIAL_HERMES_HOME).expanduser()
if not name or _is_root_profile(name): if not name or _is_root_profile(name):
return _DEFAULT_HERMES_HOME return _DEFAULT_HERMES_HOME
if not _PROFILE_ID_RE.fullmatch(name): if not _PROFILE_ID_RE.fullmatch(name):
@@ -1378,9 +1383,15 @@ def list_profiles_api() -> list:
try: try:
from hermes_cli.profiles import list_profiles from hermes_cli.profiles import list_profiles
infos = list_profiles() infos = list_profiles()
# Find the current profile and return only that one # When the isolated profile is literally named "default", upstream
# can surface the base-home row first. Only trust a row whose path
# resolves to the same directory as the isolated startup home.
for p in infos: for p in infos:
if p.name == active: try:
same_home = Path(p.path).expanduser().resolve() == hermes_home.resolve()
except OSError:
same_home = False
if p.name == active and same_home:
enabled_count, total_count = _get_profile_skills_stats(p.path) enabled_count, total_count = _get_profile_skills_stats(p.path)
return [{ return [{
'name': p.name, 'name': p.name,
@@ -1403,7 +1414,7 @@ def list_profiles_api() -> list:
return [{ return [{
'name': active, 'name': active,
'path': str(hermes_home), 'path': str(hermes_home),
'is_default': False, 'is_default': _is_root_profile(active),
'is_active': True, 'is_active': True,
'gateway_running': False, 'gateway_running': False,
'model': None, 'model': None,
+14 -5
View File
@@ -429,6 +429,7 @@ def _visible_pinned_lineage_ids(session_rows) -> set[str]:
# module keep resolving without per-call-site refactors. # module keep resolving without per-call-site refactors.
from api.profiles import ( # noqa: F401, E402 (re-export) from api.profiles import ( # noqa: F401, E402 (re-export)
_profiles_match, _profiles_match,
_is_isolated_profile_mode,
get_active_profile_name, get_active_profile_name,
get_active_profile_name as _get_active_profile_name, get_active_profile_name as _get_active_profile_name,
) )
@@ -445,6 +446,11 @@ def _all_profiles_query_flag(parsed_url) -> bool:
return raw in ('1', 'true', 'yes', 'on') return raw in ('1', 'true', 'yes', 'on')
def _all_profiles_enabled(parsed_url) -> bool:
"""Enable aggregate profile reads only when the request asks and mode allows it."""
return _all_profiles_query_flag(parsed_url) and not _is_isolated_profile_mode()
def _session_visible_to_active_profile(session_profile, handler=None) -> bool: def _session_visible_to_active_profile(session_profile, handler=None) -> bool:
"""Return whether a detail-load session belongs to the active profile. """Return whether a detail-load session belongs to the active profile.
@@ -1927,7 +1933,7 @@ def _build_session_list_cache_payload(
other_profile_count = 0 other_profile_count = 0
else: else:
scoped = [s for s in merged if _profiles_match(s.get("profile"), active_profile)] scoped = [s for s in merged if _profiles_match(s.get("profile"), active_profile)]
other_profile_count = len(merged) - len(scoped) other_profile_count = 0 if _is_isolated_profile_mode() else len(merged) - len(scoped)
diag_stage("messaging_dedupe") diag_stage("messaging_dedupe")
scoped = _keep_latest_messaging_session_per_source( scoped = _keep_latest_messaging_session_per_source(
scoped, scoped,
@@ -8175,7 +8181,7 @@ def handle_get(handler, parsed) -> bool:
show_cron_sessions = bool(settings.get("show_cron_sessions")) show_cron_sessions = bool(settings.get("show_cron_sessions"))
agent_session_source_filter = settings.get("agent_session_source_filter") agent_session_source_filter = settings.get("agent_session_source_filter")
active_profile = get_active_profile_name() active_profile = get_active_profile_name()
all_profiles = _all_profiles_query_flag(parsed) all_profiles = _all_profiles_enabled(parsed)
key = _session_list_cache_key( key = _session_list_cache_key(
active_profile=active_profile, active_profile=active_profile,
all_profiles=all_profiles, all_profiles=all_profiles,
@@ -8213,17 +8219,20 @@ def handle_get(handler, parsed) -> bool:
from api.profiles import get_active_profile_name from api.profiles import get_active_profile_name
active_profile = get_active_profile_name() active_profile = get_active_profile_name()
all_projects = load_projects() all_projects = load_projects()
all_profiles = _all_profiles_query_flag(parsed) isolated_profile_mode = _is_isolated_profile_mode()
all_profiles = _all_profiles_enabled(parsed)
if all_profiles: if all_profiles:
scoped = all_projects scoped = all_projects
other_profile_count = 0
else: else:
scoped = [p for p in all_projects scoped = [p for p in all_projects
if _profiles_match(p.get("profile"), active_profile)] if _profiles_match(p.get("profile"), active_profile)]
other_profile_count = 0 if isolated_profile_mode else len(all_projects) - len(scoped)
return j(handler, { return j(handler, {
"projects": scoped, "projects": scoped,
"all_profiles": all_profiles, "all_profiles": all_profiles,
"active_profile": active_profile, "active_profile": active_profile,
"other_profile_count": len(all_projects) - len(scoped), "other_profile_count": other_profile_count,
}) })
if parsed.path == "/api/prompts": if parsed.path == "/api/prompts":
@@ -11191,7 +11200,7 @@ def _handle_sessions_search(handler, parsed):
content_search = qs.get("content", ["1"])[0] == "1" content_search = qs.get("content", ["1"])[0] == "1"
from api.profiles import get_active_profile_name from api.profiles import get_active_profile_name
active_profile = get_active_profile_name() active_profile = get_active_profile_name()
all_profiles = _all_profiles_query_flag(parsed) all_profiles = _all_profiles_enabled(parsed)
sessions = all_sessions() sessions = all_sessions()
if not all_profiles: if not all_profiles:
sessions = [ sessions = [
+4 -2
View File
@@ -494,7 +494,8 @@ def main() -> int:
python_exe = ensure_python_has_webui_deps(discover_launcher_python(agent_dir), agent_dir) python_exe = ensure_python_has_webui_deps(discover_launcher_python(agent_dir), agent_dir)
state_dir = Path( state_dir = Path(
os.getenv("HERMES_WEBUI_STATE_DIR", str(Path.home() / ".hermes" / "webui")) os.getenv("HERMES_WEBUI_STATE_DIR")
or Path(os.getenv("HERMES_HOME") or (Path.home() / ".hermes")) / "webui"
).expanduser() ).expanduser()
state_dir.mkdir(parents=True, exist_ok=True) state_dir.mkdir(parents=True, exist_ok=True)
@@ -505,7 +506,8 @@ def main() -> int:
if agent_dir: if agent_dir:
os.environ["HERMES_WEBUI_AGENT_DIR"] = str(agent_dir) os.environ["HERMES_WEBUI_AGENT_DIR"] = str(agent_dir)
server_cwd = str(agent_dir or REPO_ROOT) # Let operators move fallback relative writes out of a read-only agent dir.
server_cwd = os.environ.get("HERMES_WEBUI_SERVER_CWD", "").strip() or str(agent_dir or REPO_ROOT)
server_path = str(REPO_ROOT / "server.py") server_path = str(REPO_ROOT / "server.py")
# Scheme the server will advertise (HTTPS when TLS cert+key are configured). # Scheme the server will advertise (HTTPS when TLS cert+key are configured).
scheme = "https" if _tls_probe_enabled() else "http" scheme = "https" if _tls_probe_enabled() else "http"
+45
View File
@@ -92,6 +92,9 @@ def clean_env(monkeypatch):
"HERMES_WEBUI_HOST", "HERMES_WEBUI_HOST",
"HERMES_WEBUI_PORT", "HERMES_WEBUI_PORT",
"HERMES_WEBUI_AGENT_DIR", "HERMES_WEBUI_AGENT_DIR",
"HERMES_WEBUI_STATE_DIR",
"HERMES_WEBUI_SERVER_CWD",
"HERMES_HOME",
): ):
monkeypatch.delenv(name, raising=False) monkeypatch.delenv(name, raising=False)
@@ -346,6 +349,27 @@ class TestMainForegroundRouting:
bs.main() bs.main()
assert len(execv_calls) == 1 assert len(execv_calls) == 1
def test_foreground_defaults_state_dir_to_hermes_home_webui(self, stub_main_dependencies, clean_env, monkeypatch, tmp_path):
bs = stub_main_dependencies
hermes_home = tmp_path / ".hermes" / "profiles" / "webui"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_WEBUI_STATE_DIR", raising=False)
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr(os, "chdir", lambda p: None)
monkeypatch.setattr(os, "access", lambda path, mode: True)
def fake_execv(*_args):
raise SystemExit(0)
monkeypatch.setattr(os, "execv", fake_execv)
with pytest.raises(SystemExit):
bs.main()
assert os.environ["HERMES_WEBUI_STATE_DIR"] == str(hermes_home / "webui")
class TestForegroundEnvAndCwd: class TestForegroundEnvAndCwd:
"""The post-execv server.py inherits os.environ and cwd from us.""" """The post-execv server.py inherits os.environ and cwd from us."""
@@ -384,6 +408,27 @@ class TestForegroundEnvAndCwd:
assert len(chdir_calls) == 1 assert len(chdir_calls) == 1
assert chdir_calls[0] == str(agent_dir) assert chdir_calls[0] == str(agent_dir)
def test_foreground_chdirs_to_server_cwd_override_before_launch(self, setup, monkeypatch, clean_env, tmp_path):
bs, _agent_dir = setup
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setenv("HERMES_WEBUI_SERVER_CWD", str(workspace))
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
monkeypatch.setattr(sys, "platform", "linux")
chdir_calls = []
monkeypatch.setattr(os, "chdir", lambda p: chdir_calls.append(p))
def fake_execv(*_args):
raise SystemExit(0)
monkeypatch.setattr(os, "execv", fake_execv)
with pytest.raises(SystemExit):
bs.main()
assert chdir_calls == [str(workspace)]
def test_foreground_exports_resolved_env_vars(self, setup, monkeypatch, clean_env): def test_foreground_exports_resolved_env_vars(self, setup, monkeypatch, clean_env):
bs, agent_dir = setup bs, agent_dir = setup
monkeypatch.setattr(sys, "argv", [ monkeypatch.setattr(sys, "argv", [
@@ -106,6 +106,22 @@ def test_all_profiles_query_flag_false_values():
assert _all_profiles_query_flag(u) is False, f"path {path!r} should be false" assert _all_profiles_query_flag(u) is False, f"path {path!r} should be false"
def test_all_profiles_enabled_in_normal_mode(monkeypatch):
"""The aggregate toggle still works outside isolated-profile mode."""
import api.routes as routes
monkeypatch.setattr(routes, "_is_isolated_profile_mode", lambda: False)
assert routes._all_profiles_enabled(urlparse('/api/sessions?all_profiles=1')) is True
def test_all_profiles_disabled_in_isolated_mode(monkeypatch):
"""An isolated deployment must ignore all_profiles=1 aggregate requests."""
import api.routes as routes
monkeypatch.setattr(routes, "_is_isolated_profile_mode", lambda: True)
assert routes._all_profiles_enabled(urlparse('/api/sessions?all_profiles=1')) is False
# ── No client-side CLI bypass ────────────────────────────────────────────── # ── No client-side CLI bypass ──────────────────────────────────────────────
+51 -3
View File
@@ -88,8 +88,8 @@ class TestIsolatedProfileModeDetection:
isolated = _is_isolated_profile_mode() isolated = _is_isolated_profile_mode()
assert isolated is True, f"Expected isolated mode for {temp_single_profile}" assert isolated is True, f"Expected isolated mode for {temp_single_profile}"
def test_hermes_base_home_override_forces_normal_mode(self, temp_single_profile): def test_hermes_base_home_does_not_disable_isolation(self, temp_single_profile):
"""HERMES_BASE_HOME env var override forces normal mode even with profile subdir.""" """HERMES_BASE_HOME must not disable isolation for a profiles/<name> path."""
base_home = temp_single_profile.parent.parent base_home = temp_single_profile.parent.parent
with mock.patch.dict( with mock.patch.dict(
os.environ, os.environ,
@@ -101,7 +101,7 @@ class TestIsolatedProfileModeDetection:
with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home): with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home):
with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_single_profile)): with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_single_profile)):
isolated = _is_isolated_profile_mode() isolated = _is_isolated_profile_mode()
assert isolated is False assert isolated is True
class TestListProfilesInIsolatedMode: class TestListProfilesInIsolatedMode:
@@ -272,6 +272,54 @@ class TestIsolatedRuntimePinning:
assert get_active_profile_name() == "default" assert get_active_profile_name() == "default"
assert get_active_hermes_home() == isolated_default assert get_active_hermes_home() == isolated_default
def test_explicit_profile_resolution_for_isolated_default_uses_pinned_home(self, temp_hermes_home, monkeypatch):
"""Explicit default-profile resolution must stay on the isolated home."""
from api.profiles import get_hermes_home_for_profile, _resolve_profile_home_for_name
isolated_default = temp_hermes_home / "profiles" / "default"
isolated_default.mkdir()
for subdir in ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron"]:
(isolated_default / subdir).mkdir(exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(isolated_default))
monkeypatch.setattr(_profiles_mod, "_DEFAULT_HERMES_HOME", temp_hermes_home)
monkeypatch.setattr(_profiles_mod, "_INITIAL_HERMES_HOME", str(isolated_default))
assert _resolve_profile_home_for_name("default") == isolated_default
assert get_hermes_home_for_profile("default") == isolated_default
assert get_hermes_home_for_profile("default") != temp_hermes_home
def test_list_profiles_prefers_matching_isolated_default_home(self, temp_hermes_home, monkeypatch):
"""The isolated profiles/default row must not collapse to the base-home row."""
isolated_default = temp_hermes_home / "profiles" / "default"
isolated_default.mkdir(parents=True)
for subdir in ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron"]:
(isolated_default / subdir).mkdir(exist_ok=True)
class _Info:
def __init__(self, name, path, is_default):
self.name = name
self.path = path
self.is_default = is_default
self.gateway_running = False
self.model = None
self.provider = None
self.has_env = False
monkeypatch.setenv("HERMES_HOME", str(isolated_default))
monkeypatch.setattr(_profiles_mod, "_DEFAULT_HERMES_HOME", temp_hermes_home)
monkeypatch.setattr(_profiles_mod, "_INITIAL_HERMES_HOME", str(isolated_default))
monkeypatch.setattr(_profiles_mod, "_get_profile_skills_stats", lambda _path: (0, 0))
with mock.patch("hermes_cli.profiles.list_profiles", return_value=[
_Info("default", temp_hermes_home, True),
_Info("default", isolated_default, False),
]):
rows = list_profiles_api()
assert len(rows) == 1
assert Path(rows[0]["path"]) == isolated_default
class TestProfileMutationsInIsolatedMode: class TestProfileMutationsInIsolatedMode:
"""Test that create/delete/switch are rejected (403) in isolated mode.""" """Test that create/delete/switch are rejected (403) in isolated mode."""
@@ -0,0 +1,34 @@
"""Regression tests for the #4449 state-dir contract folded into #4454."""
from __future__ import annotations
import importlib
import os
from pathlib import Path
import api.config as config
def test_config_state_dir_defaults_to_hermes_home_webui(tmp_path):
hermes_home = tmp_path / ".hermes" / "profiles" / "isolated"
hermes_home.mkdir(parents=True)
old_home = os.environ.get("HERMES_HOME")
old_state_dir = os.environ.get("HERMES_WEBUI_STATE_DIR")
try:
os.environ["HERMES_HOME"] = str(hermes_home)
os.environ.pop("HERMES_WEBUI_STATE_DIR", None)
reloaded = importlib.reload(config)
assert reloaded.STATE_DIR == (hermes_home / "webui").resolve()
finally:
if old_home is None:
os.environ.pop("HERMES_HOME", None)
else:
os.environ["HERMES_HOME"] = old_home
if old_state_dir is None:
os.environ.pop("HERMES_WEBUI_STATE_DIR", None)
else:
os.environ["HERMES_WEBUI_STATE_DIR"] = old_state_dir
importlib.reload(config)