Files
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
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.
2026-10-14 07:33:54 +00:00

510 lines
16 KiB
Go
Raw Permalink 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{}}
}
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)
}
}