diff --git a/internal/app/app.go b/internal/app/app.go index 89016d8..13417ad 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/app/ext13_shared_search_test.go b/internal/app/ext13_shared_search_test.go new file mode 100644 index 0000000..0c8f6ef --- /dev/null +++ b/internal/app/ext13_shared_search_test.go @@ -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) + } +} diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index bffb81a..bd363e8 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -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)。 diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 7809e4d..d6214cf 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -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 } // 实际会话沙箱为 ///;空值=未配置(工具执行报错)。 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) diff --git a/internal/runmgr/shared_run_retrieval_test.go b/internal/runmgr/shared_run_retrieval_test.go new file mode 100644 index 0000000..1780b1a --- /dev/null +++ b/internal/runmgr/shared_run_retrieval_test.go @@ -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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 8a5facb..417c1ba 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) } diff --git a/internal/server/shared_projects_test.go b/internal/server/shared_projects_test.go new file mode 100644 index 0000000..f6ab33f --- /dev/null +++ b/internal/server/shared_projects_test.go @@ -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) + } +} diff --git a/internal/store/projects.go b/internal/store/projects.go index fc529d5..009a701 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -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 {