主人 2026-10-03 过目 design/22 §8.1 草案并点头后实现。
- 新包 internal/runtime/sessionread:工具壳 + 只读取数 + 归属排版(按职责拆 8 文件)
- 只读由类型保证:注入的 HistoryReader 只声明 List/ListSessions —— 本包结构上无法写入
任何会话、也推进不了目标会话的 read_at
- fail-closed 授权,顺序 = 环境 → 白名单 → 存在性:
非 owner 环境拒(ErrNotOwner)→ 白名单只许 web / web:<uuid>(ErrSessionNotAllowed)
→ 会话不存在是硬错误(ErrSessionNotFound,绝不返回空材料,防"读不到"被当成"对方没说")
- 排版:头部三行恒定(外部会话材料 · 只读 · 不属于你的记忆 / 来源角色 / 来源会话)
+ 时间跨度与翻页游标 + 逐条「[时间] 说话人:」归属
- 说话人取消息自身 persona_id;'' → 全局默认角色;未知 id 原样显示;user → SenderName(空=主人)
- Execute 返回 string:loop 的 formatToolResult 对 string 原样透传,换行不被 json 转义
- 接线 cmd/cyrene/wire.go 一行 + wireSessionReadTool():能力默认标记(双 false)= owner-only,
不新增任何 bypass 路径;审计仍走 loop 的 gate
- 日志 source=runtime/sessionread,只记 actor/target/count/limit/before/has_older,不含正文
验证:go build/vet ./... 绿;internal/runtime/sessionread(25 测试)+ cmd/cyrene 绿;
linux/amd64 + darwin/arm64 交叉编译绿;
TestWireSessionReadToolRegistersOwnerOnly 断言 owner 放行 / group_member / stranger 拒绝
遗留(登记,未解决):§8.1 约束 4 要求"每次调用落审计",但 loop 的 gate 只在拒绝时发
AuditEvent ⇒ 成功读取目前只有日志可追溯,未进审计库;补 loop 侧成功路径钩子需先定口径。
313 lines
12 KiB
Go
313 lines
12 KiB
Go
package sessionread
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
|
)
|
|
|
|
// envCtx builds a context carrying an explicit actor environment, the way the
|
|
// gateway and channel bridge attach one per turn.
|
|
func envCtx(env restrict.ActorEnv) context.Context {
|
|
return loop.WithSessionEnv(context.Background(), loop.SessionEnv{ActorEnv: env, MemWritable: env == restrict.ActorOwner})
|
|
}
|
|
|
|
// TestNonOwnerIsRefused locks the first half of the fail-closed whitelist: a group
|
|
// or stranger conversation may not read another session at all, and the refusal
|
|
// happens before any read — nothing about the target leaks (design/22 §8.1 约束 3).
|
|
func TestNonOwnerIsRefused(t *testing.T) {
|
|
t.Parallel()
|
|
for _, env := range []restrict.ActorEnv{restrict.ActorGroupMember, restrict.ActorStranger} {
|
|
t.Run(string(env), func(t *testing.T) {
|
|
h := &fakeHistory{
|
|
sessions: []history.SessionMeta{{ID: history.MainSessionID}},
|
|
msgs: map[string][]history.Message{history.MainSessionID: mainMessages()},
|
|
}
|
|
tool := newReader(h, testPersonas())
|
|
_, err := tool.Execute(envCtx(env), map[string]any{"sessionId": history.MainSessionID})
|
|
if !errors.Is(err, ErrNotOwner) {
|
|
t.Fatalf("err = %v, want ErrNotOwner", err)
|
|
}
|
|
if h.listCalls != 0 {
|
|
t.Fatalf("history was read %d times before the refusal, want 0", h.listCalls)
|
|
}
|
|
// The refusal must be distinguishable from "the session does not exist".
|
|
if strings.Contains(err.Error(), ErrSessionNotFound.Error()) {
|
|
t.Fatalf("err = %v, a refusal must not read like a missing session", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReadableSessionID is the whitelist table: only the Web main session and Web
|
|
// sub sessions are readable. In particular "web:" with no suffix, a bare prefix
|
|
// look-alike and every IM session id are refused (design/22 §8.1 约束 3).
|
|
func TestReadableSessionID(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
id string
|
|
want bool
|
|
}{
|
|
{history.MainSessionID, true},
|
|
{"web:5f1c8d2e-0000-1111-2222-333344445555", true},
|
|
{"web:", false},
|
|
{"web", true},
|
|
{"webx", false},
|
|
{"webs", false},
|
|
{"telegram:-100200300", false},
|
|
{"kook:3bef", false},
|
|
{"sess-group", false},
|
|
{"", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := ReadableSessionID(tc.id); got != tc.want {
|
|
t.Errorf("ReadableSessionID(%q) = %v, want %v", tc.id, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUnreadableSessionIsRefused locks the other half of the whitelist: an id
|
|
// outside web/web:<uuid> is refused even though it might exist, so an IM group or
|
|
// stranger session can never be reached through this tool.
|
|
func TestUnreadableSessionIsRefused(t *testing.T) {
|
|
t.Parallel()
|
|
h := &fakeHistory{
|
|
sessions: []history.SessionMeta{{ID: "telegram:-100200300", Title: "群聊"}},
|
|
msgs: map[string][]history.Message{"telegram:-100200300": {{Role: history.RoleUser, Content: "秘密", CreatedAt: at(21, 4)}}},
|
|
}
|
|
tool := newReader(h, testPersonas())
|
|
_, err := tool.Execute(ownerCtx(), map[string]any{"sessionId": "telegram:-100200300"})
|
|
if !errors.Is(err, ErrSessionNotAllowed) {
|
|
t.Fatalf("err = %v, want ErrSessionNotAllowed", err)
|
|
}
|
|
if h.listCalls != 0 {
|
|
t.Fatalf("history was read %d times before the refusal, want 0", h.listCalls)
|
|
}
|
|
}
|
|
|
|
// TestMissingSessionIsAnErrorNotEmptyMaterial locks design/22 §8.1 约束 3: a session
|
|
// that does not exist is a hard, clearly labelled error, never empty material —
|
|
// otherwise "read nothing" could be taken for "they said nothing".
|
|
func TestMissingSessionIsAnErrorNotEmptyMaterial(t *testing.T) {
|
|
t.Parallel()
|
|
h := &fakeHistory{sessions: []history.SessionMeta{{ID: history.MainSessionID}}}
|
|
tool := newReader(h, testPersonas())
|
|
out, err := tool.Execute(ownerCtx(), map[string]any{"sessionId": "web:missing"})
|
|
if !errors.Is(err, ErrSessionNotFound) {
|
|
t.Fatalf("err = %v, want ErrSessionNotFound", err)
|
|
}
|
|
if out != nil {
|
|
t.Fatalf("out = %#v, want nil alongside the error (no empty material)", out)
|
|
}
|
|
for _, other := range []error{ErrSessionNotAllowed, ErrNotOwner} {
|
|
if errors.Is(err, other) {
|
|
t.Fatalf("err = %v, must stay distinguishable from %v", err, other)
|
|
}
|
|
}
|
|
if !strings.Contains(err.Error(), "session") {
|
|
t.Fatalf("err = %v, want a human-readable missing-session message", err)
|
|
}
|
|
}
|
|
|
|
// TestMissingArgumentIsRejected asserts the tool refuses a call without a target
|
|
// instead of silently reading the main session.
|
|
func TestMissingArgumentIsRejected(t *testing.T) {
|
|
t.Parallel()
|
|
h := &fakeHistory{sessions: []history.SessionMeta{{ID: history.MainSessionID}}}
|
|
tool := newReader(h, testPersonas())
|
|
for _, args := range []any{map[string]any{}, map[string]any{"sessionId": " "}, "not-an-object"} {
|
|
if _, err := tool.Execute(ownerCtx(), args); err == nil {
|
|
t.Fatalf("Execute(%#v) accepted the call, want an error", args)
|
|
}
|
|
}
|
|
if h.listCalls != 0 {
|
|
t.Fatalf("history was read %d times for argument-less calls, want 0", h.listCalls)
|
|
}
|
|
}
|
|
|
|
// TestLimitIsClampedNotRejected locks the page bounds: an absent, non-numeric or
|
|
// negative limit means the default 50, and a limit above the cap is clamped to 200
|
|
// (mirroring GET /sessions/{id}/messages) rather than refused as a bad argument.
|
|
func TestLimitIsClampedNotRejected(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
args map[string]any
|
|
want int
|
|
}{
|
|
{"absent", map[string]any{"sessionId": history.MainSessionID}, DefaultLimit},
|
|
{"zero", map[string]any{"sessionId": history.MainSessionID, "limit": 0}, DefaultLimit},
|
|
{"negative", map[string]any{"sessionId": history.MainSessionID, "limit": -3}, DefaultLimit},
|
|
{"non-numeric", map[string]any{"sessionId": history.MainSessionID, "limit": "abc"}, DefaultLimit},
|
|
{"above-cap", map[string]any{"sessionId": history.MainSessionID, "limit": 5000}, MaxLimit},
|
|
{"in-range", map[string]any{"sessionId": history.MainSessionID, "limit": 7}, 7},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h := &fakeHistory{sessions: []history.SessionMeta{{ID: history.MainSessionID}}}
|
|
readText(t, newReader(h, testPersonas()), tc.args)
|
|
if h.listLimit != tc.want {
|
|
t.Fatalf("List limit = %d, want %d", h.listLimit, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBeforePagesStrictlyOlder is the end-to-end pagination check against the real
|
|
// store: the first page is the newest window, and before=<oldest of the previous
|
|
// page> returns only strictly older messages — the same semantics as the REST
|
|
// endpoint, so a client cursor and a tool cursor mean the same thing.
|
|
func TestBeforePagesStrictlyOlder(t *testing.T) {
|
|
t.Parallel()
|
|
st, _ := newTestStore(t)
|
|
ctx := context.Background()
|
|
for i := 1; i <= 5; i++ {
|
|
if err := st.Append(ctx, history.Message{
|
|
SessionID: history.MainSessionID, Role: history.RoleUser,
|
|
Content: fmt.Sprintf("m%d", i), CreatedAt: at(21, i),
|
|
}); err != nil {
|
|
t.Fatalf("append m%d: %v", i, err)
|
|
}
|
|
}
|
|
tool := newReader(st, testPersonas())
|
|
|
|
first := readText(t, tool, map[string]any{"sessionId": history.MainSessionID, "limit": 2})
|
|
for _, want := range []string{"m5", "m4"} {
|
|
if !strings.Contains(first, want) {
|
|
t.Fatalf("first page = %q, want it to contain %q", first, want)
|
|
}
|
|
}
|
|
if strings.Contains(first, "m3") || !strings.Contains(first, "before="+itoa(at(21, 4))) {
|
|
t.Fatalf("first page = %q, want only m5/m4 plus the cursor for m4's timestamp", first)
|
|
}
|
|
|
|
older := readText(t, tool, map[string]any{"sessionId": history.MainSessionID, "limit": 2, "before": at(21, 4)})
|
|
if strings.Contains(older, "m5") || strings.Contains(older, "m4") {
|
|
t.Fatalf("second page = %q, want strictly older messages only", older)
|
|
}
|
|
for _, want := range []string{"m3", "m2"} {
|
|
if !strings.Contains(older, want) {
|
|
t.Fatalf("second page = %q, want it to contain %q", older, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// queryInt runs a one-value, one-int query (test helper).
|
|
func queryInt(t *testing.T, conn *sql.DB, query string, args ...any) int64 {
|
|
t.Helper()
|
|
var n int64
|
|
if err := conn.QueryRow(query, args...).Scan(&n); err != nil {
|
|
t.Fatalf("query %q: %v", query, err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// TestReadIsReadOnly is the end-to-end read-only proof of design/22 §8.1 约束 1
|
|
// against the real store: reading another session writes nothing — no message is
|
|
// appended to either session, no session is created or deleted, and the target's
|
|
// read cursor is not advanced (UnreadCount is unchanged).
|
|
func TestReadIsReadOnly(t *testing.T) {
|
|
t.Parallel()
|
|
st, conn := newTestStore(t)
|
|
ctx := context.Background()
|
|
subID, err := st.NewSubSessionFor(ctx, "owner", "yuki")
|
|
if err != nil {
|
|
t.Fatalf("new sub session: %v", err)
|
|
}
|
|
for i := 1; i <= 3; i++ {
|
|
if err := st.Append(ctx, history.Message{
|
|
SessionID: subID, Role: history.RoleAssistant, PersonaID: "yuki",
|
|
Content: fmt.Sprintf("sub%d", i), CreatedAt: at(21, i),
|
|
}); err != nil {
|
|
t.Fatalf("append sub%d: %v", i, err)
|
|
}
|
|
}
|
|
beforeReadAt := queryInt(t, conn, `SELECT read_at FROM chat_sessions WHERE id = ?`, subID)
|
|
beforeMsgs := queryInt(t, conn, `SELECT COUNT(*) FROM chat_messages WHERE session_id = ?`, subID)
|
|
beforeSessions := queryInt(t, conn, `SELECT COUNT(*) FROM chat_sessions`)
|
|
|
|
tool := newReader(st, testPersonas())
|
|
if got := readText(t, tool, map[string]any{"sessionId": subID}); !strings.Contains(got, "sub1") {
|
|
t.Fatalf("material = %q, want the sub session transcript", got)
|
|
}
|
|
|
|
if after := queryInt(t, conn, `SELECT read_at FROM chat_sessions WHERE id = ?`, subID); after != beforeReadAt {
|
|
t.Fatalf("read_at = %d after the read, want it unchanged at %d", after, beforeReadAt)
|
|
}
|
|
if after := queryInt(t, conn, `SELECT COUNT(*) FROM chat_messages WHERE session_id = ?`, subID); after != beforeMsgs {
|
|
t.Fatalf("sub session message count = %d, want unchanged at %d", after, beforeMsgs)
|
|
}
|
|
if after := queryInt(t, conn, `SELECT COUNT(*) FROM chat_messages WHERE session_id = ?`, history.MainSessionID); after != 0 {
|
|
t.Fatalf("main session gained %d messages, want the material to stay out of the reading session's history", after)
|
|
}
|
|
if after := queryInt(t, conn, `SELECT COUNT(*) FROM chat_sessions`); after != beforeSessions {
|
|
t.Fatalf("session count = %d, want unchanged at %d", after, beforeSessions)
|
|
}
|
|
}
|
|
|
|
// TestReaderExposesNoWriteMethod is the type-level half of the read-only guarantee:
|
|
// the interfaces this package depends on declare no write verb at all, so there is
|
|
// no Append/Delete/Rename/Mark/Publish method for any code path here to call. A
|
|
// future edit that widened HistoryReader with a write call would fail this test.
|
|
func TestReaderExposesNoWriteMethod(t *testing.T) {
|
|
t.Parallel()
|
|
writeVerbs := []string{"Append", "Delete", "Rename", "Mark", "Set", "Update", "Publish", "Write", "Broadcast", "Save", "Insert"}
|
|
for _, iface := range []reflect.Type{
|
|
reflect.TypeOf((*HistoryReader)(nil)).Elem(),
|
|
reflect.TypeOf((*PersonaNamer)(nil)).Elem(),
|
|
} {
|
|
methods := make([]string, 0, iface.NumMethod())
|
|
for i := 0; i < iface.NumMethod(); i++ {
|
|
methods = append(methods, iface.Method(i).Name)
|
|
}
|
|
if len(methods) == 0 {
|
|
t.Fatalf("%s declares no method at all", iface)
|
|
}
|
|
for _, name := range methods {
|
|
for _, verb := range writeVerbs {
|
|
if strings.HasPrefix(name, verb) {
|
|
t.Errorf("%s.%s looks like a write; this package must depend on reads only", iface, name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHistoryFailureIsWrapped asserts a store failure surfaces as an error wrapping
|
|
// the cause instead of being rendered as empty material.
|
|
func TestHistoryFailureIsWrapped(t *testing.T) {
|
|
t.Parallel()
|
|
sentinel := errors.New("boom")
|
|
h := &fakeHistory{
|
|
sessions: []history.SessionMeta{{ID: history.MainSessionID}},
|
|
listErr: sentinel,
|
|
}
|
|
_, err := newReader(h, testPersonas()).Execute(ownerCtx(), map[string]any{"sessionId": history.MainSessionID})
|
|
if !errors.Is(err, sentinel) {
|
|
t.Fatalf("err = %v, want it to wrap %v", err, sentinel)
|
|
}
|
|
|
|
sessions := &fakeHistory{sessionsErr: sentinel}
|
|
if _, err := newReader(sessions, testPersonas()).Execute(ownerCtx(), map[string]any{"sessionId": history.MainSessionID}); !errors.Is(err, sentinel) {
|
|
t.Fatalf("err = %v, want it to wrap %v", err, sentinel)
|
|
}
|
|
}
|
|
|
|
// TestNoHistoryInjected asserts a tool built without a history reader fails fast
|
|
// rather than fabricating material.
|
|
func TestNoHistoryInjected(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := New(WithPersonas(testPersonas())).Execute(ownerCtx(), map[string]any{"sessionId": history.MainSessionID})
|
|
if !errors.Is(err, ErrNoHistory) {
|
|
t.Fatalf("err = %v, want ErrNoHistory", err)
|
|
}
|
|
}
|