Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes. Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending. Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete. Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
510 lines
16 KiB
Go
510 lines
16 KiB
Go
// Package plugins 实现插件宿主(4.6):manifest + 声明资产 + 可选代码。
|
||
// fail-closed:任一资产解析失败整批拒绝该插件并保留上一状态;卸载注销注册项。
|
||
// 声明资产热加载:Reload 重扫目录;工具/面板/卡片/意图/工作流/设置全部数据化。
|
||
package plugins
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
|
||
"agent-system-backend/internal/settings"
|
||
"agent-system-backend/internal/store"
|
||
"agent-system-backend/internal/tools"
|
||
"agent-system-backend/internal/workflow"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// Loaded 是一个已装载插件的内存态(注册结果快照)。
|
||
type Loaded struct {
|
||
Manifest core.PluginManifest
|
||
Dir string
|
||
Panels []core.PluginPanel
|
||
Cards map[string][]core.UINode // kind → 模板节点
|
||
Workflows []core.WorkflowDef
|
||
Scripts map[string]string // workflow.ScriptKey → 插件目录内的脚本正文
|
||
Intents []json.RawMessage // agent-intent overlay 形态(目录侧消费)
|
||
Commands []json.RawMessage // Agent-Command spec overlay(cmdbus 注册)
|
||
Workspaces []core.PluginWorkspace // 工作区弹层声明(OverlayHost 渲染)
|
||
Prompts map[string]string // 文件名 → 内容(画像模板)
|
||
PluginSettings []core.SettingDesc
|
||
Defaults map[string]any // 插件设置默认值(并入有效设置)
|
||
Skills map[string]string
|
||
MCP []core.PluginMCPServer
|
||
Knowledge []core.PluginKnowledgeSet
|
||
Parsers []core.PluginParserDecl
|
||
Agents []core.PluginAgentDecl
|
||
Channels []core.PluginChannelDecl
|
||
Pages []core.PluginPageDecl
|
||
}
|
||
|
||
// Host 管理插件目录装载与注册。
|
||
type Host struct {
|
||
mu sync.RWMutex
|
||
st *store.Store
|
||
registry *tools.Registry
|
||
settings *settings.Service
|
||
dir string
|
||
skillsRoot string
|
||
hostAPI string
|
||
hostToken string
|
||
sidecars map[string]*exec.Cmd
|
||
loaded map[string]*Loaded // id → loaded
|
||
// OnIntents 在装载集合变化后回调全部意图 overlay(宿主接识别闸门;nil = 不接)。
|
||
OnIntents func(overlay []json.RawMessage) error
|
||
// OnCommands 在装载集合变化后回调全部命令 spec overlay(宿主接命令总线;nil = 不接)。
|
||
OnCommands func(overlay []json.RawMessage) error
|
||
// OnWorkflows 在装载集合变化后回调全部工作流与脚本正文(nil = 不接)。
|
||
OnWorkflows func(defs []core.WorkflowDef, scripts map[string]string) error
|
||
}
|
||
|
||
func New(st *store.Store, reg *tools.Registry, s *settings.Service, dir string) *Host {
|
||
return &Host{st: st, registry: reg, settings: s, dir: dir, loaded: map[string]*Loaded{}, sidecars: map[string]*exec.Cmd{}}
|
||
}
|
||
|
||
type loadAccountGuard struct {
|
||
st *store.Store
|
||
actor string
|
||
allowImplicitDefault bool
|
||
failed error
|
||
}
|
||
|
||
func (g *loadAccountGuard) check() error {
|
||
if g == nil {
|
||
return nil // startup and existing fixture loader retain legacy behavior
|
||
}
|
||
if g.failed != nil {
|
||
return g.failed
|
||
}
|
||
g.failed = g.st.CheckEffectiveAccountForExternalEffect(g.actor, g.allowImplicitDefault)
|
||
return g.failed
|
||
}
|
||
|
||
func (g *loadAccountGuard) record(err error) error {
|
||
if g != nil && err != nil {
|
||
g.failed = err
|
||
}
|
||
return err
|
||
}
|
||
|
||
// LoadDir 全量装载目录(启动与热加载共用);返回每个插件的结果(失败不中断其他)。
|
||
func (h *Host) LoadDir() map[string]error {
|
||
results, _ := h.loadDir(nil)
|
||
return results
|
||
}
|
||
|
||
// LoadDirAccountChecked reloads on behalf of an already authorized actor.
|
||
// Each Backend-owned DB write is fenced in its final transaction; process,
|
||
// sidecar and filesystem effects get a fresh state check immediately before
|
||
// execution. The caller must separately enforce plugin-management authority.
|
||
func (h *Host) LoadDirAccountChecked(actor string, allowImplicitDefault bool) (map[string]error, error) {
|
||
return h.loadDir(&loadAccountGuard{st: h.st, actor: actor, allowImplicitDefault: allowImplicitDefault})
|
||
}
|
||
|
||
func (h *Host) loadDir(guard *loadAccountGuard) (map[string]error, error) {
|
||
if err := guard.check(); err != nil {
|
||
return nil, err
|
||
}
|
||
entries, err := os.ReadDir(h.dir)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return map[string]error{}, nil
|
||
}
|
||
return map[string]error{"<dir>": err}, nil
|
||
}
|
||
results := map[string]error{}
|
||
seen := map[string]bool{}
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
id := e.Name()
|
||
seen[id] = true
|
||
results[id] = h.loadOne(filepath.Join(h.dir, id), guard)
|
||
if guard != nil && guard.failed != nil {
|
||
return results, guard.failed
|
||
}
|
||
}
|
||
// 移除已消失的插件(卸载语义)。
|
||
h.mu.Lock()
|
||
for id := range h.loaded {
|
||
if !seen[id] {
|
||
if err := guard.check(); err != nil {
|
||
h.mu.Unlock()
|
||
return results, err
|
||
}
|
||
delete(h.loaded, id)
|
||
if cmd := h.sidecars[id]; cmd != nil && cmd.Process != nil {
|
||
_ = cmd.Process.Kill()
|
||
}
|
||
delete(h.sidecars, id)
|
||
h.registry.UnregisterBySource(core.SourcePlugin, id)
|
||
}
|
||
}
|
||
overlay := h.collectIntentsLocked()
|
||
cmdOverlay := h.collectCommandsLocked()
|
||
defs, scripts := h.collectWorkflowsLocked()
|
||
onIntents, onCommands, onWorkflows := h.OnIntents, h.OnCommands, h.OnWorkflows
|
||
h.mu.Unlock()
|
||
if onCommands != nil {
|
||
if err := guard.check(); err != nil {
|
||
return results, err
|
||
}
|
||
if err := onCommands(cmdOverlay); err != nil {
|
||
results["<commands>"] = err
|
||
}
|
||
}
|
||
if onIntents != nil {
|
||
if err := guard.check(); err != nil {
|
||
return results, err
|
||
}
|
||
if err := onIntents(overlay); err != nil {
|
||
results["<intents>"] = err
|
||
}
|
||
}
|
||
if onWorkflows != nil {
|
||
if err := guard.check(); err != nil {
|
||
return results, err
|
||
}
|
||
if err := onWorkflows(defs, scripts); err != nil {
|
||
results["<workflows>"] = err
|
||
}
|
||
}
|
||
return results, nil
|
||
}
|
||
|
||
// loadOne 装载单个插件;任何失败返回错误且不改变既有注册(fail-closed)。
|
||
func (h *Host) loadOne(dir string, guard *loadAccountGuard) error {
|
||
manifestPath := filepath.Join(dir, "plugin.json")
|
||
raw, err := os.ReadFile(manifestPath)
|
||
if err != nil {
|
||
return fmt.Errorf("plugins: %s: %w", filepath.Base(dir), err)
|
||
}
|
||
var m core.PluginManifest
|
||
if err := json.Unmarshal(raw, &m); err != nil {
|
||
return fmt.Errorf("plugins: %s: manifest json: %w", m.ID, err)
|
||
}
|
||
if m.ID == "" || m.Name == "" || m.Version == "" {
|
||
return fmt.Errorf("plugins: %s: manifest missing id/name/version", m.ID)
|
||
}
|
||
if m.APIVersion == "" {
|
||
m.APIVersion = core.PluginAPIVersion
|
||
}
|
||
if !core.PluginAPIAccepted(m.APIVersion) {
|
||
return fmt.Errorf("plugins: %s: apiVersion %s 不在兼容窗口(%s, %s)", m.ID, m.APIVersion, core.PluginAPIVersion, core.PluginAPIVersionExperimental)
|
||
}
|
||
|
||
ld := &Loaded{Manifest: m, Dir: dir, Cards: map[string][]core.UINode{}, Prompts: map[string]string{}, Defaults: map[string]any{}, Scripts: map[string]string{}}
|
||
|
||
// 同 ID 重载:先注销旧注册(含旧版本工具),再装载新资产。
|
||
if err := guard.check(); err != nil {
|
||
return err
|
||
}
|
||
h.registry.UnregisterBySource(core.SourcePlugin, m.ID)
|
||
|
||
// tools/*.json:声明式 HTTP 工具(或含 parameters 的描述型工具)。
|
||
for _, name := range m.Provides.Tools {
|
||
var desc core.ToolDesc
|
||
if err := readAsset(dir, "tools", name, &desc); err != nil {
|
||
return fmt.Errorf("plugins: %s: tool %s: %w", m.ID, name, err)
|
||
}
|
||
desc.Source = core.SourcePlugin
|
||
desc.PluginID = m.ID
|
||
exec, err := h.bindToolExecutor(desc, m.APIVersion, m.Requires.Network)
|
||
if err != nil {
|
||
return fmt.Errorf("plugins: %s: tool %s: %w", m.ID, name, err)
|
||
}
|
||
if err := guard.check(); err != nil {
|
||
return err
|
||
}
|
||
if err := h.registry.Register(desc, exec); err != nil {
|
||
return fmt.Errorf("plugins: %s: register tool %s: %w", m.ID, name, err)
|
||
}
|
||
}
|
||
|
||
// panels/*.json
|
||
for _, name := range m.Provides.Panels {
|
||
var panel core.PluginPanel
|
||
if err := readAsset(dir, "panels", name, &panel); err != nil {
|
||
return fmt.Errorf("plugins: %s: panel %s: %w", m.ID, name, err)
|
||
}
|
||
ld.Panels = append(ld.Panels, panel)
|
||
}
|
||
|
||
// cards/*.json:{kind, data:[UINode]}
|
||
for _, name := range m.Provides.Cards {
|
||
var card struct {
|
||
Kind string `json:"kind"`
|
||
Data []core.UINode `json:"data"`
|
||
}
|
||
if err := readAsset(dir, "cards", name, &card); err != nil {
|
||
return fmt.Errorf("plugins: %s: card %s: %w", m.ID, name, err)
|
||
}
|
||
if card.Kind == "" {
|
||
return fmt.Errorf("plugins: %s: card %s: missing kind", m.ID, name)
|
||
}
|
||
ld.Cards[card.Kind] = card.Data
|
||
}
|
||
|
||
// workflows/*.json。script 步骤的文件必须留在插件目录内,正文随定义一起交给注册表。
|
||
for _, name := range m.Provides.Workflows {
|
||
var wf core.WorkflowDef
|
||
if err := readAsset(dir, "workflows", name, &wf); err != nil {
|
||
return fmt.Errorf("plugins: %s: workflow %s: %w", m.ID, name, err)
|
||
}
|
||
if err := workflow.Validate(wf, workflow.DeclarativeLimits(), func(tool string) bool {
|
||
_, _, ok := h.registry.Get(tool)
|
||
return ok
|
||
}); err != nil {
|
||
return fmt.Errorf("plugins: %s: workflow %s: %w", m.ID, name, err)
|
||
}
|
||
for _, step := range wf.Steps {
|
||
if step.Kind != "script" {
|
||
continue
|
||
}
|
||
body, err := readPluginFile(dir, step.Script)
|
||
if err != nil {
|
||
return fmt.Errorf("plugins: %s: workflow %s script: %w", m.ID, name, err)
|
||
}
|
||
ld.Scripts[workflow.ScriptKey(wf.ID, step.Script)] = string(body)
|
||
}
|
||
ld.Workflows = append(ld.Workflows, wf)
|
||
}
|
||
|
||
// intents/*.json:agent-intent overlay 形态原样透出(目录侧消费,不在此解析)。
|
||
for _, name := range m.Provides.Intents {
|
||
var specs []json.RawMessage
|
||
if err := readAsset(dir, "intents", name, &specs); err != nil {
|
||
return fmt.Errorf("plugins: %s: intents %s: %w", m.ID, name, err)
|
||
}
|
||
ld.Intents = append(ld.Intents, specs...)
|
||
}
|
||
|
||
// commands/*.json:Agent-Command spec overlay 形态透出(cmdbuf 注册;不在此校验语义)。
|
||
for _, name := range m.Provides.Commands {
|
||
var specs []json.RawMessage
|
||
if err := readAsset(dir, "commands", name, &specs); err != nil {
|
||
return fmt.Errorf("plugins: %s: commands %s: %w", m.ID, name, err)
|
||
}
|
||
ld.Commands = append(ld.Commands, specs...)
|
||
}
|
||
|
||
// workspaces/*.json:工作区弹层声明(T4;OverlayHost 渲染,声明权威在 Core 类型)。
|
||
for _, name := range m.Provides.Workspaces {
|
||
var ws []core.PluginWorkspace
|
||
if err := readAsset(dir, "workspaces", name, &ws); err != nil {
|
||
return fmt.Errorf("plugins: %s: workspaces %s: %w", m.ID, name, err)
|
||
}
|
||
ld.Workspaces = append(ld.Workspaces, ws...)
|
||
}
|
||
|
||
// prompts/*.md:画像模板(source=plugin,可被用户复制微调)。
|
||
for _, name := range m.Provides.Prompts {
|
||
content, err := os.ReadFile(filepath.Join(dir, "prompts", name+".md"))
|
||
if err != nil {
|
||
return fmt.Errorf("plugins: %s: prompt %s: %w", m.ID, name, err)
|
||
}
|
||
ld.Prompts[name] = string(content)
|
||
}
|
||
|
||
// settings.json:插件设置声明(并入设置中心 schema;默认值进覆盖链底层)。
|
||
if len(m.Provides.Settings) > 0 {
|
||
var descs []core.SettingDesc
|
||
if err := readAsset(dir, "", "settings", &descs); err != nil {
|
||
return fmt.Errorf("plugins: %s: settings: %w", m.ID, err)
|
||
}
|
||
for i := range descs {
|
||
descs[i].Group = "plugin:" + m.ID
|
||
ld.PluginSettings = append(ld.PluginSettings, descs[i])
|
||
}
|
||
}
|
||
|
||
if err := h.loadDeclared(dir, m, ld, guard); err != nil {
|
||
return err
|
||
}
|
||
if err := h.startSidecar(m, dir, guard); err != nil {
|
||
return err
|
||
}
|
||
if err := guard.check(); err != nil {
|
||
return err
|
||
}
|
||
h.mu.Lock()
|
||
h.loaded[m.ID] = ld
|
||
overlay := h.collectIntentsLocked()
|
||
cmdOverlay := h.collectCommandsLocked()
|
||
defs, scripts := h.collectWorkflowsLocked()
|
||
onIntents, onCommands, onWorkflows := h.OnIntents, h.OnCommands, h.OnWorkflows
|
||
h.mu.Unlock()
|
||
if guard == nil {
|
||
_ = h.st.UpsertPlugin(m.ID, dir, m)
|
||
} else if err := h.st.UpsertPluginAccountChecked(guard.actor, m.ID, dir, m, guard.allowImplicitDefault); err != nil {
|
||
return guard.record(err)
|
||
}
|
||
if onCommands != nil {
|
||
if err := guard.check(); err != nil {
|
||
return err
|
||
}
|
||
if err := onCommands(cmdOverlay); err != nil {
|
||
return fmt.Errorf("plugins: %s: commands rejected by command bus (kept previous): %w", m.ID, err)
|
||
}
|
||
}
|
||
if onIntents != nil {
|
||
if err := guard.check(); err != nil {
|
||
return err
|
||
}
|
||
if err := onIntents(overlay); err != nil {
|
||
return fmt.Errorf("plugins: %s: intents rejected by host gate (kept previous): %w", m.ID, err)
|
||
}
|
||
}
|
||
if onWorkflows != nil {
|
||
if err := guard.check(); err != nil {
|
||
return err
|
||
}
|
||
if err := onWorkflows(defs, scripts); err != nil {
|
||
return fmt.Errorf("plugins: %s: workflows rejected: %w", m.ID, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// collectIntentsLocked 汇总全部已装载插件的意图 overlay(调用方持锁)。
|
||
func (h *Host) collectIntentsLocked() []json.RawMessage {
|
||
var out []json.RawMessage
|
||
for _, ld := range h.loaded {
|
||
out = append(out, ld.Intents...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ListWorkspaces 全部已装载插件的工作区声明(GET /api/workspaces 数据源)。
|
||
func (h *Host) ListWorkspaces() []core.PluginWorkspace {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
var out []core.PluginWorkspace
|
||
for _, ld := range h.loaded {
|
||
out = append(out, ld.Workspaces...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// collectCommandsLocked 汇总全部已装载插件的命令 overlay(调用方持锁)。
|
||
func (h *Host) collectCommandsLocked() []json.RawMessage {
|
||
var out []json.RawMessage
|
||
for _, ld := range h.loaded {
|
||
out = append(out, ld.Commands...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (h *Host) collectWorkflowsLocked() ([]core.WorkflowDef, map[string]string) {
|
||
ids := make([]string, 0, len(h.loaded))
|
||
for id := range h.loaded {
|
||
ids = append(ids, id)
|
||
}
|
||
sort.Strings(ids)
|
||
var defs []core.WorkflowDef
|
||
scripts := map[string]string{}
|
||
for _, id := range ids {
|
||
ld := h.loaded[id]
|
||
defs = append(defs, ld.Workflows...)
|
||
for k, v := range ld.Scripts {
|
||
scripts[k] = v
|
||
}
|
||
}
|
||
return defs, scripts
|
||
}
|
||
|
||
func readPluginFile(root, rel string) ([]byte, error) {
|
||
cleaned := filepath.Clean(rel)
|
||
if cleaned == "." || filepath.IsAbs(cleaned) {
|
||
return nil, fmt.Errorf("path %s escapes plugin dir", rel)
|
||
}
|
||
full := filepath.Join(root, cleaned)
|
||
rooted, err := filepath.Rel(root, full)
|
||
if err != nil || rooted == ".." || strings.HasPrefix(rooted, ".."+string(filepath.Separator)) {
|
||
return nil, fmt.Errorf("path %s escapes plugin dir", rel)
|
||
}
|
||
return os.ReadFile(full)
|
||
}
|
||
|
||
func readAsset(dir, sub, name string, v any) error {
|
||
p := filepath.Join(dir, sub, name+".json")
|
||
if sub == "" {
|
||
p = filepath.Join(dir, name+".json")
|
||
}
|
||
raw, err := os.ReadFile(p)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return json.Unmarshal(raw, v)
|
||
}
|
||
|
||
// ---- 查询接口 ----
|
||
|
||
func (h *Host) List() []*Loaded {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
out := make([]*Loaded, 0, len(h.loaded))
|
||
for _, ld := range h.loaded {
|
||
out = append(out, ld)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Manifest.ID < out[j].Manifest.ID })
|
||
return out
|
||
}
|
||
|
||
// SettingsSchema 追加插件设置项(settings schema 汇总用)。
|
||
func (h *Host) SettingsSchema() []core.SettingDesc {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
var out []core.SettingDesc
|
||
for _, ld := range h.List() {
|
||
out = append(out, ld.PluginSettings...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Cards 返回 kind → 模板(server 演示卡片注入用)。
|
||
func (h *Host) Cards() map[string][]core.UINode {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
out := map[string][]core.UINode{}
|
||
for _, ld := range h.loaded {
|
||
for k, v := range ld.Cards {
|
||
out[k] = v
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Unregister 注销插件全部注册项(卸载语义:在途引用由 registry miss 稳定报错)。
|
||
func (h *Host) Unregister(id string) {
|
||
h.mu.Lock()
|
||
delete(h.loaded, id)
|
||
cmd := h.sidecars[id]
|
||
delete(h.sidecars, id)
|
||
overlay := h.collectIntentsLocked()
|
||
cmdOverlay := h.collectCommandsLocked()
|
||
defs, scripts := h.collectWorkflowsLocked()
|
||
onIntents, onCommands, onWorkflows := h.OnIntents, h.OnCommands, h.OnWorkflows
|
||
h.mu.Unlock()
|
||
if cmd != nil && cmd.Process != nil {
|
||
_ = cmd.Process.Kill()
|
||
}
|
||
h.registry.UnregisterBySource(core.SourcePlugin, id)
|
||
_ = h.st.RemovePlugin(id)
|
||
if onCommands != nil {
|
||
_ = onCommands(cmdOverlay)
|
||
}
|
||
if onIntents != nil {
|
||
_ = onIntents(overlay)
|
||
}
|
||
if onWorkflows != nil {
|
||
_ = onWorkflows(defs, scripts)
|
||
}
|
||
}
|