diff --git a/internal/app/compiled.go b/internal/app/compiled.go index 4eed7ce..433e182 100644 --- a/internal/app/compiled.go +++ b/internal/app/compiled.go @@ -27,6 +27,29 @@ type pluginRegistry struct { compiledRegistry pluginID string caps hostcaps + // summary 是本插件的注册面摘要(WP3-I4):AddAdminZone 声明记录于此, + // server 经 CompiledPlugins 供给分区清单端点(WP3-I3)。 + summary *server.CompiledPlugin +} + +// AddAdminZone 记录管理区分区声明(WP3-I3):声明即展示元数据,权威记录 +// 就是注册期摘要;规格非法(非对象、id/title 缺失)fail-closed 拒绝, +// 使 register() 整体中止(与命令注册同一失败语义)。 +func (r pluginRegistry) AddAdminZone(spec json.RawMessage) error { + if r.summary == nil { + return errors.New("extension: " + r.pluginID + ": admin zone outside applyCompiled") + } + var s extension.AdminZoneSpec + if err := json.Unmarshal(spec, &s); err != nil { + return fmt.Errorf("extension: admin zone spec: %w", err) + } + if s.ID == "" || s.Title == "" { + return fmt.Errorf("extension: %s: admin zone requires id and title", r.pluginID) + } + r.summary.AdminZones = append(r.summary.AdminZones, server.AdminZoneSection{ + ID: s.ID, Title: s.Title, Icon: s.Icon, Desc: s.Desc, Order: s.Order, + }) + return nil } func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error { @@ -170,10 +193,10 @@ func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) []server.C if e.Register == nil { continue } - reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps} + sum := server.CompiledPlugin{ID: e.ID} + reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps, summary: &sum} // 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) } diff --git a/internal/app/compiled_list_test.go b/internal/app/compiled_list_test.go index 346cd5e..17125e4 100644 --- a/internal/app/compiled_list_test.go +++ b/internal/app/compiled_list_test.go @@ -33,6 +33,13 @@ func TestApplyCompiledRecordsSummary(t *testing.T) { return err } _ = reg.AddSkill("demo-skill", "# 演示\n技能正文") + // WP3-I3:管理区分区声明记入摘要;缺 id/title 的非法规格 fail-closed。 + if err := reg.AddAdminZone(json.RawMessage(`{"id":"demo.audit","title":"演示审计","icon":"search","order":20}`)); err != nil { + return err + } + if err := reg.AddAdminZone(json.RawMessage(`{"title":"缺 id"}`)); err == nil { + t.Fatal("admin zone without id should fail") + } return nil }}) extension.RegisterCompiled(extension.InProc{ID: "broken", Register: func(reg extension.Registry) error { @@ -66,6 +73,13 @@ func TestApplyCompiledRecordsSummary(t *testing.T) { if len(sums[0].Skills) != 1 || sums[0].Skills[0] != "demo-skill" { t.Fatalf("demo skills = %v", sums[0].Skills) } + // WP3-I3:合法声明记入,非法规格(缺 id)被拒后摘要不受污染。 + if len(sums[0].AdminZones) != 1 { + t.Fatalf("demo admin zones = %+v", sums[0].AdminZones) + } + if z := sums[0].AdminZones[0]; z.ID != "demo.audit" || z.Title != "演示审计" || z.Icon != "search" || z.Order != 20 { + t.Fatalf("demo admin zone = %+v", z) + } // 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]) diff --git a/internal/genui/genui_test.go b/internal/genui/genui_test.go index 03d0eb3..d1e1908 100644 --- a/internal/genui/genui_test.go +++ b/internal/genui/genui_test.go @@ -223,6 +223,7 @@ func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorize return nil } func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil } +func (r *fakeRegistry) AddAdminZone(spec json.RawMessage) error { return nil } func TestPresentRejectsUnknownActionIDs(t *testing.T) { newRealSvc(t) diff --git a/internal/server/adminzone_test.go b/internal/server/adminzone_test.go new file mode 100644 index 0000000..1779ccc --- /dev/null +++ b/internal/server/adminzone_test.go @@ -0,0 +1,91 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "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" +) + +// WP3-I3:GET /api/admin-zone/sections 展平编译插件的分区声明并按 +// Order→ID 稳定排序;无声明时空数组(非 null)。 +func TestListAdminZoneSections(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{}) + ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins")) + srv := New(st, mgr, reg, profSvc, setSvc, ph) + srv.Auth = NewAuthenticator(st, true) + srv.CompiledPlugins = []CompiledPlugin{ + {ID: "rdm", AdminZones: []AdminZoneSection{ + {ID: "rdm.audit", Title: "项目追溯与审计", Icon: "search", Order: 20}, + }}, + {ID: "other", AdminZones: []AdminZoneSection{ + {ID: "other.z", Title: "Z", Order: 10}, + {ID: "other.a", Title: "A", Order: 10}, + }}, + } + h := srv.Handler() + key, err := st.CreateAPIKey("alice", "e2e") + if err != nil { + t.Fatal(err) + } + + get := func(path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + // 未认证拒绝。 + if rec := httptest.NewRecorder(); func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/admin-zone/sections", nil) + h.ServeHTTP(rec, req) + return rec + }().Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated request should 401") + } + rec := get("/api/admin-zone/sections") + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + var got []AdminZoneSection + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + wantIDs := []string{"other.a", "other.z", "rdm.audit"} // Order 同为 10 的按 ID,再 Order 20 + if len(got) != 3 { + t.Fatalf("sections = %+v", got) + } + for i, w := range wantIDs { + if got[i].ID != w { + t.Fatalf("sections[%d] = %s, want %s (full: %+v)", i, got[i].ID, w, got) + } + } + // 无声明时空数组非 null。 + srv.CompiledPlugins = nil + if rec := get("/api/admin-zone/sections"); rec.Body.String() == "null" { + t.Fatalf("empty listing must be [] not null: %s", rec.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index b695a76..dd7acd2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -14,6 +14,7 @@ import ( "net/url" "os" "path/filepath" + "sort" "strconv" "strings" "time" @@ -325,6 +326,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/plugins/reload", s.reloadPlugins) mux.HandleFunc("GET /api/plugins/settings-schema", s.pluginSettingsSchema) mux.HandleFunc("GET /api/registry", s.registry) + // WP3-I3/KTD4:管理区声明分区清单(编译插件经 Registry.AddAdminZone 声明; + // 展示元数据对所有认证主体开放,分区内数据能力由组件命令面 fail-closed)。 + mux.HandleFunc("GET /api/admin-zone/sections", s.listAdminZoneSections) // ---- 计划 / 待办 / 命令 / 解析器 / 检索 ---- mux.HandleFunc("GET /api/conversations/{id}/plan", s.getPlan) @@ -1457,10 +1461,22 @@ func (s *Server) deleteUserTool(w http.ResponseWriter, r *http.Request) { // ID 即插件身份;Commands/Intents/Skills 为装载期成功注册的声明资产名。 // 命令规格与处理器在命令总线,摘要只服务 /api/plugins 管理清单展示。 type CompiledPlugin struct { - ID string - Commands []string - Intents []string - Skills []string + ID string + Commands []string + Intents []string + Skills []string + AdminZones []AdminZoneSection +} + +// AdminZoneSection 是编译插件声明的管理区展示分区(WP3-I3/KTD4): +// 字段与 extension.AdminZoneSpec 对齐(组合根装载期记录);入口不做基座级 +// 权限判定,分区内数据能力由组件自身命令面 fail-closed。 +type AdminZoneSection struct { + ID string `json:"id"` + Title string `json:"title"` + Icon string `json:"icon,omitempty"` + Desc string `json:"desc,omitempty"` + Order int `json:"order,omitempty"` } type pluginView struct { @@ -1512,6 +1528,23 @@ func (s *Server) pluginSettingsSchema(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.plugins.SettingsSchema()) } +// listAdminZoneSections 管理区声明分区清单(WP3-I3):展平全部编译插件的 +// 声明并按 Order→ID 稳定排序。声明是展示元数据,对所有认证主体一致; +// 分区内可见数据由分区组件自身的命令面按 owner fail-closed 判定。 +func (s *Server) listAdminZoneSections(w http.ResponseWriter, r *http.Request) { + var out []AdminZoneSection + for _, cp := range s.CompiledPlugins { + out = append(out, cp.AdminZones...) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Order != out[j].Order { + return out[i].Order < out[j].Order + } + return out[i].ID < out[j].ID + }) + writeJSON(w, http.StatusOK, orEmptySlice(out)) +} + // registry 统一注册表视图(来源标记 + 按来源启停入口)。 func (s *Server) registry(w http.ResponseWriter, r *http.Request) { reg := map[string]any{ diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 37bad19..809a0d1 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -25,7 +25,10 @@ import ( // 2.8.0-experimental:EXT-17 新增可选能力接口 SurfaceHost(SurfaceSave/Get/List/ // Activation;Generative UI U2)。实现由基座 hostcaps 注入;未装配时类型断言 // 不成立即明确拒绝,不给必实现 Host 接口加方法(不破坏既有消费者)。 -const SurfaceVersion = "2.8.0-experimental" +// 2.9.0-experimental:Registry.AddAdminZone 与 AdminZoneSpec(WP3-I3/KTD4:编译 +// 插件向基座管理区声明展示分区;分区入口不做基座级权限判定,数据能力由 +// 分区组件自身的命令面 fail-closed)。 +const SurfaceVersion = "2.9.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") @@ -49,6 +52,23 @@ type Registry interface { // AddIntent 声明自然语言意图(G2-2):短语命中后路由到已注册命令—— // 经同一命令总线分发(同审计信封与 HITL 收口)。规格见 IntentSpec。 AddIntent(spec json.RawMessage) error + // AddAdminZone 声明基座管理区展示分区(WP3-I3/KTD4):编译插件把自己的 + // 领域工作台以分区形式挂进管理区;声明只携带展示元数据,分区入口不做 + // 基座级权限判定——数据能力由分区组件自身的命令面 fail-closed。 + // 规格见 AdminZoneSpec。 + AddAdminZone(spec json.RawMessage) error +} + +// AdminZoneSpec 管理区分区声明(WP3-I3):ID 是分区身份(建议形如 +// .
,前端按 ID 映射内置分区组件);Title/Icon/Desc 为展示 +// 元数据(icon 取基座前端 IconName 词表,未知值前端回退通用图标);Order +// 越小越靠前,同 Order 按 ID 字典序。 +type AdminZoneSpec struct { + ID string `json:"id"` + Title string `json:"title"` + Icon string `json:"icon,omitempty"` + Desc string `json:"desc,omitempty"` + Order int `json:"order,omitempty"` } // SurfaceSaveRequest 是一次页面提交(EXT-17):Spec 为 core.SurfaceSpec 的 diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 93c6b6f..9fa2ef7 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -1,5 +1,6 @@ AdminProvider AdminProvider.IsAdmin +AdminZoneSpec Compiled ErrNotLinked Handler @@ -28,6 +29,7 @@ NewScopedHost Options RegisterCompiled Registry +Registry.AddAdminZone Registry.AddCommand Registry.AddCommandHandler Registry.AddIntent