425 lines
20 KiB
Go
425 lines
20 KiB
Go
// 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: optional ProjectAuditHost and ProjectAuditRegistry (X2
|
||
// project-bound command-audit read slice and domain audit.read callback).
|
||
// 2.15.0-experimental: X2 optional committed project-audit link publisher,
|
||
// verifier, disclosure callback, and exact linked receipt read.
|
||
// 2.16.0-experimental: trusted Request.OperationID before handler execution and
|
||
// retryable ProjectAuditLinkRequest.HostAuditOffset=0 resolution.
|
||
// 2.17.0-experimental: cryptographic operation IDs and registration-bound
|
||
// ProjectAuditPublisherFactory; scoped Host string IDs cannot publish links.
|
||
// 2.18.0-experimental: PostgreSQL project audit pages require a verified
|
||
// committed-prefix ID generator; unsafe sequence/default drift rejects reads.
|
||
// 2.19.0-experimental: all PostgreSQL audit writes require the database
|
||
// insert guard and append-only policy before a committed-prefix claim.
|
||
// 2.20.0-experimental: combine optional X1 project context/source recheck and
|
||
// X2 project audit capabilities on one Backend surface.
|
||
// 2.21.0-experimental: optional ProjectWorkObservationRegistry; host-origin
|
||
// work-source observations and project action decisions (X3 contract).
|
||
// 2.22.0-experimental: optional HumanDecisionVerifier. The host proves that
|
||
// this dispatch is an authenticated human response to one exact pending card.
|
||
// It does not consume the proof; the caller does that in its own transaction.
|
||
// 2.23.0-experimental: InProc.HostOptions / CLIHook, plus MergeHostOptions,
|
||
// RegisterCLIFlags、RunCLIHooks(组合二进制把插件声明并进基座启动,不覆盖显式参数)。
|
||
// 2.24.0-experimental:EXT-20 可选 RegulationsHost 与受信进程内 RegulationsRegistrar
|
||
// (共享法规检索与网络证据;仅插件 kbreg 可注册提供方)。
|
||
// 2.25.0-experimental: IAM U4 optional RolePolicyProvider and
|
||
// SystemActionProvider for the Backend-owned single-effective-role policy;
|
||
// resource queries carry the current authority epoch so downstream caches can
|
||
// reject stale role snapshots.
|
||
// 2.26.0-experimental: IAM link U1 RolePolicySnapshot.Linked carries the
|
||
// Backend-computed project.create own-project management link and its source.
|
||
// 2.27.0-experimental: IAM link U3 optional CapabilityExplainProvider
|
||
// (CapabilityExplain / MemberBasicCollaborate / DocumentQualify for the
|
||
// Host-bound actor) and categorized WorkFailure for create, member add and
|
||
// conversation work.
|
||
// 2.28.0-experimental: optional RoleOptionsProvider (read-only enabled role
|
||
// directory: RoleOption roleId+name, no permissions or bindings) and the
|
||
// AccountDirectory purpose project.member-roster, an explicit project member
|
||
// enumeration inside the trusted RDM scope.
|
||
// 2.29.0-experimental: X2 project audit read completion. ProjectAuditQuery gains
|
||
// optional linked resource/run locators, IncludeBody, and Export; receipts may
|
||
// carry ProjectAuditReceiptResource only under the domain's per-object body
|
||
// right; ProjectAuditCoverage states Export scope. Every addition is an optional
|
||
// field or a new exported type: existing ProjectAuditHost/ProjectAuditRegistry
|
||
// providers and queries keep compiling and behaving unchanged.
|
||
const SurfaceVersion = "2.29.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"` // 所属项目;空=无项目私域
|
||
// AuthorityEpoch is read from Backend authority_state at the point of use.
|
||
// Downstream policy caches must reject snapshots from another epoch.
|
||
AuthorityEpoch int64 `json:"authorityEpoch,omitempty"`
|
||
// RolePolicy is the current Backend snapshot for Actor on trusted in-process
|
||
// calls. It lets downstream resource authorization refresh without a command.
|
||
RolePolicy *RolePolicySnapshot `json:"rolePolicy,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
|
||
// RequireProjectContext makes startup fail if no in-process X1 provider
|
||
// registered. Downstream products opt in when project context is mandatory.
|
||
RequireProjectContext bool
|
||
// 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
|
||
// ExecDepsDir、ExecWorkspace、ExecEnvRoot 与基座 serve 同名。
|
||
// 产品入口不解析这三项旗标时留空,link 改读同名环境变量
|
||
//(AGENT_SYSTEM_EXEC_DEPS_DIR / AGENT_SYSTEM_EXEC_WORKSPACE / AGENT_SYSTEM_EXEC_ENV_ROOT)。
|
||
ExecDepsDir string
|
||
ExecWorkspace string
|
||
ExecEnvRoot 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)
|
||
}
|