Files
Agent-System-Backend/internal/app/scratch_gates_test.go
T
weichenandCursor 2fc6e7e0a7 Gate project context and project commands for scratch hosts at the source
Why: Independent review found scratch runs still called the project
context resolver, and chat intent routing plus system.intent re-dispatch
could deliver rdm.* commands with a scratch host project.

Evidence: go test ./internal/runmgr ./internal/cmdbus ./internal/app
./internal/server ./internal/store pass locally (SELF); CI is authoritative.

Impact: ProjectContext returns nothing when project context is disabled;
Bus.Dispatch applies a host-project guard to every request carrying a host
project, rejecting rdm.* and formal project commands on scratch hosts;
scratch lookups that fail keep isolation gates closed.

Refs: Agent-System-Backend#241; agent-exec receipt u2c-run (pi, success)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 03:53:46 +00:00

182 lines
8.0 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)
}
}
// TestScratchCommandHostGateOnBusDispatch 基座项目门禁挂在命令总线上(组合根的
// 真实接线):不经 HTTP 403、也不经正式命令预检——聊天意图路由与 system.intent
// 再分发走的就是这条面——rdm.* 在临时会话宿主上被拒且处理器不会被调用;普通项目
// 宿主与无宿主分发不受这条门禁影响。
func TestScratchCommandHostGateOnBusDispatch(t *testing.T) {
st, project, conversation := newScratchGateStore(t)
normal, err := st.CreateProject("alice", "普通项目")
if err != nil {
t.Fatal(err)
}
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
handled, preflights := 0, 0
bus.SetHostProjectGuard(func(_ context.Context, req cmdbus.Request) error {
return scratchCommandHostGuard(st, req)
})
bus.SetProjectWorkPreflight(func(context.Context, cmdbus.Request) error { preflights++; return nil })
for _, spec := range []agentcommand.CommandSpec{
{ID: "rdm.reqs.add", Title: "登记需求"},
{ID: "demo.ping", Title: "会话命令"},
} {
id := spec.ID
if err := bus.RegisterPluginCommand(spec,
func(context.Context, cmdbus.Request) (any, error) { handled++; return map[string]any{"id": id}, nil }); err != nil {
t.Fatal(err)
}
}
scratchResp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice",
ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID),
})
if scratchResp.Receipt.State != agentcommand.StateRejected ||
!strings.HasPrefix(scratchResp.Receipt.Reason, "host project: ") ||
!strings.Contains(scratchResp.Receipt.Reason, ErrScratchCommandHost.Error()) {
t.Fatalf("scratch host dispatch = %+v (want host project: %s)", scratchResp.Receipt, ErrScratchCommandHost)
}
if handled != 0 || preflights != 0 {
t.Fatalf("scratch host reached the command handler %d times / preflight %d times", handled, preflights)
}
// 同一宿主上的会话类命令照旧可用(临时会话不是禁用一切命令)。
if resp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "demo.ping", Entrance: "chat", Actor: "alice", Owner: "alice",
ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID),
}); resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("conversation command on a scratch host = %+v", resp.Receipt)
}
// 正面:普通项目宿主不被这条门禁拒绝;无宿主的分发也不走门禁。
for _, req := range []cmdbus.Request{
{ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: string(normal.ID)},
{ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice"},
} {
if err := scratchCommandHostGuard(st, req); err != nil {
t.Fatalf("host guard refused %+v: %v", req, err)
}
}
resp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: string(normal.ID),
})
if resp.Receipt.State != agentcommand.StateCommitted || strings.Contains(resp.Receipt.Reason, ErrScratchCommandHost.Error()) || handled != 2 {
t.Fatalf("normal host dispatch was blocked by the scratch gate: %+v (handler calls %d)", resp.Receipt, handled)
}
}