diff --git a/README.md b/README.md index 8cfcf82b..72982f29 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/api/config.py b/api/config.py index ba469670..46860e5e 100644 --- a/api/config.py +++ b/api/config.py @@ -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() ) diff --git a/api/profiles.py b/api/profiles.py index 470d9d81..e2e90e46 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -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/`` 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, diff --git a/api/routes.py b/api/routes.py index bfa74dc6..ba3ab815 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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 = [ diff --git a/bootstrap.py b/bootstrap.py index 9bccf09e..e7e92273 100755 --- a/bootstrap.py +++ b/bootstrap.py @@ -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" diff --git a/tests/test_bootstrap_foreground.py b/tests/test_bootstrap_foreground.py index cb2169e7..f0bd98a1 100644 --- a/tests/test_bootstrap_foreground.py +++ b/tests/test_bootstrap_foreground.py @@ -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", [ diff --git a/tests/test_issue1611_session_profile_filtering.py b/tests/test_issue1611_session_profile_filtering.py index 0ca9cd03..3d68551d 100644 --- a/tests/test_issue1611_session_profile_filtering.py +++ b/tests/test_issue1611_session_profile_filtering.py @@ -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 ────────────────────────────────────────────── diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 16d972ae..000168ef 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -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/ 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.""" diff --git a/tests/test_issue4449_state_dir_contract.py b/tests/test_issue4449_state_dir_contract.py new file mode 100644 index 00000000..e6ccdfbe --- /dev/null +++ b/tests/test_issue4449_state_dir_contract.py @@ -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)