Files
HomeAgent/internal/plugins/pluginmgr/kernel_update.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

251 lines
9.8 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.

// kernel_update 工具的 agent 侧实现。
//
// ## 三个入口共享一个底层(架构约束)
//
// agent 工具 kernel_update ┐
// WebAPI /kernel/update ├─ internal/update(CheckRelease / DoUpgrade)
// CLI homed-upgrade ┘
//
// 三份实现必然漂移("agent 说没有新版、WebUI 却升了"),所以入口层只做
// 参数解析与结果呈现。
//
// ## 为什么实现放在 pluginmgr 而不是 agent/core
//
// 1. 与 plugin_update 对称(都在 pluginmgr,用户认知一致)
// 2. agent/core 拿不到 SDK 句柄,而升级完成后要 RequestKernelRestart
// (那是 sdk.KernelControlAPI)
// 3. 与内核工具分派(toolcall.go)不同:这里走插件的 RegisterTool,
// 不需要改核心包
//
// ## 两阶段(用户口径)
//
// apply=false(默认)→ 只查 GitHub Release 与本地版本比较,不改动任何东西
// apply=true → 解包 → 校验 → 备份 → 原子替换(**不重启**)
// → 请求 guard 重拉 → 新内核启动时由 update 模块确认或回滚
//
// ★ 重启为什么不在 internal/update 里做:worker 是被守护方,只有 guard 知道
// 这次退出该不该重拉(exit 42 → 重拉且不计失败轮次)。详见
// cmd/homed/kernel_control.go。
package pluginmgr
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/update"
)
// kernelUpdateCheck 是 kernel_update 的检查半边(apply=false)。
func (p *Plugin) kernelUpdateCheck() (interface{}, error) {
cur := currentKernelVersion(p)
info := update.CheckRelease(cur)
return map[string]interface{}{
"apply": false,
"current": info.Current,
"latest": info.Latest,
"has_update": info.HasUpdate,
"asset": info.Asset,
"release": info.LatestURL,
"reason": info.Reason,
"hint": "确认后传 apply=true 执行升级(会替换二进制并重启内核,连接断开数秒)",
}, nil
}
// kernelUpdateApply 是 kernel_update 的执行半边(apply=true)。
//
// 来源二选一(update.ParseSource):
//
// · pkg 非空 → 用本机已有的安装包
// · release=true → 从仓库最新 GitHub Release 自动下载
//
// ★ 顺序(此前是反的,会出真事故):
//
// 先 Apply(备份→pending→替换),**再**请求重启。
// 反过来(先请求重启)会在新二进制还没就位时就重拉,
// 或重启恰好发生在替换中途 ⇒ 跑起来的是半新半旧的文件。
//
// ★ 重启走既有的 p.sdk.KernelControl()(restartCh → exit 42 → guard 重拉)。
// 它依赖 kernelControlAllowed 白名单 —— pluginmgr 已加入(否则该接口恒为 nil,
// 以前写在那里等于静默不重启)。
func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, error) {
src, err := update.ParseSource(pkgPath, release)
if err != nil {
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)
}
defer os.RemoveAll(tmp)
root, rel, err := src.Resolve(tmp)
if err != nil {
return map[string]interface{}{"error": "取包失败: " + err.Error()}, nil
}
newBin := update.FindFile(root, "homed")
newDG := update.FindFile(root, "depgraph.bin")
if newBin == "" {
return map[string]interface{}{"error": "包内找不到 homed"}, nil
}
// 升级每一步的事件由 internal/update 以**内核身份**发出,
// 插件不再需要(也不应该)持有事件总线。
out, err := update.Apply(root, kernelBinPath(p), newDG, kernelDataDir(p), nil)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
// 产物已就位 ⇒ 走**既有**重启通道(KernelControlAPI → restartCh → exit 42
// → guard 用新二进制重拉)。不自己 systemctl,也不另开通道。
//
// ★ 顺序关键:Apply 完成**之后**才请求重启。反过来会在新二进制还没
// 就位时重拉,或重启恰好落在替换中途。
if kc := p.sdk.KernelControl(); kc != nil {
if rerr := kc.RequestKernelRestart("kernel_update apply"); rerr != nil {
return map[string]interface{}{
"applied": true,
"error": "新内核已就位,但请求重启失败: " + rerr.Error(),
"hint": "手动重启服务即可;pending 标记会让首次启动自动确认或回滚",
}, nil
}
} else {
// 拿不到通道时**如实报告**,不要静默当成功:否则用户以为已升级,
// 而进程还在跑旧镜像;pending 标记会在下次重启时被判"未确认"→ 回滚。
return map[string]interface{}{
"applied": true,
"error": "新内核已就位,但本插件没有被授予内核控制通道(kernelControlAllowed),无法请求重启",
"hint": "手动重启服务完成升级;或改用 WebUI 的 /api/v1/kernel/update",
}, nil
}
res := map[string]interface{}{
"applied": true,
"from": out.From.Version,
"to": out.To.Version,
"backup": out.BackupDir,
"message": "新内核已就位,正在由 guard 重拉。重启后若新版本无法启动,第 0 层 update 模块会自动回滚。",
}
if rel != nil {
res["release"] = rel.Latest
res["asset"] = rel.Asset
}
return res, nil
}
// kernelDataDir 返回内核**真实**的数据目录(ks.DataDir,来自 `-data`)。
//
// ★ 取注入值,不做任何推导。此前的三条路都错过:
//
// ① 从 pluginDir 反推(Dir(Dir(...)))→ 把 /x/plugins 算成 /,
// 标记落到 /kernel-upgrade-pending.json,第 0 层永远读不到
// ② 环境变量 HOMEAGENT_DATA_DIR → 部署方未必设,设了也未必与 -data 一致
// ③ 写死生产路径 → 目录/用户迁移后指向不存在的目录
//
// 三者后果相同:标记落到内核读不到的位置 ⇒ **升级失败静默不回滚**。
//
// 而“猜一个看起来合理的路径”比返回空更危险 —— 它让 Apply 的 dataDir
// 空值守卫变成死代码。所以:拿不到就返回空串,由 Apply 拒绝升级并报错。
func kernelDataDir(p *Plugin) string {
return update.KernelDataDir()
}
// currentKernelVersion 读本机 homed 自报版本(尽力而为)。
func currentKernelVersion(p *Plugin) *update.BinVersion {
v, err := update.ReadBinVersion(kernelBinPath(p))
if err != nil {
return nil
}
return v
}
// kernelBinPath 是本机 homed 的路径。
//
// 由 pluginDir 推导(<data>/../homed 不总是成立),优先按**本进程**
// 的可执行路径:homed 与内核同二进制,这是最可靠的来源。
func kernelBinPath(p *Plugin) string {
if exe, err := os.Executable(); err == nil && filepath.Base(exe) == "homed" {
return exe
}
if _, err := os.Stat("/usr/local/bin/homed"); err == nil {
return "/usr/local/bin/homed"
}
return "homed"
}
// onUpdateRequest 处理来自其他插件的更新请求(EventBus)。
//
// 请求方不必知道"怎么更新",只需发一个事件:
//
// scope: "kernel" | "plugin"
// name: 插件名(scope=plugin 时必填)
// pkg: 本机安装包路径(可选)
// release: bool,从仓库最新 Release 自动取包(可选)
//
// 为什么必须**异步**:升级会替换二进制并(对内核)请求重启,
// 本进程随后消失。事件处理器是同步投递的(见 events.Bus.Publish),
// 在处理器里同步做完会让发布方卡住直到进程被杀。
func (p *Plugin) onUpdateRequest(evt *events.Event) {
if evt == nil || evt.Payload == nil {
return
}
scope, _ := evt.Payload["scope"].(string)
name, _ := evt.Payload["name"].(string)
go func() {
var err error
switch scope {
case "kernel":
// ★ 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")
break
}
// 复用既有 plugin_update 的 apply 通路(范围即该插件名)
_, err = p.pluginUpdate(true, name)
default:
err = fmt.Errorf("未知更新范围 %q(来源 %s)", scope, evt.Source)
}
if err != nil {
// 事件是单向的,没有返回值可取 ⇒ 失败必须留日志,
// 否则请求方看到的就是“发了、没反应”。
fmt.Fprintf(os.Stderr, "[pluginmgr] update_request (%s/%s) failed: %v\n", scope, name, err)
}
}()
}