Merge pull request 'Implement EXT-13/15: shared retrieval inside runs and shared project listing/conversations' (#113) from feature/ext-13-15-shared-runs into main
ci / vet-and-test (push) Successful in 41s

This commit was merged in pull request #113.
This commit is contained in:
2026-10-09 15:49:33 +00:00
8 changed files with 369 additions and 6 deletions
+6
View File
@@ -387,6 +387,12 @@ func Run(opt Options) error {
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
authzSlot := &projectAuthz{}
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot}
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
ok, err := authzSlot.Authorize(owner, projectID)
return err == nil && ok
})
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
wireScopedHostFactory(caps)
applyCompiled(skillsRoot, bus, caps)
+69
View File
@@ -0,0 +1,69 @@
package app
// EXT-13 红测(提案 2026-10-09-ext-g1-b1-b3 §1):进程内 Host.KnowledgeSearch
// 的检索面在适配器放行后吃到他人项目的共享知识;未放行保持本人两路、零泄露。
import (
"context"
"path/filepath"
"testing"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// newExt13Host EXT-13 装配:真实 store + rag 管线 + authz 槽 + inprocHost(bob 视角)。
func newExt13Host(t *testing.T) (interface {
KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error)
}, *projectAuthz) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
set := settings.NewService(st)
pipe := rag.NewPipeline(st, set, nil, nil)
authz := &projectAuthz{}
h := inprocHost{caps: hostcaps{st: st, pipe: pipe, set: set, authz: authz}, pluginID: "rdm", owner: "bob"}
// alice 已发布的项目知识(带 ProjectID;模拟发布状态机产物)。
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "alice", ProjectID: "p-1",
Source: "plugin:rdm", Ref: "ext13-1", Chunk: "EXT13共享独占令牌陈述"}); err != nil {
t.Fatal(err)
}
return h, authz
}
func TestInprocHostKnowledgeSearchShared(t *testing.T) {
h, authz := newExt13Host(t)
ctx := context.Background()
q := core.RetrievalQuery{Text: "EXT13共享独占令牌", TopK: 5}
if hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK); err != nil || len(hits) != 0 {
t.Fatalf("before grant: hits=%d err=%v, want 0(未放行零泄露)", len(hits), err)
}
// 适配器放行 bob × p-1 → Host 检索面吃到 alice 的共享命中。
// (槽为首个注册者生效——切换授权改闭包内变量,不重注册。)
allowBob := true
authz.set("rdm", func(owner string, projectID core.ID) (bool, error) {
return allowBob && owner == "bob" && string(projectID) == "p-1", nil
})
hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK)
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("after grant: host search got 0 shared hits, want ≥1(EXT-13)")
}
// 指定他人 ProjectID 时同样经适配器(发布边界在下游状态机)。
if hits, err := h.KnowledgeSearch(ctx, "p-1", q.Text, q.TopK); err != nil || len(hits) == 0 {
t.Fatalf("scoped to shared project: hits=%d err=%v, want ≥1", len(hits), err)
}
// 撤销(同一注册内翻转授权)→ 共享命中即停、零泄露。
allowBob = false
if hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK); err != nil || len(hits) != 0 {
t.Fatalf("after revoke: hits=%d err=%v, want 0", len(hits), err)
}
}
+18 -1
View File
@@ -61,8 +61,25 @@ func wireScopedHostFactory(caps hostcaps) {
}
// search owner 作用域混合检索;projectID/topK 为空/0 时用管线默认值。
// sharedRetriever EXT-13:rag.Pipeline.RetrieveShared 形状(避免 app 直接 import rag
// 的具体类型断言散落)。
type sharedRetriever interface {
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
}
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
return c.pipe.Retrieve(ctx, core.ID(owner), core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK})
q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}
// EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走
// 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。
if c.authz != nil {
if rs, ok := interface{}(c.pipe).(sharedRetriever); ok {
return rs.RetrieveShared(ctx, core.ID(owner), q, func(docOwner, pid core.ID) bool {
ok, err := c.authz.Authorize(owner, pid)
return err == nil && ok
})
}
}
return c.pipe.Retrieve(ctx, core.ID(owner), q)
}
// searchDefault 与 hostapi /host/v1 检索端点同口径(不带 projectID/topK)。
+24 -3
View File
@@ -52,9 +52,12 @@ type Manager struct {
cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退)
// OnUsage 在 run 结束时报告 token(日预算记账;nil = 不记)。
OnUsage func(owner string, tokens int)
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
retriever core.Retriever // 可选混合检索(nil = store 全文)
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
retriever core.Retriever // 可选混合检索(nil = store 全文)
// sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
// workspaceRoot fs 预留能力(Hidden 工具族)的沙箱根;空=未配置,工具执行时报错。
workspaceRoot string
@@ -153,6 +156,12 @@ func (m *Manager) EmitCardEvent(conversationID, runID string, typ core.StreamEve
// SetRetriever 注入混合检索(rag 管线;nil = store 全文检索)。
func (m *Manager) SetRetriever(r core.Retriever) { m.retriever = r }
// SetSharedKnowledgeAllow 注入回合内共享检索谓词(EXT-13;组合根在装载扩展后
// 以同一 EXT-8 适配器槽接线)。nil = 现状。
func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.ID) bool) {
m.sharedKnowledgeAllow = fn
}
// SetIndexer 注入检索索引入口(分块+全文+可选向量;nil = 整文档单块)。
func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
@@ -160,6 +169,11 @@ func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
// 实际会话沙箱为 <root>/<owner>/<conversationID>/;空值=未配置(工具执行报错)。
func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
// sharedRetriever EXT-13:检索器具备共享通道(rag.Pipeline.RetrieveShared 同形)。
type sharedRetriever interface {
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
}
// ---- runCtx 实现(runtime.RunCtx)----
type runCtx struct {
@@ -731,6 +745,13 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
q.ProjectID = c.run.ProjectID
}
if c.mgr.retriever != nil {
if c.mgr.sharedKnowledgeAllow != nil {
if rs, ok := c.mgr.retriever.(sharedRetriever); ok {
return rs.RetrieveShared(c.ctx, c.run.Owner, q, func(docOwner, projectID core.ID) bool {
return c.mgr.sharedKnowledgeAllow(string(c.run.Owner), projectID)
})
}
}
return c.mgr.retriever.Retrieve(c.ctx, c.run.Owner, q)
}
return c.mgr.st.SearchKnowledge(c.run.Owner, q)
@@ -0,0 +1,72 @@
package runmgr
// EXT-13 红测(提案 2026-10-09-ext-g1-b1-b3 §1):回合内检索(knowledge_search
// 工具与 @knowledge 注入的同一入口 runCtx.Retrieve)在适配器放行后吃到共享命中;
// 未放行/未注册谓词时保持本人两路、零泄露。
import (
"context"
"testing"
core "agent-system-core"
)
type sharedFakeRetriever struct {
own []core.RetrievalHit
shared []core.RetrievalHit
gotAlt bool
}
func (f *sharedFakeRetriever) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
return f.own, nil
}
func (f *sharedFakeRetriever) RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
f.gotAlt = true
out := append([]core.RetrievalHit{}, f.own...) // 对齐 rag.Pipeline:本人两路如常
for _, h := range f.shared {
if allow(h.Doc.Owner, h.Doc.ProjectID) {
out = append(out, h)
}
}
return out, nil
}
func TestRunContextRetrieveShared(t *testing.T) {
m := New(nil, nil, nil, nil, nil, nil, nil, nil, nil)
fr := &sharedFakeRetriever{
own: []core.RetrievalHit{{Doc: core.KnowledgeDoc{Ref: "own-1", Chunk: "本人命中"}}},
shared: []core.RetrievalHit{{Doc: core.KnowledgeDoc{Ref: "sh-1", Chunk: "共享命中", Owner: "alice", ProjectID: "p-1"}}},
}
m.SetRetriever(fr)
rc := &runCtx{mgr: m, run: core.Run{Owner: "bob"}}
// 未注册共享谓词:本人两路如常,不触碰共享通道。
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "命中"})
if err != nil || len(hits) != 1 || hits[0].Doc.Ref != "own-1" {
t.Fatalf("no predicate: hits=%+v err=%v", hits, err)
}
if fr.gotAlt {
t.Fatal("no predicate: must not call RetrieveShared(未注册=现状)")
}
// 注册谓词(放行 bob×p-1)→ 本人 + 共享。
m.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
return owner == "bob" && string(projectID) == "p-1"
})
fr.gotAlt = false
hits, err = rc.Retrieve(core.RetrievalQuery{Text: "命中"})
if err != nil || len(hits) != 2 {
t.Fatalf("with predicate: hits=%+v err=%v, want own+shared", hits, err)
}
if !fr.gotAlt {
t.Fatal("with predicate: RetrieveShared not consulted(EXT-13)")
}
// 谓词拒绝 → 只剩本人。
m.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool { return false })
hits, err = rc.Retrieve(core.RetrievalQuery{Text: "命中"})
if err != nil || len(hits) != 1 || hits[0].Doc.Ref != "own-1" {
t.Fatalf("denied: hits=%+v err=%v, want own only", hits, err)
}
}
+39 -2
View File
@@ -435,9 +435,41 @@ func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
// ---- handlers:项目与会话 ----
// projectView 项目 API 视图:core.Project 字段原样 + EXT-15 共享标注(本人自有
// 项缺省 false/省略;适配器放行的他人项目为 true,供前端区分展示)。
type projectView struct {
core.Project
Shared bool `json:"shared,omitempty"`
}
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
ps, err := s.st.ListProjects(s.owner(r))
writeJSONOr(w, ps, err)
owner := s.owner(r)
ps, err := s.st.ListProjects(owner)
if err != nil {
writeStoreErr(w, err)
return
}
out := make([]projectView, 0, len(ps))
own := make(map[string]bool, len(ps))
for _, p := range ps {
own[string(p.ID)] = true
out = append(out, projectView{Project: p})
}
// EXT-15:适配器已注册时,追加经放行的共享项目(他人 owner、去重)。
if s.ProjectAuthz != nil {
all, err := s.st.ListAllProjects()
if err == nil {
for _, p := range all {
if own[string(p.ID)] || string(p.Owner) == owner {
continue
}
if ok, aerr := s.ProjectAuthz(owner, p.ID); aerr == nil && ok {
out = append(out, projectView{Project: p, Shared: true})
}
}
}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
@@ -524,6 +556,11 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
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.authorizeSharedProject(s.owner(r), core.ID(r.PathValue("id"))) {
c, err = s.st.CreateConversationInProject(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
}
writeJSONOr(w, c, err)
}
+99
View File
@@ -0,0 +1,99 @@
package server
// EXT-15 红测(提案 2026-10-09-ext-g1-b1-b3 §3):成员(经适配器放行)能在
// GET /api/projects 里看到共享项目(shared 标注、去重、不含未授权),并能在
// 共享项目下建会话;撤销后两者立即拒绝;未注册适配器=现状。
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
core "agent-system-core"
)
func TestSharedProjectListAndConversation(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
alice, err := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
bob, err := st.CreateAPIKey("bob", "e2e")
if err != nil {
t.Fatal(err)
}
pj, err := st.CreateProject("alice", "共享项目X")
if err != nil {
t.Fatal(err)
}
pid := pj.ID
type pv struct {
ID string `json:"id"`
Shared bool `json:"shared"`
}
call := func(key, method, path string) (*httptest.ResponseRecorder, []pv) {
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var list []pv
if rec.Body.Len() > 0 {
readJSON(t, rec.Body.Bytes(), &list)
}
return rec, list
}
// 未注册适配器:bob 看不到 alice 的项目(现状)。
rec, list := call(bob, http.MethodGet, "/api/projects")
if rec.Code != http.StatusOK {
t.Fatalf("list: %d", rec.Code)
}
if len(list) != 0 {
t.Fatalf("before adapter: bob sees %d projects, want 0", len(list))
}
// 放行 bob×pid。
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
return owner == "bob" && string(projectID) == pid, nil
})
rec, list = call(bob, http.MethodGet, "/api/projects")
if rec.Code != http.StatusOK {
t.Fatalf("list after grant: %d", rec.Code)
}
if len(list) != 1 || list[0].ID != pid || !list[0].Shared {
t.Fatalf("after grant: bob sees %+v, want 1 item id=%s shared=true", list, pid)
}
// alice 本人:仍只看到自己的(无 shared 标注)。
_, alist := call(alice, http.MethodGet, "/api/projects")
if len(alist) != 1 || alist[0].ID != pid || alist[0].Shared {
t.Fatalf("alice own list = %+v, want own without shared", alist)
}
// bob 在共享项目下建会话(EXT-15 第二入口)。
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/conversations", bytes.NewReader(mustJSON2(t, map[string]string{"title": "成员会话"})))
req.Header.Set("Authorization", "Bearer "+bob)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("member create conversation: %d %s(须放行)", rec.Code, rec.Body.String())
}
// 撤销:列表回到 0,建会话 404(不泄露存在性)。
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { return false, nil })
_, list = call(bob, http.MethodGet, "/api/projects")
if len(list) != 0 {
t.Fatalf("after revoke: bob sees %d, want 0", len(list))
}
req = httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/conversations", bytes.NewReader(mustJSON2(t, map[string]string{"title": "x"})))
req.Header.Set("Authorization", "Bearer "+bob)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("after revoke: create conversation = %d, want 404", rec.Code)
}
}
+42
View File
@@ -28,6 +28,31 @@ func (s *Store) ListProjects(owner string) ([]core.Project, error) {
return scanProjects(rows)
}
// ListAllProjects 全量项目(含他人 owner;EXT-15)。仅供服务端在共享适配器
// 已注册时枚举候选——调用方须逐项目过适配器,不得直接下发全表。
func (s *Store) ListAllProjects() ([]core.Project, error) {
rows, err := s.db.Query(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects ORDER BY updated_at DESC`))
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.Project{}
for rows.Next() {
var p core.Project
var settings, ca, ua string
if err := rows.Scan(&p.ID, &p.Owner, &p.Name, &settings, &ca, &ua); err != nil {
return nil, err
}
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
if settings != "" && settings != "{}" {
p.Settings, _ = unmarshalJSON[map[string]any](settings)
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) GetProject(owner, id string) (core.Project, error) {
var p core.Project
var settings, ca, ua string
@@ -137,6 +162,23 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c
return c, err
}
// CreateConversationInProject 在共享项目下建会话(EXT-15):只校验项目存在,
// 不校验归属(服务端已先按自有路径尝试、再经适配器放行);会话 owner 仍为创建者。
func (s *Store) CreateConversationInProject(owner, projectID, title, profileID string) (core.Conversation, error) {
var one int
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=?`), projectID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return core.Conversation{}, ErrNotFound
}
if err != nil {
return core.Conversation{}, err
}
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
return c, err
}
func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, error) {
rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
if err != nil {