1000 lines
36 KiB
Go
1000 lines
36 KiB
Go
package app
|
||
|
||
import (
|
||
"agent-system-backend/pkg/extension"
|
||
"context"
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
authzsvc "agent-system-backend/internal/authz"
|
||
"agent-system-backend/internal/authz/catalog"
|
||
"agent-system-backend/internal/cmdbus"
|
||
"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
|
||
workObserver *projectWorkObserverSlot
|
||
directory *accountDirectorySlot
|
||
contexts *projectContextSlot
|
||
projectAudit *projectAuditSlot
|
||
regulations *regulationsSlot // EXT-20 法规提供方槽(共享指针;nil/未注册=不可用)
|
||
accountGuard func(string) error
|
||
allowImplicitLocal bool
|
||
st *store.Store
|
||
pipe *rag.Pipeline
|
||
set *settings.Service
|
||
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
|
||
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
|
||
now func() time.Time
|
||
}
|
||
|
||
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
|
||
type projectAuthz struct {
|
||
mu sync.Mutex
|
||
holder string // 持有者 pluginID(首个注册者生效)
|
||
fn extension.ResourceAuthorizer
|
||
holder2 string // EXT-8b 资源级持有者(首个注册者生效)
|
||
fn2 extension.ResourceAuthorizerV2
|
||
enrich func(extension.ResourceAuthzQuery) (extension.ResourceAuthzQuery, error)
|
||
}
|
||
|
||
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, enrich := a.fn2, a.fn, a.enrich
|
||
a.mu.Unlock()
|
||
if fn2 != nil {
|
||
if q.Actor == "" || q.Action == "" || q.ResourceType == "" || q.ResourceID == "" {
|
||
return false, nil // 受信上下文字段缺失=不可判定=拒绝
|
||
}
|
||
if enrich != nil {
|
||
var err error
|
||
q, err = enrich(q)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
}
|
||
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) {
|
||
if c.accountGuard != nil {
|
||
if err := c.accountGuard(owner); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
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) {
|
||
if c.accountGuard != nil {
|
||
if err := c.accountGuard(owner); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
route := runtime.ResolveChatModel(core.AgentProfile{}, c.set.Effective(owner, ""))
|
||
if route.Model == "" || route.Base == "" {
|
||
return "", fmt.Errorf("llm route not configured")
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
return "", err
|
||
}
|
||
if c.accountGuard != nil {
|
||
if err := c.accountGuard(owner); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
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 上下文)
|
||
conversationID core.ID // dispatch-bound, never taken from completion arguments
|
||
hostProjectID core.ID // dispatch project; rechecked against stored conversation
|
||
entrance string // dispatch entrance; model tools are "agent"
|
||
humanGrant *cmdbus.HumanGrant
|
||
}
|
||
|
||
func (h inprocHost) requireEffective() error {
|
||
if h.caps.accountGuard != nil {
|
||
return h.caps.accountGuard(h.owner)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
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.Enabled && state.IsAdmin
|
||
}
|
||
|
||
func (h inprocHost) legacySystemDirectoryAuthorized() bool {
|
||
if h.requireEffective() != nil {
|
||
return false
|
||
}
|
||
if h.actor == "" {
|
||
return false
|
||
}
|
||
state, err := h.caps.st.GetUserAuthState(h.actor)
|
||
return err == nil && state.Enabled && state.IsAdmin
|
||
}
|
||
|
||
// ListAccounts 保留 EXT-19 旧签名,仅供有受信分发主体的系统管理上下文;
|
||
// 项目和文档候选必须走带 locator 的 AccountDirectoryProvider。
|
||
func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountEntry, string, error) {
|
||
if !h.legacySystemDirectoryAuthorized() {
|
||
return nil, "", store.ErrAccountForbidden
|
||
}
|
||
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: u.Enabled})
|
||
}
|
||
// 排序+游标(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 !h.legacySystemDirectoryAuthorized() {
|
||
return nil, store.ErrAccountForbidden
|
||
}
|
||
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: u.Enabled})
|
||
}
|
||
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) rolePolicyActor() (store.Account, error) {
|
||
if h.actor == "" {
|
||
return store.Account{}, store.ErrAccountForbidden
|
||
}
|
||
account, err := h.caps.st.GetAccountByOwner(h.actor)
|
||
if err != nil || !account.Enabled {
|
||
return store.Account{}, store.ErrAccountForbidden
|
||
}
|
||
return account, nil
|
||
}
|
||
|
||
func (h inprocHost) CurrentRolePolicy(ctx context.Context) (extension.RolePolicySnapshot, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return extension.RolePolicySnapshot{}, err
|
||
}
|
||
actor, err := h.rolePolicyActor()
|
||
if err != nil {
|
||
return extension.RolePolicySnapshot{}, err
|
||
}
|
||
return h.RolePolicyForUser(ctx, actor.ID)
|
||
}
|
||
|
||
func (h inprocHost) RolePolicyForUser(ctx context.Context, userID string) (extension.RolePolicySnapshot, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return extension.RolePolicySnapshot{}, err
|
||
}
|
||
if _, err := h.rolePolicyActor(); err != nil {
|
||
return extension.RolePolicySnapshot{}, err
|
||
}
|
||
return rolePolicySnapshotForUser(h.caps.st, userID)
|
||
}
|
||
|
||
func rolePolicySnapshotForUser(st *store.Store, userID string) (extension.RolePolicySnapshot, error) {
|
||
account, err := st.GetAccount(userID)
|
||
if err != nil {
|
||
return extension.RolePolicySnapshot{}, err
|
||
}
|
||
epoch, err := st.AuthorityEpoch()
|
||
if err != nil {
|
||
return extension.RolePolicySnapshot{}, err
|
||
}
|
||
snapshot := extension.RolePolicySnapshot{SchemaVersion: extension.RolePolicySchemaVersion,
|
||
UserID: account.ID, Owner: account.Owner, AccountEnabled: account.Enabled,
|
||
AuthorityEpoch: epoch, Permissions: map[string]extension.RolePermission{}}
|
||
linked, err := authzsvc.NewService(st).EffectiveLinkedCapabilities(account.Owner)
|
||
if err != nil {
|
||
return extension.RolePolicySnapshot{}, err
|
||
}
|
||
snapshot.Linked = extension.LinkedCapabilities{ProjectCreate: linked.ProjectCreate, Source: linked.Source,
|
||
Actions: linked.LinkedActions, Conflicts: linked.Conflicts}
|
||
view, err := st.RoleForUser(userID)
|
||
if err != nil {
|
||
return extension.RolePolicySnapshot{}, err
|
||
}
|
||
if view.Binding == nil || view.Role == nil {
|
||
return snapshot, nil
|
||
}
|
||
snapshot.BindingID = view.Binding.BindingID
|
||
snapshot.RoleID = view.Role.RoleID
|
||
snapshot.RoleName = view.Role.Name
|
||
snapshot.RoleDescription = view.Role.Description
|
||
snapshot.RoleEnabled = view.Role.Enabled
|
||
snapshot.RoleVersion = view.Role.RoleVersion
|
||
permissions, err := st.RolePermissions(view.Role.RoleID)
|
||
if err != nil {
|
||
return extension.RolePolicySnapshot{}, err
|
||
}
|
||
for _, permission := range permissions {
|
||
snapshot.Permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed}
|
||
}
|
||
return snapshot, nil
|
||
}
|
||
|
||
func (h inprocHost) RolePolicyForOwner(ctx context.Context, owner string) (extension.RolePolicySnapshot, error) {
|
||
if _, err := h.rolePolicyActor(); err != nil {
|
||
return extension.RolePolicySnapshot{}, err
|
||
}
|
||
account, err := h.caps.st.GetAccountByOwner(owner)
|
||
if err != nil {
|
||
return extension.RolePolicySnapshot{}, err
|
||
}
|
||
return h.RolePolicyForUser(ctx, account.ID)
|
||
}
|
||
|
||
func (h inprocHost) CheckRoleAction(ctx context.Context, userID, actionID string) (extension.RoleActionDecision, error) {
|
||
decision := extension.RoleActionDecision{ActionID: actionID}
|
||
if _, ok := catalog.DescriptorByID(actionID); !ok {
|
||
decision.Reason = "unknown-action"
|
||
return decision, nil
|
||
}
|
||
snapshot, err := h.RolePolicyForUser(ctx, userID)
|
||
if err != nil {
|
||
return decision, err
|
||
}
|
||
decision.RoleID = snapshot.RoleID
|
||
decision.AuthorityEpoch = snapshot.AuthorityEpoch
|
||
if !snapshot.AccountEnabled {
|
||
decision.Reason = "account-disabled"
|
||
return decision, nil
|
||
}
|
||
if snapshot.RoleID == "" {
|
||
decision.Reason = "role-unassigned"
|
||
return decision, nil
|
||
}
|
||
if !snapshot.RoleEnabled {
|
||
decision.Reason = "role-disabled"
|
||
return decision, nil
|
||
}
|
||
permission, ok := snapshot.Permissions[actionID]
|
||
if !ok {
|
||
decision.Reason = "action-unconfigured"
|
||
return decision, nil
|
||
}
|
||
decision.Allowed = permission.Allowed
|
||
decision.CapAllowed = permission.CapAllowed
|
||
if !permission.CapAllowed {
|
||
decision.Reason = "role-cap"
|
||
} else if !permission.Allowed {
|
||
decision.Reason = "role-default-deny"
|
||
}
|
||
return decision, nil
|
||
}
|
||
|
||
func (h inprocHost) CheckCurrentSystemAction(ctx context.Context, actionID string) (extension.SystemActionDecision, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return extension.SystemActionDecision{}, err
|
||
}
|
||
if _, err := h.rolePolicyActor(); err != nil {
|
||
return extension.SystemActionDecision{}, err
|
||
}
|
||
decision := authzsvc.NewService(h.caps.st).AuthorizeSystem(h.actor, actionID)
|
||
return extension.SystemActionDecision{ActionID: actionID, Allowed: decision.Allowed,
|
||
AuthorityEpoch: decision.AuthorityEpoch, Reason: decision.Reason}, nil
|
||
}
|
||
|
||
func (h inprocHost) currentCapabilityExplanation(ctx context.Context) (store.CapabilityExplanation, extension.CapabilitySubject, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
|
||
}
|
||
if _, err := h.rolePolicyActor(); err != nil {
|
||
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
|
||
}
|
||
explanation, err := authzsvc.NewService(h.caps.st).ExplainCapabilities(h.actor)
|
||
if err != nil {
|
||
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
|
||
}
|
||
subject := extension.CapabilitySubject{SchemaVersion: extension.CapabilityExplainSchemaVersion,
|
||
UserID: explanation.UserID, Owner: explanation.Owner, AuthorityEpoch: explanation.AuthorityEpoch,
|
||
RoleID: explanation.RoleID, RoleName: explanation.RoleName, RoleEnabled: explanation.RoleEnabled}
|
||
return explanation, subject, nil
|
||
}
|
||
|
||
func capabilityActionExplains(in []store.CapabilityActionExplain) []extension.CapabilityActionExplain {
|
||
out := make([]extension.CapabilityActionExplain, 0, len(in))
|
||
for _, action := range in {
|
||
out = append(out, extension.CapabilityActionExplain(action))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (h inprocHost) CurrentCapabilityExplain(ctx context.Context) (extension.CapabilityExplain, error) {
|
||
explanation, subject, err := h.currentCapabilityExplanation(ctx)
|
||
if err != nil {
|
||
return extension.CapabilityExplain{}, err
|
||
}
|
||
capability := explanation.Capability
|
||
return extension.CapabilityExplain{CapabilitySubject: subject,
|
||
ProjectCreate: extension.CapabilityActionExplain(capability.ProjectCreate),
|
||
Linked: capabilityActionExplains(capability.Linked),
|
||
Conflicts: append([]string{}, capability.Conflicts...), MigrationNote: capability.MigrationNote}, nil
|
||
}
|
||
|
||
func (h inprocHost) CurrentMemberBasicCollaborate(ctx context.Context) (extension.MemberBasicCollaborate, error) {
|
||
explanation, subject, err := h.currentCapabilityExplanation(ctx)
|
||
if err != nil {
|
||
return extension.MemberBasicCollaborate{}, err
|
||
}
|
||
return extension.MemberBasicCollaborate{CapabilitySubject: subject,
|
||
Actions: capabilityActionExplains(explanation.MemberBasicCollaborate.Actions)}, nil
|
||
}
|
||
|
||
func (h inprocHost) CurrentDocumentQualify(ctx context.Context) (extension.DocumentQualify, error) {
|
||
explanation, subject, err := h.currentCapabilityExplanation(ctx)
|
||
if err != nil {
|
||
return extension.DocumentQualify{}, err
|
||
}
|
||
duties := make([]extension.DocumentDutyQualify, 0, len(explanation.DocumentQualify.Duties))
|
||
for _, duty := range explanation.DocumentQualify.Duties {
|
||
duties = append(duties, extension.DocumentDutyQualify(duty))
|
||
}
|
||
return extension.DocumentQualify{CapabilitySubject: subject, Duties: duties}, nil
|
||
}
|
||
|
||
func (h inprocHost) requireRoleAdminAction(actionID string) error {
|
||
if h.actor == "" || h.owner != h.actor {
|
||
return store.ErrAccountForbidden
|
||
}
|
||
decision := authzsvc.NewService(h.caps.st).AuthorizeSystem(h.actor, actionID)
|
||
if !decision.Allowed {
|
||
return store.ErrAccountForbidden
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func roleAdminRecord(view store.RoleAdminView) extension.RoleAdminRecord {
|
||
out := extension.RoleAdminRecord{RoleID: view.RoleID, Name: view.Name, Description: view.Description,
|
||
Enabled: view.Enabled, RoleVersion: view.RoleVersion, BindingCount: view.BindingCount,
|
||
Permissions: map[string]extension.RolePermission{}}
|
||
for _, permission := range view.Permissions {
|
||
out.Permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (h inprocHost) ListRoles(ctx context.Context) ([]extension.RoleAdminRecord, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := h.requireRoleAdminAction("system.roles.view"); err != nil {
|
||
return nil, err
|
||
}
|
||
result := []extension.RoleAdminRecord{}
|
||
cursor := ""
|
||
for {
|
||
roles, next, err := h.caps.st.ListRoles("", "fuzzy", "all", "", cursor, 100)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, role := range roles {
|
||
result = append(result, roleAdminRecord(role))
|
||
}
|
||
if next == "" {
|
||
return result, nil
|
||
}
|
||
cursor = next
|
||
}
|
||
}
|
||
|
||
// ListRoleOptions is the read-only role directory for non-admin dispatch
|
||
// actors: enabled roles only, projected as roleId+name, sorted by name then
|
||
// roleId. It never requires system.roles.view and never exposes permissions,
|
||
// bindings or counts; callers still authorize their own domain action.
|
||
func (h inprocHost) ListRoleOptions(ctx context.Context) ([]extension.RoleOption, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := h.requireEffective(); err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := h.rolePolicyActor(); err != nil {
|
||
return nil, err
|
||
}
|
||
result := []extension.RoleOption{}
|
||
cursor := ""
|
||
for {
|
||
roles, next, err := h.caps.st.ListRoles("", "fuzzy", "all", "", cursor, 100)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, role := range roles {
|
||
if !role.Enabled {
|
||
continue
|
||
}
|
||
result = append(result, extension.RoleOption{RoleID: role.RoleID, Name: role.Name})
|
||
}
|
||
if next == "" {
|
||
sort.Slice(result, func(i, j int) bool {
|
||
if result[i].Name != result[j].Name {
|
||
return result[i].Name < result[j].Name
|
||
}
|
||
return result[i].RoleID < result[j].RoleID
|
||
})
|
||
return result, nil
|
||
}
|
||
cursor = next
|
||
}
|
||
}
|
||
|
||
var _ extension.RoleOptionsProvider = inprocHost{}
|
||
|
||
func (h inprocHost) CreateRole(ctx context.Context, name, description string) (extension.RoleAdminRecord, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return extension.RoleAdminRecord{}, err
|
||
}
|
||
if err := h.requireRoleAdminAction("system.roles.manage"); err != nil {
|
||
return extension.RoleAdminRecord{}, err
|
||
}
|
||
role, err := h.caps.st.CreateRole(h.actor, store.RoleCreate{Name: name, Description: description})
|
||
if err != nil {
|
||
return extension.RoleAdminRecord{}, err
|
||
}
|
||
view, err := h.caps.st.RoleAdmin(role.RoleID)
|
||
return roleAdminRecord(view), err
|
||
}
|
||
|
||
func (h inprocHost) SetRolePermission(ctx context.Context, roleID, actionID string, allowed, capAllowed bool, key string) (extension.RoleAdminRecord, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return extension.RoleAdminRecord{}, err
|
||
}
|
||
if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil {
|
||
return extension.RoleAdminRecord{}, err
|
||
}
|
||
view, err := h.caps.st.RoleAdmin(roleID)
|
||
if err != nil {
|
||
return extension.RoleAdminRecord{}, err
|
||
}
|
||
changes := make([]store.RolePermissionChange, 0, len(view.Permissions)+1)
|
||
found := false
|
||
for _, permission := range view.Permissions {
|
||
if permission.ActionID == actionID {
|
||
found = true
|
||
if permission.Allowed == allowed && permission.CapAllowed == capAllowed {
|
||
return roleAdminRecord(view), nil
|
||
}
|
||
permission.Allowed, permission.CapAllowed = allowed, capAllowed
|
||
}
|
||
changes = append(changes, store.RolePermissionChange{ActionID: permission.ActionID, Allowed: permission.Allowed, CapAllowed: permission.CapAllowed})
|
||
}
|
||
if !found {
|
||
changes = append(changes, store.RolePermissionChange{ActionID: actionID, Allowed: allowed, CapAllowed: capAllowed})
|
||
}
|
||
if key == "" {
|
||
return extension.RoleAdminRecord{}, store.ErrInvalidRolePolicy
|
||
}
|
||
if _, err := h.caps.st.ReplaceRolePermissions(h.actor, roleID, view.RoleVersion, changes, key+":"+strconv.FormatInt(view.RoleVersion, 10)); err != nil {
|
||
return extension.RoleAdminRecord{}, err
|
||
}
|
||
view, err = h.caps.st.RoleAdmin(roleID)
|
||
return roleAdminRecord(view), err
|
||
}
|
||
|
||
func (h inprocHost) AssignRoleByOwner(ctx context.Context, owner, roleID, key string) (extension.RoleBindingResult, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return extension.RoleBindingResult{}, err
|
||
}
|
||
if err := h.requireRoleAdminAction("system.roles.assign"); err != nil {
|
||
return extension.RoleBindingResult{}, err
|
||
}
|
||
target, err := h.caps.st.GetAccountByOwner(owner)
|
||
if err != nil {
|
||
return extension.RoleBindingResult{}, err
|
||
}
|
||
current, err := h.caps.st.RoleForUser(target.ID)
|
||
if err != nil {
|
||
return extension.RoleBindingResult{}, err
|
||
}
|
||
if current.Binding != nil && current.Binding.RoleID == roleID {
|
||
return extension.RoleBindingResult{BindingID: current.Binding.BindingID, UserID: target.ID, RoleID: roleID,
|
||
UserStateVersion: current.Binding.UserStateVersion, AuthorityEpoch: current.Binding.AuthorityEpoch}, nil
|
||
}
|
||
if key == "" {
|
||
return extension.RoleBindingResult{}, store.ErrInvalidRolePolicy
|
||
}
|
||
binding, err := h.caps.st.AssignUserRole(h.actor, target.ID, roleID, target.StateVersion, key+":"+strconv.FormatInt(target.StateVersion, 10))
|
||
if err != nil {
|
||
return extension.RoleBindingResult{}, err
|
||
}
|
||
return extension.RoleBindingResult{BindingID: binding.BindingID, UserID: binding.UserID, RoleID: binding.RoleID,
|
||
UserStateVersion: binding.UserStateVersion, AuthorityEpoch: binding.AuthorityEpoch}, nil
|
||
}
|
||
|
||
func (h inprocHost) SetRoleEnabled(ctx context.Context, roleID string, enabled bool) (extension.RoleAdminRecord, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return extension.RoleAdminRecord{}, err
|
||
}
|
||
if err := h.requireRoleAdminAction("system.roles.manage"); err != nil {
|
||
return extension.RoleAdminRecord{}, err
|
||
}
|
||
view, err := h.caps.st.RoleAdmin(roleID)
|
||
if err != nil {
|
||
return extension.RoleAdminRecord{}, err
|
||
}
|
||
if view.Enabled == enabled {
|
||
return roleAdminRecord(view), nil
|
||
}
|
||
role, err := h.caps.st.PatchRole(h.actor, roleID, view.RoleVersion, store.RolePatch{Enabled: &enabled})
|
||
if err != nil {
|
||
return extension.RoleAdminRecord{}, err
|
||
}
|
||
view, err = h.caps.st.RoleAdmin(role.RoleID)
|
||
return roleAdminRecord(view), err
|
||
}
|
||
|
||
func (h inprocHost) ImportRoleAuthority(ctx context.Context, in extension.RoleAuthorityImport, key string) (extension.RoleAuthorityImportResult, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return extension.RoleAuthorityImportResult{}, err
|
||
}
|
||
if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil {
|
||
return extension.RoleAuthorityImportResult{}, err
|
||
}
|
||
roles := make([]store.RoleAuthorityImportRole, 0, len(in.Roles))
|
||
for _, role := range in.Roles {
|
||
permissions := make([]store.RolePermissionChange, 0, len(role.Permissions))
|
||
for actionID, permission := range role.Permissions {
|
||
permissions = append(permissions, store.RolePermissionChange{ActionID: actionID,
|
||
Allowed: permission.Allowed, CapAllowed: permission.CapAllowed})
|
||
}
|
||
sort.Slice(permissions, func(i, j int) bool { return permissions[i].ActionID < permissions[j].ActionID })
|
||
roles = append(roles, store.RoleAuthorityImportRole{RoleID: role.RoleID, Name: role.Name,
|
||
Description: role.Description, Enabled: role.Enabled, RoleVersion: role.RoleVersion,
|
||
Permissions: permissions, Owners: append([]string(nil), role.Owners...)})
|
||
}
|
||
result, err := h.caps.st.ImportRoleAuthority(h.actor, store.RoleAuthorityImport{
|
||
SourceSchemaVersion: in.SourceSchemaVersion, SourceSHA256: in.SourceSHA256, Roles: roles}, key)
|
||
if err != nil {
|
||
return extension.RoleAuthorityImportResult{}, err
|
||
}
|
||
return extension.RoleAuthorityImportResult{AuthorityEpoch: result.AuthorityEpoch,
|
||
ImportedRoles: result.ImportedRoles, ImportedUsers: result.ImportedUsers}, nil
|
||
}
|
||
|
||
func (h inprocHost) ReadRoleAuthorityImport(ctx context.Context, sourceSHA string) (extension.RoleAuthorityImportProjection, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return extension.RoleAuthorityImportProjection{}, err
|
||
}
|
||
if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil {
|
||
return extension.RoleAuthorityImportProjection{}, err
|
||
}
|
||
staged, err := h.caps.st.ReadRoleAuthorityImport(h.actor, sourceSHA)
|
||
if err != nil {
|
||
return extension.RoleAuthorityImportProjection{}, err
|
||
}
|
||
roles := make([]extension.RoleAdminRecord, 0, len(staged.Roles))
|
||
bindings := map[string]string{}
|
||
for _, role := range staged.Roles {
|
||
permissions := map[string]extension.RolePermission{}
|
||
for _, permission := range role.Permissions {
|
||
permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed}
|
||
}
|
||
roles = append(roles, extension.RoleAdminRecord{RoleID: role.RoleID, Name: role.Name,
|
||
Description: role.Description, Enabled: role.Enabled, RoleVersion: role.RoleVersion,
|
||
BindingCount: len(role.Owners), Permissions: permissions})
|
||
for _, owner := range role.Owners {
|
||
bindings[owner] = role.RoleID
|
||
}
|
||
}
|
||
epoch, err := h.caps.st.AuthorityEpoch()
|
||
if err != nil {
|
||
return extension.RoleAuthorityImportProjection{}, err
|
||
}
|
||
return extension.RoleAuthorityImportProjection{SourceSHA256: staged.SourceSHA256,
|
||
AuthorityEpoch: epoch, Roles: roles, Bindings: bindings}, nil
|
||
}
|
||
|
||
func (h inprocHost) ActivateRoleAuthorityImport(ctx context.Context, sourceSHA string, expectedEpoch int64) (extension.RoleAuthorityImportResult, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return extension.RoleAuthorityImportResult{}, err
|
||
}
|
||
if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil {
|
||
return extension.RoleAuthorityImportResult{}, err
|
||
}
|
||
result, err := h.caps.st.ActivateRoleAuthorityImport(h.actor, sourceSHA, expectedEpoch)
|
||
if err != nil {
|
||
return extension.RoleAuthorityImportResult{}, err
|
||
}
|
||
return extension.RoleAuthorityImportResult{AuthorityEpoch: result.AuthorityEpoch,
|
||
ImportedRoles: result.ImportedRoles, ImportedUsers: result.ImportedUsers}, nil
|
||
}
|
||
|
||
func (h inprocHost) KVPut(key, value string) error {
|
||
if err := h.requireEffective(); err != nil {
|
||
return err
|
||
}
|
||
return h.caps.st.PutPluginKVAccountChecked(h.owner, h.pluginID, key, value, h.caps.allowImplicitLocal)
|
||
}
|
||
|
||
func (h inprocHost) KVGet(key string) (string, bool, error) {
|
||
if err := h.requireEffective(); err != nil {
|
||
return "", false, err
|
||
}
|
||
return h.caps.st.GetPluginKV(h.pluginID, h.owner, key)
|
||
}
|
||
|
||
func (h inprocHost) Audit(action, target, evidence string) error {
|
||
if err := h.requireEffective(); err != nil {
|
||
return err
|
||
}
|
||
if strings.HasPrefix(action, "model.context_manifest") {
|
||
return fmt.Errorf("extension: model context manifest is a reserved host receipt")
|
||
}
|
||
return h.caps.st.AppendAuditEventAccountChecked(h.owner, store.AuditEvent{
|
||
Owner: h.owner, Actor: "plugin:" + h.pluginID,
|
||
Action: action, Target: target, Evidence: evidence,
|
||
}, h.caps.allowImplicitLocal)
|
||
}
|
||
|
||
func (h inprocHost) Complete(ctx context.Context, prompt string) (string, error) {
|
||
if err := h.requireEffective(); err != nil {
|
||
return "", err
|
||
}
|
||
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) {
|
||
if err := h.requireEffective(); err != nil {
|
||
return nil, err
|
||
}
|
||
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 err := h.requireEffective(); err != nil {
|
||
return err
|
||
}
|
||
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.AddKnowledgeAccountChecked(h.owner, h.caps.allowImplicitLocal, docs...)
|
||
}
|
||
return h.caps.pipe.IndexChunksAccountChecked(ctx, core.ID(h.owner), docs, h.caps.allowImplicitLocal)
|
||
}
|
||
|
||
// 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 err := h.requireEffective(); err != nil {
|
||
return err
|
||
}
|
||
if len(docs) == 0 {
|
||
return nil
|
||
}
|
||
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
|
||
}
|
||
}
|
||
scoped := h.caps.projects != nil && h.caps.projects.active()
|
||
if embed {
|
||
return h.caps.pipe.PublishChunksAccountChecked(ctx, core.ID(h.owner), h.pluginID, scoped, docs, h.caps.allowImplicitLocal)
|
||
}
|
||
return h.caps.st.ReplaceKnowledgeAccountChecked(ctx, h.owner, h.pluginID, scoped, h.caps.allowImplicitLocal, docs...)
|
||
}
|
||
|
||
// KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual
|
||
// 或他人来源拒绝(fail-closed);删行+向量,幂等。
|
||
func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) error {
|
||
if err := h.requireEffective(); err != nil {
|
||
return err
|
||
}
|
||
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.DeleteKnowledgeBySourceAccountChecked(ctx, h.owner, sourcePrefix, h.caps.allowImplicitLocal)
|
||
return err
|
||
}
|
||
|
||
// TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。
|
||
func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, error) {
|
||
if err := h.requireEffective(); err != nil {
|
||
return "", err
|
||
}
|
||
t, err := h.caps.st.CreateBackgroundTaskAccountChecked(h.owner, kind, title, conversationID, h.caps.allowImplicitLocal)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return t.ID, nil
|
||
}
|
||
|
||
func (h inprocHost) TaskUpdate(taskID, status string, progress int, errText string) error {
|
||
if err := h.requireEffective(); err != nil {
|
||
return err
|
||
}
|
||
return h.caps.st.UpdateBackgroundTaskAccountChecked(h.owner, taskID, status, progress, errText, h.caps.allowImplicitLocal)
|
||
}
|
||
|
||
// ArtifactGet/ArtifactContent 工件只读(R1-3 ingest 读原件;写入属完整集)。
|
||
func (h inprocHost) ArtifactGet(artifactID string) (core.Artifact, error) {
|
||
if err := h.requireEffective(); err != nil {
|
||
return core.Artifact{}, err
|
||
}
|
||
return h.caps.st.GetArtifact(h.owner, artifactID)
|
||
}
|
||
|
||
func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) {
|
||
if err := h.requireEffective(); err != nil {
|
||
return nil, err
|
||
}
|
||
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) {
|
||
if err := h.requireEffective(); err != nil {
|
||
return core.ParsedDocument{}, err
|
||
}
|
||
return h.caps.parse(ctx, filename, mime, content)
|
||
}
|
||
|
||
// ---- EXT-17 Surface 能力(可选):nil 槽=能力缺失,明确拒绝不降级 ----
|
||
|
||
func (h inprocHost) surface() (*surfacesvc.Service, error) {
|
||
if err := h.requireEffective(); err != nil {
|
||
return nil, err
|
||
}
|
||
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.SubmitAccountChecked(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,
|
||
}, h.caps.allowImplicitLocal)
|
||
}
|
||
|
||
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
|
||
}
|