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_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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user