package sessionread import ( "context" "errors" "fmt" "log/slog" "strings" "time" "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" ) // Page bounds of the read. They match the REST page (GET /sessions/{id}/messages, // history.clampLimit) so a tool page and a client page are the same size, with the // default and the cap applied here rather than rejected as bad arguments. const ( DefaultLimit = 50 MaxLimit = 200 ) // Sentinels distinguishing the fail-closed refusals from a genuine failure. Each // case has its own message so the model — and the operator reading the log — can // tell "not allowed" from "does not exist", which design/22 §8.1 约束 3 requires. var ( // ErrNoHistory is returned when no read-only history view was injected. ErrNoHistory = errors.New("sessionread: no session-history reader injected") // ErrNotOwner is returned when the calling conversation is not the owner's. ErrNotOwner = errors.New("sessionread: only the owner conversation may read another session") // ErrSessionNotAllowed is returned when the target session id is outside the // readable whitelist (Web main session and Web sub sessions only). ErrSessionNotAllowed = errors.New("sessionread: target session is not readable (only web / web:)") // ErrSessionNotFound is returned when the target session does not exist. It is // deliberately an error, never empty material: "read nothing" must not be // mistakable for "they said nothing". ErrSessionNotFound = errors.New("sessionread: target session does not exist") ) // HistoryReader is the read-only session-history view this package needs. It is // deliberately narrower than history.HistoryStore: it declares no append, no // delete and no read-cursor call, so from inside this package a message cannot be // written to any session and the target session's read_at cannot be advanced // (design/22 §8.1 约束 1). type HistoryReader interface { // List returns up to limit messages of sessionID, newest first, bounded to the // messages strictly older than before when before > 0 (design/16 §3.1). List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]history.Message, error) // ListSessions returns every session's metadata (id, title, role). ListSessions(ctx context.Context) ([]history.SessionMeta, error) } // PersonaNamer resolves role ids to display names. The package keeps this a narrow // interface on purpose: importing internal/persona/registry here would couple the // runtime to the persona loader, so the composition root supplies an adapter. type PersonaNamer interface { // PersonaName returns the display name of role id. ok is false for a role this // runtime does not know, in which case the raw id is shown instead of dropping // the attribution. PersonaName(id string) (string, bool) // DefaultPersonaID returns the global default role id, or "" when none is set. // An unset role id on a message or session means "the default role". DefaultPersonaID() string } // noPersonaNames is the fallback namer: it knows no role names, so the material // shows raw ids rather than inventing a display name. type noPersonaNames struct{} // PersonaName implements PersonaNamer. func (noPersonaNames) PersonaName(string) (string, bool) { return "", false } // DefaultPersonaID implements PersonaNamer. func (noPersonaNames) DefaultPersonaID() string { return "" } // Request is one session-read request after argument decoding. type Request struct { // SessionID is the target session ("web" or "web:"). SessionID string // Limit caps the page; 0 or less means DefaultLimit, above MaxLimit is clamped. Limit int // Before is the millisecond cursor: only messages strictly older than it are // returned. 0 or less means the newest page. Before int64 } // Reader performs the fail-closed authorization and the read, then renders the // material. It holds no per-call state and is safe for concurrent use. type Reader struct { history HistoryReader personas PersonaNamer loc *time.Location log *slog.Logger } // Read authorizes the call, reads one page of the target session and returns the // formatted external material. // // The checks run in this order deliberately: the caller's environment first (a // non-owner conversation learns nothing about any session), the id whitelist // second (a session outside web/web: is refused even if it exists), and only // then the existence lookup. func (r *Reader) Read(ctx context.Context, req Request) (string, error) { if r.history == nil { return "", ErrNoHistory } env := loop.SessionEnvFrom(ctx) if err := r.authorize(env, req.SessionID); err != nil { return "", err } meta, err := r.sessionMeta(ctx, req.SessionID) if err != nil { return "", err } limit := clampLimit(req.Limit) msgs, err := r.history.List(ctx, req.SessionID, req.Before, limit, "") if err != nil { return "", fmt.Errorf("sessionread: list %s: %w", req.SessionID, err) } m := material{ sessionID: req.SessionID, title: meta.Title, personaID: meta.PersonaID, before: req.Before, msgs: msgs, hasOlder: len(msgs) == limit, } // Who read what, and how much — the provenance of design/22 §8.1 约束 4 — is // logged here; the message bodies are not. The audit record itself stays with the // loop's gate/OnAudit, which this tool neither writes to nor bypasses. r.log.Info("external session material read", "actor", string(env.ActorEnv), "target", req.SessionID, "count", len(msgs), "limit", limit, "before", req.Before, "has_older", m.hasOlder) return r.render(m), nil } // authorize enforces the fail-closed whitelist: the calling conversation must be // the owner's, and the target must be the Web main session or a Web sub session. // Default deny, never default allow (design/22 §8.1 约束 3). func (r *Reader) authorize(env loop.SessionEnv, sessionID string) error { if env.ActorEnv != restrict.ActorOwner { r.log.Warn("external session read refused", "target", sessionID, "env", string(env.ActorEnv)) return ErrNotOwner } if !ReadableSessionID(sessionID) { r.log.Warn("external session read refused", "target", sessionID, "reason", "session not readable") return ErrSessionNotAllowed } return nil } // ReadableSessionID reports whether id is in the readable set: the Web main session // or a Web sub session. It is the fail-closed whitelist of design/22 §8.1 约束 3 — // IM group and stranger sessions live outside it and are exactly the ones that must // never leak. func ReadableSessionID(id string) bool { if id == history.MainSessionID { return true } return strings.HasPrefix(id, history.MainSessionID+":") && len(id) > len(history.MainSessionID)+1 } // sessionMeta looks the session up in the session list, so a session that does not // exist is a hard error rather than empty material. The list read is a single // parameter-free query the store already serves for the session list page. func (r *Reader) sessionMeta(ctx context.Context, sessionID string) (history.SessionMeta, error) { sessions, err := r.history.ListSessions(ctx) if err != nil { return history.SessionMeta{}, fmt.Errorf("sessionread: list sessions: %w", err) } for _, meta := range sessions { if meta.ID == sessionID { return meta, nil } } return history.SessionMeta{}, fmt.Errorf("sessionread: session %q: %w", sessionID, ErrSessionNotFound) } // clampLimit bounds a requested page size to [DefaultLimit, MaxLimit], mirroring // history.clampLimit so a tool page and a client page are the same size. A // non-positive request (an absent argument, a non-numeric one, or a negative one) // means DefaultLimit; a request above the cap is clamped, not rejected. func clampLimit(limit int) int { if limit < 1 { return DefaultLimit } if limit > MaxLimit { return MaxLimit } return limit }