Merge pull request 'Declare admin-zone sections from compiled plugins (surface 2.9.0, WP3-I3)' (#165) from feature/adminzone-plugin-sections into main
ci / vet-and-test (push) Successful in 46s

This commit was merged in pull request #165.
This commit is contained in:
2026-10-12 02:39:56 +00:00
7 changed files with 191 additions and 7 deletions
+25 -2
View File
@@ -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)
}
+14
View File
@@ -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])
+1
View File
@@ -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)
+91
View File
@@ -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())
}
}
+37 -4
View File
@@ -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{
+21 -1
View File
@@ -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 是分区身份(建议形如
// <plugin>.<section>,前端按 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 的
+2
View File
@@ -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