refactor(session): remove request-scoped access grants (#5238)

This commit is contained in:
chengyongru
2026-08-05 10:18:46 +08:00
committed by GitHub
parent 858f6d96a6
commit 6e9ae5bd05
12 changed files with 124 additions and 230 deletions
+37 -39
View File
@@ -12,7 +12,6 @@ from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.sessions import ReadSessionTool, SearchSessionsTool
from nanobot.bus.events import INBOUND_META_SESSION_READ_SCOPE
from nanobot.runtime_context import RuntimeContextBlock, append_runtime_context
from nanobot.session.manager import SessionManager
from nanobot.webui.transcript import append_transcript_object
@@ -46,7 +45,6 @@ def _webui_request(
channel="websocket",
chat_id=session_key.removeprefix("websocket:"),
session_key=session_key,
metadata={INBOUND_META_SESSION_READ_SCOPE: "websocket:"},
))
@@ -56,22 +54,29 @@ def test_session_tools_are_discovered() -> None:
assert {"ReadSessionTool", "SearchSessionsTool"} <= names
def test_session_tools_are_visible_only_in_an_authorized_request(tmp_path) -> None:
def test_session_tools_stay_visible_when_enabled(tmp_path) -> None:
manager = SessionManager(tmp_path)
registry = ToolRegistry()
registry.register(SearchSessionsTool(manager))
registry.register(ReadSessionTool(manager))
assert registry.get_definitions() == []
with _webui_request():
names = {
definition["function"]["name"]
for definition in registry.get_definitions()
}
names = {
definition["function"]["name"]
for definition in registry.get_definitions()
}
assert names == {"read_session", "search_sessions"}
def test_session_tools_do_not_own_runtime_context(tmp_path) -> None:
manager = SessionManager(tmp_path)
registry = ToolRegistry()
registry.register(SearchSessionsTool(manager))
registry.register(ReadSessionTool(manager))
assert registry.get_runtime_context_providers() == []
@pytest.mark.asyncio
async def test_search_sessions_reads_the_full_webui_transcript_after_compaction(
tmp_path,
@@ -242,7 +247,7 @@ async def test_read_session_reports_invalid_requests(tmp_path):
@pytest.mark.asyncio
async def test_session_tools_reject_unscoped_and_out_of_scope_sessions(tmp_path):
async def test_session_tools_read_persisted_sessions_from_any_channel(tmp_path):
manager = SessionManager(tmp_path)
_save_session(
manager,
@@ -252,8 +257,14 @@ async def test_session_tools_reject_unscoped_and_out_of_scope_sessions(tmp_path)
)
_save_session(
manager,
"slack:private",
title="Private",
"slack:history",
title="Slack history",
messages=[{"role": "user", "content": "needle"}],
)
_save_session(
manager,
"telegram:external",
title="Current",
messages=[{"role": "user", "content": "needle"}],
)
tools = SearchSessionsTool(manager), ReadSessionTool(manager)
@@ -263,31 +274,22 @@ async def test_session_tools_reject_unscoped_and_out_of_scope_sessions(tmp_path)
chat_id="external",
session_key="telegram:external",
)):
search = await tools[0].execute(query="needle")
read = await tools[1].execute(session_key="websocket:visible")
assert search.is_error
assert read.is_error
with request_context(RequestContext(
channel="websocket",
chat_id="spoofed",
session_key="websocket:spoofed",
metadata={"webui": True},
)):
spoofed = await tools[0].execute(query="needle")
with _webui_request():
search = _decode(await tools[0].execute(query="needle"))
read = await tools[1].execute(session_key="slack:private")
websocket_read = _decode(await tools[1].execute(session_key="websocket:visible"))
slack_read = _decode(await tools[1].execute(session_key="slack:history"))
current_read = await tools[1].execute(session_key="telegram:external")
assert spoofed.is_error
assert [row["session_key"] for row in search["results"]] == ["websocket:visible"]
assert read.is_error
assert {row["session_key"] for row in search["results"]} == {
"websocket:visible",
"slack:history",
}
assert websocket_read["session_key"] == "websocket:visible"
assert slack_read["session_key"] == "slack:history"
assert current_read.is_error and "session not found" in str(current_read)
@pytest.mark.asyncio
async def test_session_tools_use_the_scope_granted_by_the_channel(tmp_path):
async def test_session_tools_work_without_request_context(tmp_path):
manager = SessionManager(tmp_path)
_save_session(
manager,
@@ -296,12 +298,8 @@ async def test_session_tools_use_the_scope_granted_by_the_channel(tmp_path):
messages=[{"role": "user", "content": "custom needle"}],
)
with request_context(RequestContext(
channel="custom",
chat_id="current",
session_key="custom:current",
metadata={INBOUND_META_SESSION_READ_SCOPE: "custom:"},
)):
result = _decode(await SearchSessionsTool(manager).execute(query="needle"))
result = _decode(await SearchSessionsTool(manager).execute(query="needle"))
read = _decode(await ReadSessionTool(manager).execute(session_key="custom:history"))
assert [row["session_key"] for row in result["results"]] == ["custom:history"]
assert read["session_key"] == "custom:history"
+1 -25
View File
@@ -9,16 +9,9 @@ from nanobot.agent.tools.registry import ToolRegistry
class _FakeTool(Tool):
def __init__(
self,
name: str,
schema: dict[str, Any] | None = None,
*,
available: bool = True,
):
def __init__(self, name: str, schema: dict[str, Any] | None = None):
self._name = name
self._schema = schema
self._available = available
@property
def name(self) -> str:
@@ -35,10 +28,6 @@ class _FakeTool(Tool):
async def execute(self, **kwargs: Any) -> Any:
return kwargs
def available(self) -> bool:
return self._available
def _tool_names(definitions: list[dict[str, Any]]) -> list[str]:
names: list[str] = []
for definition in definitions:
@@ -69,19 +58,6 @@ def test_get_definitions_orders_builtins_then_mcp_tools() -> None:
]
def test_unavailable_tools_are_hidden_and_cannot_be_called() -> None:
registry = ToolRegistry()
registry.register(_FakeTool("visible"))
registry.register(_FakeTool("hidden", available=False))
assert _tool_names(registry.get_definitions()) == ["visible"]
tool, params, error = registry.prepare_call("hidden", {})
assert tool is None
assert params == {}
assert error == "Error: Tool 'hidden' is unavailable"
def test_prepare_call_rejects_near_miss_tool_name_with_suggestion() -> None:
registry = ToolRegistry()
registry.register(_FakeTool("read_file"))
+32 -18
View File
@@ -4,7 +4,6 @@ import json
from nanobot.session.manager import SessionManager
from nanobot.webui.session_access import (
SessionAccessScope,
WebuiSessionAccess,
session_mentions_runtime_context,
)
@@ -18,7 +17,7 @@ def _save_session(manager: SessionManager, key: str, title: str) -> None:
manager.save(session)
def test_normalize_session_mentions_keeps_only_authorized_distinct_targets(
def test_normalize_session_mentions_keeps_only_existing_distinct_other_targets(
tmp_path,
monkeypatch,
) -> None:
@@ -28,7 +27,7 @@ def test_normalize_session_mentions_keeps_only_authorized_distinct_targets(
_save_session(manager, "websocket:other", "Other")
_save_session(manager, "websocket:street", "Straße")
_save_session(manager, "websocket:upper", "STRASSE")
_save_session(manager, "telegram:private", "Private")
_save_session(manager, "telegram:history", "Telegram history")
monkeypatch.setattr(
manager,
"list_sessions",
@@ -45,13 +44,12 @@ def test_normalize_session_mentions_keeps_only_authorized_distinct_targets(
{"name": "duplicate", "session_key": "websocket:pricing"},
{"name": "PRICING", "session_key": "websocket:other"},
{"name": "current", "session_key": "websocket:current"},
{"name": "bad name", "session_key": "websocket:pricing"},
{"name": "missing", "session_key": "websocket:missing"},
{"name": "Straße", "session_key": "websocket:street"},
{"name": "STRASSE", "session_key": "websocket:upper"},
{"name": "private", "session_key": "telegram:private"},
{"name": "telegram", "session_key": "telegram:history"},
],
SessionAccessScope("websocket:current", "websocket:"),
exclude_session_key="websocket:current",
)
assert mentions == [
@@ -62,6 +60,11 @@ def test_normalize_session_mentions_keeps_only_authorized_distinct_targets(
},
{"name": "Straße", "session_key": "websocket:street", "title": "Straße"},
{"name": "STRASSE", "session_key": "websocket:upper", "title": "STRASSE"},
{
"name": "telegram",
"session_key": "telegram:history",
"title": "Telegram history",
},
]
@@ -80,11 +83,9 @@ def test_session_mention_context_treats_titles_as_data() -> None:
assert json.loads(block.content.splitlines()[2])[0]["session_key"] == "websocket:history"
def test_restricted_scope_rejects_sessions_from_other_projects(tmp_path) -> None:
def test_session_mentions_do_not_isolate_workspaces(tmp_path) -> None:
manager = SessionManager(tmp_path)
project_a = tmp_path / "a"
project_b = tmp_path / "b"
project_a.mkdir()
project_b.mkdir()
session = manager.get_or_create("websocket:other")
session.metadata.update({
@@ -97,19 +98,27 @@ def test_restricted_scope_rejects_sessions_from_other_projects(tmp_path) -> None
manager.save(session)
access = WebuiSessionAccess(manager)
scope = SessionAccessScope(
"websocket:current",
"websocket:",
project_path=project_a,
restrict_to_workspace=True,
)
mentions = access.normalize_mentions(
[{"name": "other", "session_key": "websocket:other"}],
scope,
exclude_session_key="websocket:current",
)
assert mentions == []
assert access.search(scope, "Other", 5) == []
assert mentions == [{
"name": "other",
"session_key": "websocket:other",
"title": "Other",
}]
assert [row["session_key"] for row in access.search(
"Other",
5,
exclude_session_key="websocket:current",
)] == ["websocket:other"]
assert access.read(
"websocket:other",
query="",
limit=5,
exclude_session_key="websocket:current",
) is not None
def test_persisted_session_mentions_validate_fields() -> None:
@@ -117,8 +126,13 @@ def test_persisted_session_mentions_validate_fields() -> None:
{"name": 7, "session_key": "websocket:bad"},
{"name": "bad name", "session_key": "websocket:bad"},
{"name": "valid", "session_key": "websocket:valid", "title": 7},
{"name": "telegram", "session_key": "telegram:valid"},
]) == [{
"name": "valid",
"session_key": "websocket:valid",
"title": "",
}, {
"name": "telegram",
"session_key": "telegram:valid",
"title": "",
}]