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>
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user