Why: Downstream products must extend the base through a public package and host API instead of editing internal code. Evidence: go test ./... passed, including plugin v2 assets, sidecar startup, migration v13, pending confirms, HITL timeout, hash embedder, command pairs, and the extension surface snapshot. Impact: apiVersion 2.0-experimental rejects empty network allowlists for declarative HTTP. Hash embeddings stay opt-in via models.embedding.provider=hash. Plugin secrets resolve per owner at execution time. Refs: docs/proposals/2026-10-01-plugin-contract-v2.md Co-authored-by: Cursor <cursoragent@cursor.com>
221 lines
6.2 KiB
Go
221 lines
6.2 KiB
Go
package plugins
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"agent-system-backend/internal/store"
|
|
"agent-system-backend/internal/tools"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
func (h *Host) SetSkillsRoot(dir string) { h.skillsRoot = dir }
|
|
|
|
func (h *Host) SetHostAPI(baseURL, token string) {
|
|
h.hostAPI, h.hostToken = baseURL, token
|
|
}
|
|
|
|
func (h *Host) startSidecar(m core.PluginManifest, dir string) error {
|
|
if m.Backend.Mode != "sidecar" || m.Backend.Command == "" {
|
|
return nil
|
|
}
|
|
cmdPath := m.Backend.Command
|
|
if !filepath.IsAbs(cmdPath) {
|
|
if _, err := os.Stat(filepath.Join(dir, cmdPath)); err == nil {
|
|
cmdPath = filepath.Join(dir, cmdPath)
|
|
}
|
|
}
|
|
cmd := exec.Command(cmdPath, m.Backend.Args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(),
|
|
"AGENT_HOST_API="+h.hostAPI,
|
|
"AGENT_HOST_TOKEN="+h.hostToken,
|
|
"AGENT_PLUGIN_ID="+m.ID,
|
|
)
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("plugins: %s: sidecar: %w", m.ID, err)
|
|
}
|
|
h.mu.Lock()
|
|
if h.sidecars == nil {
|
|
h.sidecars = map[string]*exec.Cmd{}
|
|
}
|
|
if prev := h.sidecars[m.ID]; prev != nil && prev.Process != nil {
|
|
_ = prev.Process.Kill()
|
|
}
|
|
h.sidecars[m.ID] = cmd
|
|
h.mu.Unlock()
|
|
go func() { _ = cmd.Wait() }()
|
|
return nil
|
|
}
|
|
|
|
func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error {
|
|
for _, name := range m.Provides.Skills {
|
|
body, err := os.ReadFile(filepath.Join(dir, "skills", name+".md"))
|
|
if err != nil {
|
|
return fmt.Errorf("plugins: %s: skill %s: %w", m.ID, name, err)
|
|
}
|
|
if ld.Skills == nil {
|
|
ld.Skills = map[string]string{}
|
|
}
|
|
ld.Skills[name] = string(body)
|
|
if h.skillsRoot != "" {
|
|
dest := filepath.Join(h.skillsRoot, "plugin-"+m.ID, name)
|
|
if err := os.MkdirAll(dest, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dest, "SKILL.md"), body, 0o644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
for _, name := range m.Provides.MCPServers {
|
|
var srv core.PluginMCPServer
|
|
if err := readAsset(dir, "mcp", name, &srv); err != nil {
|
|
return fmt.Errorf("plugins: %s: mcp %s: %w", m.ID, name, err)
|
|
}
|
|
if srv.Name == "" {
|
|
srv.Name = name
|
|
}
|
|
ld.MCP = append(ld.MCP, srv)
|
|
}
|
|
for _, name := range m.Provides.Knowledge {
|
|
var item core.PluginKnowledgeSet
|
|
if err := readAsset(dir, "knowledge", name, &item); err != nil {
|
|
return fmt.Errorf("plugins: %s: knowledge %s: %w", m.ID, name, err)
|
|
}
|
|
ld.Knowledge = append(ld.Knowledge, item)
|
|
}
|
|
for _, name := range m.Provides.Parsers {
|
|
var item core.PluginParserDecl
|
|
if err := readAsset(dir, "parsers", name, &item); err != nil {
|
|
return fmt.Errorf("plugins: %s: parser %s: %w", m.ID, name, err)
|
|
}
|
|
ld.Parsers = append(ld.Parsers, item)
|
|
}
|
|
for _, name := range m.Provides.Agents {
|
|
var item core.PluginAgentDecl
|
|
if err := readAsset(dir, "agents", name, &item); err != nil {
|
|
return fmt.Errorf("plugins: %s: agent %s: %w", m.ID, name, err)
|
|
}
|
|
ld.Agents = append(ld.Agents, item)
|
|
}
|
|
for _, name := range m.Provides.Channels {
|
|
var item core.PluginChannelDecl
|
|
if err := readAsset(dir, "channels", name, &item); err != nil {
|
|
return fmt.Errorf("plugins: %s: channel %s: %w", m.ID, name, err)
|
|
}
|
|
ld.Channels = append(ld.Channels, item)
|
|
}
|
|
for _, name := range m.Provides.Pages {
|
|
var item core.PluginPageDecl
|
|
if err := readAsset(dir, "pages", name, &item); err != nil {
|
|
return fmt.Errorf("plugins: %s: page %s: %w", m.ID, name, err)
|
|
}
|
|
if item.Module == "" {
|
|
return fmt.Errorf("plugins: %s: page %s: missing module", m.ID, name)
|
|
}
|
|
ld.Pages = append(ld.Pages, item)
|
|
}
|
|
if m.Backend.SchemaVersion > 0 && h.st != nil {
|
|
cur, err := h.st.PluginSchemaVersion(m.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if m.Backend.SchemaVersion < cur {
|
|
return fmt.Errorf("plugins: %s: schema %d older than stored %d", m.ID, m.Backend.SchemaVersion, cur)
|
|
}
|
|
if err := h.st.SetPluginSchemaVersion(m.ID, m.Backend.SchemaVersion); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MCPServers 返回已装载插件贡献的 MCP 服务,供与 settings 合并。
|
|
func (h *Host) MCPServers() []core.PluginMCPServer {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
var out []core.PluginMCPServer
|
|
for _, ld := range h.loaded {
|
|
out = append(out, ld.MCP...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ListFor 按租户授权与 manifest 作用域过滤。project 为空时只检查租户。
|
|
func (h *Host) ListFor(owner, project string) []*Loaded {
|
|
all := h.List()
|
|
var out []*Loaded
|
|
for _, ld := range all {
|
|
if !ld.Manifest.Scope.Allows(owner, project) {
|
|
continue
|
|
}
|
|
if h.st != nil && owner != "" {
|
|
on, err := h.st.PluginEnabled(owner, ld.Manifest.ID)
|
|
if err != nil || !on {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, ld)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (h *Host) bindToolExecutor(desc core.ToolDesc, apiVersion string, allow []string) (tools.Executor, error) {
|
|
if desc.HTTP == nil {
|
|
return nil, fmt.Errorf("v1 仅支持声明式 HTTP 工具(缺少 http 段)")
|
|
}
|
|
if desc.HTTP.URL == "" || desc.HTTP.Method == "" {
|
|
return nil, fmt.Errorf("http 工具缺少 url/method")
|
|
}
|
|
if err := checkNetwork(apiVersion, allow, desc.HTTP.URL); err != nil {
|
|
return nil, err
|
|
}
|
|
return func(rc tools.ToolExecCtx, args json.RawMessage) (string, error) {
|
|
if err := checkNetwork(apiVersion, allow, desc.HTTP.URL); err != nil {
|
|
return "", err
|
|
}
|
|
owner := store.OwnerDefault
|
|
if rc != nil && rc.OwnerID() != "" {
|
|
owner = rc.OwnerID()
|
|
}
|
|
lookup := func(key string) string {
|
|
eff := h.settings.Effective(owner, "")
|
|
if desc.PluginID != "" {
|
|
if v := eff.GetString("secrets."+desc.PluginID+"."+key, ""); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return eff.GetString(key, "")
|
|
}
|
|
return tools.HTTPExecutor(*desc.HTTP, lookup)(rc, args)
|
|
}, nil
|
|
}
|
|
|
|
func checkNetwork(apiVersion string, allow []string, rawURL string) error {
|
|
if len(allow) == 0 {
|
|
if apiVersion == core.PluginAPIVersionExperimental {
|
|
return fmt.Errorf("plugins: api %s 要求 requires.network", apiVersion)
|
|
}
|
|
return nil
|
|
}
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return fmt.Errorf("plugins: tool url: %w", err)
|
|
}
|
|
host := u.Hostname()
|
|
for _, a := range allow {
|
|
a = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(a)), "*.")
|
|
h := strings.ToLower(host)
|
|
if h == a || strings.HasSuffix(h, "."+a) {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("plugins: host %s not in network allowlist", host)
|
|
}
|