Fail closed both ways when the scratch host lookup fails
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m23s

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>
This commit is contained in:
2026-10-22 04:02:11 +00:00
co-authored by Cursor
parent 2fc6e7e0a7
commit b704ba9897
3 changed files with 116 additions and 12 deletions
+3 -2
View File
@@ -32,9 +32,10 @@ func (m *Manager) SetProjectContextSourceRechecker(fn func(context.Context, exte
}
// ProjectContextEnabled 报告本回合是否组装项目上下文。临时会话没有 RDM 项目
// 上下文(它就是一个通用代理会话),所以即使 provider 已注册也不启用。
// 上下文(它就是一个通用代理会话),所以即使 provider 已注册也不启用;基座项目
// 读不到时按普通项目处理,门禁照旧收紧。
func (c *runCtx) ProjectContextEnabled() bool {
return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil && !c.scratchProject()
return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil && !c.confirmedScratchProject()
}
// ValidateProjectCrossGuard links separate model streams in one persisted run.
+31 -10
View File
@@ -409,35 +409,56 @@ func (m *Manager) cancelActiveOwnerRuns(owner string) {
}
}
// scratchProject 报告本次 run 的基座项目是否是临时会话。判据是存储里的 host
// 标记(store.IsScratchProject),run 生命周期内项目不变,所以判定一次缓存。
// 读不到项目时 fail closed:本次调用按临时会话处理且不缓存这个未知答案——
// 这些门禁只收紧(记忆、上下文条目、跨会话读取),读不到宿主就不能为整个 run
// 把它们全部放开。
func (c *runCtx) scratchProject() bool {
// scratchHostFlag 按存储里的 host 标记判定本次 run 的基座项目是否为临时会话。
// ok=false 表示这次没判定成功(读不到项目),此时不缓存答案——run 生命周期内项目
// 不变,下次读得到时再判定。两个方向的 fail closed 由调用点各取所需。
func (c *runCtx) scratchHostFlag() (host bool, ok bool) {
if c == nil || c.mgr == nil || c.mgr.st == nil || c.run.ProjectID == "" {
return false
return false, false
}
c.scratchMu.Lock()
host, known := c.scratchHost, c.scratchKnown
c.scratchMu.Unlock()
if known {
return host
return host, true
}
host, err := c.mgr.st.IsScratchProjectID(string(c.run.ProjectID))
if err != nil {
return true
return false, false
}
c.scratchMu.Lock()
c.scratchHost, c.scratchKnown = host, true
c.scratchMu.Unlock()
return host, true
}
// scratchProject 报告本次 run 的基座项目是否是临时会话,供隔离门禁使用(记忆、
// 上下文条目、检索、跨会话读取、记忆端口)。读不到项目时 fail closed:本次调用按
// 临时会话处理且不缓存这个未知答案——这些门禁只收紧,读不到宿主就不能为整个 run
// 把它们全部放开。
func (c *runCtx) scratchProject() bool {
if c == nil || c.mgr == nil || c.mgr.st == nil || c.run.ProjectID == "" {
return false
}
host, ok := c.scratchHostFlag()
if !ok {
return true
}
return host
}
// confirmedScratchProject 只在存储里读到 host 标记后确认本次 run 是临时会话。
// 读不到项目时为假且不缓存——“临时会话”在这个方向上是放宽信号(RDM 观察/预检
// 门禁、项目上下文),一次读失败不能为整个 run 把它们放开。
func (c *runCtx) confirmedScratchProject() bool {
host, ok := c.scratchHostFlag()
return ok && host
}
// projectWorkObservationApplies 报告本回合是否走 RDM 观察/预检通道。临时会话
// 没有 RDM(provider 从未见过它),观察与预检门都不适用:它就是一次普通聊天。
func (c *runCtx) projectWorkObservationApplies() bool {
return c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" && !c.scratchProject()
return c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" && !c.confirmedScratchProject()
}
// authorizeProjectWork is the run-side project work gate. A scratch project is
+82
View File
@@ -232,6 +232,88 @@ func TestScratchProjectFailsClosedOnLookupError(t *testing.T) {
}
}
// 门禁③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)