feat(runtime,ai): session_read — 跨会话只读「主动读取」能力(design/22 §8-3/§8.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 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 侧成功路径钩子需先定口径。
This commit is contained in:
AskaEth committed 2026-10-03 21:33:40 +08:00
1 parent 05d34853f0
commit 08b3a01752
11 files changed
+1723

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
package main
import (
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/sessionread"
)
// wireSessionReadTool registers the design/22 §8-3 cross-session read tool, so the
// running role can pull another Web session's context as external, read-only
// material (design/22 §8.1).
//
// The capability is registered with its default reachability markers (both false),
// which keeps it owner-only: the design/11 §3 policy engine hides the schema from a
// group/stranger session and the loop's security gate rejects the call there. The
// tool's own fail-closed check (owner environment + the web/web:<uuid> whitelist)
// runs behind that gate, so an unauthorized read is refused twice over. The
// audit trail is the loop's gate/OnAudit, exactly as for every other tool — no
// bypass path is added here.
func (a *app) wireSessionReadTool() {
log := logger.WithSource(a.log, "cmd/cyrene")
if a.history == nil {
log.Warn("session read tool skipped: no history store")
return
}
if a.dispatcher == nil {
log.Warn("session read tool skipped: no tool dispatcher")
return
}
reader := sessionread.New(
sessionread.WithHistory(a.history),
sessionread.WithPersonas(sessionReadPersonas{reg: a.personas}),
sessionread.WithLocation(a.sessionReadLocation(log)),
sessionread.WithLogger(a.log),
)
if err := a.dispatcher.Register(reader); err != nil {
log.Warn("session read tool register skipped", "error", err)
return
}
log.Info("session read tool registered", "tool", sessionread.ToolName, "owner_only", true)
}
// sessionReadLocation resolves the configured IANA zone for the material's
// timestamps, so the external transcript reads in the same clock as the loop's
// per-round "current time" injection (internal/runtime/loop/time.go). An unset zone
// or one that fails to load returns nil, which keeps the tool's process-local
// default — the read must not fail over a display setting.
func (a *app) sessionReadLocation(log *slog.Logger) *time.Location {
if a.cfg == nil || a.cfg.Timezone == "" {
return nil
}
loc, err := time.LoadLocation(a.cfg.Timezone)
if err != nil {
log.Warn("session read tool timezone load failed; using the process-local zone",
"tz", a.cfg.Timezone, "error", err)
return nil
}
return loc
}
// sessionReadPersonas adapts the persona registry to sessionread.PersonaNamer. The
// tool package deliberately does not import internal/persona/registry (it holds a
// narrow interface instead, keeping the layering one-way), so the composition root
// supplies the display-name lookup.
type sessionReadPersonas struct {
reg *personareg.Registry
}
// PersonaName implements sessionread.PersonaNamer: the persona's display name, or
// ok=false for an unknown/unregistered role so the tool shows the raw id rather
// than inventing a name.
func (p sessionReadPersonas) PersonaName(id string) (string, bool) {
if p.reg == nil || id == "" {
return "", false
}
persona, ok := p.reg.Persona(id)
if !ok || persona == nil || persona.Name == "" {
return "", false
}
return persona.Name, true
}
// DefaultPersonaID implements sessionread.PersonaNamer: the persona bound to the
// library's default character, mirroring (*app).defaultPersonaID. An empty id means
// the material renders the explicit "no role set" marker instead of guessing.
func (p sessionReadPersonas) DefaultPersonaID() string {
if p.reg == nil {
return ""
}
if c, ok := p.reg.DefaultCharacter(); ok && c != nil {
return c.PersonaID
}
return ""
}
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/sessionread"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// testConfigWithTimezone builds the minimal config the location lookup reads.
func testConfigWithTimezone(tz string) *config.Config {
return &config.Config{Timezone: tz}
}
// newTestHistory opens an isolated in-memory chat-history store for the wiring
// tests, mirroring what wire.go hands the composition root.
func newTestHistory(t *testing.T) *history.Store {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", testLog())
if err != nil {
t.Fatalf("open memory db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
st, err := history.NewStore(ctx, conn, testLog())
if err != nil {
t.Fatalf("new history store: %v", err)
}
return st
}
// newTestPersonas registers one explicit role plus the default character, so the
// adapter's two answers (a role's display name and the default role id) are real.
func newTestPersonas(t *testing.T) *personareg.Registry {
t.Helper()
reg := personareg.New()
for _, p := range []*loader.Persona{
{
ID: "cyrene", Name: "昔涟",
Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5},
Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10},
},
{
ID: "yuki", Name: "雪",
Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5},
Affection: loader.AffectionParams{Initial: 20, Min: 0, Max: 700, Delta: 5},
},
} {
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona(%s): %v", p.ID, err)
}
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "cyrene", Name: "昔涟", Default: true, PersonaID: "cyrene"}); err != nil {
t.Fatalf("RegisterCharacter(cyrene): %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "yuki", Name: "雪", PersonaID: "yuki"}); err != nil {
t.Fatalf("RegisterCharacter(yuki): %v", err)
}
return reg
}
// TestWireSessionReadToolRegistersOwnerOnly locks the composition-root wiring: the
// cross-session read tool must land in the capability registry AND carry the
// default reachability markers, so a group/stranger session can neither see nor
// call it. That is the auditable gate design/22 §8.1 约束 4 relies on — the tool
// itself writes no audit record.
func TestWireSessionReadToolRegistersOwnerOnly(t *testing.T) {
reg := registry.New()
d, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
a := &app{log: testLog(), dispatcher: d, history: newTestHistory(t), personas: newTestPersonas(t)}
a.wireSessionReadTool()
if _, gerr := reg.Get(sessionread.ToolName); gerr != nil {
t.Fatalf("capability %q is not registered: %v", sessionread.ToolName, gerr)
}
advertised := false
for _, s := range d.ToolSchemas() {
if s.Name == sessionread.ToolName {
advertised = true
}
}
if !advertised {
t.Fatalf("tool schemas %v, want %q advertised", d.ToolSchemas(), sessionread.ToolName)
}
if !restrict.SetContext(reg, restrict.ActorOwner).Allow(sessionread.ToolName) {
t.Fatal("owner was denied the session read tool")
}
if restrict.SetContext(reg, restrict.ActorGroupMember).Allow(sessionread.ToolName) {
t.Fatal("group member may invoke the session read tool; want owner-only")
}
if restrict.SetContext(reg, restrict.ActorStranger).Allow(sessionread.ToolName) {
t.Fatal("stranger may invoke the session read tool; want owner-only")
}
// The registered capability must be the tool itself, not a placeholder.
cap, _ := reg.Get(sessionread.ToolName)
if _, ok := cap.Value.(*sessionread.Tool); !ok {
t.Fatalf("capability value = %T, want *sessionread.Tool", cap.Value)
}
}
// TestWireSessionReadToolDegradedBoot asserts a boot without a history store or a
// dispatcher logs and skips the registration instead of panicking — the tool must
// never be registered half-wired and then fail on the model's first call.
func TestWireSessionReadToolDegradedBoot(t *testing.T) {
t.Parallel()
reg := registry.New()
d, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
for name, a := range map[string]*app{
"no history": {log: testLog(), dispatcher: d},
"no dispatcher": {log: testLog(), history: newTestHistory(t)},
} {
t.Run(name, func(t *testing.T) {
a.wireSessionReadTool()
if _, gerr := reg.Get(sessionread.ToolName); gerr == nil {
t.Fatalf("the tool was registered in a degraded boot (%s)", name)
}
})
}
}
// TestSessionReadPersonasAdapter locks the adapter the tool's attribution depends
// on: a known role answers its display name, an unknown one is reported unknown
// (so the raw id is shown instead of a wrong name), and the default is the persona
// of the default character.
func TestSessionReadPersonasAdapter(t *testing.T) {
t.Parallel()
adapter := sessionReadPersonas{reg: newTestPersonas(t)}
if got, ok := adapter.PersonaName("yuki"); !ok || got != "雪" {
t.Fatalf("PersonaName(yuki) = %q, %v; want 雪, true", got, ok)
}
if _, ok := adapter.PersonaName("nobody"); ok {
t.Fatal("PersonaName(nobody) reported a name; want unknown")
}
if got, ok := adapter.PersonaName(""); ok || got != "" {
t.Fatalf("PersonaName(\"\") = %q, %v; want \"\", false", got, ok)
}
if got := adapter.DefaultPersonaID(); got != "cyrene" {
t.Fatalf("DefaultPersonaID() = %q, want cyrene", got)
}
// A nil registry must degrade quietly, not panic: the wiring tolerates a
// persona-less boot.
empty := sessionReadPersonas{}
if _, ok := empty.PersonaName("yuki"); ok || empty.DefaultPersonaID() != "" {
t.Fatal("a nil registry answered a role lookup")
}
}
// TestSessionReadLocation asserts the configured zone is honored and an
// unresolvable one degrades to nil (the tool's process-local default) instead of
// failing the wiring.
func TestSessionReadLocation(t *testing.T) {
t.Parallel()
a := &app{log: testLog(), cfg: testConfigWithTimezone("Asia/Shanghai")}
if loc := a.sessionReadLocation(testLog()); loc == nil || loc.String() != "Asia/Shanghai" {
t.Fatalf("sessionReadLocation() = %v, want Asia/Shanghai", loc)
}
bad := &app{log: testLog(), cfg: testConfigWithTimezone("Not/AZone")}
if loc := bad.sessionReadLocation(testLog()); loc != nil {
t.Fatalf("sessionReadLocation() = %v, want nil for an unresolvable zone", loc)
}
none := &app{log: testLog()}
if loc := none.sessionReadLocation(testLog()); loc != nil {
t.Fatalf("sessionReadLocation() = %v, want nil without config", loc)
}
}
+1
View File
@@ -600,6 +600,7 @@ func (a *app) wireAuxiliary() error {
a.wireSkills()
a.wireTerminalTool()
a.wireSubagentTool()
a.wireSessionReadTool()
return nil
}