From adad4e1f627cccf71aa152e0acb8f7a297a7775d Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 20:34:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(session):=20=E7=BE=A4=E8=81=8A/=E9=99=8C?= =?UTF-8?q?=E7=94=9F=E4=BA=BA=E8=AE=B0=E5=BF=86=E9=9A=94=E7=A6=BB=E2=80=94?= =?UTF-8?q?=E2=80=94MemWritable=20=E8=B4=AF=E7=A9=BF=20loop=EF=BC=88AGENTS?= =?UTF-8?q?.md=20=E7=BA=A2=E7=BA=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 群聊/陌生人查记忆只读不写、不泄露主人敏感;owner 默认行为完全不变。 - loop/sessionenv.go:SessionEnv 类型 + WithSessionEnv/sessionEnvFrom/OwnerEnv——每次 Run 携带会话 隔离上下文,经 context.Context 传递(不改 Run 签名、不存 Loop/Options 字段,避免 16 并发渠道 消息共享 Loop 的数据竞争;Chatter 接口与测试桩签名不变)。 - loop/memory.go 门控:injectMemory 读——MemWritable=true(owner)走原 Retriever.Inject 全量不变; false(群聊/陌生人)改 Retrieve 后只留 ScopeProject(人人可读)、剔除 ScopeUser(owner习惯私有)与 ScopePersona(角色日常=owner敏感),块首加「群聊隔离」标记,无项目记忆不注入;extractMemory 写 false 直接 return 跳过(不调用 extractor 不落库)。 - 渠道入站(Channelbridge)传真实会话 MemWritable/Env;Web/REST/WS/voice chat 绑 OwnerEnv(JWT=owner 行为不变);backthink/automations/orchestration 默认 owner 全量。 - 测试:memory_gating_test 6 用例(owner/群聊只读不写/陌生人受限/无项目记忆不注入/默认owner全量)全绿; loop/session/gateway 既有测试无回归。go build/vet/test 47 包全绿。 遗留:ScopeProject 过滤在 loop 内后处理实现(未改 memory 包),若后续允许可在 Retriever 加 scope 过滤选项消除与 Inject 模板的重复(2处)。 --- internal/runtime/loop/memory.go | 67 +++++++- internal/runtime/loop/memory_gating_test.go | 153 ++++++++++++++++++ internal/runtime/loop/sessionenv.go | 62 +++++++ .../services/channelbridge/channelbridge.go | 6 +- internal/services/gateway/rest.go | 5 +- internal/services/gateway/voice.go | 5 +- internal/services/gateway/ws_hub.go | 5 +- 7 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 internal/runtime/loop/memory_gating_test.go create mode 100644 internal/runtime/loop/sessionenv.go diff --git a/internal/runtime/loop/memory.go b/internal/runtime/loop/memory.go index d5daebd..2dc7b5c 100644 --- a/internal/runtime/loop/memory.go +++ b/internal/runtime/loop/memory.go @@ -4,6 +4,7 @@ import ( "context" "strings" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) @@ -13,13 +14,20 @@ import ( // retrieval reflects the latest conversation rather than a first-turn snapshot. It // returns the input messages unchanged when no retriever/persona is configured, // retrieval fails, or nothing relevant is found. +// +// The session env on ctx gates the retrieval: the owner-full env keeps the full +// recall (unchanged behavior); the restricted env (group chat / stranger, +// design/02 §3, design/11 §2) is read-only isolation — only non-owner-private +// project memory is injected so no owner-sensitive memory leaks to a group or +// stranger. func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []model.Message { if l.retriever == nil || l.personaID == "" { return messages } - block, err := l.retriever.Inject(ctx, l.personaID, queryFromMessages(messages), 0) + env := sessionEnvFrom(ctx) + block, err := l.retrieveMemoryBlock(ctx, env, queryFromMessages(messages)) if err != nil { - l.log.Warn("memory inject", "error", err) + l.log.Warn("memory inject", "error", err, "env", env.ActorEnv) return messages } if block == "" { @@ -31,6 +39,51 @@ func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []mod return out } +// retrieveMemoryBlock returns the memory injection block for env. The owner-full +// env keeps the pre-existing Inject behavior. The restricted env (group chat / +// stranger) injects only project-scope memory (人人可读, not owner-private) and +// marks the block as group-isolated so callers can tell an isolated read from the +// owner's full read. +func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, query string) (string, error) { + if !env.Restricted() { + return l.retriever.Inject(ctx, l.personaID, query, 0) + } + results, err := l.retriever.Retrieve(ctx, l.personaID, query, 0) + if err != nil { + return "", err + } + shared := make([]memory.Result, 0, len(results)) + for _, r := range results { + if r.Item.Scope == memory.ScopeProject { + shared = append(shared, r) + } + } + if len(shared) == 0 { + return "", nil + } + return formatMemoryBlock(shared, "群聊隔离"), nil +} + +// formatMemoryBlock builds the leading system block from retrieved results. The +// non-empty marker prefixes the block so an isolated group read is distinguishable +// from the owner's full read. +func formatMemoryBlock(results []memory.Result, marker string) string { + var b strings.Builder + if marker != "" { + b.WriteString(marker) + b.WriteString(":") + } + b.WriteString("相关记忆(按相关度 × 衰减排序):") + for _, res := range results { + b.WriteByte('\n') + b.WriteString("- [") + b.WriteString(string(res.Item.Kind)) + b.WriteString("] ") + b.WriteString(res.Item.Content) + } + return b.String() +} + // assembleContext builds the model-visible message list for one round (design // §1.3 每轮 assemble, the loop's equivalent of OpenClaw's transformContext): it // re-injects the persona's relevant long-term memory — retrieval is re-run every @@ -78,7 +131,17 @@ func (l *Loop) compactIfNeeded(ctx context.Context, messages []model.Message) [] // extractMemory runs the extraction hook (design §6) over the current // conversation and persists any derived items. Errors are logged, never fatal, so // a bad extraction cannot abort the run. +// +// In a restricted context (group chat / stranger, !MemWritable) extraction is +// skipped entirely: the conversation may read shared memory read-only and may +// never persist anything that could leak owner-sensitive information (design/02 +// §3, design/11 §2). func (l *Loop) extractMemory(ctx context.Context, messages []model.Message, step int) { + env := sessionEnvFrom(ctx) + if env.Restricted() { + l.log.Debug("memory extract skipped: read-only context", "step", step, "env", env.ActorEnv) + return + } if l.extractor == nil || l.memory == nil { return } diff --git a/internal/runtime/loop/memory_gating_test.go b/internal/runtime/loop/memory_gating_test.go new file mode 100644 index 0000000..498f146 --- /dev/null +++ b/internal/runtime/loop/memory_gating_test.go @@ -0,0 +1,153 @@ +package loop + +import ( + "context" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// seedMemories fills the store with one memory per scope so a test can assert the +// scope filtering of the memory gate. +func seedMemories(t *testing.T, store *memory.Store) { + t.Helper() + ctx := context.Background() + seeds := []memory.MemoryItem{ + {PersonaID: "persona-a", Content: "用户习惯:喜欢冰美式", Scope: memory.ScopeUser, Kind: memory.KindRelationship}, + {PersonaID: "persona-a", Content: "角色日常:周末喜欢爬山", Scope: memory.ScopePersona, Kind: memory.KindRelationship}, + {PersonaID: "persona-a", Content: "项目记忆:Cyrene 用 Go 编写", Scope: memory.ScopeProject, Kind: memory.KindWorldEvent}, + } + for _, it := range seeds { + if _, err := store.Add(ctx, it); err != nil { + t.Fatalf("seed memory: %v", err) + } + } +} + +// TestInjectMemory_OwnerEnv_InjectsAllScopes proves the owner-full env keeps the +// pre-existing full recall: user, persona and project memories are all injected. +func TestInjectMemory_OwnerEnv_InjectsAllScopes(t *testing.T) { + t.Parallel() + store := newTestMemoryStore(t) + seedMemories(t, store) + l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"}) + + msgs := []model.Message{model.TextMessage(model.RoleUser, "go")} + ctx := WithSessionEnv(context.Background(), OwnerEnv()) + view := l.assembleContext(ctx, msgs, 1) + if len(view) != 2 { + t.Fatalf("owner view = %d messages, want 2 (memory injected)", len(view)) + } + block := messageText(view[0]) + for _, want := range []string{"喜欢冰美式", "喜欢爬山", "Cyrene 用 Go 编写", "相关记忆"} { + if !strings.Contains(block, want) { + t.Fatalf("owner block missing %q:\n%s", want, block) + } + } +} + +// TestInjectMemory_RestrictedEnv_OnlyProject proves the group-chat/stranger env is +// read-only isolation: only the non-owner-private project memory is injected, the +// owner's user/persona memory is excluded, and the block is marked 群聊隔离. +func TestInjectMemory_RestrictedEnv_OnlyProject(t *testing.T) { + t.Parallel() + store := newTestMemoryStore(t) + seedMemories(t, store) + l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"}) + + msgs := []model.Message{model.TextMessage(model.RoleUser, "go")} + ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}) + view := l.assembleContext(ctx, msgs, 1) + if len(view) != 2 { + t.Fatalf("restricted view = %d messages, want 2 (project memory injected)", len(view)) + } + block := messageText(view[0]) + if !strings.Contains(block, "群聊隔离") || !strings.Contains(block, "Cyrene 用 Go 编写") { + t.Fatalf("restricted block missing project memory / marker:\n%s", block) + } + for _, banned := range []string{"喜欢冰美式", "喜欢爬山"} { + if strings.Contains(block, banned) { + t.Fatalf("restricted block leaked owner memory %q:\n%s", banned, block) + } + } +} + +// TestInjectMemory_Restricted_NoProjectMemory_NoInject proves that when a group +// or stranger context has no non-owner-private (project) memory, nothing is +// injected — the conversation is not seeded with owner context. +func TestInjectMemory_Restricted_NoProjectMemory_NoInject(t *testing.T) { + t.Parallel() + store := newTestMemoryStore(t) + // Only owner-private (user/persona) memory exists. + for _, it := range []memory.MemoryItem{ + {PersonaID: "persona-a", Content: "用户习惯:喜欢冰美式", Scope: memory.ScopeUser, Kind: memory.KindRelationship}, + } { + if _, err := store.Add(context.Background(), it); err != nil { + t.Fatalf("seed memory: %v", err) + } + } + l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"}) + + msgs := []model.Message{model.TextMessage(model.RoleUser, "go")} + ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}) + view := l.assembleContext(ctx, msgs, 1) + if len(view) != 1 { + t.Fatalf("restricted (no project) view = %d messages, want 1 (no injected memory)", len(view)) + } +} + +// TestExtractMemory_OwnerEnv_Writes proves the owner-full env persists extracted +// memory as before. +func TestExtractMemory_OwnerEnv_Writes(t *testing.T) { + t.Parallel() + store := newTestMemoryStore(t) + l := New(Options{Memory: store, Extractor: memory.NewManualExtractor("persona-a", "owner"), PersonaID: "persona-a"}) + + ctx := WithSessionEnv(context.Background(), OwnerEnv()) + l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1) + + items, err := store.ListByPersona(context.Background(), "persona-a") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(items) != 1 { + t.Fatalf("owner extract persisted %d items, want 1", len(items)) + } +} + +// TestExtractMemory_RestrictedEnv_SkipsWrite proves the group-chat/stranger env +// never writes shared memory: extraction is skipped, the store stays empty. +func TestExtractMemory_RestrictedEnv_SkipsWrite(t *testing.T) { + t.Parallel() + store := newTestMemoryStore(t) + l := New(Options{Memory: store, Extractor: memory.NewManualExtractor("persona-a", "owner"), PersonaID: "persona-a"}) + + ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}) + l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1) + + items, err := store.ListByPersona(context.Background(), "persona-a") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(items) != 0 { + t.Fatalf("restricted extract wrote %d items, want 0 (read-only isolation)", len(items)) + } +} + +// TestSessionEnvFrom_DefaultOwnerFull locks the backward-compatible default: a +// context with no attached env resolves to the owner-full environment, so callers +// that do not set an env (Web main session, internal automated tasks) keep full +// memory read+write. +func TestSessionEnvFrom_DefaultOwnerFull(t *testing.T) { + t.Parallel() + env := sessionEnvFrom(context.Background()) + if env.MemWritable != true || env.Restricted() { + t.Fatalf("default env = %+v, want owner-full (MemWritable=true, not restricted)", env) + } + if env.ActorEnv != restrict.ActorOwner { + t.Fatalf("default env actor = %q, want %q", env.ActorEnv, restrict.ActorOwner) + } +} diff --git a/internal/runtime/loop/sessionenv.go b/internal/runtime/loop/sessionenv.go new file mode 100644 index 0000000..a8bfe94 --- /dev/null +++ b/internal/runtime/loop/sessionenv.go @@ -0,0 +1,62 @@ +package loop + +import ( + "context" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" +) + +// SessionEnv is the per-conversation context that gates memory injection and +// extraction for one Run call. It travels on the call's context rather than on +// the shared Loop because Loop is reused across concurrent sessions (every +// channel/group/stranger message resolves its own isolation boundary); storing it +// on the Loop struct would race. +type SessionEnv struct { + // ActorEnv is the resolved actor environment: owner, group_member, stranger. + // It is the strongly-typed identity lane decided structurally by the channel + // layer (design/11 §0), never inferred from prompt text. + ActorEnv restrict.ActorEnv + // MemWritable reports whether this conversation may write shared memory. It is + // true only for the owner's private conversation; group chats and strangers + // are read-only (design/02 §3, design/11 §2). + MemWritable bool +} + +// Restricted reports whether this is a group-chat or stranger conversation, i.e. +// the read-only memory isolation context. Because MemWritable is true only for +// the owner's private conversation, !MemWritable is exactly the restricted set. +func (e SessionEnv) Restricted() bool { return !e.MemWritable } + +// OwnerEnv returns the full-access owner session environment (private +// conversation, memory read+write). It is the Web main-session and internal +// automated-task default, matching the pre-existing owner-full behavior. +func OwnerEnv() SessionEnv { + return SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true} +} + +// defaultSessionEnv is the owner-full environment used when a caller attaches no +// explicit env: full memory read and write, the pre-existing behavior. +func defaultSessionEnv() SessionEnv { + return OwnerEnv() +} + +type sessionEnvKey struct{} + +// WithSessionEnv attaches env to ctx so Run's memory gates honor the +// group/stranger read-only isolation per call. It is concurrency-safe: the env +// travels with the call's context and is never stored on the shared Loop. +func WithSessionEnv(ctx context.Context, env SessionEnv) context.Context { + return context.WithValue(ctx, sessionEnvKey{}, env) +} + +// sessionEnvFrom returns the env attached to ctx, or the owner-full default when +// none was attached (backward compatible). +func sessionEnvFrom(ctx context.Context) SessionEnv { + if ctx == nil { + return defaultSessionEnv() + } + if env, ok := ctx.Value(sessionEnvKey{}).(SessionEnv); ok { + return env + } + return defaultSessionEnv() +} diff --git a/internal/services/channelbridge/channelbridge.go b/internal/services/channelbridge/channelbridge.go index 7b6320d..874fd93 100644 --- a/internal/services/channelbridge/channelbridge.go +++ b/internal/services/channelbridge/channelbridge.go @@ -313,7 +313,11 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) { "session", sess.SessionID, "actor_role", sess.ActorRole, "env", sess.Env(), "mem_writable", sess.MemWritable) - res, err := b.run.Run(ctx, sess.SessionID, []model.Message{ + // Thread the resolved isolation boundary into the loop's memory gates: the + // owner's private conversation reads+writes memory, group chats and strangers + // are read-only isolation (design/02 §3, design/11 §2). + runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable} + res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sess.SessionID, []model.Message{ model.TextMessage(model.RoleUser, text), }) if err != nil { diff --git a/internal/services/gateway/rest.go b/internal/services/gateway/rest.go index 4d72499..ca19665 100644 --- a/internal/services/gateway/rest.go +++ b/internal/services/gateway/rest.go @@ -9,6 +9,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) @@ -127,7 +128,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { if req.SessionID == "" { req.SessionID = "web" } - res, err := s.runner.Run(r.Context(), req.SessionID, userChatMessages(req.Text, req.Image)) + // The Web main session is the owner's private conversation (JWT = owner), so + // it binds the full-access env: memory read+write, unchanged behavior. + res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), loop.OwnerEnv()), req.SessionID, userChatMessages(req.Text, req.Image)) if err != nil { s.log.Error("chat run", "err", err, "session", req.SessionID) writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed")) diff --git a/internal/services/gateway/voice.go b/internal/services/gateway/voice.go index fcf081c..de8cd22 100644 --- a/internal/services/gateway/voice.go +++ b/internal/services/gateway/voice.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" ) @@ -96,7 +97,9 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) { h.voiceError(c, "chat service is not configured") return } - res, err := h.runner.Run(ctx, req.SessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)}) + // The Web main session is the owner's private conversation, so it binds the + // full-access env: memory read+write, unchanged behavior. + res, err := h.runner.Run(loop.WithSessionEnv(ctx, loop.OwnerEnv()), req.SessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)}) if err != nil { h.log.Warn("voice chat run", "err", err, "session", req.SessionID) h.voiceError(c, "voice chat failed") diff --git a/internal/services/gateway/ws_hub.go b/internal/services/gateway/ws_hub.go index d81e527..d768297 100644 --- a/internal/services/gateway/ws_hub.go +++ b/internal/services/gateway/ws_hub.go @@ -17,6 +17,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) @@ -223,7 +224,9 @@ func (h *hub) streamChat(ctx context.Context, payload any) { if req.SessionID == "" { req.SessionID = "web" } - res, err := h.runner.Run(ctx, req.SessionID, userChatMessages(req.Text, req.Image)) + // The Web main session is the owner's private conversation (JWT = owner), so + // it binds the full-access env: memory read+write, unchanged behavior. + res, err := h.runner.Run(loop.WithSessionEnv(ctx, loop.OwnerEnv()), req.SessionID, userChatMessages(req.Text, req.Image)) if err != nil { h.log.Error("ws chat run", "err", err, "session", req.SessionID) h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "error", "error": err.Error()}})