mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
feat(sdk): 受限事件总线的内核实现 + clawhubadapter 改用它
- internal/sdk/events.go:实现 pubsdk.EventsAPI
· publishAllowed:白名单精确匹配 或 本插件命名空间 plugin.<name>.<后缀>
· subscribeAllowed:只开只读事件(tools_ready/security_ready/device_ready)
· 载荷出向经 security.PrepareOutbound(明文→@{别名}),入向经 ScrubInbound
· 键数上限 64;越界**报错**不静默
· Source 由 SDK 固定成插件名(插件不能冒名)
- PluginSDK.Events() 访问器
- clawhubadapter 的 skill_detected 改走 s.Events().Publish,不再 import internal/events
⇒ 它为外部化的唯一阻塞项(事件发布)已消除
判据(internal/sdk/events_test.go,6 条):
白名单放行 / 越界拒绝(raw_input、agent_output、update_request、stage、system、
别的插件命名空间、空类型)/ 自己命名空间放行 / 订阅越界拒绝 / 载荷凭据被替换成别名 / 键数上限
This commit is contained in:
@ -18,7 +18,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
|
||||||
"github.com/JianFeeeee/HologramAgent/internal/plugin"
|
"github.com/JianFeeeee/HologramAgent/internal/plugin"
|
||||||
safeclose "github.com/JianFeeeee/HologramAgent/internal/safeclose"
|
safeclose "github.com/JianFeeeee/HologramAgent/internal/safeclose"
|
||||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||||
@ -1305,12 +1304,15 @@ func (p *Plugin) Stop() error {
|
|||||||
// publishSkillDetected 发布 skill_detected 事件,将纯 SKILL 条目移交原生
|
// publishSkillDetected 发布 skill_detected 事件,将纯 SKILL 条目移交原生
|
||||||
// skillmgr 插件注册。OpenClaw 兼容层不持有 native skill 状态。
|
// skillmgr 插件注册。OpenClaw 兼容层不持有 native skill 状态。
|
||||||
func (p *Plugin) publishSkillDetected(s *sdk.PluginSDK, path string) {
|
func (p *Plugin) publishSkillDetected(s *sdk.PluginSDK, path string) {
|
||||||
s.Publish(&events.Event{
|
// ★ 走**受限** SDK 事件面,而不是内核私有总线(2026-10-27)。
|
||||||
Type: events.EventSkillDetected,
|
//
|
||||||
Source: p.name,
|
// 这样 clawhubadapter 对外部化不再有阻塞项:它只需要 SDK 的
|
||||||
Payload: map[string]interface{}{"path": path},
|
// Events().Publish,而不需要 internal/events。事件类型仍是 skill_detected
|
||||||
Timestamp: time.Now().Unix(),
|
// (白名单内),载荷出向会经 security 过闸,Source 由 SDK 固定成本插件名。
|
||||||
})
|
if err := s.Events().Publish("skill_detected", map[string]interface{}{"path": path}); err != nil {
|
||||||
|
log.Printf("[clawhubadapter] publish skill_detected failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Printf("[clawhubadapter] skill %s detected, handed off to skillmgr", filepath.Base(path))
|
log.Printf("[clawhubadapter] skill %s detected, handed off to skillmgr", filepath.Base(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
package sdk
|
package sdk
|
||||||
|
|
||||||
import "github.com/JianFeeeee/HologramAgent/internal/events"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||||||
|
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||||
|
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
// 事件类型/事件类型别名:插件经 SDK 订阅/发布内核事件,
|
// 事件类型/事件类型别名:插件经 SDK 订阅/发布内核事件,
|
||||||
// 无需直接 import internal/events(内核事件总线仅通过 SDK 暴露)。
|
// 无需直接 import internal/events(内核事件总线仅通过 SDK 暴露)。
|
||||||
@ -30,3 +37,153 @@ const (
|
|||||||
// 实体名是中文概念(实测命中 0),只能退化成点亮一批固定节点。
|
// 实体名是中文概念(实测命中 0),只能退化成点亮一批固定节点。
|
||||||
EventMemoryAccess = events.EventMemoryAccess
|
EventMemoryAccess = events.EventMemoryAccess
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ───────────────────── 受限事件总线(插件可见) ─────────────────────
|
||||||
|
//
|
||||||
|
// 边界由三件事决定,缺一不可:
|
||||||
|
// 1. 类型白名单 + 插件自己的命名空间(`plugin.<name>.*`)
|
||||||
|
// 2. 载荷出向经 security 审查(明文凭据换成 @{别名})+ 键数上限
|
||||||
|
// 3. 订阅只开只读事件(tools_ready / security_ready / device_ready)
|
||||||
|
//
|
||||||
|
// 为什么不直接把 *events.Bus 交给插件:Bus.Publish 能发 EventAll 通配类型、
|
||||||
|
// 能伪造任意 Source、载荷不做任何审查。那不是"多给一个便利",而是把内核
|
||||||
|
// 内部同步面变成没有边界的公共写口。
|
||||||
|
|
||||||
|
const (
|
||||||
|
// eventPayloadMaxKeys 限制载荷顶层键数,防止"把一个对象当事件"塞进来。
|
||||||
|
// 事件是**通知**,不是数据通道:大块数据该走插件自己的通道或工具返回值。
|
||||||
|
eventPayloadMaxKeys = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
type eventsAPI struct {
|
||||||
|
pluginName string
|
||||||
|
bus *events.Bus
|
||||||
|
sec *security.Runtime // 可 nil(安全模块未装配时零成本跳过)
|
||||||
|
unsubs []func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEventsAPI 构造插件的受限事件面(bus 为 nil 时返回 nil,调用方按 nil 处理)。
|
||||||
|
func newEventsAPI(pluginName string, bus *events.Bus) pubsdk.EventsAPI {
|
||||||
|
if bus == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &eventsAPI{pluginName: pluginName, bus: bus, sec: security.RuntimeGlobal()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *eventsAPI) AllowedPublish() []string {
|
||||||
|
out := pubsdk.PluginPublishableEvents()
|
||||||
|
return append(out, "plugin."+e.pluginName+".*")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *eventsAPI) AllowedSubscribe() []string { return pubsdk.PluginSubscribableEvents() }
|
||||||
|
|
||||||
|
// publishAllowed:白名单精确匹配,或本插件命名空间前缀(plugin.<name>.<后缀>)。
|
||||||
|
func (e *eventsAPI) publishAllowed(t string) bool {
|
||||||
|
for _, a := range pubsdk.PluginPublishableEvents() {
|
||||||
|
if t == a {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefix := "plugin." + e.pluginName + "."
|
||||||
|
return strings.HasPrefix(t, prefix) && len(t) > len(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *eventsAPI) subscribeAllowed(t string) bool {
|
||||||
|
for _, a := range pubsdk.PluginSubscribableEvents() {
|
||||||
|
if t == a {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *eventsAPI) Publish(eventType string, payload map[string]interface{}) error {
|
||||||
|
if eventType == "" {
|
||||||
|
return fmt.Errorf("events: event type must not be empty")
|
||||||
|
}
|
||||||
|
if !e.publishAllowed(eventType) {
|
||||||
|
return fmt.Errorf("events: plugin %q may not publish %q (allowed: %v)",
|
||||||
|
e.pluginName, eventType, e.AllowedPublish())
|
||||||
|
}
|
||||||
|
if payload == nil {
|
||||||
|
payload = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
if len(payload) > eventPayloadMaxKeys {
|
||||||
|
return fmt.Errorf("events: payload has %d keys, limit is %d", len(payload), eventPayloadMaxKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 载荷出向审查:事件载荷会被其他订阅者(含 WebUI/状态面)读到,
|
||||||
|
// 插件顺手把凭据放进去就等于绕过凭据闸门。
|
||||||
|
e.bus.Publish(&events.Event{
|
||||||
|
Type: events.EventType(eventType),
|
||||||
|
Source: e.pluginName,
|
||||||
|
Payload: e.scrubOut(eventType, payload),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrubOut 对载荷做一次出向脱敏(安全模块未装配时原样返回)。
|
||||||
|
func (e *eventsAPI) scrubOut(eventType string, payload map[string]interface{}) map[string]interface{} {
|
||||||
|
if e.sec == nil {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{}, len(payload))
|
||||||
|
for k, v := range payload {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
if clean, n := e.sec.PrepareOutbound(security.Inspection{
|
||||||
|
Direction: security.DirectionOutbound,
|
||||||
|
Stage: "event:" + eventType,
|
||||||
|
Source: e.pluginName,
|
||||||
|
}, s); n > 0 {
|
||||||
|
out[k] = clean
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *eventsAPI) Subscribe(eventType string, handler func(map[string]interface{})) (func(), error) {
|
||||||
|
if !e.subscribeAllowed(eventType) {
|
||||||
|
return nil, fmt.Errorf("events: plugin %q may not subscribe to %q (allowed: %v)",
|
||||||
|
e.pluginName, eventType, e.AllowedSubscribe())
|
||||||
|
}
|
||||||
|
if handler == nil {
|
||||||
|
return nil, fmt.Errorf("events: handler must not be nil")
|
||||||
|
}
|
||||||
|
unsub := e.bus.Subscribe(events.EventType(eventType), func(evt *events.Event) {
|
||||||
|
handler(e.scrubIn(eventType, evt.Payload))
|
||||||
|
})
|
||||||
|
e.unsubs = append(e.unsubs, unsub)
|
||||||
|
return unsub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrubIn 对入向载荷做清洗(事件可能带其他模块写进来的内容)。
|
||||||
|
func (e *eventsAPI) scrubIn(eventType string, p map[string]interface{}) map[string]interface{} {
|
||||||
|
if e.sec == nil || p == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{}, len(p))
|
||||||
|
for k, v := range p {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
if clean, n := e.sec.ScrubInbound(security.Inspection{
|
||||||
|
Direction: security.DirectionInbound,
|
||||||
|
Stage: "event:" + eventType,
|
||||||
|
}, s); n > 0 {
|
||||||
|
out[k] = clean
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop 解除本插件建立的全部订阅(插件卸载时由宿主调用)。
|
||||||
|
func (e *eventsAPI) stop() {
|
||||||
|
for _, u := range e.unsubs {
|
||||||
|
u()
|
||||||
|
}
|
||||||
|
e.unsubs = nil
|
||||||
|
}
|
||||||
|
|||||||
137
internal/sdk/events_test.go
Normal file
137
internal/sdk/events_test.go
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
// 受限事件总线的判据。
|
||||||
|
//
|
||||||
|
// 设计前提(用户 2026-10-27):现在有 security 模块兜底,可以给插件开事件总线,
|
||||||
|
// 但只能是**受限的一条缝**——白名单类型、自己的命名空间、载荷过闸。
|
||||||
|
// 每条判据都在问:插件能不能借事件总线做超出边界的事?
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||||||
|
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||||
|
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestEvents(t *testing.T, name string) (pubsdk.EventsAPI, *events.Bus) {
|
||||||
|
t.Helper()
|
||||||
|
bus := events.NewBus()
|
||||||
|
return newEventsAPI(name, bus), bus
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 白名单事件可发布,且 Source 被强制设成插件名(不能冒名)。
|
||||||
|
func TestEventsPublishAllowlisted(t *testing.T) {
|
||||||
|
e, bus := newTestEvents(t, "clawhubadapter")
|
||||||
|
var got *events.Event
|
||||||
|
bus.Subscribe(events.EventSkillDetected, func(evt *events.Event) { got = evt })
|
||||||
|
|
||||||
|
if err := e.Publish("skill_detected", map[string]interface{}{"path": "/x/SKILL.md"}); err != nil {
|
||||||
|
t.Fatalf("白名单事件应允许发布: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("订阅者没收到事件")
|
||||||
|
}
|
||||||
|
if got.Source != "clawhubadapter" {
|
||||||
|
t.Fatalf("Source 必须是插件名(不能冒名),实际 %q", got.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 越界类型必须**报错**(静默丢弃会让插件以为发成功)。
|
||||||
|
func TestEventsPublishRejectsNonAllowlisted(t *testing.T) {
|
||||||
|
e, _ := newTestEvents(t, "evil")
|
||||||
|
for _, bad := range []string{
|
||||||
|
"raw_input", // 用户输入通道
|
||||||
|
"agent_output", // 对外输出
|
||||||
|
"update_request", // 能驱动内核升级流程
|
||||||
|
"stage", // 阶段管道内部事件
|
||||||
|
"system", // 系统事件
|
||||||
|
"plugin.other.x", // 别的插件命名空间
|
||||||
|
"plugin.evil", // 缺少后缀
|
||||||
|
"", // 空类型
|
||||||
|
} {
|
||||||
|
if err := e.Publish(bad, nil); err == nil {
|
||||||
|
t.Errorf("发布 %q 应被拒绝", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 自己的命名空间允许(插件间协作),且带后缀。
|
||||||
|
func TestEventsPublishOwnNamespace(t *testing.T) {
|
||||||
|
e, bus := newTestEvents(t, "seq")
|
||||||
|
var seen bool
|
||||||
|
bus.Subscribe(events.EventType("plugin.seq.done"), func(*events.Event) { seen = true })
|
||||||
|
if err := e.Publish("plugin.seq.done", map[string]interface{}{"n": 1}); err != nil {
|
||||||
|
t.Fatalf("自己的命名空间应允许: %v", err)
|
||||||
|
}
|
||||||
|
if !seen {
|
||||||
|
t.Fatal("订阅者没收到自己的命名空间事件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 订阅只开只读事件;越界订阅报错。
|
||||||
|
func TestEventsSubscribeAllowlistedOnly(t *testing.T) {
|
||||||
|
e, bus := newTestEvents(t, "p1")
|
||||||
|
if _, err := e.Subscribe("tools_ready", func(map[string]interface{}) {}); err != nil {
|
||||||
|
t.Fatalf("tools_ready 应可订阅: %v", err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"raw_input", "agent_output", "update_request"} {
|
||||||
|
if _, err := e.Subscribe(bad, func(map[string]interface{}) {}); err == nil {
|
||||||
|
t.Errorf("订阅 %q 应被拒绝", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 解除订阅后不再收到
|
||||||
|
cancel, err := e.Subscribe("security_ready", func(map[string]interface{}) {})
|
||||||
|
if err != nil || cancel == nil {
|
||||||
|
t.Fatalf("应能订阅 security_ready: %v", err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
if bus == nil {
|
||||||
|
t.Fatal("bus 不应为 nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑤ 载荷出向过安全闸:明文凭据被换成 @{别名},不会原样进事件。
|
||||||
|
func TestEventsPayloadIsScrubbed(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := security.InitGlobal(security.Options{
|
||||||
|
Path: dir + "/vars.json",
|
||||||
|
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||||
|
security.SetRuntimeGlobal(security.NewRuntime(store))
|
||||||
|
t.Cleanup(func() { security.SetRuntimeGlobal(nil) })
|
||||||
|
|
||||||
|
e, bus := newTestEvents(t, "leaky")
|
||||||
|
var payload map[string]interface{}
|
||||||
|
bus.Subscribe(events.EventSkillDetected, func(evt *events.Event) { payload = evt.Payload })
|
||||||
|
|
||||||
|
const secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
if err := e.Publish("skill_detected", map[string]interface{}{"note": "token=" + secret}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, _ := payload["note"].(string)
|
||||||
|
if strings.Contains(s, secret) {
|
||||||
|
t.Fatalf("载荷里的明文凭据没有被拦下: %q", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "@{sec_") {
|
||||||
|
t.Fatalf("应换成变量别名,实际 %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑥ 键数上限:事件是通知,不是数据通道。
|
||||||
|
func TestEventsPayloadKeyLimit(t *testing.T) {
|
||||||
|
e, _ := newTestEvents(t, "big")
|
||||||
|
p := map[string]interface{}{}
|
||||||
|
for i := 0; i < eventPayloadMaxKeys+1; i++ {
|
||||||
|
p[string(rune('a'+i%26))+strings.Repeat("x", i)] = i
|
||||||
|
}
|
||||||
|
if err := e.Publish("skill_detected", p); err == nil {
|
||||||
|
t.Fatal("超过键数上限应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -179,6 +179,7 @@ type PluginSDK struct {
|
|||||||
|
|
||||||
iom *agentIO.IOManager
|
iom *agentIO.IOManager
|
||||||
eventBus *events.Bus
|
eventBus *events.Bus
|
||||||
|
events pubsdk.EventsAPI
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
pluginMgr PluginManager
|
pluginMgr PluginManager
|
||||||
|
|
||||||
@ -198,7 +199,13 @@ func (s *PluginSDK) PluginMgr() PluginManager { return s.pluginMgr }
|
|||||||
|
|
||||||
// 以下访问器遮蔽公共 SDK 的同名方法,返回内置插件可用的全量接口。
|
// 以下访问器遮蔽公共 SDK 的同名方法,返回内置插件可用的全量接口。
|
||||||
|
|
||||||
func (s *PluginSDK) Settings() SettingsAPI { return s.settings }
|
func (s *PluginSDK) Settings() SettingsAPI { return s.settings }
|
||||||
|
|
||||||
|
// Events 返回**受限**事件总线(可 nil:未接总线时)。
|
||||||
|
//
|
||||||
|
// 与直接拿 *events.Bus 的区别:只能发布白名单类型或自己的命名空间
|
||||||
|
// (`plugin.<name>.*`),载荷出向经 security 过闸,订阅只开只读事件。
|
||||||
|
func (s *PluginSDK) Events() pubsdk.EventsAPI { return s.events }
|
||||||
func (s *PluginSDK) Memory() MemoryAPI { return s.memory }
|
func (s *PluginSDK) Memory() MemoryAPI { return s.memory }
|
||||||
func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem }
|
func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem }
|
||||||
func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem }
|
func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem }
|
||||||
@ -383,6 +390,7 @@ func New(name string, cfg SDKConfig) *PluginSDK {
|
|||||||
|
|
||||||
iom: cfg.IOManager,
|
iom: cfg.IOManager,
|
||||||
eventBus: cfg.EventBus,
|
eventBus: cfg.EventBus,
|
||||||
|
events: newEventsAPI(name, cfg.EventBus),
|
||||||
logger: log.Default(),
|
logger: log.Default(),
|
||||||
pluginMgr: cfg.PluginMgr,
|
pluginMgr: cfg.PluginMgr,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user