stage HOTFIX #4589 onto master (v0.51.548) — require explicit opt-in for isolated profile mode (#4586)
Profile-isolation regression: isolated mode was inferred from HERMES_HOME path shape (*/profiles/<name>), 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.
This commit is contained in:
parent
6c78d1c315
commit
17eb82f087
3 changed files with 227 additions and 26 deletions
|
|
@ -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/<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).
|
||||
|
||||
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.
|
||||
|
||||
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/<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
157
tests/test_issue4586_named_profile_not_isolated.py
Normal file
157
tests/test_issue4586_named_profile_not_isolated.py
Normal file
|
|
@ -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/<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"
|
||||
)
|
||||
Loading…
Reference in a new issue