mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
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:
@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@ -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{
|
||||
|
||||
Reference in New Issue
Block a user