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>
118 lines
4.7 KiB
Go
118 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|