Files
Agent-System-Backend/internal/server/scratch_gates_test.go
T
weichenandCursor 613c068c79 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 <cursoragent@cursor.com>
2026-10-22 02:53:31 +00:00

312 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}