Per-user and per-chat memory scoping across components
NousResearch/hermes-agent · cluster 11430 · agent
Data as of
10 open items. Fix merged in #60745. Check it covers your case.
The short answer
- Known?
- Yes. 24 related items grouped here, 5 issues already closed.
- Fix event
- A fix was merged to main in PR #60745. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 6 issues and 4 PRs.
Next step: read #60745, then check whether your report is one of the 6 still open.
Read #60745Pick up a Lab taskThese issues and PRs address propagating user identity through agents, gateways, APIs, and memory plugins to isolate long-term memory per user, per chat, or per session. Work spans bug fixes for identity bleed in multi-user contexts and features for opt-in scoping headers and store-level isolation.
Open issues6
- Issue#11430Feature: Per-user memory isolation in group chats to prevent identity contamination
- Issue#14162Feature: Per-conversation context isolation for multi-chat deployments
- Issue#27182Per-user USER.md isolation for multi-user platform deployments
- Issue#28279Feature: Per-Chat Memory Isolation (Scoped Memory)
- Issue#34605Feature request: opt-in X-Hermes-User-Id header to scope per-user long-term memory over the HTTP API
- Issue#62549[Bug]: Dashboard auth identity (`Session.user_id`) never reaches agent construction — memory providers silently fall back to the default user, breaking multi-user isolation
Open PRs4
- PR#34609feat(gateway): opt-in X-Hermes-User-Id header for per-user memory scope
- PR#42322fix(cli): pass user_id to AIAgent for correct Hindsight bank_id_template rendering
- PR#60458Propagate dashboard auth identity to TUI agents
- PR#71224feat(memory): per-chat memory isolation via optional entry-level `scope` (#28279)
Fixes on record
-
#60745 fix(dashboard): scope chat attach tokens by session
files:
hermes_cli/web_server.py,tests/test_pty_keepalive_ws.pyShow the diff
-
hermes_cli/web_server.py@@ -15149,7 +15149,8 @@ async def pty_ws(ws: WebSocket) -> None: return # --- spawn PTY ------------------------------------------------------ - resume = ws.query_params.get("resume") or None + raw_resume = ws.query_params.get("resume") or None + resume = raw_resume profile = ws.query_params.get("profile") or None channel = _channel_or_close_code(ws) sidecar_url = _build_sidecar_url(channel) if channel else None @@ -15192,6 +15193,12 @@ async def pty_ws(ws: WebSocket) -> None: attach_token = [redacted:secret-assignment]"attach") or None + registry_resume = raw_resume + if raw_resume and env: + registry_resume = env.get("HERMES_TUI_RESUME") or raw_resume + if attach_token is not None and (registry_resume or profile): + # Key explicit resumes on their canonical target, never the active-session fallback. + attach_token = f"{attach_token}\0{profile or ''}\0{registry_resume or ''}" def _spawn(): return PtyBridge.spawn(argv, cwd=cwd, env=env) -
tests/test_pty_keepalive_ws.py@@ -1,53 +1,136 @@ +import json + import pytest from hermes_cli import web_server [redacted:email] -async def test_attach_token_reuses_same_session(monkeypatch): - """Two connects with the same ?attach= token hit one spawned bridge.""" - spawned = [] +class FakeBridge: + def __init__(self): + self.alive = True + + def read(self, timeout): + return b"" # idle forever - class FakeBridge: - def __init__(self): - self.alive = True + def write(self, data): + pass - def read(self, timeout): - return b"" # idle forever + def resize(self, cols, rows): + pass - def write(self, data): - pass + def close(self): + self.alive = False - def resize(self, cols, rows): - pass - def close(self): - self.alive = False [redacted:email] +def pty_keepalive_harness(monkeypatch): + spawned = [] def fake_spawn(argv, cwd=None, env=None): b = FakeBridge() - spawned.append(b) + spawned.append(argv) return b monkeypatch.setattr(web_server.PtyBridge, "spawn", staticmethod(fake_spawn)) - # bypass auth + argv resolution for the test monkeypatch.setattr(web_server, "_ws_auth_reason", lambda ws: (None, "test")) monkeypatch.setattr(web_server, "_ws_host_origin_reason", lambda ws: None) monkeypatch.setattr(web_server, "_ws_client_reason", lambda ws: None) async def fake_argv(**kw): - return (["x"], "/tmp", {}) + resume = "child" if kw.get("resume") == "parent" else kw.get("resume") + env = {"HERMES_TUI_RESUME": resume} if resume else {} + return (["x", resume or "fresh"], "/tmp", env) monkeypatch.setattr(web_server, "_resolve_chat_argv_async", fake_argv) - from starlette.testclient import TestClient - try: - client = TestClient(web_server.app) - with client.websocket_connect("/api/pty?attach=TOK1") as ws1: - ws1.send_bytes(b"hi") - with client.websocket_connect("/api/pty?attach=TOK1") as ws2: - ws2.send_bytes(b"again") - assert len(spawned) == 1 # reattached, did not respawn + yield spawned finally: web_server.PTY_REGISTRY._sessions.clear() + + [redacted:email] +async def test_attach_token_reuses_same_session(pty_keepalive_harness): + """Two connects with the same ?attach= token hit one spawned bridge.""" + from starlette.testclient import TestClient + + client = TestClient(web_server.app) + with client.websocket_connect("/api/pty?attach=TOK1") as ws1: + ws1.send_bytes(b"hi") + with client.websocket_connect("/api/pty?attach=TOK1") as ws2: + ws2.send_bytes(b"again") + assert len(pty_keepalive_harness) == 1 # reattached, did not respawn + + [redacted:email] +async def test_attach_token_reuses_same_resume(pty_keepalive_harness): + from starlette.testclient import TestClient + + client = TestClient(web_server.app) + with client.websocket_connect("/api/pty?attach=TOK1&resume=same") as ws1: + ws1.send_bytes(b"hi") + with client.websocket_connect("/api/pty?attach=TOK1&resume=same") as ws2: + ws2.send_bytes(b"again") + assert pty_keepalive_harness == [["x", "same"]] + + [redacted:email] +async def test_attach_token_does_not_reuse_different_resume(pty_keepalive_harness): + """A keep-alive token must not pin /chat to an old selected session.""" + from starlette.testclient import TestClient + + client = TestClient(web_server.app) + with client.websocket_connect("/api/pty?attach=TOK1&resume=old") as ws1: + ws1.send_bytes(b"hi") + with client.websocket_connect("/api/pty?attach=TOK1&resume=new") as ws2: + ws2.send_bytes(b"again") + assert pty_keepalive_harness == [["x", "old"], ["x", "new"]] + + [redacted:email] +async def test_attach_token_reuses_canonical_resume(pty_keepalive_harness): + from starlette.testclient import TestClient + + client = TestClient(web_server.app) + with client.websocket_connect("/api/pty?attach=TOK1&resume=parent") as ws1: + ws1.send_bytes(b"hi") + with client.websocket_connect("/api/pty?attach=TOK1&resume=child") as ws2: + ws2.send_bytes(b"again") + assert pty_keepalive_harness == [["x", "child"]] + + [redacted:email] +async def test_attach_token_does_not_reuse_different_profile(pty_keepalive_harness): + from starlette.testclient import TestClient + + client = TestClient(web_server.app) + with client.websocket_connect("/api/pty?attach=TOK1&profile=alpha") as ws1: + ws1.send_bytes(b"hi") + with client.websocket_connect("/api/pty?attach=TOK1&profile=beta") as ws2: + ws2.send_bytes(b"again") + assert len(pty_keepalive_harness) == 2 + + [redacted:email] +async def test_attach_token_reuses_default_chat_after_active_session_fallback( + pty_keepalive_harness, tmp_path, monkeypatch +): + from starlette.testclient import TestClient + + active_session_file = tmp_path / "active-session.json" + monkeypatch.setattr( + web_server, + "_active_session_file_for_channel", + lambda app, channel: active_session_file, + ) + + client = TestClient(web_server.app) + with client.websocket_connect("/api/pty?attach=TOK1&channel=CHAT") as ws1: + ws1.send_bytes(b"hi") + + active_session_file.write_text(json.dumps({"session_id": "existing"})) + + with client.websocket_connect("/api/pty?attach=TOK1&channel=CHAT") as ws2: + ws2.send_bytes(b"again") + + assert pty_keepalive_harness == [["x", "fresh"]]
-
8 PRs closed without merging
From the close comments: duplicate 3 · won't fix / by design 1 · other 4
- #11452 feat: per-user memory isolation in group chats (#11430) · other
-
#27183 Add per-user USER.md isolation in MemoryStore · other →
#23784 -
#52903 fix(memory): isolate long-term memory per user and per group · won't fix / by design →
8a38462,6bd4311,#4845,#29006 -
#62563 fix: propagate dashboard auth user identity to agent construction (#62549) · other →
#62549,#60458,#62563 -
#62629 fix(dashboard): propagate authenticated user_id through PTY spawn to agent (#62549) · duplicate →
#62563,#62549,#60458
Closed issues · 5
- Closed#25084[Feature]: Add force_user_id config to Mem0 plugin to prevent gateway namespace fragmentation
- Closed#35408[Feature]: Dashboard authenticated user identity should propagate to the agent session
- Closed#42321TUI: AIAgent missing user_id — Hindsight bank_id_template renders empty user segment
- Closed#52900[Bug]: Cross-user long-term memory bleed on multi-user messaging platforms
- Closed#64340mem0 plugin: group chat memory stored under wrong user_id due to agent cache reuse
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.06: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #60745
- Closed issues
- 5
- Attempts
- 8 PRs closed without merging
- Fix link checked
- 16 Jul 2026