package app import ( "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/pkg/extension" "context" "fmt" "sort" "strings" "sync" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/ingest" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/rag" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/runtime" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/settings" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/store" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/surfacesvc" core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git" ) // 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:";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 }