Files
Agent-System-Backend/pkg/extension/extension.go
T
weichen 8c60ed092b
ci / vet-and-test (pull_request) Successful in 1m2s
Merge remote-tracking branch 'origin/main' into feature/svc-plugin-host-options
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	pkg/extension/extension.go
2026-10-15 01:58:24 +00:00

369 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 extension 是基座对下游仓的公开扩展面。
// 下游只能依赖本包(以及 agent-system-core 的类型),不能导入 internal。
package extension
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"sync"
core "agent-system-core"
)
// SurfaceVersion 是本包导出面的实验版本。变更导出符号必须同时更新 surface.txt。
// 2.1.0-experimental:EXT-8 新增 ResourceAuthorizer 与 Registry.RegisterResourceAuthorizer。
// 2.2.0-experimental:EXT-9 新增 Host.KnowledgePublish/KnowledgeDelete 与
// Options.DisableUploadIndexing。
// 2.3.0-experimental:EXT-12 最小开关 Options.DisableConvCardMirror(会话卡不镜像
// 进项目公共流)。
// 2.4.0-experimental:EXT-11 新增 SetScopedHostFactory/NewScopedHost(按 owner+pluginID
// 重建受限宿主句柄,供下游恢复路径)。
// 2.5.0-experimental:Options.DisableExecTools(执行原语启动级硬关闭)。
// 2.6.0-experimental:Options.ExecBackend(EXT-16 执行后端 direct|sandbox)。
// 2.7.0-experimental:Registry.AddIntent 与 IntentSpec(G2-2 意图声明面)。
// 2.8.0-experimental:EXT-17 新增可选能力接口 SurfaceHost(SurfaceSave/Get/List/
// Activation;Generative UI U2)。实现由基座 hostcaps 注入;未装配时类型断言
// 不成立即明确拒绝,不给必实现 Host 接口加方法(不破坏既有消费者)。
// 2.11.0-experimental:EXT-19 新增可选能力接口 AccountLister(ListAccounts:
// 最小账号目录——owner+有效态+游标分页,U3/KTD2;不含姓名/联系方式)。
// 2.10.0-experimental:EXT-8b 新增 ResourceAuthzQuery/ResourceAuthorizerV2 与
// Registry.RegisterResourceAuthorizerV2(资源级授权,U9 首切片)。
// 2.9.0-experimental:Registry.AddAdminZone 与 AdminZoneSpec(WP3-I3/KTD4:编译
// 插件向基座管理区声明展示分区;分区入口不做基座级权限判定,数据能力由
// 分区组件自身的命令面 fail-closed)。
// 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted
// ResourceAuthzQuery.Source for per-document publication authorization.
// 2.13.0-experimental: optional AccountDirectoryProvider and trusted in-process
// AccountDirectoryRegistrar for scoped, current account candidate decisions.
// 2.14.0-experimental:InProc.HostOptions / CLIHook,以及 MergeHostOptions、
// RegisterCLIFlags、RunCLIHooks(组合二进制把插件声明并进基座启动,不覆盖显式参数)。
const SurfaceVersion = "2.14.0-experimental"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
// ResourceAuthorizer 是 EXT-8 资源授权适配器:基座在 owner 自有资源判定未命中时,
// 回调询问「owner 对 projectID 是否有共享读取授权」。只回答布尔,不接触基座内部
// 状态;返回 error 视为拒绝(fail-closed)。仅作用于读取路径,写路径一律 owner 自有。
type ResourceAuthorizer func(owner string, projectID core.ID) (bool, error)
// ResourceAuthzQuery 是 EXT-8b 资源级授权查询(U9):字段由执行端从受信上下文
// (认证会话、受信存储)构造,不采信请求自报的归属;下游只回答布尔,不接触基座
// 内部状态;返回 error 视为拒绝(fail-closed)。读取路径专用;写路径一律 owner 自有。
type ResourceAuthzQuery struct {
Actor string `json:"actor"` // 认证主体(发起读取的会话)
Action string `json:"action"` // read|download|list|search|stream|model-context
ResourceType string `json:"resourceType"` // 基座资源类型(artifact/attachment/...)
ResourceID core.ID `json:"resourceId"` // 资源稳定 ID
Owner string `json:"owner"` // 资源属主(受信存储解析,非请求自报)
// Source is optional trusted resource provenance, populated from stored knowledge rows.
Source string `json:"source,omitempty"`
ProjectID core.ID `json:"projectId,omitempty"` // 所属项目;空=无项目私域
}
// ResourceAuthorizerV2 EXT-8b 资源级授权适配器:V1(owner×project 布尔)之上的
// 细粒度形态——注册后对域内读路径权威(含 owner 自有命中与共享读取)。
type ResourceAuthorizerV2 func(q ResourceAuthzQuery) (bool, error)
// Registry 是进程内扩展的注册口。实现在基座内部,对下游只暴露这些方法。
type Registry interface {
AddSkill(name, markdown string) error
AddCommand(spec json.RawMessage) error
// AddCommandHandler 注册插件命令规格并绑定进程内处理器(EXT-1):
// 基座在分发时按请求 Owner 构造 Host 句柄调用 h。
AddCommandHandler(spec json.RawMessage, h Handler) error
Note(kind, name string, payload json.RawMessage) error
// RegisterResourceAuthorizer 注册 EXT-8 资源授权适配器(进程内扩展);
// 首个注册者生效。声明式扩展不支持(回调需宿主进程内代码)。
RegisterResourceAuthorizer(authz ResourceAuthorizer) error
// RegisterResourceAuthorizerV2 注册 EXT-8b 资源级授权适配器(进程内扩展);
// 首个注册者生效;注册后读路径按资源粒度咨询(V1 为未注册时的兼容回落)。
RegisterResourceAuthorizerV2(authz ResourceAuthorizerV2) error
// AddIntent 声明自然语言意图(G2-2):短语命中后路由到已注册命令——
// 经同一命令总线分发(同审计信封与 HITL 收口)。规格见 IntentSpec。
AddIntent(spec json.RawMessage) error
// AddAdminZone 声明基座管理区展示分区(WP3-I3/KTD4):编译插件把自己的
// 领域工作台以分区形式挂进管理区;声明只携带展示元数据,分区入口不做
// 基座级权限判定——数据能力由分区组件自身的命令面 fail-closed。
// 规格见 AdminZoneSpec。
AddAdminZone(spec json.RawMessage) error
}
// AccountEntry 最小账号目录行(U3/KTD2):owner+有效态——授权管理所需的最小
// 身份。2026-10-13 P3 扩展(提案 rdm-settings-members-duties-perms-increment):
// Name 向授权管理读面下发(同名区分与按姓名查找是成员管理必需;扩展申请四要素
// 见 Dev 任务卡 feature/rdm-increment-settings-duties)。邮箱/联系方式仍一律
// 不下发(隐私边界不变)。
type AccountEntry struct {
Owner string `json:"owner"`
Name string `json:"name,omitempty"`
Valid bool `json:"valid"`
}
// AccountLister EXT-19 旧接口:仅有受信系统管理员分发上下文时可读取。
// 新领域消费者必须使用 AccountDirectoryProvider,不得以本接口作为降级路径。
type AccountLister interface {
ListAccounts(cursor string, limit int) (entries []AccountEntry, next string, err error)
}
// AccountSearcher EXT-19 扩展可选能力(P3,2026-10-13):按账号或姓名检索账号
// 目录。与 AccountLister 相同,它不含领域定位,只允许受信系统管理员上下文;
// 新领域消费者不得回落到此接口。
type AccountSearcher interface {
SearchAccounts(query string, limit int) (entries []AccountEntry, err error)
}
// AdminZoneSpec 管理区分区声明(WP3-I3):ID 是分区身份(建议形如
// <plugin>.<section>,前端按 ID 映射内置分区组件);Title/Icon/Desc 为展示
// 元数据(icon 取基座前端 IconName 词表,未知值前端回退通用图标);Order
// 越小越靠前,同 Order 按 ID 字典序。
type AdminZoneSpec struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Desc string `json:"desc,omitempty"`
Order int `json:"order,omitempty"`
}
// SurfaceSaveRequest 是一次页面提交(EXT-17):Spec 为 core.SurfaceSpec 的
// 严格 JSON(未知字段拒绝);身份信封由宿主按分发 Owner/PluginID 绑定,
// 不采信请求内自报的 owner/pluginId。
type SurfaceSaveRequest struct {
SurfaceID core.ID `json:"surfaceId,omitempty"`
ConversationID core.ID `json:"conversationId,omitempty"`
SourceRunID core.ID `json:"sourceRunId,omitempty"`
BaseRevision int `json:"baseRevision,omitempty"`
ObservedEpoch int64 `json:"observedEpoch,omitempty"`
ContextVersion string `json:"contextVersion,omitempty"`
SemanticText string `json:"semanticText,omitempty"`
Spec json.RawMessage `json:"spec"`
}
// SurfaceHost 是 EXT-17 可选能力接口:插件经宿主保存/读取生成页面。
// 通过对分发 Host 句柄做类型断言获取(断言失败=能力缺失,明确报错不降级)。
type SurfaceHost interface {
// SurfaceSave 校验并保存页面;禁用/epoch 失配/规格违规/revision 冲突
// 分别返回确定错误,携带字段级清单时调用方可修复后至多重提一次。
SurfaceSave(ctx context.Context, req SurfaceSaveRequest) (core.SurfaceSnapshot, error)
// SurfaceGet 读取整页快照(owner 作用域;历史页面禁用后仍可读)。
SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error)
// SurfaceList 列出当前 owner 名下(可按会话过滤)的页面元数据。
SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error)
// SurfaceActivation 观察本插件在当前 owner 的启禁与 epoch(装配能力时
// 快照 ObservedEpoch,拦截禁用后迟到的在途提交)。
SurfaceActivation(ctx context.Context) (enabled bool, epoch int64, err error)
}
// IntentSpec 意图声明规格(G2-2):id 形如 <domain>.<intent>;phrases 是触发
// 短语(宿主归一后包含匹配,最长短语优先);command 须为已注册命令 id;
// params 为参数模板(调用方可覆盖)。宿主侧路由入口:system.intent 命令。
type IntentSpec struct {
ID string `json:"id"`
Phrases []string `json:"phrases"`
Command string `json:"command"`
Params map[string]any `json:"params,omitempty"`
Description string `json:"description,omitempty"`
}
// CLIHook 让编译插件在基座 serve 旗标集上登记自己的旗标,并在解析后决定是否接管本次进程。
// 插件只登记自己的旗标,不重复基座已有旗标。基座在 flag.Parse 前调用 Register,解析后调用 AfterParse。
type CLIHook struct {
Register func(fs *flag.FlagSet)
AfterParse func(opt Options) (handled bool, err error)
}
// InProc 是随下游二进制编译进来的扩展。
type InProc struct {
ID string
Register func(reg Registry) error
// HostOptions 可选。组合二进制把它当作启动默认值:布尔开关按“任一为 true 则为 true”
// 合并,且不覆盖调用方显式传入的参数。未设置则无产品默认。
HostOptions func() Options
// CLI 可选。组合二进制在基座 serve 旗标上挂接插件专属旗标(如 bootstrap-admin)。
CLI *CLIHook
// Prepare 可选。基座合并 HostOptions 之后、进入服务之前调用,可改写 opt 或拒绝启动。
Prepare func(opt *Options) error
}
// Options 是下游自有二进制的启动参数,字段与 agent-system serve 对齐。
type Options struct {
Addr string
DataDir string
PluginsDir string
Mock bool
StorageDSN string
ArtifactsDir string
AuthMode string
// DisableUploadIndexing 关闭上传端点的自动检索索引(EXT-9;零值=false=现状:
// 可解析文本照旧进索引。下游以发布状态机自管索引时置 true)。
DisableUploadIndexing bool
// DisableConvCardMirror 关闭会话卡向项目公共流的镜像(EXT-12 最小开关;
// 零值=false=现状全量镜像。true 时会话卡只进本人会话流,不发布即不持久,
// 项目流重放不含会话卡;项目级卡不受影响)。
DisableConvCardMirror bool
// DisableExecTools 启动级硬关闭执行原语(run_shell / run_python /
// register_artifact 及小队内核执行配置):用户设置 runtime.exec 无法覆盖。
// 零值=false=现状。多用户共享部署在执行隔离就位前应置 true。
DisableExecTools bool
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档
// (模型生成代码按不可信对待:断网、只读系统根、tmpfs 遮蔽 /home 与 /tmp、
// 仅会话工作目录与技能目录挂载、进程数限额、超时杀整个沙箱)。
// DisableExecTools 优先级高于此选择。
ExecBackend string
}
var (
mu sync.Mutex
compiled []InProc
runner func(Options) error
)
// MergeHostOptions 把已登记插件的 HostOptions 合并进 base。
// 布尔开关按“任一为 true 则为 true”。ExecBackend 取首个非空值,彼此冲突则返回错误。
// Addr、DataDir、PluginsDir、StorageDSN、AuthMode 等运行参数不从插件合并,避免覆盖调用方。
func MergeHostOptions(base Options) (Options, error) {
for _, e := range Compiled() {
if e.HostOptions == nil {
continue
}
opt := e.HostOptions()
if opt.DisableUploadIndexing {
base.DisableUploadIndexing = true
}
if opt.DisableConvCardMirror {
base.DisableConvCardMirror = true
}
if opt.DisableExecTools {
base.DisableExecTools = true
}
if opt.ExecBackend == "" {
continue
}
if base.ExecBackend != "" && base.ExecBackend != opt.ExecBackend {
return base, fmt.Errorf("extension: plugin %s exec backend %q conflicts with %q", e.ID, opt.ExecBackend, base.ExecBackend)
}
if base.ExecBackend == "" {
base.ExecBackend = opt.ExecBackend
}
}
return base, nil
}
// RegisterCLIFlags 让每个已登记插件往 fs 登记自己的旗标。须在 Parse 之前调用。
func RegisterCLIFlags(fs *flag.FlagSet) {
if fs == nil {
return
}
for _, e := range Compiled() {
if e.CLI != nil && e.CLI.Register != nil {
e.CLI.Register(fs)
}
}
}
// RunCLIHooks 在旗标解析之后依次调用插件 AfterParse。
// 第一个 handled 的插件接管本次进程,基座不应再进入服务循环。
func RunCLIHooks(opt Options) (bool, error) {
for _, e := range Compiled() {
if e.CLI == nil || e.CLI.AfterParse == nil {
continue
}
handled, err := e.CLI.AfterParse(opt)
if err != nil || handled {
return handled, err
}
}
return false, nil
}
// PrepareCompiled 在 HostOptions 合并进 opt 之后调用各插件 Prepare。任一错误拒绝启动。
func PrepareCompiled(opt *Options) error {
if opt == nil {
return errors.New("extension: nil options")
}
for _, e := range Compiled() {
if e.Prepare == nil {
continue
}
if err := e.Prepare(opt); err != nil {
return fmt.Errorf("extension: %s: %w", e.ID, err)
}
}
return nil
}
// RegisterCompiled 在 init 中登记进程内扩展。
func RegisterCompiled(e InProc) {
mu.Lock()
defer mu.Unlock()
compiled = append(compiled, e)
}
// Compiled 返回已登记扩展的副本。
func Compiled() []InProc {
mu.Lock()
defer mu.Unlock()
out := make([]InProc, len(compiled))
copy(out, compiled)
return out
}
// ResetCompiled 只给测试清状态。
func ResetCompiled() {
mu.Lock()
compiled = nil
mu.Unlock()
}
// SetRunner 由 link 包在 init 时注入。下游不要调用。
func SetRunner(fn func(Options) error) {
mu.Lock()
runner = fn
mu.Unlock()
}
// scopedHostFactory 由组合根在装配 hostcaps 后注入(EXT-11):按 owner+pluginID
// 构造与分发期同构的受限 Host。下游不要调用 SetScopedHostFactory。
var scopedHostFactory func(owner, pluginID string) Host
// SetScopedHostFactory 组合根注入(EXT-11)。下游不要调用。
func SetScopedHostFactory(fn func(owner, pluginID string) Host) {
mu.Lock()
scopedHostFactory = fn
mu.Unlock()
}
// NewScopedHost 按 owner+pluginID 重建受限宿主句柄(EXT-11):供下游任务恢复等
// 非分发期路径获取合法句柄;能力与分发期同构(owner 作用域、来源前缀强制、
// KV/任务台账隔离)。未 link 或未注入时返回错误(fail-closed,不降级)。
func NewScopedHost(owner, pluginID string) (Host, error) {
mu.Lock()
fn := scopedHostFactory
mu.Unlock()
if fn == nil {
return nil, ErrNotLinked
}
if owner == "" || pluginID == "" {
return nil, errors.New("extension: scoped host requires owner and pluginID")
}
h := fn(owner, pluginID)
if h == nil {
return nil, errors.New("extension: scoped host factory returned nil")
}
return h, nil
}
// Main 启动带有已登记扩展的服务。
func Main(opt Options) error {
mu.Lock()
fn := runner
mu.Unlock()
if fn == nil {
return ErrNotLinked
}
return fn(opt)
}