From 17eb82f087446e0f43df6c4075fc96a27e1c49eb Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 21 Jun 2026 05:08:59 +0000 Subject: [PATCH] =?UTF-8?q?stage=20HOTFIX=20#4589=20onto=20master=20(v0.51?= =?UTF-8?q?.548)=20=E2=80=94=20require=20explicit=20opt-in=20for=20isolate?= =?UTF-8?q?d=20profile=20mode=20(#4586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile-isolation regression: isolated mode was inferred from HERMES_HOME path shape (*/profiles/), which the launcher exports for ANY normal named profile -> single-users on a named profile lost profile switching + saw only 1 profile. Fix: HERMES_WEBUI_ISOLATED_PROFILE explicit opt-in is now the PRIMARY gate. --- api/profiles.py | 72 +++++--- tests/test_issue2698_isolated_hermes_home.py | 24 ++- ...st_issue4586_named_profile_not_isolated.py | 157 ++++++++++++++++++ 3 files changed, 227 insertions(+), 26 deletions(-) create mode 100644 tests/test_issue4586_named_profile_not_isolated.py diff --git a/api/profiles.py b/api/profiles.py index cc8481b4..c04b544b 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -125,41 +125,71 @@ def _unwrap_profile_home_to_base(home: Path) -> Path: return home -def _is_isolated_profile_mode() -> bool: - """Detect isolated single-profile mode from HERMES_HOME env var. +def _isolated_profile_opt_in() -> bool: + """Return True only when isolated single-profile mode is EXPLICITLY enabled. - Returns True when HERMES_HOME points at a concrete profile subdirectory - (e.g., ~/.hermes/profiles/user1) rather than the base home (~/.hermes). - This indicates a single-profile deployment where the WebUI should pin to - that profile and reject cross-profile operations. + Isolated mode is an intentional multi-user deployment posture (each user is + pinned to one profile and cross-profile operations are rejected). It must be + opted into with ``HERMES_WEBUI_ISOLATED_PROFILE`` — it is NEVER inferred from + the ``HERMES_HOME`` shape alone, because a normal single-user who runs under a + named profile produces the byte-identical ``*/profiles/`` shape (the + Hermes Agent launcher exports ``HERMES_HOME=~/.hermes/profiles/`` for any + active named profile). Keying isolation off the shape alone therefore breaks + profile switching for ordinary single-user deployments (#4586). - 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. + Accepts the usual truthy values; default (unset/empty/falsey) is OFF. """ + return os.getenv('HERMES_WEBUI_ISOLATED_PROFILE', '').strip().lower() in ( + '1', 'true', 'yes', 'on', + ) + + +def _is_isolated_profile_mode() -> bool: + """Detect isolated single-profile mode. + + Returns True only when BOTH conditions hold: + 1. ``HERMES_WEBUI_ISOLATED_PROFILE`` is explicitly enabled (the PRIMARY + gate — see _isolated_profile_opt_in), AND + 2. HERMES_HOME at startup points at a concrete profile subdirectory + (e.g., ~/.hermes/profiles/user1) rather than the base home. + + Why the explicit flag is required (#4586 regression fix): the + ``*/profiles/`` shape alone CANNOT distinguish an intentional + multi-user isolation deployment from an ordinary single-user running under a + named profile — the Hermes Agent launcher sets + ``HERMES_HOME=~/.hermes/profiles/`` for any active named profile, so the + two cases are byte-identical at the env-var level. Inferring isolation from + the shape alone (the v0.51.528 behaviour from #2698) wrongly pinned ordinary + single-user deployments to one profile and disabled profile switching. The + multi-user wrapper that genuinely wants isolation now sets the explicit flag; + everyone else is never caught. The shape stays as a secondary requirement so + a stray flag without a profile-shaped HERMES_HOME does not engage isolation. + + Uses _INITIAL_HERMES_HOME (snapshotted at import time) to detect the shape, + not the current os.environ value. init_profile_state() overwrites HERMES_HOME + at startup, which would disable detection if we read it here. + """ + # PRIMARY gate: explicit opt-in. Default OFF → a normal named-profile launch + # is never treated as isolated, so profile switching keeps working (#4586). + if not _isolated_profile_opt_in(): + return False + hermes_home = _INITIAL_HERMES_HOME if not hermes_home: return False p = Path(hermes_home).expanduser() - # Check if this path looks like ~/.hermes/profiles/ - # i.e., parent dir is named 'profiles' and grandparent exists + # SECONDARY requirement: HERMES_HOME must look like ~/.hermes/profiles/ + # i.e., parent dir is named 'profiles' and grandparent exists. if p.parent.name == 'profiles' and p.parent.parent.exists(): return True if p.is_symlink(): global _ISOLATED_SYMLINK_WARNING_EMITTED if not _ISOLATED_SYMLINK_WARNING_EMITTED: logger.warning( - "HERMES_HOME symlink %s does not literally match */profiles/; " - "isolated profile mode is disabled unless the literal profile path is used.", + "HERMES_WEBUI_ISOLATED_PROFILE is set but HERMES_HOME %s does not " + "literally match */profiles/; isolated profile mode stays off " + "unless the literal profile path is used.", p, ) _ISOLATED_SYMLINK_WARNING_EMITTED = True diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index dae1026d..958cc5c8 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -1,9 +1,16 @@ """ Tests for issue #2698: HERMES_HOME isolated profile mode. -When HERMES_HOME points at a specific profile directory like ~/.hermes/profiles/user1, -the WebUI should pin to that single profile: list only it, reject create/switch/delete -of other profiles, and hide multi-profile UI affordances. +When HERMES_HOME points at a specific profile directory like ~/.hermes/profiles/user1 +AND isolated mode is explicitly opted into via HERMES_WEBUI_ISOLATED_PROFILE=1, the WebUI +should pin to that single profile: list only it, reject create/switch/delete of other +profiles, and hide multi-profile UI affordances. + +Note (#4586): isolated mode now requires the explicit HERMES_WEBUI_ISOLATED_PROFILE opt-in +in addition to the profile-shaped HERMES_HOME — the shape alone is NOT sufficient, because a +normal single-user named profile produces the same shape. The autouse fixture below enables +the flag for this whole module (it tests the isolated-mode deployment posture); the +shape-without-flag regression is covered separately in test_issue4586_*. """ import os @@ -32,8 +39,15 @@ from api.profiles import ( @pytest.fixture(autouse=True) -def _clear_profile_cache(): - """Clear the profile list cache before and after each test to prevent cross-test leakage.""" +def _clear_profile_cache(monkeypatch): + """Clear the profile list cache + enable the isolated-mode opt-in for every test. + + #4586: isolated mode is gated on the explicit HERMES_WEBUI_ISOLATED_PROFILE flag, so + this whole module — which exercises isolated-mode behavior — enables it. Normal-mode + assertions in this file still hold because they point HERMES_HOME at the base home, which + fails the secondary shape requirement regardless of the flag. + """ + monkeypatch.setenv("HERMES_WEBUI_ISOLATED_PROFILE", "1") _profiles_mod._LIST_PROFILES_CACHE = None yield _profiles_mod._LIST_PROFILES_CACHE = None diff --git a/tests/test_issue4586_named_profile_not_isolated.py b/tests/test_issue4586_named_profile_not_isolated.py new file mode 100644 index 00000000..81d7663a --- /dev/null +++ b/tests/test_issue4586_named_profile_not_isolated.py @@ -0,0 +1,157 @@ +""" +Regression test for issue #4586: the v0.51.528 isolated-profile-mode feature (#2698) +must NOT engage from the HERMES_HOME *shape* alone. + +The bug: `_is_isolated_profile_mode()` inferred "isolated mode" purely from a +`~/.hermes/profiles/` shaped HERMES_HOME. But the Hermes Agent launcher exports +exactly that shape for ANY active named (non-default) profile in a normal single-user +deployment — so an ordinary single user running under a named profile (e.g. `webui`) was +wrongly treated as an intentional multi-user isolation deployment. Symptoms (v0.51.528+): + - the Profiles tab listed only the active profile, and + - switching to any other profile was blocked with PermissionError + ("Profile switching is not allowed in isolated profile mode."). + +The fix (#4586): isolated mode requires an EXPLICIT opt-in — HERMES_WEBUI_ISOLATED_PROFILE — +as the primary gate (default OFF). The profile-shaped HERMES_HOME is only a secondary +requirement. A normal named-profile launch (shape set, flag unset) must therefore stay in +normal multi-profile mode. + +These tests deliberately set ONLY the profile shape (no flag) and assert NORMAL behavior — +the exact thing that regressed. They would FAIL on v0.51.528..current and PASS after the fix. +""" + +import os +import tempfile +from pathlib import Path +from unittest import mock + +import pytest + +import api.profiles as _profiles_mod +from api.profiles import ( + _is_isolated_profile_mode, + _isolated_profile_opt_in, + switch_profile, +) + + +@pytest.fixture(autouse=True) +def _clear_cache_and_force_flag_off(monkeypatch): + """Every test here runs WITHOUT the isolated opt-in — the regressed single-user case. + + We explicitly clear HERMES_WEBUI_ISOLATED_PROFILE so the suite's ambient env can't + accidentally enable isolation and mask the regression. + """ + monkeypatch.delenv("HERMES_WEBUI_ISOLATED_PROFILE", raising=False) + _profiles_mod._LIST_PROFILES_CACHE = None + yield + _profiles_mod._LIST_PROFILES_CACHE = None + + +@pytest.fixture +def named_profile_home(): + """A normal single-user layout: base ~/.hermes with several profiles, active = `webui`. + + The active profile's home is `~/.hermes/profiles/webui` — exactly the + `*/profiles/` shape the Hermes Agent launcher exports for a named profile, and + exactly what the #4586 false-positive keyed off. + """ + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) / ".hermes" + profiles_root = home / "profiles" + profiles_root.mkdir(parents=True) + for name in ("alpha", "beta", "webui"): + p = profiles_root / name + for subdir in ("memories", "sessions", "skills", "skins", + "logs", "plans", "workspace", "cron"): + (p / subdir).mkdir(parents=True, exist_ok=True) + yield {"base": home, "active": profiles_root / "webui", "profiles_root": profiles_root} + + +class TestIssue4586NamedProfileIsNotIsolated: + """A named-profile single-user launch (shape set, flag unset) is NOT isolated mode.""" + + def test_shape_alone_does_not_enable_isolated_mode(self, named_profile_home): + """The core regression: */profiles/ WITHOUT the flag → NOT isolated.""" + active = named_profile_home["active"] + assert active.parent.name == "profiles" # has the shape that used to false-positive + with mock.patch.dict(os.environ, {"HERMES_HOME": str(active)}, clear=False): + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(active)): + assert _isolated_profile_opt_in() is False + assert _is_isolated_profile_mode() is False, ( + "named-profile shape must NOT engage isolated mode without the explicit " + "HERMES_WEBUI_ISOLATED_PROFILE opt-in (#4586)" + ) + + def test_profiles_tab_is_not_clamped_to_single_profile(self, named_profile_home): + """Regressed symptom #1: Profiles tab showed only the active profile. + + The regression was driven entirely by `list_profiles_api()` taking its + isolated-mode early-return (`if _is_isolated_profile_mode(): return [only active]`). + We assert the GATE that controls that branch is off — environment-independent, + unlike exercising the full hermes_cli-backed discovery machinery (which depends on + real base-home/profiles-root dirs that differ under CI). With the gate off, + list_profiles_api() takes its normal multi-profile path instead of clamping to one. + """ + active = named_profile_home["active"] + with mock.patch.dict(os.environ, {"HERMES_HOME": str(active)}, clear=False): + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(active)): + # The isolated early-return in list_profiles_api() is gated on this: + assert _is_isolated_profile_mode() is False, ( + "list_profiles_api() must NOT take its single-profile early-return for " + "a normal named profile — that early-return is what hid all but the " + "active profile in the Profiles tab (#4586)" + ) + + def test_switching_to_another_profile_is_allowed(self, named_profile_home): + """Regressed symptom #2: switching was blocked with PermissionError.""" + base = named_profile_home["base"] + active = named_profile_home["active"] + with mock.patch.dict(os.environ, {"HERMES_HOME": str(active)}, clear=False): + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(active)): + with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base): + with mock.patch("api.profiles._resolve_base_hermes_home", return_value=base): + # Must NOT raise PermissionError (the regression). process_wide=False + # keeps this from mutating global interpreter state during the test. + try: + switch_profile("alpha", process_wide=False) + except PermissionError as e: + pytest.fail( + f"profile switching must work for a normal named profile; " + f"got PermissionError: {e} (#4586)" + ) + + +class TestIssue4586ExplicitOptInStillWorks: + """The explicit opt-in still engages isolated mode (multi-user deployments unaffected).""" + + @pytest.mark.parametrize("flag", ["1", "true", "TRUE", "yes", "on"]) + def test_flag_plus_shape_enables_isolated_mode(self, named_profile_home, flag): + active = named_profile_home["active"] + with mock.patch.dict(os.environ, {"HERMES_HOME": str(active), + "HERMES_WEBUI_ISOLATED_PROFILE": flag}, clear=False): + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(active)): + assert _isolated_profile_opt_in() is True + assert _is_isolated_profile_mode() is True, ( + f"explicit opt-in ({flag!r}) + profile shape must still engage isolated mode" + ) + + def test_flag_without_profile_shape_stays_off(self, named_profile_home): + """Secondary requirement: a stray flag without a profile-shaped HERMES_HOME = OFF.""" + base = named_profile_home["base"] # base ~/.hermes, NOT a */profiles/ path + with mock.patch.dict(os.environ, {"HERMES_HOME": str(base), + "HERMES_WEBUI_ISOLATED_PROFILE": "1"}, clear=False): + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(base)): + assert _is_isolated_profile_mode() is False, ( + "flag set but HERMES_HOME is the base home → isolation must stay off" + ) + + @pytest.mark.parametrize("flag", ["", "0", "false", "no", "off", " "]) + def test_falsey_flag_values_are_off(self, named_profile_home, flag): + active = named_profile_home["active"] + with mock.patch.dict(os.environ, {"HERMES_HOME": str(active), + "HERMES_WEBUI_ISOLATED_PROFILE": flag}, clear=False): + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(active)): + assert _is_isolated_profile_mode() is False, ( + f"falsey flag {flag!r} must not engage isolated mode" + )