refactor(session): clarify reference boundaries

This commit is contained in:
Xubin Ren
2026-08-04 12:14:51 +08:00
parent d8aeb0eb2c
commit d99f589a59
9 changed files with 108 additions and 43 deletions
+35 -22
View File
@@ -33,19 +33,19 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
def _session_scope() -> SessionAccessScope | None:
ctx = current_request_context()
if ctx is None:
if ctx is None or not ctx.session_key:
return None
session_key = ctx.session_key
prefix = ctx.metadata.get(INBOUND_META_SESSION_READ_SCOPE)
if (
ctx.channel != "websocket"
or session_key is None
or not session_key.startswith("websocket:")
or ctx.metadata.get(INBOUND_META_SESSION_READ_SCOPE) is not True
not isinstance(prefix, str)
or not prefix.endswith(":")
or not ctx.session_key.startswith(prefix)
):
return None
workspace = current_workspace_scope()
return SessionAccessScope(
current_session_key=session_key,
current_session_key=ctx.session_key,
session_key_prefix=prefix,
project_path=workspace.project_path if workspace is not None else ctx.workspace,
restrict_to_workspace=workspace.restrict_to_workspace if workspace is not None else False,
)
@@ -131,20 +131,30 @@ class SearchSessionsTool(_SessionTool):
return ToolResult.error("Error: session search is not available to this client")
matches = await asyncio.to_thread(self._access.search, scope, query, _SEARCH_LIMIT)
needle = query.casefold()
for match in matches:
match["session_ref"] = _session_ref(match["session_key"])
match["excerpts"] = [
result = {
"notice": _UNTRUSTED_NOTICE,
"query": query,
"results": [
{
"message_index": message["message_index"],
"role": message["role"],
"content": _excerpt(message["content"], needle, _SEARCH_EXCERPT_CHARS),
"session_key": match["session_key"],
"session_ref": _session_ref(match["session_key"]),
"title": match["title"],
"updated_at": match["updated_at"],
"excerpts": [
{
"message_index": message["message_index"],
"role": message["role"],
"content": _excerpt(
message["content"], needle, _SEARCH_EXCERPT_CHARS
),
}
for message in match["messages"]
],
}
for message in match.pop("messages")
]
return json.dumps(
{"notice": _UNTRUSTED_NOTICE, "query": query, "results": matches},
ensure_ascii=False,
)
for match in matches
],
}
return json.dumps(result, ensure_ascii=False)
@tool_parameters(
@@ -205,13 +215,16 @@ class ReadSessionTool(_SessionTool):
if match is None:
return ToolResult.error(f"Error: session not found: {session_key}")
needle = query_text.casefold()
match.update({
result = {
"notice": _UNTRUSTED_NOTICE,
"session_key": match["session_key"],
"session_ref": _session_ref(session_key),
"title": match["title"],
"updated_at": match["updated_at"],
"query": query_text or None,
"messages": [
{**message, "content": _excerpt(message["content"], needle, _READ_MESSAGE_CHARS)}
for message in match["messages"]
],
})
return json.dumps(match, ensure_ascii=False)
}
return json.dumps(result, ensure_ascii=False)
+1 -1
View File
@@ -15,7 +15,7 @@ OUTBOUND_META_AGENT_UI = "_agent_ui"
# Internal-only inbound metadata used by in-process channels to ask the agent
# loop to update runtime state without going through a user session.
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
# Trusted WebUI grant for read-only persisted-session tools.
# Trusted namespace grant for read-only persisted-session tools.
INBOUND_META_SESSION_READ_SCOPE = "_session_read_scope"
RUNTIME_CONTROL_ACK = "_ack"
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
+2 -1
View File
@@ -814,7 +814,7 @@ class WebSocketChannel(BaseChannel):
metadata.update(self._transcripts.client_turn_metadata(envelope.get("turn_id")))
trusted_webui = metadata.get("webui") is True and connection in self._webui_connections
if trusted_webui:
metadata[INBOUND_META_SESSION_READ_SCOPE] = True
metadata[INBOUND_META_SESSION_READ_SCOPE] = f"{self.name}:"
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
if cli_apps:
metadata["cli_apps"] = cli_apps
@@ -831,6 +831,7 @@ class WebSocketChannel(BaseChannel):
envelope.get("session_mentions"),
SessionAccessScope(
current_session_key=f"{self.name}:{cid}",
session_key_prefix=f"{self.name}:",
project_path=scope.project_path,
restrict_to_workspace=scope.restrict_to_workspace,
),
@@ -219,7 +219,7 @@ async def test_webui_message_forwards_verified_session_mentions(tmp_path) -> Non
channel._handle_message.assert_awaited_once()
metadata = channel._handle_message.call_args.kwargs["metadata"]
assert metadata[INBOUND_META_SESSION_READ_SCOPE] is True
assert metadata[INBOUND_META_SESSION_READ_SCOPE] == "websocket:"
assert metadata["session_mentions"] == [{
"name": "pricing",
"session_key": "websocket:pricing",
+22 -7
View File
@@ -7,7 +7,7 @@ from collections.abc import Mapping
from dataclasses import dataclass
from functools import cache
from pathlib import Path
from typing import Any, cast
from typing import Any, TypedDict, cast
from nanobot.runtime_context import (
RuntimeContextBlock,
@@ -24,24 +24,39 @@ from nanobot.webui.transcript import (
)
_VISIBLE_ROLES = {"user", "assistant"}
_WEBUI_SESSION_PREFIX = "websocket:"
SessionMention = dict[str, str]
SessionMessage = dict[str, Any]
SessionMatch = dict[str, Any]
class SessionMention(TypedDict):
name: str
session_key: str
title: str
class SessionMessage(TypedDict):
message_index: int
role: str
timestamp: str | int | None
content: str
class SessionMatch(TypedDict):
session_key: str
title: str
updated_at: str | None
messages: list[SessionMessage]
@dataclass(frozen=True)
class SessionAccessScope:
current_session_key: str
session_key_prefix: str
project_path: Path | None = None
restrict_to_workspace: bool = False
def allows(self, session_key: object) -> bool:
return (
isinstance(session_key, str)
and session_key.startswith(_WEBUI_SESSION_PREFIX)
and session_key.startswith(self.session_key_prefix)
and session_key != self.current_session_key
)
@@ -245,7 +260,7 @@ class WebuiSessionAccess:
seen_keys: set[str] = set()
seen_names: set[str] = set()
for raw_mention in normalize_session_mentions_metadata(raw):
mention = raw_mention
mention = cast(SessionMention, raw_mention)
key = mention["session_key"]
folded_name = mention["name"].lower()
payload = self._metadata(key, scope)