fix(#2698): keep isolated deployments on their own state and profile roots
This commit is contained in:
1 parent
c00bfab9f5
commit
f086158c10
9 files changed
+187
-20
No files matched your search
@@ -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_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_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_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
@@ -70,9 +70,10 @@ TLS_ENABLED = TLS_CERT is not None and TLS_KEY is not None
|
||||
|
||||
# ── State directory (env-overridable, never inside repo) ──────────────────────
|
||||
_DEFAULT_HERMES_HOME = _platform_default_hermes_home()
|
||||
_DEFAULT_STATE_HOME = Path(os.getenv("HERMES_HOME") or _DEFAULT_HERMES_HOME).expanduser()
|
||||
|
||||
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()
|
||||
.resolve()
|
||||
)
|
||||
|
||||
+20
-9
@@ -133,17 +133,18 @@ def _is_isolated_profile_mode() -> bool:
|
||||
This indicates a single-profile deployment where the WebUI should pin to
|
||||
that profile and reject cross-profile operations.
|
||||
|
||||
HERMES_BASE_HOME env var, if set, overrides and forces non-isolated mode
|
||||
(allows deployments to explicitly opt out of isolation detection).
|
||||
Isolation is derived only from the literal ``*/profiles/<name>`` shape of
|
||||
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,
|
||||
not the current os.environ value. init_profile_state() overwrites HERMES_HOME
|
||||
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
|
||||
if not hermes_home:
|
||||
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
|
||||
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):
|
||||
return _DEFAULT_HERMES_HOME
|
||||
if not _PROFILE_ID_RE.fullmatch(name):
|
||||
@@ -1378,9 +1383,15 @@ def list_profiles_api() -> list:
|
||||
try:
|
||||
from hermes_cli.profiles import 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:
|
||||
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)
|
||||
return [{
|
||||
'name': p.name,
|
||||
@@ -1403,7 +1414,7 @@ def list_profiles_api() -> list:
|
||||
return [{
|
||||
'name': active,
|
||||
'path': str(hermes_home),
|
||||
'is_default': False,
|
||||
'is_default': _is_root_profile(active),
|
||||
'is_active': True,
|
||||
'gateway_running': False,
|
||||
'model': None,
|
||||
|
||||
+14
-5
@@ -429,6 +429,7 @@ def _visible_pinned_lineage_ids(session_rows) -> set[str]:
|
||||
# module keep resolving without per-call-site refactors.
|
||||
from api.profiles import ( # noqa: F401, E402 (re-export)
|
||||
_profiles_match,
|
||||
_is_isolated_profile_mode,
|
||||
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')
|
||||
|
||||
|
||||
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:
|
||||
"""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
|
||||
else:
|
||||
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")
|
||||
scoped = _keep_latest_messaging_session_per_source(
|
||||
scoped,
|
||||
@@ -8175,7 +8181,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
show_cron_sessions = bool(settings.get("show_cron_sessions"))
|
||||
agent_session_source_filter = settings.get("agent_session_source_filter")
|
||||
active_profile = get_active_profile_name()
|
||||
all_profiles = _all_profiles_query_flag(parsed)
|
||||
all_profiles = _all_profiles_enabled(parsed)
|
||||
key = _session_list_cache_key(
|
||||
active_profile=active_profile,
|
||||
all_profiles=all_profiles,
|
||||
@@ -8213,17 +8219,20 @@ def handle_get(handler, parsed) -> bool:
|
||||
from api.profiles import get_active_profile_name
|
||||
active_profile = get_active_profile_name()
|
||||
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:
|
||||
scoped = all_projects
|
||||
other_profile_count = 0
|
||||
else:
|
||||
scoped = [p for p in all_projects
|
||||
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, {
|
||||
"projects": scoped,
|
||||
"all_profiles": all_profiles,
|
||||
"active_profile": active_profile,
|
||||
"other_profile_count": len(all_projects) - len(scoped),
|
||||
"other_profile_count": other_profile_count,
|
||||
})
|
||||
|
||||
if parsed.path == "/api/prompts":
|
||||
@@ -11191,7 +11200,7 @@ def _handle_sessions_search(handler, parsed):
|
||||
content_search = qs.get("content", ["1"])[0] == "1"
|
||||
from api.profiles import 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()
|
||||
if not all_profiles:
|
||||
sessions = [
|
||||
|
||||
+4
-2
@@ -494,7 +494,8 @@ def main() -> int:
|
||||
|
||||
python_exe = ensure_python_has_webui_deps(discover_launcher_python(agent_dir), agent_dir)
|
||||
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()
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -505,7 +506,8 @@ def main() -> int:
|
||||
if 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")
|
||||
# Scheme the server will advertise (HTTPS when TLS cert+key are configured).
|
||||
scheme = "https" if _tls_probe_enabled() else "http"
|
||||
|
||||
@@ -92,6 +92,9 @@ def clean_env(monkeypatch):
|
||||
"HERMES_WEBUI_HOST",
|
||||
"HERMES_WEBUI_PORT",
|
||||
"HERMES_WEBUI_AGENT_DIR",
|
||||
"HERMES_WEBUI_STATE_DIR",
|
||||
"HERMES_WEBUI_SERVER_CWD",
|
||||
"HERMES_HOME",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
@@ -346,6 +349,27 @@ class TestMainForegroundRouting:
|
||||
bs.main()
|
||||
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:
|
||||
"""The post-execv server.py inherits os.environ and cwd from us."""
|
||||
@@ -384,6 +408,27 @@ class TestForegroundEnvAndCwd:
|
||||
assert len(chdir_calls) == 1
|
||||
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):
|
||||
bs, agent_dir = setup
|
||||
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"
|
||||
|
||||
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -88,8 +88,8 @@ class TestIsolatedProfileModeDetection:
|
||||
isolated = _is_isolated_profile_mode()
|
||||
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):
|
||||
"""HERMES_BASE_HOME env var override forces normal mode even with profile subdir."""
|
||||
def test_hermes_base_home_does_not_disable_isolation(self, temp_single_profile):
|
||||
"""HERMES_BASE_HOME must not disable isolation for a profiles/<name> path."""
|
||||
base_home = temp_single_profile.parent.parent
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
@@ -101,7 +101,7 @@ class TestIsolatedProfileModeDetection:
|
||||
with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home):
|
||||
with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_single_profile)):
|
||||
isolated = _is_isolated_profile_mode()
|
||||
assert isolated is False
|
||||
assert isolated is True
|
||||
|
||||
|
||||
class TestListProfilesInIsolatedMode:
|
||||
@@ -272,6 +272,54 @@ class TestIsolatedRuntimePinning:
|
||||
assert get_active_profile_name() == "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:
|
||||
"""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)
|
||||
Reference in new issue
Block a user