Files
Agent-System-Backend/internal/runmgr/scratch_gates_test.go
T
weichenandCursor b704ba9897
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m23s
Fail closed both ways when the scratch host lookup fails
Why: Returning true from scratchProject on a lookup error closed the
isolation gates correctly, but the same helper also skipped RDM
observation and project context for a normal project during a
transient store failure.

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

Impact: confirmedScratchProject (lookup succeeded and marked scratch)
drives the relaxing gates; scratchProject (lookup error => true) keeps
driving the tightening ones.

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

465 lines
20 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 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")
}
}
// 门禁③bis:ProjectContext 本身(llm.go refresh.ProjectContext、GuardProjectStream
// 与其它调用方走的同一个入口)在临时会话回合里一次 resolver 都不调;普通项目的
// 回合照旧调用。
func TestScratchProjectContextNeverReachesResolver(t *testing.T) {
env := newScratchRunEnv(t)
env.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil })
resolved := 0
env.mgr.SetProjectContextResolver(func(_ context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
resolved++
return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.ProjectID}, nil
})
bundle, active, err := env.scratchCtx().ProjectContext(context.Background(), "临时会话的目标", 900)
if err != nil || active {
t.Fatalf("scratch ProjectContext = active:%v err:%v", active, err)
}
if bundle.Status != "" || bundle.ProjectID != "" || len(bundle.Items) != 0 || len(bundle.Evidence) != 0 {
t.Fatalf("scratch ProjectContext returned a bundle: %+v", bundle)
}
if resolved != 0 {
t.Fatalf("scratch run called the project context resolver %d times", resolved)
}
// ProjectEvidence 同样不得借此拿到材料。
if _, err := env.scratchCtx().ProjectEvidence(context.Background(), "ref-a"); err == nil {
t.Fatal("scratch run read project evidence")
}
// 正面:普通项目的已持久化 run 照旧装一次上下文。
run := core.Run{ConversationID: env.normalAt.ID, ProjectID: env.normal.ID, Owner: env.normalAt.Owner,
Kind: core.RunConversation, Status: core.RunPending, Phase: core.PhaseThinking}
if err := env.st.CreateRun(&run); err != nil {
t.Fatal(err)
}
normalRC := env.runCtx(string(env.normal.ID), string(env.normalAt.ID), string(env.normalAt.Owner))
normalRC.run = run
if _, active, err := normalRC.ProjectContext(context.Background(), "普通项目的目标", 900); err != nil || !active {
t.Fatalf("normal project ProjectContext = active:%v err:%v", active, err)
}
if resolved != 1 {
t.Fatalf("normal run called the project context resolver %d times, want 1", resolved)
}
}
// 门禁③ter:基座项目读不到时 fail closed——本次调用按临时会话处理,且不把这个
// 未知答案缓存下来(否则一次读失败就会为整个 run 放开记忆、上下文条目与跨会话读取)。
func TestScratchProjectFailsClosedOnLookupError(t *testing.T) {
env := newScratchRunEnv(t)
rc := env.runCtx("prj-missing", string(env.scratch.ID), "alice")
if !rc.scratchProject() {
t.Fatal("unreadable host project was treated as a normal project")
}
// 未缓存未知答案:换成能读到的项目后重新判定(缓存 false 会在这里露馅)。
rc.run.ProjectID = env.project.ID
if !rc.scratchProject() {
t.Fatal("real scratch project was not recognised after a failed lookup")
}
// 正面:读得到时按存储标记判定,普通项目不受影响。
normal := env.runCtx(string(env.normal.ID), string(env.normalAt.ID), "alice")
if normal.scratchProject() {
t.Fatal("normal project was treated as scratch")
}
scratch := env.runCtx(string(env.project.ID), string(env.scratch.ID), "alice")
if !scratch.scratchProject() {
t.Fatal("scratch project was not recognised")
}
// 无项目 run 不受影响(没有基座项目就没有临时会话可谈)。
if env.runCtx("", string(env.scratch.ID), "alice").scratchProject() {
t.Fatal("projectless run was treated as scratch")
}
}
// 门禁③quater:同一件事(基座项目读不到)在两个方向上都 fail closed。收紧型门禁
// (记忆、上下文条目、检索、跨会话读取、记忆端口)按临时会话处理;放宽型门禁(RDM
// 观察/预检、项目上下文)按普通项目处理。读到标记之前谁都不能被一次读失败固定下来。
func TestScratchGatesFailClosedBothWaysOnLookupError(t *testing.T) {
env := newScratchRunEnv(t)
preflights := 0
configure := func(mgr *Manager) {
mgr.SetProjectWorkGate(
func(context.Context, string, string) (extension.ProjectWorkDecision, error) {
preflights++
return extension.ProjectWorkDecision{}, errors.New("domain preflight unreachable")
},
func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) {
return extension.ProjectWorkDecision{}, errors.New("domain preflight unreachable")
},
)
mgr.SetProjectContextResolver(func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
return extension.ProjectContextBundle{}, nil
})
}
configure(env.mgr)
// 基线:普通项目上,两个方向本来就是收紧(不隔离)/ 放宽门生效(观察+上下文)。
before := env.normalCtx()
if before.scratchProject() {
t.Fatal("normal project was treated as scratch")
}
if !before.projectWorkObservationApplies() {
t.Fatal("normal project lost the RDM observation channel")
}
if !before.ProjectContextEnabled() {
t.Fatal("normal project lost project context")
}
// 关掉存储句柄:项目行没变(仍是普通项目),只是 host 标记读不到了。
if err := env.st.Close(); err != nil {
t.Fatal(err)
}
rc := env.normalCtx()
if !rc.scratchProject() {
t.Fatal("unreadable host project stopped tightening the isolation gates")
}
if !rc.projectWorkObservationApplies() {
t.Fatal("a failed scratch lookup skipped the RDM observation/preflight gate")
}
if !rc.ProjectContextEnabled() {
t.Fatal("a failed scratch lookup disabled project context for a normal project")
}
// 观察门真的走到领域预检:门没有因为读不到标记而被整体跳过。
rc.h = &runHandle{}
if err := rc.requireProjectWorkDecision("conversation.run"); err == nil {
t.Fatal("project work gate skipped the domain preflight after a failed lookup")
}
if preflights != 1 {
t.Fatalf("domain preflight consulted %d times after a failed lookup, want 1", preflights)
}
// 未缓存未知答案:读失败后在能读到的存储上重新判定,两个方向都只认存储标记。
again := newScratchRunEnv(t)
configure(again.mgr)
probe := again.runCtx("prj-missing", string(again.scratch.ID), "alice")
if !probe.scratchProject() || probe.confirmedScratchProject() {
t.Fatalf("failed lookup did not split the two directions: isolation=%t relaxing=%t",
probe.scratchProject(), probe.confirmedScratchProject())
}
probe.run.ProjectID = again.project.ID
if !probe.scratchProject() || !probe.confirmedScratchProject() {
t.Fatal("real scratch project was not recognised after a failed lookup")
}
normal := again.normalCtx()
if normal.scratchProject() || normal.confirmedScratchProject() {
t.Fatal("normal project was cached as scratch after a failed lookup")
}
if !normal.projectWorkObservationApplies() || !normal.ProjectContextEnabled() {
t.Fatal("readable normal project lost the observation gate or project context")
}
// 真实临时会话依旧拿不到这些放宽面。
if scratch := again.scratchCtx(); scratch.projectWorkObservationApplies() || scratch.ProjectContextEnabled() {
t.Fatal("scratch project gained the RDM observation gate or 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)
}
}