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

132 lines
4.0 KiB
Go

package sessionread
import (
"context"
"database/sql"
"io"
"log/slog"
"strconv"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// testLogger returns a logger that writes nowhere, keeping test output clean.
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// testLocation pins the rendering timezone so a timestamp assertion is stable
// regardless of the machine's TZ.
var testLocation = time.UTC
// at builds a millisecond timestamp from the readable wall clock the assertions
// use, so an expected line is written the same way a reader thinks about it.
func at(hour, minute int) int64 {
return time.Date(2026, 10, 3, hour, minute, 0, 0, testLocation).UnixMilli()
}
// itoa renders a millisecond cursor the way the material does.
func itoa(ms int64) string { return strconv.FormatInt(ms, 10) }
// newTestStore opens an isolated in-memory history store. The *sql.DB comes back
// too so a read-only assertion can inspect the tables directly.
func newTestStore(t *testing.T) (*history.Store, *sql.DB) {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", testLogger())
if err != nil {
t.Fatalf("open memory db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
st, err := history.NewStore(ctx, conn, testLogger())
if err != nil {
t.Fatalf("new store: %v", err)
}
return st, conn
}
// testPersonas is the role-name double: it knows two roles and one default.
func testPersonas() PersonaNamer {
return fakePersonas{
names: map[string]string{"yuki": "雪", "cyrene": "昔涟"},
def: "cyrene",
}
}
// fakePersonas is a read-only PersonaNamer with a configurable map.
type fakePersonas struct {
names map[string]string
def string
}
// PersonaName implements PersonaNamer.
func (f fakePersonas) PersonaName(id string) (string, bool) {
name, ok := f.names[id]
return name, ok
}
// DefaultPersonaID implements PersonaNamer.
func (f fakePersonas) DefaultPersonaID() string { return f.def }
// fakeHistory is the read-only test double: it implements exactly the two read
// calls the package depends on and nothing else. There is no append, no delete and
// no read-cursor method for the code under test to reach, which is how the
// read-only guarantee of design/22 §8.1 约束 1 becomes a type-level property
// instead of a promise (see TestReaderExposesNoWriteMethod).
type fakeHistory struct {
sessions []history.SessionMeta
msgs map[string][]history.Message // newest first, like the store
listErr error
sessionsErr error
// observed arguments of the last List call, for the pagination assertions.
listBefore int64
listLimit int
listCalls int
}
// List implements HistoryReader with the store's semantics: newest first, strictly
// older than before, at most limit rows.
func (f *fakeHistory) List(_ context.Context, sessionID string, before int64, limit int, _ string) ([]history.Message, error) {
f.listBefore, f.listLimit = before, limit
f.listCalls++
if f.listErr != nil {
return nil, f.listErr
}
out := make([]history.Message, 0, limit)
for _, m := range f.msgs[sessionID] {
if before > 0 && m.CreatedAt >= before {
continue
}
if len(out) == limit {
break
}
out = append(out, m)
}
return out, nil
}
// ListSessions implements HistoryReader.
func (f *fakeHistory) ListSessions(_ context.Context) ([]history.SessionMeta, error) {
if f.sessionsErr != nil {
return nil, f.sessionsErr
}
return f.sessions, nil
}
// Compile-time assertions: the real store must satisfy the read-only view this
// package holds (the composition root injects it), and the read-only double must
// satisfy it too.
var (
_ HistoryReader = (*history.Store)(nil)
_ HistoryReader = (*fakeHistory)(nil)
)
// newReader builds a Reader over h with the test doubles the assertions expect.
func newReader(h HistoryReader, p PersonaNamer) *Tool {
return New(WithHistory(h), WithPersonas(p), WithLocation(testLocation), WithLogger(testLogger()))
}