From 613c068c794c128c52a31ea3ab64c9eae341cfa1 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 22 Oct 2026 02:53:31 +0000 Subject: [PATCH] Isolate scratch sessions from project-only capabilities Why: A scratch session is a private general-agent chat; it must work for its owner without the RDM provider and must not share documents or context with any other conversation. Evidence: agent-exec run fb04c4a9 (pi, success); go test ./internal/store ./internal/server ./internal/runmgr ./internal/app pass. Impact: Owner work/read on scratch bypasses the domain provider; non-owners and shared adapters get NotFound. Scratch runs skip project context, knowledge retrieval, owner/project context entries, user/project memory, and cross-session reads (both directions). Knowledge add/search 400, rdm.* and formal project commands 403, settings writes 400, uploads skip indexing, and deleting the sole conversation removes the scratch project. Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md Co-authored-by: Cursor --- internal/app/app.go | 25 +++ internal/app/scratch_gates_test.go | 117 ++++++++++ internal/runmgr/memory.go | 33 ++- internal/runmgr/project_context.go | 4 +- internal/runmgr/runmgr.go | 87 ++++++- internal/runmgr/scratch_gates_test.go | 311 ++++++++++++++++++++++++++ internal/server/project_lifecycle.go | 45 +++- internal/server/scratch_gates_test.go | 311 ++++++++++++++++++++++++++ internal/server/scratch_session.go | 50 +++++ internal/server/server.go | 43 +++- internal/store/projects.go | 15 +- internal/store/scratch.go | 102 +++++++++ internal/store/scratch_test.go | 118 ++++++++++ 13 files changed, 1235 insertions(+), 26 deletions(-) create mode 100644 internal/app/scratch_gates_test.go create mode 100644 internal/runmgr/scratch_gates_test.go create mode 100644 internal/server/scratch_gates_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 175ae5c..f3f8734 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,6 +8,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "log" "net" @@ -627,6 +628,9 @@ func Run(opt Options) error { if req.Actor == "" || req.Actor != req.Owner || req.ConversationID == "" || req.AnchorMessageID == "" || req.HostProjectID == "" { return fmt.Errorf("formal project command requires an authenticated conversation source") } + if err := rejectScratchCommandHost(st, req); err != nil { + return err + } conv, err := st.GetConversation(req.Owner, req.ConversationID) if err != nil || string(conv.ProjectID) != req.HostProjectID { return fmt.Errorf("formal project command conversation binding changed") @@ -1123,6 +1127,27 @@ func splitKeyPool(s string) []string { return out } +// ErrScratchCommandHost 是命令分发的临时会话门禁:临时会话没有 RDM 项目域, +// 任何以基座项目为前提的命令(rdm.* 与正式项目命令)在里面一律不可用。 +var ErrScratchCommandHost = errors.New("not available in scratch sessions") + +// rejectScratchCommandHost 在命令总线内部分发(宿主与插件处理器共用的分发面) +// 上执行同一个门禁。基座项目是临时会话时拒绝;不是临时会话(包括项目读不到) +// 时交回原路径,不改变任何现有判定。 +func rejectScratchCommandHost(st *store.Store, req cmdbus.Request) error { + if st == nil || req.HostProjectID == "" { + return nil + } + handled, err := st.ScratchProjectDecision(req.HostProjectID, req.Owner) + if err != nil { + return err + } + if handled { + return ErrScratchCommandHost + } + return nil +} + // busAdapter EXT-14:cmdbus.Bus 适配 runmgr.CommandSource。视图类型转换在此收口 // (runmgr 不依赖 cmdbus,边不进架构基线);Entrance 由 runmgr 固定为 agent。 type busAdapter struct{ bus *cmdbus.Bus } diff --git a/internal/app/scratch_gates_test.go b/internal/app/scratch_gates_test.go new file mode 100644 index 0000000..38ccdb8 --- /dev/null +++ b/internal/app/scratch_gates_test.go @@ -0,0 +1,117 @@ +package app + +// 临时会话的内部命令分发门禁(U2):组合根在命令总线的项目预检入口上拒绝 +// “基座项目是临时会话”的正式项目命令,宿主与插件处理器共用这一条分发面。 + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func newScratchGateStore(t *testing.T) (*store.Store, core.Project, core.Conversation) { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "scratch-gate.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("alice", "Alice", "", false); err != nil { + t.Fatal(err) + } + project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + return st, project, conversation +} + +// TestScratchCommandHostGateDecidesOwnership 门禁本身:属主的临时会话拒绝, +// 普通项目与无基座项目的分发不受影响;非属主按存储判定(NotFound)。 +func TestScratchCommandHostGateDecidesOwnership(t *testing.T) { + st, project, _ := newScratchGateStore(t) + + if err := rejectScratchCommandHost(st, cmdbus.Request{ + ID: "rdm.reqs.add", Owner: "alice", HostProjectID: string(project.ID), + }); !errors.Is(err, ErrScratchCommandHost) { + t.Fatalf("scratch host project accepted a formal command: %v", err) + } + normal, err := st.CreateProject("alice", "普通项目") + if err != nil { + t.Fatal(err) + } + if err := rejectScratchCommandHost(st, cmdbus.Request{ + ID: "rdm.reqs.add", Owner: "alice", HostProjectID: string(normal.ID), + }); err != nil { + t.Fatalf("normal host project refused: %v", err) + } + if err := rejectScratchCommandHost(st, cmdbus.Request{ID: "rdm.reqs.add", Owner: "alice"}); err != nil { + t.Fatalf("hostless dispatch refused: %v", err) + } + // 非属主不能把自己的“属主身份”带进临时会话。 + if err := rejectScratchCommandHost(st, cmdbus.Request{ + ID: "rdm.reqs.add", Owner: "bob", HostProjectID: string(project.ID), + }); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("non-owner scratch dispatch = %v", err) + } +} + +// TestScratchCommandHostGateOnBusPreflight 门禁在真实分发面上生效:正式项目命令 +// 在临时会话宿主上被拒,普通项目宿主不受影响(失败原因来自后续的会话来源校验, +// 不是临时会话门禁)。 +func TestScratchCommandHostGateOnBusPreflight(t *testing.T) { + st, project, conversation := newScratchGateStore(t) + normal, err := st.CreateProject("alice", "普通项目") + if err != nil { + t.Fatal(err) + } + normalConv, err := st.CreateConversation("alice", string(normal.ID), "会话", "") + if err != nil { + t.Fatal(err) + } + + bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + bus.SetProjectWorkPreflight(func(ctx context.Context, req cmdbus.Request) error { + if req.Actor == "" || req.Actor != req.Owner || req.ConversationID == "" || req.AnchorMessageID == "" || req.HostProjectID == "" { + return errors.New("formal project command requires an authenticated conversation source") + } + if err := rejectScratchCommandHost(st, req); err != nil { + return err + } + conv, err := st.GetConversation(req.Owner, req.ConversationID) + if err != nil || string(conv.ProjectID) != req.HostProjectID { + return errors.New("formal project command conversation binding changed") + } + return errors.New("source analysis did not commit a proceed decision") + }) + if err := bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"}, + func(context.Context, cmdbus.Request) (any, error) { return map[string]any{"ok": true}, nil }); err != nil { + t.Fatal(err) + } + + scratchResp := bus.Dispatch(context.Background(), cmdbus.Request{ + ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice", + ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID), + }) + if scratchResp.Receipt.State != agentcommand.StateRejected || + !strings.Contains(scratchResp.Receipt.Reason, ErrScratchCommandHost.Error()) { + t.Fatalf("scratch host dispatch = %+v (want %s)", scratchResp.Receipt, ErrScratchCommandHost) + } + + normalResp := bus.Dispatch(context.Background(), cmdbus.Request{ + ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice", + ConversationID: string(normalConv.ID), AnchorMessageID: "msg-1", HostProjectID: string(normal.ID), + }) + if normalResp.Receipt.State != agentcommand.StateRejected || + strings.Contains(normalResp.Receipt.Reason, ErrScratchCommandHost.Error()) { + t.Fatalf("normal host dispatch was blocked by the scratch gate: %+v", normalResp.Receipt) + } +} diff --git a/internal/runmgr/memory.go b/internal/runmgr/memory.go index 9debfe6..762ac31 100644 --- a/internal/runmgr/memory.go +++ b/internal/runmgr/memory.go @@ -2,6 +2,7 @@ package runmgr import ( "context" + "errors" "time" "agent-native/runtime/memory" @@ -17,23 +18,38 @@ type storeMemory struct { projectID string checked bool allowImplicitDefault bool + // scratch 为真时端口对 user/project 记忆完全关闭:临时会话不读也不写 + // 跨会话记忆(U2 隔离)。会话内状态不受影响。 + scratch bool } +// ErrScratchMemory 临时会话不写记忆:记忆是跨会话的共享层。 +var ErrScratchMemory = errors.New("memory is not available in scratch sessions") + // NewStoreMemory 构建端口实现(owner 为空时用默认租户)。 func NewStoreMemory(st *store.Store, owner, projectID string) memory.Store { + return newStoreMemory(st, owner, projectID, false) +} + +func newStoreMemory(st *store.Store, owner, projectID string, checked bool) *storeMemory { if owner == "" { owner = store.OwnerDefault } - return &storeMemory{st: st, owner: owner, projectID: projectID} + return &storeMemory{st: st, owner: owner, projectID: projectID, checked: checked} } -func newAccountCheckedStoreMemory(st *store.Store, owner, projectID string, allowImplicitDefault bool) memory.Store { - return &storeMemory{st: st, owner: owner, projectID: projectID, checked: true, allowImplicitDefault: allowImplicitDefault} +func newAccountCheckedStoreMemory(st *store.Store, owner, projectID string, allowImplicitDefault bool) *storeMemory { + port := newStoreMemory(st, owner, projectID, true) + port.allowImplicitDefault = allowImplicitDefault + return port } // Recall 按 scope 过滤(空 = 不限,保持迁移前 Memories() 的全量语义), // 过期与空内容条目的剔除与文案渲染在 memory.Block(同一形态)。 func (m *storeMemory) Recall(ctx context.Context, q memory.RecallQuery) ([]memory.Entry, error) { + if m.scratch { + return nil, nil + } projectID := "" if q.Scope == "project" { projectID = m.projectID @@ -65,14 +81,21 @@ func (c *runCtx) StoreMemory() memory.Store { if c.mgr == nil || c.mgr.st == nil { return nil } + var port *storeMemory if c.mgr.accountEffectiveAuthorization != nil { - return newAccountCheckedStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, c.mgr.accountAllowImplicitLocal) + port = newAccountCheckedStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, c.mgr.accountAllowImplicitLocal) + } else { + port = newStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, false) } - return NewStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID) + port.scratch = c.scratchProject() + return port } // Append 写回一条(project scope 用适配器的项目上下文)。 func (m *storeMemory) Append(ctx context.Context, e memory.Entry) error { + if m.scratch { + return ErrScratchMemory + } projectID := e.ProjectID if e.Scope == "project" && projectID == "" { projectID = m.projectID diff --git a/internal/runmgr/project_context.go b/internal/runmgr/project_context.go index 9c736d2..2bafad1 100644 --- a/internal/runmgr/project_context.go +++ b/internal/runmgr/project_context.go @@ -31,8 +31,10 @@ func (m *Manager) SetProjectContextSourceRechecker(fn func(context.Context, exte m.projectSourceRechecker = fn } +// ProjectContextEnabled 报告本回合是否组装项目上下文。临时会话没有 RDM 项目 +// 上下文(它就是一个通用代理会话),所以即使 provider 已注册也不启用。 func (c *runCtx) ProjectContextEnabled() bool { - return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil + return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil && !c.scratchProject() } // ValidateProjectCrossGuard links separate model streams in one persisted run. diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 1e708f6..92fc2e5 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -344,7 +344,7 @@ func (m *Manager) SetProjectWorkGate(preflight projectWorkPreflight, respond pro } func (c *runCtx) requireProjectWorkDecision(action string) error { - if !c.mgr.projectWorkObservationEnabled || c.run.ProjectID == "" { + if !c.projectWorkObservationApplies() { return nil } for { @@ -408,7 +408,38 @@ func (m *Manager) cancelActiveOwnerRuns(owner string) { cancel() } } + +// scratchProject 报告本次 run 的基座项目是否是临时会话。判据是存储里的 host +// 标记(store.IsScratchProject),run 生命周期内项目不变,所以判定一次缓存。 +// 读不到项目时按“非临时会话”处理:临时与否只放宽自己的门禁,不收紧别人的。 +func (c *runCtx) scratchProject() bool { + if c == nil || c.mgr == nil || c.mgr.st == nil || c.run.ProjectID == "" { + return false + } + c.scratchOnce.Do(func() { + c.scratchHost, _ = c.mgr.st.IsScratchProjectID(string(c.run.ProjectID)) + }) + return c.scratchHost +} + +// projectWorkObservationApplies 报告本回合是否走 RDM 观察/预检通道。临时会话 +// 没有 RDM(provider 从未见过它),观察与预检门都不适用:它就是一次普通聊天。 +func (c *runCtx) projectWorkObservationApplies() bool { + return c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" && !c.scratchProject() +} + +// authorizeProjectWork is the run-side project work gate. A scratch project is +// never known to the domain provider (RDM would always report false), so the +// stored ownership decides: its owner works without the provider, everyone else +// is NotFound. Ordinary projects keep the provider decision unchanged. func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error { + if m.st != nil { + if handled, err := m.st.ScratchProjectDecision(string(id), owner); err != nil { + return err + } else if handled { + return nil + } + } if m.projectWorkAuthorization == nil { return nil } @@ -531,6 +562,9 @@ type runCtx struct { launch *workflow.Launch files *filestate.Tracker permissionMode string + // scratchHost 缓存本次 run 的基座项目是否为临时会话(U2 隔离门禁)。 + scratchHost bool + scratchOnce sync.Once // 材料整理(附件即保证):预启动说明注入第 0 回合;ensureMaterial 兜底幂等。 materialNote string workContext string @@ -807,11 +841,17 @@ func (c *runCtx) Injections() []runtime.ContextInjection { if err != nil { return nil } + // 临时会话只带本会话自己的上下文条目:owner 级/项目级条目会把别的会话的 + // 材料带进来,与“私有会话”相反。 + scratch := c.scratchProject() out := make([]runtime.ContextInjection, 0, len(list)) for _, e := range list { if !e.Enabled { continue } + if scratch && e.ConversationID != c.run.ConversationID { + continue + } out = append(out, runtime.ContextInjection{ Keys: e.Keys, Secondary: e.Secondary, Logic: e.Logic, Content: e.Content, Constant: e.Constant, ScanDepth: e.ScanDepth, BudgetChars: e.BudgetChars, @@ -824,6 +864,11 @@ func (c *runCtx) Memories() []core.MemoryEntry { if c.mgr == nil || c.mgr.st == nil { return nil } + // 临时会话不读 user/project 级记忆:记忆是跨会话的,正好是被隔离的那一层。 + // 会话内状态(SessionNote)不受影响。 + if c.scratchProject() { + return nil + } list, err := c.mgr.st.ListMemory(c.run.Owner, "", "") if err != nil { return nil @@ -843,7 +888,7 @@ func (c *runCtx) Emit(typ core.StreamEventType, payload any) error { if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { return err } - if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" && (typ == core.EvMessageDelta || typ == core.EvThinkingDelta) { + if c.projectWorkObservationApplies() && (typ == core.EvMessageDelta || typ == core.EvThinkingDelta) { // Project reply text is released only through the committed message after // the complete run-source manifest receives a domain risk decision. return nil @@ -1237,8 +1282,13 @@ func indexTextOf(name, mime string, content []byte) string { } func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { + // 临时会话没有可检索的共享知识:@knowledge 与 knowledge_search 一律空手 + // 而非报错(会话里的附件仍以材料形式进入本回合)。 + if c.scratchProject() { + return []core.RetrievalHit{}, nil + } hits, err := c.retrieveRaw(q) - if err != nil || !c.mgr.projectWorkObservationEnabled || c.run.ProjectID == "" { + if err != nil || !c.projectWorkObservationApplies() { return hits, err } for _, hit := range hits { @@ -1364,7 +1414,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { if ct.ID != call.Name { continue } - if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" { + if c.projectWorkObservationApplies() { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "project command tool awaits trusted source preflight"} } var params map[string]any @@ -1403,7 +1453,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { last := tc.arts[len(tc.arts)-1] res.Artifact = &last } - if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" { + if c.projectWorkObservationApplies() { if call.CallID == "" { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "project tool result lacks a stable call ID"} } @@ -1494,7 +1544,11 @@ var ( ) // crossReadSelf 加载当前会话并校验项目闸门;出站检查由调用方按目标是否为自身决定。 +// 临时会话直接关闭出站读取:它的材料不流向任何其他会话(U2)。 func (t *toolCtx) crossReadSelf() (core.Conversation, error) { + if t.rc.scratchProject() { + return core.Conversation{}, ErrCrossReadDisabled + } if !t.rc.eff.GetBool("crossSession.read", false) { return core.Conversation{}, ErrCrossReadDisabled } @@ -1505,6 +1559,20 @@ func (t *toolCtx) crossReadSelf() (core.Conversation, error) { return self, nil } +// scratchPeer reports whether a conversation lives in a scratch project. Peers are +// grouped by project so the trusted lookup runs once per distinct project. +func (t *toolCtx) scratchPeer(c core.Conversation, cache map[core.ID]bool) bool { + if seen, ok := cache[c.ProjectID]; ok { + return seen + } + scratch, err := t.rc.mgr.st.IsScratchProjectID(string(c.ProjectID)) + if err != nil { + scratch = false + } + cache[c.ProjectID] = scratch + return scratch +} + func (t *toolCtx) PeerConversations() ([]core.Conversation, error) { self, err := t.crossReadSelf() if err != nil { @@ -1517,12 +1585,17 @@ func (t *toolCtx) PeerConversations() ([]core.Conversation, error) { if err != nil { return nil, err } + scratchProjects := map[core.ID]bool{} out := make([]core.Conversation, 0, len(convs)) for _, c := range convs { // 只读他人:排除自身;私有会话不出现(存在性本身是信息)。 if c.ID == self.ID || c.CrossPrivate { continue } + // 临时会话永不作为同伴出现:它不是共享材料,而是别人的私有会话。 + if t.scratchPeer(c, scratchProjects) { + continue + } out = append(out, c) } return out, nil @@ -1548,6 +1621,10 @@ func (t *toolCtx) PeerConversationMessages(conversationID string, limit int) ([] if !isSelf && !self.AllowCrossRead { return nil, ErrCrossReadDisabled } + // 临时会话的材料不出会话:即使同租户、即使未标私有也拒(自读除外)。 + if !isSelf && t.scratchPeer(target, map[core.ID]bool{}) { + return nil, ErrCrossReadForbidden + } if !isSelf && (target.CrossPrivate || target.ProjectID != t.rc.run.ProjectID) { return nil, ErrCrossReadForbidden } diff --git a/internal/runmgr/scratch_gates_test.go b/internal/runmgr/scratch_gates_test.go new file mode 100644 index 0000000..36161d9 --- /dev/null +++ b/internal/runmgr/scratch_gates_test.go @@ -0,0 +1,311 @@ +package runmgr + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "agent-native/runtime/memory" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +// 临时会话隔离门禁(U2,runmgr 侧)。产品规则:临时会话是“只属于自己的通用代理 +// 会话”——属主的回合像普通聊天一样跑,但它不读检索、不带 owner/项目级上下文、 +// 不读不写跨会话记忆、也不与任何会话互相读。 + +type scratchRunEnv struct { + mgr *Manager + st *store.Store + svc *settings.Service + project core.Project // scratch 宿主项目 + scratch core.Conversation + normal core.Project + normalAt core.Conversation // 普通项目的调用方会话 + peer core.Conversation // 普通项目的同伴会话 +} + +func newScratchRunEnv(t *testing.T) *scratchRunEnv { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "scratch.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("alice", "Alice", "", false); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("bob", "Bob", "", false); err != nil { + t.Fatal(err) + } + env := &scratchRunEnv{st: st, mgr: &Manager{st: st}, svc: settings.NewService(st)} + project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + env.project, env.scratch = project, conversation + normal, err := st.CreateProject("alice", "普通项目") + if err != nil { + t.Fatal(err) + } + env.normal = normal + if env.normalAt, err = st.CreateConversation("alice", normal.ID, "调用方", ""); err != nil { + t.Fatal(err) + } + if env.peer, err = st.CreateConversation("alice", normal.ID, "同伴", ""); err != nil { + t.Fatal(err) + } + msg := core.Message{ConversationID: env.peer.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "同伴内容"}}} + if err := st.AppendMessage(&msg); err != nil { + t.Fatal(err) + } + return env +} + +// runCtx 造一次“回合视角”:项目 + 会话 + 属主。 +func (e *scratchRunEnv) runCtx(projectID, conversationID, owner string) *runCtx { + return &runCtx{mgr: e.mgr, eff: e.svc.Effective(owner, projectID), + run: core.Run{ID: "run-1", Owner: core.ID(owner), ProjectID: core.ID(projectID), ConversationID: core.ID(conversationID)}} +} + +func (e *scratchRunEnv) scratchCtx() *runCtx { + return e.runCtx(string(e.project.ID), e.scratch.ID, "alice") +} + +func (e *scratchRunEnv) normalCtx() *runCtx { + return e.runCtx(string(e.normal.ID), e.normalAt.ID, "alice") +} + +// 门禁①:runmgr.authorizeRunWork —— 领域 provider 恒 false 时,scratch 属主照常 +// 工作(provider 从未见过这个项目),其他人 NotFound;普通项目仍由 provider 决定。 +func TestScratchRunWorkUsesOwnerInsteadOfDomainProvider(t *testing.T) { + env := newScratchRunEnv(t) + asked := 0 + env.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { + asked++ + return false, nil // RDM 视角:永远没有这个项目 + }) + scratchID := core.ID(env.project.ID) + + if err := env.mgr.authorizeRunWork(context.Background(), "alice", scratchID); err != nil { + t.Fatalf("scratch owner blocked by the domain provider: %v", err) + } + if asked != 0 { + t.Fatalf("scratch owner work consulted the provider %d times", asked) + } + if err := env.mgr.authorizeRunWork(context.Background(), "bob", scratchID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("non-owner reached a scratch run: %v", err) + } + // 负面:普通项目没有被这条门禁放宽。 + if err := env.mgr.authorizeRunWork(context.Background(), "alice", core.ID(env.normal.ID)); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("normal project bypassed the provider decision: %v", err) + } + // 正面:provider 放行的普通项目照旧放行。 + env.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil }) + if err := env.mgr.authorizeRunWork(context.Background(), "alice", core.ID(env.normal.ID)); err != nil { + t.Fatalf("provider-approved normal project blocked: %v", err) + } + // 无 provider 注册时,非属主仍然进不去 scratch。 + env.mgr.projectWorkAuthorization = nil + if err := env.mgr.authorizeRunWork(context.Background(), "bob", scratchID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("unregistered host opened a scratch run for a non-owner: %v", err) + } +} + +// 门禁②:RDM 预检/观察通道不适用于临时会话。 +func TestScratchRunSkipsDomainPreflightGate(t *testing.T) { + env := newScratchRunEnv(t) + preflights := 0 + env.mgr.SetProjectWorkGate( + func(context.Context, string, string) (extension.ProjectWorkDecision, error) { + preflights++ + return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "d1"}, nil + }, + func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) { + return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "d1"}, nil + }, + ) + rc := env.scratchCtx() + rc.h = &runHandle{} + if err := rc.requireProjectWorkDecision("conversation.run"); err != nil { + t.Fatalf("scratch run hit the domain preflight: %v", err) + } + if preflights != 0 { + t.Fatalf("scratch run called the domain preflight %d times", preflights) + } + if rc.projectWorkObservationApplies() { + t.Fatal("scratch run must not enter the RDM observation channel") + } +} + +// 门禁③:项目上下文(X1)即使 provider 已注册也不装配。 +func TestScratchProjectContextDisabled(t *testing.T) { + env := newScratchRunEnv(t) + resolved := 0 + env.mgr.SetProjectContextResolver(func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) { + resolved++ + return extension.ProjectContextBundle{ProjectID: env.project.ID}, nil + }) + if env.scratchCtx().ProjectContextEnabled() { + t.Fatal("scratch run assembled project context") + } + if resolved != 0 { + t.Fatalf("scratch run resolved project context %d times", resolved) + } + // 正面:普通项目照旧启用。 + if !env.normalCtx().ProjectContextEnabled() { + t.Fatal("normal project lost project context") + } +} + +// 门禁④:检索为空 —— @knowledge / knowledge_search 在临时会话里拿不到任何东西。 +func TestScratchRunRetrievalReturnsNoHits(t *testing.T) { + env := newScratchRunEnv(t) + docs := []core.KnowledgeDoc{ + {Owner: "alice", ProjectID: core.ID(env.project.ID), Source: "manual", Title: "临时项目文档", Chunk: "临时会话项目内的文档"}, + {Owner: "alice", Source: "manual", Title: "属主文档", Chunk: "属主全局文档"}, + } + if err := env.st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + query := core.RetrievalQuery{Text: "文档", TopK: 10} + + hits, err := env.scratchCtx().Retrieve(query) + if err != nil || len(hits) != 0 { + t.Fatalf("scratch retrieval must return no hits: %+v %v", hits, err) + } + // 工具面(toolCtx.Retrieve)同样空手。 + tc := &toolCtx{rc: env.scratchCtx(), callID: "call-1"} + if hits, err := tc.Retrieve(query); err != nil || len(hits) != 0 { + t.Fatalf("scratch tool retrieval must return no hits: %+v %v", hits, err) + } + // 正面:普通项目照旧检索得到(这里是项目文档 + 属主文档)。 + hits, err = env.normalCtx().Retrieve(query) + if err != nil || len(hits) == 0 { + t.Fatalf("normal project retrieval lost hits: %+v %v", hits, err) + } +} + +// 门禁⑤:上下文注入只留本会话条目;记忆不读不写。 +func TestScratchRunKeepsNoSharedContextAndNoMemory(t *testing.T) { + env := newScratchRunEnv(t) + // 三种作用域各一条:属主级、项目级(本临时会话项目)、本会话级。 + for _, entry := range []store.ContextEntry{ + {Keys: []string{"owner"}, Content: "属主级上下文", Enabled: true}, + {Keys: []string{"project"}, ProjectID: env.project.ID, Content: "项目级上下文", Enabled: true}, + {Keys: []string{"conversation"}, ProjectID: env.project.ID, ConversationID: env.scratch.ID, Content: "本会话上下文", Enabled: true}, + } { + if _, err := env.st.SaveContextEntry("alice", entry); err != nil { + t.Fatal(err) + } + } + + got := map[string]string{} + for _, inj := range env.scratchCtx().Injections() { + for _, key := range inj.Keys { + got[key] = inj.Content + } + } + if got["conversation"] != "本会话上下文" { + t.Fatalf("scratch run lost its own context entry: %+v", got) + } + if _, ok := got["owner"]; ok { + t.Fatalf("owner-wide context entry leaked into a scratch run: %+v", got) + } + if _, ok := got["project"]; ok { + t.Fatalf("project-wide context entry leaked into a scratch run: %+v", got) + } + // 正面:普通项目的回合仍然带属主级上下文。 + normalInj := map[string]bool{} + for _, inj := range env.normalCtx().Injections() { + for _, key := range inj.Keys { + normalInj[key] = true + } + } + if !normalInj["owner"] { + t.Fatalf("normal run lost owner-wide context: %+v", normalInj) + } + + // 记忆:临时会话既不读也不写 user/project 记忆。 + if _, err := env.st.PutMemory("alice", core.MemoryEntry{Scope: "user", Type: "note", Content: "属主记忆", Confidence: 0.9}); err != nil { + t.Fatal(err) + } + if entries := env.scratchCtx().Memories(); len(entries) != 0 { + t.Fatalf("scratch run read user memory: %+v", entries) + } + if entries := env.normalCtx().Memories(); len(entries) != 1 || entries[0].Content != "属主记忆" { + t.Fatalf("normal run lost user memory: %+v", entries) + } + + scratchPort := env.scratchCtx().StoreMemory() + if entries, err := scratchPort.Recall(context.Background(), memory.RecallQuery{Scope: "user"}); err != nil || len(entries) != 0 { + t.Fatalf("scratch memory port recalled: %+v %v", entries, err) + } + if err := scratchPort.Append(context.Background(), memory.Entry{Scope: "user", Type: "note", Content: "临时记忆"}); !errors.Is(err, ErrScratchMemory) { + t.Fatalf("scratch memory port accepted a user-scope write: %v", err) + } + if err := scratchPort.Append(context.Background(), memory.Entry{Scope: "project", Type: "note", Content: "临时项目记忆"}); !errors.Is(err, ErrScratchMemory) { + t.Fatalf("scratch memory port accepted a project-scope write: %v", err) + } + if entries, err := env.st.ListMemory("alice", "", ""); err != nil || len(entries) != 1 { + t.Fatalf("refused writes reached the store: %+v %v", entries, err) + } + // 正面:普通项目的记忆端口照常读写。 + normalPort := env.normalCtx().StoreMemory() + if err := normalPort.Append(context.Background(), memory.Entry{Scope: "user", Type: "note", Content: "普通记忆"}); err != nil { + t.Fatalf("normal memory port write: %v", err) + } + if entries, err := normalPort.Recall(context.Background(), memory.RecallQuery{Scope: "user"}); err != nil || len(entries) < 2 { + t.Fatalf("normal memory port recall: %+v %v", entries, err) + } +} + +// 门禁⑥:跨会话读取 —— 临时会话读不到别人;别人的列表里也没有临时会话。 +func TestScratchCrossSessionReadIsIsolated(t *testing.T) { + env := newScratchRunEnv(t) + // 临时会话自己的项目开关:即使显式开闸也不放行。 + if err := env.svc.MergeProject("alice", string(env.project.ID), map[string]any{"crossSession": map[string]any{"read": true}}); err != nil { + t.Fatal(err) + } + if err := env.svc.MergeProject("alice", string(env.normal.ID), map[string]any{"crossSession": map[string]any{"read": true}}); err != nil { + t.Fatal(err) + } + + scratchTool := &toolCtx{rc: env.scratchCtx(), callID: "call-1"} + if _, err := scratchTool.PeerConversations(); !errors.Is(err, ErrCrossReadDisabled) { + t.Fatalf("scratch run listed peers: %v", err) + } + if _, err := scratchTool.PeerConversationMessages(env.peer.ID, 0); !errors.Is(err, ErrCrossReadDisabled) { + t.Fatalf("scratch run read a peer: %v", err) + } + + normalTool := &toolCtx{rc: env.normalCtx(), callID: "call-2"} + convs, err := normalTool.PeerConversations() + if err != nil { + t.Fatalf("normal peer listing: %v", err) + } + for _, c := range convs { + if c.ID == env.scratch.ID { + t.Fatalf("scratch conversation showed up in a normal peer list: %+v", convs) + } + } + found := false + for _, c := range convs { + if c.ID == env.peer.ID { + found = true + } + } + if !found { + t.Fatalf("normal peer listing lost the real peer: %+v", convs) + } + if _, err := normalTool.PeerConversationMessages(env.scratch.ID, 0); !errors.Is(err, ErrCrossReadForbidden) { + t.Fatalf("normal run read a scratch conversation: %v", err) + } + // 正面:普通项目的同伴会话照旧可读。 + if msgs, err := normalTool.PeerConversationMessages(env.peer.ID, 0); err != nil || len(msgs) != 1 { + t.Fatalf("normal peer read: %+v %v", msgs, err) + } +} diff --git a/internal/server/project_lifecycle.go b/internal/server/project_lifecycle.go index de955d9..af9fd64 100644 --- a/internal/server/project_lifecycle.go +++ b/internal/server/project_lifecycle.go @@ -21,7 +21,24 @@ func (s *Server) lifecycleHost(owner string) extension.Host { } return s.projectHost(owner) } + +// scratchProjectWork 让临时会话的属主在没有 RDM provider 时也能工作。scratch 项目 +// 从不进入 RDM:provider 只会报 Work=false,属主会被自己的临时会话挡住(U2 核心)。 +// 所以门禁先走存储侧归属:属主放行且不再问 provider,非属主一律 NotFound(与 +// 项目工作同一错误面)。handled=false 表示这不是临时会话,调用点照旧。 +func (s *Server) scratchProjectWork(owner, projectID string) (handled bool, err error) { + if s.st == nil { + return false, nil + } + return s.st.ScratchProjectDecision(projectID, owner) +} + func (s *Server) projectWork(ctx context.Context, owner, projectID string) error { + if handled, err := s.scratchProjectWork(owner, projectID); err != nil { + return err + } else if handled { + return nil + } if s.projectLifecycle == nil { return nil } @@ -48,6 +65,12 @@ func (s *Server) projectWork(ctx context.Context, owner, projectID string) error // With no resource authorizer registered (EXT-8b V2 absent) this is exactly // projectWork, so un-extended hosts keep their current denial unchanged. func (s *Server) projectRead(ctx context.Context, owner, projectID string) error { + if handled, err := s.scratchProjectWork(owner, projectID); err != nil { + return err + } else if handled { + // 临时会话只对属主可读:共享域回落永远不参与(它不是共享资源)。 + return nil + } if s.projectLifecycle == nil { return nil } @@ -73,7 +96,7 @@ func (s *Server) projectRead(ctx context.Context, owner, projectID string) error // A scratch project already holds its one private conversation, so this shared // path refuses a second one instead of handing out a normal session under it. func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) { - if err := s.rejectScratchProjectConversation(projectID); err != nil { + if err := s.rejectScratchProjectConversation(projectID, owner); err != nil { return core.Conversation{}, err } if s.projectLifecycle != nil { @@ -91,19 +114,19 @@ func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID // rejectScratchProjectConversation keeps the scratch 1:1 rule on the shared // conversation-create path (POST /api/projects/{id}/conversations and /new). -// The lookup is the internal trusted one, so a scratch project cannot slip -// through the shared-project retry branch; nothing here is returned to the -// caller, and an unreadable project is left to the store path, which decides -// not-found and shared access as before. -func (s *Server) rejectScratchProjectConversation(projectID string) error { - project, err := s.st.ProjectMetadata(projectID) - if err != nil { +// The lookup is the internal trusted one, and it is ownership-aware: a non-owner +// is not told anything about the session (projectWork answers NotFound), while +// the owner still gets the 1:1 refusal. Nothing here is returned to the caller +// for an unreadable project, which the store path decides as before. +func (s *Server) rejectScratchProjectConversation(projectID, owner string) error { + if s.st == nil { return nil } - if store.IsScratchProject(project.Settings) { - return store.ErrScratchSessionSingle + handled, err := s.st.ScratchProjectDecision(projectID, owner) + if !handled || err != nil { + return nil } - return nil + return store.ErrScratchSessionSingle } func writeProjectErr(w http.ResponseWriter, err error) { diff --git a/internal/server/scratch_gates_test.go b/internal/server/scratch_gates_test.go new file mode 100644 index 0000000..0172add --- /dev/null +++ b/internal/server/scratch_gates_test.go @@ -0,0 +1,311 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +// 临时会话隔离门禁(U2)。产品规则:scratch 会话是“只属于自己的通用代理会话”—— +// 属主能像普通聊天一样用它,但它不共享任何文档/上下文,也不触达项目域能力。 +// 每个门禁都成对验证:owner 放行、其他人/其它项目照旧。 + +// scratchGateFixture 建一个已登录的属主账号并落下一个临时会话。 +func scratchGateFixture(t *testing.T, srv *Server, st *store.Store, owner string) (key, projectID, conversationID string) { + t.Helper() + key = scratchAccountKey(t, st, owner) + rec := bearerRequest(srv.Handler(), "POST", "/api/scratch-sessions", `{}`, key) + if rec.Code != http.StatusOK { + t.Fatalf("create scratch session: %d %s", rec.Code, rec.Body.String()) + } + view := decodeScratchSessionHTTP(t, rec) + return key, view.Project.ID, view.Conversation.ID +} + +// TestScratchOwnerWorksWithoutRDMProvider 是 U2 的核心:注册一个 Work 恒为 false +// 的 projectLifecycle(RDM 从未听说过 scratch 项目)后,属主仍能在自己的临时 +// 会话里发消息,而别的账号读不到这个临时会话——即使共享授权适配器对所有人放行。 +func TestScratchOwnerWorksWithoutRDMProvider(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + aliceKey, projectID, conversationID := scratchGateFixture(t, srv, st, "alice") + bobKey := scratchAccountKey(t, st, "bob") + + providerCalls := 0 + srv.SetProjectLifecycle(extension.ProjectLifecycle{ + Work: func(context.Context, string, core.ID) (bool, error) { + providerCalls++ + return false, nil // RDM 视角:没有这个项目 + }, + }, nil) + // 共享域适配器对任何人都放行(最宽松的现网配置),临时会话仍不可共享。 + srv.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil }) + srv.SetResourceAuthorizerV2(func(extension.ResourceAuthzQuery) (bool, error) { return true, nil }) + + rec := bearerRequest(h, "POST", "/api/conversations/"+conversationID+"/messages", `{"text":"帮我总结这段话"}`, aliceKey) + if rec.Code != http.StatusOK { + t.Fatalf("scratch owner could not chat without the provider: %d %s", rec.Code, rec.Body.String()) + } + msgs, err := st.ListMessages(conversationID) + if err != nil || len(msgs) == 0 { + t.Fatalf("owner message not persisted: %+v %v", msgs, err) + } + if providerCalls != 0 { + t.Fatalf("scratch owner work asked the domain provider %d times", providerCalls) + } + + // 负面:别的账号读不到这个临时会话(共享授权适配器一律放行也不作数)。 + if rec := bearerRequest(h, "GET", "/api/projects/"+projectID+"/conversations", "", bobKey); rec.Code != http.StatusNotFound { + t.Fatalf("non-owner listed a scratch project: %d %s", rec.Code, rec.Body.String()) + } + if rec := bearerRequest(h, "POST", "/api/projects/"+projectID+"/conversations", `{"title":"混入"}`, bobKey); rec.Code != http.StatusNotFound { + t.Fatalf("non-owner created a conversation under scratch: %d %s", rec.Code, rec.Body.String()) + } + if err := srv.projectRead(context.Background(), "bob", projectID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("projectRead opened a scratch project for a non-owner: %v", err) + } + if err := srv.projectWork(context.Background(), "bob", projectID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("projectWork opened a scratch project for a non-owner: %v", err) + } + // 属主的读面不受共享适配器影响(存储归属即可)。 + if err := srv.projectRead(context.Background(), "alice", projectID); err != nil { + t.Fatalf("scratch owner projectRead: %v", err) + } + // 普通项目不受影响:provider 说 false 就是 false。 + normal, err := st.CreateProject("alice", "普通项目") + if err != nil { + t.Fatal(err) + } + if err := srv.projectWork(context.Background(), "alice", string(normal.ID)); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("normal project bypassed the provider decision: %v", err) + } +} + +// TestScratchHasNoKnowledgeBase 知识库是项目域能力:入库与检索两个面都 400。 +func TestScratchHasNoKnowledgeBase(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + key, projectID, _ := scratchGateFixture(t, srv, st, "alice") + grantProjectCreateFixture(t, st, "alice") + normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)) + doc := `[{"title":"手动文档","chunk":"知识库内容"}]` + + for name, path := range map[string]string{"scratch": projectID, "normal": normal} { + rec := bearerRequest(h, "POST", "/api/projects/"+path+"/knowledge", doc, key) + switch name { + case "scratch": + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no knowledge base") { + t.Fatalf("addKnowledge on scratch = %d %s", rec.Code, rec.Body.String()) + } + case "normal": + if rec.Code != http.StatusOK { + t.Fatalf("addKnowledge on a normal project = %d %s", rec.Code, rec.Body.String()) + } + } + } + // 被拒的写入没有落库。 + if docs, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: projectID, Text: "手动文档", TopK: 10}); err != nil || len(docs) != 0 { + t.Fatalf("scratch project kept knowledge rows: %+v %v", docs, err) + } + + rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库&project="+projectID, "", key) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no knowledge base") { + t.Fatalf("searchKnowledge on scratch = %d %s", rec.Code, rec.Body.String()) + } + // 正面:普通项目仍然检索得到自己刚入库的文档。 + if rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库&project="+normal, "", key); rec.Code != http.StatusOK { + t.Fatalf("searchKnowledge on a normal project = %d %s", rec.Code, rec.Body.String()) + } + // 无 project 参数的全局检索不受门禁影响(它本来就不落在临时会话上)。 + if rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库", "", key); rec.Code != http.StatusOK { + t.Fatalf("global search = %d %s", rec.Code, rec.Body.String()) + } +} + +// TestScratchUploadStaysOutOfKnowledgeIndex 上传仍可用(对话附件),但临时会话的 +// 附件不进任何知识索引;普通项目的上传照旧入库。 +func TestScratchUploadStaysOutOfKnowledgeIndex(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + key, projectID, _ := scratchGateFixture(t, srv, st, "alice") + grantProjectCreateFixture(t, st, "alice") + normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)) + + upload := func(projectID, name, text string) string { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("file", name) + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write([]byte(text)); err != nil { + t.Fatal(err) + } + mw.Close() + req := httptest.NewRequest(http.MethodPost, "/api/projects/"+projectID+"/uploads", &buf) + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Content-Type", mw.FormDataContentType()) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("upload to %s: %d %s", projectID, rec.Code, rec.Body.String()) + } + var up struct { + ArtifactID string `json:"artifactId"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil { + t.Fatal(err) + } + return up.ArtifactID + } + + scratchArtifact := upload(projectID, "临时附件.txt", "临时会话的附件正文") + if scratchArtifact == "" { + t.Fatal("scratch upload returned no artifact") + } + upload(normal, "普通附件.txt", "普通项目的附件正文") + + scratchHits, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: projectID, Text: "附件", TopK: 10}) + if err != nil { + t.Fatal(err) + } + for _, hit := range scratchHits { + if string(hit.Doc.ProjectID) == projectID || hit.Doc.Ref == scratchArtifact { + t.Fatalf("scratch upload was indexed: %+v", hit.Doc) + } + } + normalHits, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: normal, Text: "附件", TopK: 10}) + if err != nil || len(normalHits) == 0 { + t.Fatalf("normal project upload lost indexing: %+v %v", normalHits, err) + } +} + +// TestScratchRejectsDomainCommands 临时会话里 rdm.* 命令 403;不碰项目域的内置 +// 命令照旧可用(它就是普通聊天能力)。 +func TestScratchRejectsDomainCommands(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + key, projectID, conversationID := scratchGateFixture(t, srv, st, "alice") + grantProjectCreateFixture(t, st, "alice") + normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)) + normalConv := createdProjectID(t, bearerRequest(h, "POST", "/api/projects/"+normal+"/conversations", `{"title":"会话"}`, key)) + + srv.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() }) + srv.WireBuiltinCommands() + if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"}, func(ctx context.Context, req cmdbus.Request) (any, error) { + return map[string]any{"ok": true}, nil + }); err != nil { + t.Fatal(err) + } + if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.list", Title: "需求列表"}, func(ctx context.Context, req cmdbus.Request) (any, error) { + return map[string]any{"ok": true}, nil + }); err != nil { + t.Fatal(err) + } + + // API 入口(POST /api/commands/{id}):临时会话宿主一律 403。 + for _, id := range []string{"rdm.reqs.add", "rdm.reqs.list"} { + rec := bearerRequest(h, "POST", "/api/commands/"+id, `{"conversationId":"`+conversationID+`"}`, key) + if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "not available in scratch sessions") { + t.Fatalf("%s on a scratch host = %d %s", id, rec.Code, rec.Body.String()) + } + } + // 正面:读侧命令在普通项目宿主上照旧分发。 + if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.list", `{"conversationId":"`+normalConv+`"}`, key); rec.Code != http.StatusOK { + t.Fatalf("rdm.reqs.list on a normal host = %d %s", rec.Code, rec.Body.String()) + } + // 没有会话就没有基座项目:门禁不适用,命令走原路径(正式项目命令仍由总线预检拒绝)。 + if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.list", `{}`, key); rec.Code != http.StatusOK { + t.Fatalf("command without a host project = %d %s", rec.Code, rec.Body.String()) + } + if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.add", `{}`, key); rec.Code != http.StatusBadRequest { + t.Fatalf("hostless formal command must keep the bus preflight answer: %d %s", rec.Code, rec.Body.String()) + } + // 内置会话命令在临时会话里照旧可用(它不碰项目域)。 + rec := bearerRequest(h, "POST", "/api/conversations/"+conversationID+"/messages", `{"text":"/help"}`, key) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "command") { + t.Fatalf("builtin host command in a scratch session = %d %s", rec.Code, rec.Body.String()) + } + if !store.IsScratchProject(mustScratchSettings(t, st, projectID)) { + t.Fatalf("scratch marker lost during command gating: %s", projectID) + } +} + +func mustScratchSettings(t *testing.T, st *store.Store, projectID string) map[string]any { + t.Helper() + p, err := st.ProjectMetadata(projectID) + if err != nil { + t.Fatal(err) + } + return p.Settings +} + +// TestScratchProjectPatchAllowsNameOnly 临时会话只允许改名;任何设置写入 400。 +func TestScratchProjectPatchAllowsNameOnly(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + key, projectID, _ := scratchGateFixture(t, srv, st, "alice") + grantProjectCreateFixture(t, st, "alice") + + if rec := bearerRequest(h, "PATCH", "/api/projects/"+projectID, `{"name":"改个名字"}`, key); rec.Code != http.StatusOK { + t.Fatalf("rename scratch project = %d %s", rec.Code, rec.Body.String()) + } + if p, err := st.ProjectMetadata(projectID); err != nil || p.Name != "改个名字" { + t.Fatalf("scratch project not renamed: %+v %v", p, err) + } + for _, body := range []string{`{"settings":{"note":"任何设置"}}`, `{"settings":{"crossSession":{"read":true}}}`, `{"settings":{"host":{"kind":"normal"}}}`} { + rec := bearerRequest(h, "PATCH", "/api/projects/"+projectID, body, key) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no project settings") { + t.Fatalf("scratch settings patch %s = %d %s", body, rec.Code, rec.Body.String()) + } + } + if !store.IsScratchProject(mustScratchSettings(t, st, projectID)) { + t.Fatal("refused patch changed the scratch marker") + } + // 正面:普通项目的设置写入照旧放行。 + normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)) + if rec := bearerRequest(h, "PATCH", "/api/projects/"+normal, `{"settings":{"note":"普通"}}`, key); rec.Code != http.StatusOK { + t.Fatalf("normal settings patch = %d %s", rec.Code, rec.Body.String()) + } +} + +// TestDeletingScratchConversationRemovesScratchProject 删会话即删临时会话外壳; +// 普通项目删会话仍保留项目。 +func TestDeletingScratchConversationRemovesScratchProject(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + key, projectID, conversationID := scratchGateFixture(t, srv, st, "alice") + grantProjectCreateFixture(t, st, "alice") + normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)) + normalConv := createdProjectID(t, bearerRequest(h, "POST", "/api/projects/"+normal+"/conversations", `{"title":"会话"}`, key)) + + bobKey := scratchAccountKey(t, st, "bob") + if rec := bearerRequest(h, "DELETE", "/api/conversations/"+conversationID, "", bobKey); rec.Code == http.StatusOK { + t.Fatalf("non-owner deleted a scratch conversation: %d %s", rec.Code, rec.Body.String()) + } + if rec := bearerRequest(h, "DELETE", "/api/conversations/"+conversationID, "", key); rec.Code != http.StatusOK { + t.Fatalf("delete scratch conversation = %d %s", rec.Code, rec.Body.String()) + } + if _, err := st.ProjectMetadata(projectID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("scratch project survived its conversation: %v", err) + } + if rec := bearerRequest(h, "DELETE", "/api/conversations/"+normalConv, "", key); rec.Code != http.StatusOK { + t.Fatalf("delete normal conversation = %d %s", rec.Code, rec.Body.String()) + } + if _, err := st.ProjectMetadata(normal); err != nil { + t.Fatalf("normal project removed with its conversation: %v", err) + } +} diff --git a/internal/server/scratch_session.go b/internal/server/scratch_session.go index 4efad58..2adddf4 100644 --- a/internal/server/scratch_session.go +++ b/internal/server/scratch_session.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + "agent-system-backend/internal/cmdbus" "agent-system-backend/internal/store" core "agent-system-core" ) @@ -61,3 +62,52 @@ func (s *Server) createScratchSession(w http.ResponseWriter, r *http.Request) { // 200 配 writeJSON,而不是 201。 writeJSON(w, http.StatusOK, scratchSessionView{Project: project, Conversation: conversation}) } + +// ---- U2:隔离门禁 ---- +// +// 临时会话是“只属于自己的通用代理会话”:没有知识库、没有项目上下文、不与任何 +// 会话共享材料。这些门禁只认存储里的 host 标记(store.IsScratchProject),调用点 +// 不自己比对标记字符串。 + +const ( + // scratchNoKnowledge 知识库是项目域能力,临时会话一律没有。 + scratchNoKnowledge = "scratch sessions have no knowledge base" + // scratchNoSettings 临时会话只有会话,没有可写的项目设置。 + scratchNoSettings = "scratch sessions have no project settings" + // scratchNoCommand 项目域命令(RDM 及其它需要基座项目的命令)在临时会话不可用。 + scratchNoCommand = "not available in scratch sessions" +) + +// scratchProjectOf reports whether projectID is the caller's own scratch project. +// Other owners' projects (and unknown ids) fall through to the normal decision +// path, so the gate never leaks the existence of someone else's scratch session. +func (s *Server) scratchProjectOf(projectID, owner string) bool { + if s.st == nil { + return false + } + handled, err := s.st.ScratchProjectDecision(projectID, owner) + return handled && err == nil +} + +// refuseScratchProjectCapability 门禁拒绝:只对调用者自己的临时会话生效。 +func (s *Server) refuseScratchProjectCapability(w http.ResponseWriter, projectID, owner, message string) bool { + if !s.scratchProjectOf(projectID, owner) { + return false + } + http.Error(w, message, http.StatusBadRequest) + return true +} + +// refuseScratchCommandHost 命令门禁(403):临时会话里不得分发需要基座项目的命令。 +// rdm.* 全族与正式项目命令(cmdbus.RequiresProjectWorkDecision)都不行;内置的 +// 会话类命令照旧可用。 +func (s *Server) refuseScratchCommandHost(w http.ResponseWriter, hostProjectID, owner, commandID string) bool { + if !s.scratchProjectOf(hostProjectID, owner) { + return false + } + if !strings.HasPrefix(commandID, "rdm.") && !cmdbus.RequiresProjectWorkDecision(commandID) { + return false + } + http.Error(w, scratchNoCommand, http.StatusForbidden) + return true +} diff --git a/internal/server/server.go b/internal/server/server.go index ca2143f..d9d49b9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -140,6 +140,9 @@ func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action // Owner/ProjectID 从受信存储元数据解析;出错/字段缺失一律 false,fail-closed // 且不泄露存在性);V2 未注册回落 authorizeSharedProject(现行兼容)。 func (s *Server) authorizeSharedResource(actor, action, resourceType string, resourceID, owner, projectID core.ID) bool { + if s.sharedScratchDenied(actor, projectID) { + return false + } if s.ProjectAuthzV2 == nil { return s.authorizeSharedProject(actor, projectID) } @@ -164,10 +167,24 @@ func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool { if s.ProjectAuthz == nil { return false } + if s.sharedScratchDenied(owner, projectID) { + return false + } ok, err := s.ProjectAuthz(owner, projectID) return err == nil && ok } +// sharedScratchDenied 保证共享域授权永远打不开临时会话:临时会话是私有的 +// 1:1 会话外壳,不是共享资源,EXT-8/EXT-8b 适配器不得为他人授予它。属主 +// 不受影响(属主的读写按存储归属判定)。 +func (s *Server) sharedScratchDenied(actor string, projectID core.ID) bool { + if s.st == nil || projectID == "" { + return false + } + handled, err := s.st.ScratchProjectDecision(string(projectID), actor) + return handled && err != nil +} + func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.Service, s *settings.Service, ph *plugins.Host) *Server { sub, err := web.Dist() if err != nil { @@ -780,6 +797,7 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") + owner := s.owner(r) var req struct { Name *string `json:"name"` Settings map[string]any `json:"settings"` @@ -788,6 +806,11 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + // 临时会话只有会话:除了改名没有任何项目设置可写(标记保护之外再加一道)。 + if req.Settings != nil && s.scratchProjectOf(id, owner) { + http.Error(w, scratchNoSettings, http.StatusBadRequest) + return + } if req.Settings != nil { values, err := s.set.PrepareProjectMetadataWrite(req.Settings) if err != nil { @@ -1291,6 +1314,10 @@ func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request, conv core http.Error(w, "未知命令 "+slash+"(/help 查看)", http.StatusBadRequest) return } + // 临时会话里没有项目域:rdm.* 与正式项目命令一律 403。 + if s.refuseScratchCommandHost(w, string(conv.ProjectID), s.owner(r), id) { + return + } params, err := parseSlashParams(id, fields[1:]) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -1374,6 +1401,10 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) { } } } + // 基座项目是临时会话时,项目域命令一律 403(无会话上下文的命令不在此门)。 + if s.refuseScratchCommandHost(w, hostProject, s.owner(r), commandID) { + return + } dispatch := cmdbus.Request{ ID: commandID, Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r), @@ -1714,7 +1745,9 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) { } // 文本可解析的进检索索引(管线分块+全文+可选向量)。 // EXT-9:宿主关闭自动索引时跳过(发布决定移交下游命令层;原件与摄取卡不变)。 - if !s.DisableUploadIndexing { + // 临时会话只有会话:附件进入对话,但不进任何知识索引(否则会话材料会 + // 漏进别的会话的检索结果)。 + if !s.DisableUploadIndexing && !s.scratchProjectOf(projectID, owner) { if plain := doc.PlainText(); plain != "" && len(plain) < 200000 { var indexErr error if s.Indexer != nil { @@ -2294,6 +2327,10 @@ func (s *Server) addKnowledge(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } + // 知识库是项目域能力:临时会话不接收任何文档入库。 + if s.refuseScratchProjectCapability(w, r.PathValue("id"), owner, scratchNoKnowledge) { + return + } for i := range docs { docs[i].Owner = owner docs[i].ProjectID = r.PathValue("id") @@ -2313,6 +2350,10 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) { topK, _ := strconv.Atoi(r.URL.Query().Get("topK")) query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK} owner := s.owner(r) + // 检索面同样先问门禁:临时会话没有知识库(而不是“搜出来是空的”)。 + if s.refuseScratchProjectCapability(w, query.ProjectID, owner, scratchNoKnowledge) { + return + } if s.projectLifecycle != nil && query.ProjectID != "" { id, err := projectscope.Resolve(r.Context(), s.lifecycleHost(owner), *s.projectLifecycle, query.ProjectID) if err != nil { diff --git a/internal/store/projects.go b/internal/store/projects.go index f3c136e..057a45b 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -464,8 +464,13 @@ func (s *Store) SetPermissionMode(owner, id, mode string) error { return nil } +// DeleteConversation deletes one conversation; deleting the sole conversation of +// a scratch project also removes that scratch project, so a scratch session is +// never left as an empty shell project. func (s *Store) DeleteConversation(owner, id string) error { - return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner) + return s.deleteConversationCascade(s.db, owner, id, func() error { + return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner) + }) } // withOwnedConversationWrite locks the actor before the conversation and @@ -521,10 +526,14 @@ func (s *Store) UpdateConversationAccountChecked(actor, id, title, profileID str }) } -// DeleteConversationAccountChecked retains the legacy owner-only delete rule. +// DeleteConversationAccountChecked deletes the conversation; when it was the +// sole conversation of a scratch project, that scratch project goes with it in +// the same write transaction, so a scratch session cannot be left half-deleted. func (s *Store) DeleteConversationAccountChecked(actor, id string, allowImplicitDefault bool) error { return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error { - return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor) + return s.deleteConversationCascade(tx, actor, id, func() error { + return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor) + }) }) } diff --git a/internal/store/scratch.go b/internal/store/scratch.go index af7035a..72d116c 100644 --- a/internal/store/scratch.go +++ b/internal/store/scratch.go @@ -43,6 +43,108 @@ func IsScratchProject(settings map[string]any) bool { return kind == ScratchHostKind } +// IsScratchProjectID 是内部可信判定:按 id 读 projects.settings 的 host 标记。 +// 调用点(HTTP 门禁、回合门禁、命令分发)一律走它,不自己比对标记字符串。 +func (s *Store) IsScratchProjectID(projectID string) (bool, error) { + if projectID == "" { + return false, nil + } + p, err := s.ProjectMetadata(projectID) + if err != nil { + return false, err + } + return IsScratchProject(p.Settings), nil +} + +// ScratchProjectDecision 是临时会话的归属判定,回答“这个调用是否发生在某个 +// 临时会话里”: +// - handled=false:不是临时会话(或项目读不到),调用点继续走原来的判定面; +// - handled=true, err=nil:调用者是该临时会话的属主,放行且不必再问 RDM; +// - handled=true, err=ErrNotFound:临时会话不是共享资源,其他人一律不可见。 +// +// 判据只来自存储里的属主列与 host 标记,不接受调用方自称的身份。 +func (s *Store) ScratchProjectDecision(projectID, actor string) (bool, error) { + if projectID == "" { + return false, nil + } + p, err := s.ProjectMetadata(projectID) + if err != nil { + // 读不到项目(不存在或存储异常):存在性与共享判定留给原路径, + // 这里不把“读不到”当成临时会话,也就不会放宽任何门禁。 + return false, nil + } + if !IsScratchProject(p.Settings) { + return false, nil + } + if p.Owner != core.ID(actor) { + return true, ErrNotFound + } + return true, nil +} + +// scratchQuerier 是本文件里临时会话判定可用的两种执行面(*sql.DB 与 *sql.Tx), +// 于是同一个事务内的级联删除与 store 级删除共用一份语义。 +type scratchQuerier interface { + QueryRow(string, ...any) *sql.Row + Exec(string, ...any) (sql.Result, error) +} + +// scratchProjectOfConversation 返回会话所属的 scratch 项目 id;普通项目返回空串。 +func (s *Store) scratchProjectOfConversation(q scratchQuerier, owner, conversationID string) (string, error) { + var projectID, settings string + err := q.QueryRow(s.q(`SELECT c.project_id, COALESCE(p.settings,'') FROM conversations c LEFT JOIN projects p ON p.id=c.project_id WHERE c.id=? AND c.owner=?`), + conversationID, owner).Scan(&projectID, &settings) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if !IsScratchProject(decodeProjectSettings(settings)) { + return "", nil + } + return projectID, nil +} + +// deleteScratchProjectIfEmpty 在会话已被删掉之后连带删掉它的临时会话项目: +// 临时会话项目只承载这一个会话,删会话即删临时会话(同一个执行面、同一个门禁)。 +// 仍有会话时不动(1:1 由创建面保证,删除面不依赖该不变量做正确性);普通项目 +// id 传进来也不会被删。 +func (s *Store) deleteScratchProjectIfEmpty(q scratchQuerier, owner, projectID string) error { + if projectID == "" { + return nil + } + var remaining int + if err := q.QueryRow(s.q(`SELECT COUNT(1) FROM conversations WHERE project_id=?`), projectID).Scan(&remaining); err != nil { + return err + } + if remaining > 0 { + return nil + } + res, err := q.Exec(s.q(`DELETE FROM projects WHERE id=? AND owner=?`), projectID, owner) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// deleteConversationCascade 删除一个会话,并在它是临时会话唯一会话时连带删掉 +// 临时会话项目。account-checked 路径在最终写事务内调用,legacy 路径在同一执行 +// 面上调用:语义只有一份。 +func (s *Store) deleteConversationCascade(q scratchQuerier, owner, id string, del func() error) error { + scratchProject, err := s.scratchProjectOfConversation(q, owner, id) + if err != nil { + return err + } + if err := del(); err != nil { + return err + } + return s.deleteScratchProjectIfEmpty(q, owner, scratchProject) +} + // decodeProjectSettings 把存储的 settings JSON 读成 map;缺省或损坏都是空表。 func decodeProjectSettings(raw string) map[string]any { if raw == "" || raw == "{}" { diff --git a/internal/store/scratch_test.go b/internal/store/scratch_test.go index f88a930..dac1b5f 100644 --- a/internal/store/scratch_test.go +++ b/internal/store/scratch_test.go @@ -215,3 +215,121 @@ func TestScratchSessionTransactionLeavesNothingOnFailure(t *testing.T) { t.Fatalf("rolled back create left rows: %+v %v", all, err) } } + +// ---- U2:隔离门禁的 store 契约 ---- + +// IsScratchProjectID / ScratchProjectDecision 是所有门禁唯一的判定来源: +// 属主放行,其他人 NotFound,普通项目不接管(交回原判定面)。 +func TestScratchProjectLookupAndOwnershipDecision(t *testing.T) { + st := openTestStore(t) + scratchAccountFixture(t, st, "alice", false) + scratchAccountFixture(t, st, "bob", false) + project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false) + if err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + projectID string + scratch bool + err error + }{ + {"scratch", string(project.ID), true, nil}, + {"normal", string(normal.ID), false, nil}, + {"empty", "", false, nil}, + {"missing", "prj_missing", false, ErrNotFound}, + } { + got, err := st.IsScratchProjectID(tc.projectID) + if got != tc.scratch || !errors.Is(err, tc.err) { + t.Fatalf("%s: IsScratchProjectID = %v, %v (want %v, %v)", tc.name, got, err, tc.scratch, tc.err) + } + } + + // 属主:门禁接管并放行(不查 RDM)。 + handled, err := st.ScratchProjectDecision(string(project.ID), "alice") + if !handled || err != nil { + t.Fatalf("scratch owner decision = %v, %v", handled, err) + } + // 其他人:NotFound——临时会话不是共享资源。 + if handled, err := st.ScratchProjectDecision(string(project.ID), "bob"); !handled || !errors.Is(err, ErrNotFound) { + t.Fatalf("scratch non-owner decision = %v, %v", handled, err) + } + // 普通项目与空项目 id 都不接管,原路径继续。 + if handled, err := st.ScratchProjectDecision(string(normal.ID), "bob"); handled || err != nil { + t.Fatalf("normal project decision = %v, %v", handled, err) + } + if handled, err := st.ScratchProjectDecision("", "bob"); handled || err != nil { + t.Fatalf("empty project decision = %v, %v", handled, err) + } + // 读不到的项目不按临时会话处理(不因此放宽任何门禁)。 + if handled, err := st.ScratchProjectDecision("prj_missing", "alice"); handled || err != nil { + t.Fatalf("missing project decision = %v, %v", handled, err) + } + _ = conversation +} + +// 删掉临时会话的唯一会话即删掉它的 scratch 项目(不留空壳);普通项目照旧。 +func TestDeletingScratchConversationDeletesScratchProject(t *testing.T) { + st := openTestStore(t) + scratchAccountFixture(t, st, "alice", false) + scratchAccountFixture(t, st, "bob", false) + project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + + // 他人删除:仍然 NotFound,且临时会话原样存在。 + if err := st.DeleteConversationAccountChecked("bob", string(conversation.ID), false); !errors.Is(err, ErrNotFound) { + t.Fatalf("non-owner delete = %v", err) + } + if _, err := st.ProjectMetadata(string(project.ID)); err != nil { + t.Fatalf("non-owner delete removed the scratch project: %v", err) + } + + if err := st.DeleteConversationAccountChecked("alice", string(conversation.ID), false); err != nil { + t.Fatalf("owner delete: %v", err) + } + if _, err := st.ProjectMetadata(string(project.ID)); !errors.Is(err, ErrNotFound) { + t.Fatalf("scratch project survived its only conversation: %v", err) + } + if scratch, err := st.IsScratchProjectID(string(project.ID)); scratch || !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted scratch project still answers as scratch: %v %v", scratch, err) + } + + // 普通项目的会话删除不动项目。 + normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false) + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversationAccountChecked("alice", string(normal.ID), "会话", "", false, true) + if err != nil { + t.Fatal(err) + } + if err := st.DeleteConversationAccountChecked("alice", string(conv.ID), false); err != nil { + t.Fatal(err) + } + if _, err := st.ProjectMetadata(string(normal.ID)); err != nil { + t.Fatalf("normal project removed with its conversation: %v", err) + } +} + +// legacy DeleteConversation 是同一条语义:删会话即删临时会话外壳。 +func TestLegacyDeleteConversationCascadesScratchProject(t *testing.T) { + st := openTestStore(t) + scratchAccountFixture(t, st, "alice", false) + project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + if err := st.DeleteConversation("alice", string(conversation.ID)); err != nil { + t.Fatalf("legacy delete: %v", err) + } + if _, err := st.ProjectMetadata(string(project.ID)); !errors.Is(err, ErrNotFound) { + t.Fatalf("legacy delete left the scratch shell: %v", err) + } +}