fix(profiles): profile isolation — new_session uses per-request profile, not process global (#800)

Fixes the multi-client profile isolation bug (#798).

- get_hermes_home_for_profile(): pure path resolver, validates name against
  _PROFILE_ID_RE (rejects path traversal), never mutates os.environ or globals
- new_session() accepts explicit profile= param from POST body (S.activeProfile),
  short-circuits the process-level _active_profile global
- streaming handler resolves HERMES_HOME from s.profile instead of the global
- sessions.js sends profile: S.activeProfile in every new-session POST

10 tests in tests/test_issue798.py including concurrency and traversal coverage.

Co-authored-by: nesquena <nesquena@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-04-21 09:16:51 -07:00
committed by GitHub
parent d527629281
commit cbb4ba3f28
7 changed files with 232 additions and 13 deletions

View File

@@ -176,18 +176,27 @@ def get_session(sid):
return s
raise KeyError(sid)
def new_session(workspace=None, model=None):
# Use the live config-derived default so Hermes config changes apply without restart.
try:
from api.profiles import get_active_profile_name
_profile = get_active_profile_name()
except ImportError:
_profile = None
def new_session(workspace=None, model=None, profile=None):
"""Create a new in-memory session and persist it.
*profile* — when supplied by the caller (e.g. from the request body sent
by the active browser tab), it is used directly so that concurrent clients
on different profiles don't fight over a shared process-global. If not
supplied, we fall back to the process-level active profile (the pre-#798
behaviour, preserved for calls that originate outside a request context).
"""
if profile is None:
# Fallback: read process-level global (single-client or startup path)
try:
from api.profiles import get_active_profile_name
profile = get_active_profile_name()
except ImportError:
profile = None
effective_model = model or get_effective_default_model()
s = Session(
workspace=workspace or get_last_workspace(),
model=effective_model,
profile=_profile,
profile=profile,
)
with LOCK:
SESSIONS[s.session_id] = s