Files
HomeAgent/internal/events/bus.go
JianFeeeee ba281a855a feat(update): 差分 OTA + 安装形态分派 + 事件总线统一受安全监控
三条主线,由 e2e 实测驱动。

## 一、差分 OTA(internal/update/delta.go)

实测:改一行逻辑,88MB 的二进制逐字节只差 80 字节,而 OTA 一直下整包。

用 rsync 族的滚动哈希块匹配(4KB 粒度),不是 bsdiff —— 后者需 17×N
峰值内存(88MB → 1.5GB),而升级恰可能发生在内存紧张时。

真实二进制实测:88,036,728 字节 → 补丁 77,316 字节(0.088%)。

三道闸:应用前验旧文件 sha256(错基线会静默产出垃圾)、应用后验结果
sha256、COPY/LITERAL 越界防护。任何失败**退回整包**,不向上抛 ——
差分是优化,不是可用性前提。

接入:Source.Resolve 优先试 delta;CheckRelease 按 -from-<commit8>.delta
匹配(不猜基线);CLI 新增 -make-delta;打包脚本 build_delta(需
PREVIOUS_HOMED),delta 进 SHA256SUMS。

## 二、安装形态分派(internal/update/install.go + channel.go)

内核对「能不能自己升级」此前只有一个答案:直接替换二进制。而 deb/rpm
形态下那样做会让 dpkg/rpm 数据库与实际内容不一致(下次 apt upgrade
覆盖回旧版,或 dpkg 拒绝后续升级)。

新增探测:deb/rpm/tar/container/source 五类形态 + 变体(full/server/
client/toolchain)+ 目录写权限。判据全部来自可观测系统事实(dpkg 的
info 目录、容器标记文件),不做路径推断。

三端统一走 PlanUpgrade 分派;形态闸门放在**下载 880MB 之前**。

## 三、事件总线统一受安全监控(internal/events + security)

用户指正:「eventbus 上所有事件都要接受安全模块的监控」。

此前 EventPolicy 只被 internal/sdk/events.go 调用,而那是四条路径中的
一条。s.Publish / s.Subscribe / s.EventBus() 拿的是裸 *events.Bus,
任何插件都能绕过策略发任意事件(含伪造内核事件)。

现在闸门在 Bus.Publish/Subscribe 本体上(Guard 接口注入,避免 events
↔ security 成环),四条路径全部覆盖。sdk.EventBus() 改返回带身份的
BusHandle。内核身份免授权,但载荷照样过凭据闸门。

白名单按主体收窄:agent_output/stage/terminal_output 只允许承载者
(webui/cli/agentcli)发布 —— 它们是内核向外的输出面,谁都能发就等于
能伪造 agent 说的话。

## e2e 暴露并修掉的三个真实回归

	[bus] subscribe denied: pluginmgr type=update_request
	[bus] subscribe denied: skillmgr type=skill_detected

前两个让升级请求静默丢失、职责链交接断掉。内部协作事件必须可订阅
(安全边界在发布侧,订阅只是「听得到」)。

## 顺带的止血修复

- 回滚判据从 `--version` 改为 digest:旧内核(1.3.x 及以前)没有该
  flag,而它恰是最常见的回滚目标 —— 原逻辑让「从旧版升级失败」永久
  无法回滚。
- CopyFile 补 fsync(替换 homed 本体时掉电会留半截文件)。
- restore 完整还原才清 marker(原先先清再还原,还原失败就失去第二次
  自愈机会)。
- 回滚时移除升级中新增的 depgraph(否则「旧 homed + 新 depgraph」
  会因 digest 不匹配崩溃循环)。
- homed-upgrade 不再硬编码 systemctl restart(丢 guard 状态、unit 名
  漂移);改用内核已有的 restartCh → exit 42 语义。
- homed-upgrade 不再用 CGO_ENABLED=0 编却依赖 go-sqlite3(stub 下
  静默失效,该路径从未工作过);改用 internal/config。
- 服务单元补 User=hologramagent + 三条安装路径补建用户(此前发行物里
  完全没有落实降权)。
- rpm 的 fpm 调用补 --after-install(此前 rpm 装完根本不建用户)。

## 判据

