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 失败。
This commit is contained in:
JianFeeeee
2026-10-29 09:54:55 +08:00
parent 7f68d2b1bf
commit ba281a855a
42 changed files with 4897 additions and 272 deletions

View File

@ -31,6 +31,7 @@ package pluginmgr
import (
"fmt"
"log"
"os"
"path/filepath"
@ -76,6 +77,25 @@ func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, e
return map[string]interface{}{"error": err.Error()}, nil
}
// ★ 安装形态闸门:先判断“能不能自己升”,再决定要不要下载 880MB。
//
// deb/rpm 形态下文件归包管理器所有,直接替换会破坏数据库一致性;
// 容器内替换毫无意义(重启即还原)。这几种都要**明确告知走哪条通道**,
// 而不是先下载再在 Apply 里报一个难懂的错误。
capab := update.Detect(kernelBinPath(p), kernelDataDir(p))
plan := update.PlanUpgrade(capab, pkgPath)
if !plan.Executable {
return map[string]interface{}{
"applied": false,
"channel": string(plan.Channel),
"install_kind": string(capab.Kind),
"variant": string(capab.Variant),
"error": plan.Reason,
"commands": plan.Commands,
"hint": "本机安装形态不支持内核自替换;请执行 commands 中的命令完成升级",
}, nil
}
tmp, err := os.MkdirTemp("", "kernel-upgrade-*")
if err != nil {
return nil, fmt.Errorf("建临时目录: %w", err)
@ -92,13 +112,9 @@ func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, e
return map[string]interface{}{"error": "包内找不到 homed"}, nil
}
// 走事件总线:升级的每一步都让 WebUI 看得到。
var bus *events.Bus
if p.sdk != nil {
bus = p.sdk.EventBus()
}
out, err := update.Apply(root, kernelBinPath(p), newDG, kernelDataDir(p), bus)
// 升级每一步的事件由 internal/update 以**内核身份**发出,
// 插件不再需要(也不应该)持有事件总线。
out, err := update.Apply(root, kernelBinPath(p), newDG, kernelDataDir(p), nil)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
@ -140,36 +156,23 @@ func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, e
return res, nil
}
// kernelDataDir 推导数据目录(升级确认标记的落点)。
// kernelDataDir 返回内核**真实**的数据目录(ks.DataDir,来自 `-data`)。
//
// 从 pluginDir 反推:<dataDir>/plugins ⇒ 上一级即 dataDir。
// 与内核 --data 的值一致,update 模块(第 0 层)才能读到同一个标记。
// ★ 取注入值,不做任何推导。此前的三条路都错过:
//
// ① 从 pluginDir 反推(Dir(Dir(...)))→ 把 /x/plugins 算成 /,
// 标记落到 /kernel-upgrade-pending.json,第 0 层永远读不到
// ② 环境变量 HOMEAGENT_DATA_DIR → 部署方未必设,设了也未必与 -data 一致
// ③ 写死生产路径 → 目录/用户迁移后指向不存在的目录
//
// 三者后果相同:标记落到内核读不到的位置 ⇒ **升级失败静默不回滚**。
//
// 而“猜一个看起来合理的路径”比返回空更危险 —— 它让 Apply 的 dataDir
// 空值守卫变成死代码。所以:拿不到就返回空串,由 Apply 拒绝升级并报错。
func kernelDataDir(p *Plugin) string {
// <dataDir>/plugins ⇒ 上一级即 dataDir。
//
// ★ 只 Dir 一次。此前写成 Dir(Dir(...)),把 /home/newqqagent/plugins
// 算成 /home —— pending 标记落到 /home/kernel-upgrade-pending.json,
// 而第 0 层 update 模块从 ks.DataDir 读 ⇒ **永远读不到标记** ⇒
// agent 发起的升级一旦启动失败就**不会回滚**(静默失效)。
// ★ 不再写死生产路径(2026-10-26 迁移教训):
// 硬编码 /home/newqqagent 在目录/用户迁移后会让 pending 标记落到一个
// **已经不存在的目录**,而第 0 层 update 从 ks.DataDir 读 ⇒ 永远读不到
// ⇒ agent 发起的升级失败时静默不回滚。
//
// 优先级:显式环境变量(部署方声明自己在哪)→ 从 pluginDir 反推 → 空。
// 空表示"不知道",由调用方拒绝执行并报错,而不是猜一个路径。
if d := os.Getenv(dataDirEnv); d != "" {
return d
}
if p.pluginDir != "" {
return filepath.Dir(p.pluginDir)
}
return ""
return update.KernelDataDir()
}
// dataDirEnv 是部署方声明数据目录的环境变量(与 webui 侧同名)。
const dataDirEnv = "HOMEAGENT_DATA_DIR"
// currentKernelVersion 读本机 homed 自报版本(尽力而为)。
func currentKernelVersion(p *Plugin) *update.BinVersion {
v, err := update.ReadBinVersion(kernelBinPath(p))
@ -211,14 +214,23 @@ func (p *Plugin) onUpdateRequest(evt *events.Event) {
}
scope, _ := evt.Payload["scope"].(string)
name, _ := evt.Payload["name"].(string)
pkgPath, _ := evt.Payload["pkg"].(string)
release, _ := evt.Payload["release"].(bool)
go func() {
var err error
switch scope {
case "kernel":
_, err = p.kernelUpdateApply(pkgPath, release)
// ★ scope=kernel **不归插件管**(第 3 项:执行权归内核)。
//
// 内核侧有专用执行器(internal/update.AttachExecutor),它:
// · 不会被 disable / auto_start:false 影响
// · 不依赖插件进程活着(插件崩了请求就丢)
// · 手里有 ks.DataDir 这个权威值
//
// 这里曾经也接 kernel(go 启动后再调 kernelUpdateApply),
// 后果是**两个执行者抢同一个请求**:两边同时写 pending、
// 互相覆盖备份目录。所以直接让位,并留下可诊断的日志。
log.Printf("[pluginmgr] update_request scope=kernel 已交给内核执行器处理(来源 %s)", evt.Source)
return
case "plugin":
if name == "" {
err = fmt.Errorf("plugin 更新请求缺 name")
@ -231,7 +243,7 @@ func (p *Plugin) onUpdateRequest(evt *events.Event) {
}
if err != nil {
// 事件是单向的,没有返回值可取 ⇒ 失败必须留日志,
// 否则请求方看到的就是"发了、没反应"。
// 否则请求方看到的就是“发了、没反应”。
fmt.Fprintf(os.Stderr, "[pluginmgr] update_request (%s/%s) failed: %v\n", scope, name, err)
}
}()

View File

@ -520,8 +520,11 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
//
// 注意:内核升级的**重启**仍走既有 KernelControlAPI(restartCh → exit 42
// → guard 重拉),本订阅只负责"受理请求 + 执行替换"。
if bus := s.EventBus(); bus != nil {
p.unsubUpdateReq = bus.Subscribe(events.EventUpdateRequest, p.onUpdateRequest)
// ★ 订阅要带**插件身份**(原来拿裸总线,可不经任何授权订阅)。
if unsub, err := s.EventBus().Subscribe(events.EventUpdateRequest, p.onUpdateRequest); err == nil {
p.unsubUpdateReq = unsub
} else {
log.Printf("[pluginmgr] subscribe update_request denied: %v", err)
}
s.RegisterTool("kernel_update", sdk.ToolDef{