From 17eb82f087446e0f43df6c4075fc96a27e1c49eb Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 21 Jun 2026 05:08:59 +0000 Subject: [PATCH 1/4] =?UTF-8?q?stage=20HOTFIX=20#4589=20onto=20master=20(v?= =?UTF-8?q?0.51.548)=20=E2=80=94=20require=20explicit=20opt-in=20for=20iso?= =?UTF-8?q?lated=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" + ) From cb7efedc3818dbf081ac22f968f28756801dd53b Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 21 Jun 2026 05:17:20 +0000 Subject: [PATCH 2/4] harden(#4589): protect HERMES_WEBUI_ISOLATED_PROFILE from profile-.env override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex gate (CORE, reproduced): _reload_dotenv() copies a profile's .env into os.environ, so a contained user could set HERMES_WEBUI_ISOLATED_PROFILE=0 in their own profile .env and silently escape isolation. Fix: _PROTECTED_ENV_KEYS — _reload_dotenv refuses to override operator/deployment keys from a profile .env (logs a warning). + regression test proving .env can't disable isolation. --- api/profiles.py | 26 +++++++++++- ...st_issue4586_named_profile_not_isolated.py | 42 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/api/profiles.py b/api/profiles.py index c04b544b..314e454c 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -125,9 +125,15 @@ def _unwrap_profile_home_to_base(home: Path) -> Path: return home +# Env keys a pinned profile's .env may NOT override via _reload_dotenv() — these +# are operator/deployment-level postures, not per-profile toggles. Letting a +# profile .env set HERMES_WEBUI_ISOLATED_PROFILE=0 would let a contained user +# escape isolation (#4589). +_PROTECTED_ENV_KEYS = frozenset({'HERMES_WEBUI_ISOLATED_PROFILE'}) + + def _isolated_profile_opt_in() -> bool: """Return True only when isolated single-profile mode is EXPLICITLY enabled. - 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 @@ -138,6 +144,14 @@ def _isolated_profile_opt_in() -> bool: profile switching for ordinary single-user deployments (#4586). Accepts the usual truthy values; default (unset/empty/falsey) is OFF. + + Security: this reads the operator-set env var live, but a pinned profile's + ``.env`` can NEVER override it — ``_reload_dotenv()`` explicitly refuses to + copy ``HERMES_WEBUI_ISOLATED_PROFILE`` from a profile ``.env`` into + ``os.environ`` (see ``_PROTECTED_ENV_KEYS``). Otherwise a contained user could + set ``HERMES_WEBUI_ISOLATED_PROFILE=0`` in their own profile ``.env`` and + silently escape isolation (#4589). The opt-in is an operator/deployment + posture, never a per-profile-file toggle. """ return os.getenv('HERMES_WEBUI_ISOLATED_PROFILE', '').strip().lower() in ( '1', 'true', 'yes', 'on', @@ -1061,6 +1075,16 @@ def _reload_dotenv(home: Path): k = k.strip() v = v.strip().strip('"').strip("'") if k and v: + # Operator/deployment-level keys are never overridable by a + # profile's own .env (#4589 — prevents a contained user from + # disabling their isolation via HERMES_WEBUI_ISOLATED_PROFILE=0). + if k in _PROTECTED_ENV_KEYS: + logger.warning( + "Ignoring protected key %s in profile .env %s; " + "operator/deployment env takes precedence", + k, env_path, + ) + continue os.environ[k] = v loaded_keys.add(k) _loaded_profile_env_keys = loaded_keys diff --git a/tests/test_issue4586_named_profile_not_isolated.py b/tests/test_issue4586_named_profile_not_isolated.py index 81d7663a..8b392d7b 100644 --- a/tests/test_issue4586_named_profile_not_isolated.py +++ b/tests/test_issue4586_named_profile_not_isolated.py @@ -155,3 +155,45 @@ class TestIssue4586ExplicitOptInStillWorks: assert _is_isolated_profile_mode() is False, ( f"falsey flag {flag!r} must not engage isolated mode" ) + + +class TestIssue4589ProfileEnvCannotDisableIsolation: + """#4589: a pinned profile's own .env must NOT be able to turn isolation OFF. + + _reload_dotenv() copies a profile's .env into os.environ. Before the fix, a + contained user could put HERMES_WEBUI_ISOLATED_PROFILE=0 in their profile .env + and escape isolation. The operator flag is now protected from .env override. + """ + + def test_profile_env_cannot_clear_isolated_flag(self, named_profile_home): + from api.profiles import _reload_dotenv, _PROTECTED_ENV_KEYS + + assert "HERMES_WEBUI_ISOLATED_PROFILE" in _PROTECTED_ENV_KEYS + + active = named_profile_home["active"] + # Operator opts the deployment into isolation. + with mock.patch.dict(os.environ, {"HERMES_HOME": str(active), + "HERMES_WEBUI_ISOLATED_PROFILE": "1"}, clear=False): + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(active)): + assert _is_isolated_profile_mode() is True + + # The contained user plants HERMES_WEBUI_ISOLATED_PROFILE=0 in their .env. + (active / ".env").write_text( + "HERMES_WEBUI_ISOLATED_PROFILE=0\nSOME_OTHER_KEY=ok\n", encoding="utf-8" + ) + try: + _reload_dotenv(active) + # The protected flag must NOT have been overridden → still isolated. + assert os.environ.get("HERMES_WEBUI_ISOLATED_PROFILE") == "1", ( + "profile .env must not override the operator isolation flag" + ) + assert _is_isolated_profile_mode() is True, ( + "#4589: a profile .env must not be able to disable isolation" + ) + # Non-protected keys still load normally. + assert os.environ.get("SOME_OTHER_KEY") == "ok" + finally: + try: + os.environ.pop("SOME_OTHER_KEY", None) + except Exception: + pass From fba80e69819b158b55541b445111cbc84c042123 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 21 Jun 2026 05:23:09 +0000 Subject: [PATCH 3/4] harden(#4589): close the SECOND isolation-escape door (runtime-env path) Codex re-gate (CORE, reproduced): _reload_dotenv() was guarded, but get_profile_runtime_env() also copies a profile .env into runtime env, which profile_env_for_background_worker()/streaming apply to os.environ -> HERMES_WEBUI_ISOLATED_PROFILE=0 in a profile .env still escaped isolation on that path. Fix: filter _PROTECTED_ENV_KEYS in get_profile_runtime_env's .env loop + add the key to _BLOCKED_RUNTIME_ENV_KEYS (gateway-parity filter). + runtime-path regression test. --- api/profiles.py | 9 +++++++++ ...st_issue4586_named_profile_not_isolated.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/api/profiles.py b/api/profiles.py index 314e454c..dff2dbfc 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -814,6 +814,12 @@ def get_profile_runtime_env(home: Path) -> dict[str, str]: k = k.strip() v = v.strip().strip('"').strip("'") if k and v: + # #4589: never let a profile's own .env override an + # operator/deployment posture (e.g. disable isolation via + # HERMES_WEBUI_ISOLATED_PROFILE=0) on the runtime-env path + # the same way _reload_dotenv() protects the live env. + if k in _PROTECTED_ENV_KEYS: + continue env[k] = v except Exception: logger.debug("Failed to read runtime env from %s", env_path) @@ -837,6 +843,9 @@ _BLOCKED_RUNTIME_ENV_KEYS = { 'PYTHONPATH', 'VIRTUAL_ENV', 'LD_LIBRARY_PATH', + # #4589: operator/deployment isolation posture — never overridable by a + # profile's own env on any runtime/gateway-parity path. + 'HERMES_WEBUI_ISOLATED_PROFILE', } diff --git a/tests/test_issue4586_named_profile_not_isolated.py b/tests/test_issue4586_named_profile_not_isolated.py index 8b392d7b..8bb89496 100644 --- a/tests/test_issue4586_named_profile_not_isolated.py +++ b/tests/test_issue4586_named_profile_not_isolated.py @@ -197,3 +197,22 @@ class TestIssue4589ProfileEnvCannotDisableIsolation: os.environ.pop("SOME_OTHER_KEY", None) except Exception: pass + + def test_runtime_env_path_also_strips_protected_flag(self, named_profile_home): + """#4589 (runtime path): get_profile_runtime_env must NOT carry the protected + isolation flag out of a profile's .env (the background-worker/streaming path + also applies runtime env to os.environ — same escape, second door).""" + from api.profiles import get_profile_runtime_env, _BLOCKED_RUNTIME_ENV_KEYS + + assert "HERMES_WEBUI_ISOLATED_PROFILE" in _BLOCKED_RUNTIME_ENV_KEYS + + active = named_profile_home["active"] + (active / ".env").write_text( + "HERMES_WEBUI_ISOLATED_PROFILE=0\nMY_PROFILE_KEY=value1\n", encoding="utf-8" + ) + env = get_profile_runtime_env(active) + assert "HERMES_WEBUI_ISOLATED_PROFILE" not in env, ( + "#4589: runtime env must not carry the isolation flag out of a profile .env" + ) + # Non-protected profile keys still project into runtime env. + assert env.get("MY_PROFILE_KEY") == "value1" From fbab5bb42bf462f473cd191607dccdc23fc09e0e Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 21 Jun 2026 05:32:53 +0000 Subject: [PATCH 4/4] =?UTF-8?q?docs(release):=20stamp=20v0.51.549=20?= =?UTF-8?q?=E2=80=94=20hotfix=20profile=20switching=20for=20single-user=20?= =?UTF-8?q?named=20profiles=20(#4589/#4586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33fee753..f106bcbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.549] — 2026-06-21 — Release TH (hotfix: profile switching restored for single-user named profiles) + +### Fixed + +- **Profile switching works again for normal single-user named profiles (hotfix #4586).** A regression (since v0.51.528) wrongly treated any single user running under a named profile as an isolated multi-user deployment — because isolated mode was inferred from the `HERMES_HOME` path shape (`*/profiles/`), which the agent launcher exports for any named profile. The Profiles tab showed only one profile and switching was disabled. Isolated single-profile mode now requires an explicit `HERMES_WEBUI_ISOLATED_PROFILE` opt-in and is never inferred from the path shape. The opt-in is also protected from being overridden by a profile's own `.env` (on both the live-env and runtime/background-worker paths), so a pinned profile can't disable its own isolation. Thanks @nesquena-hermes. + ## [v0.51.548] — 2026-06-21 — Release TG (extension load diagnostics) ### Added