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:
@@ -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
|
||||
|
||||
@@ -100,6 +100,26 @@ def get_active_hermes_home() -> Path:
|
||||
return _DEFAULT_HERMES_HOME
|
||||
|
||||
|
||||
|
||||
def get_hermes_home_for_profile(name: str) -> Path:
|
||||
"""Return the HERMES_HOME Path for *name* without mutating any process state.
|
||||
|
||||
Safe to call from per-request context (streaming, session creation) because
|
||||
it reads only the filesystem — it never touches os.environ, module-level
|
||||
cached paths, or the process-level _active_profile global.
|
||||
|
||||
Falls back to _DEFAULT_HERMES_HOME (same as 'default') when *name* is None,
|
||||
empty, 'default', or does not match the profile-name format (rejects path
|
||||
traversal such as '../../etc').
|
||||
"""
|
||||
if not name or name == 'default' or not _PROFILE_ID_RE.match(name):
|
||||
return _DEFAULT_HERMES_HOME
|
||||
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||
if profile_dir.is_dir():
|
||||
return profile_dir
|
||||
return _DEFAULT_HERMES_HOME
|
||||
|
||||
|
||||
def _set_hermes_home(home: Path):
|
||||
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
|
||||
os.environ['HERMES_HOME'] = str(home)
|
||||
|
||||
@@ -886,7 +886,9 @@ def handle_post(handler, parsed) -> bool:
|
||||
workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
s = new_session(workspace=workspace, model=body.get("model"))
|
||||
# Use the profile sent by the client tab (if any) so that two tabs on
|
||||
# different profiles never clobber each other via the process-level global.
|
||||
s = new_session(workspace=workspace, model=body.get("model"), profile=body.get("profile") or None)
|
||||
return j(handler, {"session": s.compact() | {"messages": s.messages}})
|
||||
|
||||
if parsed.path == "/api/default-model":
|
||||
|
||||
@@ -834,10 +834,13 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
put('cancel', {'message': 'Cancelled before start'})
|
||||
return
|
||||
|
||||
# Resolve profile home for this agent run (snapshot at start)
|
||||
# Resolve profile home for this agent run — use the session's own profile
|
||||
# (stamped at new_session() time from the client's S.activeProfile) so that
|
||||
# two concurrent tabs on different profiles don't clobber each other via the
|
||||
# process-level active-profile global. Falls back gracefully.
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
_profile_home = str(get_active_hermes_home())
|
||||
from api.profiles import get_hermes_home_for_profile
|
||||
_profile_home = str(get_hermes_home_for_profile(getattr(s, 'profile', None)))
|
||||
except ImportError:
|
||||
_profile_home = os.environ.get('HERMES_HOME', '')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user