Compare commits

..
Author SHA1 Message Date
weichenandCursor 2424f8e3f7 Merge origin/main into feature/scratch-session-general-agent
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m24s
Why: main advanced with agent-stack audit batch-1; resolve the Bus
struct conflict by keeping both hostProjectGuard and operationQuery.

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

Impact: scratch host guard retained alongside the new operation query
port; deps gitlinks match origin/main.

Refs: Agent-System-Backend#241
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 04:06:52 +00:00
weichenandCursor b704ba9897 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>
2026-10-22 04:02:11 +00:00
weichen b4290c03bd Merge pull request 'Land the agent stack audit batch-1 fixes on main' (#244) from fix/agent-stack-audit-b1 into main
governance / governance (push) Successful in 17s
ci / vet-and-test (push) Successful in 1m21s
2026-10-22 04:00:42 +00:00
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
weichenandCursor f3f70a7978 Mark scratch conversations in conversation responses
governance / governance (pull_request) Successful in 21s
ci / vet-and-test (pull_request) Successful in 1m27s
Why: The Frontend groups scratch sessions under a separate sidebar category and
needs an explicit marker instead of inferring it from the project list.
Evidence: agent-exec run 2d662c46 (pi, success); go test ./internal/store ./internal/server pass.
Impact: listTasks, listConversations, conversationDetail and POST /api/scratch-sessions
return scratch: true for scratch conversations; other conversations keep the same JSON (omitempty).
Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 03:05:25 +00:00
weichenandCursor 613c068c79 Isolate scratch sessions from project-only capabilities
Why: A scratch session is a private general-agent chat; it must work for its
owner without the RDM provider and must not share documents or context with any
other conversation.
Evidence: agent-exec run fb04c4a9 (pi, success); go test ./internal/store ./internal/server ./internal/runmgr ./internal/app pass.
Impact: Owner work/read on scratch bypasses the domain provider; non-owners and
shared adapters get NotFound. Scratch runs skip project context, knowledge
retrieval, owner/project context entries, user/project memory, and cross-session
reads (both directions). Knowledge add/search 400, rdm.* and formal project
commands 403, settings writes 400, uploads skip indexing, and deleting the sole
conversation removes the scratch project.
Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 02:53:31 +00:00
weichenandCursor 3b4a7af4b4 Add scratch sessions as inherent private no-project conversations
Why: Accounts without project.create need an isolated general-agent chat; each
scratch session is a private shell project (settings host.kind=scratch) with
exactly one cross-private conversation.
Evidence: agent-exec run 9b3d31a3 (pi, success); go test ./internal/store ./internal/server -run Scratch pass.
Impact: New POST /api/scratch-sessions (no project.create needed); project lists
omit scratch; second conversation under scratch 409; client writes cannot add or
remove the scratch marker; POST /api/projects rejects the marker.
Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 02:16:15 +00:00
17 changed files with 2650 additions and 35 deletions
+39
View File
@@ -8,6 +8,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"net"
@@ -542,10 +543,16 @@ func Run(opt Options) error {
}
return nil
}}
bus.SetHostProjectGuard(func(_ context.Context, req cmdbus.Request) error {
return scratchCommandHostGuard(st, req)
})
bus.SetProjectWorkPreflight(func(ctx context.Context, req cmdbus.Request) error {
if req.Actor == "" || req.Actor != req.Owner || req.ConversationID == "" || req.AnchorMessageID == "" || req.HostProjectID == "" {
return fmt.Errorf("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 fmt.Errorf("formal project command conversation binding changed")
@@ -1042,6 +1049,38 @@ func splitKeyPool(s string) []string {
return out
}
// ErrScratchCommandHost 是命令分发的临时会话门禁:临时会话没有 RDM 项目域,
// 任何以基座项目为前提的命令(rdm.* 与正式项目命令)在里面一律不可用。
var ErrScratchCommandHost = errors.New("not available in scratch sessions")
// rejectScratchCommandHost 在命令总线内部分发(宿主与插件处理器共用的分发面)
// 上执行同一个门禁。基座项目是临时会话时拒绝;不是临时会话(包括项目读不到)
// 时交回原路径,不改变任何现有判定。
func rejectScratchCommandHost(st *store.Store, req cmdbus.Request) error {
if st == nil || req.HostProjectID == "" {
return nil
}
handled, err := st.ScratchProjectDecision(req.HostProjectID, req.Owner)
if err != nil {
return err
}
if handled {
return ErrScratchCommandHost
}
return nil
}
// scratchCommandHostGuard 是命令总线上的基座项目门禁(组合根装配):
// 项目域命令(rdm.* 全族与正式项目命令 cmdbus.RequiresProjectWorkDecision)在
// 临时会话宿主上一律拒绝,其它命令与普通项目宿主原样通过。它挂在分发面本身,
// 所以聊天意图路由与 system.intent 再分发不再绕过 HTTP 403 与正式命令预检。
func scratchCommandHostGuard(st *store.Store, req cmdbus.Request) error {
if !strings.HasPrefix(req.ID, "rdm.") && !cmdbus.RequiresProjectWorkDecision(req.ID) {
return nil
}
return rejectScratchCommandHost(st, req)
}
// busAdapter EXT-14:cmdbus.Bus 适配 runmgr.CommandSource。视图类型转换在此收口
// (runmgr 不依赖 cmdbus,边不进架构基线);Entrance 由 runmgr 固定为 agent。
type busAdapter struct{ bus *cmdbus.Bus }
+181
View File
@@ -0,0 +1,181 @@
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)
}
}
+17
View File
@@ -241,6 +241,7 @@ type Bus struct {
strictAccountWrites bool
allowImplicitLocal bool
projectWorkPreflight func(context.Context, Request) error
hostProjectGuard func(context.Context, Request) error
// operationQuery operationID 对账查询端口(nil = 不提供,见 OperationQuery)。
operationQuery OperationQuery
}
@@ -249,6 +250,15 @@ func (b *Bus) SetProjectWorkPreflight(fn func(context.Context, Request) error) {
b.projectWorkPreflight = fn
}
// SetHostProjectGuard installs the composition-root check applied to every
// dispatch that carries a base project, before the project-work preflight and
// before any handler runs. It is the single bus-level place where host-project
// capability gates live, so every entrance (slash, exec, chat intent routing,
// system.intent re-dispatch, agent tool) is covered by one decision.
func (b *Bus) SetHostProjectGuard(fn func(context.Context, Request) error) {
b.hostProjectGuard = fn
}
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
type PendingStore interface {
SavePendingConfirm(cardID, owner, payload string) error
@@ -481,6 +491,13 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
if err := validateParams(spec.ParamsSchema, req.Params); err != nil {
return receipt(agentcommand.StateRejected, "", "params: "+err.Error())
}
// 基座项目门禁:所有带 HostProjectID 的分发都在这里过一道,命令种类与宿主
// 能力的匹配面由组合根决定(nil 时保持原行为)。
if req.HostProjectID != "" && b.hostProjectGuard != nil {
if err := b.hostProjectGuard(ctx, req); err != nil {
return receipt(agentcommand.StateRejected, "", "host project: "+err.Error())
}
}
if RequiresProjectWorkDecision(req.ID) {
if b.projectWorkPreflight == nil {
return receipt(agentcommand.StateRejected, "", "project source preflight unavailable")
+94
View File
@@ -0,0 +1,94 @@
package cmdbus
// 基座项目门禁在命令总线上的行为(临时会话隔离,U2):SetHostProjectGuard 装
// 的门禁对所有带 HostProjectID 的分发生效,且在 handler 与项目工作预检之前。
import (
"context"
"errors"
"strings"
"testing"
"time"
agentcommand "agent-command"
)
// TestHostProjectGuardRefusesBeforeHandler 基座项目门禁:带宿主的请求先过门禁,
// 拒绝时 handler 与预检都不该被调用;同一个命令换一个普通宿主项目照旧执行。
func TestHostProjectGuardRefusesBeforeHandler(t *testing.T) {
audit := &memAudit{}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
handled, preflights := 0, 0
bus.SetProjectWorkPreflight(func(context.Context, Request) error { preflights++; return nil })
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"}, "",
func(context.Context, Request) (any, error) { handled++; return map[string]any{"ok": true}, nil }); err != nil {
t.Fatal(err)
}
bus.SetHostProjectGuard(func(_ context.Context, req Request) error {
if strings.HasPrefix(req.ID, "rdm.") || RequiresProjectWorkDecision(req.ID) {
if req.HostProjectID == "prj_scratch" {
return errors.New("not available in scratch sessions")
}
}
return nil
})
resp := bus.Dispatch(context.Background(), Request{
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice",
ConversationID: "cnv_scratch", HostProjectID: "prj_scratch",
})
if resp.Receipt.State != agentcommand.StateRejected {
t.Fatalf("scratch host dispatch state = %s, want rejected", resp.Receipt.State)
}
if !strings.HasPrefix(resp.Receipt.Reason, "host project: ") ||
!strings.Contains(resp.Receipt.Reason, "not available in scratch sessions") {
t.Fatalf("scratch host rejection reason = %q", resp.Receipt.Reason)
}
if handled != 0 || preflights != 0 {
t.Fatalf("guard ran late: handler=%d preflight=%d", handled, preflights)
}
// 正面:同一命令在普通项目宿主上不被这条门禁拒绝。
ok := bus.Dispatch(context.Background(), Request{
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice",
ConversationID: "cnv_normal", HostProjectID: "prj_normal",
})
if ok.Receipt.State != agentcommand.StateCommitted || handled != 1 || preflights != 1 {
t.Fatalf("normal host dispatch = %+v handler=%d preflight=%d", ok.Receipt, handled, preflights)
}
}
// TestHostProjectGuardSkipsHostlessAndUnrelatedCommands 门禁只作用于带宿主的
// 请求与非项目命令;会话类命令在同一个临时会话宿主上照旧可用。
func TestHostProjectGuardSkipsHostlessAndUnrelatedCommands(t *testing.T) {
bus := New(&memAudit{}, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.ping", Title: "Ping"}, "",
func(context.Context, Request) (any, error) { return map[string]any{"pong": true}, nil }); err != nil {
t.Fatal(err)
}
guarded := 0
bus.SetHostProjectGuard(func(_ context.Context, req Request) error {
guarded++
if req.HostProjectID == "prj_scratch" && strings.HasPrefix(req.ID, "rdm.") {
return errors.New("not available in scratch sessions")
}
return nil
})
resp := bus.Dispatch(context.Background(), Request{
ID: "demo.ping", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: "prj_scratch",
})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("conversation command on a scratch host = %+v", resp.Receipt)
}
if guarded != 1 {
t.Fatalf("host guard calls = %d, want 1", guarded)
}
hostless := bus.Dispatch(context.Background(), Request{ID: "demo.ping", Entrance: "api", Actor: "alice", Owner: "alice"})
if hostless.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("hostless dispatch = %+v", hostless.Receipt)
}
if guarded != 1 {
t.Fatalf("host guard ran for a hostless dispatch: %d", guarded)
}
}
+28 -5
View File
@@ -2,6 +2,7 @@ package runmgr
import (
"context"
"errors"
"time"
"agent-native/runtime/memory"
@@ -17,23 +18,38 @@ type storeMemory struct {
projectID string
checked bool
allowImplicitDefault bool
// scratch 为真时端口对 user/project 记忆完全关闭:临时会话不读也不写
// 跨会话记忆(U2 隔离)。会话内状态不受影响。
scratch bool
}
// ErrScratchMemory 临时会话不写记忆:记忆是跨会话的共享层。
var ErrScratchMemory = errors.New("memory is not available in scratch sessions")
// NewStoreMemory 构建端口实现(owner 为空时用默认租户)。
func NewStoreMemory(st *store.Store, owner, projectID string) memory.Store {
return newStoreMemory(st, owner, projectID, false)
}
func newStoreMemory(st *store.Store, owner, projectID string, checked bool) *storeMemory {
if owner == "" {
owner = store.OwnerDefault
}
return &storeMemory{st: st, owner: owner, projectID: projectID}
return &storeMemory{st: st, owner: owner, projectID: projectID, checked: checked}
}
func newAccountCheckedStoreMemory(st *store.Store, owner, projectID string, allowImplicitDefault bool) memory.Store {
return &storeMemory{st: st, owner: owner, projectID: projectID, checked: true, allowImplicitDefault: allowImplicitDefault}
func newAccountCheckedStoreMemory(st *store.Store, owner, projectID string, allowImplicitDefault bool) *storeMemory {
port := newStoreMemory(st, owner, projectID, true)
port.allowImplicitDefault = allowImplicitDefault
return port
}
// Recall 按 scope 过滤(空 = 不限,保持迁移前 Memories() 的全量语义),
// 过期与空内容条目的剔除与文案渲染在 memory.Block(同一形态)。
func (m *storeMemory) Recall(ctx context.Context, q memory.RecallQuery) ([]memory.Entry, error) {
if m.scratch {
return nil, nil
}
projectID := ""
if q.Scope == "project" {
projectID = m.projectID
@@ -65,14 +81,21 @@ func (c *runCtx) StoreMemory() memory.Store {
if c.mgr == nil || c.mgr.st == nil {
return nil
}
var port *storeMemory
if c.mgr.accountEffectiveAuthorization != nil {
return newAccountCheckedStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, c.mgr.accountAllowImplicitLocal)
port = newAccountCheckedStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, c.mgr.accountAllowImplicitLocal)
} else {
port = newStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, false)
}
return NewStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID)
port.scratch = c.scratchProject()
return port
}
// Append 写回一条(project scope 用适配器的项目上下文)。
func (m *storeMemory) Append(ctx context.Context, e memory.Entry) error {
if m.scratch {
return ErrScratchMemory
}
projectID := e.ProjectID
if e.Scope == "project" && projectID == "" {
projectID = m.projectID
+8 -2
View File
@@ -31,8 +31,11 @@ func (m *Manager) SetProjectContextSourceRechecker(fn func(context.Context, exte
m.projectSourceRechecker = fn
}
// ProjectContextEnabled 报告本回合是否组装项目上下文。临时会话没有 RDM 项目
// 上下文(它就是一个通用代理会话),所以即使 provider 已注册也不启用;基座项目
// 读不到时按普通项目处理,门禁照旧收紧。
func (c *runCtx) ProjectContextEnabled() bool {
return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil
return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil && !c.confirmedScratchProject()
}
// ValidateProjectCrossGuard links separate model streams in one persisted run.
@@ -282,7 +285,10 @@ func (c *runCtx) ProjectEvidence(ctx context.Context, id string) (extension.Proj
// ProjectContext reloads identity and project from trusted run and conversation
// rows on every model request, including a resumed or background run.
func (c *runCtx) ProjectContext(ctx context.Context, goal string, budget int) (extension.ProjectContextBundle, bool, error) {
if c.run.ProjectID == "" || c.mgr.projectContextResolver == nil {
// ProjectContextEnabled 已经是本回合唯一的项目上下文开关(含临时会话):不在
// 这里再调 resolver,否则 llm.go 的 refresh.ProjectContext、GuardProjectStream
// 与其它任何调用方都会替临时会话把项目上下文取回来。
if c.run.ProjectID == "" || c.mgr.projectContextResolver == nil || !c.ProjectContextEnabled() {
return extension.ProjectContextBundle{}, false, nil
}
req, err := c.trustedProjectContextRequest(ctx, budget)
+117 -5
View File
@@ -344,7 +344,7 @@ func (m *Manager) SetProjectWorkGate(preflight projectWorkPreflight, respond pro
}
func (c *runCtx) requireProjectWorkDecision(action string) error {
if !c.mgr.projectWorkObservationEnabled || c.run.ProjectID == "" {
if !c.projectWorkObservationApplies() {
return nil
}
for {
@@ -408,7 +408,71 @@ func (m *Manager) cancelActiveOwnerRuns(owner string) {
cancel()
}
}
// 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, false
}
c.scratchMu.Lock()
host, known := c.scratchHost, c.scratchKnown
c.scratchMu.Unlock()
if known {
return host, true
}
host, err := c.mgr.st.IsScratchProjectID(string(c.run.ProjectID))
if err != nil {
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.confirmedScratchProject()
}
// authorizeProjectWork is the run-side project work gate. A scratch project is
// never known to the domain provider (RDM would always report false), so the
// stored ownership decides: its owner works without the provider, everyone else
// is NotFound. Ordinary projects keep the provider decision unchanged.
func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error {
if m.st != nil {
if handled, err := m.st.ScratchProjectDecision(string(id), owner); err != nil {
return err
} else if handled {
return nil
}
}
if m.projectWorkAuthorization == nil {
return nil
}
@@ -531,6 +595,11 @@ type runCtx struct {
launch *workflow.Launch
files *filestate.Tracker
permissionMode string
// scratchHost 缓存本次 run 的基座项目是否为临时会话(U2 隔离门禁);
// scratchKnown 为 false 表示还没判定成功过(读不到项目),不缓存未知答案。
scratchHost bool
scratchKnown bool
scratchMu sync.Mutex
// 材料整理(附件即保证):预启动说明注入第 0 回合;ensureMaterial 兜底幂等。
materialNote string
workContext string
@@ -807,11 +876,17 @@ func (c *runCtx) Injections() []runtime.ContextInjection {
if err != nil {
return nil
}
// 临时会话只带本会话自己的上下文条目:owner 级/项目级条目会把别的会话的
// 材料带进来,与“私有会话”相反。
scratch := c.scratchProject()
out := make([]runtime.ContextInjection, 0, len(list))
for _, e := range list {
if !e.Enabled {
continue
}
if scratch && e.ConversationID != c.run.ConversationID {
continue
}
out = append(out, runtime.ContextInjection{
Keys: e.Keys, Secondary: e.Secondary, Logic: e.Logic, Content: e.Content,
Constant: e.Constant, ScanDepth: e.ScanDepth, BudgetChars: e.BudgetChars,
@@ -824,6 +899,11 @@ func (c *runCtx) Memories() []core.MemoryEntry {
if c.mgr == nil || c.mgr.st == nil {
return nil
}
// 临时会话不读 user/project 级记忆:记忆是跨会话的,正好是被隔离的那一层。
// 会话内状态(SessionNote)不受影响。
if c.scratchProject() {
return nil
}
list, err := c.mgr.st.ListMemory(c.run.Owner, "", "")
if err != nil {
return nil
@@ -843,7 +923,7 @@ func (c *runCtx) Emit(typ core.StreamEventType, payload any) error {
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return err
}
if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" && (typ == core.EvMessageDelta || typ == core.EvThinkingDelta) {
if c.projectWorkObservationApplies() && (typ == core.EvMessageDelta || typ == core.EvThinkingDelta) {
// Project reply text is released only through the committed message after
// the complete run-source manifest receives a domain risk decision.
return nil
@@ -1237,8 +1317,13 @@ func indexTextOf(name, mime string, content []byte) string {
}
func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
// 临时会话没有可检索的共享知识:@knowledge 与 knowledge_search 一律空手
// 而非报错(会话里的附件仍以材料形式进入本回合)。
if c.scratchProject() {
return []core.RetrievalHit{}, nil
}
hits, err := c.retrieveRaw(q)
if err != nil || !c.mgr.projectWorkObservationEnabled || c.run.ProjectID == "" {
if err != nil || !c.projectWorkObservationApplies() {
return hits, err
}
for _, hit := range hits {
@@ -1364,7 +1449,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
if ct.ID != call.Name {
continue
}
if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" {
if c.projectWorkObservationApplies() {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "project command tool awaits trusted source preflight"}
}
var params map[string]any
@@ -1403,7 +1488,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
last := tc.arts[len(tc.arts)-1]
res.Artifact = &last
}
if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" {
if c.projectWorkObservationApplies() {
if call.CallID == "" {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "project tool result lacks a stable call ID"}
}
@@ -1494,7 +1579,11 @@ var (
)
// crossReadSelf 加载当前会话并校验项目闸门;出站检查由调用方按目标是否为自身决定。
// 临时会话直接关闭出站读取:它的材料不流向任何其他会话(U2)。
func (t *toolCtx) crossReadSelf() (core.Conversation, error) {
if t.rc.scratchProject() {
return core.Conversation{}, ErrCrossReadDisabled
}
if !t.rc.eff.GetBool("crossSession.read", false) {
return core.Conversation{}, ErrCrossReadDisabled
}
@@ -1505,6 +1594,20 @@ func (t *toolCtx) crossReadSelf() (core.Conversation, error) {
return self, nil
}
// scratchPeer reports whether a conversation lives in a scratch project. Peers are
// grouped by project so the trusted lookup runs once per distinct project.
func (t *toolCtx) scratchPeer(c core.Conversation, cache map[core.ID]bool) bool {
if seen, ok := cache[c.ProjectID]; ok {
return seen
}
scratch, err := t.rc.mgr.st.IsScratchProjectID(string(c.ProjectID))
if err != nil {
scratch = false
}
cache[c.ProjectID] = scratch
return scratch
}
func (t *toolCtx) PeerConversations() ([]core.Conversation, error) {
self, err := t.crossReadSelf()
if err != nil {
@@ -1517,12 +1620,17 @@ func (t *toolCtx) PeerConversations() ([]core.Conversation, error) {
if err != nil {
return nil, err
}
scratchProjects := map[core.ID]bool{}
out := make([]core.Conversation, 0, len(convs))
for _, c := range convs {
// 只读他人:排除自身;私有会话不出现(存在性本身是信息)。
if c.ID == self.ID || c.CrossPrivate {
continue
}
// 临时会话永不作为同伴出现:它不是共享材料,而是别人的私有会话。
if t.scratchPeer(c, scratchProjects) {
continue
}
out = append(out, c)
}
return out, nil
@@ -1548,6 +1656,10 @@ func (t *toolCtx) PeerConversationMessages(conversationID string, limit int) ([]
if !isSelf && !self.AllowCrossRead {
return nil, ErrCrossReadDisabled
}
// 临时会话的材料不出会话:即使同租户、即使未标私有也拒(自读除外)。
if !isSelf && t.scratchPeer(target, map[core.ID]bool{}) {
return nil, ErrCrossReadForbidden
}
if !isSelf && (target.CrossPrivate || target.ProjectID != t.rc.run.ProjectID) {
return nil, ErrCrossReadForbidden
}
+464
View File
@@ -0,0 +1,464 @@
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)
}
}
+46
View File
@@ -21,7 +21,24 @@ func (s *Server) lifecycleHost(owner string) extension.Host {
}
return s.projectHost(owner)
}
// scratchProjectWork 让临时会话的属主在没有 RDM provider 时也能工作。scratch 项目
// 从不进入 RDM:provider 只会报 Work=false,属主会被自己的临时会话挡住(U2 核心)。
// 所以门禁先走存储侧归属:属主放行且不再问 provider,非属主一律 NotFound(与
// 项目工作同一错误面)。handled=false 表示这不是临时会话,调用点照旧。
func (s *Server) scratchProjectWork(owner, projectID string) (handled bool, err error) {
if s.st == nil {
return false, nil
}
return s.st.ScratchProjectDecision(projectID, owner)
}
func (s *Server) projectWork(ctx context.Context, owner, projectID string) error {
if handled, err := s.scratchProjectWork(owner, projectID); err != nil {
return err
} else if handled {
return nil
}
if s.projectLifecycle == nil {
return nil
}
@@ -48,6 +65,12 @@ func (s *Server) projectWork(ctx context.Context, owner, projectID string) error
// With no resource authorizer registered (EXT-8b V2 absent) this is exactly
// projectWork, so un-extended hosts keep their current denial unchanged.
func (s *Server) projectRead(ctx context.Context, owner, projectID string) error {
if handled, err := s.scratchProjectWork(owner, projectID); err != nil {
return err
} else if handled {
// 临时会话只对属主可读:共享域回落永远不参与(它不是共享资源)。
return nil
}
if s.projectLifecycle == nil {
return nil
}
@@ -70,7 +93,12 @@ func (s *Server) projectRead(ctx context.Context, owner, projectID string) error
// createPrivateConversation is shared by the API and /new. Only live work
// authorization grants member creation; the resulting conversation stays owned.
// A scratch project already holds its one private conversation, so this shared
// path refuses a second one instead of handing out a normal session under it.
func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) {
if err := s.rejectScratchProjectConversation(projectID, owner); err != nil {
return core.Conversation{}, err
}
if s.projectLifecycle != nil {
if err := s.projectWork(ctx, owner, projectID); err != nil {
return core.Conversation{}, err
@@ -83,6 +111,24 @@ func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID
}
return c, err
}
// rejectScratchProjectConversation keeps the scratch 1:1 rule on the shared
// conversation-create path (POST /api/projects/{id}/conversations and /new).
// The lookup is the internal trusted one, and it is ownership-aware: a non-owner
// is not told anything about the session (projectWork answers NotFound), while
// the owner still gets the 1:1 refusal. Nothing here is returned to the caller
// for an unreadable project, which the store path decides as before.
func (s *Server) rejectScratchProjectConversation(projectID, owner string) error {
if s.st == nil {
return nil
}
handled, err := s.st.ScratchProjectDecision(projectID, owner)
if !handled || err != nil {
return nil
}
return store.ErrScratchSessionSingle
}
func writeProjectErr(w http.ResponseWriter, err error) {
var coded interface{ StatusCode() int }
if errors.As(err, &coded) {
+150
View File
@@ -0,0 +1,150 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"agent-system-backend/internal/store"
)
// 会话响应上的 scratch 标记(U2B):临时会话在任务列表、项目会话列表与详情里都
// 带 scratch=true,普通项目的会话没有这个键——前端据此把临时会话归到“无项目对话”,
// 不必再从项目列表反推。
// conversationRow 保留原始 JSON 对象:标记在不在键里都能看见。
type conversationRow map[string]any
func decodeConversationRows(t *testing.T, rec *httptest.ResponseRecorder) map[string]conversationRow {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("list conversations: %d %s", rec.Code, rec.Body.String())
}
var rows []conversationRow
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
t.Fatalf("decode conversations: %v body=%s", err, rec.Body.String())
}
byID := map[string]conversationRow{}
for _, row := range rows {
id, _ := row["id"].(string)
if id == "" {
t.Fatalf("conversation without id: %s", rec.Body.String())
}
byID[id] = row
}
return byID
}
// assertScratchFlag 校验一条会话:want=true 必须带 scratch=true;want=false 时
// 必须完全没有 scratch 键(普通项目的会话 JSON 与加标记之前同形)。
func assertScratchFlag(t *testing.T, where string, row conversationRow, want bool) {
t.Helper()
if row == nil {
t.Fatalf("%s: conversation missing from the response", where)
}
raw, ok := row["scratch"]
if !want {
if ok {
t.Fatalf("%s: non-scratch conversation carries scratch: %v", where, raw)
}
return
}
if flag, _ := raw.(bool); !ok || !flag {
t.Fatalf("%s: scratch conversation is not marked: %v", where, raw)
}
}
func createdConversationObject(t *testing.T, rec *httptest.ResponseRecorder) conversationRow {
t.Helper()
var body struct {
Conversation conversationRow `json:"conversation"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode scratch session: %v body=%s", err, rec.Body.String())
}
return body.Conversation
}
// scratchFlagIDs 是 fixture 造出来的两条会话(普通 + 临时)的 id 与项目 id。
type scratchFlagIDs struct {
normalID, normalProjectID string
scratchID, scratchProjectID string
}
// scratchFlagFixture 造一个普通项目的会话和一个临时会话。临时会话走 HTTP 创建,
// 因此创建响应上的标记也一并断言。
func scratchFlagFixture(t *testing.T, h http.Handler, st *store.Store, key string) scratchFlagIDs {
t.Helper()
project, err := st.CreateProjectAccountChecked("engineer", "普通项目", false)
if err != nil {
t.Fatal(err)
}
normal, err := st.CreateConversation("engineer", string(project.ID), "普通会话", "")
if err != nil {
t.Fatal(err)
}
created := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
if created.Code != http.StatusOK {
t.Fatalf("create scratch session: %d %s", created.Code, created.Body.String())
}
view := decodeScratchSessionHTTP(t, created)
if view.Project.ID == "" || view.Conversation.ID == "" {
t.Fatalf("incomplete scratch session: %s", created.Body.String())
}
assertScratchFlag(t, "POST /api/scratch-sessions", createdConversationObject(t, created), true)
return scratchFlagIDs{
normalID: string(normal.ID), normalProjectID: string(project.ID),
scratchID: view.Conversation.ID, scratchProjectID: view.Project.ID,
}
}
// TestScratchFlagOnConversationResponses 同一份 fixture 上验证三条读取路径:
// 任务列表、项目会话列表、会话详情——临时会话带 scratch=true,普通项目的会话
// 不带这个键。
func TestScratchFlagOnConversationResponses(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key := scratchAccountKey(t, st, "engineer")
ids := scratchFlagFixture(t, h, st, key)
tasks := decodeConversationRows(t, bearerRequest(h, "GET", "/api/conversations", "", key))
if len(tasks) != 2 {
t.Fatalf("task list must hold both conversations: %d %+v", len(tasks), tasks)
}
if tasks[ids.scratchID] == nil || tasks[ids.normalID] == nil {
t.Fatalf("task list does not cover both conversations: %+v", tasks)
}
assertScratchFlag(t, "GET /api/conversations (scratch)", tasks[ids.scratchID], true)
assertScratchFlag(t, "GET /api/conversations (normal)", tasks[ids.normalID], false)
scratchUnder := decodeConversationRows(t, bearerRequest(h, "GET", "/api/projects/"+ids.scratchProjectID+"/conversations", "", key))
normalUnder := decodeConversationRows(t, bearerRequest(h, "GET", "/api/projects/"+ids.normalProjectID+"/conversations", "", key))
assertScratchFlag(t, "GET /api/projects/{scratch}/conversations", scratchUnder[ids.scratchID], true)
assertScratchFlag(t, "GET /api/projects/{normal}/conversations", normalUnder[ids.normalID], false)
for _, tc := range []struct {
name string
id string
scratch bool
}{
{"scratch", ids.scratchID, true},
{"normal", ids.normalID, false},
} {
rec := bearerRequest(h, "GET", "/api/conversations/"+tc.id, "", key)
if rec.Code != http.StatusOK {
t.Fatalf("detail %s: %d %s", tc.name, rec.Code, rec.Body.String())
}
var body struct {
Conversation conversationRow `json:"conversation"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode detail %s: %v body=%s", tc.name, err, rec.Body.String())
}
if body.Conversation["id"] != tc.id {
t.Fatalf("detail %s returned %v", tc.name, body.Conversation["id"])
}
assertScratchFlag(t, "GET /api/conversations/{id} ("+tc.name+")", body.Conversation, tc.scratch)
}
}
+311
View File
@@ -0,0 +1,311 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// 临时会话隔离门禁(U2)。产品规则:scratch 会话是“只属于自己的通用代理会话”——
// 属主能像普通聊天一样用它,但它不共享任何文档/上下文,也不触达项目域能力。
// 每个门禁都成对验证:owner 放行、其他人/其它项目照旧。
// scratchGateFixture 建一个已登录的属主账号并落下一个临时会话。
func scratchGateFixture(t *testing.T, srv *Server, st *store.Store, owner string) (key, projectID, conversationID string) {
t.Helper()
key = scratchAccountKey(t, st, owner)
rec := bearerRequest(srv.Handler(), "POST", "/api/scratch-sessions", `{}`, key)
if rec.Code != http.StatusOK {
t.Fatalf("create scratch session: %d %s", rec.Code, rec.Body.String())
}
view := decodeScratchSessionHTTP(t, rec)
return key, view.Project.ID, view.Conversation.ID
}
// TestScratchOwnerWorksWithoutRDMProvider 是 U2 的核心:注册一个 Work 恒为 false
// 的 projectLifecycle(RDM 从未听说过 scratch 项目)后,属主仍能在自己的临时
// 会话里发消息,而别的账号读不到这个临时会话——即使共享授权适配器对所有人放行。
func TestScratchOwnerWorksWithoutRDMProvider(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
aliceKey, projectID, conversationID := scratchGateFixture(t, srv, st, "alice")
bobKey := scratchAccountKey(t, st, "bob")
providerCalls := 0
srv.SetProjectLifecycle(extension.ProjectLifecycle{
Work: func(context.Context, string, core.ID) (bool, error) {
providerCalls++
return false, nil // RDM 视角:没有这个项目
},
}, nil)
// 共享域适配器对任何人都放行(最宽松的现网配置),临时会话仍不可共享。
srv.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
srv.SetResourceAuthorizerV2(func(extension.ResourceAuthzQuery) (bool, error) { return true, nil })
rec := bearerRequest(h, "POST", "/api/conversations/"+conversationID+"/messages", `{"text":"帮我总结这段话"}`, aliceKey)
if rec.Code != http.StatusOK {
t.Fatalf("scratch owner could not chat without the provider: %d %s", rec.Code, rec.Body.String())
}
msgs, err := st.ListMessages(conversationID)
if err != nil || len(msgs) == 0 {
t.Fatalf("owner message not persisted: %+v %v", msgs, err)
}
if providerCalls != 0 {
t.Fatalf("scratch owner work asked the domain provider %d times", providerCalls)
}
// 负面:别的账号读不到这个临时会话(共享授权适配器一律放行也不作数)。
if rec := bearerRequest(h, "GET", "/api/projects/"+projectID+"/conversations", "", bobKey); rec.Code != http.StatusNotFound {
t.Fatalf("non-owner listed a scratch project: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "POST", "/api/projects/"+projectID+"/conversations", `{"title":"混入"}`, bobKey); rec.Code != http.StatusNotFound {
t.Fatalf("non-owner created a conversation under scratch: %d %s", rec.Code, rec.Body.String())
}
if err := srv.projectRead(context.Background(), "bob", projectID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("projectRead opened a scratch project for a non-owner: %v", err)
}
if err := srv.projectWork(context.Background(), "bob", projectID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("projectWork opened a scratch project for a non-owner: %v", err)
}
// 属主的读面不受共享适配器影响(存储归属即可)。
if err := srv.projectRead(context.Background(), "alice", projectID); err != nil {
t.Fatalf("scratch owner projectRead: %v", err)
}
// 普通项目不受影响:provider 说 false 就是 false。
normal, err := st.CreateProject("alice", "普通项目")
if err != nil {
t.Fatal(err)
}
if err := srv.projectWork(context.Background(), "alice", string(normal.ID)); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("normal project bypassed the provider decision: %v", err)
}
}
// TestScratchHasNoKnowledgeBase 知识库是项目域能力:入库与检索两个面都 400。
func TestScratchHasNoKnowledgeBase(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, projectID, _ := scratchGateFixture(t, srv, st, "alice")
grantProjectCreateFixture(t, st, "alice")
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
doc := `[{"title":"手动文档","chunk":"知识库内容"}]`
for name, path := range map[string]string{"scratch": projectID, "normal": normal} {
rec := bearerRequest(h, "POST", "/api/projects/"+path+"/knowledge", doc, key)
switch name {
case "scratch":
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no knowledge base") {
t.Fatalf("addKnowledge on scratch = %d %s", rec.Code, rec.Body.String())
}
case "normal":
if rec.Code != http.StatusOK {
t.Fatalf("addKnowledge on a normal project = %d %s", rec.Code, rec.Body.String())
}
}
}
// 被拒的写入没有落库。
if docs, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: projectID, Text: "手动文档", TopK: 10}); err != nil || len(docs) != 0 {
t.Fatalf("scratch project kept knowledge rows: %+v %v", docs, err)
}
rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库&project="+projectID, "", key)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no knowledge base") {
t.Fatalf("searchKnowledge on scratch = %d %s", rec.Code, rec.Body.String())
}
// 正面:普通项目仍然检索得到自己刚入库的文档。
if rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库&project="+normal, "", key); rec.Code != http.StatusOK {
t.Fatalf("searchKnowledge on a normal project = %d %s", rec.Code, rec.Body.String())
}
// 无 project 参数的全局检索不受门禁影响(它本来就不落在临时会话上)。
if rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库", "", key); rec.Code != http.StatusOK {
t.Fatalf("global search = %d %s", rec.Code, rec.Body.String())
}
}
// TestScratchUploadStaysOutOfKnowledgeIndex 上传仍可用(对话附件),但临时会话的
// 附件不进任何知识索引;普通项目的上传照旧入库。
func TestScratchUploadStaysOutOfKnowledgeIndex(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, projectID, _ := scratchGateFixture(t, srv, st, "alice")
grantProjectCreateFixture(t, st, "alice")
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
upload := func(projectID, name, text string) string {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte(text)); err != nil {
t.Fatal(err)
}
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+projectID+"/uploads", &buf)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("upload to %s: %d %s", projectID, rec.Code, rec.Body.String())
}
var up struct {
ArtifactID string `json:"artifactId"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil {
t.Fatal(err)
}
return up.ArtifactID
}
scratchArtifact := upload(projectID, "临时附件.txt", "临时会话的附件正文")
if scratchArtifact == "" {
t.Fatal("scratch upload returned no artifact")
}
upload(normal, "普通附件.txt", "普通项目的附件正文")
scratchHits, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: projectID, Text: "附件", TopK: 10})
if err != nil {
t.Fatal(err)
}
for _, hit := range scratchHits {
if string(hit.Doc.ProjectID) == projectID || hit.Doc.Ref == scratchArtifact {
t.Fatalf("scratch upload was indexed: %+v", hit.Doc)
}
}
normalHits, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: normal, Text: "附件", TopK: 10})
if err != nil || len(normalHits) == 0 {
t.Fatalf("normal project upload lost indexing: %+v %v", normalHits, err)
}
}
// TestScratchRejectsDomainCommands 临时会话里 rdm.* 命令 403;不碰项目域的内置
// 命令照旧可用(它就是普通聊天能力)。
func TestScratchRejectsDomainCommands(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, projectID, conversationID := scratchGateFixture(t, srv, st, "alice")
grantProjectCreateFixture(t, st, "alice")
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
normalConv := createdProjectID(t, bearerRequest(h, "POST", "/api/projects/"+normal+"/conversations", `{"title":"会话"}`, key))
srv.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() })
srv.WireBuiltinCommands()
if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"}, func(ctx context.Context, req cmdbus.Request) (any, error) {
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.list", Title: "需求列表"}, func(ctx context.Context, req cmdbus.Request) (any, error) {
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
// API 入口(POST /api/commands/{id}):临时会话宿主一律 403。
for _, id := range []string{"rdm.reqs.add", "rdm.reqs.list"} {
rec := bearerRequest(h, "POST", "/api/commands/"+id, `{"conversationId":"`+conversationID+`"}`, key)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "not available in scratch sessions") {
t.Fatalf("%s on a scratch host = %d %s", id, rec.Code, rec.Body.String())
}
}
// 正面:读侧命令在普通项目宿主上照旧分发。
if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.list", `{"conversationId":"`+normalConv+`"}`, key); rec.Code != http.StatusOK {
t.Fatalf("rdm.reqs.list on a normal host = %d %s", rec.Code, rec.Body.String())
}
// 没有会话就没有基座项目:门禁不适用,命令走原路径(正式项目命令仍由总线预检拒绝)。
if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.list", `{}`, key); rec.Code != http.StatusOK {
t.Fatalf("command without a host project = %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.add", `{}`, key); rec.Code != http.StatusBadRequest {
t.Fatalf("hostless formal command must keep the bus preflight answer: %d %s", rec.Code, rec.Body.String())
}
// 内置会话命令在临时会话里照旧可用(它不碰项目域)。
rec := bearerRequest(h, "POST", "/api/conversations/"+conversationID+"/messages", `{"text":"/help"}`, key)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "command") {
t.Fatalf("builtin host command in a scratch session = %d %s", rec.Code, rec.Body.String())
}
if !store.IsScratchProject(mustScratchSettings(t, st, projectID)) {
t.Fatalf("scratch marker lost during command gating: %s", projectID)
}
}
func mustScratchSettings(t *testing.T, st *store.Store, projectID string) map[string]any {
t.Helper()
p, err := st.ProjectMetadata(projectID)
if err != nil {
t.Fatal(err)
}
return p.Settings
}
// TestScratchProjectPatchAllowsNameOnly 临时会话只允许改名;任何设置写入 400。
func TestScratchProjectPatchAllowsNameOnly(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, projectID, _ := scratchGateFixture(t, srv, st, "alice")
grantProjectCreateFixture(t, st, "alice")
if rec := bearerRequest(h, "PATCH", "/api/projects/"+projectID, `{"name":"改个名字"}`, key); rec.Code != http.StatusOK {
t.Fatalf("rename scratch project = %d %s", rec.Code, rec.Body.String())
}
if p, err := st.ProjectMetadata(projectID); err != nil || p.Name != "改个名字" {
t.Fatalf("scratch project not renamed: %+v %v", p, err)
}
for _, body := range []string{`{"settings":{"note":"任何设置"}}`, `{"settings":{"crossSession":{"read":true}}}`, `{"settings":{"host":{"kind":"normal"}}}`} {
rec := bearerRequest(h, "PATCH", "/api/projects/"+projectID, body, key)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no project settings") {
t.Fatalf("scratch settings patch %s = %d %s", body, rec.Code, rec.Body.String())
}
}
if !store.IsScratchProject(mustScratchSettings(t, st, projectID)) {
t.Fatal("refused patch changed the scratch marker")
}
// 正面:普通项目的设置写入照旧放行。
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
if rec := bearerRequest(h, "PATCH", "/api/projects/"+normal, `{"settings":{"note":"普通"}}`, key); rec.Code != http.StatusOK {
t.Fatalf("normal settings patch = %d %s", rec.Code, rec.Body.String())
}
}
// TestDeletingScratchConversationRemovesScratchProject 删会话即删临时会话外壳;
// 普通项目删会话仍保留项目。
func TestDeletingScratchConversationRemovesScratchProject(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, projectID, conversationID := scratchGateFixture(t, srv, st, "alice")
grantProjectCreateFixture(t, st, "alice")
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
normalConv := createdProjectID(t, bearerRequest(h, "POST", "/api/projects/"+normal+"/conversations", `{"title":"会话"}`, key))
bobKey := scratchAccountKey(t, st, "bob")
if rec := bearerRequest(h, "DELETE", "/api/conversations/"+conversationID, "", bobKey); rec.Code == http.StatusOK {
t.Fatalf("non-owner deleted a scratch conversation: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "DELETE", "/api/conversations/"+conversationID, "", key); rec.Code != http.StatusOK {
t.Fatalf("delete scratch conversation = %d %s", rec.Code, rec.Body.String())
}
if _, err := st.ProjectMetadata(projectID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("scratch project survived its conversation: %v", err)
}
if rec := bearerRequest(h, "DELETE", "/api/conversations/"+normalConv, "", key); rec.Code != http.StatusOK {
t.Fatalf("delete normal conversation = %d %s", rec.Code, rec.Body.String())
}
if _, err := st.ProjectMetadata(normal); err != nil {
t.Fatalf("normal project removed with its conversation: %v", err)
}
}
+118
View File
@@ -0,0 +1,118 @@
package server
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// 临时会话入口(U1):POST /api/scratch-sessions。
//
// 能力是内生的——任何已启用账号都能建,不需要 project.create,也不在权限目录
// /replaceRolePermissions 里出现为可配置项。项目与会话由 store 在一个事务里
// 写出(scratch 标记 + cross_private 会话),不经过 RDM/projectLifecycle。
// scratchSessionView 是创建结果:调用方同时拿到 scratch 项目与它唯一的会话。
// 会话走 server 本地的 conversationView:刚建出来的会话必然属于 scratch 项目,
// 标记直接置真,不必回查。
type scratchSessionView struct {
Project core.Project `json:"project"`
Conversation conversationView `json:"conversation"`
}
func (s *Server) createScratchSession(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Title string `json:"title"`
ProfileID string `json:"profileId"`
Settings map[string]any `json:"settings"`
}
// 请求体可省略:空体即默认名临时会话。非空体必须是单一 JSON 对象。
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
http.Error(w, "invalid scratch session request", http.StatusBadRequest)
return
}
// 标记归服务端所有:客户端不能带 settings 上来(哪怕是别的键),
// 否则“scratch 标记”会变成一个客户端可写的项目字段。
if req.Settings != nil {
http.Error(w, "scratch host marker is server-owned", http.StatusBadRequest)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = store.ScratchDefaultProjectName
}
title := strings.TrimSpace(req.Title)
if title == "" {
title = "新会话"
}
// 与其它 create 路径一致:认证关闭(嵌入式档)走默认租户,认证开启时由
// store 的账号有效性边界拒绝停用账号——scratch 不是绕过账号状态的旁路。
project, conversation, err := s.st.CreateScratchSessionAccountChecked(
s.owner(r), name, title, strings.TrimSpace(req.ProfileID), s.Auth == nil || !s.Auth.Enabled)
if err != nil {
writeStoreErr(w, err)
return
}
// 状态码随同类创建入口(POST /api/projects、/api/projects/{id}/conversations):
// 200 配 writeJSON,而不是 201。
writeJSON(w, http.StatusOK, scratchSessionView{
Project: project,
Conversation: conversationView{Conversation: conversation, Scratch: true},
})
}
// ---- U2:隔离门禁 ----
//
// 临时会话是“只属于自己的通用代理会话”:没有知识库、没有项目上下文、不与任何
// 会话共享材料。这些门禁只认存储里的 host 标记(store.IsScratchProject),调用点
// 不自己比对标记字符串。
const (
// scratchNoKnowledge 知识库是项目域能力,临时会话一律没有。
scratchNoKnowledge = "scratch sessions have no knowledge base"
// scratchNoSettings 临时会话只有会话,没有可写的项目设置。
scratchNoSettings = "scratch sessions have no project settings"
// scratchNoCommand 项目域命令(RDM 及其它需要基座项目的命令)在临时会话不可用。
scratchNoCommand = "not available in scratch sessions"
)
// scratchProjectOf reports whether projectID is the caller's own scratch project.
// Other owners' projects (and unknown ids) fall through to the normal decision
// path, so the gate never leaks the existence of someone else's scratch session.
func (s *Server) scratchProjectOf(projectID, owner string) bool {
if s.st == nil {
return false
}
handled, err := s.st.ScratchProjectDecision(projectID, owner)
return handled && err == nil
}
// refuseScratchProjectCapability 门禁拒绝:只对调用者自己的临时会话生效。
func (s *Server) refuseScratchProjectCapability(w http.ResponseWriter, projectID, owner, message string) bool {
if !s.scratchProjectOf(projectID, owner) {
return false
}
http.Error(w, message, http.StatusBadRequest)
return true
}
// refuseScratchCommandHost 命令门禁(403):临时会话里不得分发需要基座项目的命令。
// rdm.* 全族与正式项目命令(cmdbus.RequiresProjectWorkDecision)都不行;内置的
// 会话类命令照旧可用。
func (s *Server) refuseScratchCommandHost(w http.ResponseWriter, hostProjectID, owner, commandID string) bool {
if !s.scratchProjectOf(hostProjectID, owner) {
return false
}
if !strings.HasPrefix(commandID, "rdm.") && !cmdbus.RequiresProjectWorkDecision(commandID) {
return false
}
http.Error(w, scratchNoCommand, http.StatusForbidden)
return true
}
+311
View File
@@ -0,0 +1,311 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"agent-system-backend/internal/authz/catalog"
"agent-system-backend/internal/store"
)
// 临时会话(U1)HTTP 契约:POST /api/scratch-sessions 对任何已启用账号内生可用,
// 不经 project.create;scratch 项目不进项目列表但会话仍出现在任务列表;1:1、
// 标记只归服务端、目录里没有可配置动作。
type scratchSessionHTTPView struct {
Project struct {
ID string `json:"id"`
Name string `json:"name"`
Settings map[string]any `json:"settings"`
} `json:"project"`
Conversation struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Title string `json:"title"`
CrossPrivate bool `json:"crossPrivate"`
AllowCrossRead bool `json:"allowCrossRead"`
} `json:"conversation"`
}
func decodeScratchSessionHTTP(t *testing.T, rec *httptest.ResponseRecorder) scratchSessionHTTPView {
t.Helper()
var view scratchSessionHTTPView
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
t.Fatalf("decode scratch session: %v body=%s", err, rec.Body.String())
}
return view
}
func projectIDsOf(t *testing.T, rec *httptest.ResponseRecorder) []string {
t.Helper()
var rows []struct {
ID string `json:"id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
t.Fatalf("decode projects: %v body=%s", err, rec.Body.String())
}
out := make([]string, 0, len(rows))
for _, row := range rows {
out = append(out, row.ID)
}
return out
}
func conversationIDsOf(t *testing.T, rec *httptest.ResponseRecorder) []string {
t.Helper()
var rows []struct {
ID string `json:"id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
t.Fatalf("decode conversations: %v body=%s", err, rec.Body.String())
}
out := make([]string, 0, len(rows))
for _, row := range rows {
out = append(out, row.ID)
}
return out
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func createdProjectID(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var project struct {
ID string `json:"id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &project); err != nil || project.ID == "" {
t.Fatalf("decode project: %v body=%s", err, rec.Body.String())
}
return project.ID
}
// scratchAccountKey 造一个没有密码的普通账号并签发 API key(与 role_authorization_test 同款)。
func scratchAccountKey(t *testing.T, st *store.Store, owner string) string {
t.Helper()
if err := st.CreateAccount(owner, owner, "", false); err != nil {
t.Fatal(err)
}
key, err := st.CreateAPIKey(owner, owner+"-scratch")
if err != nil {
t.Fatal(err)
}
return key
}
// TestScratchSessionNeedsNoProjectCreatePermission 是 U1 的核心契约:没有
// project.create 的已启用账号照样能建临时会话,而同一个账号仍然建不了普通项目。
// 状态码沿用同类创建入口的 200(writeJSON),不是 201。
func TestScratchSessionNeedsNoProjectCreatePermission(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key := scratchAccountKey(t, st, "engineer")
if allowed, _, reason := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action=project.create", "", key)); allowed || reason != "not-granted" {
t.Fatalf("fixture must not hold project.create: allowed=%v reason=%s", allowed, reason)
}
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key); rec.Code != http.StatusForbidden {
t.Fatalf("project created without project.create: %d %s", rec.Code, rec.Body.String())
}
rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
if rec.Code != http.StatusOK {
t.Fatalf("scratch session denied without project.create: %d %s", rec.Code, rec.Body.String())
}
view := decodeScratchSessionHTTP(t, rec)
if !store.IsScratchProject(view.Project.Settings) || view.Project.ID == "" {
t.Fatalf("scratch project marker missing: %s", rec.Body.String())
}
if view.Project.Name == "" || view.Conversation.ID == "" || view.Conversation.ProjectID != view.Project.ID {
t.Fatalf("incomplete scratch session: %s", rec.Body.String())
}
if !view.Conversation.CrossPrivate || !view.Conversation.AllowCrossRead {
t.Fatalf("scratch conversation flags: %s", rec.Body.String())
}
// 项目列表里没有 scratch,会话列表与项目会话列表里都有它。
projects := bearerRequest(h, "GET", "/api/projects", "", key)
if projects.Code != http.StatusOK || containsString(projectIDsOf(t, projects), view.Project.ID) {
t.Fatalf("scratch project listed as a normal project: %d %s", projects.Code, projects.Body.String())
}
tasks := bearerRequest(h, "GET", "/api/conversations", "", key)
if tasks.Code != http.StatusOK || !containsString(conversationIDsOf(t, tasks), view.Conversation.ID) {
t.Fatalf("scratch conversation missing from task list: %d %s", tasks.Code, tasks.Body.String())
}
under := bearerRequest(h, "GET", "/api/projects/"+view.Project.ID+"/conversations", "", key)
if under.Code != http.StatusOK || len(conversationIDsOf(t, under)) != 1 {
t.Fatalf("scratch project conversations: %d %s", under.Code, under.Body.String())
}
// 1:1:第二个会话被拒;普通项目下的会话创建不受影响。
second := bearerRequest(h, "POST", "/api/projects/"+view.Project.ID+"/conversations", `{"title":"第二个"}`, key)
if second.Code != http.StatusConflict || !strings.Contains(second.Body.String(), "scratch") {
t.Fatalf("second conversation under scratch project: %d %s", second.Code, second.Body.String())
}
after := bearerRequest(h, "GET", "/api/projects/"+view.Project.ID+"/conversations", "", key)
if len(conversationIDsOf(t, after)) != 1 {
t.Fatalf("refused create still wrote a conversation: %s", after.Body.String())
}
normal, err := st.CreateProjectAccountChecked("engineer", "普通项目", false)
if err != nil {
t.Fatal(err)
}
if rec := bearerRequest(h, "POST", "/api/projects/"+string(normal.ID)+"/conversations", `{"title":"普通会话"}`, key); rec.Code != http.StatusOK {
t.Fatalf("normal project lost conversation create: %d %s", rec.Code, rec.Body.String())
}
}
// TestScratchSessionCreateForProjectCreateHolder 覆盖另一侧:已经持有
// project.create 的账号走同一条内生路径,不需要额外的开关。
func TestScratchSessionCreateForProjectCreateHolder(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
superAdmin := bootSuperAdmin(t, h, st)
key := scratchAccountKey(t, st, "creator")
createAssignedRoleFixture(t, h, st, superAdmin, "creator", "项目创建者", true)
if allowed, source, _ := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action=project.create", "", key)); !allowed || source != "role" {
t.Fatalf("fixture must hold project.create: allowed=%v source=%s", allowed, source)
}
rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{"name":"方案草稿","title":"临时会话"}`, key)
if rec.Code != http.StatusOK {
t.Fatalf("scratch session denied for project creator: %d %s", rec.Code, rec.Body.String())
}
view := decodeScratchSessionHTTP(t, rec)
if !store.IsScratchProject(view.Project.Settings) || view.Project.Name != "方案草稿" || view.Conversation.Title != "临时会话" {
t.Fatalf("scratch session body: %s", rec.Body.String())
}
// 显式空体同样可用(默认名)。
if rec := bearerRequest(h, "POST", "/api/scratch-sessions", ``, key); rec.Code != http.StatusOK {
t.Fatalf("empty scratch body: %d %s", rec.Code, rec.Body.String())
}
}
// TestScratchSessionCreateRejectsDisabledAccount 停用账号既进不来(凭证被撤),
// 也不会绕过账号状态写出项目。
func TestScratchSessionCreateRejectsDisabledAccount(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
key := scratchAccountKey(t, st, "disabled")
account, err := st.GetAccountByOwner("disabled")
if err != nil {
t.Fatal(err)
}
state := do(h, "PUT", "/api/admin/users/"+account.ID+"/state",
`{"expectedStateVersion":`+strconv.FormatInt(account.StateVersion, 10)+`,"enabled":false}`, admin)
if state.Code != http.StatusOK {
t.Fatalf("disable account: %d %s", state.Code, state.Body.String())
}
rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
// 停用会消费已有凭证,所以请求在认证中间件就被拒(401);即使凭证仍在,
// store 的账号有效性边界也会返回 403——两条路径都不写项目。
if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
t.Fatalf("disabled account scratch create: unexpected status %d %s", rec.Code, rec.Body.String())
}
if rec.Code == http.StatusUnauthorized && !strings.Contains(rec.Body.String(), "revoked") {
t.Fatalf("disabled account scratch create: %d %s", rec.Code, rec.Body.String())
}
if _, _, err := st.CreateScratchSessionAccountChecked("disabled", "临时会话", "临时会话", "", false); err == nil {
t.Fatal("disabled account reached the scratch create past the account fence")
}
if projects, err := st.ListProjects("disabled"); err != nil || len(projects) != 0 {
t.Fatalf("disabled account left a project row: %+v %v", projects, err)
}
}
// TestScratchMarkerIsServerOwned 客户端既不能在普通项目上加标记,也不能从
// scratch 项目上摘掉,更不能借 POST /api/projects 直接造一个 scratch。
func TestScratchMarkerIsServerOwned(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
superAdmin := bootSuperAdmin(t, h, st)
key := scratchAccountKey(t, st, "creator")
createAssignedRoleFixture(t, h, st, superAdmin, "creator", "项目创建者", true)
normal := bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)
if normal.Code != http.StatusOK {
t.Fatalf("create project: %d %s", normal.Code, normal.Body.String())
}
normalID := createdProjectID(t, normal)
scratch := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
if scratch.Code != http.StatusOK {
t.Fatalf("create scratch: %d %s", scratch.Code, scratch.Body.String())
}
scratchID := decodeScratchSessionHTTP(t, scratch).Project.ID
if rec := bearerRequest(h, "PATCH", "/api/projects/"+normalID, `{"settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest {
t.Fatalf("patch added the scratch marker: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "PATCH", "/api/projects/"+scratchID, `{"settings":{"host":{"kind":"normal"}}}`, key); rec.Code != http.StatusBadRequest {
t.Fatalf("patch cleared the scratch marker: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"伪 scratch","settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest {
t.Fatalf("POST /api/projects created a scratch: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{"settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest {
t.Fatalf("scratch endpoint accepted client settings: %d %s", rec.Code, rec.Body.String())
}
// 不改标记的普通设置写入照旧放行。
if rec := bearerRequest(h, "PATCH", "/api/projects/"+normalID, `{"settings":{"note":"保留"}}`, key); rec.Code != http.StatusOK {
t.Fatalf("unrelated settings patch refused: %d %s", rec.Code, rec.Body.String())
}
// 被拒的写入没有落库:普通项目仍然不是 scratch。
stored, err := st.GetProject("creator", normalID)
if err != nil || store.IsScratchProject(stored.Settings) {
t.Fatalf("rejected patch changed the marker: %+v %v", stored.Settings, err)
}
}
// TestScratchIsNotAConfigurablePermission 权限目录与 replaceRolePermissions
// 都没有 scratch 创建动作:能力内生,不可授予也不可撤销。
func TestScratchIsNotAConfigurablePermission(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
viewerKey := scratchAccountKey(t, st, "viewer")
rec := do(h, "GET", "/api/admin/permissions/catalog", "", admin)
if rec.Code != http.StatusOK {
t.Fatalf("catalog: %d %s", rec.Code, rec.Body.String())
}
for _, descriptor := range catalog.Catalog() {
if strings.Contains(strings.ToLower(descriptor.ActionID), "scratch") {
t.Fatalf("catalog exposes a scratch action: %s", descriptor.ActionID)
}
}
if strings.Contains(rec.Body.String(), "scratch") {
t.Fatalf("catalog body mentions scratch: %s", rec.Body.String())
}
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"临时会话职位"}`, admin)
if roleRec.Code != http.StatusOK {
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
}
role := decodeRoleHTTP(t, roleRec)
for _, actionID := range []string{"project.scratch.create", "scratch.create", "scratch.session.create"} {
body := `{"expectedRoleVersion":1,"idempotencyKey":"scratch-action","changes":[{"actionId":"` + actionID + `","allowed":true,"capAllowed":true}]}`
if rec := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions", body, admin); rec.Code != http.StatusBadRequest {
t.Fatalf("role policy accepted %s: %d %s", actionID, rec.Code, rec.Body.String())
}
}
view := do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin)
if strings.Contains(view.Body.String(), "scratch") {
t.Fatalf("role view mentions scratch: %s", view.Body.String())
}
me := bearerRequest(h, "GET", "/api/me", "", viewerKey)
if me.Code != http.StatusOK || strings.Contains(me.Body.String(), "scratch") {
t.Fatalf("me mentions a scratch capability: %d %s", me.Code, me.Body.String())
}
}
+99 -6
View File
@@ -140,6 +140,9 @@ func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action
// Owner/ProjectID 从受信存储元数据解析;出错/字段缺失一律 false,fail-closed
// 且不泄露存在性);V2 未注册回落 authorizeSharedProject(现行兼容)。
func (s *Server) authorizeSharedResource(actor, action, resourceType string, resourceID, owner, projectID core.ID) bool {
if s.sharedScratchDenied(actor, projectID) {
return false
}
if s.ProjectAuthzV2 == nil {
return s.authorizeSharedProject(actor, projectID)
}
@@ -164,10 +167,24 @@ func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool {
if s.ProjectAuthz == nil {
return false
}
if s.sharedScratchDenied(owner, projectID) {
return false
}
ok, err := s.ProjectAuthz(owner, projectID)
return err == nil && ok
}
// sharedScratchDenied 保证共享域授权永远打不开临时会话:临时会话是私有的
// 1:1 会话外壳,不是共享资源,EXT-8/EXT-8b 适配器不得为他人授予它。属主
// 不受影响(属主的读写按存储归属判定)。
func (s *Server) sharedScratchDenied(actor string, projectID core.ID) bool {
if s.st == nil || projectID == "" {
return false
}
handled, err := s.st.ScratchProjectDecision(string(projectID), actor)
return handled && err != nil
}
func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.Service, s *settings.Service, ph *plugins.Host) *Server {
sub, err := web.Dist()
if err != nil {
@@ -337,6 +354,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/capability/explain", s.explainCapability)
// ---- 项目 / 会话 ----
mux.HandleFunc("POST /api/scratch-sessions", s.createScratchSession)
mux.HandleFunc("GET /api/projects", s.listProjects)
mux.HandleFunc("POST /api/projects", s.createProject)
mux.HandleFunc("PATCH /api/projects/{id}", s.patchProject)
@@ -716,6 +734,10 @@ func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
}
out := make([]projectView, 0, len(ps))
for _, p := range ps {
// scratch 项目不作为普通项目出现(provider 列表同样过滤)。
if store.IsScratchProject(p.Settings) {
continue
}
out = append(out, projectView{Project: s.publicProjectMetadata(p), Shared: p.Owner != owner})
}
writeJSON(w, http.StatusOK, out)
@@ -754,12 +776,18 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
Name string `json:"name"`
Name string `json:"name"`
Settings map[string]any `json:"settings"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
// 普通项目创建路径不能造 scratch:标记只由 POST /api/scratch-sessions 写。
if store.IsScratchProject(req.Settings) {
http.Error(w, "scratch sessions are created by POST /api/scratch-sessions", http.StatusBadRequest)
return
}
if s.projectLifecycle != nil {
if s.projectLifecycle.Create == nil {
writeProjectErr(w, store.ErrNotFound)
@@ -775,6 +803,7 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
owner := s.owner(r)
var req struct {
Name *string `json:"name"`
Settings map[string]any `json:"settings"`
@@ -783,6 +812,11 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// 临时会话只有会话:除了改名没有任何项目设置可写(标记保护之外再加一道)。
if req.Settings != nil && s.scratchProjectOf(id, owner) {
http.Error(w, scratchNoSettings, http.StatusBadRequest)
return
}
if req.Settings != nil {
values, err := s.set.PrepareProjectMetadataWrite(req.Settings)
if err != nil {
@@ -867,6 +901,37 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
writeOKOr(w, s.st.DeleteProjectAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
}
// conversationView 是下发给前端的会话视图:core.Conversation 原样透出,外加一个
// 只读的 scratch 标记,供前端把临时会话归到“无项目对话”,不用再从项目列表反推。
// core.Conversation 在另一个仓库,所以标记只存在于这份 server 本地视图里。
// 标记 omitempty:普通项目的会话 JSON 与加标记之前逐字节同形。
type conversationView struct {
core.Conversation
Scratch bool `json:"scratch,omitempty"`
}
// scratchProjectSet 读出 owner 的 scratch 项目 id 集合;读不到时按空集处理——
// 标记只是列表上的附加信息,取不到不改变列表本身(与详情里的辅助查询同款取舍)。
func (s *Server) scratchProjectSet(owner string) map[string]bool {
if s.st == nil {
return map[string]bool{}
}
ids, err := s.st.ScratchProjectIDs(owner)
if err != nil {
return map[string]bool{}
}
return ids
}
// conversationViews 给一批会话打 scratch 标记;返回的每一项与 store 的会话一一对应。
func (s *Server) conversationViews(scratch map[string]bool, cs []core.Conversation) []conversationView {
out := make([]conversationView, 0, len(cs))
for _, c := range cs {
out = append(out, conversationView{Conversation: c, Scratch: scratch[string(c.ProjectID)]})
}
return out
}
func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
if s.projectLifecycle != nil {
@@ -882,14 +947,15 @@ func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
return
}
cs, err := s.st.ListConversations(owner, r.PathValue("id"))
writeJSONOr(w, cs, err)
writeJSONOr(w, s.conversationViews(s.scratchProjectSet(owner), cs), err)
}
// listTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档,?archived=1 含归档。
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
v := r.URL.Query().Get("archived")
cs, err := s.st.ListTasks(s.owner(r), v == "1" || v == "true")
writeJSONOr(w, cs, err)
owner := s.owner(r)
cs, err := s.st.ListTasks(owner, v == "1" || v == "true")
writeJSONOr(w, s.conversationViews(s.scratchProjectSet(owner), cs), err)
}
// me 当前身份(阶段 1:API key 即身份,user 即租户 owner;嵌入式档 owner 为默认租户)。
@@ -955,8 +1021,9 @@ func (s *Server) conversationDetail(w http.ResponseWriter, r *http.Request) {
resumeSeq = seq - 1
}
}
view := conversationView{Conversation: conv, Scratch: s.scratchProjectSet(owner)[string(conv.ProjectID)]}
writeJSON(w, http.StatusOK, map[string]any{
"conversation": conv, "messages": msgs, "runs": runs,
"conversation": view, "messages": msgs, "runs": runs,
"activeHitl": cards, "lastSeq": lastSeq, "resumeSeq": resumeSeq,
})
}
@@ -1309,6 +1376,10 @@ func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request, conv core
http.Error(w, "未知命令 "+slash+"(/help 查看)", http.StatusBadRequest)
return
}
// 临时会话里没有项目域:rdm.* 与正式项目命令一律 403。
if s.refuseScratchCommandHost(w, string(conv.ProjectID), s.owner(r), id) {
return
}
params, err := parseSlashParams(id, fields[1:])
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -1392,6 +1463,10 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
}
}
}
// 基座项目是临时会话时,项目域命令一律 403(无会话上下文的命令不在此门)。
if s.refuseScratchCommandHost(w, hostProject, s.owner(r), commandID) {
return
}
dispatch := cmdbus.Request{
ID: commandID, Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r),
@@ -1732,7 +1807,9 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
}
// 文本可解析的进检索索引(管线分块+全文+可选向量)。
// EXT-9:宿主关闭自动索引时跳过(发布决定移交下游命令层;原件与摄取卡不变)。
if !s.DisableUploadIndexing {
// 临时会话只有会话:附件进入对话,但不进任何知识索引(否则会话材料会
// 漏进别的会话的检索结果)。
if !s.DisableUploadIndexing && !s.scratchProjectOf(projectID, owner) {
if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
var indexErr error
if s.Indexer != nil {
@@ -2312,6 +2389,10 @@ func (s *Server) addKnowledge(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
// 知识库是项目域能力:临时会话不接收任何文档入库。
if s.refuseScratchProjectCapability(w, r.PathValue("id"), owner, scratchNoKnowledge) {
return
}
for i := range docs {
docs[i].Owner = owner
docs[i].ProjectID = r.PathValue("id")
@@ -2331,6 +2412,10 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) {
topK, _ := strconv.Atoi(r.URL.Query().Get("topK"))
query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK}
owner := s.owner(r)
// 检索面同样先问门禁:临时会话没有知识库(而不是“搜出来是空的”)。
if s.refuseScratchProjectCapability(w, query.ProjectID, owner, scratchNoKnowledge) {
return
}
if s.projectLifecycle != nil && query.ProjectID != "" {
id, err := projectscope.Resolve(r.Context(), s.lifecycleHost(owner), *s.projectLifecycle, query.ProjectID)
if err != nil {
@@ -2438,6 +2523,14 @@ func writeOKOr(w http.ResponseWriter, err error) {
}
func writeStoreErr(w http.ResponseWriter, err error) {
if errors.Is(err, store.ErrScratchMarkerReserved) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.Is(err, store.ErrScratchSessionSingle) {
http.Error(w, err.Error(), http.StatusConflict)
return
}
if errors.Is(err, store.ErrAccountForbidden) {
http.Error(w, err.Error(), http.StatusForbidden)
return
+52 -17
View File
@@ -19,17 +19,34 @@ func (s *Store) CreateProject(owner, name string) (core.Project, error) {
return p, err
}
// ListProjects 不含 scratch 项目:临时会话不作为普通项目出现在列表里。
func (s *Store) ListProjects(owner string) ([]core.Project, error) {
rows, err := s.db.Query(s.q(`SELECT id,name,settings,created_at,updated_at FROM projects WHERE owner=? ORDER BY updated_at DESC`), owner)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProjects(rows)
all, err := scanProjects(rows)
if err != nil {
return nil, err
}
return withoutScratchProjects(all), nil
}
func withoutScratchProjects(in []core.Project) []core.Project {
out := make([]core.Project, 0, len(in))
for _, p := range in {
if IsScratchProject(p.Settings) {
continue
}
out = append(out, p)
}
return out
}
// ListAllProjects 全量项目(含他人 owner;EXT-15)。仅供服务端在共享适配器
// 已注册时枚举候选——调用方须逐项目过适配器,不得直接下发全表。
// 与 ListProjects 一致,scratch 项目不进入候选。
func (s *Store) ListAllProjects() ([]core.Project, error) {
rows, err := s.db.Query(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects ORDER BY updated_at DESC`))
if err != nil {
@@ -48,6 +65,9 @@ func (s *Store) ListAllProjects() ([]core.Project, error) {
if settings != "" && settings != "{}" {
p.Settings, _ = unmarshalJSON[map[string]any](settings)
}
if IsScratchProject(p.Settings) {
continue
}
out = append(out, p)
}
return out, rows.Err()
@@ -91,11 +111,11 @@ func (s *Store) UpdateProjectSettings(owner, id string, values map[string]any) e
if err != nil {
return err
}
cur := map[string]any{}
if settings != "" && settings != "{}" {
cur, _ = unmarshalJSON[map[string]any](settings)
}
cur := decodeProjectSettings(settings)
merged := core.MergeSettings(cur, values)
if err := rejectScratchMarkerWrite(cur, merged); err != nil {
return err
}
res, err := tx.Exec(s.q(`UPDATE projects SET settings=?, updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, owner)
if err != nil {
return err
@@ -215,11 +235,11 @@ func (s *Store) UpdateProjectSettingsAccountChecked(actor, id string, values map
if err != nil {
return err
}
cur := map[string]any{}
if settings != "" && settings != "{}" {
cur, _ = unmarshalJSON[map[string]any](settings)
}
cur := decodeProjectSettings(settings)
merged := core.MergeSettings(cur, values)
if err := rejectScratchMarkerWrite(cur, merged); err != nil {
return err
}
res, err := tx.Exec(s.q(`UPDATE projects SET settings=?,updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, actor)
if err != nil {
return err
@@ -255,15 +275,18 @@ func scanProjects(rows *sql.Rows) ([]core.Project, error) {
// ---- 会话 ----
func (s *Store) CreateConversation(owner, projectID, title, profileID string) (core.Conversation, error) {
// 项目归属校验:会话必须落在请求者的项目下。
var one int
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=? AND owner=?`), projectID, owner).Scan(&one)
// 项目归属校验:会话必须落在请求者的项目下;scratch 项目保持 1:1。
var settings string
err := s.db.QueryRow(s.q(`SELECT settings FROM projects WHERE id=? AND owner=?`), projectID, owner).Scan(&settings)
if errors.Is(err, sql.ErrNoRows) {
return core.Conversation{}, ErrNotFound
}
if err != nil {
return core.Conversation{}, err
}
if err := refuseScratchConversation(settings); err != nil {
return core.Conversation{}, err
}
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
@@ -273,14 +296,17 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c
// CreateConversationInProject 在共享项目下建会话(EXT-15):只校验项目存在,
// 不校验归属(服务端已先按自有路径尝试、再经适配器放行);会话 owner 仍为创建者。
func (s *Store) CreateConversationInProject(owner, projectID, title, profileID string) (core.Conversation, error) {
var one int
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=?`), projectID).Scan(&one)
var settings string
err := s.db.QueryRow(s.q(`SELECT settings FROM projects WHERE id=?`), projectID).Scan(&settings)
if errors.Is(err, sql.ErrNoRows) {
return core.Conversation{}, ErrNotFound
}
if err != nil {
return core.Conversation{}, err
}
if err := refuseScratchConversation(settings); err != nil {
return core.Conversation{}, err
}
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
@@ -438,8 +464,13 @@ func (s *Store) SetPermissionMode(owner, id, mode string) error {
return nil
}
// DeleteConversation deletes one conversation; deleting the sole conversation of
// a scratch project also removes that scratch project, so a scratch session is
// never left as an empty shell project.
func (s *Store) DeleteConversation(owner, id string) error {
return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner)
return s.deleteConversationCascade(s.db, owner, id, func() error {
return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner)
})
}
// withOwnedConversationWrite locks the actor before the conversation and
@@ -495,10 +526,14 @@ func (s *Store) UpdateConversationAccountChecked(actor, id, title, profileID str
})
}
// DeleteConversationAccountChecked retains the legacy owner-only delete rule.
// DeleteConversationAccountChecked deletes the conversation; when it was the
// sole conversation of a scratch project, that scratch project goes with it in
// the same write transaction, so a scratch session cannot be left half-deleted.
func (s *Store) DeleteConversationAccountChecked(actor, id string, allowImplicitDefault bool) error {
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor)
return s.deleteConversationCascade(tx, actor, id, func() error {
return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor)
})
})
}
+230
View File
@@ -0,0 +1,230 @@
package store
import (
"database/sql"
"errors"
"time"
core "agent-system-core"
)
// 临时会话(scratch session):一个 scratch 项目 + 恰好一个私有会话。
// scratch 属性只由项目 settings 的 host.kind 表达——它随项目行一起存储,
// 所以判定只需要一个读函数,任何调用点都不自己比对字符串。
const (
// ScratchHostKind 是 projects.settings.host.kind 的临时会话取值。
ScratchHostKind = "scratch"
// ScratchDefaultProjectName 是未指定名称时临时会话项目使用的默认名。
ScratchDefaultProjectName = "临时会话"
)
var (
// ErrScratchSessionSingle 会话与临时会话项目是 1:1:第二个会话被拒。
ErrScratchSessionSingle = errors.New("store: scratch session holds exactly one conversation")
// ErrScratchMarkerReserved 标记归服务端所有:客户端既不能给普通项目
// 加上 scratch,也不能从已有 scratch 项目上摘掉它。
ErrScratchMarkerReserved = errors.New("store: scratch host marker is server-owned")
)
// ScratchHostSettings 返回标记 scratch 项目的 settings 值。
func ScratchHostSettings() map[string]any {
return map[string]any{"host": map[string]any{"kind": ScratchHostKind}}
}
// IsScratchProject 报告 settings 是否带 scratch 宿主标记。已存储的
// projects.settings 与尚未合并的客户端 settings 都用它判定。
func IsScratchProject(settings map[string]any) bool {
host, ok := settings["host"].(map[string]any)
if !ok {
return false
}
kind, _ := host["kind"].(string)
return kind == ScratchHostKind
}
// ScratchProjectIDs 返回 owner 名下带 scratch 标记的项目 id 集合:一次查询读出
// 该 owner 的全部 projects.settings,逐行按 IsScratchProject 判定。列表响应要给
// 一批会话打标时走它,不必逐个会话回查项目;别人的 scratch 项目不在结果里。
func (s *Store) ScratchProjectIDs(owner string) (map[string]bool, error) {
rows, err := s.db.Query(s.q(`SELECT id,settings FROM projects WHERE owner=?`), owner)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var id, settings string
if err := rows.Scan(&id, &settings); err != nil {
return nil, err
}
if IsScratchProject(decodeProjectSettings(settings)) {
out[id] = true
}
}
return out, rows.Err()
}
// IsScratchProjectID 是内部可信判定:按 id 读 projects.settings 的 host 标记。
// 调用点(HTTP 门禁、回合门禁、命令分发)一律走它,不自己比对标记字符串。
func (s *Store) IsScratchProjectID(projectID string) (bool, error) {
if projectID == "" {
return false, nil
}
p, err := s.ProjectMetadata(projectID)
if err != nil {
return false, err
}
return IsScratchProject(p.Settings), nil
}
// ScratchProjectDecision 是临时会话的归属判定,回答“这个调用是否发生在某个
// 临时会话里”:
// - handled=false:不是临时会话(或项目读不到),调用点继续走原来的判定面;
// - handled=true, err=nil:调用者是该临时会话的属主,放行且不必再问 RDM;
// - handled=true, err=ErrNotFound:临时会话不是共享资源,其他人一律不可见。
//
// 判据只来自存储里的属主列与 host 标记,不接受调用方自称的身份。
func (s *Store) ScratchProjectDecision(projectID, actor string) (bool, error) {
if projectID == "" {
return false, nil
}
p, err := s.ProjectMetadata(projectID)
if err != nil {
// 读不到项目(不存在或存储异常):存在性与共享判定留给原路径,
// 这里不把“读不到”当成临时会话,也就不会放宽任何门禁。
return false, nil
}
if !IsScratchProject(p.Settings) {
return false, nil
}
if p.Owner != core.ID(actor) {
return true, ErrNotFound
}
return true, nil
}
// scratchQuerier 是本文件里临时会话判定可用的两种执行面(*sql.DB 与 *sql.Tx),
// 于是同一个事务内的级联删除与 store 级删除共用一份语义。
type scratchQuerier interface {
QueryRow(string, ...any) *sql.Row
Exec(string, ...any) (sql.Result, error)
}
// scratchProjectOfConversation 返回会话所属的 scratch 项目 id;普通项目返回空串。
func (s *Store) scratchProjectOfConversation(q scratchQuerier, owner, conversationID string) (string, error) {
var projectID, settings string
err := q.QueryRow(s.q(`SELECT c.project_id, COALESCE(p.settings,'') FROM conversations c LEFT JOIN projects p ON p.id=c.project_id WHERE c.id=? AND c.owner=?`),
conversationID, owner).Scan(&projectID, &settings)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
if !IsScratchProject(decodeProjectSettings(settings)) {
return "", nil
}
return projectID, nil
}
// deleteScratchProjectIfEmpty 在会话已被删掉之后连带删掉它的临时会话项目:
// 临时会话项目只承载这一个会话,删会话即删临时会话(同一个执行面、同一个门禁)。
// 仍有会话时不动(1:1 由创建面保证,删除面不依赖该不变量做正确性);普通项目
// id 传进来也不会被删。
func (s *Store) deleteScratchProjectIfEmpty(q scratchQuerier, owner, projectID string) error {
if projectID == "" {
return nil
}
var remaining int
if err := q.QueryRow(s.q(`SELECT COUNT(1) FROM conversations WHERE project_id=?`), projectID).Scan(&remaining); err != nil {
return err
}
if remaining > 0 {
return nil
}
res, err := q.Exec(s.q(`DELETE FROM projects WHERE id=? AND owner=?`), projectID, owner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// deleteConversationCascade 删除一个会话,并在它是临时会话唯一会话时连带删掉
// 临时会话项目。account-checked 路径在最终写事务内调用,legacy 路径在同一执行
// 面上调用:语义只有一份。
func (s *Store) deleteConversationCascade(q scratchQuerier, owner, id string, del func() error) error {
scratchProject, err := s.scratchProjectOfConversation(q, owner, id)
if err != nil {
return err
}
if err := del(); err != nil {
return err
}
return s.deleteScratchProjectIfEmpty(q, owner, scratchProject)
}
// decodeProjectSettings 把存储的 settings JSON 读成 map;缺省或损坏都是空表。
func decodeProjectSettings(raw string) map[string]any {
if raw == "" || raw == "{}" {
return map[string]any{}
}
cur, err := unmarshalJSON[map[string]any](raw)
if err != nil || cur == nil {
return map[string]any{}
}
return cur
}
// rejectScratchMarkerWrite 拒绝改变 scratch 标记的客户端写入。保持标记不变的
// 普通设置写入(例如给 scratch 项目改别的键)照旧通过。
func rejectScratchMarkerWrite(current, merged map[string]any) error {
if IsScratchProject(current) != IsScratchProject(merged) {
return ErrScratchMarkerReserved
}
return nil
}
// refuseScratchConversation 按存储的 projects.settings 判定 1:1:scratch 项目
// 下再建会话即拒,错误在会话插入之前返回。
func refuseScratchConversation(rawSettings string) error {
if IsScratchProject(decodeProjectSettings(rawSettings)) {
return ErrScratchSessionSingle
}
return nil
}
// CreateScratchSessionAccountChecked 在一个事务里建出 scratch 项目与它唯一的
// 会话:项目写入 host.kind 标记,会话写 cross_private=true。两者都发生在校验
// 过的 actor 行锁之下,所以临时会话与普通项目走同一条账号有效性边界。
// 调用方不需要(也不应)先调 CreateProjectAccountChecked——该路径不带标记,
// 无法表示临时会话。
func (s *Store) CreateScratchSessionAccountChecked(actor, name, title, profileID string, allowImplicitDefault bool) (core.Project, core.Conversation, error) {
now := time.Now().UTC()
project := core.Project{
ID: newID("prj"), Owner: core.ID(actor), Name: name,
CreatedAt: now, UpdatedAt: now, Settings: ScratchHostSettings(),
}
conversation := core.Conversation{
ID: newID("cnv"), ProjectID: project.ID, Owner: core.ID(actor), Title: title, ProfileID: profileID,
AllowCrossRead: true, CrossPrivate: true, CreatedAt: now, UpdatedAt: now,
}
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
if _, err := tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`),
project.ID, actor, project.Name, marshalJSON(project.Settings),
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
return err
}
_, err := tx.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,cross_private,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`),
conversation.ID, conversation.ProjectID, actor, conversation.Title, conversation.ProfileID,
true, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
return err
})
if err != nil {
return core.Project{}, core.Conversation{}, err
}
return project, conversation, nil
}
+385
View File
@@ -0,0 +1,385 @@
package store
import (
"errors"
"testing"
)
// 临时会话 store 契约(U1):一个 scratch 项目 + 恰好一个私有会话,
// 项目列表不出现它,会话列表仍然出现它,标记不可被客户端写入改动。
func scratchAccountFixture(t *testing.T, st *Store, owner string, admin bool) {
t.Helper()
if err := st.CreateAccount(owner, owner, "", admin); err != nil {
t.Fatal(err)
}
}
func TestScratchSessionCreatesOnePrivateConversationUnderScratchProject(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatalf("create scratch session: %v", err)
}
if !IsScratchProject(project.Settings) {
t.Fatalf("project is not marked scratch: %+v", project.Settings)
}
if string(project.Owner) != "alice" || project.ID == "" {
t.Fatalf("unexpected project: %+v", project)
}
if string(conversation.ProjectID) != string(project.ID) || string(conversation.Owner) != "alice" {
t.Fatalf("conversation is not under the scratch project: %+v", conversation)
}
if !conversation.CrossPrivate || !conversation.AllowCrossRead {
t.Fatalf("scratch conversation must be private and cross-readable: %+v", conversation)
}
// 读回的项目行带着同一个标记,判定只有 IsScratchProject 一个来源。
stored, err := st.GetProject("alice", string(project.ID))
if err != nil || !IsScratchProject(stored.Settings) {
t.Fatalf("stored project marker: %+v %v", stored.Settings, err)
}
}
func TestScratchProjectsStayOutOfProjectListsButNotConversationLists(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
scratchAccountFixture(t, st, "bob", false)
normal, err := st.CreateProject("alice", "普通项目")
if err != nil {
t.Fatal(err)
}
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
// 他人的 scratch 也不进全量候选(共享适配器枚举)。
if _, _, err := st.CreateScratchSessionAccountChecked("bob", "临时会话", "临时会话", "", false); err != nil {
t.Fatal(err)
}
projects, err := st.ListProjects("alice")
if err != nil {
t.Fatal(err)
}
if len(projects) != 1 || projects[0].ID != normal.ID {
t.Fatalf("ListProjects must hide scratch projects: %+v", projects)
}
all, err := st.ListAllProjects()
if err != nil {
t.Fatal(err)
}
if len(all) != 1 || all[0].ID != normal.ID {
t.Fatalf("ListAllProjects must hide scratch projects: %+v", all)
}
// 侧栏任务列表仍然要能看到临时会话。
tasks, err := st.ListTasks("alice", false)
if err != nil {
t.Fatal(err)
}
found := false
for _, task := range tasks {
if task.ID == conversation.ID {
found = true
}
}
if !found {
t.Fatalf("ListTasks dropped the scratch conversation: %+v", tasks)
}
conversations, err := st.ListConversations("alice", string(project.ID))
if err != nil || len(conversations) != 1 || conversations[0].ID != conversation.ID {
t.Fatalf("ListConversations under scratch: %+v %v", conversations, err)
}
}
func TestScratchProjectRefusesSecondConversationOnEveryStoreCreatePath(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
project, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
// account-checked 路径由服务端共用的会话创建入口(createPrivateConversation,
// 覆盖 POST /api/projects/{id}/conversations 与 /new)先行拒绝,见
// internal/server/scratch_session_test.go;这里的 store 契约覆盖本文件内
// 的两个创建函数。
for name, create := range map[string]func() error{
"legacy": func() error {
_, err := st.CreateConversation("alice", string(project.ID), "第二个", "")
return err
},
"shared": func() error {
_, err := st.CreateConversationInProject("alice", string(project.ID), "第二个", "")
return err
},
} {
if err := create(); !errors.Is(err, ErrScratchSessionSingle) {
t.Fatalf("%s path allowed a second conversation: %v", name, err)
}
}
conversations, err := st.ListConversations("alice", string(project.ID))
if err != nil || len(conversations) != 1 {
t.Fatalf("refused create still wrote a row: %+v %v", conversations, err)
}
// 普通项目不受影响。
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateConversationAccountChecked("alice", string(normal.ID), "正常会话", "", false, true); err != nil {
t.Fatalf("normal project lost conversation create: %v", err)
}
}
func TestScratchHostMarkerIsServerOwned(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
scratchAccountFixture(t, st, "bob", false)
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
if err != nil {
t.Fatal(err)
}
project, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
// 普通项目不能被加上标记(两种写入路径都不行)。
for name, write := range map[string]func(id string) error{
"legacy": func(id string) error {
return st.UpdateProjectSettings("alice", id, ScratchHostSettings())
},
"account-checked": func(id string) error {
return st.UpdateProjectSettingsAccountChecked("alice", id, ScratchHostSettings(), false)
},
} {
if err := write(string(normal.ID)); !errors.Is(err, ErrScratchMarkerReserved) {
t.Fatalf("%s path added the scratch marker: %v", name, err)
}
}
// scratch 项目也不能被摘掉标记。
cleared := map[string]any{"host": map[string]any{"kind": "normal"}}
if err := st.UpdateProjectSettings("alice", string(project.ID), cleared); !errors.Is(err, ErrScratchMarkerReserved) {
t.Fatalf("scratch marker cleared: %v", err)
}
if err := st.UpdateProjectSettingsAccountChecked("alice", string(project.ID), map[string]any{"host": nil}, false); !errors.Is(err, ErrScratchMarkerReserved) {
t.Fatalf("scratch marker dropped by null: %v", err)
}
// 不改变标记的普通写入照旧放行。
if err := st.UpdateProjectSettings("alice", string(project.ID), map[string]any{"note": "保留标记"}); err != nil {
t.Fatalf("unrelated settings write refused: %v", err)
}
kept, err := st.GetProject("alice", string(project.ID))
if err != nil || !IsScratchProject(kept.Settings) || kept.Settings["note"] != "保留标记" {
t.Fatalf("scratch project settings after unrelated write: %+v %v", kept.Settings, err)
}
if err := st.UpdateProjectSettings("alice", string(normal.ID), map[string]any{"note": "普通"}); err != nil {
t.Fatalf("normal project settings write refused: %v", err)
}
}
func TestScratchSessionCreateFollowsAccountState(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "admin", true)
scratchAccountFixture(t, st, "alice", false)
if _, _, err := st.CreateScratchSessionAccountChecked("missing", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("unknown actor created a scratch session: %v", err)
}
account, err := st.GetAccountByOwner("alice")
if err != nil {
t.Fatal(err)
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
if _, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("disabled account created a scratch session: %v", err)
}
projects, err := st.ListProjects("alice")
if err != nil || len(projects) != 0 {
t.Fatalf("refused create left a project row: %+v %v", projects, err)
}
}
func TestScratchSessionTransactionLeavesNothingOnFailure(t *testing.T) {
st := openTestStore(t)
// 缺账号时事务回滚,项目与会话都不落库。
if _, _, err := st.CreateScratchSessionAccountChecked("ghost", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("ghost actor: %v", err)
}
all, err := st.ListAllProjects()
if err != nil || len(all) != 0 {
t.Fatalf("rolled back create left rows: %+v %v", all, err)
}
}
// ---- U2:隔离门禁的 store 契约 ----
// IsScratchProjectID / ScratchProjectDecision 是所有门禁唯一的判定来源:
// 属主放行,其他人 NotFound,普通项目不接管(交回原判定面)。
func TestScratchProjectLookupAndOwnershipDecision(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
scratchAccountFixture(t, st, "bob", false)
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
projectID string
scratch bool
err error
}{
{"scratch", string(project.ID), true, nil},
{"normal", string(normal.ID), false, nil},
{"empty", "", false, nil},
{"missing", "prj_missing", false, ErrNotFound},
} {
got, err := st.IsScratchProjectID(tc.projectID)
if got != tc.scratch || !errors.Is(err, tc.err) {
t.Fatalf("%s: IsScratchProjectID = %v, %v (want %v, %v)", tc.name, got, err, tc.scratch, tc.err)
}
}
// 属主:门禁接管并放行(不查 RDM)。
handled, err := st.ScratchProjectDecision(string(project.ID), "alice")
if !handled || err != nil {
t.Fatalf("scratch owner decision = %v, %v", handled, err)
}
// 其他人:NotFound——临时会话不是共享资源。
if handled, err := st.ScratchProjectDecision(string(project.ID), "bob"); !handled || !errors.Is(err, ErrNotFound) {
t.Fatalf("scratch non-owner decision = %v, %v", handled, err)
}
// 普通项目与空项目 id 都不接管,原路径继续。
if handled, err := st.ScratchProjectDecision(string(normal.ID), "bob"); handled || err != nil {
t.Fatalf("normal project decision = %v, %v", handled, err)
}
if handled, err := st.ScratchProjectDecision("", "bob"); handled || err != nil {
t.Fatalf("empty project decision = %v, %v", handled, err)
}
// 读不到的项目不按临时会话处理(不因此放宽任何门禁)。
if handled, err := st.ScratchProjectDecision("prj_missing", "alice"); handled || err != nil {
t.Fatalf("missing project decision = %v, %v", handled, err)
}
_ = conversation
}
// ScratchProjectIDs 只报属主自己的 scratch 项目:列表响应靠它给会话打标,
// 所以普通项目与他人临时会话都不在里面。
func TestScratchProjectIDsCoverOnlyOwnScratchProjects(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
scratchAccountFixture(t, st, "bob", false)
scratch, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
second, _, err := st.CreateScratchSessionAccountChecked("alice", "方案草稿", "方案草稿", "", false)
if err != nil {
t.Fatal(err)
}
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
if err != nil {
t.Fatal(err)
}
other, _, err := st.CreateScratchSessionAccountChecked("bob", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
ids, err := st.ScratchProjectIDs("alice")
if err != nil {
t.Fatal(err)
}
if len(ids) != 2 || !ids[string(scratch.ID)] || !ids[string(second.ID)] {
t.Fatalf("alice scratch ids = %+v", ids)
}
if ids[string(normal.ID)] {
t.Fatalf("normal project marked scratch: %+v", ids)
}
// 租户边界:他人的临时会话不出现在 alice 的集合里。
if ids[string(other.ID)] {
t.Fatalf("other owner's scratch project leaked: %+v", ids)
}
others, err := st.ScratchProjectIDs("bob")
if err != nil {
t.Fatal(err)
}
if len(others) != 1 || !others[string(other.ID)] {
t.Fatalf("bob scratch ids = %+v", others)
}
// 没有项目的人拿到空集合而不是 nil 之外的东西(判空与遍历都安全)。
if empty, err := st.ScratchProjectIDs("carol"); err != nil || len(empty) != 0 {
t.Fatalf("empty scratch ids = %+v, %v", empty, err)
}
}
// 删掉临时会话的唯一会话即删掉它的 scratch 项目(不留空壳);普通项目照旧。
func TestDeletingScratchConversationDeletesScratchProject(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
scratchAccountFixture(t, st, "bob", false)
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
// 他人删除:仍然 NotFound,且临时会话原样存在。
if err := st.DeleteConversationAccountChecked("bob", string(conversation.ID), false); !errors.Is(err, ErrNotFound) {
t.Fatalf("non-owner delete = %v", err)
}
if _, err := st.ProjectMetadata(string(project.ID)); err != nil {
t.Fatalf("non-owner delete removed the scratch project: %v", err)
}
if err := st.DeleteConversationAccountChecked("alice", string(conversation.ID), false); err != nil {
t.Fatalf("owner delete: %v", err)
}
if _, err := st.ProjectMetadata(string(project.ID)); !errors.Is(err, ErrNotFound) {
t.Fatalf("scratch project survived its only conversation: %v", err)
}
if scratch, err := st.IsScratchProjectID(string(project.ID)); scratch || !errors.Is(err, ErrNotFound) {
t.Fatalf("deleted scratch project still answers as scratch: %v %v", scratch, err)
}
// 普通项目的会话删除不动项目。
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversationAccountChecked("alice", string(normal.ID), "会话", "", false, true)
if err != nil {
t.Fatal(err)
}
if err := st.DeleteConversationAccountChecked("alice", string(conv.ID), false); err != nil {
t.Fatal(err)
}
if _, err := st.ProjectMetadata(string(normal.ID)); err != nil {
t.Fatalf("normal project removed with its conversation: %v", err)
}
}
// legacy DeleteConversation 是同一条语义:删会话即删临时会话外壳。
func TestLegacyDeleteConversationCascadesScratchProject(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
if err := st.DeleteConversation("alice", string(conversation.ID)); err != nil {
t.Fatalf("legacy delete: %v", err)
}
if _, err := st.ProjectMetadata(string(project.ID)); !errors.Is(err, ErrNotFound) {
t.Fatalf("legacy delete left the scratch shell: %v", err)
}
}