Files
Agent-System-Backend/internal/plugins/host_v2.go
T
weichen dc6d9985d8
ci / vet-and-test (pull_request) Successful in 41s
Validate plugin agent declarations and wire the squad ask port
Why: N1 increment three host side - plugin agent assets must reject
hooks/mcp/permissionMode declarations (WP1 acceptance), and squad
members need the kernel ask port wired to the HITL card (WP4).

Evidence: go vet clean; go test ./... 29 packages ok, including new
agent-asset tests (banned keys located to the field, unknown keys,
missing id) and the V2 asset suite. Nested pins at native c5b9dca
(PR #18) and squad fc9620e (PR #17).

Impact: plugins agents/ assets now parse via parseAgentAsset
(strict JSON with banned-key rejection); PermissionAskOf exported
and takes any host with the optional method; app.go wires
cfg.PermissionAsk for the squad path. Legacy behavior unchanged when
nothing registers the port.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP1/WP4
2026-10-07 05:14:06 +00:00

225 lines
6.3 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 {
raw, err := readPluginFile(dir, filepath.Join("agents", name+".json"))
if err != nil {
return fmt.Errorf("plugins: %s: agent %s: %w", m.ID, name, err)
}
item, err := parseAgentAsset(raw)
if 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)
}