From 9aa51e52505e5b43d57a72d261ab192f43461e38 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 01:44:56 +0000 Subject: [PATCH] List compiled plugins in /api/plugins Why: compiled-in plugins (generative-ui, rdm) never appeared in the admin plugins list: GET /api/plugins only scanned the directory host, so binary- level capabilities were invisible to operators (user-reported gap). Evidence: go test ./internal/app/ ./internal/server/ green locally incl. new TestApplyCompiledRecordsSummary and TestListPluginsMergesCompiled; CI (make test) is authoritative. Impact: applyCompiled records each plugin's registered commands/intents/ skills into a summary handed to the server; /api/plugins merges synthesized inproc entries (backend.mode=inproc, apiVersion=extension surface version, provides from the registration record). Directory entries win on id collision. No public extension-surface change. Refs: WP3-I4; task card feature-plugins-compiled-listing. --- internal/app/app.go | 4 +- internal/app/compiled.go | 68 +++++++++++++- internal/app/compiled_list_test.go | 78 ++++++++++++++++ internal/server/compiled_list_test.go | 124 ++++++++++++++++++++++++++ internal/server/server.go | 29 ++++++ 5 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 internal/app/compiled_list_test.go create mode 100644 internal/server/compiled_list_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 7d1475b..5febb78 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -563,7 +563,7 @@ func Run(opt Options) error { mgr.SetCommandSource(busAdapter{bus: bus}) // EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。 wireScopedHostFactory(caps) - applyCompiled(skillsRoot, bus, caps) + compiledSummary := applyCompiled(skillsRoot, bus, caps) mgr.OnUsage = func(owner string, tokens int) { budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0)) status, aerr := st.AccountTokens(owner, tokens, budget) @@ -617,6 +617,8 @@ func Run(opt Options) error { mgr.SetWorkflows(wfReg) srv.Cards = cardSvc srv.Surfaces = surfaceSvc + // WP3-I4:编译插件摘要进 /api/plugins 管理清单(注册面即清单面)。 + srv.CompiledPlugins = compiledSummary srv.Bus = bus srv.Gate = gate srv.Prompts = promptSvc diff --git a/internal/app/compiled.go b/internal/app/compiled.go index b2609cd..4eed7ce 100644 --- a/internal/app/compiled.go +++ b/internal/app/compiled.go @@ -11,6 +11,7 @@ import ( agentcommand "agent-command" "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/server" "agent-system-backend/pkg/extension" core "agent-system-core" ) @@ -105,17 +106,80 @@ func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error return nil } -func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) { +// recordRegistry 包装 per-plugin 注册表,把成功注册的声明资产名记入摘要 +// (WP3-I4:注册面即清单面——编译插件的管理条目不另设声明口)。 +type recordRegistry struct { + extension.Registry + sum *server.CompiledPlugin +} + +func (r recordRegistry) AddCommand(spec json.RawMessage) error { + if err := r.Registry.AddCommand(spec); err != nil { + return err + } + if id := specID(spec); id != "" { + r.sum.Commands = append(r.sum.Commands, id) + } + return nil +} + +func (r recordRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error { + if err := r.Registry.AddCommandHandler(spec, h); err != nil { + return err + } + if id := specID(spec); id != "" { + r.sum.Commands = append(r.sum.Commands, id) + } + return nil +} + +func (r recordRegistry) AddIntent(spec json.RawMessage) error { + if err := r.Registry.AddIntent(spec); err != nil { + return err + } + if id := specID(spec); id != "" { + r.sum.Intents = append(r.sum.Intents, id) + } + return nil +} + +func (r recordRegistry) AddSkill(name, markdown string) error { + if err := r.Registry.AddSkill(name, markdown); err != nil { + return err + } + r.sum.Skills = append(r.sum.Skills, name) + return nil +} + +// specID 宽松提取 spec 顶层 id:仅服务清单摘要展示,解析失败返回空串 +// 不记录,不影响注册结果本身。 +func specID(spec json.RawMessage) string { + var s struct { + ID string `json:"id"` + } + if err := json.Unmarshal(spec, &s); err != nil { + return "" + } + return s.ID +} + +func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) []server.CompiledPlugin { base := compiledRegistry{skillsRoot: skillsRoot, bus: bus} + var out []server.CompiledPlugin for _, e := range extension.Compiled() { if e.Register == nil { continue } reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps} - if err := e.Register(reg); err != nil { + // Register 失败的插件保留已成功注册部分(与命令总线一致:部分注册 + // 生效),摘要如实反映实际注册面。 + sum := server.CompiledPlugin{ID: e.ID} + if err := e.Register(recordRegistry{Registry: reg, sum: &sum}); err != nil { log.Printf("extension: %s: %v", e.ID, err) } + out = append(out, sum) } + return out } func mergeMCPServers(raw string, extra []core.PluginMCPServer) string { diff --git a/internal/app/compiled_list_test.go b/internal/app/compiled_list_test.go new file mode 100644 index 0000000..346cd5e --- /dev/null +++ b/internal/app/compiled_list_test.go @@ -0,0 +1,78 @@ +package app + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +// WP3-I4:applyCompiled 的注册面摘要——成功注册的命令/意图/技能记入 +// CompiledPlugin,失败注册不记录,Register 整体失败保留已成功部分。 +func TestApplyCompiledRecordsSummary(t *testing.T) { + extension.ResetCompiled() + t.Cleanup(extension.ResetCompiled) + extension.RegisterCompiled(extension.InProc{ID: "demo", Register: func(reg extension.Registry) error { + if err := reg.AddCommandHandler(json.RawMessage(`{"id":"demo.one","title":"一"}`), func(ctx context.Context, h extension.Host, req extension.Request) (any, error) { + return "ok", nil + }); err != nil { + return err + } + // 重复 id 注册失败:总线拒绝,摘要不记录。 + if err := reg.AddCommand(json.RawMessage(`{"id":"demo.one","title":"重复"}`)); err == nil { + t.Fatal("duplicate command should fail") + } + if err := reg.AddIntent(json.RawMessage(`{"id":"demo.go","phrases":["演示"],"command":"demo.one"}`)); err != nil { + return err + } + _ = reg.AddSkill("demo-skill", "# 演示\n技能正文") + return nil + }}) + extension.RegisterCompiled(extension.InProc{ID: "broken", Register: func(reg extension.Registry) error { + if err := reg.AddCommand(json.RawMessage(`{"id":"broken.cmd","title":"先成功"}`)); err != nil { + return err + } + return reg.AddCommandHandler(json.RawMessage(`not-json`), nil) + }}) + // 无 Register 的登记不产生条目。 + extension.RegisterCompiled(extension.InProc{ID: "empty"}) + + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + sums := applyCompiled(t.TempDir(), bus, hostcaps{st: st, set: settings.NewService(st)}) + if len(sums) != 2 { + t.Fatalf("want 2 summaries, got %d: %+v", len(sums), sums) + } + if sums[0].ID != "demo" { + t.Fatalf("sums[0] = %+v", sums[0]) + } + if len(sums[0].Commands) != 1 || sums[0].Commands[0] != "demo.one" { + t.Fatalf("demo commands = %v", sums[0].Commands) + } + if len(sums[0].Intents) != 1 || sums[0].Intents[0] != "demo.go" { + t.Fatalf("demo intents = %v", sums[0].Intents) + } + if len(sums[0].Skills) != 1 || sums[0].Skills[0] != "demo-skill" { + t.Fatalf("demo skills = %v", sums[0].Skills) + } + // broken:Register 报错但先成功的命令仍在总线,摘要如实保留。 + if sums[1].ID != "broken" || len(sums[1].Commands) != 1 || sums[1].Commands[0] != "broken.cmd" { + t.Fatalf("broken summary = %+v", sums[1]) + } + // 摘要与总线一致:记录的命令真实可分发(不是空头衔)。 + resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "demo.one", Owner: "alice", Entrance: "test"}) + if resp.Receipt.State != agentcommand.StateCommitted { + t.Fatalf("demo.one dispatch = %s (%s)", resp.Receipt.State, resp.Receipt.Reason) + } +} diff --git a/internal/server/compiled_list_test.go b/internal/server/compiled_list_test.go new file mode 100644 index 0000000..93d756d --- /dev/null +++ b/internal/server/compiled_list_test.go @@ -0,0 +1,124 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "agent-system-backend/internal/bus" + "agent-system-backend/internal/plugins" + "agent-system-backend/internal/profiles" + "agent-system-backend/internal/runmgr" + "agent-system-backend/internal/runtime" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/internal/tools" + "agent-system-backend/pkg/extension" +) + +type compiledListView struct { + Manifest struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + APIVersion string `json:"apiVersion"` + Provides struct { + Commands []string `json:"commands"` + Intents []string `json:"intents"` + } `json:"provides"` + Backend struct { + Mode string `json:"mode"` + } `json:"backend"` + } `json:"manifest"` +} + +// WP3-I4:/api/plugins 合并编译插件条目——backend.mode=inproc、apiVersion 取 +// 扩展面版本、provides 来自注册面摘要;目录插件同名时目录条目权威不重复。 +func TestListPluginsMergesCompiled(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() }) + b := bus.New() + reg := tools.NewRegistry() + reg.RegisterBuiltin() + setSvc := settings.NewService(st) + profSvc := profiles.New(st) + profSvc.EnsureDefault() + mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{}) + + pluginsRoot := filepath.Join(dir, "plugins") + // 目录插件与编译条目同名:目录条目权威,编译合成条目不重复列出。 + if err := os.MkdirAll(filepath.Join(pluginsRoot, "generative-ui"), 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"id":"generative-ui","name":"目录版","version":"1.2.3","apiVersion":"1.0","provides":{}}` + if err := os.WriteFile(filepath.Join(pluginsRoot, "generative-ui", "plugin.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + ph := plugins.New(st, reg, setSvc, pluginsRoot) + for id, err := range ph.LoadDir() { + if err != nil { + t.Fatalf("LoadDir %s: %v", id, err) + } + } + + srv := New(st, mgr, reg, profSvc, setSvc, ph) + srv.Auth = NewAuthenticator(st, true) + srv.CompiledPlugins = []CompiledPlugin{ + {ID: "rdm", Commands: []string{"rdm.ingest", "rdm.inspect.capabilities"}, Intents: []string{"rdm.write-proposal"}}, + {ID: "generative-ui", Commands: []string{"generative-ui.present"}}, + } + h := srv.Handler() + key, err := st.CreateAPIKey("alice", "e2e") + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/plugins", nil) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + var list []compiledListView + if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil { + t.Fatal(err) + } + var rdm, genui *compiledListView + genuiCount := 0 + for i := range list { + switch list[i].Manifest.ID { + case "rdm": + rdm = &list[i] + case "generative-ui": + genui = &list[i] + genuiCount++ + } + } + if rdm == nil { + t.Fatalf("compiled rdm entry missing: %s", rec.Body.String()) + } + if rdm.Manifest.Name != "rdm" || rdm.Manifest.Backend.Mode != "inproc" { + t.Fatalf("rdm entry = %+v", rdm.Manifest) + } + if rdm.Manifest.APIVersion != extension.SurfaceVersion { + t.Fatalf("rdm apiVersion = %q, want %q", rdm.Manifest.APIVersion, extension.SurfaceVersion) + } + if len(rdm.Manifest.Provides.Commands) != 2 || len(rdm.Manifest.Provides.Intents) != 1 { + t.Fatalf("rdm provides = %+v", rdm.Manifest.Provides) + } + if genuiCount != 1 || genui == nil { + t.Fatalf("generative-ui listed %d times", genuiCount) + } + // 目录条目权威:同名编译条目不重复、manifest 不被合成版覆盖。 + if genui.Manifest.Name != "目录版" || genui.Manifest.Version != "1.2.3" || genui.Manifest.Backend.Mode != "" { + t.Fatalf("genui entry = %+v", genui.Manifest) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index fbfde59..340a795 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -34,6 +34,7 @@ import ( "agent-system-backend/internal/surfacesvc" "agent-system-backend/internal/tools" "agent-system-backend/internal/workflow" + "agent-system-backend/pkg/extension" "agent-system-backend/web" core "agent-system-core" ) @@ -70,6 +71,9 @@ type Server struct { // 默认拒绝——旧只读适配器不得自动获得写入能力。公开契约的版本化扩展 // 计划见 docs/guides/team-storage.md(KTD13 一节)。 ProjectAuthzActions func(owner string, projectID core.ID, action ProjectAction) (bool, error) + // CompiledPlugins 编译进二进制的进程内插件摘要(组合根在 applyCompiled + // 期间记录注册面生成;nil/空 = 无编译插件,清单行为不变)。 + CompiledPlugins []CompiledPlugin } // ProjectAction 项目资源的动作授权矩阵(KTD13)。共享可读不等于可写或可 @@ -1448,6 +1452,16 @@ func (s *Server) deleteUserTool(w http.ResponseWriter, r *http.Request) { // ---- 插件 ---- +// CompiledPlugin 是一个编译进二进制的进程内插件的注册面摘要(WP3-I4): +// ID 即插件身份;Commands/Intents/Skills 为装载期成功注册的声明资产名。 +// 命令规格与处理器在命令总线,摘要只服务 /api/plugins 管理清单展示。 +type CompiledPlugin struct { + ID string + Commands []string + Intents []string + Skills []string +} + type pluginView struct { Manifest core.PluginManifest `json:"manifest"` Panels []core.PluginPanel `json:"panels"` @@ -1463,13 +1477,28 @@ type pluginView struct { func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) { var out []pluginView + dirIDs := map[string]bool{} for _, ld := range s.plugins.ListFor(s.owner(r), r.URL.Query().Get("projectId")) { + dirIDs[ld.Manifest.ID] = true out = append(out, pluginView{ Manifest: ld.Manifest, Panels: ld.Panels, Cards: ld.Cards, Workflows: ld.Workflows, Intents: ld.Intents, Prompts: ld.Prompts, Settings: ld.PluginSettings, Pages: ld.Pages, Channels: ld.Channels, Skills: ld.Skills, }) } + // 编译插件(WP3-I4):条目由注册面合成——backend.mode=inproc 标记编译 + // 来源,apiVersion 取扩展面版本;与目录插件同名时目录 manifest 权威 + // (目录版本可装卸、信息更全),编译条目不重复列出。 + for _, cp := range s.CompiledPlugins { + if dirIDs[cp.ID] { + continue + } + out = append(out, pluginView{Manifest: core.PluginManifest{ + ID: cp.ID, Name: cp.ID, APIVersion: extension.SurfaceVersion, + Provides: core.PluginProvides{Commands: cp.Commands, Intents: cp.Intents, Skills: cp.Skills}, + Backend: core.PluginBackend{Mode: "inproc"}, + }}) + } writeJSON(w, http.StatusOK, orEmptySlice(out)) }