Files
Agent-System-Backend/internal/app/hostcaps.go
T
weichen 4f72aaf3d8 Recheck resumed project work and isolate publication replacement
Why:
Membership could be revoked while a tool waited for confirmation or a queued
HITL reply waited for consumption. Publication replacement also crossed an
empty project scope or another plugin's source namespace for the same Ref.

Evidence:
SELF replay of all four independent review reproductions failed before the
fix and passed unchanged afterward. Committed regressions cover direct and
queued HITL, yes/session confirmation, direct artifact writes, protected vectors,
exact empty project scopes, literal wildcard IDs and unregistered compatibility.
SELF uncached go test ./..., isolated PostgreSQL Store tests and static build passed.

Impact:
Recheck trusted live owner/project authorization when HITL resumes and at write
boundaries. Registered publishers replace only exact owner/project/ref/plugin
rows and vectors; ordinary unregistered publication semantics are preserved.

Refs:
Dev project-member-access implementation plan r1 Task 1; independent Backend
review findings against b9bea5bee0
2026-10-13 09:04:06 +00:00

445 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"agent-system-backend/pkg/extension"
"context"
"fmt"
"sort"
"strings"
"sync"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
core "agent-system-core"
)
// hostcaps 聚合 owner 作用域的宿主能力(存储、检索管线、LLM 路由)。
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
type hostcaps struct {
projects *projectLifecycleSlot
st *store.Store
pipe *rag.Pipeline
set *settings.Service
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
}
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
type projectAuthz struct {
mu sync.Mutex
holder string // 持有者 pluginID(首个注册者生效)
fn extension.ResourceAuthorizer
holder2 string // EXT-8b 资源级持有者(首个注册者生效)
fn2 extension.ResourceAuthorizerV2
}
func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) {
a.mu.Lock()
defer a.mu.Unlock()
if a.fn != nil {
return // 首个注册者生效;后来者忽略(调用方经日志可查)
}
a.holder, a.fn = pluginID, fn
}
func (a *projectAuthz) setV2(pluginID string, fn extension.ResourceAuthorizerV2) {
a.mu.Lock()
defer a.mu.Unlock()
if a.fn2 != nil {
return
}
a.holder2, a.fn2 = pluginID, fn
}
// AuthorizeResource EXT-8b 资源级判定(U9):V2 注册时按查询六要素咨询(含
// owner 自有命中与共享读取,fail-closed);未注册回落 V1 的 owner×project
// 布尔(兼容现行行为)。查询字段由执行端受信构造。
func (a *projectAuthz) AuthorizeResource(q extension.ResourceAuthzQuery) (bool, error) {
a.mu.Lock()
fn2, fn1 := a.fn2, a.fn
a.mu.Unlock()
if fn2 != nil {
if q.Actor == "" || q.Action == "" || q.ResourceType == "" || q.ResourceID == "" {
return false, nil // 受信上下文字段缺失=不可判定=拒绝
}
return fn2(q)
}
if fn1 == nil || q.Owner == "" || q.ProjectID == "" {
return false, nil
}
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
a.mu.Unlock()
if fn == nil || owner == "" || projectID == "" {
return false, nil
}
return fn(owner, projectID)
}
// wireScopedHostFactory EXT-11:把「按 owner+pluginID 构造受限 Host」注入公开面
// (extension.SetScopedHostFactory)。组合根在 hostcaps 就绪后调用;重建句柄与
// 分发期同构(owner 作用域、来源前缀强制、KV/任务台账隔离)。
func wireScopedHostFactory(caps hostcaps) {
extension.SetScopedHostFactory(func(owner, pluginID string) extension.Host {
return inprocHost{caps: caps, pluginID: pluginID, owner: owner}
})
}
// 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) {
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 {
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)。
func (c hostcaps) searchDefault(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
return c.search(ctx, owner, "", text, 0)
}
// complete 经基座 LLM 路由补全(owner 的模型配置生效;未配置报错)。
func (c hostcaps) complete(ctx context.Context, owner, prompt string) (string, error) {
route := runtime.ResolveChatModel(core.AgentProfile{}, c.set.Effective(owner, ""))
if route.Model == "" || route.Base == "" {
return "", fmt.Errorf("llm route not configured")
}
return completeChat(ctx, route.Base, route.Key, route.Model, prompt)
}
// maxParseInput 与上传端点的读取上限同口径(server.upload 的 100<<20)。
const maxParseInput = 100 << 20
// parse 走基座内置解析器(EXT-5):纯计算、不落 owner 数据;不可解析由
// Warnings 显式表达,只有取消与超限是 error。
func (c hostcaps) parse(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
if err := ctx.Err(); err != nil {
return core.ParsedDocument{}, err
}
if len(content) > maxParseInput {
return core.ParsedDocument{}, fmt.Errorf("parse input %d bytes exceeds %d limit", len(content), maxParseInput)
}
return ingest.Parse(filename, mime, content), nil
}
// inprocHost 是进程内插件命令的 owner 作用域宿主句柄(extension.Host 实现)。
// 每次分发按请求 Owner 新建;所有能力调用都以该 owner 为第一参数,插件无法越权。
// actor 是本次分发主体(通常等于 owner);AdminProvider 能力经 actor 查询
// (EXT-18)。非 dispatch 上下文(EXT-11 scoped host)actor 为空——IsAdmin 恒 false。
type inprocHost struct {
caps hostcaps
pluginID string
owner string
actor string // EXT-18:分发主体(空=非 dispatch 上下文)
}
func (h inprocHost) PluginID() string { return h.pluginID }
func (h inprocHost) Owner() string { return h.owner }
// IsAdmin 实现 EXT-18 AdminProvider:查询本次分发主体的基座平台管理角色。
// actor 为空(非 dispatch 上下文)恒 false;store 查询失败按非管理员处理
// (fail-closed——不因存储故障放行超管面)。
func (h inprocHost) IsAdmin() bool {
if h.actor == "" {
return false
}
state, err := h.caps.st.GetUserAuthState(h.actor)
if err != nil {
return false
}
return state.IsAdmin
}
// ListAccounts 实现 EXT-19 AccountLister(最小账号目录):仅授权管理读面用。
// 有效态=账号存在且非停用(当前 users 表无停用位——must_change_password 不影响
// 有效性;后续加停用位在此映射)。P3(2026-10-13):携带 Name 供同名区分。
// 游标=owner 升序偏移。
func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountEntry, string, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
users, err := h.caps.st.ListUsersAdmin()
if err != nil {
return nil, "", err
}
entries := make([]extension.AccountEntry, 0, len(users))
for _, u := range users {
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true})
}
// 排序+游标(owner 升序;cursor 为上一页末 owner,空=首页)。
for i := 0; i < len(entries); i++ {
for j := i + 1; j < len(entries); j++ {
if entries[j].Owner < entries[i].Owner {
entries[i], entries[j] = entries[j], entries[i]
}
}
}
start := 0
if cursor != "" {
for start < len(entries) && entries[start].Owner <= cursor {
start++
}
}
end := start + limit
if end > len(entries) {
end = len(entries)
}
next := ""
if end < len(entries) {
next = entries[end-1].Owner
}
return entries[start:end], next, nil
}
// SearchAccounts 实现 EXT-19 扩展 AccountSearcher(P3,2026-10-13):账号或
// 姓名子串检索(大小写不敏感;空 query = 首页候选)。结果按 owner 升序,
// 有效态口径与 ListAccounts 一致。
func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.AccountEntry, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
users, err := h.caps.st.ListUsersAdmin()
if err != nil {
return nil, err
}
q := strings.ToLower(strings.TrimSpace(query))
entries := make([]extension.AccountEntry, 0, len(users))
for _, u := range users {
if q != "" &&
!strings.Contains(strings.ToLower(u.Owner), q) &&
!strings.Contains(strings.ToLower(u.Name), q) {
continue
}
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Owner < entries[j].Owner })
if len(entries) > limit {
entries = entries[:limit]
}
return entries, nil
}
func (h inprocHost) KVPut(key, value string) error {
return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value)
}
func (h inprocHost) KVGet(key string) (string, bool, error) {
return h.caps.st.GetPluginKV(h.pluginID, h.owner, key)
}
func (h inprocHost) Audit(action, target, evidence string) error {
return h.caps.st.AppendAuditEvent(store.AuditEvent{
Owner: h.owner, Actor: "plugin:" + h.pluginID,
Action: action, Target: target, Evidence: evidence,
})
}
func (h inprocHost) Complete(ctx context.Context, prompt string) (string, error) {
return h.caps.complete(ctx, h.owner, prompt)
}
func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
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 失败降级纯全文,不阻断写入)。
func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
if len(docs) == 0 {
return nil
}
src := "plugin:" + h.pluginID
for i := range docs {
docs[i].Owner = core.ID(h.owner)
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...)
}
return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs)
}
// 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
}
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[publicationRef{docs[i].ProjectID, docs[i].Ref}] = true
}
}
scoped := h.caps.projects != nil && h.caps.projects.active()
for ref := range refs {
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 != nil {
return err
}
}
return h.KnowledgeWrite(ctx, docs, embed)
}
// KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual
// 或他人来源拒绝(fail-closed);删行+向量,幂等。
func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) error {
if sourcePrefix != "plugin:"+h.pluginID {
return fmt.Errorf("ext-9: plugins may only delete their own source prefix, want %q", "plugin:"+h.pluginID)
}
_, err := h.caps.st.DeleteKnowledgeBySource(ctx, h.owner, sourcePrefix)
return err
}
// TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。
func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, error) {
t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID)
if err != nil {
return "", err
}
return t.ID, nil
}
func (h inprocHost) TaskUpdate(taskID, status string, progress int, errText string) error {
return h.caps.st.UpdateBackgroundTask(h.owner, taskID, status, progress, errText)
}
// ArtifactGet/ArtifactContent 工件只读(R1-3 ingest 读原件;写入属完整集)。
func (h inprocHost) ArtifactGet(artifactID string) (core.Artifact, error) {
return h.caps.st.GetArtifact(h.owner, artifactID)
}
func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) {
return h.caps.st.ArtifactContent(h.owner, artifactID)
}
// ParseDocument 内置文档解析(EXT-5):委托 hostcaps.parse;纯计算不经存储,
// owner 作用域由调用方经 ArtifactContent 取字节时保证。
func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
return h.caps.parse(ctx, filename, mime, content)
}
// ---- EXT-17 Surface 能力(可选):nil 槽=能力缺失,明确拒绝不降级 ----
func (h inprocHost) surface() (*surfacesvc.Service, error) {
if h.caps.surfaces == nil {
return nil, surfacesvc.ErrUnavailable
}
return h.caps.surfaces, nil
}
func (h inprocHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
svc, err := h.surface()
if err != nil {
return core.SurfaceSnapshot{}, err
}
return svc.Submit(ctx, h.owner, h.pluginID, surfacesvc.SubmitRequest{
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, SourceRunID: req.SourceRunID,
BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch,
ContextVersion: req.ContextVersion, SemanticText: req.SemanticText, Spec: req.Spec,
})
}
func (h inprocHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) {
svc, err := h.surface()
if err != nil {
return core.SurfaceSnapshot{}, err
}
return svc.Get(ctx, h.owner, surfaceID)
}
func (h inprocHost) SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error) {
svc, err := h.surface()
if err != nil {
return nil, err
}
return svc.List(ctx, h.owner, conversationID)
}
func (h inprocHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
svc, err := h.surface()
if err != nil {
return false, 0, err
}
row, err := svc.Activation(ctx, h.owner, h.pluginID)
return row.Enabled, row.Epoch, err
}