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>
312 lines
15 KiB
Go
312 lines
15 KiB
Go
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)
|
||
}
|
||
}
|