Files
nanobot/tests/session/test_session_location.py
T
李明振andXubin Ren b34f1bd0e8 fix(session): store session history outside the agent workspace
Session files lived under <workspace>/sessions/ (since #713), which is the
on-disk scope of the agent's filesystem tools. With restrict_to_workspace
enabled, an agent could read_file / list_dir every session transcript —
including other users' or channels' conversations — bypassing the scoped
sessions.py access layer entirely.

Move session storage to ~/.nanobot/sessions/<sha256-of-resolved-workspace>[:16]/,
outside the workspace. Per-workspace isolation (the goal of #713) is preserved
via a hash of the resolved workspace path, so different workspaces keep
independent session stores. A one-shot, idempotent migration moves legacy
in-workspace *.jsonl files into the new location at store init.

Scope note: this protects sessions whenever restrict_to_workspace=true. The
default restrict_to_workspace=false leaves read_file unrestricted in general
(not only sessions) and is a separate concern.

Refs #5278
2026-08-13 01:41:10 +09:00

104 lines
3.8 KiB
Python

"""Session storage location: outside the agent workspace (ADR-0001)."""
from __future__ import annotations
import json
from pathlib import Path
from nanobot.session.manager import JsonlSessionStore, SessionManager
def _write_legacy_session(old_dir: Path, key: str, content: str) -> Path:
"""Write a valid session file in the legacy in-workspace location."""
old_dir.mkdir(parents=True, exist_ok=True)
path = old_dir / f"{JsonlSessionStore.storage_key(key)}.jsonl"
path.write_text(
json.dumps(
{
"_type": "metadata",
"key": key,
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00",
"metadata": {},
"last_consolidated": 0,
}
)
+ "\n"
+ json.dumps({"role": "user", "content": content})
+ "\n",
encoding="utf-8",
)
return path
def test_sessions_are_stored_outside_workspace(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
manager = SessionManager(workspace=workspace)
session = manager.get_or_create("telegram:1")
session.add_message("user", "hello")
manager.save(session)
# The session file must NOT live inside the workspace.
workspace_sessions = workspace / "sessions"
assert not workspace_sessions.exists() or not any(workspace_sessions.glob("*.jsonl"))
# The out-of-workspace store records which workspace it belongs to.
marker = manager.sessions_dir / ".workspace"
assert marker.read_text(encoding="utf-8") == str(workspace.resolve())
# And it must still round-trip through a fresh manager for the same workspace.
reloaded = SessionManager(workspace=workspace).get_or_create("telegram:1")
assert reloaded.messages[-1]["content"] == "hello"
def test_different_workspaces_are_isolated(tmp_path: Path) -> None:
workspace_a = tmp_path / "ws_a"
workspace_b = tmp_path / "ws_b"
manager_a = SessionManager(workspace=workspace_a)
session = manager_a.get_or_create("telegram:1")
session.add_message("user", "secret-for-a")
manager_a.save(session)
# A second workspace must not see A's session.
assert manager_a.sessions_dir != SessionManager(workspace=workspace_b).sessions_dir
in_b = SessionManager(workspace=workspace_b).get_or_create("telegram:1")
assert in_b.messages == []
def test_equivalent_workspace_paths_share_one_store(tmp_path: Path) -> None:
real_workspace = tmp_path / "real_ws"
real_workspace.mkdir()
link_workspace = tmp_path / "link_ws"
link_workspace.symlink_to(real_workspace, target_is_directory=True)
# Save via the real path, then read via a symlink to the same directory.
manager = SessionManager(workspace=real_workspace)
session = manager.get_or_create("telegram:1")
session.add_message("user", "via-real")
manager.save(session)
via_link = SessionManager(workspace=link_workspace).get_or_create("telegram:1")
assert via_link.messages[-1]["content"] == "via-real"
def test_legacy_in_workspace_sessions_are_migrated(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
key = "telegram:1"
old_file = _write_legacy_session(workspace / "sessions", key, "migrated-msg")
manager = SessionManager(workspace=workspace)
# The session is readable through the normal store.
loaded = manager.get_or_create(key)
assert loaded.messages[-1]["content"] == "migrated-msg"
# The legacy in-workspace file has been moved away...
assert not old_file.exists()
# ...into the out-of-workspace store.
assert (manager.sessions_dir / old_file.name).exists()
# Migration is idempotent: a second construction must not corrupt anything.
again = SessionManager(workspace=workspace).get_or_create(key)
assert again.messages[-1]["content"] == "migrated-msg"