主人 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 侧成功路径钩子需先定口径。
98 lines
3.5 KiB
Go
98 lines
3.5 KiB
Go
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 ""
|
|
}
|