新增 30 条:差分 8(往返/压缩比/错基线/截断/篡改/边界)、通道 8、
执行器 6、事件闸门 8、按主体授权 8。全仓 go test ./... 0 失败。
2026-10-29 09:54:55 +08:00

321 lines
12 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 events
import (
"fmt"
"log"
"sync"
)
type EventType string
const (
EventRawInput EventType = "raw_input"
EventAgentOutput EventType = "agent_output"
EventAgentLLMChain EventType = "agent_llm_chain"
EventToolCall EventType = "tool_call"
EventReasoning EventType = "reasoning"
EventStage EventType = "stage"
// EventScheduler 是输入调度器的状态变更事件(抢占/挂起/恢复),
// 供状态页与诊断订阅(设计文档 §11 O2)。
EventScheduler EventType = "scheduler"
EventSystem EventType = "system"
EventTerminalOutput EventType = "terminal_output"
// 流式增量事件(LLM token 级):核心改为流式后每收到一个增量块发布。
// 订阅者可选订;不认识的旧订阅者自然忽略(Bus 按 EventType 精确匹配分发)。
// 聚合事件 EventReasoning / EventAgentLLMChain 仍照常在每轮结束时全文发布,
// 插件体系行为不变。
EventReasoningDelta EventType = "reasoning_delta"
EventContentDelta EventType = "content_delta"
// skill_detected:clawhubadapter(OpenClaw 兼容层)扫描 skills 目录时
// 发现纯 SKILL 类型插件后发布,由原生 skillmgr 插件订阅并接管注册。
// 职责链:发现者(兼容层)→ 移交事件 → 归属者(skillmgr)加载管理。
EventSkillDetected EventType = "skill_detected"
// EventToolsReady 在**全部插件加载完成、工具已注册完毕**之后发布一次。
//
// 语义边界(关键):它只保证"工具调用可用",**不保证 agent 正在运行**。
// 订阅者若只是执行一段工具调用序列(如 seq 的开机序列),此刻即可开始;
// 它们的返回值进队列,等 agent 起来后再处理即可。
//
// 为什么不是 AgentReady:seq 自动运行**不依赖 agent** —— 它只是一段
// 调用序列。把事件绑在 agent.Start() 上会让时机被人为推后,且把
// "何时可跑序列"这个内核判断外包给编排层(main),换个入口就漂移。
//
// 发布点:kernelmod/pluginhost 在 `ks.PluginsReady = true` 处发布
// (那里正是"内置插件也已 LoadSkipping 完成"的时刻)。
//
// payload 约定:
//
// plugins: []string,已加载插件名
// plugin_count: int
EventToolsReady EventType = "tools_ready"
// EventMemoryAccess 是**图记忆读写事件**:谁读了/写了哪几个记忆块。
//
// 为什么需要它(2026-10-08):星图此前的「跟随 agent 活动」靠**工具名猜节点**
// (把 "memory_recall" 拆成词元去匹配实体名)。而图谱里的实体名是中文概念
// (小宅/对话/待命),与英文工具名永不交集 —— 实测每个工具名命中 0 个节点,
// 于是每次都回退到「按 mention_count 取前 8 个」,即每次工具调用点亮的都是
// **同一批无关节点**。
//
// 正确的做法是让**图数据库自己在读写处上报**:它本来就知道自己碰了哪些块 ID。
// payload 约定:
//
// op: "recall" | "commit" | "merge" | "purge" | "delete"
// blocks: []string,本次实际读写到的块 ID
// created: []string,本次**新建**的块 ID(驱动「从小变大」生长动画)
// removed: []string,本次**删除/合入消失**的块 ID(驱动粒子消散)
// merged: [][2]string 或 []map,合入对(驱动「两节点消失又出现」)
// tool: 触发这次访问的工具名(可空,便于 UI 显示来源)
//
// 订阅者(webui SSE)据此按**真实块 ID** 精确高亮,不再猜。
EventMemoryAccess EventType = "memory_access"
// ── 更新子系统(internal/update)的事件 ──
//
// 为什么不走"工具返回值 + WebUI 轮询":
// 升级是**长耗时且会重启进程**的动作。轮询在重启瞬间必然断,
// 而断线时用户最需要知道"升级到哪一步了/失败了/已回滚"。
// 事件总线是内核既有的发布订阅机制(同步投递、handler panic 隔离),
// 用它不需要新造一条通知通道。
// EventUpdateCheck 发布一次「更新检查」结果(内核或插件)。
//
// 语义:只读检查,不改动任何东西。WebUI 据此刷新版本徽标。
//
// payload 约定:
//
// scope: "kernel" | "plugin"
// name: 插件名(scope=plugin 时有值;kernel 时为空)
// current: 本地版本
// latest: 远端最新版本(查不到时为空)
// has_update: bool
// reason: 查不到的原因(可空)
EventUpdateCheck EventType = "update_check"
// EventUpdateProgress 发布升级过程中的一个步骤。
//
// 语义:进度通知。升级会让进程消失,所以每一步都必须**先发事件再动作** ——
// 顺序反了的话,最后几步的事件永远发不出去(进程已经没了)。
//
// payload 约定:
//
// scope: "kernel" | "plugin"
// name: 插件名(scope=plugin 时有值)
// step: "verify" | "backup" | "replace" | "restart" | "verify_version"
// | "rollback" | "done" | "failed"
// detail: 该步的人读说明
EventUpdateProgress EventType = "update_progress"
// EventUpdateRequest 是**其他插件请求更新**的入口。
//
// ## 为什么用事件而不是让各插件直接 import internal/update
//
// 请求方只需说"我要更新",不必知道怎么拉源码、怎么编译、怎么替换、
// 怎么回滚。那些是本子系统的事。用事件解耦后:
//
// · 请求方连 import 都不需要(外置插件更不可能 import 内核内部包)
// · 请求与进度都在总线上,WebUI 能显示"谁请求 → 到哪一步 → 结果如何"
// · 多个插件同时请求时天然按到达顺序处理,不会两个 git clone 抢同一工作目录
//
// payload 约定:
//
// scope: "kernel" | "plugin"
// name: 插件名(scope=plugin 时必填)
// pkg: 本机安装包路径(可选)
// release: bool,从仓库最新 Release 自动取包(可选)
EventUpdateRequest EventType = "update_request"
// EventUpdateResult 发布升级的最终结果(成功或失败,含回滚轨迹)。
//
// payload 约定:
//
// scope: "kernel" | "plugin"
// name: 插件名(scope=plugin 时有值)
// ok: bool
// from / to: 版本轨迹(回滚时 to 是回滚后的版本)
// rolled_back: bool
// error: 失败原因(成功时为空)
EventUpdateResult EventType = "update_result"
// EventSecurityReady 在**安全防火墙装配完成**后发布一次。
//
// 语义:此后 Core 与外部模块之间的数据流都经过安全闸门。
// 订阅者(状态面 / healthcheck)据此报告"审查是否真的生效",
// 而不是靠读配置猜 —— 配置开了但装配失败是另一回事。
//
// payload 约定:
//
// enabled: bool,是否真的在审查(配置关闭时 false)
// variables: int,变量库条数
EventSecurityReady EventType = "security_ready"
// EventDeviceReady 在**设备/工具数据面装配完成**后发布一次。
//
// payload 约定:
//
// tools: int,模型可见的工具数
// secured: bool,安全审查是否真的在生效
EventDeviceReady EventType = "device_ready"
EventAll EventType = "*"
)
type Event struct {
Type EventType `json:"type"`
Source string `json:"source"`
Payload map[string]interface{} `json:"payload"`
Timestamp int64 `json:"timestamp"`
}
type Handler func(event *Event)
type Bus struct {
mu sync.RWMutex
subs map[EventType][]Handler
// guard 是**总线闸门**(可 nil = 不设闸门)。
//
// ★ 放在总线上而不是适配层:在此之前,策略只存在于
// internal/sdk/events.go 那一条路径,而 s.EventBus() 和 s.Subscribe()
// 拿的是裸总线 —— 任何插件都能绕过它发任意事件(含伪造内核事件)。
//
// 现在:Publish / Subscribe 本身先问 guard,绕不过去。
guard Guard
}
func NewBus() *Bus {
return &Bus{
subs: make(map[EventType][]Handler),
}
}
// SetGuard 注入闸门(由装配方调用,通常一次)。
//
// 传 nil 等于"不设闸门"(开发/test 场景)。
func (b *Bus) SetGuard(g Guard) {
b.mu.Lock()
b.guard = g
b.mu.Unlock()
}
// Guard 返回当前闸门(可能 nil)。
func (b *Bus) Guard() Guard {
b.mu.RLock()
defer b.mu.RUnlock()
return b.guard
}
// Publish 以**内核身份**发布(等价于 PublishAs{Name: ""})。
//
// 保留这个签名是为了不让几十个内核调用点全部改签名:内核模块本来就
// 是可信主体,它们不需要授权判定。但**载荷脱敏照样执行**。
func (b *Bus) Publish(evt *Event) {
_ = b.publishAs(Publisher{}, evt)
}
// PublishAs 以指定身份发布(**插件侧必须用这个**)。
//
// 返回 error:被拒时要让调用方知道,而不是静默丢弃。
func (b *Bus) PublishAs(p Publisher, evt *Event) error {
return b.publishAs(p, evt)
}
func (b *Bus) publishAs(p Publisher, evt *Event) error {
if evt == nil {
return nil
}
b.mu.RLock()
g := b.guard
b.mu.RUnlock()
if g != nil {
// ① 授权
if allowed, reason := g.AllowPublish(p, evt.Type); !allowed {
log.Printf("[bus] publish denied: publisher=%q type=%q: %s",
p.Name, evt.Type, reason)
return &DeniedError{Publisher: p.Name, EventType: string(evt.Type), Reason: reason}
}
// ② 载荷脱敏(**内核身份也过**:内核也可能把敏感值拼进事件)
if evt.Payload != nil {
evt.Payload = g.ScrubPayload(evt.Type, evt.Payload)
}
}
b.mu.RLock()
allHandlers := make([]Handler, len(b.subs[EventAll]))
copy(allHandlers, b.subs[EventAll])
typeHandlers := make([]Handler, len(b.subs[evt.Type]))
copy(typeHandlers, b.subs[evt.Type])
b.mu.RUnlock()
for _, h := range allHandlers {
b.safeCall(h, evt)
}
for _, h := range typeHandlers {
b.safeCall(h, evt)
}
return nil
}
func (b *Bus) safeCall(h Handler, evt *Event) {
defer func() {
if r := recover(); r != nil {
log.Printf("[bus] handler panic: %v", r)
}
}()
h(evt)
}
// Subscribe 以**内核身份**订阅(内核模块用)。
func (b *Bus) Subscribe(eventType EventType, handler Handler) func() {
fn, err := b.subscribeAs(Publisher{}, eventType, handler)
if err != nil {
// 内核身份不会被拒(策略只约束插件),但保留返回值以防将来收紧
log.Printf("[bus] kernel subscribe rejected: %v", err)
}
return fn
}
// SubscribeAs 以指定身份订阅(**插件侧必须用这个**)。
func (b *Bus) SubscribeAs(p Publisher, eventType EventType, handler Handler) (func(), error) {
return b.subscribeAs(p, eventType, handler)
}
func (b *Bus) subscribeAs(p Publisher, eventType EventType, handler Handler) (func(), error) {
b.mu.RLock()
g := b.guard
b.mu.RUnlock()
// EventAll 是内部通配订阅,不做逐类型判定(只允许内核使用:
// 插件走 s.Events().Subscribe 时本来就限制了具体类型)。
if g != nil && eventType != EventAll {
if allowed, reason := g.AllowSubscribe(p, eventType); !allowed {
log.Printf("[bus] subscribe denied: publisher=%q type=%q: %s",
p.Name, eventType, reason)
return func() {}, &DeniedError{
Publisher: p.Name, EventType: string(eventType), Reason: reason, Subscribe: true,
}
}
}
b.mu.Lock()
b.subs[eventType] = append(b.subs[eventType], handler)
b.mu.Unlock()
return func() {
b.mu.Lock()
defer b.mu.Unlock()
list := b.subs[eventType]
for i, h := range list {
if fmt.Sprintf("%p", h) == fmt.Sprintf("%p", handler) {
b.subs[eventType] = append(list[:i], list[i+1:]...)
break
}
}
}, nil
}