Why: plugin skills were written to the skill root unvalidated and the bundled recipes carried no name — both now go through the library's spec validator (name required + kebab-case + equals dir, description required, known-keys whitelist). Evidence: go test -count=1 ./... green; TestV2InvalidSkillRejected pins the fail-closed path with plugin context + field location; Seed validates each SKILL.md before write (recipes dogfood-compliant); nested pins carry agent-native#28 (the validator). Impact: invalid plugin skills now abort the plugin load with a located error instead of landing silently; the architecture baseline gains backend/plugins→backend/skills. Refs: Agent-Native#28, Agent-Squad#32, N2 plan WP4
129 lines
4.8 KiB
Go
129 lines
4.8 KiB
Go
package plugins
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
core "agent-system-core"
|
|
)
|
|
|
|
func TestV2AssetsAndNetwork(t *testing.T) {
|
|
host, _, dir := newHost(t)
|
|
skills := filepath.Join(t.TempDir(), "skills")
|
|
host.SetSkillsRoot(skills)
|
|
root := filepath.Join(dir, "v2")
|
|
for _, sub := range []string{"skills", "mcp", "knowledge", "parsers", "agents", "channels", "pages", "tools"} {
|
|
if err := os.MkdirAll(filepath.Join(root, sub), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write := func(rel, body string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(root, rel), []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("plugin.json", `{
|
|
"id":"v2","name":"V2","version":"0.1.0","apiVersion":"`+core.PluginAPIVersionExperimental+`",
|
|
"requires":{"network":["api.example.com"]},
|
|
"scope":{"tenants":["acme"]},
|
|
"backend":{"schemaVersion":1},
|
|
"provides":{"tools":["echo"],"skills":["note"],"mcpServers":["local"],"knowledge":["regs"],"parsers":["pdf"],"agents":["writer"],"channels":["feishu"],"pages":["home"]}
|
|
}`)
|
|
write("tools/echo.json", `{"name":"v2_echo","description":"e","parameters":{"type":"object"},"http":{"method":"GET","url":"https://api.example.com/x"}}`)
|
|
write("skills/note.md", "---\nname: note\ndescription: 笔记技能\n---\n\n# note\n")
|
|
write("mcp/local.json", `{"name":"local","command":"echo"}`)
|
|
write("knowledge/regs.json", `{"id":"regs","title":"法规"}`)
|
|
write("parsers/pdf.json", `{"id":"pdf","title":"PDF","mime":"application/pdf"}`)
|
|
write("agents/writer.json", `{"id":"writer","title":"写作"}`)
|
|
write("channels/feishu.json", `{"id":"feishu","kind":"feishu","title":"飞书"}`)
|
|
write("pages/home.json", `{"id":"home","title":"首页","module":"./page.mjs"}`)
|
|
|
|
if err := host.LoadDir()["v2"]; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ld := host.List()[0]
|
|
if ld.Skills["note"] == "" || len(ld.MCP) != 1 || len(ld.Pages) != 1 || len(ld.Channels) != 1 {
|
|
t.Fatalf("assets: %+v", ld)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(skills, "plugin-v2", "note", "SKILL.md")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := host.ListFor("other", ""); len(got) != 0 {
|
|
t.Fatalf("scope leak: %d", len(got))
|
|
}
|
|
if got := host.ListFor("acme", ""); len(got) != 1 {
|
|
t.Fatalf("scope miss: %d", len(got))
|
|
}
|
|
if err := host.st.SetPluginEnabled("acme", "v2", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := host.ListFor("acme", ""); len(got) != 0 {
|
|
t.Fatal("grant should hide plugin")
|
|
}
|
|
}
|
|
|
|
func TestExperimentalNetworkDeny(t *testing.T) {
|
|
host, _, dir := newHost(t)
|
|
root := filepath.Join(dir, "open")
|
|
os.MkdirAll(filepath.Join(root, "tools"), 0o755)
|
|
os.WriteFile(filepath.Join(root, "plugin.json"), []byte(`{"id":"open","name":"O","version":"0.1.0","apiVersion":"`+core.PluginAPIVersionExperimental+`","provides":{"tools":["e"]}}`), 0o644)
|
|
os.WriteFile(filepath.Join(root, "tools", "e.json"), []byte(`{"name":"open_e","description":"e","parameters":{"type":"object"},"http":{"method":"GET","url":"https://example.com"}}`), 0o644)
|
|
if err := host.LoadDir()["open"]; err == nil {
|
|
t.Fatal("v2 without network allowlist must fail closed")
|
|
}
|
|
}
|
|
|
|
func TestSidecarTouch(t *testing.T) {
|
|
host, _, dir := newHost(t)
|
|
host.SetHostAPI("http://127.0.0.1:1", "tok")
|
|
root := filepath.Join(dir, "side")
|
|
os.MkdirAll(root, 0o755)
|
|
mark := filepath.Join(t.TempDir(), "up")
|
|
os.WriteFile(filepath.Join(root, "plugin.json"), []byte(`{"id":"side","name":"S","version":"0.1.0","apiVersion":"1.0","backend":{"mode":"sidecar","command":"/bin/touch","args":["`+mark+`"],"schemaVersion":1},"provides":{}}`), 0o644)
|
|
if err := host.LoadDir()["side"]; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(mark); err == nil {
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatal("sidecar did not run")
|
|
}
|
|
|
|
// N2 WP4:不合规插件技能整批拒载,错误带插件上下文与字段定位。
|
|
func TestV2InvalidSkillRejected(t *testing.T) {
|
|
host, _, dir := newHost(t)
|
|
root := filepath.Join(dir, "badskill")
|
|
if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
write := func(rel, body string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(root, rel), []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("plugin.json", `{
|
|
"id":"badskill","name":"Bad","version":"0.1.0","apiVersion":"`+core.PluginAPIVersionExperimental+`",
|
|
"backend":{"schemaVersion":1},
|
|
"provides":{"skills":["note"]}
|
|
}`)
|
|
// 缺 name(开放规范必填)。
|
|
write("skills/note.md", "---\ndescription: 没有名字\n---\n正文\n")
|
|
err := host.LoadDir()["badskill"]
|
|
if err == nil {
|
|
t.Fatal("不合规技能必须拒载")
|
|
}
|
|
if !strings.Contains(err.Error(), "plugins: badskill: skill note:") ||
|
|
!strings.Contains(err.Error(), "name") {
|
|
t.Fatalf("错误须带插件上下文与字段定位:%v", err)
|
|
}
|
|
}
|