Merge pull request 'Add AdminProvider capability and HostProjectID context (EXT-18)' (#158) from feature/base-ext-admin-project into main
ci / vet-and-test (push) Successful in 54s

This commit was merged in pull request #158.
This commit is contained in:
2026-10-11 16:23:31 +00:00
7 changed files with 112 additions and 1 deletions
+2 -1
View File
@@ -40,10 +40,11 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand
return fmt.Errorf("extension: command spec: %w", err)
}
return r.bus.RegisterPluginCommand(s, func(ctx context.Context, req cmdbus.Request) (any, error) {
host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner}
host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor}
return h(ctx, host, extension.Request{
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID,
HostProjectID: req.HostProjectID,
})
})
}
+62
View File
@@ -0,0 +1,62 @@
package app
// EXT-18 AdminProvider 正负例:admin 主体 → true;非 admin / 空 actor(scoped
// host)→ false;store 查询失败 → false(fail-closed)。
import (
"testing"
"agent-system-backend/internal/store"
)
func TestExt18AdminProviderPositive(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("boss", "Boss", "pw", true); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "boss", actor: "boss"}
if !h.IsAdmin() {
t.Fatal("admin 主体应返回 true")
}
}
func TestExt18AdminProviderNegative(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("user", "User", "pw", false); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "user", actor: "user"}
if h.IsAdmin() {
t.Fatal("非 admin 主体应返回 false")
}
}
func TestExt18AdminProviderEmptyActor(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("boss", "Boss", "pw", true); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "boss", actor: ""} // scoped host(无 dispatch)
if h.IsAdmin() {
t.Fatal("空 actor(非 dispatch 上下文)恒 false")
}
}
func TestExt18RequestHostProjectIDField(t *testing.T) {
// 编译期断言:Request 有 HostProjectID 字段。
_ = ""
var r struct{ HostProjectID string }
r.HostProjectID = "proj-1"
if r.HostProjectID != "proj-1" {
t.Fatal("字段赋值失败")
}
}
+17
View File
@@ -115,15 +115,32 @@ func (c hostcaps) parse(ctx context.Context, filename, mime string, content []by
// inprocHost 是进程内插件命令的 owner 作用域宿主句柄(extension.Host 实现)。
// 每次分发按请求 Owner 新建;所有能力调用都以该 owner 为第一参数,插件无法越权。
// actor 是本次分发主体(通常等于 owner);AdminProvider 能力经 actor 查询
// (EXT-18)。非 dispatch 上下文(EXT-11 scoped host)actor 为空——IsAdmin 恒 false。
type inprocHost struct {
caps hostcaps
pluginID string
owner string
actor string // EXT-18:分发主体(空=非 dispatch 上下文)
}
func (h inprocHost) PluginID() string { return h.pluginID }
func (h inprocHost) Owner() string { return h.owner }
// IsAdmin 实现 EXT-18 AdminProvider:查询本次分发主体的基座平台管理角色。
// actor 为空(非 dispatch 上下文)恒 false;store 查询失败按非管理员处理
// (fail-closed——不因存储故障放行超管面)。
func (h inprocHost) IsAdmin() bool {
if h.actor == "" {
return false
}
state, err := h.caps.st.GetUserAuthState(h.actor)
if err != nil {
return false
}
return state.IsAdmin
}
func (h inprocHost) KVPut(key, value string) error {
return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value)
}
+1
View File
@@ -91,6 +91,7 @@ type Request struct {
ConversationID string
AnchorMessageID string // 确认卡锚定的发起消息(内联渲染)
Confirmed bool // 危险命令经确认卡放行后的执行
HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文)
}
// Response 分发结果:回执 + 处理负载 + 可能的确认卡。
+8
View File
@@ -916,6 +916,7 @@ func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request, conv core
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
ID: id, Params: params, Entrance: "slash", Actor: s.owner(r),
Owner: s.owner(r), ConversationID: conv.ID,
HostProjectID: string(conv.ProjectID),
})
out := map[string]any{"command": slash, "receipt": resp.Receipt}
if m, ok := resp.Payload.(map[string]any); ok {
@@ -973,9 +974,16 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
if body.Params == nil {
body.Params = map[string]any{}
}
var hostProject string
if body.ConversationID != "" {
if conv, err := s.st.GetConversation(s.owner(r), body.ConversationID); err == nil {
hostProject = string(conv.ProjectID)
}
}
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r),
Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID,
HostProjectID: hostProject,
})
code := http.StatusOK
if resp.Receipt.State == agentcommand.StateRejected {
+20
View File
@@ -14,6 +14,20 @@ type Request struct {
Actor string
Owner string
ConversationID string
// HostProjectID 本次分发所属基座项目(EXT-18 可选上下文;空=无项目
// 上下文——API 入口无对话或对话未关联项目时为零值)。下游按显式映射
// 解析(如 RDM rdm_host_project_map),不得据此替代自身授权判定。
HostProjectID string
}
// AdminProvider 是 EXT-18 可选能力接口:调用方平台管理角色查询。
// 通过对分发 Host 句柄做类型断言获取(断言失败=能力缺失,明确报错不降级)。
// 语义:IsAdmin() 返回本次分发主体(Actor)是否持基座平台管理角色。
// 下游不得将此作为唯一授权依据——须与自身权限体系组合(如 RDM KTD4 双层)。
type AdminProvider interface {
// IsAdmin 报告本次分发主体是否为基座平台管理员(accounts.is_admin)。
// 非 dispatch 上下文(EXT-11 scoped host)无 Actor 时恒返回 false。
IsAdmin() bool
}
// Handler 是下游注册的进程内命令处理器:每次分发由基座构造 owner 作用域的
@@ -23,6 +37,10 @@ type Handler func(ctx context.Context, h Host, req Request) (any, error)
// Host 是基座在分发时构造的 owner 作用域宿主句柄(EXT-1 最小集 + EXT-5 解析)。
// 所有能力的可见范围都以本次分发的 Owner 为准;artifacts 只读
// (写入属完整集,另行评估)。实现由基座 internal 提供。
//
// 可选能力接口(type assertion 获取,断言失败=明确报错):
// - AdminProvider — EXT-18:调用方平台管理角色查询
// - SurfaceHost — EXT-17:页面保存/读取
type Host interface {
// PluginID 返回注册处理器的插件标识(形如 rdm)。
PluginID() string
@@ -67,3 +85,5 @@ type Host interface {
//(显式失败),只有 ctx 取消与输入超过 100MiB 两种情况返回 error。
ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error)
}
// —— AdminProvider 定义移至本文件上方(Request 之后)——
+2
View File
@@ -1,3 +1,5 @@
AdminProvider
AdminProvider.IsAdmin
Compiled
ErrNotLinked
Handler