refactor(session): clarify reference boundaries

This commit is contained in:
Xubin Ren 2026-08-04 11:39:39 +08:00
parent d8aeb0eb2c
commit d99f589a59
9 changed files with 108 additions and 43 deletions

View File

@ -33,19 +33,19 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
def _session_scope() -> SessionAccessScope | None: def _session_scope() -> SessionAccessScope | None:
ctx = current_request_context() ctx = current_request_context()
if ctx is None: if ctx is None or not ctx.session_key:
return None return None
session_key = ctx.session_key prefix = ctx.metadata.get(INBOUND_META_SESSION_READ_SCOPE)
if ( if (
ctx.channel != "websocket" not isinstance(prefix, str)
or session_key is None or not prefix.endswith(":")
or not session_key.startswith("websocket:") or not ctx.session_key.startswith(prefix)
or ctx.metadata.get(INBOUND_META_SESSION_READ_SCOPE) is not True
): ):
return None return None
workspace = current_workspace_scope() workspace = current_workspace_scope()
return SessionAccessScope( 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, 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, 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") return ToolResult.error("Error: session search is not available to this client")
matches = await asyncio.to_thread(self._access.search, scope, query, _SEARCH_LIMIT) matches = await asyncio.to_thread(self._access.search, scope, query, _SEARCH_LIMIT)
needle = query.casefold() needle = query.casefold()
for match in matches: result = {
match["session_ref"] = _session_ref(match["session_key"]) "notice": _UNTRUSTED_NOTICE,
match["excerpts"] = [ "query": query,
"results": [
{ {
"message_index": message["message_index"], "session_key": match["session_key"],
"role": message["role"], "session_ref": _session_ref(match["session_key"]),
"content": _excerpt(message["content"], needle, _SEARCH_EXCERPT_CHARS), "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") for match in matches
] ],
return json.dumps( }
{"notice": _UNTRUSTED_NOTICE, "query": query, "results": matches}, return json.dumps(result, ensure_ascii=False)
ensure_ascii=False,
)
@tool_parameters( @tool_parameters(
@ -205,13 +215,16 @@ class ReadSessionTool(_SessionTool):
if match is None: if match is None:
return ToolResult.error(f"Error: session not found: {session_key}") return ToolResult.error(f"Error: session not found: {session_key}")
needle = query_text.casefold() needle = query_text.casefold()
match.update({ result = {
"notice": _UNTRUSTED_NOTICE, "notice": _UNTRUSTED_NOTICE,
"session_key": match["session_key"],
"session_ref": _session_ref(session_key), "session_ref": _session_ref(session_key),
"title": match["title"],
"updated_at": match["updated_at"],
"query": query_text or None, "query": query_text or None,
"messages": [ "messages": [
{**message, "content": _excerpt(message["content"], needle, _READ_MESSAGE_CHARS)} {**message, "content": _excerpt(message["content"], needle, _READ_MESSAGE_CHARS)}
for message in match["messages"] for message in match["messages"]
], ],
}) }
return json.dumps(match, ensure_ascii=False) return json.dumps(result, ensure_ascii=False)

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 # Internal-only inbound metadata used by in-process channels to ask the agent
# loop to update runtime state without going through a user session. # loop to update runtime state without going through a user session.
INBOUND_META_RUNTIME_CONTROL = "_runtime_control" 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" INBOUND_META_SESSION_READ_SCOPE = "_session_read_scope"
RUNTIME_CONTROL_ACK = "_ack" RUNTIME_CONTROL_ACK = "_ack"
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload" RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"

View File

@ -814,7 +814,7 @@ class WebSocketChannel(BaseChannel):
metadata.update(self._transcripts.client_turn_metadata(envelope.get("turn_id"))) metadata.update(self._transcripts.client_turn_metadata(envelope.get("turn_id")))
trusted_webui = metadata.get("webui") is True and connection in self._webui_connections trusted_webui = metadata.get("webui") is True and connection in self._webui_connections
if trusted_webui: 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")) cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
if cli_apps: if cli_apps:
metadata["cli_apps"] = cli_apps metadata["cli_apps"] = cli_apps
@ -831,6 +831,7 @@ class WebSocketChannel(BaseChannel):
envelope.get("session_mentions"), envelope.get("session_mentions"),
SessionAccessScope( SessionAccessScope(
current_session_key=f"{self.name}:{cid}", current_session_key=f"{self.name}:{cid}",
session_key_prefix=f"{self.name}:",
project_path=scope.project_path, project_path=scope.project_path,
restrict_to_workspace=scope.restrict_to_workspace, restrict_to_workspace=scope.restrict_to_workspace,
), ),

View File

@ -219,7 +219,7 @@ async def test_webui_message_forwards_verified_session_mentions(tmp_path) -> Non
channel._handle_message.assert_awaited_once() channel._handle_message.assert_awaited_once()
metadata = channel._handle_message.call_args.kwargs["metadata"] 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"] == [{ assert metadata["session_mentions"] == [{
"name": "pricing", "name": "pricing",
"session_key": "websocket:pricing", "session_key": "websocket:pricing",

View File

@ -7,7 +7,7 @@ from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from functools import cache from functools import cache
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, TypedDict, cast
from nanobot.runtime_context import ( from nanobot.runtime_context import (
RuntimeContextBlock, RuntimeContextBlock,
@ -24,24 +24,39 @@ from nanobot.webui.transcript import (
) )
_VISIBLE_ROLES = {"user", "assistant"} _VISIBLE_ROLES = {"user", "assistant"}
_WEBUI_SESSION_PREFIX = "websocket:"
SessionMention = dict[str, str] class SessionMention(TypedDict):
SessionMessage = dict[str, Any] name: str
SessionMatch = dict[str, Any] 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) @dataclass(frozen=True)
class SessionAccessScope: class SessionAccessScope:
current_session_key: str current_session_key: str
session_key_prefix: str
project_path: Path | None = None project_path: Path | None = None
restrict_to_workspace: bool = False restrict_to_workspace: bool = False
def allows(self, session_key: object) -> bool: def allows(self, session_key: object) -> bool:
return ( return (
isinstance(session_key, str) 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 and session_key != self.current_session_key
) )
@ -245,7 +260,7 @@ class WebuiSessionAccess:
seen_keys: set[str] = set() seen_keys: set[str] = set()
seen_names: set[str] = set() seen_names: set[str] = set()
for raw_mention in normalize_session_mentions_metadata(raw): for raw_mention in normalize_session_mentions_metadata(raw):
mention = raw_mention mention = cast(SessionMention, raw_mention)
key = mention["session_key"] key = mention["session_key"]
folded_name = mention["name"].lower() folded_name = mention["name"].lower()
payload = self._metadata(key, scope) payload = self._metadata(key, scope)

View File

@ -46,7 +46,7 @@ def _webui_request(
channel="websocket", channel="websocket",
chat_id=session_key.removeprefix("websocket:"), chat_id=session_key.removeprefix("websocket:"),
session_key=session_key, session_key=session_key,
metadata={INBOUND_META_SESSION_READ_SCOPE: True}, metadata={INBOUND_META_SESSION_READ_SCOPE: "websocket:"},
)) ))
@ -284,3 +284,24 @@ async def test_session_tools_reject_unscoped_and_out_of_scope_sessions(tmp_path)
assert spoofed.is_error assert spoofed.is_error
assert [row["session_key"] for row in search["results"]] == ["websocket:visible"] assert [row["session_key"] for row in search["results"]] == ["websocket:visible"]
assert read.is_error assert read.is_error
@pytest.mark.asyncio
async def test_session_tools_use_the_scope_granted_by_the_channel(tmp_path):
manager = SessionManager(tmp_path)
_save_session(
manager,
"custom:history",
title="History",
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"))
assert [row["session_key"] for row in result["results"]] == ["custom:history"]

View File

@ -51,7 +51,7 @@ def test_normalize_session_mentions_keeps_only_authorized_distinct_targets(
{"name": "STRASSE", "session_key": "websocket:upper"}, {"name": "STRASSE", "session_key": "websocket:upper"},
{"name": "private", "session_key": "telegram:private"}, {"name": "private", "session_key": "telegram:private"},
], ],
SessionAccessScope("websocket:current"), SessionAccessScope("websocket:current", "websocket:"),
) )
assert mentions == [ assert mentions == [
@ -99,6 +99,7 @@ def test_restricted_scope_rejects_sessions_from_other_projects(tmp_path) -> None
access = WebuiSessionAccess(manager) access = WebuiSessionAccess(manager)
scope = SessionAccessScope( scope = SessionAccessScope(
"websocket:current", "websocket:current",
"websocket:",
project_path=project_a, project_path=project_a,
restrict_to_workspace=True, restrict_to_workspace=True,
) )

View File

@ -1325,14 +1325,23 @@ export function ThreadComposer({
logoUrl: preset.logo_url ?? null, logoUrl: preset.logo_url ?? null,
initials: mcpPresetInitials(preset), initials: mcpPresetInitials(preset),
})); }));
return [ const groups = [
...sessionCandidates.slice(0, 4), { candidates: cliCandidates, reserved: 2 },
...cliCandidates.slice(0, 2), { candidates: mcpCandidates, reserved: 2 },
...mcpCandidates.slice(0, 2), { candidates: sessionCandidates, reserved: 4 },
...sessionCandidates.slice(4), ];
...cliCandidates.slice(2), let remaining = 8;
...mcpCandidates.slice(2), const counts = groups.map(({ candidates, reserved }) => {
].slice(0, 8); const count = Math.min(candidates.length, reserved);
remaining -= count;
return count;
});
for (const index of [2, 0, 1]) {
const extra = Math.min(remaining, groups[index].candidates.length - counts[index]);
counts[index] += extra;
remaining -= extra;
}
return groups.flatMap(({ candidates }, index) => candidates.slice(0, counts[index]));
}, [activeSessionMentions, availableSessionMentions, cliAppMention, cliApps, mcpPresets]); }, [activeSessionMentions, availableSessionMentions, cliAppMention, cliApps, mcpPresets]);
const showCliAppMenu = filteredMentionCandidates.length > 0; const showCliAppMenu = filteredMentionCandidates.length > 0;
@ -2660,7 +2669,7 @@ function CliAppMentionPalette({
layout.maxHeight - SLASH_PALETTE_CHROME_PX, layout.maxHeight - SLASH_PALETTE_CHROME_PX,
); );
const listRef = useSelectedOptionScroll(selectedIndex); const listRef = useSelectedOptionScroll(selectedIndex);
const groupedCandidates = (["session", "cli", "mcp"] as const) const groupedCandidates = (["cli", "mcp", "session"] as const)
.map((kind) => ({ .map((kind) => ({
kind, kind,
label: kind === "session" label: kind === "session"

View File

@ -1596,6 +1596,7 @@ describe("ThreadComposer", () => {
onSend={vi.fn()} onSend={vi.fn()}
placeholder="Type your message..." placeholder="Type your message..."
cliApps={CLI_APPS} cliApps={CLI_APPS}
mcpPresets={MCP_PRESETS}
sessions={[ sessions={[
...["a", "b"].map((chatId) => session(chatId, "Plan")), ...["a", "b"].map((chatId) => session(chatId, "Plan")),
session("blender-chat", "Blender", "3D notes"), session("blender-chat", "Blender", "3D notes"),
@ -1606,6 +1607,10 @@ describe("ThreadComposer", () => {
const input = screen.getByLabelText("Message input"); const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } }); fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
const palette = screen.getByRole("listbox", { name: "Mentions" });
expect(within(palette).getAllByRole("group").map((group) => (
group.getAttribute("aria-label")
))).toEqual(["CLI apps", "MCP services", "Nanobot conversations"]);
const options = screen.getAllByRole("option", { name: /Plan @Plan/i }); const options = screen.getAllByRole("option", { name: /Plan @Plan/i });
expect(options.map((option) => option.textContent)).toEqual([ expect(options.map((option) => option.textContent)).toEqual([
expect.stringContaining("@Plan"), expect.stringContaining("@Plan"),