Files
Agent-System-Backend/internal/plugins/host.go
T
weichenandCursor 63ea1a262c Register plugin workflows and drop them on uninstall
Why: Intent routing needs a registry of workflow definitions, and uninstall was leaving intents and workflows in place.
Evidence: go test ./internal/workflow/ ./internal/plugins/ ./internal/app/ passed. Validation rejects duplicate ids, unknown kinds, denied tools, and both step caps. Unregister clears the registry and the intent gate.
Impact: Script steps must stay inside the plugin directory. The server holds the registry for later routing.
Refs: feature/agent-harness-p4
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 15:59:54 +00:00

431 lines
14 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 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{}}
}
// LoadDir 全量装载目录(启动与热加载共用);返回每个插件的结果(失败不中断其他)。
func (h *Host) LoadDir() map[string]error {
entries, err := os.ReadDir(h.dir)
if err != nil {
if os.IsNotExist(err) {
return map[string]error{}
}
return map[string]error{"<dir>": err}
}
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))
}
// 移除已消失的插件(卸载语义)。
h.mu.Lock()
for id := range h.loaded {
if !seen[id] {
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 := onCommands(cmdOverlay); err != nil {
results["<commands>"] = err
}
}
if onIntents != nil {
if err := onIntents(overlay); err != nil {
results["<intents>"] = err
}
}
if onWorkflows != nil {
if err := onWorkflows(defs, scripts); err != nil {
results["<workflows>"] = err
}
}
return results
}
// loadOne 装载单个插件;任何失败返回错误且不改变既有注册(fail-closed)。
func (h *Host) loadOne(dir string) 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 重载:先注销旧注册(含旧版本工具),再装载新资产。
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 := 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); err != nil {
return err
}
if err := h.startSidecar(m, dir); 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()
_ = h.st.UpsertPlugin(m.ID, dir, m)
if onCommands != nil {
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 := 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 := 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)
}
}