Merge pull request #4596 from nesquena/stage-4589

Release v0.51.549 — HOTFIX profile switching for single-user named profiles (#4589/#4586)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-20 22:36:23 -07:00
commit 5d2e510d75
4 files changed
+326 -25

No files matched your search

+6
View File
@@ -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/<name>`), 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
+83 -20
View File
@@ -125,41 +125,85 @@ 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.
# 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'})
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.
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.
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
the ``HERMES_HOME`` shape alone, because a normal single-user who runs under a
named profile produces the byte-identical ``*/profiles/<name>`` shape (the
Hermes Agent launcher exports ``HERMES_HOME=~/.hermes/profiles/<name>`` for any
active named profile). Keying isolation off the shape alone therefore breaks
profile switching for ordinary single-user deployments (#4586).
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.
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',
)
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/<name>`` 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/<name>`` 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/<name>
# i.e., parent dir is named 'profiles' and grandparent exists
# SECONDARY requirement: HERMES_HOME must look like ~/.hermes/profiles/<name>
# 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/<name>; "
"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/<name>; isolated profile mode stays off "
"unless the literal profile path is used.",
p,
)
_ISOLATED_SYMLINK_WARNING_EMITTED = True
@@ -770,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)
@@ -793,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',
}
@@ -1031,6 +1084,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
+19 -5
View File
@@ -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
@@ -0,0 +1,218 @@
"""
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/<name>` 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/<name>` 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/<name> 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/<name> 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"
)
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
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"