Add EXT-19 account lister host capability #181

Merged
weichen merged 2 commits from feature/ext19-account-lister into main 2026-10-12 13:47:26 +00:00
6 changed files with 141 additions and 3 deletions
+53
View File
@@ -0,0 +1,53 @@
package app
// EXT-19 消费测试:inprocHost.ListAccounts——最小目录(owner/valid)、游标分页、
// limit 钳制(1-200,缺省 50)。数据来自 users 表(真实 store,非桩)。
import (
"testing"
"agent-system-backend/internal/store"
)
func TestInprocHostListAccounts(t *testing.T) {
dir := t.TempDir()
st, err := store.OpenSQLite(dir + "/test.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
for _, o := range []string{"carol", "alice", "bob", "dave"} {
if _, err := st.CreateAPIKey(o, "e2e"); err != nil {
t.Fatal(err)
}
}
h := inprocHost{caps: hostcaps{st: st}}
// 首页(limit=2):alice/bob,next=bob。
page, next, err := h.ListAccounts("", 2)
if err != nil {
t.Fatal(err)
}
if len(page) != 2 || page[0].Owner != "alice" || page[1].Owner != "bob" {
t.Fatalf("首页不符: %+v", page)
}
for _, e := range page {
if !e.Valid {
t.Fatalf("现有账号应 valid: %+v", e)
}
}
if next != "bob" {
t.Fatalf("next 应为 bob, got %q", next)
}
// 次页:carol/dave,next=""(末页)。
page2, next2, _ := h.ListAccounts(next, 2)
if len(page2) != 2 || page2[0].Owner != "carol" || page2[1].Owner != "dave" || next2 != "" {
t.Fatalf("次页不符: %+v next=%q", page2, next2)
}
// limit 钳制:0 → 50 缺省;超 200 → 200(不炸)。
if p, _, _ := h.ListAccounts("", 0); len(p) != 4 {
t.Fatalf("limit=0 应取缺省(4 账号全量), got %d", len(p))
}
if _, _, err := h.ListAccounts("", 999); err != nil {
t.Fatalf("limit 超限应钳制不报错: %v", err)
}
}
+40
View File
@@ -171,6 +171,46 @@ func (h inprocHost) IsAdmin() bool {
return state.IsAdmin
}
// ListAccounts 实现 EXT-19 AccountLister(最小账号目录):仅授权管理读面用。
// 有效态=账号存在且非停用(当前 users 表无停用位——must_change_password 不影响
// 有效性;后续加停用位在此映射)。游标=owner 升序偏移。
func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountEntry, string, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
users, err := h.caps.st.ListUsersAdmin()
if err != nil {
return nil, "", err
}
entries := make([]extension.AccountEntry, 0, len(users))
for _, u := range users {
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Valid: true})
}
// 排序+游标(owner 升序;cursor 为上一页末 owner,空=首页)。
for i := 0; i < len(entries); i++ {
for j := i + 1; j < len(entries); j++ {
if entries[j].Owner < entries[i].Owner {
entries[i], entries[j] = entries[j], entries[i]
}
}
}
start := 0
if cursor != "" {
for start < len(entries) && entries[start].Owner <= cursor {
start++
}
}
end := start + limit
if end > len(entries) {
end = len(entries)
}
next := ""
if end < len(entries) {
next = entries[end-1].Owner
}
return entries[start:end], next, nil
}
func (h inprocHost) KVPut(key, value string) error {
return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value)
}
+26
View File
@@ -0,0 +1,26 @@
package extension
// EXT-19 红测(方案 r1 U3/KTD2 最小账号目录):公开面新增可选能力接口
// AccountLister(ListAccounts:owner+有效态+游标分页——授权管理所需的最小身份,
// 不含姓名/邮箱/联系方式)。红:类型未定义,编译红先行。
import (
"encoding/json"
"strings"
"testing"
)
func TestAccountListerSurface(t *testing.T) {
if !strings.HasPrefix(SurfaceVersion, "2.11.0") {
t.Fatalf("SurfaceVersion 应升至 2.11.0-experimental(新增导出符号须升版),got %s", SurfaceVersion)
}
// 接口方法签名即契约:游标分页 + 最小字段。
var _ AccountLister = (AccountLister)(nil)
e := AccountEntry{Owner: "u1", Valid: true}
b, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"owner":"u1"`) || !strings.Contains(string(b), `"valid":true`) {
t.Fatalf("AccountEntry 序列化不符(最小字段 owner/valid): %s", b)
}
}
+17 -1
View File
@@ -25,12 +25,14 @@ import (
// 2.8.0-experimental:EXT-17 新增可选能力接口 SurfaceHost(SurfaceSave/Get/List/
// Activation;Generative UI U2)。实现由基座 hostcaps 注入;未装配时类型断言
// 不成立即明确拒绝,不给必实现 Host 接口加方法(不破坏既有消费者)。
// 2.11.0-experimental:EXT-19 新增可选能力接口 AccountLister(ListAccounts:
// 最小账号目录——owner+有效态+游标分页,U3/KTD2;不含姓名/联系方式)。
// 2.10.0-experimental:EXT-8b 新增 ResourceAuthzQuery/ResourceAuthorizerV2 与
// Registry.RegisterResourceAuthorizerV2(资源级授权,U9 首切片)。
// 2.9.0-experimental:Registry.AddAdminZone 与 AdminZoneSpec(WP3-I3/KTD4:编译
// 插件向基座管理区声明展示分区;分区入口不做基座级权限判定,数据能力由
// 分区组件自身的命令面 fail-closed)。
const SurfaceVersion = "2.10.0-experimental"
const SurfaceVersion = "2.11.0-experimental"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
@@ -80,6 +82,20 @@ type Registry interface {
AddAdminZone(spec json.RawMessage) error
}
// AccountEntry 最小账号目录行(U3/KTD2):owner+有效态——授权管理所需的最小
// 身份;姓名/邮箱/联系方式一律不下发(隐私边界)。
type AccountEntry struct {
Owner string `json:"owner"`
Valid bool `json:"valid"`
}
// AccountLister EXT-19 可选能力接口(实现由宿主注入):游标分页列举系统账号。
// 未实现时下游回落现行行为(成员添加不做账号有效性校验);实现后仅用于
// 授权管理读面——不授予任何写权。
type AccountLister interface {
ListAccounts(cursor string, limit int) (entries []AccountEntry, next string, err error)
}
// AdminZoneSpec 管理区分区声明(WP3-I3):ID 是分区身份(建议形如
// <plugin>.<section>,前端按 ID 映射内置分区组件);Title/Icon/Desc 为展示
// 元数据(icon 取基座前端 IconName 词表,未知值前端回退通用图标);Order
+2 -2
View File
@@ -13,8 +13,8 @@ import (
)
func TestResourceAuthzV2SurfacePresent(t *testing.T) {
if !strings.HasPrefix(SurfaceVersion, "2.10.0") {
t.Fatalf("SurfaceVersion 应升至 2.10.0-experimental(新增导出符号须升版),got %s", SurfaceVersion)
if SurfaceVersion < "2.10.0" {
t.Fatalf("SurfaceVersion 应 ≥2.10.0-experimental(EXT-8b 起生效),got %s", SurfaceVersion)
}
var _ ResourceAuthorizerV2 = func(q ResourceAuthzQuery) (bool, error) {
return q.Actor != "" && q.Action != "" && q.ResourceType != "" && q.ResourceID != "", nil
+3
View File
@@ -1,3 +1,6 @@
AccountEntry
AccountLister
AccountLister.ListAccounts
AdminProvider
AdminProvider.IsAdmin
AdminZoneSpec