Files
HomeAgent/internal/sdk/events_test.go
JianFeeeee 981b969f58 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、
别的插件命名空间、空类型)/ 自己命名空间放行 / 订阅越界拒绝 / 载荷凭据被替换成别名 / 键数上限
2026-10-27 11:16:40 +08:00

138 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("超过键数上限应报错")
}
}