From 4ae125a3ef9a7cb67bdf034358636b34839088a3 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 16:20:54 +0000 Subject: [PATCH] Add AdminProvider capability and HostProjectID context (EXT-18) Why: RDM KTD4 dual-layer admin requires base isAdmin visibility (superuser slot); KTD5 host-project binding needs trusted current-project context. Evidence: SELF 4 ext18 tests pass + surface snapshot regenerated + full pkg/extension + internal/app suites green. Impact: extension.Request gains HostProjectID; optional AdminProvider interface (EXT-17 pattern); inprocHost implements IsAdmin via GetUserAuthState; three dispatch sites set HostProjectID from conversation. Refs: RDM docs/proposals U1 base extension requests --- internal/app/compiled.go | 3 +- internal/app/ext18_admin_test.go | 62 ++++++++++++++++++++++++++++++++ internal/app/hostcaps.go | 17 +++++++++ internal/cmdbus/cmdbus.go | 1 + internal/server/server.go | 8 +++++ pkg/extension/host.go | 20 +++++++++++ pkg/extension/surface.txt | 2 ++ 7 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 internal/app/ext18_admin_test.go diff --git a/internal/app/compiled.go b/internal/app/compiled.go index c961697..b2609cd 100644 --- a/internal/app/compiled.go +++ b/internal/app/compiled.go @@ -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, }) }) } diff --git a/internal/app/ext18_admin_test.go b/internal/app/ext18_admin_test.go new file mode 100644 index 0000000..ae03746 --- /dev/null +++ b/internal/app/ext18_admin_test.go @@ -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("字段赋值失败") + } +} diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 3d0dd9c..ed94350 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -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) } diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index cf013b0..9b63e7d 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -91,6 +91,7 @@ type Request struct { ConversationID string AnchorMessageID string // 确认卡锚定的发起消息(内联渲染) Confirmed bool // 危险命令经确认卡放行后的执行 + HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文) } // Response 分发结果:回执 + 处理负载 + 可能的确认卡。 diff --git a/internal/server/server.go b/internal/server/server.go index 93b8dfc..fbfde59 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 { diff --git a/pkg/extension/host.go b/pkg/extension/host.go index 2a5a9e4..c528b81 100644 --- a/pkg/extension/host.go +++ b/pkg/extension/host.go @@ -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 之后)—— diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 666e4a0..93c6b6f 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -1,3 +1,5 @@ +AdminProvider +AdminProvider.IsAdmin Compiled ErrNotLinked Handler