Files
AskaEth 08b3a01752
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime,ai): session_read — 跨会话只读「主动读取」能力(design/22 §8-3/§8.1)
主人 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 侧成功路径钩子需先定口径。
2026-10-03 21:33:40 +08:00

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)
}
}