diff --git a/internal/runmgr/project_context.go b/internal/runmgr/project_context.go index 21d3f6c..9f83420 100644 --- a/internal/runmgr/project_context.go +++ b/internal/runmgr/project_context.go @@ -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. diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 6c0340c..212e8e0 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -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 diff --git a/internal/runmgr/scratch_gates_test.go b/internal/runmgr/scratch_gates_test.go index d8b2ca9..82c262f 100644 --- a/internal/runmgr/scratch_gates_test.go +++ b/internal/runmgr/scratch_gates_test.go @@ -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)