package plugins import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "agent-system-backend/internal/intentgate" "agent-system-backend/internal/settings" "agent-system-backend/internal/store" "agent-system-backend/internal/tools" "agent-system-backend/internal/workflow" core "agent-system-core" ) func newHost(t *testing.T) (*Host, *tools.Registry, string) { t.Helper() dir := t.TempDir() st, err := store.OpenSQLite(filepath.Join(dir, "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) reg := tools.NewRegistry() reg.RegisterBuiltin() setSvc := settings.NewService(st) pluginDir := filepath.Join(dir, "plugins") os.MkdirAll(pluginDir, 0o755) return New(st, reg, setSvc, pluginDir), reg, pluginDir } // writeExamplePlugin 写一个覆盖五类能力的最小示例插件目录。 func writeExamplePlugin(t *testing.T, dir, id string) { t.Helper() root := filepath.Join(dir, id) for _, sub := range []string{"tools", "panels", "cards", "intents", "prompts"} { os.MkdirAll(filepath.Join(root, sub), 0o755) } writeFile := func(rel, content string) { if err := os.WriteFile(filepath.Join(root, rel), []byte(content), 0o644); err != nil { t.Fatal(err) } } writeFile("plugin.json", `{"id":"`+id+`","name":"示例插件","version":"0.1.0","apiVersion":"`+core.PluginAPIVersion+`", "requires":{"network":["api.example.com"]}, "provides":{"tools":["http_echo"],"intents":["demo"],"panels":["demo"],"cards":["note"],"prompts":["assistant"],"settings":["greeting"]}}`) writeFile("tools/http_echo.json", `{"name":"http_echo","description":"回显输入到示例端点","source":"plugin", "parameters":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}, "http":{"method":"POST","url":"https://api.example.com/echo","bodyTemplate":"{\"q\":\"{{param.text}}\"}","resultPath":"echo"}}`) writeFile("panels/demo.json", `{"id":"demo","title":"示例面板","body":[{"type":"markdown","markdown":"# 示例\n插件面板。"}]}`) writeFile("cards/note.json", `{"kind":"note","data":[{"type":"markdown","markdown":"插件卡片模板"}]}`) writeFile("intents/demo.json", `[{"id":"demo.show","domain":"demo","includes_any":["示例"]}]`) writeFile("prompts/assistant.md", `你是示例插件附带的助手模板。`) writeFile("settings.json", `[{"key":"greeting","group":"plugin:`+id+`","label":"问候语","type":"string","default":"你好","effect":"hot"}]`) } func TestLoadAndRegister(t *testing.T) { host, reg, dir := newHost(t) writeExamplePlugin(t, dir, "example") results := host.LoadDir() if err := results["example"]; err != nil { t.Fatalf("load: %v", err) } // 工具注册(source=plugin,声明式 HTTP 绑定执行器)。 desc, exec, ok := reg.Get("http_echo") if !ok || desc.Source != core.SourcePlugin || desc.PluginID != "example" || exec == nil { t.Fatalf("tool not registered: %+v", desc) } // 面板/卡片/意图/提示词/设置声明。 lds := host.List() if len(lds) != 1 || len(lds[0].Panels) != 1 || lds[0].Cards["note"] == nil || len(lds[0].Intents) != 1 || lds[0].Prompts["assistant"] == "" { t.Fatalf("loaded state wrong: %+v", lds) } if len(host.SettingsSchema()) != 1 || host.SettingsSchema()[0].Group != "plugin:example" { t.Errorf("plugin settings schema: %+v", host.SettingsSchema()) } } func TestUnloadRemovesRegistrations(t *testing.T) { host, reg, dir := newHost(t) writeExamplePlugin(t, dir, "example") host.LoadDir() if _, _, ok := reg.Get("http_echo"); !ok { t.Fatal("prerequisite: tool registered") } // 目录中删除插件后重载 → 卸载语义。 os.RemoveAll(filepath.Join(dir, "example")) host.LoadDir() if _, _, ok := reg.Get("http_echo"); ok { t.Error("tool should be unregistered after plugin removal") } if len(host.List()) != 0 { t.Error("plugin should be unloaded") } // 内置工具不受影响(核心零回归)。 if _, _, ok := reg.Get("knowledge_search"); !ok { t.Error("builtin tool must survive plugin unload") } } // fail-closed:坏资产整批拒绝,不部分注册。 func TestFailClosedOnBadAsset(t *testing.T) { host, reg, dir := newHost(t) writeExamplePlugin(t, dir, "example") // 破坏工具资产(非法 JSON)。 os.WriteFile(filepath.Join(dir, "example", "tools", "http_echo.json"), []byte(`{broken`), 0o644) results := host.LoadDir() if err := results["example"]; err == nil { t.Fatal("bad asset should fail load") } if _, _, ok := reg.Get("http_echo"); ok { t.Error("fail-closed violated: tool registered from broken plugin") } if len(host.List()) != 0 { t.Error("broken plugin must not be in loaded list") } } func TestAPIVersionGuard(t *testing.T) { host, _, dir := newHost(t) writeExamplePlugin(t, dir, "oldapi") os.WriteFile(filepath.Join(dir, "oldapi", "plugin.json"), []byte(`{"id":"oldapi","name":"旧版","version":"1.0.0","apiVersion":"0.9","provides":{}}`), 0o644) results := host.LoadDir() if err := results["oldapi"]; err == nil { t.Fatal("incompatible apiVersion should be rejected") } } func TestWorkspacesOverlay(t *testing.T) { dir := t.TempDir() st, err := store.OpenSQLite(filepath.Join(dir, "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) reg := tools.NewRegistry() reg.RegisterBuiltin() pluginDir := filepath.Join(dir, "plugins") os.MkdirAll(filepath.Join(pluginDir, "wsdemo", "workspaces"), 0o755) os.WriteFile(filepath.Join(pluginDir, "wsdemo", "plugin.json"), []byte(`{"id":"wsdemo","name":"WS","version":"1.0.0","apiVersion":"1.0","provides":{"workspaces":["main"]}}`), 0o644) os.WriteFile(filepath.Join(pluginDir, "wsdemo", "workspaces", "main.json"), []byte(`[ {"id":"demo.governance","title":"治理总览","icon":"🛡","shortcut":"5","body":[{"type":"markdown","markdown":"声明式工作区"}]} ]`), 0o644) setSvc := settings.NewService(st) h := New(st, reg, setSvc, pluginDir) if errs := h.LoadDir(); len(errs) > 0 { for _, err := range errs { if err != nil { t.Fatalf("load errs: %v", errs) } } } ws := h.ListWorkspaces() if len(ws) != 1 || ws[0].ID != "demo.governance" || ws[0].Shortcut != "5" || len(ws[0].Body) != 1 { t.Fatalf("workspaces: %+v", ws) } } func TestUnregisterDropsWorkflowsAndIntents(t *testing.T) { host, _, dir := newHost(t) root := filepath.Join(dir, "example") os.MkdirAll(filepath.Join(root, "workflows", "scripts"), 0o755) os.MkdirAll(filepath.Join(root, "intents"), 0o755) os.WriteFile(filepath.Join(root, "plugin.json"), []byte(`{"id":"example","name":"示例","version":"0.1.0","apiVersion":"1.0","provides":{"workflows":["demo-brief"],"intents":["demo"]}}`), 0o644) os.WriteFile(filepath.Join(root, "workflows", "demo-brief.json"), []byte(`{"id":"demo.brief","title":"简报","intentId":"demo.brief","steps":[{"id":"run","title":"脚本","kind":"script","script":"workflows/scripts/demo.py"}]}`), 0o644) os.WriteFile(filepath.Join(root, "workflows", "scripts", "demo.py"), []byte("print('ok')\n"), 0o644) os.WriteFile(filepath.Join(root, "intents", "demo.json"), []byte(`[{"id":"demo.brief","domain":"demo","version":1,"matcher":{"includes_any":["简报"]}}]`), 0o644) reg := workflow.NewRegistry() gate := intentgate.New() host.OnWorkflows = func(defs []core.WorkflowDef, scripts map[string]string) error { reg.Set(defs, scripts) return nil } host.OnIntents = func(overlay []json.RawMessage) error { return gate.Rebuild(context.Background(), overlay) } if err := host.LoadDir()["example"]; err != nil { t.Fatal(err) } if _, ok := reg.ByIntent("demo.brief"); !ok || !gate.Enabled() { t.Fatal("workflow and intent should be registered") } body, ok := reg.Script("demo.brief", "workflows/scripts/demo.py") if !ok || body != "print('ok')\n" { t.Fatalf("script body %q", body) } host.Unregister("example") if _, ok := reg.ByIntent("demo.brief"); ok { t.Fatal("unregister left the workflow") } if gate.Enabled() { t.Fatal("unregister left the intent") } } func TestWorkflowScriptCannotEscape(t *testing.T) { host, _, dir := newHost(t) root := filepath.Join(dir, "example") os.MkdirAll(filepath.Join(root, "workflows"), 0o755) os.WriteFile(filepath.Join(root, "plugin.json"), []byte(`{"id":"example","name":"示例","version":"0.1.0","apiVersion":"1.0","provides":{"workflows":["bad"]}}`), 0o644) os.WriteFile(filepath.Join(root, "workflows", "bad.json"), []byte(`{"id":"bad","title":"坏","steps":[{"id":"run","title":"逃","kind":"script","script":"../secret.py"}]}`), 0o644) if err := host.LoadDir()["example"]; err == nil { t.Fatal("script path outside the plugin dir should be rejected") } } func TestRepoExamplePluginLoads(t *testing.T) { root := filepath.Join("..", "..", "..", "plugins") if _, err := os.Stat(filepath.Join(root, "example", "workflows", "demo-brief.json")); err != nil { t.Skip("example plugin is not beside this module") } dir := t.TempDir() st, err := store.OpenSQLite(filepath.Join(dir, "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) regTools := tools.NewRegistry() regTools.RegisterBuiltin() host := New(st, regTools, settings.NewService(st), root) gate := intentgate.New() wf := workflow.NewRegistry() host.OnIntents = func(overlay []json.RawMessage) error { return gate.Rebuild(context.Background(), overlay) } host.OnWorkflows = func(defs []core.WorkflowDef, scripts map[string]string) error { wf.Set(defs, scripts) return nil } if err := host.LoadDir()["example"]; err != nil { t.Fatal(err) } if _, ok := wf.ByIntent("demo.brief"); !ok { t.Fatal("demo.brief is not bound") } body, ok := wf.Script("demo.script", "workflows/scripts/demo.py") if !ok || !strings.Contains(body, `wf.tool("time_now"`) { t.Fatalf("script %q", body) } got := gate.Recognize(context.Background(), "写一份简报") if got.ID != "demo.brief" || len(got.Missing) != 1 || got.Missing[0].Question != "主题是什么?" { t.Fatalf("%+v", got) } filled := gate.Recognize(context.Background(), "主题:核能 写一份简报") if filled.Slots["topic"] != "核能" || len(filled.Missing) != 0 { t.Fatalf("%+v", filled) } }