From beb39f1ef27cfd727c5b87bdb29b48386a4fcfeb Mon Sep 17 00:00:00 2001 From: hinotoi-agent Date: Mon, 29 Jun 2026 10:04:09 +0800 Subject: [PATCH 1/4] fix: scope file manager sessions to active profile --- api/models.py | 19 +++-- tests/test_file_manager_external_session.py | 83 ++++++++++++++++++++- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/api/models.py b/api/models.py index 8d6363e6..0969958e 100644 --- a/api/models.py +++ b/api/models.py @@ -3392,21 +3392,30 @@ class _ExternalSessionView: def get_session_for_file_ops(sid: str): - """Return a session-like object for file-manager handlers. + """Return a profile-authorized session-like object for file-manager handlers. Tries ``get_session`` first (preserves all existing behavior for WebUI - sessions). If that raises ``KeyError``, checks state.db; when the session - exists there, returns an ``_ExternalSessionView`` whose ``workspace`` is - the active WebUI workspace. If neither has the session, re-raises + sessions) and only returns that session when its stored profile belongs to + the active request profile. If that lookup fails, checks state.db; when the + session exists there, returns an ``_ExternalSessionView`` whose ``workspace`` + is the active WebUI workspace. If neither has the session, re-raises ``KeyError`` so callers continue to return their existing 404. """ try: - return get_session(sid, metadata_only=True) + session = get_session(sid, metadata_only=True) except KeyError: if state_db_has_session(sid): return _ExternalSessionView(str(sid), str(get_last_workspace())) raise + from api.profiles import _profiles_match, get_active_profile_name + + if not _profiles_match( + getattr(session, 'profile', None), get_active_profile_name() + ): + raise KeyError(sid) + return session + def _active_state_db_path() -> Path: """Return state.db for the active Hermes profile, degrading to HERMES_HOME.""" diff --git a/tests/test_file_manager_external_session.py b/tests/test_file_manager_external_session.py index 0acac148..eb6e5c86 100644 --- a/tests/test_file_manager_external_session.py +++ b/tests/test_file_manager_external_session.py @@ -11,9 +11,12 @@ Covers: from __future__ import annotations +import io import re import sqlite3 from pathlib import Path +from types import SimpleNamespace +from urllib.parse import urlparse import pytest @@ -107,7 +110,8 @@ def models_module(): def test_get_session_for_file_ops_webui_passthrough(models_module, monkeypatch): """(a) WebUI session — delegates to get_session, no state.db consulted.""" - sentinel = object() + profiles_module = pytest.importorskip("api.profiles") + sentinel = SimpleNamespace(profile=None) called = {"get_session": 0, "state_db": 0} def fake_get_session(sid, metadata_only=False): @@ -120,11 +124,88 @@ def test_get_session_for_file_ops_webui_passthrough(models_module, monkeypatch): monkeypatch.setattr(models_module, "get_session", fake_get_session) monkeypatch.setattr(models_module, "state_db_has_session", fake_has) + monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default") result = models_module.get_session_for_file_ops("webui-sid") assert result is sentinel assert called == {"get_session": 1, "state_db": 0} +def test_get_session_for_file_ops_rejects_foreign_profile( + models_module, monkeypatch, tmp_path +): + """WebUI sessions must belong to the active profile before file access.""" + profiles_module = pytest.importorskip("api.profiles") + foreign_session = SimpleNamespace(profile="research", workspace=str(tmp_path)) + called = {"get_session": 0, "state_db": 0} + + def fake_get_session(sid, metadata_only=False): + called["get_session"] += 1 + return foreign_session + + def fake_has(_sid): + called["state_db"] += 1 + return True + + monkeypatch.setattr(models_module, "get_session", fake_get_session) + monkeypatch.setattr(models_module, "state_db_has_session", fake_has) + monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default") + + with pytest.raises(KeyError): + models_module.get_session_for_file_ops("foreign-webui-sid") + # A found-but-foreign WebUI sidecar is an authorization failure, not a + # missing-session condition that can fall through to the state.db fallback. + assert called == {"get_session": 1, "state_db": 0} + + +def test_file_read_rejects_foreign_profile_session( + models_module, monkeypatch, tmp_path +): + """A default-profile file route cannot read a named-profile workspace.""" + profiles_module = pytest.importorskip("api.profiles") + routes_module = pytest.importorskip("api.routes") + workspace = tmp_path / "named-workspace" + workspace.mkdir() + (workspace / "marker.txt").write_text("foreign profile marker") + session = models_module.Session( + session_id="foreign-profile-file-read", + workspace=str(workspace), + profile="research", + ) + models_module.SESSIONS[session.session_id] = session + + class Handler: + command = "GET" + headers = {} + + def __init__(self): + self.status = None + self.headers_sent = [] + self.wfile = io.BytesIO() + + def send_response(self, code): + self.status = code + + def send_header(self, key, value): + self.headers_sent.append((key, value)) + + def end_headers(self): + pass + + monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default") + try: + handler = Handler() + routes_module._handle_file_read( + handler, + urlparse( + "/api/file?session_id=foreign-profile-file-read&path=marker.txt" + ), + ) + assert handler.status == 404 + assert b"foreign profile marker" not in handler.wfile.getvalue() + finally: + models_module.SESSIONS.pop(session.session_id, None) + + def test_get_session_for_file_ops_state_db_fallback( models_module, monkeypatch, tmp_path ): From 40679f2af8f3062948c71d48ab839e06319007c9 Mon Sep 17 00:00:00 2001 From: hinotoi-agent Date: Mon, 29 Jun 2026 12:09:46 +0800 Subject: [PATCH 2/4] test: isolate file manager profile checks --- tests/test_file_manager_external_session.py | 22 +++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/test_file_manager_external_session.py b/tests/test_file_manager_external_session.py index eb6e5c86..56df3b0c 100644 --- a/tests/test_file_manager_external_session.py +++ b/tests/test_file_manager_external_session.py @@ -112,22 +112,29 @@ def test_get_session_for_file_ops_webui_passthrough(models_module, monkeypatch): """(a) WebUI session — delegates to get_session, no state.db consulted.""" profiles_module = pytest.importorskip("api.profiles") sentinel = SimpleNamespace(profile=None) - called = {"get_session": 0, "state_db": 0} + called = {"get_session": 0, "profile_match": 0, "state_db": 0} def fake_get_session(sid, metadata_only=False): called["get_session"] += 1 return sentinel + def fake_profiles_match(session_profile, active_profile): + called["profile_match"] += 1 + assert session_profile is None + assert active_profile == "default" + return True + def fake_has(_sid): called["state_db"] += 1 return True monkeypatch.setattr(models_module, "get_session", fake_get_session) monkeypatch.setattr(models_module, "state_db_has_session", fake_has) + monkeypatch.setattr(profiles_module, "_profiles_match", fake_profiles_match) monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default") result = models_module.get_session_for_file_ops("webui-sid") assert result is sentinel - assert called == {"get_session": 1, "state_db": 0} + assert called == {"get_session": 1, "profile_match": 1, "state_db": 0} def test_get_session_for_file_ops_rejects_foreign_profile( @@ -136,25 +143,32 @@ def test_get_session_for_file_ops_rejects_foreign_profile( """WebUI sessions must belong to the active profile before file access.""" profiles_module = pytest.importorskip("api.profiles") foreign_session = SimpleNamespace(profile="research", workspace=str(tmp_path)) - called = {"get_session": 0, "state_db": 0} + called = {"get_session": 0, "profile_match": 0, "state_db": 0} def fake_get_session(sid, metadata_only=False): called["get_session"] += 1 return foreign_session + def fake_profiles_match(session_profile, active_profile): + called["profile_match"] += 1 + assert session_profile == "research" + assert active_profile == "default" + return False + def fake_has(_sid): called["state_db"] += 1 return True monkeypatch.setattr(models_module, "get_session", fake_get_session) monkeypatch.setattr(models_module, "state_db_has_session", fake_has) + monkeypatch.setattr(profiles_module, "_profiles_match", fake_profiles_match) monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default") with pytest.raises(KeyError): models_module.get_session_for_file_ops("foreign-webui-sid") # A found-but-foreign WebUI sidecar is an authorization failure, not a # missing-session condition that can fall through to the state.db fallback. - assert called == {"get_session": 1, "state_db": 0} + assert called == {"get_session": 1, "profile_match": 1, "state_db": 0} def test_file_read_rejects_foreign_profile_session( From 41ec8238ba7a1ebc2ede348f40667c84d5ebad77 Mon Sep 17 00:00:00 2001 From: hinotoi-agent Date: Mon, 29 Jun 2026 16:50:20 +0800 Subject: [PATCH 3/4] chore: log file manager profile denials --- api/models.py | 13 ++++++++++--- tests/test_file_manager_external_session.py | 12 +++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/api/models.py b/api/models.py index 0969958e..eb423121 100644 --- a/api/models.py +++ b/api/models.py @@ -3410,9 +3410,16 @@ def get_session_for_file_ops(sid: str): from api.profiles import _profiles_match, get_active_profile_name - if not _profiles_match( - getattr(session, 'profile', None), get_active_profile_name() - ): + session_profile = getattr(session, 'profile', None) + active_profile = get_active_profile_name() + if not _profiles_match(session_profile, active_profile): + logger.debug( + "Rejected file-manager session for foreign profile: " + "session_id=%s session_profile=%r active_profile=%r", + sid, + session_profile, + active_profile, + ) raise KeyError(sid) return session diff --git a/tests/test_file_manager_external_session.py b/tests/test_file_manager_external_session.py index 56df3b0c..fb5d26e9 100644 --- a/tests/test_file_manager_external_session.py +++ b/tests/test_file_manager_external_session.py @@ -12,6 +12,7 @@ Covers: from __future__ import annotations import io +import logging import re import sqlite3 from pathlib import Path @@ -138,7 +139,7 @@ def test_get_session_for_file_ops_webui_passthrough(models_module, monkeypatch): def test_get_session_for_file_ops_rejects_foreign_profile( - models_module, monkeypatch, tmp_path + models_module, monkeypatch, tmp_path, caplog ): """WebUI sessions must belong to the active profile before file access.""" profiles_module = pytest.importorskip("api.profiles") @@ -164,11 +165,16 @@ def test_get_session_for_file_ops_rejects_foreign_profile( monkeypatch.setattr(profiles_module, "_profiles_match", fake_profiles_match) monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default") - with pytest.raises(KeyError): - models_module.get_session_for_file_ops("foreign-webui-sid") + with caplog.at_level(logging.DEBUG, logger=models_module.logger.name): + with pytest.raises(KeyError): + models_module.get_session_for_file_ops("foreign-webui-sid") # A found-but-foreign WebUI sidecar is an authorization failure, not a # missing-session condition that can fall through to the state.db fallback. assert called == {"get_session": 1, "profile_match": 1, "state_db": 0} + assert "Rejected file-manager session for foreign profile" in caplog.text + assert "foreign-webui-sid" in caplog.text + assert "session_profile='research'" in caplog.text + assert "active_profile='default'" in caplog.text def test_file_read_rejects_foreign_profile_session( From 741973ce7a82f0fe9f8d97dd83c3053905dcc7fe Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 30 Jun 2026 04:22:11 +0000 Subject: [PATCH 4/4] docs(changelog): enforce active profile for file-manager session workspaces (#5179) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 538d0b0a..65ac1d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ ### Fixed +- **The file manager now enforces the active profile when resolving a session's workspace.** When multiple Hermes profiles share a box, a session-scoped file operation looked up the session by id without checking which profile owns it, so a session belonging to a *different* profile could expose its workspace path to the currently active profile. The session-for-file-ops resolver now rejects a session whose owning profile doesn't match the request's active profile (raising the same not-found that the file routes already translate to a 404), failing closed. Same-profile access, single-profile/no-auth deployments, and the external-session (state.db) fallback are all unchanged — only genuine cross-profile lookups are now denied. Thanks @Hinotoi-agent. (#5179) + - **A terminal streaming failure no longer wipes the transcript you can already see.** When the live SSE connection dropped for good mid-response and the server's settled snapshot came back *shorter* than what the browser had already rendered, the chat pane replaced the visible transcript with the shorter snapshot — momentarily losing rows the reader was looking at. The recovery path now preserves the visible transcript when the server snapshot is a verified strict prefix of it and the visible tail is the "Connection interrupted" marker, and otherwise still takes the authoritative server messages (normal settlement is unchanged and stays server-authoritative). The interrupted-connection marker is also de-duplicated so a recovery can't stack multiple copies. Thanks @rodboev. (#5240, fixes #5224) - **Forking/truncating a conversation mid-history no longer drops a kept turn or its tool rows.** When the truncate logic matched rows that lacked a stable id/timestamp (common for provider rows, especially after a recovery that strips timestamps), a duplicate row adjacent to the cut could bind the wrong copy — dropping the kept user turn, or, in the ambiguous case, the kept assistant turn's trailing tool-result rows. The matcher now compares a richer row signature (role + content + tool_call_id / tool_use_id / tool_name / tool_calls) and **fails closed on ambiguity** (cutting at the earliest ambiguous candidate so the kept turn and its tool tail survive) instead of greedily binding the first match. Thanks @rodboev. (#5212, #5134)