Merge pull request 'Enable private member work in shared projects' (#195) from feature/project-member-access into main
This commit is contained in:
@@ -284,7 +284,7 @@ var copyOrder = []string{
|
||||
"projects", "users", "profiles", "settings", "plugins",
|
||||
"conversations", "runs", "messages", "events", "artifacts",
|
||||
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
|
||||
"activity_cards", "command_audit", "prompt_modules",
|
||||
"activity_cards", "project_event_shares", "command_audit", "prompt_modules",
|
||||
"sessions", "api_keys",
|
||||
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks",
|
||||
"memory_entries", "audit_events", "pending_confirms", "llm_usage_daily",
|
||||
@@ -784,7 +784,7 @@ func advanceSequences(db *sql.DB, table string) error {
|
||||
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
|
||||
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
|
||||
func checkOwners(db *sql.DB, src *sql.DB) error {
|
||||
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "command_audit"}
|
||||
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit"}
|
||||
for _, t := range ownerTables {
|
||||
if !sqliteHasTable(src, t) {
|
||||
continue
|
||||
|
||||
+20
-14
@@ -55,6 +55,7 @@ import (
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/internal/tools"
|
||||
"agent-system-backend/internal/workflow"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
@@ -527,6 +528,7 @@ func Run(opt Options) error {
|
||||
|
||||
// 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。
|
||||
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
|
||||
cardSvc.SetProjectSharedEmitter(mgr.EmitProjectSharedCardEvent)
|
||||
surfaceSvc := surfacesvc.New(st, mgr.EmitCardEvent)
|
||||
go func() { // 启动恢复:重发「快照已落盘、事件未发出」的待恢复投影(KTD)。
|
||||
if n, err := surfaceSvc.RecoverPendingEvents(context.Background()); err == nil && n > 0 {
|
||||
@@ -594,7 +596,8 @@ func Run(opt Options) error {
|
||||
pluginHost.OnCommands = bus.RegisterPluginCommands
|
||||
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
|
||||
authzSlot := &projectAuthz{}
|
||||
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
|
||||
projectSlot := &projectLifecycleSlot{}
|
||||
caps := hostcaps{projects: projectSlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
|
||||
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
|
||||
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
|
||||
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
|
||||
@@ -607,6 +610,16 @@ func Run(opt Options) error {
|
||||
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
|
||||
wireScopedHostFactory(caps)
|
||||
compiledSummary := applyCompiled(skillsRoot, bus, caps)
|
||||
mgr.SetProjectWorkAuthorization(projectSlot.work())
|
||||
mgr.SetKnowledgeProjectScope(func(ctx context.Context, actor string, id core.ID) (core.ID, error) {
|
||||
return projectSlot.resolve(ctx, caps, actor, id)
|
||||
})
|
||||
if authzSlot.hasV2() {
|
||||
mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool {
|
||||
ok, err := authzSlot.AuthorizeResource(extension.ResourceAuthzQuery{Actor: actor, Action: "model-context", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source})
|
||||
return err == nil && ok
|
||||
})
|
||||
}
|
||||
mgr.OnUsage = func(owner string, tokens int) {
|
||||
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0))
|
||||
status, aerr := st.AccountTokens(owner, tokens, budget)
|
||||
@@ -631,28 +644,21 @@ func Run(opt Options) error {
|
||||
}
|
||||
// EXT-8 余项:hostapi 检索面同样吃适配器共享(本人 + 放行的他人项目知识)。
|
||||
hostSrv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||||
own, err := caps.searchDefault(ctx, owner, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared, err := st.SearchKnowledgeShared(owner, core.RetrievalQuery{Text: text, TopK: 5}, func(docOwner, projectID core.ID) bool {
|
||||
ok, aerr := authzSlot.Authorize(owner, projectID)
|
||||
return aerr == nil && ok
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(own, shared...), nil
|
||||
return caps.searchDefault(ctx, owner, text)
|
||||
}
|
||||
|
||||
hostSrv.Complete = func(ctx context.Context, owner, prompt string) (string, error) {
|
||||
return caps.complete(ctx, owner, prompt)
|
||||
}
|
||||
|
||||
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
|
||||
projectSlot.wire(srv, caps)
|
||||
// EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。
|
||||
srv.SetProjectAuthorizer(authzSlot.Authorize)
|
||||
// EXT-8b:资源级适配器注入读面(未注册=server 回落 V1,行为不变)。
|
||||
srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource)
|
||||
if authzSlot.hasV2() {
|
||||
srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource)
|
||||
}
|
||||
// EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。
|
||||
srv.DisableUploadIndexing = opt.DisableUploadIndexing
|
||||
// EXT-12 最小开关:会话卡不镜像进项目公共流(cardSvc 在上方已建)。
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
|
||||
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
|
||||
type hostcaps struct {
|
||||
projects *projectLifecycleSlot
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
@@ -71,10 +72,16 @@ func (a *projectAuthz) AuthorizeResource(q extension.ResourceAuthzQuery) (bool,
|
||||
if fn1 == nil || q.Owner == "" || q.ProjectID == "" {
|
||||
return false, nil
|
||||
}
|
||||
return fn1(q.Owner, q.ProjectID)
|
||||
return fn1(q.Actor, q.ProjectID)
|
||||
}
|
||||
|
||||
// Authorize owner×project 共享读取判定;未注册一律 false(默认拒绝)。
|
||||
func (a *projectAuthz) hasV2() bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.fn2 != nil
|
||||
}
|
||||
|
||||
func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) {
|
||||
a.mu.Lock()
|
||||
fn := a.fn
|
||||
@@ -103,6 +110,19 @@ type sharedRetriever interface {
|
||||
|
||||
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
|
||||
q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}
|
||||
if c.projects != nil && projectID != "" {
|
||||
id, err := c.projects.resolve(ctx, c, owner, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.ProjectID = id
|
||||
}
|
||||
if c.authz != nil && c.authz.hasV2() {
|
||||
return c.pipe.RetrieveAuthorized(ctx, core.ID(owner), q, func(d core.KnowledgeDoc) bool {
|
||||
ok, err := c.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source})
|
||||
return err == nil && ok
|
||||
})
|
||||
}
|
||||
// EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走
|
||||
// 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。
|
||||
if c.authz != nil {
|
||||
@@ -265,6 +285,20 @@ func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text
|
||||
return h.caps.search(ctx, h.owner, projectID, text, topK)
|
||||
}
|
||||
|
||||
// knowledgeSource preserves ordinary host Source semantics. A lifecycle provider
|
||||
// introduces shared publication provenance, so plugins may only assert their own
|
||||
// namespace; another plugin cannot impersonate an RDM published row.
|
||||
func (h inprocHost) knowledgeSource(source string) error {
|
||||
if h.caps.projects == nil || !h.caps.projects.active() {
|
||||
return nil
|
||||
}
|
||||
prefix := "plugin:" + h.pluginID
|
||||
if source == prefix || strings.HasPrefix(source, prefix+":") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("extension: knowledge source must belong to plugin %q", h.pluginID)
|
||||
}
|
||||
|
||||
// KnowledgeWrite 强制 owner 归属:Owner 改写为本次分发 owner,Source 为空补
|
||||
// "plugin:<id>";embed=false 只写全文行,embed=true 走 IndexChunks 建向量
|
||||
// (embedding 失败降级纯全文,不阻断写入)。
|
||||
@@ -278,6 +312,9 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
|
||||
if docs[i].Source == "" {
|
||||
docs[i].Source = src
|
||||
}
|
||||
if err := h.knowledgeSource(docs[i].Source); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !embed {
|
||||
return h.caps.st.AddKnowledge(docs...)
|
||||
@@ -285,31 +322,40 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
|
||||
return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs)
|
||||
}
|
||||
|
||||
// KnowledgePublish 幂等发布(EXT-9):按 docs 的 ref 整批替换——先清该 owner
|
||||
// 下同 ref 旧行与旧向量(跨来源也清:ref 是发布单元),再走 KnowledgeWrite
|
||||
// 语义(Owner/Source 强制同彼)。重试产生相同结果。
|
||||
// KnowledgePublish replaces refs and their vectors within the publication scope.
|
||||
// A registered lifecycle scopes replacement to the exact owner/project/plugin;
|
||||
// ordinary unregistered Hosts retain the legacy cross-source ref semantics.
|
||||
func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
refs := map[string]bool{}
|
||||
type publicationRef struct{ projectID, ref string }
|
||||
refs := map[publicationRef]bool{}
|
||||
for i := range docs {
|
||||
docs[i].Owner = core.ID(h.owner)
|
||||
if docs[i].Source == "" {
|
||||
docs[i].Source = "plugin:" + h.pluginID
|
||||
}
|
||||
if err := h.knowledgeSource(docs[i].Source); err != nil {
|
||||
return err
|
||||
}
|
||||
if docs[i].Ref != "" {
|
||||
refs[docs[i].Ref] = true
|
||||
refs[publicationRef{docs[i].ProjectID, docs[i].Ref}] = true
|
||||
}
|
||||
}
|
||||
scoped := h.caps.projects != nil && h.caps.projects.active()
|
||||
for ref := range refs {
|
||||
if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil {
|
||||
return err
|
||||
var err error
|
||||
if scoped {
|
||||
err = h.caps.st.DeletePluginProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref, h.pluginID)
|
||||
} else {
|
||||
err = h.caps.st.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref)
|
||||
}
|
||||
if err := h.caps.st.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return h.KnowledgeWrite(ctx, docs, embed)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHostKnowledgeV2AndProjectReplacement(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
pipe := rag.NewPipeline(st, settings.NewService(st), nil, nil)
|
||||
auth := &projectAuthz{}
|
||||
alice := inprocHost{owner: "alice", pluginID: "rdm", caps: hostcaps{st: st, pipe: pipe, authz: auth}}
|
||||
first := []core.KnowledgeDoc{{ID: "slice-1", ProjectID: "domain-1", Ref: "notes.md", Chunk: "sharedneedle one"}}
|
||||
second := []core.KnowledgeDoc{{ID: "slice-2", ProjectID: "domain-2", Ref: "notes.md", Chunk: "sharedneedle two"}}
|
||||
for _, ds := range [][]core.KnowledgeDoc{first, second, second} {
|
||||
if err := alice.KnowledgePublish(context.Background(), ds, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
own, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "sharedneedle"})
|
||||
if err != nil || len(own) != 2 {
|
||||
t.Fatalf("cross project publish replaced first: %+v %v", own, err)
|
||||
}
|
||||
auth.set("rdm", func(string, core.ID) (bool, error) { return true, nil })
|
||||
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return q.ResourceType == "project" || (q.ResourceType == "knowledge" && q.ResourceID == "slice-1" && q.ProjectID == "domain-1" && q.Actor == "bob" && q.Owner == "alice"), nil
|
||||
})
|
||||
bob := inprocHost{owner: "bob", pluginID: "rdm", caps: alice.caps}
|
||||
hits, err := bob.KnowledgeSearch(context.Background(), "", "sharedneedle", 5)
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-1" {
|
||||
t.Fatalf("host V2=%+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostKnowledgeResolvesTrustedProjectScope(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slot := &projectLifecycleSlot{}
|
||||
provider := completeLifecycle()
|
||||
provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) {
|
||||
if h.Owner() != "bob" {
|
||||
t.Fatalf("wrong scope actor %q", h.Owner())
|
||||
}
|
||||
return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}}, nil
|
||||
}
|
||||
caps := hostcaps{st: st, pipe: rag.NewPipeline(st, settings.NewService(st), nil, nil), authz: &projectAuthz{}, projects: slot}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caps.authz.setV2("rdm", func(extension.ResourceAuthzQuery) (bool, error) { return true, nil })
|
||||
h := inprocHost{owner: "bob", pluginID: "rdm", caps: caps}
|
||||
hits, err := h.KnowledgeSearch(context.Background(), "host-current", "scopeneedle", 5)
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
|
||||
t.Fatalf("host scope: %+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
slot := &projectLifecycleSlot{}
|
||||
caps := hostcaps{st: st, projects: slot}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{owner: "alice", pluginID: "other", caps: caps}
|
||||
for _, publish := range []bool{false, true} {
|
||||
docs := []core.KnowledgeDoc{{ID: "claimed-published", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "ref", Chunk: "spoofneedle"}}
|
||||
var err error
|
||||
if publish {
|
||||
err = h.KnowledgePublish(context.Background(), docs, false)
|
||||
} else {
|
||||
err = h.KnowledgeWrite(context.Background(), docs, false)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("foreign namespace accepted publish=%v", publish)
|
||||
}
|
||||
}
|
||||
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "spoofneedle"}); err != nil || len(hits) != 0 {
|
||||
t.Fatalf("foreign source wrote data: %+v %v", hits, err)
|
||||
}
|
||||
// Existing ordinary Hosts preserve their documented caller-provided Source.
|
||||
h.caps.projects = nil
|
||||
if err := h.KnowledgeWrite(context.Background(), []core.KnowledgeDoc{{Source: "manual", Chunk: "ordinaryneedle"}}, false); err != nil {
|
||||
t.Fatalf("ordinary source semantics changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPublishPreservesForeignPluginAndScope(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
slot := &projectLifecycleSlot{}
|
||||
caps := hostcaps{st: st, projects: slot}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := core.KnowledgeDoc{ID: "rdm-published", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "notes.md", Chunk: "originalneedle"}
|
||||
if err := st.AddKnowledge(original); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpsertKnowledgeVector(context.Background(), "alice", original.ID, original.Ref, []float32{1, 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{owner: "alice", pluginID: "other", caps: caps}
|
||||
replacement := []core.KnowledgeDoc{{ID: "other", ProjectID: "domain", Source: "plugin:other", Ref: "notes.md", Chunk: "replacementneedle"}}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := h.KnowledgePublish(context.Background(), replacement, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle"}); err != nil || len(hits) != 1 {
|
||||
t.Fatalf("foreign plugin deleted RDM publication: %+v %v", hits, err)
|
||||
}
|
||||
if hits, err := st.SearchKnowledgeVectors(context.Background(), "alice", []float32{1, 0}, 5, core.RetrievalQuery{ProjectID: "domain"}); err != nil || len(hits) != 1 || hits[0].Doc.ID != original.ID {
|
||||
t.Fatalf("foreign plugin deleted RDM vector: %+v %v", hits, err)
|
||||
}
|
||||
h.pluginID = "rdm"
|
||||
if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "unscoped", Source: "plugin:rdm", Ref: "notes.md", Chunk: "privateneedle"}}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle", ProjectID: "domain"}); err != nil || len(hits) != 1 {
|
||||
t.Fatalf("unscoped publication deleted scoped RDM data: %+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnregisteredPublishRetainsLegacyRefScope(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "legacy", Owner: "alice", ProjectID: "old-project", Source: "manual", Ref: "same", Chunk: "legacyneedle"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{owner: "alice", pluginID: "other", caps: hostcaps{st: st}}
|
||||
if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "replacement", Source: "manual", Ref: "same", Chunk: "replacementneedle"}}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "legacyneedle"}); err != nil || len(hits) != 0 {
|
||||
t.Fatalf("unregistered legacy scope changed: %+v %v", hits, err)
|
||||
}
|
||||
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "replacementneedle"}); err != nil || len(hits) != 1 {
|
||||
t.Fatalf("unregistered replacement missing: %+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/projectscope"
|
||||
"agent-system-backend/internal/server"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type projectLifecycleSlot struct {
|
||||
mu sync.Mutex
|
||||
holder string
|
||||
provider *extension.ProjectLifecycle
|
||||
}
|
||||
|
||||
func (r compiledRegistry) RegisterProjectLifecycle(extension.ProjectLifecycle) error {
|
||||
return errors.New("extension: declarative plugins cannot register project lifecycle")
|
||||
}
|
||||
func (r pluginRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error {
|
||||
if r.caps.projects == nil || r.pluginID == "" {
|
||||
return errors.New("extension: project lifecycle slot unavailable")
|
||||
}
|
||||
if p.List == nil || p.Create == nil || p.Rename == nil || p.Archive == nil || p.Work == nil {
|
||||
return errors.New("extension: project lifecycle requires all callbacks")
|
||||
}
|
||||
slot := r.caps.projects
|
||||
slot.mu.Lock()
|
||||
defer slot.mu.Unlock()
|
||||
if slot.provider != nil {
|
||||
return errors.New("extension: project lifecycle already registered")
|
||||
}
|
||||
slot.holder, slot.provider = r.pluginID, &p
|
||||
return nil
|
||||
}
|
||||
func (r recordRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error {
|
||||
reg, ok := r.Registry.(extension.ProjectLifecycleRegistry)
|
||||
if !ok {
|
||||
return errors.New("extension: project lifecycle registration unavailable")
|
||||
}
|
||||
return reg.RegisterProjectLifecycle(p)
|
||||
}
|
||||
func (slot *projectLifecycleSlot) wire(srv *server.Server, caps hostcaps) {
|
||||
slot.mu.Lock()
|
||||
defer slot.mu.Unlock()
|
||||
if slot.provider == nil {
|
||||
return
|
||||
}
|
||||
holder := slot.holder
|
||||
srv.SetProjectLifecycle(*slot.provider, func(owner string) extension.Host {
|
||||
return inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}
|
||||
})
|
||||
}
|
||||
|
||||
func (slot *projectLifecycleSlot) active() bool {
|
||||
slot.mu.Lock()
|
||||
defer slot.mu.Unlock()
|
||||
return slot.provider != nil
|
||||
}
|
||||
|
||||
func (slot *projectLifecycleSlot) work() func(context.Context, string, core.ID) (bool, error) {
|
||||
slot.mu.Lock()
|
||||
defer slot.mu.Unlock()
|
||||
if slot.provider == nil {
|
||||
return nil
|
||||
}
|
||||
return slot.provider.Work
|
||||
}
|
||||
|
||||
func (slot *projectLifecycleSlot) resolve(ctx context.Context, caps hostcaps, owner string, id core.ID) (core.ID, error) {
|
||||
slot.mu.Lock()
|
||||
if slot.provider == nil {
|
||||
slot.mu.Unlock()
|
||||
return id, nil
|
||||
}
|
||||
p, holder := *slot.provider, slot.holder
|
||||
slot.mu.Unlock()
|
||||
return projectscope.Resolve(ctx, inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}, p, id)
|
||||
}
|
||||
|
||||
var _ extension.ProjectHost = inprocHost{}
|
||||
|
||||
func (h inprocHost) OwnedProjects() ([]core.Project, error) {
|
||||
ps, err := h.caps.st.ListProjects(h.owner)
|
||||
for i := range ps {
|
||||
ps[i].Owner = h.owner
|
||||
}
|
||||
return ps, err
|
||||
}
|
||||
func (h inprocHost) OwnedProject(id string) (core.Project, error) {
|
||||
return h.caps.st.GetProject(h.owner, id)
|
||||
}
|
||||
func (h inprocHost) ProjectMetadata(id string) (core.Project, error) {
|
||||
p, err := h.caps.st.ProjectMetadata(id)
|
||||
if err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
if h.caps.projects != nil && h.caps.projects.active() {
|
||||
p.Settings = h.caps.set.MaskedProjectMetadata(p.Settings)
|
||||
}
|
||||
if p.Owner == h.owner {
|
||||
return p, nil
|
||||
}
|
||||
if h.caps.authz == nil {
|
||||
return core.Project{}, store.ErrNotFound
|
||||
}
|
||||
ok, err := h.caps.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: h.owner, Action: "read", ResourceType: "project", ResourceID: core.ID(id), Owner: p.Owner, ProjectID: core.ID(id)})
|
||||
if err != nil || !ok {
|
||||
return core.Project{}, store.ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
func (h inprocHost) EnsureProject(id, name string) (core.Project, error) {
|
||||
return h.caps.st.EnsureProject(h.owner, id, name)
|
||||
}
|
||||
func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) {
|
||||
return h.caps.st.RenameOwnedProject(h.owner, id, name)
|
||||
}
|
||||
func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) {
|
||||
return h.caps.st.ProjectNameAvailable(name, exceptID)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/server"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func completeLifecycle() extension.ProjectLifecycle {
|
||||
return extension.ProjectLifecycle{
|
||||
List: func(context.Context, extension.Host) ([]core.Project, error) { return nil, nil },
|
||||
Create: func(context.Context, extension.Host, string) (core.Project, error) { return core.Project{}, nil },
|
||||
Rename: func(context.Context, extension.Host, string, string) (core.Project, error) {
|
||||
return core.Project{}, nil
|
||||
},
|
||||
Archive: func(context.Context, extension.Host, string) error { return nil },
|
||||
Work: func(context.Context, string, core.ID) (bool, error) { return true, nil },
|
||||
}
|
||||
}
|
||||
func TestProjectLifecycleOptionalRegistration(t *testing.T) {
|
||||
slot := &projectLifecycleSlot{}
|
||||
reg := pluginRegistry{pluginID: "rdm", caps: hostcaps{projects: slot}}
|
||||
if err := reg.RegisterProjectLifecycle(extension.ProjectLifecycle{}); err == nil {
|
||||
t.Fatal("incomplete registration accepted")
|
||||
}
|
||||
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err == nil {
|
||||
t.Fatal("duplicate registration accepted")
|
||||
}
|
||||
if err := (compiledRegistry{}).RegisterProjectLifecycle(completeLifecycle()); err == nil {
|
||||
t.Fatal("declarative callback accepted")
|
||||
}
|
||||
rr := recordRegistry{Registry: pluginRegistry{pluginID: "other", caps: hostcaps{projects: &projectLifecycleSlot{}}}}
|
||||
optional, ok := any(rr).(extension.ProjectLifecycleRegistry)
|
||||
if !ok {
|
||||
t.Fatal("record registry hides optional registration")
|
||||
}
|
||||
if err := optional.RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
alice := inprocHost{owner: "alice", caps: hostcaps{st: st}}
|
||||
p, err := alice.EnsureProject("stable", "Shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth := &projectAuthz{}
|
||||
auth.set("rdm", func(owner string, id core.ID) (bool, error) { return owner == "bob" && id == core.ID(p.ID), nil })
|
||||
bob := inprocHost{owner: "bob", caps: hostcaps{st: st, authz: auth}}
|
||||
if _, err := bob.ProjectMetadata(p.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := bob.OwnedProject(p.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("owned lookup=%v", err)
|
||||
}
|
||||
if _, err := bob.RenameOwnedProject(p.ID, "Other"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("shared rename=%v", err)
|
||||
}
|
||||
if _, err := bob.EnsureProject(p.ID, p.Name); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("shared ensure=%v", err)
|
||||
}
|
||||
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { return false, nil })
|
||||
if _, err := bob.ProjectMetadata(p.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("V2 denial ignored=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
p, err := st.CreateProject("alice", "Shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-nested-secret"}}, "models.embedding.apiKey": "fixture-flat-secret", "legacy.token": "fixture-legacy-secret", "plugins.private": map[string]any{"credential": "fixture-schema-secret"}, "display": map[string]any{"caption": "Shared configuration"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
|
||||
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := settings.NewService(st)
|
||||
svc.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}})
|
||||
slot := &projectLifecycleSlot{}
|
||||
auth := &projectAuthz{}
|
||||
caps := hostcaps{st: st, set: svc, projects: slot, authz: auth}
|
||||
provider := completeLifecycle()
|
||||
provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) {
|
||||
project, err := h.(extension.ProjectHost).ProjectMetadata(p.ID)
|
||||
return []core.Project{project}, err
|
||||
}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return q.Actor == "bob" && q.ResourceType == "project" && q.ResourceID == p.ID, nil
|
||||
})
|
||||
for _, actor := range []string{"bob", "alice"} {
|
||||
h := inprocHost{owner: actor, caps: caps}
|
||||
view, err := h.ProjectMetadata(p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, _ := json.Marshal(view.Settings)
|
||||
for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} {
|
||||
if bytes.Contains(encoded, []byte(value)) {
|
||||
t.Fatalf("%s metadata exposed a fixture credential", actor)
|
||||
}
|
||||
}
|
||||
if view.Settings["rdm.projectId"] != "canonical" || view.Settings["rdm.state"] != "ready" || view.Settings["display"].(map[string]any)["caption"] != "Shared configuration" {
|
||||
t.Fatal("allowed service/configuration metadata changed")
|
||||
}
|
||||
}
|
||||
// Exercise the real registry -> actor-scoped Host -> public HTTP list chain.
|
||||
srv := server.New(st, nil, nil, nil, svc, nil)
|
||||
srv.Auth = server.NewAuthenticator(st, true)
|
||||
slot.wire(srv, caps)
|
||||
key, err := st.CreateAPIKey("bob", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest("GET", "/api/projects", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+key)
|
||||
response := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(response, request)
|
||||
if response.Code != 200 {
|
||||
t.Fatalf("member list=%d", response.Code)
|
||||
}
|
||||
for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} {
|
||||
if bytes.Contains(response.Body.Bytes(), []byte(value)) {
|
||||
t.Fatal("actor-scoped lifecycle list exposed a fixture credential")
|
||||
}
|
||||
}
|
||||
if !bytes.Contains(response.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) {
|
||||
t.Fatal("actor-scoped list lost canonical metadata")
|
||||
}
|
||||
|
||||
owned, err := (inprocHost{owner: "alice", caps: caps}).OwnedProject(p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := json.Marshal(raw)
|
||||
after, _ := json.Marshal(owned.Settings)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("metadata read mutated persisted owner settings")
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,9 @@ var (
|
||||
const cardAppendWindow = 250 * time.Millisecond
|
||||
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
projectSharedEmit ProjectSharedEmitter
|
||||
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
|
||||
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
|
||||
MirrorConvCards bool
|
||||
@@ -41,6 +42,42 @@ func New(st *store.Store, emit Emitter) *Service {
|
||||
return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}, MirrorConvCards: true}
|
||||
}
|
||||
|
||||
// ProjectSharedEmitter is an internal trusted publication adapter. It is never
|
||||
// bound to model arguments or exposed as a mandatory extension capability.
|
||||
type ProjectSharedEmitter func(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error
|
||||
|
||||
func (s *Service) SetProjectSharedEmitter(emit ProjectSharedEmitter) { s.projectSharedEmit = emit }
|
||||
|
||||
// PublishProjectCard explicitly publishes a project-level structural snapshot.
|
||||
// Ordinary Create/Update remain private unless this entry issues a new proof.
|
||||
func (s *Service) PublishProjectCard(owner, producer string, card core.ActivityCard, ref string) (core.ActivityCard, error) {
|
||||
if s.projectSharedEmit == nil || owner == "" || producer == "" || card.ProjectID == "" || card.ConversationID != "" {
|
||||
return card, errors.New("card: trusted project publisher required")
|
||||
}
|
||||
if card.ID != "" {
|
||||
current, err := s.st.GetCard(owner, card.ID)
|
||||
if err != nil {
|
||||
return card, err
|
||||
}
|
||||
if current.ProjectID != card.ProjectID || current.ConversationID != "" {
|
||||
return card, store.ErrNotFound
|
||||
}
|
||||
} else {
|
||||
card.ID = store.NewID("card")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
card.CreatedAt = now
|
||||
card.UpdatedAt = now
|
||||
if card.Status == "" {
|
||||
card.Status = core.CardActive
|
||||
}
|
||||
if err := s.st.SaveCard(owner, &card, ref); err != nil {
|
||||
return card, err
|
||||
}
|
||||
err := s.projectSharedEmit(owner, producer, string(core.ProjectChannelKey(card.ProjectID)), card.SourceRunID, core.EvCardCreated, core.CardPayload{Card: card})
|
||||
return card, err
|
||||
}
|
||||
|
||||
// emitCard 按卡归属发布卡片事件(D50):
|
||||
// - 会话卡 → 会话流;同时镜像项目流(伪会话键 "project:<projectId>")——
|
||||
// 项目收件箱聚合视图(项目级卡 ∪ 各会话未决卡)的实时性依赖它;卡片事件
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Package projectscope resolves host retrieval filters using service-provided
|
||||
// lifecycle metadata. Stored/client settings never supply an authority mapping.
|
||||
package projectscope
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
)
|
||||
|
||||
func Resolve(ctx context.Context, host extension.Host, p extension.ProjectLifecycle, id core.ID) (core.ID, error) {
|
||||
if id == "" {
|
||||
return id, nil
|
||||
}
|
||||
if p.List == nil {
|
||||
return "", store.ErrNotFound
|
||||
}
|
||||
projects, err := p.List(ctx, host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, project := range projects {
|
||||
domainID, _ := project.Settings["rdm.projectId"].(string)
|
||||
if project.ID != id && domainID != id {
|
||||
continue
|
||||
}
|
||||
state, _ := project.Settings["rdm.state"].(string)
|
||||
if domainID == "" || state != "ready" {
|
||||
return "", store.ErrNotFound
|
||||
}
|
||||
return core.ID(domainID), nil
|
||||
}
|
||||
return "", store.ErrNotFound
|
||||
}
|
||||
+52
-4
@@ -174,19 +174,66 @@ func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.Ret
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// RetrieveAuthorized is the resource-level path for HTTP, Host and model
|
||||
// context. It filters owned and shared candidates before external reranking.
|
||||
func (p *Pipeline) RetrieveAuthorized(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
|
||||
own, err := p.retrieve(ctx, owner, q, allow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared, err := p.st.SearchKnowledgeSharedAuthorized(string(owner), q, allow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.embedder != nil {
|
||||
if vectors, e := p.embedder.Embed(ctx, []string{q.Text}); e == nil && len(vectors) > 0 && len(vectors[0]) > 0 {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
vs, e := p.st.SearchKnowledgeVectorsSharedAuthorized(ctx, string(owner), embedderModel(p.embedder), vectors[0], topK, q, allow)
|
||||
if e == nil {
|
||||
shared = rrfMerge(vs, shared)
|
||||
}
|
||||
}
|
||||
}
|
||||
merged := rrfMerge(own, shared)
|
||||
if limit := q.TopK; limit > 0 && len(merged) > limit {
|
||||
merged = merged[:limit]
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
func authorizedHits(hits []core.RetrievalHit, allow func(core.KnowledgeDoc) bool) []core.RetrievalHit {
|
||||
if allow == nil {
|
||||
return hits
|
||||
}
|
||||
out := make([]core.RetrievalHit, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
if allow(h.Doc) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Retrieve 统一检索(core.Retriever)。
|
||||
func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
return p.retrieve(ctx, owner, q, nil)
|
||||
}
|
||||
func (p *Pipeline) retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
if p.embedder == nil {
|
||||
return p.st.SearchKnowledge(owner, q)
|
||||
hits, err := p.st.SearchKnowledge(owner, q)
|
||||
return authorizedHits(hits, allow), err
|
||||
}
|
||||
route := p.embeddingRoute(ctx)
|
||||
offline, _ := p.embedder.(interface{ Offline() bool })
|
||||
if !route.OK() && (offline == nil || !offline.Offline()) {
|
||||
return p.st.SearchKnowledge(owner, q)
|
||||
hits, err := p.st.SearchKnowledge(owner, q)
|
||||
return authorizedHits(hits, allow), err
|
||||
}
|
||||
qvecs, err := p.embedder.Embed(ctx, []string{q.Text})
|
||||
if err != nil || len(qvecs) == 0 || len(qvecs[0]) == 0 {
|
||||
@@ -194,7 +241,8 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval
|
||||
if err != nil {
|
||||
log.Printf("[rag] query embedding fallback to fulltext: %v", err)
|
||||
}
|
||||
return p.st.SearchKnowledge(owner, q)
|
||||
hits, err := p.st.SearchKnowledge(owner, q)
|
||||
return authorizedHits(hits, allow), err
|
||||
}
|
||||
q.TopK = topK * 2 // 每路多取,融合后再裁剪
|
||||
vhits, err := p.st.SearchKnowledgeVectorsModel(ctx, owner, embedderModel(p.embedder), qvecs[0], topK*2, q)
|
||||
@@ -205,7 +253,7 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged := rrfMerge(vhits, fhits)
|
||||
merged := authorizedHits(rrfMerge(vhits, fhits), allow)
|
||||
if p.reranker != nil && p.rerankRoute(ctx).OK() && len(merged) > 0 {
|
||||
docs := make([]core.KnowledgeDoc, len(merged))
|
||||
for i, h := range merged {
|
||||
|
||||
@@ -116,6 +116,34 @@ func (eb *eventBuffer) emit(conversationID, runID string, typ core.StreamEventTy
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// emitProjectShared persists an explicit internal publication and its proof
|
||||
// before fan-out. A client-visible payload cannot opt itself into this path.
|
||||
func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error {
|
||||
project, ok := core.ProjectFromChannel(channel)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := core.StreamEvent{ConversationID: channel, ProjectID: project, RunID: runID, Type: typ, Payload: encoded, TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion}
|
||||
eb.flushConversation(channel)
|
||||
eb.flushing.Lock()
|
||||
defer eb.flushing.Unlock()
|
||||
events := []core.StreamEvent{event}
|
||||
if _, err := eb.store.AppendProjectSharedEvents(owner, producer, channel, events); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, event := range events {
|
||||
eb.bus.Publish(event)
|
||||
}
|
||||
if eb.onPersisted != nil {
|
||||
eb.onPersisted(channel, events)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// append 落盘并按 seq 顺序发布。
|
||||
func (eb *eventBuffer) append(conversationID string, evs []core.StreamEvent) error {
|
||||
eb.flushing.Lock()
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunKnowledgeResourceAuthorization(t *testing.T) {
|
||||
rc, st := newExecEnv(t)
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "allowed-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery published"}, core.KnowledgeDoc{ID: "private-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery private"}, core.KnowledgeDoc{ID: "restricted-self", Owner: "alice", ProjectID: rc.run.ProjectID, Chunk: "resourcequery self"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil))
|
||||
rc.mgr.SetSharedKnowledgeAllow(func(string, core.ID) bool { return true })
|
||||
rc.mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool {
|
||||
return actor == "alice" && d.ID == "allowed-slice" && d.Owner == "bob"
|
||||
})
|
||||
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "resourcequery"})
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "allowed-slice" {
|
||||
t.Fatalf("model-context resources: %+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWorkRevocationGuardsDirectExecution(t *testing.T) {
|
||||
rc, st := newExecEnv(t)
|
||||
rc.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return false, nil })
|
||||
conv, err := st.GetConversation("alice", rc.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rc.mgr.StartConversationRun(conv, core.Message{}, nil, nil); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("direct run should reject: %v", err)
|
||||
}
|
||||
res := rc.ExecuteTool(core.ToolCallRequest{Name: "write_artifact", Arguments: json.RawMessage(`{"name":"forbidden.md","content":"x"}`)})
|
||||
if res.Status != "error" {
|
||||
t.Fatalf("revoked execution: %+v", res)
|
||||
}
|
||||
artifacts, err := st.ListArtifacts("alice", rc.run.ProjectID)
|
||||
if err != nil || len(artifacts) != 0 {
|
||||
t.Fatalf("revoked tool wrote: %+v %v", artifacts, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunKnowledgeResolvesTrustedProjectScope(t *testing.T) {
|
||||
rc, st := newExecEnv(t)
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "bob", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "bob", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil))
|
||||
rc.mgr.SetKnowledgeResourceAllow(func(string, core.KnowledgeDoc) bool { return true })
|
||||
rc.mgr.SetKnowledgeProjectScope(func(_ context.Context, actor string, id core.ID) (core.ID, error) {
|
||||
if actor != "alice" || id != rc.run.ProjectID {
|
||||
t.Fatalf("untrusted scope actor=%s project=%s", actor, id)
|
||||
}
|
||||
return "domain-current", nil
|
||||
})
|
||||
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "scopeneedle"})
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
|
||||
t.Fatalf("model current scope: %+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWorkRevocationBlocksQueuedHITL(t *testing.T) {
|
||||
c, st := newExecEnv(t)
|
||||
c.run.ID = "revoked-run"
|
||||
c.run.Status = core.RunWaitingHITL
|
||||
if err := st.CreateRun(&c.run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &runHandle{hitlCh: make(chan hitlAnswer, 1)}
|
||||
c.mgr.active = map[core.ID]*runHandle{c.run.ID: h}
|
||||
card := core.HITLCard{ID: "revoked-card", RunID: c.run.ID, ConversationID: c.run.ConversationID}
|
||||
if err := st.SaveHITLCard("alice", &card); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.SetProjectWorkAuthorization(func(_ context.Context, owner string, id core.ID) (bool, error) {
|
||||
if owner != "alice" || id != c.run.ProjectID {
|
||||
t.Fatalf("untrusted resolution identity owner=%s project=%s", owner, id)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if _, _, err := c.mgr.ResolveHITL("alice", card.ID, core.HITLResponse{ChoiceID: "yes"}); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("revoked confirmation should reject: %v", err)
|
||||
}
|
||||
persisted, err := st.GetHITLCard("alice", card.ID)
|
||||
if err != nil || persisted.Resolved || len(h.hitlCh) != 0 {
|
||||
t.Fatalf("revoked confirmation changed gate: card=%+v channel=%d error=%v", persisted, len(h.hitlCh), err)
|
||||
}
|
||||
run, err := st.GetRunByID(c.run.ID)
|
||||
if err != nil || run.Status != core.RunWaitingHITL {
|
||||
t.Fatalf("revoked confirmation resumed run: %+v %v", run, err)
|
||||
}
|
||||
payload, _ := json.Marshal(HITLCommandPayload{Owner: "alice", CardID: card.ID, Kind: "yes"})
|
||||
c.mgr.applyHITLCommand(ownedCommand{CommandID: "queued-approval", Payload: string(payload)})
|
||||
if len(h.hitlCh) != 0 {
|
||||
t.Fatal("queued approval delivered a revoked answer")
|
||||
}
|
||||
run, err = st.GetRunByID(c.run.ID)
|
||||
if err != nil || run.Status != core.RunWaitingHITL {
|
||||
t.Fatalf("queued approval resumed run: %+v %v", run, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWorkRevocationAfterToolConfirmation(t *testing.T) {
|
||||
for _, choice := range []string{"yes", "session"} {
|
||||
t.Run(choice, func(t *testing.T) {
|
||||
c, st := newExecEnv(t)
|
||||
c.h = &runHandle{hitlCh: make(chan hitlAnswer, 1)}
|
||||
c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: choice}}
|
||||
c.prof.Tools.Confirm = []string{"write_artifact"}
|
||||
ready := true
|
||||
c.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { ok := ready; ready = false; return ok, nil })
|
||||
res := c.ExecuteTool(core.ToolCallRequest{CallID: "revoked-call", Name: "write_artifact", Arguments: json.RawMessage(`{"name":"revoked.txt","content":"x"}`)})
|
||||
artifacts, err := st.ListArtifacts("alice", c.run.ProjectID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != "error" || len(artifacts) != 0 {
|
||||
t.Fatalf("tool executed after revoked confirmation: status=%s artifacts=%d", res.Status, len(artifacts))
|
||||
}
|
||||
if c.mgr.sessionAllows(c.run.ConversationID, "write_artifact") {
|
||||
t.Fatal("revoked confirmation granted session permission")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWorkRevocationBlocksDirectArtifactWrite(t *testing.T) {
|
||||
c, st := newExecEnv(t)
|
||||
c.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return false, nil })
|
||||
if _, err := c.WriteArtifact("revoked.txt", "data", "text/plain", []byte("private")); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("direct artifact should reject: %v", err)
|
||||
}
|
||||
artifacts, err := st.ListArtifacts("alice", c.run.ProjectID)
|
||||
if err != nil || len(artifacts) != 0 {
|
||||
t.Fatalf("revoked artifact was written: %+v %v", artifacts, err)
|
||||
}
|
||||
}
|
||||
+117
-4
@@ -57,7 +57,10 @@ type Manager struct {
|
||||
retriever core.Retriever // 可选混合检索(nil = store 全文)
|
||||
// sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属
|
||||
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
|
||||
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
||||
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
||||
knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool
|
||||
knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error)
|
||||
projectWorkAuthorization func(context.Context, string, core.ID) (bool, error)
|
||||
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
|
||||
commands CommandSource
|
||||
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
|
||||
@@ -176,6 +179,12 @@ type IntentGate interface {
|
||||
Match(ctx context.Context, input string) (branch, topID string)
|
||||
}
|
||||
|
||||
// EmitProjectSharedCardEvent is an internal producer-authorized structural
|
||||
// publication entry, separate from the ordinary private card event emitter.
|
||||
func (m *Manager) EmitProjectSharedCardEvent(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error {
|
||||
return m.events.emitProjectShared(owner, producer, channel, runID, typ, payload)
|
||||
}
|
||||
|
||||
// SetGate 注入意图闸门(装配期一次;插件意图 overlay 经 pluginhost 回调重建)。
|
||||
func (m *Manager) SetGate(g IntentGate) { m.gate = g }
|
||||
|
||||
@@ -197,6 +206,37 @@ func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.I
|
||||
m.sharedKnowledgeAllow = fn
|
||||
}
|
||||
|
||||
// SetKnowledgeResourceAllow makes resource-level authorization authoritative for
|
||||
// model context, including owned rows. The callback receives stored identities.
|
||||
func (m *Manager) SetKnowledgeResourceAllow(fn func(string, core.KnowledgeDoc) bool) {
|
||||
m.knowledgeResourceAllow = fn
|
||||
}
|
||||
|
||||
// SetProjectWorkAuthorization is the live domain guard for starting runs and
|
||||
// executing tools. Nil preserves the ordinary unregistered host behavior.
|
||||
func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, core.ID) (bool, error)) {
|
||||
m.projectWorkAuthorization = fn
|
||||
}
|
||||
func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error {
|
||||
if m.projectWorkAuthorization == nil {
|
||||
return nil
|
||||
}
|
||||
ok, err := m.projectWorkAuthorization(ctx, owner, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetKnowledgeProjectScope binds host project filters to the provider's trusted
|
||||
// canonical domain metadata without widening the current conversation scope.
|
||||
func (m *Manager) SetKnowledgeProjectScope(fn func(context.Context, string, core.ID) (core.ID, error)) {
|
||||
m.knowledgeProjectScope = fn
|
||||
}
|
||||
|
||||
// SetCommandSource 注入命令即工具源(EXT-14;组合根以命令总线适配)。nil = 现状。
|
||||
func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src }
|
||||
|
||||
@@ -596,7 +636,7 @@ func (c *runCtx) SetAssistantParts(parts []core.Part) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||||
func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err error) {
|
||||
card.ID = store.NewID("hitl")
|
||||
card.RunID = c.run.ID
|
||||
card.ConversationID = c.run.ConversationID
|
||||
@@ -638,8 +678,13 @@ func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||||
// 排队,取消语境下重取失败由取消路径收口)。
|
||||
c.mgr.adm.release(c.mgr.st, string(c.run.ID))
|
||||
defer func() {
|
||||
if err := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); err != nil {
|
||||
log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, err)
|
||||
if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); acquireErr != nil {
|
||||
log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, acquireErr)
|
||||
}
|
||||
// Permission can change during both confirmation and admission waiting.
|
||||
// Every HITL caller receives the live decision before it resumes work.
|
||||
if err == nil {
|
||||
err = c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID)
|
||||
}
|
||||
}()
|
||||
return waitHITL(c.ctx, c.h.hitlCh, timeout)
|
||||
@@ -842,6 +887,9 @@ func (c *runCtx) Artifact(id string) (core.Artifact, []byte, bool) {
|
||||
}
|
||||
|
||||
func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||||
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.Artifact{}, err
|
||||
}
|
||||
art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID}
|
||||
if err := c.mgr.st.CreateArtifact(&art, content); err != nil {
|
||||
return art, err
|
||||
@@ -902,6 +950,40 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
if q.ProjectID == "" {
|
||||
q.ProjectID = c.run.ProjectID
|
||||
}
|
||||
if c.mgr.knowledgeProjectScope != nil {
|
||||
id, err := c.mgr.knowledgeProjectScope(c.ctx, c.run.Owner, q.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.ProjectID = id
|
||||
}
|
||||
if c.mgr.knowledgeResourceAllow != nil {
|
||||
allow := func(d core.KnowledgeDoc) bool { return c.mgr.knowledgeResourceAllow(string(c.run.Owner), d) }
|
||||
if rs, ok := c.mgr.retriever.(interface {
|
||||
RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error)
|
||||
}); ok {
|
||||
return rs.RetrieveAuthorized(c.ctx, c.run.Owner, q, allow)
|
||||
}
|
||||
hits, err := c.mgr.st.SearchKnowledge(c.run.Owner, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []core.RetrievalHit{}
|
||||
for _, h := range hits {
|
||||
if allow(h.Doc) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
shared, err := c.mgr.st.SearchKnowledgeSharedAuthorized(c.run.Owner, q, allow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, shared...)
|
||||
if len(out) > q.TopK {
|
||||
out = out[:q.TopK]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if c.mgr.retriever != nil {
|
||||
if c.mgr.sharedKnowledgeAllow != nil {
|
||||
if rs, ok := c.mgr.retriever.(sharedRetriever); ok {
|
||||
@@ -917,6 +999,9 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
|
||||
// ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。
|
||||
func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||||
}
|
||||
policy := c.prof.Tools
|
||||
if !policy.ToolAllowed(call.Name) {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)}
|
||||
@@ -937,12 +1022,18 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
if err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
|
||||
}
|
||||
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||||
}
|
||||
if resp.ChoiceID == "session" {
|
||||
c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name)
|
||||
} else if resp.ChoiceID != "yes" {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
|
||||
}
|
||||
}
|
||||
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||||
}
|
||||
// EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、
|
||||
// Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。
|
||||
if c.mgr.commands != nil {
|
||||
@@ -1141,6 +1232,9 @@ func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *wo
|
||||
// attachments 为已解析的附件文档(server 层经 ingest 处理)。
|
||||
// inputs 是同一批附件的原件,落到会话工作区 inputs/,供模板和仿制使用。
|
||||
func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) {
|
||||
if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||||
return core.Run{}, err
|
||||
}
|
||||
if err := m.claimConversation(conv.ID); err != nil {
|
||||
return core.Run{}, err
|
||||
}
|
||||
@@ -1150,6 +1244,9 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.
|
||||
// StartWorkflowRun 启动一条绑定工作流的会话 run。定义在这里拷贝,
|
||||
// 之后插件卸载不会改到已经在跑的这份。
|
||||
func (m *Manager) StartWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) {
|
||||
if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||||
return core.Run{}, err
|
||||
}
|
||||
if err := m.claimConversation(conv.ID); err != nil {
|
||||
return core.Run{}, err
|
||||
}
|
||||
@@ -1259,6 +1356,10 @@ func (m *Manager) claimConversation(conversationID string) error {
|
||||
}
|
||||
|
||||
func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile, launch *workflow.Launch) (core.Run, error) {
|
||||
if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||||
m.releaseConversation(conv.ID)
|
||||
return core.Run{}, err
|
||||
}
|
||||
prof, _ := m.profiles.Get(conv.Owner, conv.ProfileID)
|
||||
eff := m.settings.Effective(conv.Owner, conv.ProjectID)
|
||||
run := core.Run{
|
||||
@@ -1581,6 +1682,12 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if m.projectWorkAuthorization != nil && (run.Owner != owner || run.ConversationID != card.ConversationID) {
|
||||
return "", false, store.ErrNotFound
|
||||
}
|
||||
if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
// U7 双进程验收实测缺陷修复:run 不在本进程执行时不得先落 resolve——
|
||||
// 卡一旦标记 resolved,另一实例 server 层的命令 fallback(resolveHITL 的
|
||||
// !card.Resolved 判定)即被跳过,执行实例的 runner 永远等不到答案,
|
||||
@@ -1625,6 +1732,9 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
}
|
||||
}
|
||||
if pivot {
|
||||
if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
_ = m.CancelRun(run.ID)
|
||||
m.st.ResolveHITLCard(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||||
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||||
@@ -1635,6 +1745,9 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
return trimmed, true, nil
|
||||
}
|
||||
}
|
||||
if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind}
|
||||
if err := m.st.ResolveHITLCard(owner, cardID, resolution); err != nil {
|
||||
return "", false, err
|
||||
|
||||
@@ -164,6 +164,10 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
MessageID string `json:"messageId"`
|
||||
}
|
||||
|
||||
@@ -273,6 +273,20 @@ func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
if s.projectLifecycle != nil {
|
||||
snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
|
||||
if err != nil {
|
||||
s.writeSurfaceError(w, err)
|
||||
return
|
||||
}
|
||||
if snap.Identity.ProjectID != "" {
|
||||
if err := s.projectWork(r.Context(), s.owner(r), snap.Identity.ProjectID); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注意:本端点不绑定任何 confirmed 类字段——高危确认只能经既有确认卡回道
|
||||
// (bus.Confirm)置位,客户端/模型无法伪造(U5 提案第二节第 2 条红测)。
|
||||
var body struct {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// projectStreamMembership consults the registered resource authorizer using the
|
||||
// physical project's trusted owner, never the event publisher or payload claims.
|
||||
func (s *Server) projectStreamMembership(actor, project string) bool {
|
||||
p, err := s.st.ProjectMetadata(project)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return s.authorizeSharedResource(actor, "stream", "project", project, p.Owner, project)
|
||||
}
|
||||
|
||||
func (s *Server) projectEventVisible(actor string, event core.StreamEvent) bool {
|
||||
if s.projectLifecycle == nil {
|
||||
return true
|
||||
}
|
||||
access, err := s.st.ResolveProjectEventAccess(event)
|
||||
if err != nil || access.Owner == "" {
|
||||
return false
|
||||
}
|
||||
if access.Owner == actor {
|
||||
return true
|
||||
}
|
||||
project, ok := core.ProjectFromChannel(event.ConversationID)
|
||||
return ok && access.Shared && s.projectStreamMembership(actor, project)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func projectStreamReplay(t *testing.T, s *Server, key, project string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+project+"&since=0", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+key)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r.WithContext(ctx))
|
||||
return w
|
||||
}
|
||||
func projectEventFixture(t *testing.T) (*Server, string, string, string, *atomic.Bool) {
|
||||
t.Helper()
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
|
||||
s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent)
|
||||
member := &atomic.Bool{}
|
||||
member.Store(true)
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || (q.Actor == "bob" && member.Load())), nil
|
||||
})
|
||||
return s, alice, bob, p.ID, member
|
||||
}
|
||||
func TestProjectEventsLegacyReplayIsPrivate(t *testing.T) {
|
||||
s, alice, bob, p, _ := projectEventFixture(t)
|
||||
s.DisableUploadIndexing = true
|
||||
if w := lifecycleUpload(t, s, alice, p, "legacy-owner-private.txt", "PRIVATE"); w.Code != 200 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }}, nil)
|
||||
member := projectStreamReplay(t, s, bob, p)
|
||||
if member.Code != 200 || bytes.Contains(member.Body.Bytes(), []byte("legacy-owner-private.txt")) || bytes.Contains(member.Body.Bytes(), []byte("builtin:txt")) {
|
||||
t.Fatal("legacy private upload leaked to member")
|
||||
}
|
||||
owner := projectStreamReplay(t, s, alice, p)
|
||||
if owner.Code != 200 || !bytes.Contains(owner.Body.Bytes(), []byte("legacy-owner-private.txt")) {
|
||||
t.Fatal("owner lost private history")
|
||||
}
|
||||
}
|
||||
func TestProjectEventsSharedProofAndPrivatePagination(t *testing.T) {
|
||||
s, _, bob, p, _ := projectEventFixture(t)
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
|
||||
card, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "PRIVATE HISTORY"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"card": card, "owner": "bob", "shared": true, "producer": "plugin:rdm:structure"})
|
||||
private := make([]core.StreamEvent, 1001)
|
||||
for i := range private {
|
||||
private[i] = core.StreamEvent{Type: core.EvCardUpdated, Payload: payload, TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1}
|
||||
}
|
||||
if _, err := s.st.AppendEvents(core.ProjectChannelKey(p), private); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "EXPLICIT SHARED"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := projectStreamReplay(t, s, bob, p)
|
||||
if w.Code != 200 || bytes.Contains(w.Body.Bytes(), []byte("PRIVATE HISTORY")) || !bytes.Contains(w.Body.Bytes(), []byte("EXPLICIT SHARED")) {
|
||||
t.Fatal("private pagination/spoof proof failed")
|
||||
}
|
||||
}
|
||||
func TestProjectEventsLivePrivacyAndRevocation(t *testing.T) {
|
||||
s, _, bob, p, member := projectEventFixture(t)
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
|
||||
httpServer := httptest.NewServer(s.Handler())
|
||||
defer httpServer.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+bob)
|
||||
response, err := httpServer.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LIVE PRIVATE"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "LIVE SHARED"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader := bufio.NewReader(response.Body)
|
||||
var before bytes.Buffer
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("live shared event did not arrive: %v", err)
|
||||
}
|
||||
before.WriteString(line)
|
||||
if bytes.Contains([]byte(line), []byte("LIVE SHARED")) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if bytes.Contains(before.Bytes(), []byte("LIVE PRIVATE")) {
|
||||
t.Fatal("live private frame leaked before revocation")
|
||||
}
|
||||
member.Store(false)
|
||||
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "AFTER REMOVAL"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("revoked stream did not stop: %v", err)
|
||||
}
|
||||
if bytes.Contains(body, []byte("LIVE PRIVATE")) || bytes.Contains(body, []byte("AFTER REMOVAL")) {
|
||||
t.Fatal("live private/revoked event leaked")
|
||||
}
|
||||
}
|
||||
func TestUnregisteredProjectEventsRetainLegacyReads(t *testing.T) {
|
||||
s, _, bob, p, _ := projectEventFixture(t)
|
||||
if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LEGACY BASE"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := projectStreamReplay(t, s, bob, p)
|
||||
if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte("LEGACY BASE")) {
|
||||
t.Fatal("unregistered project stream changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectEventsIdleStreamStopsAfterMembershipRemoval(t *testing.T) {
|
||||
previous := sseAuthRecheck
|
||||
sseAuthRecheck = 20 * time.Millisecond
|
||||
defer func() { sseAuthRecheck = previous }()
|
||||
s, _, bob, p, member := projectEventFixture(t)
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
|
||||
httpServer := httptest.NewServer(s.Handler())
|
||||
defer httpServer.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
request, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil)
|
||||
request.Header.Set("Authorization", "Bearer "+bob)
|
||||
response, err := httpServer.Client().Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
member.Store(false)
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil || !bytes.Contains(body, []byte("project-access-revoked")) {
|
||||
t.Fatal("idle project stream kept revoked member connected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// SetProjectLifecycle is wired only for a registered in-process provider. The
|
||||
// optional host factory constructs a capability scoped to this request's actor.
|
||||
func (s *Server) SetProjectLifecycle(p extension.ProjectLifecycle, host func(string) extension.Host) {
|
||||
s.projectLifecycle = &p
|
||||
s.projectHost = host
|
||||
}
|
||||
func (s *Server) lifecycleHost(owner string) extension.Host {
|
||||
if s.projectHost == nil {
|
||||
return nil
|
||||
}
|
||||
return s.projectHost(owner)
|
||||
}
|
||||
func (s *Server) projectWork(ctx context.Context, owner, projectID string) error {
|
||||
if s.projectLifecycle == nil {
|
||||
return nil
|
||||
}
|
||||
if s.projectLifecycle.Work == nil {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
ok, err := s.projectLifecycle.Work(ctx, owner, core.ID(projectID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createPrivateConversation is shared by the API and /new. Only live work
|
||||
// authorization grants member creation; the resulting conversation stays owned.
|
||||
func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) {
|
||||
if s.projectLifecycle != nil {
|
||||
if err := s.projectWork(ctx, owner, projectID); err != nil {
|
||||
return core.Conversation{}, err
|
||||
}
|
||||
return s.st.CreateConversationInProject(owner, projectID, title, profile)
|
||||
}
|
||||
c, err := s.st.CreateConversation(owner, projectID, title, profile)
|
||||
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) {
|
||||
return s.st.CreateConversationInProject(owner, projectID, title, profile)
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
func writeProjectErr(w http.ResponseWriter, err error) {
|
||||
var coded interface{ StatusCode() int }
|
||||
if errors.As(err, &coded) {
|
||||
switch code := coded.StatusCode(); code {
|
||||
case 400, 403, 404, 409, 503:
|
||||
http.Error(w, err.Error(), code)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeStoreErr(w, err)
|
||||
}
|
||||
func writeProjectJSON(w http.ResponseWriter, p any, err error) {
|
||||
if err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
// publicProjectMetadata also masks provider-returned metadata, since providers
|
||||
// may obtain raw owner-management rows before producing a shared public view.
|
||||
func (s *Server) publicProjectMetadata(p core.Project) core.Project {
|
||||
if s.projectLifecycle != nil {
|
||||
p.Settings = s.set.MaskedProjectMetadata(p.Settings)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type lifecycleStatusError int
|
||||
|
||||
func (e lifecycleStatusError) Error() string { return "provider refusal" }
|
||||
func (e lifecycleStatusError) StatusCode() int { return int(e) }
|
||||
func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
r.Header.Set("Authorization", "Bearer "+key)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Original")
|
||||
c, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
||||
calls := []string{}
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{
|
||||
List: func(context.Context, extension.Host) ([]core.Project, error) {
|
||||
calls = append(calls, "list")
|
||||
return []core.Project{{ID: p.ID, Owner: "alice", Name: "Decorated", Settings: map[string]any{"rdm.state": "ready"}}}, nil
|
||||
},
|
||||
Create: func(_ context.Context, _ extension.Host, name string) (core.Project, error) {
|
||||
calls = append(calls, "create")
|
||||
return core.Project{ID: "provider-created", Name: name}, nil
|
||||
},
|
||||
Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) {
|
||||
calls = append(calls, "rename")
|
||||
return core.Project{ID: id, Name: name}, nil
|
||||
},
|
||||
Archive: func(context.Context, extension.Host, string) error { calls = append(calls, "archive"); return nil },
|
||||
Work: func(context.Context, string, core.ID) (bool, error) { return true, nil },
|
||||
}, nil)
|
||||
for _, q := range []struct{ method, path, body string }{{"GET", "/api/projects", ""}, {"POST", "/api/projects", `{"name":" New "}`}, {"PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {"DELETE", "/api/projects/" + p.ID, ""}} {
|
||||
w := lifecycleRequest(t, s, key, q.method, q.path, q.body)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("%s: %d %s", q.method, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if len(calls) != 4 || calls[0] != "list" || calls[1] != "create" || calls[2] != "rename" || calls[3] != "archive" {
|
||||
t.Fatalf("calls=%v", calls)
|
||||
}
|
||||
if _, err := st.GetConversation("alice", c.ID); err != nil {
|
||||
t.Fatalf("archive removed history: %v", err)
|
||||
}
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Create: func(context.Context, extension.Host, string) (core.Project, error) {
|
||||
return core.Project{}, fmtWrap(lifecycleStatusError(409))
|
||||
}}, nil)
|
||||
if w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"dup"}`); w.Code != 409 {
|
||||
t.Fatalf("trusted status=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
func fmtWrap(err error) error { return errors.Join(errors.New("wrapped"), err) }
|
||||
func TestProjectLifecycleMemberPrivateWork(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
mallory, _ := st.CreateAPIKey("mallory", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
ac, _ := st.CreateConversation("alice", p.ID, "Alice private", "")
|
||||
ready := true
|
||||
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
|
||||
return ready && (actor == "alice" || actor == "bob") && id == core.ID(p.ID), nil
|
||||
}}, nil)
|
||||
w := lifecycleRequest(t, s, bob, "POST", "/api/projects/"+p.ID+"/conversations", `{"title":"Bob private"}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("member create=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var bc core.Conversation
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &bc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w = lifecycleRequest(t, s, bob, "GET", "/api/projects/"+p.ID+"/conversations", "")
|
||||
var cs []core.Conversation
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &cs)
|
||||
if w.Code != 200 || len(cs) != 1 || cs[0].ID != bc.ID {
|
||||
t.Fatalf("member list=%d %+v", w.Code, cs)
|
||||
}
|
||||
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+ac.ID, ""); w.Code != 404 {
|
||||
t.Fatalf("private history=%d", w.Code)
|
||||
}
|
||||
if w := lifecycleRequest(t, s, mallory, "POST", "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 {
|
||||
t.Fatalf("nonmember create=%d", w.Code)
|
||||
}
|
||||
if w := lifecycleRequest(t, s, mallory, "GET", "/api/projects/"+p.ID+"/conversations", ""); w.Code != 404 {
|
||||
t.Fatalf("nonmember list=%d", w.Code)
|
||||
}
|
||||
s.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() })
|
||||
s.WireBuiltinCommands()
|
||||
w = lifecycleRequest(t, s, bob, "POST", "/api/conversations/"+bc.ID+"/messages", `{"text":"/new"}`)
|
||||
if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte(`"conversation"`)) {
|
||||
t.Fatalf("/new=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
ready = false
|
||||
for _, q := range []struct{ key, method, path, body string }{{bob, "POST", "/api/projects/" + p.ID + "/conversations", `{}`}, {bob, "GET", "/api/projects/" + p.ID + "/conversations", ""}, {alice, "POST", "/api/conversations/" + ac.ID + "/messages", `{"text":"new work"}`}, {bob, "POST", "/api/commands/conversation.create", `{"conversationId":"` + bc.ID + `"}`}} {
|
||||
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("revoked %s=%d %s", q.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+bc.ID, ""); w.Code != 200 {
|
||||
t.Fatalf("owned revoked history=%d", w.Code)
|
||||
}
|
||||
}
|
||||
func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
|
||||
if w := lifecycleRequest(t, s, key, http.MethodPost, "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 {
|
||||
t.Fatalf("read-only write=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if cs, err := st.ListConversations("bob", p.ID); err != nil || len(cs) != 0 {
|
||||
t.Fatalf("denial wrote state: %v %v", cs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil)
|
||||
card := core.HITLCard{ID: "hitl-revoked", ConversationID: conv.ID, RunID: "run-revoked", Kind: "confirm"}
|
||||
if err := st.SaveHITLCard("alice", &card); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, q := range []struct{ path, body string }{{"/api/conversations/" + conv.ID + "/regenerate", `{}`}, {"/api/hitl/" + card.ID + "/resolve", `{"choiceId":"yes"}`}} {
|
||||
w := lifecycleRequest(t, s, key, "POST", q.path, q.body)
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("resumption %s=%d %s", q.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
||||
svc := surfacesvc.New(st, nil)
|
||||
s.Surfaces = svc
|
||||
if _, err := svc.SetActivation(context.Background(), "alice", "generative-ui", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap, err := svc.Submit(context.Background(), "alice", "generative-ui", surfacesvc.SubmitRequest{ConversationID: conv.ID, Spec: json.RawMessage(`{"title":"Page","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"Submit"}]}]}`)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil)
|
||||
w := lifecycleRequest(t, s, key, "POST", "/api/surfaces/"+snap.Identity.SurfaceID+"/actions", `{"actionId":"generative-ui.submit","revision":1,"requestId":"request"}`)
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("revoked surface action=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
|
||||
current, err := st.GetProject("alice", p.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current.Settings == nil {
|
||||
current.Settings = map[string]any{}
|
||||
}
|
||||
current.Settings["rdm.projectId"] = "canonical"
|
||||
current.Settings["rdm.state"] = "ready"
|
||||
return []core.Project{current}, nil
|
||||
}}, nil)
|
||||
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"rdm.projectId":"forged","example":true}}`)
|
||||
var got core.Project
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if w.Code != 200 || got.Settings["rdm.projectId"] != "canonical" || got.Settings["example"] != true {
|
||||
t.Fatalf("settings lost service metadata: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
raw := map[string]any{"models.chat.apiKey": "fixture-key", "custom": map[string]any{"token": "fixture-token"}, "custom.api_key": "fixture-flat-token", "plugin.private.credential": "fixture-schema-token", "display": map[string]any{"caption": "Shared"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
|
||||
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugin.private.credential", Secret: true}})
|
||||
read := func() (core.Project, error) { return st.GetProject("alice", p.ID) }
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
|
||||
current, err := read()
|
||||
return []core.Project{current}, err
|
||||
}, Create: func(context.Context, extension.Host, string) (core.Project, error) { return read() }, Rename: func(context.Context, extension.Host, string, string) (core.Project, error) { return read() }}, nil)
|
||||
for _, q := range []struct{ key, method, path, body string }{{bob, "GET", "/api/projects", ""}, {alice, "GET", "/api/projects", ""}, {alice, "POST", "/api/projects", `{"name":"New"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"settings":{"display":{"caption":"Updated"}}}`}} {
|
||||
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("public metadata %s=%d", q.method, w.Code)
|
||||
}
|
||||
for _, secret := range []string{"fixture-key", "fixture-token", "fixture-flat-token", "fixture-schema-token"} {
|
||||
if bytes.Contains(w.Body.Bytes(), []byte(secret)) {
|
||||
t.Fatalf("%s %s leaked a fixture credential", q.method, q.path)
|
||||
}
|
||||
}
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) || !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.state":"ready"`)) {
|
||||
t.Fatal("service metadata lost")
|
||||
}
|
||||
}
|
||||
owned, err := read()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owned.Settings["models.chat.apiKey"] != "fixture-key" || owned.Settings["custom"].(map[string]any)["token"] != "fixture-token" {
|
||||
t.Fatal("public metadata masking changed stored credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericProjectPatchRejectsManagedSettingsAtomically(t *testing.T) {
|
||||
for _, registered := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(registered), func(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Original")
|
||||
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}})
|
||||
if registered {
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
|
||||
current, err := st.GetProject("alice", p.ID)
|
||||
return []core.Project{current}, err
|
||||
}, Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) {
|
||||
if err := st.RenameProject("alice", id, name); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
return st.GetProject("alice", id)
|
||||
}}, nil)
|
||||
}
|
||||
for _, body := range []string{`{"name":"Changed","settings":{"models":{"chat":{"apiKey":"fixture-key"}},"display":{"caption":"Forbidden partial write"}}}`, `{"settings":{"models.chat.apiKey":"fixture-key"}}`, `{"settings":{"models":"smash"}}`, `{"settings":{"plugins.private.managed":"override"}}`} {
|
||||
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body)
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("managed patch=%d", w.Code)
|
||||
}
|
||||
current, err := st.GetProject("alice", p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.Name != "Original" || len(current.Settings) != 0 {
|
||||
t.Fatal("rejected managed patch applied a partial mutation")
|
||||
}
|
||||
}
|
||||
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"custom.caption":"Allowed"}}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("allowed patch=%d", w.Code)
|
||||
}
|
||||
current, err := st.GetProject("alice", p.ID)
|
||||
if err != nil || current.Settings["custom.caption"] != "Allowed" {
|
||||
t.Fatal("allowed metadata shape changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericProjectPatchAcceptsNestedMaskedWriteback(t *testing.T) {
|
||||
for _, registered := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(registered), func(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
if err := st.UpdateProjectSettings("alice", p.ID, map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-persisted-key"}}, "display": map[string]any{"caption": "Original"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if registered {
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
|
||||
current, err := st.GetProject("alice", p.ID)
|
||||
return []core.Project{current}, err
|
||||
}}, nil)
|
||||
}
|
||||
for _, tc := range []struct{ body, caption string }{
|
||||
{`{"settings":{"models":{"chat":{"apiKey":"••••••"}},"display":{"caption":"Nested updated"}}}`, "Nested updated"},
|
||||
{`{"settings":{"models.chat":{"apiKey":"••••••"},"display":{"caption":"Mixed updated"}}}`, "Mixed updated"},
|
||||
{`{"settings":{"models":{"chat.apiKey":"••••••"},"display":{"caption":"Mixed leaf updated"}}}`, "Mixed leaf updated"},
|
||||
} {
|
||||
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, tc.body)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("masked writeback rejected legal edit: %d", w.Code)
|
||||
}
|
||||
current, err := st.GetProject("alice", p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] != "fixture-persisted-key" || current.Settings["display"].(map[string]any)["caption"] != tc.caption {
|
||||
t.Fatalf("masked writeback secret preserved=%v caption=%q expected=%q", current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] == "fixture-persisted-key", current.Settings["display"].(map[string]any)["caption"], tc.caption)
|
||||
}
|
||||
}
|
||||
for _, body := range []string{`{"settings":{"models":{}}}`, `{"settings":{"models":{"chat":{}}}}`, `{"settings":{"models.chat":{}}}`, `{"settings":{"models":{"chat":{"apiKey":"••••••","model":"Overwrite"}}}}`} {
|
||||
if w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body); w.Code != 400 {
|
||||
t.Fatalf("explicit empty/real managed write accepted: %d", w.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func lifecycleUpload(t *testing.T, s *Server, key, projectID, name, content string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
file, err := writer.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/uploads", &body)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestProjectLifecycleMemberUploadStaysPrivate(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
mallory, _ := st.CreateAPIKey("mallory", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
ready := true
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
|
||||
return ready && id == p.ID && (actor == "alice" || actor == "bob"), nil
|
||||
}}, nil)
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
if q.ResourceType == "artifact" {
|
||||
return q.Actor == q.Owner, nil
|
||||
}
|
||||
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil
|
||||
})
|
||||
s.DisableUploadIndexing = true
|
||||
w := lifecycleUpload(t, s, bob, p.ID, "member-private.txt", "QA_MEMBER_PRIVATE_ORIGINAL")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("member upload=%d", w.Code)
|
||||
}
|
||||
var info core.UploadInfo
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artifact, err := st.GetArtifact("bob", info.ArtifactID)
|
||||
if err != nil || artifact.ProjectID != p.ID || artifact.Owner != "bob" {
|
||||
t.Fatalf("upload ownership/target incorrect: %+v %v", artifact, err)
|
||||
}
|
||||
if w := lifecycleRequest(t, s, bob, "GET", "/api/artifacts/"+info.ArtifactID+"/content", ""); w.Code != 200 || w.Body.String() != "QA_MEMBER_PRIVATE_ORIGINAL" {
|
||||
t.Fatalf("owner original read=%d", w.Code)
|
||||
}
|
||||
for _, key := range []string{alice, mallory} {
|
||||
for _, suffix := range []string{"", "/content"} {
|
||||
if w := lifecycleRequest(t, s, key, "GET", "/api/artifacts/"+info.ArtifactID+suffix, ""); w.Code != 404 {
|
||||
t.Fatalf("other actor read private original=%d", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
if w := lifecycleUpload(t, s, mallory, p.ID, "forbidden.txt", "DENIED"); w.Code != 404 {
|
||||
t.Fatalf("nonmember upload=%d", w.Code)
|
||||
}
|
||||
ready = false
|
||||
if w := lifecycleUpload(t, s, bob, p.ID, "revoked.txt", "DENIED"); w.Code != 404 {
|
||||
t.Fatalf("revoked upload=%d", w.Code)
|
||||
}
|
||||
artifacts, err := st.ListProjectArtifactsAll(p.ID)
|
||||
if err != nil || len(artifacts) != 1 {
|
||||
t.Fatalf("denied upload wrote artifacts: count=%d error=%v", len(artifacts), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
|
||||
return id == p.ID && (actor == "alice" || actor == "bob"), nil
|
||||
}}, nil)
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil
|
||||
})
|
||||
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
|
||||
s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent)
|
||||
s.DisableUploadIndexing = true
|
||||
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p.ID, Title: "Shared structure", Kind: core.CardKindProgress}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The owner path already admitted upload before the member authorization fix.
|
||||
if w := lifecycleUpload(t, s, alice, p.ID, "owner-private-filename.txt", "PRIVATE ORIGINAL"); w.Code != 200 {
|
||||
t.Fatalf("owner upload=%d", w.Code)
|
||||
}
|
||||
if w := lifecycleUpload(t, s, bob, p.ID, "member-private-filename.txt", "MEMBER PRIVATE ORIGINAL"); w.Code != 200 {
|
||||
t.Fatalf("member upload=%d", w.Code)
|
||||
}
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
cards, err := st.ListProjectCards(owner, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 0
|
||||
if owner == "alice" {
|
||||
expected = 1
|
||||
}
|
||||
if len(cards) != expected {
|
||||
t.Fatal("private upload added project card history")
|
||||
}
|
||||
for _, card := range cards {
|
||||
if card.Title != "Shared structure" {
|
||||
t.Fatal("private filename/parser card entered project history")
|
||||
}
|
||||
}
|
||||
}
|
||||
events, err := st.EventsAfter(core.ProjectChannelKey(p.ID), 0, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatal("private upload added project events")
|
||||
}
|
||||
for _, event := range events {
|
||||
if bytes.Contains(event.Payload, []byte("owner-private-filename.txt")) || bytes.Contains(event.Payload, []byte("member-private-filename.txt")) {
|
||||
t.Fatal("private upload metadata entered persisted project stream")
|
||||
}
|
||||
}
|
||||
request := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+p.ID, nil)
|
||||
request.Header.Set("Authorization", "Bearer "+bob)
|
||||
ctx, cancel := context.WithTimeout(request.Context(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
request = request.WithContext(ctx)
|
||||
response := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(response, request)
|
||||
if response.Code != 200 || !bytes.Contains(response.Body.Bytes(), []byte("Shared structure")) {
|
||||
t.Fatalf("member structural stream did not replay: %d", response.Code)
|
||||
}
|
||||
if bytes.Contains(response.Body.Bytes(), []byte("owner-private-filename.txt")) || bytes.Contains(response.Body.Bytes(), []byte("member-private-filename.txt")) {
|
||||
t.Fatal("another member's SSE exposed private upload metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Private")
|
||||
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
|
||||
s.DisableUploadIndexing = true
|
||||
if w := lifecycleUpload(t, s, key, p.ID, "legacy-upload.txt", "Original"); w.Code != 200 {
|
||||
t.Fatalf("unregistered owner upload=%d", w.Code)
|
||||
}
|
||||
cards, err := st.ListProjectCards("alice", p.ID)
|
||||
if err != nil || len(cards) != 1 || cards[0].Title != "legacy-upload.txt" {
|
||||
t.Fatalf("unregistered ingestion card changed: count=%d error=%v", len(cards), err)
|
||||
}
|
||||
if w := lifecycleRequest(t, s, key, "POST", "/api/projects/missing/uploads", "not multipart"); w.Code != 400 {
|
||||
t.Fatalf("legacy missing-file validation changed: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
ready := true
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) {
|
||||
allowed := ready
|
||||
ready = false
|
||||
return allowed, nil
|
||||
}}, nil)
|
||||
if w := lifecycleUpload(t, s, key, p.ID, "revoked-during-upload.txt", "PRIVATE"); w.Code != 404 {
|
||||
t.Fatalf("upload persisted after authorization changed: %d", w.Code)
|
||||
}
|
||||
artifacts, err := st.ListProjectArtifactsAll(p.ID)
|
||||
if err != nil || len(artifacts) != 0 {
|
||||
t.Fatal("revoked upload created an artifact")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKnowledgeV2UsesDocumentIdentity(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
docs := []core.KnowledgeDoc{{ID: "published-slice", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle published"}, {ID: "private-draft", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle draft"}, {ID: "self-denied", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own restricted"}, {ID: "self-allowed", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own"}}
|
||||
if err := st.AddKnowledge(docs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
|
||||
seen := map[string]extension.ResourceAuthzQuery{}
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
seen[q.ResourceID] = q
|
||||
return q.ResourceType == "project" || q.ResourceID == "published-slice" || q.ResourceID == "self-allowed", nil
|
||||
})
|
||||
w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sharedneedle", "")
|
||||
var hits []core.RetrievalHit
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &hits)
|
||||
if w.Code != 200 || len(hits) != 2 {
|
||||
t.Fatalf("V2 filtered hits=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
for _, hit := range hits {
|
||||
if hit.Doc.ID != "published-slice" && hit.Doc.ID != "self-allowed" {
|
||||
t.Fatalf("private leak: %+v", hit.Doc)
|
||||
}
|
||||
}
|
||||
q := seen["published-slice"]
|
||||
if q.ResourceType != "knowledge" || q.Actor != "bob" || q.Action != "search" || q.Owner != "alice" || q.ProjectID != "domain-p" {
|
||||
t.Fatalf("trusted doc query: %+v", q)
|
||||
}
|
||||
}
|
||||
func TestArtifactListV2FiltersEachResource(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
private := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Private", MIME: "text/plain"}
|
||||
public := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Shared", MIME: "text/plain"}
|
||||
if err := st.CreateArtifact(&private, []byte("private")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateArtifact(&public, []byte("shared")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return q.ResourceType == "project" || (q.ResourceType == "artifact" && q.Action == "list" && q.ResourceID == public.ID && q.Owner == "alice"), nil
|
||||
})
|
||||
w := lifecycleRequest(t, s, key, "GET", "/api/projects/"+p.ID+"/artifacts", "")
|
||||
var as []core.Artifact
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &as)
|
||||
if w.Code != 200 || len(as) != 1 || as[0].ID != public.ID {
|
||||
t.Fatalf("artifact list=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeV2ResolvesTrustedProjectScope(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { return q.ResourceType == "knowledge", nil })
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
|
||||
return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}, {ID: "host-other", Settings: map[string]any{"rdm.projectId": "domain-other", "rdm.state": "ready"}}}, nil
|
||||
}}, nil)
|
||||
w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=host-current", "")
|
||||
var hits []core.RetrievalHit
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &hits)
|
||||
if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
|
||||
t.Fatalf("current scope=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=unknown", ""); w.Code != 404 {
|
||||
t.Fatalf("unknown scope=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeV2UsesStoredPublicationSource(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
// A known published identity replaced by a private row must lose sharing.
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "known-published-id", Owner: "alice", ProjectID: "domain", Source: "manual", Chunk: "sourceneedle private"}, core.KnowledgeDoc{ID: "real-published-id", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sourceneedle public"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return q.ResourceType == "knowledge" && q.Source == "plugin:rdm:published", nil
|
||||
})
|
||||
w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sourceneedle", "")
|
||||
var hits []core.RetrievalHit
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &hits)
|
||||
if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "real-published-id" {
|
||||
t.Fatalf("actual stored source=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func TestSharedReadPathsConsultV2WithResourceContext(t *testing.T) {
|
||||
t.Fatalf("download 查询六要素不符: %+v", dl)
|
||||
}
|
||||
ls := byAction["list"]
|
||||
if ls.Actor != "bob" || ls.ResourceType != "project" || ls.ResourceID != pid || ls.ProjectID != pid {
|
||||
if ls.Actor != "bob" || ls.ResourceType != "artifact" || ls.ResourceID != art.ID || ls.ProjectID != pid || ls.Owner != "alice" {
|
||||
t.Fatalf("list 查询不符: %+v", ls)
|
||||
}
|
||||
rd := byAction["read"]
|
||||
|
||||
+295
-50
@@ -26,6 +26,7 @@ import (
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/projectscope"
|
||||
"agent-system-backend/internal/prompts"
|
||||
"agent-system-backend/internal/ratelimit"
|
||||
"agent-system-backend/internal/route"
|
||||
@@ -41,24 +42,26 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
st *store.Store
|
||||
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
||||
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
|
||||
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
|
||||
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
||||
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
||||
mgr *runmgr.Manager
|
||||
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
||||
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
|
||||
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
||||
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
||||
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
||||
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
||||
reg *tools.Registry
|
||||
prof *profiles.Service
|
||||
set *settings.Service
|
||||
plugins *plugins.Host
|
||||
static fs.FS
|
||||
projectLifecycle *extension.ProjectLifecycle
|
||||
projectHost func(string) extension.Host
|
||||
st *store.Store
|
||||
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
||||
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
|
||||
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
|
||||
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
||||
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
||||
mgr *runmgr.Manager
|
||||
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
||||
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
|
||||
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
||||
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
||||
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
||||
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
||||
reg *tools.Registry
|
||||
prof *profiles.Service
|
||||
set *settings.Service
|
||||
plugins *plugins.Host
|
||||
static fs.FS
|
||||
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
|
||||
Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
|
||||
// DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与
|
||||
@@ -206,7 +209,7 @@ func (s *Server) WireBuiltinCommands() {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := s.st.CreateConversation(conv.Owner, conv.ProjectID, "新会话", conv.ProfileID)
|
||||
c, err := s.createPrivateConversation(ctx, conv.Owner, conv.ProjectID, "新会话", conv.ProfileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -428,6 +431,7 @@ var sseAuthRecheck = 15 * time.Second
|
||||
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
conversationID := r.URL.Query().Get("conversationId")
|
||||
isProject := false
|
||||
streamProject := ""
|
||||
// 项目流(D50):scope=project&projectId=<id> 订阅项目频道——伪会话键
|
||||
// "project:<id>" 复用会话流的持久化/seq/重放机制;租户边界按项目校验。
|
||||
if r.URL.Query().Get("scope") == "project" {
|
||||
@@ -445,6 +449,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
conversationID = string(core.ProjectChannelKey(core.ID(pid)))
|
||||
isProject = true
|
||||
streamProject = pid
|
||||
} else if conversationID == "" {
|
||||
http.Error(w, "conversationId required", http.StatusBadRequest)
|
||||
return
|
||||
@@ -506,28 +511,38 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
live, stop := s.Subscribe(conversationID)
|
||||
defer stop()
|
||||
|
||||
lastVisible := since
|
||||
send := func(e core.StreamEvent) bool {
|
||||
if e.Seq <= lastVisible {
|
||||
return true
|
||||
}
|
||||
if isProject && !s.projectEventVisible(wantOwner, e) {
|
||||
return r.Context().Err() == nil
|
||||
}
|
||||
buf, _ := json.Marshal(e)
|
||||
fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", e.Seq, e.Type, buf)
|
||||
flusher.Flush()
|
||||
lastVisible = e.Seq
|
||||
return r.Context().Err() == nil
|
||||
}
|
||||
|
||||
lastSent := since
|
||||
// Seen progresses even when a private frame is filtered. Visible only tracks
|
||||
// frames delivered to this actor, preserving authoritative seq and resumption.
|
||||
lastSeen := since
|
||||
for {
|
||||
replay, err := s.st.EventsAfter(conversationID, lastSent, 1000)
|
||||
replay, err := s.st.EventsAfter(conversationID, lastSeen, 1000)
|
||||
if err != nil || len(replay) == 0 {
|
||||
break
|
||||
}
|
||||
for _, e := range replay {
|
||||
if e.Seq <= lastSent {
|
||||
if e.Seq <= lastSeen {
|
||||
continue
|
||||
}
|
||||
if !send(e) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
lastSent = e.Seq
|
||||
lastSeen = e.Seq
|
||||
}
|
||||
if len(replay) < 1000 {
|
||||
break
|
||||
@@ -545,6 +560,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
stop()
|
||||
return
|
||||
case <-recheck.C:
|
||||
if isProject && s.projectLifecycle != nil && !s.projectStreamMembership(wantOwner, streamProject) {
|
||||
fmt.Fprint(w, ": project-access-revoked\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
if !stillAuthed() {
|
||||
fmt.Fprint(w, ": session-expired\n\n")
|
||||
flusher.Flush()
|
||||
@@ -559,14 +579,19 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if e.ConversationID != conversationID || e.Seq <= lastSent {
|
||||
if e.ConversationID != conversationID || e.Seq <= lastSeen {
|
||||
continue
|
||||
}
|
||||
if isProject && s.projectLifecycle != nil && !s.projectStreamMembership(wantOwner, streamProject) {
|
||||
fmt.Fprint(w, ": project-access-revoked\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
if !send(e) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
lastSent = e.Seq
|
||||
lastSeen = e.Seq
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -612,6 +637,23 @@ type projectView struct {
|
||||
|
||||
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
if s.projectLifecycle != nil {
|
||||
if s.projectLifecycle.List == nil {
|
||||
writeProjectErr(w, store.ErrNotFound)
|
||||
return
|
||||
}
|
||||
ps, err := s.projectLifecycle.List(r.Context(), s.lifecycleHost(owner))
|
||||
if err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]projectView, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
out = append(out, projectView{Project: s.publicProjectMetadata(p), Shared: p.Owner != owner})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
ps, err := s.st.ListProjects(owner)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
@@ -648,6 +690,15 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if s.projectLifecycle != nil {
|
||||
if s.projectLifecycle.Create == nil {
|
||||
writeProjectErr(w, store.ErrNotFound)
|
||||
return
|
||||
}
|
||||
p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name))
|
||||
writeProjectJSON(w, s.publicProjectMetadata(p), err)
|
||||
return
|
||||
}
|
||||
p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name))
|
||||
writeJSONOr(w, p, err)
|
||||
}
|
||||
@@ -662,6 +713,58 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Settings != nil {
|
||||
values, err := s.set.PrepareProjectMetadataWrite(req.Settings)
|
||||
if err != nil {
|
||||
s.writeSettingsMerge(w, func() error { return err })
|
||||
return
|
||||
}
|
||||
req.Settings = values
|
||||
}
|
||||
if s.projectLifecycle != nil {
|
||||
var p core.Project
|
||||
var err error
|
||||
if req.Name != nil {
|
||||
if strings.TrimSpace(*req.Name) == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if s.projectLifecycle.Rename == nil {
|
||||
writeProjectErr(w, store.ErrNotFound)
|
||||
return
|
||||
}
|
||||
p, err = s.projectLifecycle.Rename(r.Context(), s.lifecycleHost(s.owner(r)), id, strings.TrimSpace(*req.Name))
|
||||
if err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Settings != nil {
|
||||
if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Name == nil || req.Settings != nil {
|
||||
if s.projectLifecycle.List == nil {
|
||||
writeProjectErr(w, store.ErrNotFound)
|
||||
return
|
||||
}
|
||||
var projects []core.Project
|
||||
projects, err = s.projectLifecycle.List(r.Context(), s.lifecycleHost(s.owner(r)))
|
||||
if err == nil {
|
||||
err = store.ErrNotFound
|
||||
for _, candidate := range projects {
|
||||
if candidate.ID == id {
|
||||
p, err = candidate, nil
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeProjectJSON(w, s.publicProjectMetadata(p), err)
|
||||
return
|
||||
}
|
||||
if req.Name != nil {
|
||||
if err := s.st.RenameProject(s.owner(r), id, *req.Name); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
@@ -679,12 +782,29 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
if s.projectLifecycle != nil {
|
||||
if s.projectLifecycle.Archive == nil {
|
||||
writeProjectErr(w, store.ErrNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.projectLifecycle.Archive(r.Context(), s.lifecycleHost(s.owner(r)), r.PathValue("id")); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.st.DeleteProject(s.owner(r), r.PathValue("id")))
|
||||
}
|
||||
|
||||
func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
|
||||
if s.projectLifecycle != nil {
|
||||
if err := s.projectWork(r.Context(), owner, r.PathValue("id")); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
} else if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -728,13 +848,8 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Title == "" {
|
||||
req.Title = "新会话"
|
||||
}
|
||||
c, err := s.st.CreateConversation(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||||
// EXT-15:自有未命中且适配器放行(成员视角的共享项目)→ 会话仍归属创建者,
|
||||
// 只是落在他人项目下;撤销授权后此路径立即 404(不泄露存在性)。
|
||||
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(s.owner(r), "read", "project", core.ID(r.PathValue("id")), "", core.ID(r.PathValue("id"))) {
|
||||
c, err = s.st.CreateConversationInProject(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||||
}
|
||||
writeJSONOr(w, c, err)
|
||||
c, err := s.createPrivateConversation(r.Context(), s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||||
writeProjectJSON(w, c, err)
|
||||
}
|
||||
|
||||
// conversationDetail 终态对账权威:消息树 + runs + 活动 HITL 卡片。
|
||||
@@ -813,6 +928,10 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
AttachmentIDs []string `json:"attachmentIds"`
|
||||
@@ -968,7 +1087,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if startErr != nil {
|
||||
s.mgr.ReleaseConversation(convID)
|
||||
writeStoreErr(w, startErr)
|
||||
writeProjectErr(w, startErr)
|
||||
return
|
||||
}
|
||||
_ = s.st.SetMessageRunID(msg.ID, run.ID)
|
||||
@@ -1073,10 +1192,21 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var hostProject string
|
||||
if body.ConversationID != "" {
|
||||
if conv, err := s.st.GetConversation(s.owner(r), body.ConversationID); err == nil {
|
||||
conv, err := s.st.GetConversation(s.owner(r), body.ConversationID)
|
||||
if err != nil {
|
||||
if s.projectLifecycle != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
hostProject = string(conv.ProjectID)
|
||||
if err := s.projectWork(r.Context(), s.owner(r), hostProject); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
|
||||
ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r),
|
||||
Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID,
|
||||
@@ -1155,6 +1285,22 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
resp.CardID = r.PathValue("id")
|
||||
if s.projectLifecycle != nil {
|
||||
card, err := s.st.GetHITLCard(s.owner(r), resp.CardID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
conv, err := s.st.GetConversation(s.owner(r), card.ConversationID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.projectWork(r.Context(), s.owner(r), conv.ProjectID); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
pivotText, pivoted, err := s.mgr.ResolveHITL(s.owner(r), string(resp.CardID), resp)
|
||||
if err != nil && s.st.Dialect() == "postgres" {
|
||||
// 跨实例 HITL(U5/KTD5):本地无执行句柄——写持久命令,执行实例
|
||||
@@ -1221,6 +1367,28 @@ func (s *Server) cardToProject(w http.ResponseWriter, r *http.Request) {
|
||||
// cardAction 处理卡上按钮:resolved + 回执消息进对话时间线(确认即留痕)。
|
||||
func (s *Server) cardAction(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
if s.projectLifecycle != nil {
|
||||
card, err := s.st.GetCard(owner, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
pid := card.ProjectID
|
||||
if card.ConversationID != "" {
|
||||
conv, err := s.st.GetConversation(owner, card.ConversationID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
pid = conv.ProjectID
|
||||
}
|
||||
if pid != "" {
|
||||
if err := s.projectWork(r.Context(), owner, pid); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
var body struct {
|
||||
FreeText string `json:"freeText,omitempty"`
|
||||
}
|
||||
@@ -1267,6 +1435,17 @@ func (s *Server) cardDismiss(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := r.PathValue("id")
|
||||
owner := s.owner(r)
|
||||
if s.projectLifecycle != nil {
|
||||
if err := s.projectWork(r.Context(), owner, projectID); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
if _, err := s.st.ProjectMetadata(projectID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
r.ParseMultipartForm(64 << 20)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -1279,10 +1458,18 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
owner := s.owner(r)
|
||||
if _, err := s.st.GetProject(owner, projectID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
if s.projectLifecycle == nil {
|
||||
if _, err := s.st.GetProject(owner, projectID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if s.projectLifecycle != nil {
|
||||
// Request-body reading can outlast the membership/ready decision.
|
||||
if err := s.projectWork(r.Context(), owner, projectID); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
mime := header.Header.Get("Content-Type")
|
||||
art := core.Artifact{ProjectID: projectID, Owner: owner, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime}
|
||||
@@ -1297,11 +1484,14 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
Status: core.CardActive, Tone: "info",
|
||||
Body: []core.UINode{{Type: core.UIProgress, Progress: &core.UIProgressSpec{Label: "解析中", Value: 0.3}}},
|
||||
}
|
||||
if s.Cards != nil {
|
||||
// This endpoint has no private conversation stream. Registered uploads keep
|
||||
// original filename/parser metadata out of shared project cards and events.
|
||||
showIngestCard := s.Cards != nil && s.projectLifecycle == nil
|
||||
if showIngestCard {
|
||||
ingestCard, _ = s.Cards.Create(owner, ingestCard, "")
|
||||
}
|
||||
doc := ingest.Parse(art.Name, mime, content)
|
||||
if s.Cards != nil {
|
||||
if showIngestCard {
|
||||
ingestCard.Status = core.CardDone
|
||||
ingestCard.Tone = "ok"
|
||||
body := []core.UINode{
|
||||
@@ -1345,20 +1535,32 @@ func kindOf(filename string) string {
|
||||
}
|
||||
|
||||
func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
pid := r.PathValue("id")
|
||||
if _, err := s.st.GetProject(owner, pid); err != nil {
|
||||
// EXT-8b:资源级咨询(list×project);放行则返回项目全部工件(只读共享)。
|
||||
owner, pid := s.owner(r), r.PathValue("id")
|
||||
var as []core.Artifact
|
||||
var err error
|
||||
if _, ownedErr := s.st.GetProject(owner, pid); ownedErr != nil {
|
||||
if !s.authorizeSharedResource(owner, "list", "project", core.ID(pid), "", core.ID(pid)) {
|
||||
writeStoreErr(w, err)
|
||||
writeStoreErr(w, ownedErr)
|
||||
return
|
||||
}
|
||||
as, err := s.st.ListProjectArtifactsAll(pid)
|
||||
writeJSONOr(w, as, err)
|
||||
as, err = s.st.ListProjectArtifactsAll(pid)
|
||||
} else {
|
||||
as, err = s.st.ListArtifacts(owner, pid)
|
||||
}
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
as, err := s.st.ListArtifacts(owner, pid)
|
||||
writeJSONOr(w, as, err)
|
||||
if s.ProjectAuthzV2 != nil {
|
||||
filtered := make([]core.Artifact, 0, len(as))
|
||||
for _, a := range as {
|
||||
if s.authorizeSharedResource(owner, "list", "artifact", a.ID, a.Owner, a.ProjectID) {
|
||||
filtered = append(filtered, a)
|
||||
}
|
||||
}
|
||||
as = filtered
|
||||
}
|
||||
writeJSON(w, http.StatusOK, as)
|
||||
}
|
||||
|
||||
// sharedArtifactGet EXT-8 工件读取回退:owner 自有未命中 → 取元数据问适配器,
|
||||
@@ -1847,6 +2049,49 @@ 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.projectLifecycle != nil && query.ProjectID != "" {
|
||||
id, err := projectscope.Resolve(r.Context(), s.lifecycleHost(owner), *s.projectLifecycle, query.ProjectID)
|
||||
if err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
query.ProjectID = id
|
||||
}
|
||||
if s.ProjectAuthzV2 != nil {
|
||||
allow := func(d core.KnowledgeDoc) bool {
|
||||
ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source})
|
||||
return err == nil && ok
|
||||
}
|
||||
if rs, ok := s.Retriever.(interface {
|
||||
RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error)
|
||||
}); ok {
|
||||
hits, err := rs.RetrieveAuthorized(r.Context(), owner, query, allow)
|
||||
writeJSONOr(w, hits, err)
|
||||
return
|
||||
}
|
||||
hits, err := s.st.SearchKnowledge(owner, query)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
filtered := make([]core.RetrievalHit, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
if allow(h.Doc) {
|
||||
filtered = append(filtered, h)
|
||||
}
|
||||
}
|
||||
shared, err := s.st.SearchKnowledgeSharedAuthorized(owner, query, allow)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
filtered = append(filtered, shared...)
|
||||
if query.TopK > 0 && len(filtered) > query.TopK {
|
||||
filtered = filtered[:query.TopK]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, filtered)
|
||||
return
|
||||
}
|
||||
// EXT-8 余项:Retriever 支持共享版(向量+全文两路追加)时整体走共享版;
|
||||
// 否则维持切片 2 行为(本人检索 + 全文共享追加)。
|
||||
allow := func(docOwner, projectID core.ID) bool {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaskedProjectMetadata returns a detached credential-safe view without changing
|
||||
// stored values or dotted service/configuration key shapes. The builtin and
|
||||
// plugin secret schema plus the existing secret-leaf fallback cover legacy data.
|
||||
func (s *Service) MaskedProjectMetadata(values map[string]any) map[string]any {
|
||||
if values == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
schema := Schema()
|
||||
if s != nil {
|
||||
schema = append(schema, s.extraSchema...)
|
||||
}
|
||||
secrets := map[string]bool{}
|
||||
for _, d := range schema {
|
||||
if d.Type == core.SettingSecret || d.Secret {
|
||||
secrets[d.Key] = true
|
||||
}
|
||||
}
|
||||
return maskedProjectValue(values, "", secrets).(map[string]any)
|
||||
}
|
||||
|
||||
func maskedProjectValue(value any, path string, secrets map[string]bool) any {
|
||||
leaf := path
|
||||
if i := strings.LastIndexByte(leaf, '.'); i >= 0 {
|
||||
leaf = leaf[i+1:]
|
||||
}
|
||||
if secrets[path] || isSecretKey(leaf) {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if text, ok := value.(string); ok && text == "" {
|
||||
return text
|
||||
}
|
||||
return MaskPlaceholder
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
if v == nil {
|
||||
return v
|
||||
}
|
||||
out := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
child := key
|
||||
if path != "" {
|
||||
child = path + "." + key
|
||||
}
|
||||
out[key] = maskedProjectValue(item, child, secrets)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
if v == nil {
|
||||
return v
|
||||
}
|
||||
out := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
out[i] = maskedProjectValue(item, path, secrets)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareProjectMetadataWrite applies the same managed-path and mask-placeholder
|
||||
// rules as project settings, while preserving the metadata store's key shapes.
|
||||
// Project metadata lives in projects.settings, not the settings scope table.
|
||||
func (s *Service) PrepareProjectMetadataWrite(values map[string]any) (map[string]any, error) {
|
||||
incoming := dropProjectMaskPlaceholders(values)
|
||||
if err := s.checkManagedWrite(normalizeKeys(incoming)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return incoming, nil
|
||||
}
|
||||
|
||||
// dropProjectMaskPlaceholders prunes a parent only when a previously nonempty
|
||||
// object became empty through mask removal. Explicit empty objects still express
|
||||
// an overwrite and must reach the managed-path guard unchanged.
|
||||
func dropProjectMaskPlaceholders(values map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
if child, ok := value.(map[string]any); ok {
|
||||
prepared := dropProjectMaskPlaceholders(child)
|
||||
if len(child) > 0 && len(prepared) == 0 {
|
||||
continue
|
||||
}
|
||||
out[key] = prepared
|
||||
continue
|
||||
}
|
||||
if text, ok := value.(string); ok && text == MaskPlaceholder {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) checkManagedWrite(incoming map[string]any) error {
|
||||
if hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 {
|
||||
return &ErrManagedPath{Paths: hits}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMaskedProjectMetadataCopiesSecretViews(t *testing.T) {
|
||||
s := NewService(nil)
|
||||
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}})
|
||||
raw := map[string]any{"models.chat.apiKey": "fixture-known", "legacy.token": "fixture-flat", "plugins.private": map[string]any{"credential": "fixture-schema"}, "nested": []any{map[string]any{"api_key": "fixture-array"}}, "opaque.secret": map[string]any{"value": "fixture-opaque"}, "custom.token": []any{"fixture-list"}, "custom.caption": "Allowed", "rdm.projectId": "canonical", "rdm.state": "ready"}
|
||||
before, _ := json.Marshal(raw)
|
||||
view := s.MaskedProjectMetadata(raw)
|
||||
after, _ := json.Marshal(raw)
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("read-only metadata view mutated its source")
|
||||
}
|
||||
encoded, _ := json.Marshal(view)
|
||||
for _, secret := range []string{"fixture-known", "fixture-flat", "fixture-schema", "fixture-array", "fixture-opaque", "fixture-list"} {
|
||||
if bytes.Contains(encoded, []byte(secret)) {
|
||||
t.Fatal("metadata view exposed a fixture secret")
|
||||
}
|
||||
}
|
||||
if view["custom.caption"] != "Allowed" || view["rdm.projectId"] != "canonical" || view["rdm.state"] != "ready" {
|
||||
t.Fatal("nonsecret metadata shape/value changed")
|
||||
}
|
||||
if view["legacy.token"] != MaskPlaceholder || view["opaque.secret"] != MaskPlaceholder || view["custom.token"] != MaskPlaceholder {
|
||||
t.Fatal("secret fallback did not mask complete dotted values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareProjectMetadataWriteUsesManagedGuard(t *testing.T) {
|
||||
s := NewService(nil)
|
||||
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}})
|
||||
for _, raw := range []map[string]any{{"models.chat.apiKey": "fixture"}, {"models": map[string]any{"chat": map[string]any{"apiKey": "fixture"}}}, {"models": "smash"}, {"plugins.private.managed": "override"}} {
|
||||
if _, err := s.PrepareProjectMetadataWrite(raw); err == nil {
|
||||
t.Fatal("managed metadata write accepted")
|
||||
}
|
||||
}
|
||||
raw := map[string]any{"custom.caption": "Allowed", "custom.token": MaskPlaceholder, "models.chat.apiKey": MaskPlaceholder}
|
||||
view, err := s.PrepareProjectMetadataWrite(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(view) != 1 || view["custom.caption"] != "Allowed" {
|
||||
t.Fatal("metadata writes must preserve allowed dotted keys and ignore mask placeholders")
|
||||
}
|
||||
if raw["custom.token"] != MaskPlaceholder {
|
||||
t.Fatal("write preparation mutated its input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareProjectMetadataWritePrunesOnlyMaskedParents(t *testing.T) {
|
||||
s := NewService(nil)
|
||||
for _, raw := range []map[string]any{
|
||||
{"models": map[string]any{"chat": map[string]any{"apiKey": MaskPlaceholder}}, "display": map[string]any{"caption": "Updated"}},
|
||||
{"models.chat": map[string]any{"apiKey": MaskPlaceholder}, "display.caption": "Updated"},
|
||||
{"models": map[string]any{"chat.apiKey": MaskPlaceholder}, "display.caption": "Updated"},
|
||||
} {
|
||||
before, _ := json.Marshal(raw)
|
||||
got, err := s.PrepareProjectMetadataWrite(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unchanged masked credential rejected legal metadata edit: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatal("mask-only parent remained in prepared metadata")
|
||||
}
|
||||
after, _ := json.Marshal(raw)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("mask preparation changed its input")
|
||||
}
|
||||
}
|
||||
for _, raw := range []map[string]any{
|
||||
{"models": map[string]any{}},
|
||||
{"models": map[string]any{"chat": map[string]any{}}},
|
||||
{"models.chat": map[string]any{}},
|
||||
{"models": map[string]any{"chat": map[string]any{"apiKey": MaskPlaceholder, "model": "Real overwrite"}}},
|
||||
{"models.chat": map[string]any{"apiKey": MaskPlaceholder}, "models": map[string]any{"chat": map[string]any{}}},
|
||||
} {
|
||||
if _, err := s.PrepareProjectMetadataWrite(raw); err == nil {
|
||||
t.Fatal("explicit empty or real managed overwrite accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ func (s *Service) SetExtraSchema(descs []core.SettingDesc) { s.extraSchema = des
|
||||
// ManagedSettingPaths 返回全部受管键路径(内置 schema + 插件声明)。
|
||||
func (s *Service) ManagedSettingPaths() []string {
|
||||
paths := core.ManagedPaths(Schema())
|
||||
if len(s.extraSchema) > 0 {
|
||||
if s != nil && len(s.extraSchema) > 0 {
|
||||
paths = append(paths, core.ManagedPaths(s.extraSchema)...)
|
||||
}
|
||||
return paths
|
||||
@@ -238,8 +238,8 @@ func (s *Service) MergeSystem(values map[string]any) error {
|
||||
func (s *Service) mergeScope(owner, scope string, values map[string]any) error {
|
||||
incoming := prepareWrite(values)
|
||||
if scope != ScopeSystem {
|
||||
if hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 {
|
||||
return &ErrManagedPath{Paths: hits}
|
||||
if err := s.checkManagedWrite(incoming); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) {
|
||||
|
||||
@@ -388,6 +388,66 @@ func (s *Store) SearchKnowledgeShared(requester string, q core.RetrievalQuery, a
|
||||
return s.searchKnowledgeSQLiteShared(requester, q, pred)
|
||||
}
|
||||
|
||||
// SearchKnowledgeSharedAuthorized evaluates the trusted document identity before
|
||||
// returning a shared row. Project-level permissions cannot grant private content.
|
||||
func (s *Store) SearchKnowledgeSharedAuthorized(requester string, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
|
||||
pred := func(d core.KnowledgeDoc) bool {
|
||||
return d.Owner != "" && d.Owner != requester && d.ProjectID != "" &&
|
||||
(q.ProjectID == "" || d.ProjectID == q.ProjectID) &&
|
||||
(len(q.Sources) == 0 || containsStr(q.Sources, d.Source)) && allow(d)
|
||||
}
|
||||
if s.dialect.name() == "postgres" {
|
||||
return s.searchKnowledgePGSharedCandidates(requester, q, pred, false)
|
||||
}
|
||||
out, err := s.searchKnowledgeFTSCandidates(pred, q, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 && q.Text != "" {
|
||||
return s.searchKnowledgeLikeSharedCandidates(requester, q, pred, false)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteProjectKnowledgeByRef preserves the ordinary legacy replacement scope.
|
||||
// Registered lifecycle publishers use DeletePluginProjectKnowledgeByRef instead.
|
||||
func (s *Store) DeleteProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref string) error {
|
||||
args := []any{owner, ref}
|
||||
pred := "owner=? AND ref=?"
|
||||
if projectID != "" {
|
||||
pred += " AND project_id=?"
|
||||
args = append(args, projectID)
|
||||
}
|
||||
return s.deleteKnowledgeAndVectors(ctx, pred, args...)
|
||||
}
|
||||
|
||||
// DeletePluginProjectKnowledgeByRef replaces only this actor/plugin publication
|
||||
// scope. Empty project is an exact unscoped domain, never a wildcard. The source
|
||||
// prefix is compared literally, including any percent/underscore in plugin IDs.
|
||||
func (s *Store) DeletePluginProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref, pluginID string) error {
|
||||
if owner == "" || pluginID == "" {
|
||||
return errors.New("store: publication owner and plugin required")
|
||||
}
|
||||
prefix := "plugin:" + pluginID
|
||||
pred := `owner=? AND project_id=? AND ref=? AND (source=? OR substr(source,1,length(?))=?)`
|
||||
return s.deleteKnowledgeAndVectors(ctx, pred, owner, projectID, ref, prefix, prefix+":", prefix+":")
|
||||
}
|
||||
|
||||
func (s *Store) deleteKnowledgeAndVectors(ctx context.Context, pred string, args ...any) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
out, err := s.searchKnowledgeFTS(ownerVisible(owner, q), q)
|
||||
if err != nil {
|
||||
@@ -419,14 +479,23 @@ func (s *Store) searchKnowledgeSQLiteShared(requester string, q core.RetrievalQu
|
||||
|
||||
// searchKnowledgeFTS FTS 通道(owner 作用域与共享作用域共用:可见性谓词由调用方注入)。
|
||||
func (s *Store) searchKnowledgeFTS(pred knowledgePred, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
return s.searchKnowledgeFTSCandidates(pred, q, true)
|
||||
}
|
||||
|
||||
func (s *Store) searchKnowledgeFTSCandidates(pred knowledgePred, q core.RetrievalQuery, bounded bool) ([]core.RetrievalHit, error) {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank
|
||||
FROM knowledge_fts f JOIN knowledge k ON k.rowid=f.rowid
|
||||
WHERE knowledge_fts MATCH ? ORDER BY rank LIMIT ?`), ftsQuery(q.Text), topK*2)
|
||||
query := `SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank
|
||||
FROM knowledge_fts f JOIN knowledge k ON k.rowid=f.rowid
|
||||
WHERE knowledge_fts MATCH ? ORDER BY rank`
|
||||
args := []any{ftsQuery(q.Text)}
|
||||
if bounded {
|
||||
query += " LIMIT ?"
|
||||
args = append(args, topK*2)
|
||||
}
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -449,12 +518,21 @@ func (s *Store) searchKnowledgeFTS(pred knowledgePred, q core.RetrievalQuery) ([
|
||||
}
|
||||
|
||||
func (s *Store) searchKnowledgeLikeShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
|
||||
return s.searchKnowledgeLikeSharedCandidates(requester, q, pred, true)
|
||||
}
|
||||
|
||||
func (s *Store) searchKnowledgeLikeSharedCandidates(requester string, q core.RetrievalQuery, pred knowledgePred, bounded bool) ([]core.RetrievalHit, error) {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
rows, err := s.db.Query(s.q(`SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE owner<>? AND owner<>'' AND (chunk LIKE ? OR title LIKE ?) LIMIT ?`),
|
||||
requester, "%"+q.Text+"%", "%"+q.Text+"%", topK)
|
||||
query := `SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE owner<>? AND owner<>'' AND (chunk LIKE ? OR title LIKE ?)`
|
||||
args := []any{requester, "%" + q.Text + "%", "%" + q.Text + "%"}
|
||||
if bounded {
|
||||
query += " LIMIT ?"
|
||||
args = append(args, topK)
|
||||
}
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthorizedKnowledgeFiltersBeforeTopK(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
docs := []core.KnowledgeDoc{}
|
||||
for i := 0; i < 20; i++ {
|
||||
docs = append(docs, core.KnowledgeDoc{Owner: "alice", ProjectID: "domain", Source: "private", Chunk: "sharedterm sharedterm sharedterm sharedterm"})
|
||||
}
|
||||
docs = append(docs, core.KnowledgeDoc{ID: "published", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sharedterm plus additional explanatory words for this lower scoring published source"})
|
||||
if err := st.AddKnowledge(docs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, err := st.SearchKnowledgeSharedAuthorized("bob", core.RetrievalQuery{Text: "sharedterm", ProjectID: "domain", TopK: 1}, func(d core.KnowledgeDoc) bool { return d.ID == "published" })
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "published" {
|
||||
t.Fatalf("denied rows consumed top-k: %+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProjectPublicationDeleteUsesExactScope(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
docs := []core.KnowledgeDoc{{ID: "unscoped", Owner: "alice", Source: "plugin:rdm", Ref: "same", Chunk: "unscoped"}, {ID: "scoped", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "same", Chunk: "scoped"}, {ID: "foreign", Owner: "alice", Source: "plugin:other", Ref: "same", Chunk: "foreign"}, {ID: "other_owner", Owner: "bob", Source: "plugin:rdm", Ref: "same", Chunk: "other owner"}}
|
||||
if err := st.AddKnowledge(docs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range docs {
|
||||
if err := st.UpsertKnowledgeVector(ctx, d.Owner, d.ID, d.Ref, []float32{1, 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := st.DeletePluginProjectKnowledgeByRef(ctx, "alice", "", "same", "rdm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, table := range []string{"knowledge", "knowledge_vectors"} {
|
||||
var count int
|
||||
if err := st.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 3 {
|
||||
t.Fatalf("exact empty-project namespace %s count=%d error=%v", table, count, err)
|
||||
}
|
||||
}
|
||||
if err := st.DeletePluginProjectKnowledgeByRef(ctx, "alice", "domain", "same", "rdm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"foreign", "other_owner"} {
|
||||
var count int
|
||||
if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM knowledge WHERE id=?`), id).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("protected row %s count=%d error=%v", id, count, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginPublicationNamespaceIsLiteral(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
docs := []core.KnowledgeDoc{{ID: "owned", Owner: "alice", Source: "plugin:rdm_%", Ref: "same", Chunk: "owned"}, {ID: "owned_suffix", Owner: "alice", Source: "plugin:rdm_%:published", Ref: "same", Chunk: "owned suffix"}, {ID: "protected", Owner: "alice", Source: "plugin:rdmXYZ:published", Ref: "same", Chunk: "protected"}}
|
||||
if err := st.AddKnowledge(docs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.DeletePluginProjectKnowledgeByRef(context.Background(), "alice", "", "same", "rdm_%"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var id string
|
||||
if err := st.db.QueryRow(`SELECT id FROM knowledge`).Scan(&id); err != nil || id != "protected" {
|
||||
t.Fatalf("wildcards broadened namespace: id=%s error=%v", id, err)
|
||||
}
|
||||
}
|
||||
@@ -266,6 +266,10 @@ func (s *Store) ChildRuns(runID string) ([]core.Run, error) {
|
||||
// 成功后回填 evs[i].Seq。事务内锁定会话序列:PG 用事务级咨询锁(跨实例安全),
|
||||
// SQLite 单写连接天然串行。空批次为空操作。
|
||||
func (s *Store) AppendEvents(conversationID string, evs []core.StreamEvent) (int64, error) {
|
||||
return s.appendEvents(conversationID, evs, "", "")
|
||||
}
|
||||
|
||||
func (s *Store) appendEvents(conversationID string, evs []core.StreamEvent, owner, producer string) (int64, error) {
|
||||
if len(evs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -298,6 +302,13 @@ func (s *Store) AppendEvents(conversationID string, evs []core.StreamEvent) (int
|
||||
if _, err := tx.Exec(s.q(sb.String()), args...); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if producer != "" {
|
||||
for i, e := range evs {
|
||||
if err := s.saveProjectEventProof(tx, conversationID, base.Int64+int64(i)+1, e, owner, producer); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
// KTD6:事件提交与 NOTIFY 同事务(outbox 语义的前半——通知不早于事件
|
||||
// 可见;后半的命令 outbox 走 EnqueueCommandInTx 由批准路径使用)。
|
||||
if s.dialect.name() == "postgres" {
|
||||
|
||||
@@ -841,6 +841,13 @@ func init() {
|
||||
`DROP INDEX IF EXISTS idx_redeem_attempts_ip_at`,
|
||||
`DROP TABLE IF EXISTS redeem_attempts`,
|
||||
}}},
|
||||
migration{version: 30, name: "project_event_publication_proofs",
|
||||
up: map[string][]string{
|
||||
"sqlite": {`CREATE TABLE project_event_shares(channel TEXT NOT NULL,seq INTEGER NOT NULL,event_type TEXT NOT NULL,payload_sha256 TEXT NOT NULL,owner TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id TEXT NOT NULL,producer TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(channel,seq),FOREIGN KEY(channel,seq) REFERENCES events(conversation_id,seq) ON DELETE CASCADE)`},
|
||||
"postgres": {`CREATE TABLE project_event_shares(channel TEXT NOT NULL,seq BIGINT NOT NULL,event_type TEXT NOT NULL,payload_sha256 TEXT NOT NULL,owner TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id TEXT NOT NULL,producer TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(channel,seq),FOREIGN KEY(channel,seq) REFERENCES events(conversation_id,seq) ON DELETE CASCADE)`},
|
||||
},
|
||||
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS project_event_shares`}, "postgres": {`DROP TABLE IF EXISTS project_event_shares`}},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ var migrationBreaksOldReaders = map[int]bool{
|
||||
27: false,
|
||||
28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响
|
||||
29: false, // redeem_attempts:新表,旧读者不受影响
|
||||
30: false, // project_event_publication_proofs:新表,旧读者不受影响
|
||||
}
|
||||
|
||||
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProjectEventAccess is trusted read metadata; payload claims never establish it.
|
||||
type ProjectEventAccess struct {
|
||||
Owner string
|
||||
Shared bool
|
||||
Producer string
|
||||
}
|
||||
type eventResource struct{ owner, project, conversation, kind, id string }
|
||||
type eventQuery interface{ QueryRow(string, ...any) *sql.Row }
|
||||
|
||||
func cardEventID(e core.StreamEvent) (string, error) {
|
||||
switch e.Type {
|
||||
case core.EvCardCreated, core.EvCardUpdated, core.EvCardStatus, core.EvCardResolved:
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
var payload struct {
|
||||
Card struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"card"`
|
||||
CardID string `json:"cardId"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Payload, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch e.Type {
|
||||
case core.EvCardCreated, core.EvCardUpdated:
|
||||
return payload.Card.ID, nil
|
||||
case core.EvCardStatus, core.EvCardResolved:
|
||||
return payload.CardID, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
func eventPayloadHash(payload []byte) string {
|
||||
hash := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func (s *Store) projectEventResource(q eventQuery, e core.StreamEvent) (eventResource, error) {
|
||||
var resource eventResource
|
||||
id, err := cardEventID(e)
|
||||
if err != nil {
|
||||
return resource, err
|
||||
}
|
||||
switch e.Type {
|
||||
case core.EvCardCreated, core.EvCardUpdated, core.EvCardStatus, core.EvCardResolved:
|
||||
if id == "" {
|
||||
return resource, ErrNotFound
|
||||
}
|
||||
}
|
||||
if id != "" {
|
||||
resource.kind, resource.id = "card", id
|
||||
err = q.QueryRow(s.q(`SELECT owner,project_id,conversation_id FROM activity_cards WHERE id=?`), id).Scan(&resource.owner, &resource.project, &resource.conversation)
|
||||
} else if e.Type == core.EvArtifactCreated {
|
||||
var payload struct {
|
||||
Artifact struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"artifact"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Payload, &payload); err != nil {
|
||||
return resource, err
|
||||
}
|
||||
if payload.Artifact.ID == "" {
|
||||
return resource, ErrNotFound
|
||||
}
|
||||
resource.kind, resource.id = "artifact", payload.Artifact.ID
|
||||
err = q.QueryRow(s.q(`SELECT owner,project_id FROM artifacts WHERE id=?`), resource.id).Scan(&resource.owner, &resource.project)
|
||||
} else if e.RunID != "" {
|
||||
resource.kind, resource.id = "run", e.RunID
|
||||
err = q.QueryRow(s.q(`SELECT owner,project_id,conversation_id FROM runs WHERE id=?`), e.RunID).Scan(&resource.owner, &resource.project, &resource.conversation)
|
||||
} else {
|
||||
return resource, ErrNotFound
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return resource, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return resource, err
|
||||
}
|
||||
project, ok := core.ProjectFromChannel(e.ConversationID)
|
||||
if !ok || resource.owner == "" || resource.project != project || (e.ProjectID != "" && e.ProjectID != project) {
|
||||
return resource, ErrNotFound
|
||||
}
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
// ResolveProjectEventAccess resolves immutable resource ownership and verifies an
|
||||
// exact internal publication record. Old events retain their private default.
|
||||
func (s *Store) ResolveProjectEventAccess(e core.StreamEvent) (ProjectEventAccess, error) {
|
||||
resource, err := s.projectEventResource(s.db, e)
|
||||
if err != nil {
|
||||
return ProjectEventAccess{}, err
|
||||
}
|
||||
access := ProjectEventAccess{Owner: resource.owner}
|
||||
var owner, kind, id, producer, typ, hash string
|
||||
err = s.db.QueryRow(s.q(`SELECT owner,resource_type,resource_id,producer,event_type,payload_sha256 FROM project_event_shares WHERE channel=? AND seq=?`), e.ConversationID, e.Seq).Scan(&owner, &kind, &id, &producer, &typ, &hash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return access, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ProjectEventAccess{}, err
|
||||
}
|
||||
if owner != resource.owner || kind != resource.kind || id != resource.id || strings.TrimSpace(producer) == "" || typ != string(e.Type) || hash != eventPayloadHash(e.Payload) || resource.conversation != "" {
|
||||
return ProjectEventAccess{}, ErrNotFound
|
||||
}
|
||||
access.Shared, access.Producer = true, producer
|
||||
return access, nil
|
||||
}
|
||||
|
||||
// AppendProjectSharedEvents is an internal publication boundary, not an API or
|
||||
// model tool. Events and their exact sharing proofs commit together. Caller
|
||||
// identity must own a project-level card; private conversation cards cannot be
|
||||
// published through this structural-card path.
|
||||
func (s *Store) AppendProjectSharedEvents(owner, producer, channel string, events []core.StreamEvent) (int64, error) {
|
||||
if owner == "" || strings.TrimSpace(producer) == "" {
|
||||
return 0, fmt.Errorf("store: shared event requires owner and producer")
|
||||
}
|
||||
if _, ok := core.ProjectFromChannel(channel); !ok {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
return s.appendEvents(channel, events, owner, producer)
|
||||
}
|
||||
|
||||
func (s *Store) saveProjectEventProof(tx *sql.Tx, channel string, seq int64, e core.StreamEvent, owner, producer string) error {
|
||||
e.ConversationID = channel
|
||||
resource, err := s.projectEventResource(tx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resource.kind != "card" || resource.owner != owner || resource.conversation != "" {
|
||||
return ErrNotFound
|
||||
}
|
||||
_, err = tx.Exec(s.q(`INSERT INTO project_event_shares(channel,seq,event_type,payload_sha256,owner,resource_type,resource_id,producer,created_at) VALUES(?,?,?,?,?,?,?,?,?)`), channel, seq, string(e.Type), eventPayloadHash(e.Payload), resource.owner, resource.kind, resource.id, producer, nowISO())
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func storedProjectCardEvent(t *testing.T, st *Store, owner, project, id string) core.StreamEvent {
|
||||
t.Helper()
|
||||
card := core.ActivityCard{ID: id, ProjectID: project, Title: "PRIVATE", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
|
||||
if err := st.SaveCard(owner, &card, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, _ := json.Marshal(core.CardPayload{Card: card})
|
||||
return core.StreamEvent{ConversationID: core.ProjectChannelKey(project), ProjectID: project, Type: core.EvCardCreated, Payload: payload, TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1}
|
||||
}
|
||||
func TestProjectEventSharedProofBindsExactEvent(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
e := storedProjectCardEvent(t, st, "alice", p.ID, "card-a")
|
||||
private := []core.StreamEvent{e}
|
||||
if _, err := st.AppendEvents(e.ConversationID, private); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
access, err := st.ResolveProjectEventAccess(private[0])
|
||||
if err != nil || access.Owner != "alice" || access.Shared {
|
||||
t.Fatalf("private access=%+v %v", access, err)
|
||||
}
|
||||
shared := []core.StreamEvent{e}
|
||||
if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", e.ConversationID, shared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
access, err = st.ResolveProjectEventAccess(shared[0])
|
||||
if err != nil || !access.Shared || access.Owner != "alice" {
|
||||
t.Fatalf("shared proof=%+v %v", access, err)
|
||||
}
|
||||
access, err = st.ResolveProjectEventAccess(private[0])
|
||||
if err != nil || access.Shared {
|
||||
t.Fatal("publication retroactively shared private history")
|
||||
}
|
||||
for _, mutate := range []func(*core.StreamEvent){func(e *core.StreamEvent) { e.Type = core.EvCardUpdated }, func(e *core.StreamEvent) { e.Payload = append(e.Payload, ' ') }, func(e *core.StreamEvent) { e.Seq += 100 }, func(e *core.StreamEvent) { e.ConversationID = "project:another" }, func(e *core.StreamEvent) { e.ProjectID = "another" }} {
|
||||
changed := shared[0]
|
||||
mutate(&changed)
|
||||
access, err = st.ResolveProjectEventAccess(changed)
|
||||
if err == nil && access.Shared {
|
||||
t.Fatal("mutated event retained sharing")
|
||||
}
|
||||
}
|
||||
bad := []core.StreamEvent{e}
|
||||
before, _ := st.LastEventSeq(e.ConversationID)
|
||||
if _, err := st.AppendProjectSharedEvents("bob", "plugin:rdm:structure", e.ConversationID, bad); err == nil {
|
||||
t.Fatal("other owner issued publication")
|
||||
}
|
||||
after, _ := st.LastEventSeq(e.ConversationID)
|
||||
if after != before {
|
||||
t.Fatal("rejected publication appended event")
|
||||
}
|
||||
}
|
||||
func TestProjectEventMigrationPreservesPrivateHistory(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.MigrateDown(29); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ := st.CreateProject("alice", "Legacy")
|
||||
e := storedProjectCardEvent(t, st, "alice", p.ID, "legacy-card")
|
||||
events := []core.StreamEvent{e}
|
||||
if _, err := st.AppendEvents(e.ConversationID, events); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := st.EventsAfter(e.ConversationID, 0, 100)
|
||||
if err := st.migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := st.EventsAfter(e.ConversationID, 0, 100)
|
||||
b, _ := json.Marshal(before)
|
||||
a, _ := json.Marshal(after)
|
||||
if string(a) != string(b) {
|
||||
t.Fatal("upgrade changed private event history")
|
||||
}
|
||||
access, err := st.ResolveProjectEventAccess(after[0])
|
||||
if err != nil || access.Owner != "alice" || access.Shared {
|
||||
t.Fatalf("legacy privacy=%+v %v", access, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectSharedEventBatchRollsBackProofAndEvents(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
a := storedProjectCardEvent(t, st, "alice", p.ID, "card-a")
|
||||
b := storedProjectCardEvent(t, st, "bob", p.ID, "card-b")
|
||||
if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", a.ConversationID, []core.StreamEvent{a, b}); err == nil {
|
||||
t.Fatal("mixed owner batch published")
|
||||
}
|
||||
var count int
|
||||
for _, table := range []string{"events", "project_event_shares"} {
|
||||
if err := st.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("partial %s publication: count=%d error=%v", table, count, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectEventInvalidProofAndMissingOwnerFailClosed(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
e := storedProjectCardEvent(t, st, "alice", p.ID, "card-a")
|
||||
shared := []core.StreamEvent{e}
|
||||
if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", e.ConversationID, shared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(`UPDATE project_event_shares SET producer=''`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if access, err := st.ResolveProjectEventAccess(shared[0]); err == nil || access.Shared {
|
||||
t.Fatal("corrupted publication proof was accepted")
|
||||
}
|
||||
if _, err := st.db.Exec(`UPDATE activity_cards SET owner=''`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ResolveProjectEventAccess(shared[0]); err == nil {
|
||||
t.Fatal("missing resource owner was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectSharingRejectsPrivateConversationCards(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
e := storedProjectCardEvent(t, st, "alice", p.ID, "card-a")
|
||||
if _, err := st.db.Exec(`UPDATE activity_cards SET conversation_id='private-conversation'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", e.ConversationID, []core.StreamEvent{e}); err == nil {
|
||||
t.Fatal("private conversation card publicly marked")
|
||||
}
|
||||
events, err := st.EventsAfter(e.ConversationID, 0, 100)
|
||||
if err != nil || len(events) != 0 {
|
||||
t.Fatal("rejected conversation publication appended events")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// lockProjectNames serializes projection identity and name admission across all
|
||||
// owners. SQLite acquires its write reservation before any reads; PostgreSQL
|
||||
// uses a transaction-scoped advisory lock shared by every projection mutation.
|
||||
func (s *Store) lockProjectNames(tx *sql.Tx) error {
|
||||
if s.dialect.name() == "sqlite" {
|
||||
_, err := tx.Exec(`UPDATE projects SET id=id WHERE 1=0`)
|
||||
return err
|
||||
}
|
||||
return s.dialect.lockScope(tx, "", "host-project-names")
|
||||
}
|
||||
|
||||
// EnsureProject creates a stable projection or verifies an identical retry.
|
||||
func (s *Store) EnsureProject(owner, id, name string) (core.Project, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if owner == "" || id == "" || name == "" {
|
||||
return core.Project{}, errors.New("store: project owner, id and name required")
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = s.lockProjectNames(tx); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
p, err := s.projectMetadata(tx, id)
|
||||
if err == nil {
|
||||
if p.Owner != owner {
|
||||
return core.Project{}, ErrNotFound
|
||||
}
|
||||
if p.Name != name {
|
||||
return core.Project{}, ErrConflict
|
||||
}
|
||||
return p, tx.Commit()
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return core.Project{}, err
|
||||
}
|
||||
available, err := s.projectNameAvailable(tx, name, id)
|
||||
if err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
if !available {
|
||||
return core.Project{}, ErrConflict
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
p = core.Project{ID: id, Owner: owner, Name: name, CreatedAt: now, UpdatedAt: now, Settings: map[string]any{}}
|
||||
_, err = tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`), id, owner, name, "{}", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
return p, tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) RenameOwnedProject(owner, id, name string) (core.Project, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return core.Project{}, errors.New("store: project name required")
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = s.lockProjectNames(tx); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
p, err := s.projectMetadata(tx, id)
|
||||
if err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
if p.Owner != owner {
|
||||
return core.Project{}, ErrNotFound
|
||||
}
|
||||
available, err := s.projectNameAvailable(tx, name, id)
|
||||
if err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
if !available {
|
||||
return core.Project{}, ErrConflict
|
||||
}
|
||||
p.Name, p.UpdatedAt = name, time.Now().UTC()
|
||||
_, err = tx.Exec(s.q(`UPDATE projects SET name=?,updated_at=? WHERE id=? AND owner=?`), p.Name, p.UpdatedAt.Format(time.RFC3339Nano), id, owner)
|
||||
if err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
return p, tx.Commit()
|
||||
}
|
||||
|
||||
type projectQuerier interface {
|
||||
QueryRow(string, ...any) *sql.Row
|
||||
Query(string, ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
func (s *Store) projectNameAvailable(q projectQuerier, name, exceptID string) (bool, error) {
|
||||
// Use the same Go whitespace rule as admission, including legacy raw names.
|
||||
rows, err := q.Query(s.q(`SELECT name FROM projects WHERE id<>?`), exceptID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
name = strings.TrimSpace(name)
|
||||
for rows.Next() {
|
||||
var candidate string
|
||||
if err := rows.Scan(&candidate); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(candidate) == name {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ProjectNameAvailable(name, exceptID string) (bool, error) {
|
||||
return s.projectNameAvailable(s.db, name, exceptID)
|
||||
}
|
||||
func (s *Store) projectMetadata(q projectQuerier, id string) (core.Project, error) {
|
||||
var p core.Project
|
||||
var settings, ca, ua string
|
||||
err := q.QueryRow(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects WHERE id=?`), id).Scan(&p.ID, &p.Owner, &p.Name, &settings, &ca, &ua)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return p, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
|
||||
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
|
||||
p.Settings = map[string]any{}
|
||||
if settings != "" && settings != "{}" {
|
||||
p.Settings, _ = unmarshalJSON[map[string]any](settings)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ProjectMetadata is an internal trusted lookup. Capabilities must authorize it
|
||||
// before exposing metadata to another owner.
|
||||
func (s *Store) ProjectMetadata(id string) (core.Project, error) { return s.projectMetadata(s.db, id) }
|
||||
@@ -0,0 +1,83 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProjectProjectionIdentityAndOwnership(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
p, err := st.EnsureProject("alice", "host-stable", " Global ")
|
||||
if err != nil || p.ID != "host-stable" || p.Name != "Global" || p.Owner != "alice" {
|
||||
t.Fatalf("ensure: %+v %v", p, err)
|
||||
}
|
||||
retry, err := st.EnsureProject("alice", p.ID, "Global")
|
||||
if err != nil || retry.ID != p.ID {
|
||||
t.Fatalf("retry: %+v %v", retry, err)
|
||||
}
|
||||
if _, err = st.EnsureProject("bob", p.ID, "Global"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("other owner: %v", err)
|
||||
}
|
||||
if _, err = st.EnsureProject("alice", p.ID, "Different"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("different retry name: %v", err)
|
||||
}
|
||||
if _, err = st.RenameOwnedProject("bob", p.ID, "Other"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("rename owner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectProjectionGlobalNameAdmission(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if _, err := st.CreateProject("legacy", "Taken"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.EnsureProject("alice", "a", " Taken "); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("legacy collision: %v", err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
wg.Add(1)
|
||||
go func(owner string) {
|
||||
defer wg.Done()
|
||||
_, err := st.EnsureProject(owner, "p-"+owner, "Race")
|
||||
results <- err
|
||||
}(owner)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
successes, conflicts := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
successes++
|
||||
} else if errors.Is(err, ErrConflict) {
|
||||
conflicts++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if successes != 1 || conflicts != 1 {
|
||||
t.Fatalf("successes=%d conflicts=%d", successes, conflicts)
|
||||
}
|
||||
if _, err := st.EnsureProject("alice", "rename-a", "Rename source"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.RenameOwnedProject("alice", "rename-a", "Taken"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("rename collision: %v", err)
|
||||
}
|
||||
available, err := st.ProjectNameAvailable("Taken", "")
|
||||
if err != nil || available {
|
||||
t.Fatalf("availability=%v %v", available, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectProjectionAdmissionTrimsLegacyWhitespace(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if _, err := st.CreateProject("legacy", "\tReserved\u00a0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.EnsureProject("other", "new-id", "Reserved"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("trimmed legacy admission: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -53,19 +53,28 @@ func (s *Store) searchKnowledgePG(owner string, q core.RetrievalQuery) ([]core.R
|
||||
// searchKnowledgePGShared EXT-8 切片 2 共享版:他人 owner 的行 + 谓词后置判定
|
||||
// (allow 回调由上层注入;排序与自检索同构)。
|
||||
func (s *Store) searchKnowledgePGShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
|
||||
return s.searchKnowledgePGSharedCandidates(requester, q, pred, true)
|
||||
}
|
||||
|
||||
func (s *Store) searchKnowledgePGSharedCandidates(requester string, q core.RetrievalQuery, pred knowledgePred, bounded bool) ([]core.RetrievalHit, error) {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
text := q.Text
|
||||
rows, err := s.db.Query(s.q(`
|
||||
query := `
|
||||
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at,
|
||||
ts_rank(k.tsv, websearch_to_tsquery('simple', ?)) AS r
|
||||
FROM knowledge k
|
||||
WHERE (k.owner<>? AND k.owner<>'')
|
||||
AND (? = '' OR k.tsv @@ websearch_to_tsquery('simple', ?) OR k.chunk LIKE ? OR k.title LIKE ?)
|
||||
ORDER BY r DESC, k.created_at DESC
|
||||
LIMIT ?`), text, requester, text, text, "%"+text+"%", "%"+text+"%", topK*2)
|
||||
ORDER BY r DESC, k.created_at DESC`
|
||||
args := []any{text, requester, text, text, "%" + text + "%", "%" + text + "%"}
|
||||
if bounded {
|
||||
query += " LIMIT ?"
|
||||
args = append(args, topK*2)
|
||||
}
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -121,7 +121,16 @@ func (s *Store) SearchKnowledgeVectorsSharedModel(ctx context.Context, requester
|
||||
return hits, nil
|
||||
}
|
||||
}
|
||||
return s.searchKnowledgeVectorsSharedScan(ctx, requester, model, vec, topK, filter, allow)
|
||||
return s.SearchKnowledgeVectorsSharedAuthorized(ctx, requester, model, vec, topK, filter, func(d core.KnowledgeDoc) bool { return allow == nil || allow(d.Owner, d.ProjectID) })
|
||||
}
|
||||
|
||||
// SearchKnowledgeVectorsSharedAuthorized applies per-document authorization to
|
||||
// shared vector candidates before ranking, including their actual stored ID.
|
||||
func (s *Store) SearchKnowledgeVectorsSharedAuthorized(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
return s.searchKnowledgeVectorsSharedAuthorizedScan(ctx, requester, model, vec, topK, filter, allow)
|
||||
}
|
||||
|
||||
// allowAll 判定谓词是否恒真(nil 或恒放行)——库内路径的启用条件。
|
||||
@@ -187,6 +196,9 @@ func (s *Store) searchKnowledgeVectorsPG(ctx context.Context, owner, model strin
|
||||
|
||||
// searchKnowledgeVectorsSharedScan 原进程内共享扫描(降级/谓词路径)。
|
||||
func (s *Store) searchKnowledgeVectorsSharedScan(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
|
||||
return s.searchKnowledgeVectorsSharedAuthorizedScan(ctx, requester, model, vec, topK, filter, func(d core.KnowledgeDoc) bool { return allow == nil || allow(d.Owner, d.ProjectID) })
|
||||
}
|
||||
func (s *Store) searchKnowledgeVectorsSharedAuthorizedScan(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
|
||||
q := `
|
||||
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, v.vec
|
||||
FROM knowledge_vectors v JOIN knowledge k ON k.id = v.doc_id
|
||||
@@ -215,7 +227,7 @@ func (s *Store) searchKnowledgeVectorsSharedScan(ctx context.Context, requester,
|
||||
}
|
||||
d.Owner = own
|
||||
d.CreatedAt, _ = parseTime(ca)
|
||||
if d.ProjectID == "" || !allow(d.Owner, d.ProjectID) {
|
||||
if d.ProjectID == "" || !allow(d) {
|
||||
continue
|
||||
}
|
||||
if filter.ProjectID != "" && d.ProjectID != filter.ProjectID {
|
||||
|
||||
@@ -5,14 +5,21 @@ package extension
|
||||
// 不含姓名/邮箱/联系方式)。红:类型未定义,编译红先行。
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAccountListerSurface(t *testing.T) {
|
||||
if !strings.HasPrefix(SurfaceVersion, "2.11.0") {
|
||||
t.Fatalf("SurfaceVersion 应升至 2.11.0-experimental(新增导出符号须升版),got %s", SurfaceVersion)
|
||||
parts := strings.Split(SurfaceVersion, ".")
|
||||
minor := 0
|
||||
if len(parts) > 1 {
|
||||
minor, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
if len(parts) < 2 || parts[0] != "2" || minor < 11 {
|
||||
t.Fatalf("AccountLister requires experimental version 2.11 or later, got %s", SurfaceVersion)
|
||||
}
|
||||
|
||||
// 接口方法签名即契约:游标分页 + 最小字段。
|
||||
var _ AccountLister = (AccountLister)(nil)
|
||||
e := AccountEntry{Owner: "u1", Valid: true}
|
||||
|
||||
@@ -32,7 +32,9 @@ import (
|
||||
// 2.9.0-experimental:Registry.AddAdminZone 与 AdminZoneSpec(WP3-I3/KTD4:编译
|
||||
// 插件向基座管理区声明展示分区;分区入口不做基座级权限判定,数据能力由
|
||||
// 分区组件自身的命令面 fail-closed)。
|
||||
const SurfaceVersion = "2.11.0-experimental"
|
||||
// 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted
|
||||
// ResourceAuthzQuery.Source for per-document publication authorization.
|
||||
const SurfaceVersion = "2.12.0-experimental"
|
||||
|
||||
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||||
@@ -43,15 +45,17 @@ var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-
|
||||
type ResourceAuthorizer func(owner string, projectID core.ID) (bool, error)
|
||||
|
||||
// ResourceAuthzQuery 是 EXT-8b 资源级授权查询(U9):字段由执行端从受信上下文
|
||||
//(认证会话、受信存储)构造,不采信请求自报的归属;下游只回答布尔,不接触基座
|
||||
// (认证会话、受信存储)构造,不采信请求自报的归属;下游只回答布尔,不接触基座
|
||||
// 内部状态;返回 error 视为拒绝(fail-closed)。读取路径专用;写路径一律 owner 自有。
|
||||
type ResourceAuthzQuery struct {
|
||||
Actor string `json:"actor"` // 认证主体(发起读取的会话)
|
||||
Action string `json:"action"` // read|download|list|search|stream|model-context
|
||||
ResourceType string `json:"resourceType"` // 基座资源类型(artifact/attachment/...)
|
||||
ResourceID core.ID `json:"resourceId"` // 资源稳定 ID
|
||||
Owner string `json:"owner"` // 资源属主(受信存储解析,非请求自报)
|
||||
ProjectID core.ID `json:"projectId,omitempty"` // 所属项目;空=无项目私域
|
||||
Actor string `json:"actor"` // 认证主体(发起读取的会话)
|
||||
Action string `json:"action"` // read|download|list|search|stream|model-context
|
||||
ResourceType string `json:"resourceType"` // 基座资源类型(artifact/attachment/...)
|
||||
ResourceID core.ID `json:"resourceId"` // 资源稳定 ID
|
||||
Owner string `json:"owner"` // 资源属主(受信存储解析,非请求自报)
|
||||
// Source is optional trusted resource provenance, populated from stored knowledge rows.
|
||||
Source string `json:"source,omitempty"`
|
||||
ProjectID core.ID `json:"projectId,omitempty"` // 所属项目;空=无项目私域
|
||||
}
|
||||
|
||||
// ResourceAuthorizerV2 EXT-8b 资源级授权适配器:V1(owner×project 布尔)之上的
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
)
|
||||
|
||||
// ProjectHost is an optional actor-scoped host projection capability. Metadata
|
||||
// can be shared by an authorizer; registered-lifecycle metadata masks credentials.
|
||||
// OwnedProject remains an owner-only management lookup; mutations remain owner-only.
|
||||
type ProjectHost interface {
|
||||
Owner() string
|
||||
OwnedProjects() ([]core.Project, error)
|
||||
ProjectMetadata(projectID string) (core.Project, error)
|
||||
OwnedProject(projectID string) (core.Project, error)
|
||||
EnsureProject(projectID, name string) (core.Project, error)
|
||||
RenameOwnedProject(projectID, name string) (core.Project, error)
|
||||
ProjectNameAvailable(name, exceptID string) (bool, error)
|
||||
}
|
||||
|
||||
// ProjectLifecycle supplies the domain's public project lifecycle and live work
|
||||
// authorization. Each Host is scoped to the authenticated actor. Errors with a
|
||||
// StatusCode() int method may report 400, 403, 404, 409, or 503 to HTTP clients.
|
||||
type ProjectLifecycle struct {
|
||||
List func(context.Context, Host) ([]core.Project, error)
|
||||
Create func(context.Context, Host, string) (core.Project, error)
|
||||
Rename func(context.Context, Host, string, string) (core.Project, error)
|
||||
Archive func(context.Context, Host, string) error
|
||||
Work func(context.Context, string, core.ID) (bool, error)
|
||||
}
|
||||
|
||||
// ProjectLifecycleRegistry is optional so older Registry implementations retain
|
||||
// their contract. Only in-process plugins can register one complete provider.
|
||||
type ProjectLifecycleRegistry interface {
|
||||
RegisterProjectLifecycle(ProjectLifecycle) error
|
||||
}
|
||||
@@ -32,6 +32,17 @@ Main
|
||||
NewHostClient
|
||||
NewScopedHost
|
||||
Options
|
||||
ProjectHost
|
||||
ProjectHost.EnsureProject
|
||||
ProjectHost.OwnedProject
|
||||
ProjectHost.OwnedProjects
|
||||
ProjectHost.Owner
|
||||
ProjectHost.ProjectMetadata
|
||||
ProjectHost.ProjectNameAvailable
|
||||
ProjectHost.RenameOwnedProject
|
||||
ProjectLifecycle
|
||||
ProjectLifecycleRegistry
|
||||
ProjectLifecycleRegistry.RegisterProjectLifecycle
|
||||
RegisterCompiled
|
||||
Registry
|
||||
Registry.AddAdminZone
|
||||
|
||||
Reference in New Issue
Block a user