merge: 差分 OTA + 安装形态分派 + 事件总线统一受安全监控 (#32)

feat(update): 差分 OTA + 安装形态分派 + 事件总线统一受安全监控
This commit is contained in:
JianFeeeee
2026-10-29 10:01:45 +08:00
committed by GitHub
42 changed files with 4897 additions and 272 deletions

3
.gitignore vendored
View File

@ -131,3 +131,6 @@ dist/
# 有意不提交:二进制大文件会永久留在 git 历史里,且需与本机 nlp 加载路径配套。
# 需要时由部署脚本/人工放置到该目录。
/nlp/
# 内核升级 CLI 的本地构建产物(与 /homed 同理,不入库)
/homed-upgrade

View File

@ -5,35 +5,35 @@
//
// ## 范围:只面向内核
//
// 只处理 homed / depgraph.bin / waiter 三个文件。**不碰插件**——插件有独立的
// 升级通道(pluginmgr),且插件与内核的兼容性由"自动禁用不兼容插件"处理
// (见 internal/plugin/registry.go 的 maybeAutoDisableIncompatible):
// 换内核后旧 SDK 编的插件会握手失败,内核把它持久禁用并写明原因,
// 用户在界面上直接看到"为什么没了",而不是翻日志。
// 只处理 homed / depgraph.bin。**不碰插件**——插件有独立的升级通道
// (pluginmgr),且与内核的兼容性由"自动禁用不兼容插件"兜底。
//
// ## 为什么默认只探测
//
// 替换二进制不可逆,且内核是这个机器上唯一常驻的关键服务。默认只做
// 探测与预检、把"会发生什么"完整报出来;`-apply` 才真动手。
// 沿同仓 homed-kb-migrate / homed-predicate-migrate 的惯例。
//
// ## 探测什么(-probe)
// ## ★ 它**不重启**服务
//
// 1. 当前运行的内核版本(从 /proc 找进程,读它自己的 --version 输出)
// 2. 新包里的内核版本
// 3. 两者比较:能否升级、是不是降级、commit 是否真的变了
// 4. depgraph.bin 与新二进制是否匹配(包内自带,必须同批替换)
// 5. 现有插件与**新**内核的兼容性预判(SDK 主版本)
// 这一条原先做错了:CLI 曾直接 `systemctl restart hologramagent`,而
// internal/update/kernel.go 的 Apply 文档专门论证过为什么不能这么做:
//
// 第 5 条是 OTA 里最容易出事的地方:内核换了、插件没换。这里提前算出来,
// 好让用户在动手前就知道"升完会有 N 个插件被自动禁用"。
// · 杀掉整个 cgroup(含 guard)—— 把监督者一起杀掉
// · 丢失 exit 42 语义:guard 的 failures / failbackRound 状态全丢
// · 发命令的进程正是被重启杀掉的进程 ⇒ 返回值不可靠
//
// 正确的重启决策在 guard 手里(只有它知道这次退出该不该重拉)。CLI 是
// 外部管理命令,它的职责到"磁盘上的产物已就绪、pending 已写"为止;
// 重启由**运行中的内核**经 KernelControlAPI 发起(restartCh → exit 42)。
//
// 若服务没在跑,连重启都不需要:下次启动自然加载新二进制并由第 0 层
// update 模块确认或回滚。
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
@ -41,23 +41,6 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/update"
)
// fail 打印错误并以非零退出(CLI 专属;库内部一律返回 error)。
// upgradeDataDir 推导数据目录(升级确认标记的落点)。
//
// 与生产一致:内核按 `--data <dir>` 运行,标记必须落在同一个 dataDir 下,
// 否则 update 模块读不到它。推导规则:
//
// ① 环境变量 HOMEAGENT_DATA_DIR(部署方显式指定)
// ② 二进制同级的 ../newqqagent(/usr/local/bin/homed → /home/newqqagent)
// —— 太脆弱,改为最常见的部署形态:/usr/local/bin/homed → /home/newqqagent
// ③ 退回 /home/newqqagent(本仓默认部署路径)
func upgradeDataDir(binPath string) string {
if d := strings.TrimSpace(os.Getenv("HOMEAGENT_DATA_DIR")); d != "" {
return d
}
return "/home/newqqagent"
}
func fail(format string, a ...interface{}) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
@ -66,17 +49,85 @@ func fail(format string, a ...interface{}) {
func main() {
pkgPath := flag.String("pkg", "", "本地安装包(.tar.gz);与 -release 二选一")
release := flag.Bool("release", false, "从 GitHub 最新 Release 下载 OTA 包(默认仅探测;配合 -apply 执行)")
binPath := flag.String("bin", "/usr/local/bin/homed", "当前运行的内核二进制路径")
pluginsDir := flag.String("plugins", "/home/newqqagent/plugins", "外部插件目录(探测兼容性用)")
binPath := flag.String("bin", "", "当前运行的内核二进制路径(默认取本进程自身;它不是 homed 时必须显式给)")
pluginsDir := flag.String("plugins", "", "外部插件目录(探测兼容性用;为空则跳过该步)")
dataDirFlag := flag.String("data", "", "内核数据目录(= 内核的 --data;不填则从 config.db 的 core.daemon.data_dir 读)")
apply := flag.Bool("apply", false, "真正执行升级(默认只探测+预检)")
unit := flag.String("unit", "homeagent", "重启用的 systemd 单元名(仅 -apply 时使用)")
// ★ 发布侧差分生成(不参与升级流程)。
//
// 为何放在这个 CLI 而不是单开一个命令:它只是把两个文件喂给
// internal/update.MakeDeltaForRelease,而那个包已经在这里了。
// 多发一个二进制意味着多一条要维护的发布链。
makeDelta := flag.Bool("make-delta", false, "生成差分补丁(发布用):-delta-old <旧homed> 与 -delta-new <新homed>")
deltaOld := flag.String("delta-old", "", "旧版本 homed 路径(上一版发布的二进制)")
deltaNew := flag.String("delta-new", "", "新版本 homed 路径")
deltaOut := flag.String("delta-out", "kernel.delta", "补丁输出路径")
flag.Parse()
if *makeDelta {
if *deltaOld == "" || *deltaNew == "" {
fmt.Fprintln(os.Stderr, "-make-delta 需要 -delta-old <旧homed> 与 -delta-new <新homed>")
os.Exit(2)
}
hdr, size, err := update.MakeDeltaForRelease(*deltaOld, *deltaNew, *deltaOut)
if err != nil {
fail("生成差分: %v", err)
}
st := hdr.Stats(size)
fmt.Printf("补丁已写出: %s\n", *deltaOut)
fmt.Printf(" 旧大小 %d 字节\n 新大小 %d 字节\n 补丁 %d 字节(%.3f%%)\n",
st.OldSize, st.NewSize, st.PatchSize, st.Ratio*100)
fmt.Printf(" 基线导入: %s\n", update.ShortSum(hdr.OldSHA256))
fmt.Printf(" 目标导入: %s\n", update.ShortSum(hdr.NewSHA256))
fmt.Printf("\n发布资产名建议: %s\n",
update.DeltaAssetName("<version>", "linux", "amd64", update.ShortCommit(hdr.OldSHA256)))
return
}
if *pkgPath == "" && !*release {
fmt.Fprintln(os.Stderr, "必须提供 -pkg <安装包.tar.gz> 或 -release")
os.Exit(2)
}
// ── 0. 探测:安装形态决定后面每一步该怎么做 ──
//
// 必须在**动任何文件之前**做:deb/rpm 形态下直接替换会破坏包管理器
// 数据库(下次 apt upgrade 覆盖回旧版,或 dpkg 拒绝后续升级)。
bin := resolveBinPath(*binPath)
if bin == "" {
// ★ 拒绝,而不是猜。同目录没有 homed 时曾经兜底到 os.Executable(),
// 于是“升级 homed”实际变成“把 CLI 自己覆盖成新包里的 homed”。
fmt.Fprintln(os.Stderr, "找不到内核二进制。")
fmt.Fprintln(os.Stderr, "本工具默认升级**与自己同目录**的 homed;找不到时请显式指定:")
fmt.Fprintln(os.Stderr, " homed-upgrade -bin /usr/bin/homed ...")
fmt.Fprintln(os.Stderr, "提示:`systemctl show hologramagent -p ExecStart` 里能看到真实路径。")
os.Exit(2)
}
dataDir := resolveDataDir(*dataDirFlag)
capab := update.Detect(bin, dataDir)
printCapabilities(capab)
if *apply {
if ch := capab.Channel(); ch != update.ChannelSelfReplace {
// ★ 不是"不支持升级",是"升级必须由别人执行"。
// 区分这两件事很重要:前者听起来像缺陷,后者是可执行的操作指引。
fmt.Printf("\n== 本机不能自替换 ==\n%s\n", capab.Reason)
if cmd := capab.UpgradeCommand(""); cmd != "" {
fmt.Printf("\n请执行:\n %s\n", cmd)
}
os.Exit(1)
}
}
if dataDir == "" {
fmt.Println("\n⚠️ 无法确定内核数据目录:升级确认标记与备份无处可放(无回滚能力)")
fmt.Println(" 请显式指定 -data <dir>(与内核 unit 的 --data 一致)")
if *apply {
os.Exit(1)
}
fmt.Println(" (探测模式继续,-apply 会被拒绝)")
}
tmp, err := os.MkdirTemp("", "homed-upgrade-*")
if err != nil {
fail("建临时目录: %v", err)
@ -93,14 +144,14 @@ func main() {
fmt.Printf("最新 Release: %s (%s)\n", info.Latest, info.Asset)
}
fmt.Printf("== 解包 %s ==\n", *pkgPath)
fmt.Printf("\n== 解包 %s ==\n", *pkgPath)
root, err := update.ExtractPkg(*pkgPath, tmp)
if err != nil {
fail("解包: %v", err)
}
// ── 1. 当前版本 ──
cur, curErr := update.ReadBinVersion(*binPath)
cur, curErr := update.ReadBinVersion(bin)
if curErr != nil {
fmt.Printf("⚠️ 读当前内核版本失败: %v\n", curErr)
cur = nil
@ -120,8 +171,7 @@ func main() {
fmt.Printf("包内内核: %s (commit %s)\n", nv.Version, update.ShortCommit(nv.Commit))
// ── 3. 版本比较 ──
verdict := update.CompareVersions(cur, nv)
fmt.Printf("\n== 结论 ==\n%s\n", verdict)
fmt.Printf("\n== 结论 ==\n%s\n", update.CompareVersions(cur, nv))
// ── 4. depgraph 必须与新二进制同批 ──
newDG := update.FindFile(root, "depgraph.bin")
@ -131,18 +181,13 @@ func main() {
fmt.Printf("depgraph.bin: 包内已带(%d 字节),将与 homed 同批替换\n", update.FileSize(newDG))
}
// ── 5. 插件兼容性预判(OTA 最容易出事的点)──
incompat := update.ProbePluginCompat(*pluginsDir, nv.SDKVersion)
if len(incompat) > 0 {
fmt.Printf("\n== 插件兼容性警告(%d 个)==\n", len(incompat))
fmt.Println("换内核后这些插件会握手失败并被**自动禁用**(写入禁用配置 + 原因):")
for _, p := range incompat {
fmt.Printf(" · %-16s SDK %s(新内核要求主版本 %s)\n", p.Name, p.SDKVersion, p.WantMajor)
}
fmt.Println("它们在界面上会显示为「已禁用」并带原因,重编插件或手动启用即可恢复。")
} else {
fmt.Println("\n== 插件兼容性 ==\n现有插件与新内核 SDK 主版本一致,无需重编。")
}
// ── 5. 插件兼容性预判 ──
//
// ★ 目录为空时**跳过并明说**,不再打印"无需重编"。
// 此前那个函数读 plugin.json 的 sdk_version 字段,而该字段根本
// 不存在(生产 32 个插件 grep 命中 0)⇒ 恒返回空 ⇒ 永远打印
// "现有插件与新内核 SDK 主版本一致"。那是假话,比不检查更糟。
reportPluginCompat(*pluginsDir, nv.SDKVersion)
if !*apply {
fmt.Println("\n(探测模式:未改动任何文件。确认无误后加 -apply)")
@ -150,49 +195,164 @@ func main() {
}
// ── 6. 真正升级:预检 → 备份 → pending → 原子替换(**不含重启**)──
out, err := update.Apply(root, *binPath, newDG, upgradeDataDir(*binPath), nil)
out, err := update.Apply(root, bin, newDG, dataDir, nil)
if err != nil {
fail("升级失败: %v", err)
}
fmt.Printf("\n新内核已就位:%s → %s\n备份保留在 %s\n", out.From.Version, out.To.Version, out.BackupDir)
// ── 7. 重启服务并核对版本 ──
// ── 7. 不再自己重启 ──
//
// ★ 为什么 CLI 可以停下服务:它**不在**被守护的 cgroup 里,是外部管理命令,
// 与"worker 自己 systemctl restart 自杀"是两件事。
// 服务在跑时优先让它走既有重启通道(guard 重拉、不计失败轮次)——
// 那需要 WebAPI 的 api_key,这里只做最朴素的外部重启,并如实报告结果。
fmt.Printf("\n== 重启服务(unit %s)==\n", *unit)
if rerr := restartUnit(*unit); rerr != nil {
fmt.Printf("⚠️ 重启失败: %v\n", rerr)
fmt.Printf(" 新内核已在磁盘上就位,pending 标记也已写入:\n")
fmt.Printf(" · 服务下次启动时会自动确认新版本;若新版本起不来,第 0 层 update 模块会自动回滚\n")
fail("需要手动重启服务才能生效")
// 说清楚接下来会发生什么,而不是替用户拍一个可能破坏 guard 状态的决定。
fmt.Printf("\n== 下一步 ==\n")
if update.SystemdActive(update.DefaultServiceUnit) {
fmt.Printf("内核正在运行,请让它走热重启通道(不丢 guard 状态):\n")
fmt.Printf(" · 重启会由内核自己发起(restartCh → exit 42 → guard 用新二进制重拉)\n")
fmt.Printf(" · 若需手工触发,用 WebUI 的「重启」按钮或 POST /api/v1/kernel/restart\n")
fmt.Printf("✅ 升级产物已就绪:%s → %s\n", out.From.Version, out.To.Version)
} else {
fmt.Printf("服务当前未运行:直接下次启动即生效(pending 标记会确认或回滚)。\n")
fmt.Printf("✅ 升级产物已就绪:%s → %s\n", out.From.Version, out.To.Version)
}
fmt.Printf("\n启动后若新内核起不来,第 0 层 update 模块会自动回滚到 %s。\n", out.From.Version)
}
// 版本复核:等二进制自报版本变成期望值(最多 60s)。
// 用版本而不是进程状态:只有版本能证明"跑起来的确实是新的"。
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
if v, verr := update.ReadBinVersion(*binPath); verr == nil &&
v.Version == nv.Version && v.Commit == nv.Commit {
fmt.Printf("\n✅ 升级完成:%s (commit %s)\n", v.Version, update.ShortCommit(v.Commit))
return
// resolveBinPath 决定“要升级的那个二进制”在哪。找不到时返回**空串**。
//
// ## ★ 为什么不兜底到 os.Executable()
//
// 这个兜底是真的会搞坏东西的(实测踩到):
//
// 本 CLI 叫 homed-upgrade,与 homed **不同名**。如果同目录没有 homed
// (比如开发机、或把 CLI 单独拷到一个空目录里跑),旧兜底会返回
// **CLI 自己的路径**,于是“升级 homed”实际变成“把 CLI 覆盖成新包里的
// homed” —— 一个 1 字节的假包就能把工具本体换成一段 shell 脚本。
//
// 静默做错比直接报错危险得多,所以:找不到就返回空,由调用方拒绝。
//
// 取值顺序:
//
// ① -bin(人工显式给,最可靠)
// ② 本 CLI 同目录的 homed(发行包里两者总是同批安装)
func resolveBinPath(explicit string) string {
if d := strings.TrimSpace(explicit); d != "" {
return d
}
if exe, err := os.Executable(); err == nil {
if resolved, err2 := filepath.EvalSymlinks(exe); err2 == nil {
exe = resolved
}
cand := filepath.Join(filepath.Dir(exe), "homed")
if update.FileExists(cand) {
return cand
}
time.Sleep(time.Second)
}
if v, verr := update.ReadBinVersion(*binPath); verr == nil {
fmt.Printf("⚠️ 版本复核未通过:期望 %s/%s,实际 %s/%s\n",
nv.Version, update.ShortCommit(nv.Commit), v.Version, update.ShortCommit(v.Commit))
}
fail("重启后未能确认新版本已生效(若服务起不来,下次启动会自动回滚)")
return ""
}
// restartUnit 重启 systemd 单元(外部管理动作)。
func restartUnit(unit string) error {
out, err := exec.Command("systemctl", "restart", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("%v (%s)", err, strings.TrimSpace(string(out)))
// resolveDataDir 返回内核**真实**的数据目录(升级标记与备份的落点)。
//
// ## ★ 不猜路径,只认两个显式来源
//
// 1. `-data <dir>`(人工直接给,最高优先)
// 2. config.db 里的 `core.daemon.data_dir`
//
// 都拿不到就返回**空串**,由调用方拒绝 -apply 并说明怎么修。
//
// ## 为什么不再自己开 sqlite
//
// 旧实现用 `sql.Open("sqlite3", ...)` 只读查配置。问题是打包脚本用
// `CGO_ENABLED=0` 编这个 CLI,而 go-sqlite3 在无 cgo 时是 **stub**:
// 查询返回 "requires cgo to work",错误被吞掉、返回空串 ⇒ 发行包里
// "自动发现 dataDir"这条路径**从未真正工作过**。
//
// 改为复用内核已有的 internal/config.ConfigRegistry:同一个打开逻辑、
// 同一套 key 语义,不需要 CGO 时也能编(它在无配置时返回默认值)。
func resolveDataDir(explicit string) string {
if d := strings.TrimSpace(explicit); d != "" {
return d
}
return nil
// 与内核一致的候选顺序:安装形态优先。
bin, err := os.Executable()
if err != nil {
return ""
}
if resolved, err2 := filepath.EvalSymlinks(bin); err2 == nil {
bin = resolved
}
candidates := []string{
filepath.Join(filepath.Dir(filepath.Dir(bin)), "var/lib/hologram"),
"/var/lib/hologram",
filepath.Join(filepath.Dir(filepath.Dir(bin)), "newqqagent"),
"/home/hologramagent",
"/home/newqqagent",
}
for _, d := range candidates {
if !update.FileExists(filepath.Join(d, "config.db")) {
continue
}
if v := update.DataDirFromConfigDB(d); v != "" {
fmt.Printf("数据目录来源: %s/config.db 的 core.daemon.data_dir = %s\n", d, v)
return v
}
}
return ""
}
func printCapabilities(c update.Capabilities) {
fmt.Printf("== 本机安装形态 ==\n")
fmt.Printf(" 形态: %s\n", c.Kind)
fmt.Printf(" 变体: %s\n", c.Variant)
fmt.Printf(" 二进制: %s\n", c.BinPath)
if c.DepGraph != "" {
fmt.Printf(" 依赖图: %s\n", c.DepGraph)
}
if c.PackageName != "" {
fmt.Printf(" 包名: %s\n", c.PackageName)
}
fmt.Printf(" 可自替换: %v\n", c.CanWriteBin)
if !c.CanWriteBin && c.Reason != "" {
fmt.Printf(" 原因: %s\n", c.Reason)
}
fmt.Printf(" 升级通道: %s\n", c.Channel())
}
// reportPluginCompat 报告插件与新内核的兼容性预判。
//
// ★ 无数据时**明确说无数据**,不打印"无需重编"。
//
// 旧实现的判据是 plugin.json 的 sdk_version 字段,而内核 manifest 结构里
// 根本没有这个字段(生产 32 个插件 grep 命中 0)⇒ ProbePluginCompat 恒
// 返回空 ⇒ CLI 永远打印"现有插件与新内核 SDK 主版本一致,无需重编"。
//
// 用户读着这句话点 -apply,升完发现一半插件被自动禁用 —— 这不是"没实现",
// 是"实现了但在撒谎"。真实数据在握手时才有(proc/process.go 的 res.SDKVersion),
// 静态预判拿不到,那就如实说拿不到。
func reportPluginCompat(dir, wantSDK string) {
if dir == "" {
fmt.Println("\n== 插件兼容性 ==\n未指定 -plugins,跳过预判(升级后内核会自动禁用不兼容的插件并写明原因)")
return
}
entries, err := os.ReadDir(dir)
if err != nil {
fmt.Printf("\n== 插件兼容性 ==\n无法读取 %s(%v):跳过预判\n", dir, err)
return
}
compat := update.ProbePluginCompat(dir, wantSDK)
if len(compat) > 0 {
fmt.Printf("\n== 插件兼容性警告(%d 个)==\n", len(compat))
fmt.Println("换内核后这些插件会握手失败并被**自动禁用**(写入禁用配置 + 原因):")
for _, p := range compat {
fmt.Printf(" · %-16s SDK %s(新内核要求主版本 %s)\n", p.Name, p.SDKVersion, p.WantMajor)
}
fmt.Println("它们在界面上会显示为「已禁用」并带原因,重编插件或手动启用即可恢复。")
return
}
// 没有可判定的数据时,说清楚原因,而不是给一个假的"全部兼容"。
fmt.Printf("\n== 插件兼容性 ==\n共 %d 个插件目录,但**无法静态预判**兼容性:\n", len(entries))
fmt.Println(" plugin.json 不含 sdk_version 字段(该字段不存在),真实 SDK 版本只在握手时上报。")
fmt.Println(" ⇒ 升级后内核会逐个握手,主版本不符的会被自动禁用并在界面上写明原因。")
fmt.Println(" ⇒ 本题结论为「未检查」,**不是**「全部兼容」。")
}
var _ = time.Now

View File

@ -4,11 +4,13 @@ import (
"context"
"log"
"path/filepath"
"strings"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod/pluginhost"
securitymod "github.com/JianFeeeee/HologramAgent/internal/kernelmod/security"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/meta"
// ★ 内置插件统一走 internal/plugins 的清单(all.go),**不要**在这里
// 单独导入某个插件:
@ -17,6 +19,7 @@ import (
// · 想增删内置插件请改 all.go。
_ "github.com/JianFeeeee/HologramAgent/internal/plugins"
"github.com/JianFeeeee/HologramAgent/internal/systemd"
updatepkg "github.com/JianFeeeee/HologramAgent/internal/update"
// 空白导入内置 provider:它们各自在 init 里注册到 pkg/embedding。
// 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main)。
@ -81,6 +84,16 @@ func main() {
agentWorkDir := ensureDataDirs(opt.dataDir)
// ★ 数据外置接线(第 4 项):告诉分词器去哪里找外置词库。
//
// 词库 12MB + ONNX 模型 47MB 是**数据**而不是代码,却 //go:embed 进了
// 二进制(88MB 里 59MB 是它们)。外置后:
// · 词库/模型可独立发版,不必重下整个二进制
// · 而 patch 版本之间这两个目录实际从未变过
//
// 必须在 GetJieba() 首次调用之前设:首次调用会初始化单例并固定目录。
memory.SetJiebaDataDir(opt.dataDir)
// ---- 配置中心(SQLite 持久化,唯一配置源) ----
//
// 必须早于 initMemoryStack:蒸馏的生成侧 provider 由配置决定
@ -89,6 +102,34 @@ func main() {
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(opt.dataDir, "config.db"))
defer cfgReg.Close()
cfgReg.SeedDefaults(opt.dataDir)
// ========================================================================
// ★ 数据目录**权威源**:数据库优先,命令行次之
//
// 用户 2026-10-28 裁定:`core.daemon.data_dir` 有值时以它为准,没有时
// 才用 `-data`。理由是这个键是**运维可改的**(WebUI 就能改),而 `-data`
// 写在 systemd 单元里、改它要碰部署配置。让可改的那个赢,才符合直觉。
//
// ## 为什么必须在 cfgReg 之后、一切重型初始化之前
//
// 它决定 logDir / cli.sock / 心跳文件 / 各存储的落点。晚一步就出现
// "日志已经写进 A、数据写进 B"的分裂。
//
// ## 为什么只做一次
//
// cfgReg 是从 <cliDataDir>/config.db 打开的,而它读出的 data_dir 可能是
// 另一个目录 —— 那个目录下**也有** config.db。若再次打开去读它,就成了
// 自我引用的无限链。所以:库只开一次,键只读一次。
// ========================================================================
if dbDir := strings.TrimSpace(cfgReg.GetString("core.daemon.data_dir", "")); dbDir != "" {
if clean := filepath.Clean(dbDir); clean != filepath.Clean(opt.dataDir) {
log.Printf("[homed] data dir: 以数据库 core.daemon.data_dir 为准 → %s(原 -data %s)", clean, opt.dataDir)
}
opt.dataDir = filepath.Clean(dbDir)
}
if opt.cliSocket == "" || !filepath.IsAbs(opt.cliSocket) {
opt.cliSocket = filepath.Join(opt.dataDir, "cli.sock")
}
// LLM 配置写前留档(config_set 写 core.llm.* 前自动快照),guard 恢复用基线
// ★ 锚点:本块必须在 SetLLMSnapshotFile / ToConfig 之前(两者都会读 core.llm.*)。
// ========================================================================
@ -197,6 +238,22 @@ func main() {
}
defer ks.CloseAll()
// ★ 挂载内核侧升级执行器(第 3 项:执行权归内核)。
//
// 为何在这里而不是在 update 模块的 Init 里:update 模块必须在第 0 层
// (回滚判定要早于一切模块),而事件总线由 kernelapi 层产出 ——
// 第 0 层时它还不存在。所以分两段:Init 做回滚与探测,此处订阅
// update_request 并接管“scope=kernel”的执行。
//
// 为何执行权必须在内核而不是插件:插件可以被 disable / auto_start:false,
// 而升级内核这件事不能依赖一个可被禁用的部件。
// ★ 注入事件总线(升级进度/结果都要发出去)。
//
// 注入后 internal/update 以内核身份发事件,**插件不再需要持有 *events.Bus**
// —— 那正是“安全监控可被绕过”的根源(拿裸总线就能伪造任意事件)。
updatepkg.SetEventBus(ks.EventBus)
defer updatepkg.AttachExecutor(ks.EventBus, ks.DataDir)()
cfg := ks.Cfg
luaVM := ks.LuaVM
sup := ks.Supervisor

View File

@ -27,6 +27,22 @@ Wants=network-online.target
# exec() 真正成功才继续,凭据目录与 ExecStartPost 的顺序才是确定的。
Type=exec
# ★ 降权运行(2026-10-28 落定)。
#
# 此前本单元**没有** User= ⇒ 以 root 运行。生产实例之所以是
# hologramagent,完全靠现场手工 drop-in —— 也就是说发行物里并没有
# 落实降权,装到新机器上就是 root。
#
# 配套要求:用户与组由包脚本(deb postinst / rpm %pre / install.sh)创建。
# 缺失时 systemd 会因 User= 不存在而拒绕启动 —— 这是**响亮失败**,
# 比默默用 root 好。
#
# ⚠️ 降权后 `Apply` 无法替换 /usr/bin/homed(属主 root、目录不可写)。
# 这不是缺陷,是设计:是否允许自替换由 update.Capabilities 判定,
# deb/rpm 形态一律走包管理器(见 internal/update/install.go)。
User=hologramagent
Group=hologramagent
# ★ guard 模式(与本机生产实例一致)。
# 数据目录默认 /var/lib/hologram(Debian 惯例);迁移到别的目录时
# 必须**同时**改这里和 core.daemon.data_dir —— 两者不等价,见 README。

View File

@ -5,6 +5,7 @@ SERVICE_NAME="hologramagent"
HOMED_BIN="/usr/bin/homed"
DATA_DIR="/var/lib/hologram"
SETUP_SH="/usr/lib/hologram/setup.sh"
SETUP_USER_SH="/usr/lib/hologram/setup-user.sh"
# unit 由本包装到 /etc/systemd/system/,而旧 postinst 只查
# /lib/systemd/system/(merged-usr 下等于 /usr/lib/systemd/system,那里没有
@ -25,6 +26,18 @@ find_unit() {
case "$1" in
configure)
if [ -f "$HOMED_BIN" ]; then
# ★ 先建用户/组:服务单元写死了 User=hologramagent。
# 缺用户时 systemd 会拒绕启动(响亮失败),但用户装完起不来同样是事故。
# 必须在 chown/mkdir 之前 —— 否则首次安装会把数据目录建成 root 所有。
if [ -x "$SETUP_USER_SH" ]; then
HOLOGRAM_DATA="$DATA_DIR" "$SETUP_USER_SH" || {
echo "E: 无法创建 hologramagent 用户 —— 服务将无法启动。" >&2
echo "E: 请手工执行:$SETUP_USER_SH" >&2
}
else
echo "W: 未找到 $SETUP_USER_SH,跳过用户创建(首次安装会因缺用户而启动失败)。" >&2
fi
mkdir -p "$DATA_DIR"
# 初始化凭据和数据库。

View File

@ -57,10 +57,14 @@ hologram-gui (desktop GUI). Connects to a remote HologramAgent server.
mkdir -p %{buildroot}%{_bindir}
mkdir -p %{buildroot}%{_unitdir}
mkdir -p %{buildroot}%{_varlibdir}
mkdir -p %{buildroot}/usr/lib/hologram
%if "%{variant}" == "full" || "%{variant}" == "server"
install -m 755 %{_sourcedir}/homed %{buildroot}%{_bindir}/homed
install -m 644 %{_sourcedir}/hologramagent.service %{buildroot}%{_unitdir}/hologramagent.service
# 服务单元写死了 User=hologramagent,用户创建是安装契约的一部分。
# 与 deb 的 postinst 共用同一份幂等 helper,不另写一套。
install -m 755 %{_sourcedir}/setup-user.sh %{buildroot}/usr/lib/hologram/setup-user.sh
%endif
# waiter 三个变体都要:server 也含 CLI(对齐 deb 的 stage_variant
@ -79,6 +83,7 @@ cp -r %{_sourcedir}/hologram-gui-linux-*/* %{buildroot}%{_datadir}/hologram-gui/
%{_bindir}/homed
%{_unitdir}/hologramagent.service
%dir %{_varlibdir}
/usr/lib/hologram/setup-user.sh
%endif
%{_bindir}/waiter
@ -90,6 +95,13 @@ cp -r %{_sourcedir}/hologram-gui-linux-*/* %{buildroot}%{_datadir}/hologram-gui/
%post
%if "%{variant}" == "full" || "%{variant}" == "server"
# ★ 先建用户/组:单元里是 User=hologramagent。
# 缺用户时 systemd 会拒绕启动(响亮失败),但装完起不来同样是事故。
# 必须在 mkdir/chown 之前 —— 否则首次安装会把数据目录建成 root 所有。
if [ -x /usr/lib/hologram/setup-user.sh ]; then
HOLOGRAM_DATA=%{_varlibdir} /usr/lib/hologram/setup-user.sh || \
echo "warning: 无法创建 hologramagent 用户,服务将无法启动" >&2
fi
mkdir -p %{_varlibdir}
%systemd_post hologramagent.service
%endif

View File

@ -0,0 +1,72 @@
#!/bin/sh
# 创建 HologramAgent 的系统用户与组(幂等)。
#
# ## 为什么需要单独一个脚本
#
# 服务单元现在写死了 `User=hologramagent`。若安装时没有这个用户,
# systemd 会在启动时报 "Failed to determine user credentials" 并拒绝启动
# —— 这是**响亮失败**,比默默用 root 好,但用户装完起不来同样是事故。
#
# 三条安装路径(deb postinst / rpm %pre / tar install.sh)都要它,
# 各写一遍必然漂移(本仓已多次踩过这个坑)。所以收敛到这一个文件,
# 由打包脚本装到 /usr/lib/hologram/ 并调用。
#
# ## 为什么优先 systemd-sysusers
#
# sysusers 是声明式、幂等的,且不会:
# · 给用户分配一个可猜测的 UID(useradd --system 也还行)
# · 在 /etc/passwd 里留下不一致的 home/shell
# 老系统没有 sysusers 时退回 useradd。
#
# ## 为什么 shell 是 nologin
#
# 服务不需要登录。留 /bin/sh 会让这个账户成为一个可用的远程登录目标
# (配合弱密码或密钥泄露)。nologin 让"有这个账户"不再是攻击面。
set -e
USER_NAME="${HOLOGRAM_USER:-hologramagent}"
GROUP_NAME="${HOLOGRAM_GROUP:-hologramagent}"
DATA_DIR="${HOLOGRAM_DATA:-/var/lib/hologram}"
if ! command -v getent >/dev/null 2>&1; then
echo "W: 找不到 getent,跳过用户创建检查" >&2
exit 0
fi
# 组
if ! getent group "$GROUP_NAME" >/dev/null 2>&1; then
if command -v groupadd >/dev/null 2>&1; then
groupadd --system "$GROUP_NAME" 2>/dev/null || true
fi
fi
# 用户
if ! getent passwd "$USER_NAME" >/dev/null 2>&1; then
if command -v systemd-sysusers >/dev/null 2>&1; then
# 声明式:重复执行无副作用
printf 'u %s - "HologramAgent service" %s %s\n' \
"$USER_NAME" "$DATA_DIR" "/usr/sbin/nologin" \
| systemd-sysusers - 2>/dev/null || true
fi
# sysusers 未生效(老系统)时退回 useradd
if ! getent passwd "$USER_NAME" >/dev/null 2>&1 && command -v useradd >/dev/null 2>&1; then
useradd --system \
--gid "$GROUP_NAME" \
--home-dir "$DATA_DIR" \
--shell /usr/sbin/nologin \
--comment "HologramAgent service" \
"$USER_NAME" 2>/dev/null || true
fi
fi
if ! getent passwd "$USER_NAME" >/dev/null 2>&1; then
echo "E: 无法创建用户 $USER_NAME —— 服务单元要求 User=$USER_NAME,将无法启动。" >&2
echo "E: 请手工创建:useradd --system --gid $GROUP_NAME --home-dir $DATA_DIR --shell /usr/sbin/nologin $USER_NAME" >&2
exit 1
fi
# 数据目录属主(首次安装时目录可能还不存在)
mkdir -p "$DATA_DIR" 2>/dev/null || true
chown -R "$USER_NAME:$GROUP_NAME" "$DATA_DIR" 2>/dev/null || true
exit 0

View File

@ -540,11 +540,19 @@ SCRIPT
stage_setup() {
local staging="$1"
local setup_src="$PROJECT_ROOT/deploy/packaging/linux/setup.sh"
local user_src="$PROJECT_ROOT/deploy/packaging/linux/setup-user.sh"
if [ -f "$setup_src" ]; then
mkdir -p "$staging/usr/lib/hologram"
cp "$setup_src" "$staging/usr/lib/hologram/setup.sh"
chmod 755 "$staging/usr/lib/hologram/setup.sh"
fi
# 服务单元以 User=hologramagent 运行;用户创建是安装契约的一部分,
# 不能只靠现场 drop-in。三种包形态共用同一份幂等 helper。
if [ -f "$user_src" ]; then
mkdir -p "$staging/usr/lib/hologram"
cp "$user_src" "$staging/usr/lib/hologram/setup-user.sh"
chmod 755 "$staging/usr/lib/hologram/setup-user.sh"
fi
}
# 项目自身的许可:**所有变体**都要带(client 也分发 waiter 与 GUI)。
@ -710,6 +718,52 @@ build_deb() {
}
# ---- create combined .tar.gz (all binaries, no variant split) ----
# ---- generate binary delta (optional) ----
#
# 差分 OTA:发布时针对**上一版已发布的 homed** 生成本版的补丁资产。
#
# 为何需要外部传入旧二进制(PREVIOUS_HOMED):上一版的产物不在本次
# 构建目录里,而“取上一版”意味着下载历史 release。那一步留给发布者
# 决定(CI 可以 gh release download 上一 tag 再传进来)。
#
# 未设置 PREVIOUS_HOMED 时**静默跳过**:首次发布、跳版本、或发布者
# 选择只发整包时都是正常情形。差分是优化,不是可用性前提。
#
# 实测量级(2026-10-28,88MB 内核):
# 改一趟逻辑的补丁 ≈ 77KB(0.09%)
build_delta() {
local previous="${PREVIOUS_HOMED:-}"
local new_bin="$BUILD_DIR/homed_linux_${ARCH}"
if [ -z "$previous" ] || [ ! -f "$previous" ]; then
echo ">>> Skip binary delta: 未提供 PREVIOUS_HOMED(上一版 homed 路径)"
return 0
fi
if [ ! -f "$new_bin" ]; then
echo ">>> Skip binary delta: 缺 $new_bin"
return 0
fi
local prev_commit
prev_commit="$("$previous" --version 2>/dev/null | sed -n 's/^commit=//p' | head -1)"
if [ -z "$prev_commit" ] || [ "$prev_commit" = "unknown" ]; then
echo ">>> Skip binary delta: 读不出上一版的 commit(老内核无 --version?)"
return 0
fi
local short="${prev_commit:0:8}"
local out="${DIST_DIR}/hologramagent-${PKG_VERSION}-linux-${ARCH}-from-${short}.delta"
echo ">>> Building binary delta (from commit $short)"
if "$BUILD_DIR/homed-upgrade" -make-delta \
-delta-old "$previous" -delta-new "$new_bin" -delta-out "$out" 2>&1; then
echo " Created: $out ($(du -h "$out" | cut -f1))"
else
# ★ 不当作失败(set -e 下靠 || true):delta 不可用不影响整包发布
echo " WARN: delta 生成失败,本次只发整包"
rm -f "$out"
fi
}
build_tar() {
local tar_dir="${DIST_DIR}/tar"
mkdir -p "$tar_dir"
@ -817,19 +871,36 @@ build_rpm() {
export RPM_CONFIGDIR="$rpmbuild_dir/usr/lib/rpm"
fi
echo ">>> Building .rpm via fpm: $pkg_name"
"$fpm_bin" -s dir -t rpm \
-n "hologramagent-${variant}" \
-v "$VERSION" \
--iteration 1 \
-a "$RPM_ARCH" \
--description "HologramAgent ${variant^} package" \
--url "https://github.com/trueagent/HologramAgent" \
--license "AGPL-3.0-only" \
-C "$staging" \
-p "$rpm_dir/$pkg_name" \
. 2>&1
echo " Created: $rpm_dir/$pkg_name"
echo ">>> Building .rpm via fpm: $pkg_name"
# ★ 必须带 --after-install(实测缺口,2026-10-28 补):
#
# 原先 fpm 调用只有 `-C "$staging"`,**没有**任何一个 script 参数 ⇒
# rpm 装完不会建 hologramagent 用户,而单元里写死了 User=hologramagent
# ⇒ systemd 拒绕启动。deb 侧有 postinst 所以一直正常,rpm 侧静默缺失。
#
# 脚本复用 deb 的 postinst:两条安装路径共用一份,不另写一套。
local postinst_src="$PACKAGE_ROOT/deb/postinst"
local fpm_args=()
if [ -f "$postinst_src" ]; then
fpm_args+=(--after-install "$postinst_src")
if [ -f "$PACKAGE_ROOT/deb/prerm" ]; then
fpm_args+=(--before-remove "$PACKAGE_ROOT/deb/prerm")
fi
fi
"$fpm_bin" -s dir -t rpm \
-n "hologramagent-${variant}" \
-v "$VERSION" \
--iteration 1 \
-a "$RPM_ARCH" \
--description "HologramAgent ${variant^} package" \
--url "https://github.com/trueagent/HologramAgent" \
--license "AGPL-3.0-only" \
"${fpm_args[@]}" \
-C "$staging" \
-p "$rpm_dir/$pkg_name" \
. 2>&1
echo " Created: $rpm_dir/$pkg_name"
}
# ---- main ----
@ -882,6 +953,12 @@ main() {
all|tar) build_tar ;;
esac
# 差分资产(可选,需 PREVIOUS_HOMED)。必须在 SHA256SUMS 计算之前 ——
# 否则 delta 不会进校验和清单,下载方无法验它。
case "$ACTION" in
all|tar|deb) build_delta ;;
esac
echo ""
echo "=== Done! Packages in: $DIST_DIR ==="
echo ""
@ -889,7 +966,11 @@ main() {
# 只列**本批**产物:dist/ 会跨多次构建累积,用 find 全目录会让清单/SHA256SUMS
# 带上历史版本的文件名——用户下载那种清单后 `sha256sum -c` 必然报缺失。
# (v1.2.2 构建时就出现过:清单里混进了 1.2.0/1.2.1 的包名。)按本批版本号过滤。
mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*${PKG_VERSION}*.deb" -o -name "hologramagent_${PKG_VERSION}_*.tar.gz" -o -name "*${PKG_VERSION}*.rpm" \) 2>/dev/null | sort)
#
# ★ 必须包含 .delta:差分资产也要进校验和清单。
# 否则下载方无法验它的完整性 —— 而 delta 是会被直接喂给 ApplyDelta 的
# 输入,未校验的补丁等于未校验的二进制。
mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*${PKG_VERSION}*.deb" -o -name "hologramagent_${PKG_VERSION}_*.tar.gz" -o -name "*${PKG_VERSION}*.rpm" -o -name "*${PKG_VERSION}*.delta" \) 2>/dev/null | sort)
for f in "${release_files[@]}"; do
echo " $(du -h "$f" | cut -f1) $f"
done

View File

@ -175,6 +175,15 @@ 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 {
@ -183,7 +192,59 @@ func NewBus() *Bus {
}
}
// 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])
@ -197,6 +258,7 @@ func (b *Bus) Publish(evt *Event) {
for _, h := range typeHandlers {
b.safeCall(h, evt)
}
return nil
}
func (b *Bus) safeCall(h Handler, evt *Event) {
@ -208,7 +270,38 @@ func (b *Bus) safeCall(h Handler, evt *Event) {
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()
@ -223,5 +316,5 @@ func (b *Bus) Subscribe(eventType EventType, handler Handler) func() {
break
}
}
}
}, nil
}

79
internal/events/guard.go Normal file
View File

@ -0,0 +1,79 @@
package events
// 事件总线的**统一监控闸门**(类型与接口部分)。
//
// ## 为什么必须做在总线本体上,而不是各适配层
//
// 在此之前,授权只存在于 `internal/sdk/events.go`(插件走 `s.Events()` 那条路)。
// 而实际有**四条**路径能发出/收到事件:
//
// ① s.Events().Publish/Subscribe ← 过策略(唯一一条)
// ② s.Publish(evt) / s.Subscribe() ← 裸总线,不过策略
// ③ s.EventBus() 直接拿 *Bus ← 裸总线,连适配层都绕过
// ④ 内核模块 bus.Publish ← 内核自身,不需要策略
//
// 于是 `EventPolicy` 只是"合规者才受约束"的一纸约定:任何插件调
// `s.EventBus().Publish(...)` 就能发任意事件(含伪造内核事件)。
//
// 现在把闸门放到 **Bus.Publish / Bus.Subscribe 本身**(见 bus.go):
// 注册一个 Guard,所有发布与订阅都先问它。适配层不再持有策略,
// 只负责把「谁在调用」告诉总线。
//
// ## 为什么用接口注入而不是直接 import security
//
// events 是最底层的广播设施,security 依赖它(要发 security_ready)。
// 直接 import 会成环。所以这里定义**最小接口**,由装配方注入实现。
//
// 这与 syncbus 的「内核可写 / 插件只读」是同一思路:类型层面表达边界。
import "fmt"
// Publisher 是事件发布/订阅的主体身份。
//
// ★ 为什么必须显式传:没有身份就无法判定"这个 plugin 能不能发这个事件"。
// 空身份 = 内核内部(内核模块自己发布时不需要授权)。
type Publisher struct {
// Name 是插件名;空串表示**内核内部**。
//
// 内核模块(update / device / pluginhost / agent)发布系统事件时
// 不填 Name —— 它们本身就是可信的执行体,且没有"插件名"这个身份。
Name string
}
// IsKernel 报告该发布者是否内核内部。
func (p Publisher) IsKernel() bool { return p.Name == "" }
// Guard 是总线闸门:判定某个主体能否发布/订阅某事件。
//
// 实现在 internal/security(策略与载荷脱敏都在那里),
// events 包只负责"每次发布/订阅都问一遍"。
type Guard interface {
// AllowPublish 判定能否发布;allowed=false 时 reason 会进日志与错误。
AllowPublish(p Publisher, eventType EventType) (allowed bool, reason string)
// AllowSubscribe 判定能否订阅。
AllowSubscribe(p Publisher, eventType EventType) (allowed bool, reason string)
// ScrubPayload 对出向载荷做脱敏(凭据闸门)。
//
// 内核内部发布者同样要过:内核也可能把敏感值拼进事件
//(例如把 api_key 放进 Stage 事件里)。
ScrubPayload(eventType EventType, payload map[string]interface{}) map[string]interface{}
}
// DeniedError 表示事件操作被闸门拒绝。
//
// ★ 为什么返回 error 而不是静默丢弃:静默丢弃会让插件以为
// "发了、没反应",那是最难排查的一类故障。
type DeniedError struct {
Publisher string
EventType string
Reason string
Subscribe bool
}
func (e *DeniedError) Error() string {
verb := "publish"
if e.Subscribe {
verb = "subscribe to"
}
return fmt.Sprintf("events: %q may not %s %q: %s", e.Publisher, verb, e.EventType, e.Reason)
}

View File

@ -0,0 +1,192 @@
package events
// 总线闸门的判据(用户 2026-10-28:「eventbus 上所有事件都要接受安全模块的监控」)。
//
// ## 这组判据要钉住什么
//
// 在此之前,授权只存在于 internal/sdk/events.go 那**一条**路径。
// 其余三条(s.Publish / s.Subscribe / s.EventBus())拿的是裸 *events.Bus,
// 任何插件都能绕过策略发任意事件 —— 包括伪造 kernel 事件。
//
// 现在闸门在 Bus.Publish / Bus.Subscribe 本体上,绕不过去。
// 所以判据的重点是「**绕过尝试必须失败**」,而不是「正常路径能跑」。
import (
"errors"
"testing"
)
// fakeGuard 是一个只放行白名单的闸门。
type fakeGuard struct {
allowPublish map[string]bool
allowSubscribe map[string]bool
scrubCalls int
}
func (g *fakeGuard) AllowPublish(p Publisher, t EventType) (bool, string) {
if p.IsKernel() {
return true, ""
}
if g.allowPublish[string(t)] {
return true, ""
}
return false, "not allowed"
}
func (g *fakeGuard) AllowSubscribe(p Publisher, t EventType) (bool, string) {
if p.IsKernel() {
return true, ""
}
if g.allowSubscribe[string(t)] {
return true, ""
}
return false, "not allowed"
}
func (g *fakeGuard) ScrubPayload(_ EventType, payload map[string]interface{}) map[string]interface{} {
g.scrubCalls++
if _, ok := payload["secret"]; ok {
out := map[string]interface{}{}
for k, v := range payload {
if k == "secret" {
out[k] = "@{redacted}"
continue
}
out[k] = v
}
return out
}
return payload
}
// ① 插件发布未授权事件必须被**拒绝**,且不送达订阅者。
//
// 这是最核心的一条:能发 raw_input / agent_output 等于能伪造内核事件。
func TestGuardRejectsUnauthorizedPublish(t *testing.T) {
bus := NewBus()
bus.SetGuard(&fakeGuard{allowPublish: map[string]bool{"skill_detected": true}})
got := 0
bus.Subscribe(EventRawInput, func(*Event) { got++ })
err := bus.PublishAs(Publisher{Name: "evil"}, &Event{Type: EventRawInput})
if err == nil {
t.Fatal("★ 未授权发布必须返回错误(静默丢弃会让插件以为发了)")
}
var de *DeniedError
if !errors.As(err, &de) {
t.Fatalf("应返回 DeniedError,实际 %T", err)
}
if got != 0 {
t.Errorf("★ 被拒事件不应送达订阅者,实际送达 %d 次", got)
}
}
// ② 授权事件正常送达,且来源是插件名。
func TestGuardAllowsAuthorizedPublish(t *testing.T) {
bus := NewBus()
bus.SetGuard(&fakeGuard{allowPublish: map[string]bool{"skill_detected": true}})
var got *Event
bus.Subscribe(EventSkillDetected, func(e *Event) { got = e })
if err := bus.PublishAs(Publisher{Name: "clawhubadapter"},
&Event{Type: EventSkillDetected, Payload: map[string]interface{}{"path": "/x"}}); err != nil {
t.Fatalf("白名单事件应放行: %v", err)
}
if got == nil {
t.Fatal("订阅者应收到事件")
}
}
// ③ 内核身份不受授权限制(内核模块发布系统事件不需要策略)。
func TestGuardAllowsKernelPublish(t *testing.T) {
bus := NewBus()
bus.SetGuard(&fakeGuard{allowPublish: map[string]bool{}})
if err := bus.PublishAs(Publisher{}, &Event{Type: EventSystem}); err != nil {
t.Fatalf("内核身份应放行: %v", err)
}
}
// ④ 内核身份**也要过载荷脱敏**。
//
// 凭据闸门与身份无关:内核把 api_key 拼进事件,和插件拼进去一样危险。
func TestGuardScrubsKernelPayload(t *testing.T) {
bus := NewBus()
g := &fakeGuard{allowPublish: map[string]bool{}}
bus.SetGuard(g)
var got *Event
bus.Subscribe(EventSystem, func(e *Event) { got = e })
bus.Publish(&Event{Type: EventSystem, Payload: map[string]interface{}{"secret": "sk-123"}})
if got == nil {
t.Fatal("应收到事件")
}
if got.Payload["secret"] != "@{redacted}" {
t.Errorf("★ 内核载荷也必须脱敏,实际 %v", got.Payload["secret"])
}
if g.scrubCalls == 0 {
t.Error("ScrubPayload 应被调用")
}
}
// ⑤ 未授权订阅必须被拒。
//
// 若能订阅 raw_input,插件就能读到用户的全部输入。
func TestGuardRejectsUnauthorizedSubscribe(t *testing.T) {
bus := NewBus()
bus.SetGuard(&fakeGuard{allowSubscribe: map[string]bool{"tools_ready": true}})
if _, err := bus.SubscribeAs(Publisher{Name: "evil"}, EventRawInput, func(*Event) {}); err == nil {
t.Fatal("★ 未授权订阅必须报错")
}
// 白名单内的可订阅
if _, err := bus.SubscribeAs(Publisher{Name: "p1"}, EventToolsReady, func(*Event) {}); err != nil {
t.Errorf("白名单订阅应放行: %v", err)
}
}
// ⑥ 没有闸门时行为不变(开发/测试场景,且保证既有部署不被意外破坏)。
func TestGuardAbsentBehavesAsBefore(t *testing.T) {
bus := NewBus()
got := 0
bus.Subscribe(EventRawInput, func(*Event) { got++ })
if err := bus.PublishAs(Publisher{Name: "any"}, &Event{Type: EventRawInput}); err != nil {
t.Fatalf("无闸门时不应报错: %v", err)
}
if got != 1 {
t.Errorf("无闸门时应正常送达,实际 %d", got)
}
}
// ⑦ 闸门可以被后装(启动顺序:总线先建、security 后装配)。
//
// 这条钉住真实的装配顺序:kernelapi 创建 Bus,security 模块在更晚的层
// 才注入 Guard。先发的 security_ready 事件不受影响,之后全部受控。
func TestGuardCanBeInstalledLater(t *testing.T) {
bus := NewBus()
// 先无闸门发一条
if err := bus.PublishAs(Publisher{Name: "p"}, &Event{Type: EventSystem}); err != nil {
t.Fatal(err)
}
// 装闸门后必须生效
bus.SetGuard(&fakeGuard{allowPublish: map[string]bool{}})
if err := bus.PublishAs(Publisher{Name: "p"}, &Event{Type: EventSystem}); err == nil {
t.Fatal("★ 后装闸门必须立即生效")
}
}
// ⑧ 未授权发布不得污染 EventAll 通配订阅。
func TestGuardDeniesBeforeWildcardHandlers(t *testing.T) {
bus := NewBus()
bus.SetGuard(&fakeGuard{allowPublish: map[string]bool{}})
got := 0
bus.Subscribe(EventAll, func(*Event) { got++ })
_ = bus.PublishAs(Publisher{Name: "evil"}, &Event{Type: EventAgentOutput})
if got != 0 {
t.Errorf("★ 被拒事件不得送到 EventAll 订阅者,实际 %d", got)
}
}

View File

@ -185,6 +185,33 @@ type KernelState struct {
// 读它时都已就绪 —— 包括"启动失败回滚"的判定结果。
Update UpdateStateProvider
// UpdateCapabilities 是本机**升级能力**的快照(update 模块产出)。
//
// ## 为什么它是一个独立字段而不是塞进 Update.State
//
// State 回答"我这台机器现在是什么版本、上次回滚过没有";
// Capabilities 回答"我这台机器**能不能**自己升级"。
// 前者随时间变,后者在进程生命周期内恒定(安装形态不变)。
//
// 消费者不同:
// · WebUI / healthcheck 展示 State(版本、回滚轨迹)
// · kernel_update 工具 / CLI 决策看 Capabilities(走哪条升级通道)
//
// 合并成一个结构会让“只关心能不能升级”的调用方也拿到一堆版本字段,
// 反之亦然——那类混搭在这仓已经出过问题(healthcheck 把工具表塞进快照)。
//
// ★ 用 interface{} 而不是定义接口(与 PluginCatalog 同一范式)。
//
// 尝试过定义 UpdateCapabilitiesProvider 接口,但它要求方法名
// Variant()/InstallKind() —— 而 Capabilities 的**字段**就叫 Variant,
// 方法名与字段名在同一个类型上不能共存(编译错误)。
// 改方法名(VariantName())只会让接口变丑而无收益:读取方本来
// 就要完整结构体(WebUI 要 bin_path / reason / package_name)。
//
// 所以:与 ks.PluginCatalog 一致,存 any,读取方断言回
// *update.Capabilities。代价是一次类型断言,收益是零重复定义。
UpdateCapabilities any
// StageHost 是阶段管道(stage-host 模块产出)。
StageHost *agentCore.StageHost

View File

@ -63,6 +63,24 @@ func (Module) Init(ks *kernelboot.KernelState) error {
return err
}
ks.Security = rt
// ★ 把安全策略注入**总线本体**(用户 2026-10-28 指正:「eventbus 上所有
// 事件都要接受安全模块的监控」)。
//
// 为何必须在总线上而不是只在 internal/sdk/events.go:
// 后者只盖住了 s.Events() 那**一条**路径,而插件还有 s.Publish /
// s.Subscribe / s.EventBus() 三条能拿裸总线的路。不装在这里,
// “安全监控”就只对合规者生效。
//
// ★ rt 为 nil 或未 enable 时**仍然装**:enable=false 关掉的是凭据替换,
// “插件不许发 raw_input”这类授权与密钥无关(见 EventPolicy 头注释)。
// 不装就等于默认放行全部事件,方向错误。
if ks.EventBus != nil {
if g := sec.AsEventGuard(rt); g != nil {
ks.EventBus.SetGuard(g)
}
}
if ks.EventBus != nil && rt.Enabled() {
ks.EventBus.Publish(secReadyEvent(rt))
}

View File

@ -42,6 +42,15 @@ func (Module) Provides() []string { return []string{FacilityVersion} }
// Init 执行版本自检,必要时回滚上一次升级。
func (Module) Init(ks *kernelboot.KernelState) error {
// ⓪ 把内核**真实**的 dataDir 注入 update 包。
//
// 必须最先做:pending 标记/备份/回滚验全都靠它。进程内调用方
// (pluginmgr / webui)用 upd.KernelDataDir() 取值,而不是自己推导。
//
// 推导必错(实测三类):从 exe 推 → /usr/bin/data(只读,备份必挂);
// 硬编码 → 迁移后指向不存在的目录;读 env → 未必与 -data 一致。
upd.SetKernelDataDir(ks.DataDir)
st := &upd.State{}
ks.Update = st
@ -89,6 +98,22 @@ func (Module) Init(ks *kernelboot.KernelState) error {
st.DepGraphPath = dg
}
// ⑤ 安装形态与升级能力**探测**(不改任何状态,只产出可读快照)。
//
// 为何放在第 0 层:它就是第 0 层自己的事实——“我能怎么升”。
// 而且它要读 dpkg/rpm 数据库与目录权限,这些在启动时只跑一次。
//
// ★ 探测失败不能阻断启动:它只是诊断信息。拿不到就报 Unknown,
// Apply 会因 Unknown 拒绝自替换(失败方向是收紧)。
capab := upd.Detect(bin, ks.DataDir)
ks.UpdateCapabilities = &capab
log.Printf("[update] install kind=%s variant=%s channel=%s can_replace=%v",
capab.Kind, capab.Variant, capab.Channel(), capab.CanWriteBin)
if !capab.CanWriteBin {
// 不阻断启动,但必须响亮:否则用户以为“点一下就能升级”。
log.Printf("[update] 本机不支持自替换升级:%s", capab.Reason)
}
log.Printf("[update] subsystem ready (version=%s rolled_back=%v)", st.Version, st.RolledBack)
return nil
}

View File

@ -42,8 +42,34 @@ var contentPOS = map[string]bool{
var (
jiebaOnce sync.Once
jiebaInst *gojieba.Jieba
// jiebaDataDir 是内核数据目录(供外置词库查找,第 4 项)。
//
// 为何用包级变量而不是给 GetJieba() 加参数:它是**全局单例**
// (jiebaOnce),调用方散布在 memory/indexer/knowledge 多处,
// 加参数会让每个调用点都要传一遍 dataDir,而那些地方未必拿得到。
//
// 未设置时为空 ⇒ 只查系统目录与内嵌词库(与旧行为一致)。
jiebaDataDirMu sync.RWMutex
jiebaDataDir string
)
// SetJiebaDataDir 由内核启动时注入数据目录(供外置词库覆盖)。
//
// **必须在 GetJieba() 首次调用之前调**:首次调用会初始化单例并
// 固定词库目录,之后再改不会生效。
func SetJiebaDataDir(dir string) {
jiebaDataDirMu.Lock()
defer jiebaDataDirMu.Unlock()
jiebaDataDir = dir
}
func jiebaDataDirValue() string {
jiebaDataDirMu.RLock()
defer jiebaDataDirMu.RUnlock()
return jiebaDataDir
}
func GetJieba() *gojieba.Jieba {
jiebaOnce.Do(func() {
defer func() {
@ -68,11 +94,11 @@ func GetJieba() *gojieba.Jieba {
}
func jiebaDictDir() string {
// 首选内嵌词库:它是产物的一部分,与二进制同版本、不依赖宿主环境。
// 首选外置词库(DataDir 与系统目录)→ 退回内嵌词库落盘。
//
// 以前这里只猜 GOMODCACHE/GOPATH/~/go/pkg/mod,部署机上通常没有 Go 模块缓存,
// 于是分词与关键词提取会**静默退回空列表**(详见 jieba_embed.go 的说明)。
if dir, err := materializeJiebaDict(); err == nil && dir != "" {
if dir, err := materializeJiebaDict(jiebaDataDirValue()); err == nil && dir != "" {
return dir
}
log.Printf("[jieba] writing embedded dictionary failed, falling back to module cache lookup (embedded failure usually means cache dir is not writable)")

View File

@ -45,16 +45,41 @@ var jiebaDictFiles = []string{
"stop_words.utf8",
}
// materializeJiebaDict 把内嵌词库落盘,返回目录路径。
// materializeJiebaDict 返回可用的词库目录(gojieba 只接文件路径)。
//
// gojieba 的 C++ API 只接受**文件路径**(NewJieba 会对每个路径 os.Stat,
// 缺失就 panic),所以必须先落盘再传路径。
// ## 取值顺序(第 4 项:数据外置)
//
// 落盘位置与幂等性:
// - 用内容哈希命名目录:词库升级后不会复用旧文件(否则会出现「新旧词库混用」
// 这种最难查的一类问题——分词结果与版本对不上)。
// - 已存在且大小一致就跳过写入:正常启动只做几次 stat。
func materializeJiebaDict() (string, error) {
// ① 部署目录 <DataDir>/jiebadict/(5 个文件齐备且非空)
// ② 系统目录 /usr/share/hologram/jiebadict/
// ③ 内嵌词库落盘(向下兼容:旧部署里二进制内就有)
//
// ## 为何要允许外置(这是 OTA 体积问题的根)
//
// 词库 12MB 是**数据**,不是代码。它 #go:embed 进二进制后:
//
// · 二进制 88MB 里有 59MB 是模型+词库,改一行代码要重下 88MB
// · 而 patch 版本之间这两个目录**从未变过**(实测 v1.3.8→v1.3.13
// 的 git log 里零提交)
//
// 外置后:词库可以独立发版,二进制只剩代码;掉电风险也不受影响
// (词库坏了只是分词变差,不阻断启动)。
//
// ★ 外置目录只要**文件齐备且非空**就用,不做哈希校验:
// 词库是数据不是可执行代码,装错版本最多分词效果差;而哈希校验会让
// “用户手工换了一份词库”直接不可用,那是不必要的刚性。
func materializeJiebaDict(dataDirs ...string) (string, error) {
// ① ② 部署目录优先(带 DataDir 的调用方由 cut.go 传入)
for _, d := range append(dataDirs, "/usr/share/hologram") {
if d == "" {
continue
}
cand := filepath.Join(d, "jiebadict")
if externalJiebaDictComplete(cand) {
return cand, nil
}
}
// ③ 退回内嵌(保持旧行为,不破坏任何现有部署)
sum, err := jiebaDictDigest()
if err != nil {
return "", err
@ -89,6 +114,20 @@ func materializeJiebaDict() (string, error) {
return dir, nil
}
// externalJiebaDictComplete 判断外部目录里的 5 个词库是否齐备且非空。
//
// ★ 只查“存在且非空”,不跟内嵌版本比大小:外置的意义就是允许
// 词库独立升级,拿内嵌版本当基准会让升级永远不生效。
func externalJiebaDictComplete(dir string) bool {
for _, name := range jiebaDictFiles {
st, err := os.Stat(filepath.Join(dir, name))
if err != nil || st.IsDir() || st.Size() == 0 {
return false
}
}
return true
}
// jiebaDictDigest 对全部内嵌词库内容求哈希,作为落盘目录名的一部分。
func jiebaDictDigest() (string, error) {
h := sha256.New()

View File

@ -0,0 +1,102 @@
package memory
// 外置词库的判据(第 4 项:数据外置)。
//
// 这一项的直接收益是 OTA 体积:词库 12MB 与 ONNX 模型 47MB 是数据而非代码,
// 却 #go:embed 进了 88MB 的二进制;而 patch 版本之间它们从未变过。
import (
"os"
"path/filepath"
"testing"
)
// ① 外部目录齐备时**优先**用它(而不是内嵌落盘)。
func TestMaterializeJiebaDict_PrefersExternal(t *testing.T) {
dataDir := t.TempDir()
dictDir := filepath.Join(dataDir, "jiebadict")
if err := os.MkdirAll(dictDir, 0755); err != nil {
t.Fatal(err)
}
// 造 5 个非空文件(内容不重要:外置不做哈希校验)
for _, name := range jiebaDictFiles {
if err := os.WriteFile(filepath.Join(dictDir, name), []byte("external-"+name), 0644); err != nil {
t.Fatal(err)
}
}
got, err := materializeJiebaDict(dataDir)
if err != nil {
t.Fatalf("不应失败: %v", err)
}
if got != dictDir {
t.Errorf("★ 应优先使用外置目录 %s,实际 %s", dictDir, got)
}
}
// ② 外置目录**缺一个文件就不算齐备**,必须退回内嵌。
//
// 判据重点:半套词库会让分词结果不可预测(缺 stop_words 会把停用词
// 当成普通词),比"整份退回内嵌"更糟。
func TestMaterializeJiebaDict_IncompleteExternalFallsBack(t *testing.T) {
dataDir := t.TempDir()
dictDir := filepath.Join(dataDir, "jiebadict")
if err := os.MkdirAll(dictDir, 0755); err != nil {
t.Fatal(err)
}
// 只放 4 个(少一个)
for i, name := range jiebaDictFiles {
if i == 0 {
continue
}
if err := os.WriteFile(filepath.Join(dictDir, name), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
got, err := materializeJiebaDict(dataDir)
if err != nil {
t.Fatalf("退回内嵌不应失败: %v", err)
}
if got == dictDir {
t.Error("★ 缺文件的外置目录不得被采用")
}
}
// ③ 空文件等同缺失(磁盘写坏的典型形态)。
func TestExternalJiebaDictComplete_RejectsEmptyFile(t *testing.T) {
dir := t.TempDir()
for _, name := range jiebaDictFiles {
if err := os.WriteFile(filepath.Join(dir, name), []byte{}, 0644); err != nil {
t.Fatal(err)
}
}
if externalJiebaDictComplete(dir) {
t.Error("★ 全零字节的词库不得被判为齐备")
}
// 填上内容后应变齐备
for _, name := range jiebaDictFiles {
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
if !externalJiebaDictComplete(dir) {
t.Error("非空词库应判为齐备")
}
}
// ④ SetJiebaDataDir 的读写不产生数据竞争(配合 -race)。
func TestSetJiebaDataDir_RaceFree(t *testing.T) {
defer SetJiebaDataDir("")
done := make(chan struct{})
go func() {
for i := 0; i < 100; i++ {
SetJiebaDataDir("/tmp/x")
}
close(done)
}()
for i := 0; i < 100; i++ {
_ = jiebaDataDirValue()
}
<-done
}

View File

@ -298,8 +298,34 @@ func extractEmbeddedModel(dataDir string) (string, error) {
dataDir = filepath.Join(os.TempDir(), "homeagent-nlp")
}
os.MkdirAll(dataDir, 0755)
dst := filepath.Join(dataDir, "dep_parser.onnx")
if _, err := os.Stat(dst); err == nil {
// ★ 外置模型优先(第 4 项:数据外置)。
//
// 模型 47MB 是**数据**不是代码,却 #go:embed 进了 88MB 的二进制:
// 改一行代码要重下 47MB,而 patch 版本之间它从未变过。
//
// 查找顺序(只认非空文件):
// ① <DataDir>/nlp/dep_parser.onnx(部署目录,与旧行为兼容:
// 旧版首次启动会把内嵌模型解到这里)
// ② /usr/share/hologram/models/dep_parser.onnx(系统目录)
// ③ 内嵌解出
//
// ★ 不做哈希校验:模型是数据,装错版本最多依存分析变差,
// 不阻断启动。哈希会让“用户手工换模型”直接不可用,那是不必要的刚性。
for _, cand := range []string{
filepath.Join(dataDir, "nlp", "dep_parser.onnx"),
"/usr/share/hologram/models/dep_parser.onnx",
} {
if st, err := os.Stat(cand); err == nil && !st.IsDir() && st.Size() > 0 {
return cand, nil
}
}
// ③ 退回内嵌(保持旧行为:首次启动解到 dataDir/nlp/)
nlpDir := filepath.Join(dataDir, "nlp")
os.MkdirAll(nlpDir, 0755)
dst := filepath.Join(nlpDir, "dep_parser.onnx")
if st, err := os.Stat(dst); err == nil && st.Size() > 0 {
return dst, nil
}
data, err := onnxModelFS.ReadFile("models/dep_parser.onnx")

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{

View File

@ -33,7 +33,6 @@ import (
"net/http"
"os"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/update"
)
@ -70,6 +69,30 @@ func (h *Handler) handleKernelUpdateApply(w http.ResponseWriter, r *http.Request
}
binPath := update.KernelBinaryPath()
dataDir := dataDirOf(binPath)
// ★ 安装形态闸门:先判断“本机能不能自己升”,再决定要不要下载 880MB。
//
// deb/rpm 形态下文件归包管理器所有,直接替换会让 dpkg/rpm 数据库与
// 实际内容不一致(下次 apt upgrade 覆盖回旧版,或 dpkg 拒绝后续升级)。
// 此时必须明确告知走包管理器,**而不是**先下载再在 Apply 里失败。
//
// 容器同理:文件系统随销毁还原,自替换毫无意义。
capab := update.Detect(binPath, dataDir)
plan := update.PlanUpgrade(capab, body.Pkg)
if !plan.Executable {
// ★ 用 409(Conflict)而不是 500:这不是“服务器坏了”,
// 而是“这个请求在本机形态下不适用”,并且带着可执行指引。
writeJSON(w, http.StatusConflict, map[string]interface{}{
"applied": false,
"channel": string(plan.Channel),
"install_kind": string(capab.Kind),
"variant": string(capab.Variant),
"error": plan.Reason,
"commands": plan.Commands,
})
return
}
tmp, err := os.MkdirTemp("", "kernel-upgrade-*")
if err != nil {
@ -85,13 +108,13 @@ func (h *Handler) handleKernelUpdateApply(w http.ResponseWriter, r *http.Request
}
newDG := update.FindFile(root, "depgraph.bin")
// WebUI 侧天然有总线(它就是 SSE 的上游):升级每一步都让界面看得到
var bus *events.Bus
if h.sdk != nil {
bus = h.sdk.EventBus()
}
out, err := update.Apply(root, binPath, newDG, dataDirOf(binPath), bus)
// ★ 不再自己取总线:进度事件由 internal/update 以**内核身份**发出
// (内核在启动时 SetEventBus 注入了总线)。
//
// 之前这里从 sdk 拿 *events.Bus 再传给 Apply —— 那既是"安全监控可被
// 绕过"的根源(插件持有裸总线就能伪造任意事件),也让插件依赖了
// 一个它本不该有的能力。
out, err := update.Apply(root, binPath, newDG, dataDir, nil)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
@ -140,15 +163,16 @@ func currentKernelVersion() *update.BinVersion {
return v
}
// dataDirOf 推导数据目录(升级确认标记的落点)。
// dataDirOf 返回内核**真实**的数据目录(升级确认标记的落点)。
//
// 与内核 `--data` 必须一致,否则第 0 层 update 模块读不到 pending 标记。
// 优先用环境变量显式指定(部署方知道自己在哪),否则退回本机生产路径。
// ★ 取内核注入值(ks.DataDir),不做任何推导:pending 标记落在哪,
// 第 0 层 update 模块就从哪读;两边一漂移,升级失败时就**静默不回滚**。
// 而“猜一个看起来合理的路径”会让 Apply 的空值守卫变成死代码。
//
// 拿不到就返回空串,由 Apply 拒绝升级并报错。
//
// ⚠️ 不要再加 HOMEAGENT_DATA_DIR 回退:部署方可能不设,而设了也未必
// 与内核实际的 -data 一致 —— 那正是“看起来在工作”的一类错误。
func dataDirOf(binPath string) string {
if d := os.Getenv("HOMEAGENT_DATA_DIR"); d != "" {
return d
}
// ★ 不写死生产路径:迁移后它会指向一个不存在的目录,
// 让升级 pending 标记落错地方(静默不回滚)。返回空由调用方报错。
return ""
return update.KernelDataDir()
}

View File

@ -70,17 +70,33 @@ func TestEventsPublishOwnNamespace(t *testing.T) {
}
}
// ④ 订阅只开只读事件;越界订阅报错。
// ④ 订阅只开白名单事件;越界订阅报错。
//
// ★ 2026-10-28 语义变更:raw_input / agent_output 改为可订阅(与
// security.EventPolicy 同步)。理由是它们是**通道出口的必需品**:
// WebUI/CLI 的对话界面靠订阅它们渲染用户消息与 agent 回复;
// 不开就等于“装监控把界面打瘫”。订阅是只读观测,真正需要收紧的是**发布**。
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)
for _, ok := range []string{
"tools_ready", "security_ready", "device_ready",
"raw_input", "agent_output",
"tool_call", "stage", "reasoning", "terminal_output",
} {
if _, err := e.Subscribe(ok, func(map[string]interface{}) {}); err != nil {
t.Errorf("%q 应可订阅: %v", ok, err)
}
}
// ★ update_request 可订阅(e2e 实测:内核执行器靠它收请求,
// 不开会让升级请求静默丢失)。安全边界在**发布**侧。
if _, err := e.Subscribe("update_request", func(map[string]interface{}) {}); err != nil {
t.Errorf("update_request 应可订阅: %v", err)
}
// 完全不存在的事件名也要拒
if _, err := e.Subscribe("no_such_event", func(map[string]interface{}) {}); err == nil {
t.Error("订阅未登记事件应被拒绝")
}
// 解除订阅后不再收到
cancel, err := e.Subscribe("security_ready", func(map[string]interface{}) {})
if err != nil || cancel == nil {

View File

@ -170,6 +170,13 @@ type PluginManager interface {
type PluginSDK struct {
*pubsdk.PluginSDK
// name 是本插件的名字(同时是事件发布者身份)。
//
// ★ 为何必须存下来:总线闸门要判断“这个插件能不能发这个事件”,
// 而之前三条路径(Publish / Subscribe / EventBus)都不带身份 ——
// 不带身份会被当成内核内部而一律放行,等于给所有插件开了伪造后门。
name string
settings SettingsAPI
memory MemoryAPI
textMem TextMemoryAPI
@ -381,6 +388,7 @@ func New(name string, cfg SDKConfig) *PluginSDK {
}
return &PluginSDK{
PluginSDK: base,
name: name,
settings: cfg.Settings,
memory: cfg.Memory,
textMem: cfg.TextMemory,
@ -506,25 +514,77 @@ func (s *PluginSDK) ListChannels() []agentIO.ChannelInfo {
return nil
}
// Publish 以**本插件身份**发布事件。
//
// ★ 必须带身份(pluginName):总线的闸门要判断“这个插件能不能发这个
// 事件”。不带身份会被当成内核内部而一律放行 —— 那等于给所有插件
// 开了伪造系统事件的后门。
func (s *PluginSDK) Publish(evt *events.Event) {
if s.eventBus != nil {
s.eventBus.Publish(evt)
if s.eventBus == nil {
return
}
if err := s.eventBus.PublishAs(events.Publisher{Name: s.name}, evt); err != nil {
// 被拒时不能静默:插件否则以为“发了、没反应”
log.Printf("[sdk] %s publish %q denied: %v", s.name, evt.Type, err)
}
}
// EventBus 返回内核事件总线(nil 表示该部署未接总线)。
//
// ★ 为什么需要 getter 而不只用 Publish:有些能力要**把总线本身传下去**,
// 例如 update 子系统的升级闭环(internal/update.DoUpgradeBus)——
// 它在替换二进制、重启进程的长过程中持续发进度事件。
// 只给 Publish 的话,那类长流程就得在每个调用点手动转发一遍。
func (s *PluginSDK) EventBus() *events.Bus { return s.eventBus }
// ## ★ 为什么它仍然返回裸总线(但不再是一个漏洞)
//
// 有些能力要**把总线本身传下去**(例如升级闭环在替换二进制期间持续
// 发进度事件)。但裸总线本身不再能绕过策略:
//
// 闸门挂在 Bus.Publish / Bus.Subscribe 上(见 internal/events/bus.go)。
// 拿到 *Bus 的调用方直接 Publish,仍然会被当作**内核身份**吗?
// 不会——因为他们拿到的是同一个 *Bus,而 Bus.Publish 用空身份。
//
// ★★ 所以这里返回的是一个**带身份的视图**:订阅/发布都绑定本插件名。
//
// 之前返回 *events.Bus 的后果是任何插件都能调 bus.Publish(伪造事件)
// 而不经任何授权 —— 那正是“安全监控可被绕过”的根源。
type BusHandle struct {
bus *events.Bus
name string
}
func (s *PluginSDK) Subscribe(eventType events.EventType, handler events.Handler) func() {
if s.eventBus != nil {
return s.eventBus.Subscribe(eventType, handler)
// Publish 以插件身份发布。
func (h BusHandle) Publish(evt *events.Event) error {
if h.bus == nil {
return nil
}
return func() {}
return h.bus.PublishAs(events.Publisher{Name: h.name}, evt)
}
// Subscribe 以插件身份订阅(被拒时返回 error)。
func (h BusHandle) Subscribe(eventType events.EventType, handler events.Handler) (func(), error) {
if h.bus == nil {
return func() {}, nil
}
return h.bus.SubscribeAs(events.Publisher{Name: h.name}, eventType, handler)
}
// EventBus 返回**带本插件身份**的总线视图。
//
// 拿到它也不能绕过闸门:Publish/Subscribe 都会带上插件名去过策略。
func (s *PluginSDK) EventBus() BusHandle { return BusHandle{bus: s.eventBus, name: s.name} }
// Subscribe 以**本插件身份**订阅事件。
//
// ★ 之前这里调的是裸 Bus.Subscribe —— 不查策略、不报错。
// 现在走 SubscribeAs,被拒时会记日志(不能用 error:旧调用点习惯
// 只拿一个 unsub 函数,改签名会波及几十处)。
func (s *PluginSDK) Subscribe(eventType events.EventType, handler events.Handler) func() {
if s.eventBus == nil {
return func() {}
}
unsub, err := s.eventBus.SubscribeAs(events.Publisher{Name: s.name}, eventType, handler)
if err != nil {
log.Printf("[sdk] %s subscribe %q denied: %v", s.name, eventType, err)
return func() {}
}
return unsub
}
// SetToolBlocks 桥接到 IOManager:插件工具注入多模态块,process.go 消费。

View File

@ -0,0 +1,91 @@
package security
// EventPolicy 作为**总线闸门**的实现(events.Guard)。
//
// ## 为什么这一层必须存在(用户 2026-10-28 的指正)
//
// 「eventbus 上所有事件都要接受安全模块的监控。」
//
// 在此之前,EventPolicy 只被 internal/sdk/events.go 调用 —— 而那是**四条**
// 事件路径中的**一条**。其余三条(s.Publish / s.Subscribe / s.EventBus())
// 拿的是裸 *events.Bus,策略完全不参与。于是「安全监控」只对合规者生效。
//
// 现在闸门挂在总线本体上(见 internal/events/bus.go 的 publishAs/subscribeAs),
// 本文件把 security 的策略与脱敏接上去。
//
// ## 分工(与既有设计一致)
//
// internal/security 策略 + 载荷过闸(**本文件**)
// internal/events 总线:每次发布/订阅都问一遍 Guard
// internal/sdk 适配:把「谁在调用」翻译成 events.Publisher
// sdk(公开) 只给接口与事件名
import (
"github.com/JianFeeeee/HologramAgent/internal/events"
)
// eventGuard 把 Runtime 的策略适配成 events.Guard。
type eventGuard struct {
rt *Runtime
}
// AsEventGuard 返回总线闸门实现。
//
// rt 为 nil 时返回 nil(调用方据此不装闸门)。**不**返回一个
// "永远放行"的假实现 —— 那会让"安全未装配"看起来像"安全已通过"。
func AsEventGuard(rt *Runtime) events.Guard {
if rt == nil {
return nil
}
return &eventGuard{rt: rt}
}
// AllowPublish 判定能否发布。
//
// ★ 内核身份(空 Name)一律放行:内核模块发布系统事件不需要授权,
// 它们本身就是可信执行体。但**载荷脱敏仍然执行**(见 ScrubPayload)——
// 内核也可能把敏感值拼进事件。
func (g *eventGuard) AllowPublish(p events.Publisher, eventType events.EventType) (bool, string) {
if p.IsKernel() {
return true, ""
}
d := g.rt.CheckEventPublish(p.Name, string(eventType))
return d.Allowed, d.Reason
}
// AllowSubscribe 判定能否订阅。
func (g *eventGuard) AllowSubscribe(p events.Publisher, eventType events.EventType) (bool, string) {
if p.IsKernel() {
return true, ""
}
d := g.rt.CheckEventSubscribe(p.Name, string(eventType))
return d.Allowed, d.Reason
}
// ScrubPayload 对出向载荷逐字段脱敏。
//
// ★ 对**所有**发布者生效(含内核):凭据闸门与身份无关。
// 内核模块把 api_key 拼进事件,和插件拼进去一样危险。
func (g *eventGuard) ScrubPayload(eventType events.EventType, payload map[string]interface{}) map[string]interface{} {
if g.rt == nil || payload == nil {
return payload
}
out := make(map[string]interface{}, len(payload))
for k, v := range payload {
if s, ok := v.(string); ok && s != "" {
clean, n := g.rt.PrepareOutbound(Inspection{
Direction: DirectionOutbound,
Stage: "event:" + string(eventType),
}, s)
if n > 0 {
out[k] = clean
continue
}
}
out[k] = v
}
return out
}
// 编译期确认实现了 events.Guard。
var _ events.Guard = (*eventGuard)(nil)

View File

@ -32,10 +32,34 @@ type EventPolicy struct {
Publishable []string
// Subscribable 是插件可订阅的**只读**事件白名单。
Subscribable []string
// PublisherAllow 是「事件类型 → 允许发布的主体名」的**白名单子集**。
//
// ## 为何需要它(平表的缺陷)
//
// Publishable 是扁平的:进去一项就意味着**任何插件**都能发。
// 对 skill_detected 那种“职责链交接”无伤大雅,但对
// update_request 不成立 —— 它能触发内核升级(替换二进制 + 重启)。
//
// 而实际上只有极少几个内置插件有资格:webui(有登录鉴权)、
// pluginmgr(插件升级通道)、cli(本地终端)。把这种能力发给每个
// 内置插件,等于整个攻击面 = 全部内置插件。
//
// 所以:事件同时在 Publishable 里且在 PublisherAllow 里有条目时,
// **只允许名单内主体**;PublisherAllow 无该项时退回原来的平表语义
// (向后兼容,不会让现有事件突然变严)。
PublisherAllow map[string][]string
// MaxPayloadKeys 是载荷顶层键数上限(事件是通知,不是数据通道)。
MaxPayloadKeys int
}
// EventUpdateRequest 是内核升级请求的事件名。
//
// 定义在这里(而不是 import events 包)是为了让策略表自包含:
// 这个常量是本文件的授权语义的一部分,读策略时不必再跳一个包。
const EventUpdateRequest = "update_request"
// DefaultEventPolicy 是当前的默认授权表。
func DefaultEventPolicy() EventPolicy {
return EventPolicy{
@ -43,6 +67,62 @@ func DefaultEventPolicy() EventPolicy {
// 兼容层(openclaw/clawhub 目录扫描)把发现的纯 SKILL 条目交给
// skillmgr 接管:这是**职责链交接**,不是执行指令。
"skill_detected",
// ★ update_request:**有资格请求内核升级**。
//
// 这个事件以前被明确列为“不得发布”(见 eventpolicy_test.go
// 与 sdk/events_test.go 的 RejectsUnlistedPublish)。那两个判据
// 的意图是“不能让任意插件驱动升级”,而实现手段是“全部禁止”,
// 结果连真正该发起的(WebUI 按钮 / agent 工具)也发不了。
//
// 现在改为**按主体授权**:事件在白名单里,但只有
// PublisherAllow 列出的主体能发。意图不变(不是谁都能发),
// 而合法调用方终于能工作。
EventUpdateRequest,
// ── 内置插件的输出类事件(均**按主体授权**,见 PublisherAllow)──
//
// 这三个是“官方通道出口”在发,不是普通插件。把它们列在这里
// 是为了让闸门能给出统一的拒绝语义(“不在白名单”),
// 而真正的边界在 PublisherAllow。
"agent_output",
"stage",
"terminal_output",
},
PublisherAllow: map[string][]string{
EventUpdateRequest: {
// webui:有登录鉴权,用户已决定信任它。
"webui",
// pluginmgr:agent 工具 kernel_update 的宿主。
"pluginmgr",
// cli:本地终端,已经是完全受信主体。
"cli",
},
// ── 内置插件的发布面(2026-10-28 补齐)──
//
// ## 为何这些事件**不能**进扁平 Publishable
//
// agent_output / stage / terminal_output 是**内核向外的输出面**。
// 任何插件能发它们,就等于能伪造 agent 说的话、伪造阶段进度、
// 冒充终端输出。那是最直接的欺骗路径(不是理论风险:
// WebUI 与 CLI 都直接渲染这些事件)。
//
// 而这三个内置插件确实需要发:它们就是那几个通道的**承载者**。
// 所以按主体开:事件在 Publishable 里,但只有下列主体能发。
"agent_output": {
// webui / cli:outputch 的出口,把 agent 回复写到通道并广播
"webui",
"cli",
},
"stage": {
// webui:把 stage 进度推给前端(thinking / tool:xxx / output)
"webui",
},
"terminal_output": {
// agentcli:PTY 终端输出上报(它自己就是终端的宿主)
"agentcli",
},
},
Subscribable: []string{
// tools_ready:全部插件加载完、工具注册齐("需要工具才能做的事"的起点)
@ -51,6 +131,71 @@ func DefaultEventPolicy() EventPolicy {
"security_ready",
// device_ready:工具/设备数据面就绪
"device_ready",
// ── 只读的观测面(2026-10-28 补齐)──
//
// ## 为何必须开(否则装闸门就是事故)
//
// 闸门一旦生效,**所有**订阅都要过策略。而内置插件(webui / cli)
// 订阅了 agent_output / tool_call / stage 等十余种事件来驱动界面。
// 不开就等于把 WebUI 和 CLI 直接打瘫 —— 那是装监控造成的故障,
// 比不装更糟。
//
// ## 为何订阅它们不构成提权
//
// 这些都是**只读观测**事件:订阅只让你看见,不让你改变任何状态。
// 真正需要收紧的是**发布**(见 Publishable 的按主体授权)。
// 订阅侧收紧只保留真正敏感的两项:raw_input(用户原文,含隐私)
// 与 update_request(执行入口,不是通知)。
"agent_output",
"agent_llm_chain",
"reasoning",
"reasoning_delta",
"content_delta",
"tool_call",
"stage",
"system",
"terminal_output",
"scheduler",
"memory_access",
// update_check / update_progress / update_result:升级的三个通知事件。
//
// ★ 它们必须**可订阅**(之前不在名单里,而 WebUI 走裸总线订阅
// 所以没暴露)。升级是长耗时且会让进程消失的动作,WebUI 靠
// 这些事件才能在断线重连后补到"升到哪步 / 失败 / 已回滚"。
// 它们只是通知(不改内核状态),订阅它们不构成提权。
"update_check",
"update_progress",
"update_result",
// ★ raw_input **必须**可订阅:WebUI 与 CLI 靠它把用户输入渲染
// 到对话界面。若不开,用户发的消息在界面上就不显示了。
//
// 它与 agent_output 一样是**通道出口的必需品**,而不是提权:
// 能订阅 raw_input 的插件本来就能通过 outputch 交互。
// 真正的隐私边界在 security 的凭据闸门与内容审查,不在这里。
"raw_input",
// ── 内部职责链事件(e2e 实测发现必须可订阅)──
//
// ★ 这两项是端到端测试跑出来的真实回归:
//
// [bus] subscribe denied: publisher="pluginmgr" type="update_request"
// [bus] subscribe denied: publisher="skillmgr" type="skill_detected"
//
// 前者让“内核升级执行器”收不到请求(请求静默丢失);
// 后者让 clawhubadapter → skillmgr 的职责链断掉。
//
// 它们都是**内置插件之间的协作通道**,不是对外 API。
// 错在把它们当成“对外只读事件”而拒了内部消费方。
//
// 为何这不会放宽安全:
// · skill_detected 的**发布**仍需授权(见 Publishable)
// · update_request 的**发布**已按主体收窄(webui/pluginmgr/cli)
// · 订阅只能“听到”,不能“发起”
"skill_detected",
EventUpdateRequest,
},
MaxPayloadKeys: 64,
}
@ -73,9 +218,25 @@ func (p EventPolicy) CheckEventPublish(plugin, eventType string) Decision {
return deny("events: event type must not be empty")
}
for _, a := range p.Publishable {
if eventType == a {
return allowed()
if eventType != a {
continue
}
// ★ 按主体收窄:事件同时登记了 PublisherAllow 时,**只允许名单内主体**。
//
// 为何不能简单把它加进 Publishable:平表语义是“任何插件都能发”。
// update_request 能触发内核升级(替换二进制 + 重启),不应下放到
// 每个内置插件。而反过来“完全禁止”也不行 —— 那样合法的调用方
// (WebUI 按钮 / agent 工具)也发不了。
if allow, hasAllow := p.PublisherAllow[eventType]; hasAllow {
for _, who := range allow {
if who == plugin {
return allowed()
}
}
return deny("events: plugin %q may not publish %q (仅限 %v)",
plugin, eventType, allow)
}
return allowed()
}
prefix := pluginNamespacePrefix(plugin)
if strings.HasPrefix(eventType, prefix) && len(eventType) > len(prefix) {

View File

@ -58,17 +58,47 @@ func TestEventPolicyOwnNamespace(t *testing.T) {
}
// ④ 订阅只开只读事件。
//
// ★ 2026-10-28 语义变更:raw_input / agent_output 从“必须拒绝”改为“可订阅”。
//
// 旧判据把它们当提权口子,但实际它们是**通道出口的必需品**:
// WebUI 与 CLI 的对话界面就靠订阅这两个事件渲染用户消息与 agent 回复。
// 不开就等于“装监控把界面打瘫”—— 那是装监控造成的故障,比不装更糟。
//
// 订阅是只读观测:看得见不等于能改变什么。真正的边界在**发布**侧
// (agent_output / stage / terminal_output 已按主体限定,见 PublisherAllow)。
//
// 仍然拒绝的两项:
//
// · update_request —— 执行入口,不是通知
func TestEventPolicySubscribeReadOnlyOnly(t *testing.T) {
p := DefaultEventPolicy()
for _, ok := range []string{"tools_ready", "security_ready", "device_ready"} {
// 通道出口与观测面:必须可订阅
for _, ok := range []string{
"tools_ready", "security_ready", "device_ready",
"raw_input", "agent_output", // 对话界面必需品
"tool_call", "stage", "reasoning", "reasoning_delta", "content_delta",
"terminal_output", "system", "scheduler", "memory_access", "agent_llm_chain",
"update_check", "update_progress", "update_result",
} {
if d := p.CheckEventSubscribe("p1", ok); !d.Allowed {
t.Errorf("%q 应可订阅: %s", ok, d.Reason)
}
}
for _, bad := range []string{"raw_input", "agent_output", "update_request"} {
if d := p.CheckEventSubscribe("p1", bad); d.Allowed {
t.Errorf("订阅 %q 应被拒绝", bad)
}
// ★ update_request 现在**可订阅**(e2e 实测发现必须开):
// 内核侧升级执行器靠订阅它收请求,pluginmgr 也订阅它处理 plugin 范围。
// 不开的后果是“升级请求静默丢失”——比安全约束失守更直接的故障。
//
// 安全边界仍在**发布**侧:只有 webui/pluginmgr/cli 能发。
if d := p.CheckEventSubscribe("pluginmgr", EventUpdateRequest); !d.Allowed {
t.Errorf("pluginmgr 应可订阅 update_request: %s", d.Reason)
}
// 真正必须拒绝的:未登记的事件名
if d := p.CheckEventSubscribe("p1", "no_such_event"); d.Allowed {
t.Error("订阅未登记事件应被拒绝")
}
}

View File

@ -0,0 +1,165 @@
package security
// update_request 的**按主体授权**判据。
//
// ## 背景:这里曾经是“全禁”
//
// 旧的判据把 update_request 列进 RejectsUnlistedPublish,意图是
// “不能让任意插件驱动内核升级”。但实现手段是“全部禁止”,于是连
// 真正该发起的地方(WebUI 按钮、agent 工具)也发不了 —— 而内核侧
// 的执行器(internal/update)已经在等这个事件。
//
// 现在改为按主体授权:事件在白名单里,但只有 PublisherAllow 列出的
// 主体能发。意图不变(不是谁都能发),合法调用方终于能工作。
//
// 这组判据钉住三件事:
// · 名单内主体能发
// · 名单外主体**不能**发(这才是安全边界)
// · 订阅侧不受影响(通知类事件,订阅不构成提权)
import "testing"
// ① 名单内的主体可以发布 update_request。
func TestUpdateRequest_AllowedPublishers(t *testing.T) {
p := DefaultEventPolicy()
for _, who := range []string{"webui", "pluginmgr", "cli"} {
if d := p.CheckEventPublish(who, EventUpdateRequest); !d.Allowed {
t.Errorf("%s 应可发布 update_request,实际被拒: %s", who, d.Reason)
}
}
}
// ② 名单外的主体必须被拒绝 —— 这是安全边界本身。
func TestUpdateRequest_RejectsOtherPlugins(t *testing.T) {
p := DefaultEventPolicy()
for _, who := range []string{"files", "seq", "agentcli", "evil", "mcp", "kbtree"} {
d := p.CheckEventPublish(who, EventUpdateRequest)
if d.Allowed {
t.Errorf("★ %s 不应能发布 update_request(能发就等于能触发内核升级)", who)
}
if d.Reason == "" {
t.Errorf("拒绝 %s 时必须给出理由", who)
}
}
}
// ③ 拒绝理由要指出**允许名单**(否则调用方只能猜)。
func TestUpdateRequest_DenyReasonNamesAllowlist(t *testing.T) {
p := DefaultEventPolicy()
d := p.CheckEventPublish("evil", EventUpdateRequest)
if d.Allowed {
t.Fatal("应被拒绝")
}
for _, want := range []string{"webui", "pluginmgr", "cli"} {
if !containsStr(d.Reason, want) {
t.Errorf("拒绝理由应列出允许主体 %q,实际: %s", want, d.Reason)
}
}
}
// ④ 无 PublisherAllow 条目的事件仍走平表语义(向后兼容)。
//
// 判据:skill_detected 没有被收窄,任何插件都能发 —— 否则会把
// 现有兼容层的职责链交接弄坏。
func TestSkillDetected_StillFlatAllowlist(t *testing.T) {
p := DefaultEventPolicy()
for _, who := range []string{"clawhubadapter", "any-other-plugin"} {
if d := p.CheckEventPublish(who, "skill_detected"); !d.Allowed {
t.Errorf("%s 应可发布 skill_detected(平表语义): %s", who, d.Reason)
}
}
}
// ⑤ update 的三个通知事件可订阅(WebUI 靠它们显示升级进度)。
func TestUpdateEvents_Subscribable(t *testing.T) {
p := DefaultEventPolicy()
for _, evt := range []string{"update_check", "update_progress", "update_result"} {
if d := p.CheckEventSubscribe("webui", evt); !d.Allowed {
t.Errorf("应可订阅 %q: %s", evt, d.Reason)
}
}
}
// ⑥ update_request **必须可订阅**:内核执行器与 pluginmgr 靠它收请求。
//
// ★ 这条判据的方向在 e2e 后反转了(2026-10-28)。
//
// 原先写的是“不可订阅”,理由是“它是执行入口,不是通知”。但端到端实测
// 立刻打脸:
//
// [bus] subscribe denied: publisher="pluginmgr" type="update_request"
//
// 后果是升级请求**静默丢失** —— 请求方以为发了,执行器永远收不到。
// 这比“订阅侧略宽”严重得多。
//
// 安全边界在**发布**侧(只有 webui/pluginmgr/cli 能发),订阅只是“听得到”。
func TestUpdateRequest_SubscribableByInternalConsumers(t *testing.T) {
p := DefaultEventPolicy()
for _, who := range []string{"pluginmgr", "webui"} {
if d := p.CheckEventSubscribe(who, EventUpdateRequest); !d.Allowed {
t.Errorf("%s 应可订阅 update_request(升级执行器依赖它): %s", who, d.Reason)
}
}
// 但**发布**仍严格收窄
if d := p.CheckEventPublish("seq", EventUpdateRequest); d.Allowed {
t.Error("★ seq 不应能发布 update_request")
}
}
func containsStr(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
// ⑦ 内置插件的输出面**只允许承载者**发布。
//
// 这是本次补齐的核心边界:agent_output / stage / terminal_output 是
// 内核向外的输出面,任何插件能发就等于能伪造 agent 的话。
// 它们必须按主体授权,**不能**落进扁平白名单。
func TestBuiltinOutputEvents_RestrictedToOwners(t *testing.T) {
p := DefaultEventPolicy()
allow := map[string][]string{
"agent_output": {"webui", "cli"},
"stage": {"webui"},
"terminal_output": {"agentcli"},
}
deny := map[string][]string{
"agent_output": {"files", "seq", "clawhubadapter", "evil"},
"stage": {"files", "cli", "evil"},
"terminal_output": {"webui", "cli", "evil"},
}
for evt, owners := range allow {
for _, who := range owners {
if d := p.CheckEventPublish(who, evt); !d.Allowed {
t.Errorf("%s 应可发布 %s: %s", who, evt, d.Reason)
}
}
}
for evt, others := range deny {
for _, who := range others {
if d := p.CheckEventPublish(who, evt); d.Allowed {
t.Errorf("★ %s 不应能发布 %s(伪造输出面)", who, evt)
}
}
}
}
// ⑧ 对话界面依赖的观测事件可订阅(装闸门不能把界面打瘫)。
func TestChatObservabilityEvents_Subscribable(t *testing.T) {
p := DefaultEventPolicy()
for _, evt := range []string{
"raw_input", "agent_output", "tool_call", "stage",
"reasoning", "reasoning_delta", "content_delta",
"terminal_output", "system", "scheduler", "memory_access",
} {
if d := p.CheckEventSubscribe("webui", evt); !d.Allowed {
t.Errorf("★ webui 应可订阅 %q(对话/状态界面依赖它): %s", evt, d.Reason)
}
}
}

View File

@ -0,0 +1,386 @@
package update
// Apply / 回滚路径的**回归判据**。
//
// 这些用例的共同点:它们钉住的都是“**静默产出看起来成功的结果**”
// 那类缺陷。升级路径上的静默失败 = 机器进崩溃循环 + 没人知道为什么,
// 所以每个用例都不只断言“返回了 error”,而是断言**副作用没有发生**。
import (
"os"
"path/filepath"
"strings"
"testing"
)
// ── 测试脚手架 ───────────────────────────────────────────────
// fakeBin 造一个能被 `--version` 读出版本的假二进制(shell 脚本)。
func fakeBin(t *testing.T, dir, name, version, commit string) string {
t.Helper()
p := filepath.Join(dir, name)
body := "#!/bin/sh\necho version=" + version + "\necho commit=" + commit +
"\necho build_time=2026-01-01T00:00:00Z\necho sdk=1.4.0\n"
if err := os.WriteFile(p, []byte(body), 0755); err != nil {
t.Fatalf("写假二进制: %v", err)
}
return p
}
// oldKernelBin 造一个**不支持 --version** 的假旧内核(模拟真实 1.3.x 形态)。
func oldKernelBin(t *testing.T, dir string) string {
t.Helper()
p := filepath.Join(dir, "homed")
body := "#!/bin/sh\n" +
"echo 'flag provided but not defined: -version' >&2\n" +
"exit 2\n"
if err := os.WriteFile(p, []byte(body), 0755); err != nil {
t.Fatalf("写旧内核: %v", err)
}
return p
}
func newPkgRoot(t *testing.T, version, commit string) string {
t.Helper()
root := t.TempDir()
binDir := filepath.Join(root, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatal(err)
}
src := fakeBin(t, binDir, "homed", version, commit)
_ = src
return root
}
// ── ★ 核心回归:从「没有 --version 的旧内核」升级失败后,必须能回滚 ──
// TestRollbackToOldKernelWithoutVersionFlag 钉住 PR #31 引入的兼容性回归。
//
// 事故形态:旧内核(1.3.x 及以前)没有 `--version`。若回滚校验依赖
// `--version`,那么从它升上去、新版本起不来时:
//
// ① 文件已正确还原成可执行的旧内核
// ② 但读版本失败 ⇒ 判“回滚失败”
// ③ kernelmod/update 拒绝启动
// ④ marker 保留 ⇒ 下次启动同一条路径 ⇒ **永久回滚不了**
//
// 判据不是“返回 nil”,而是“文件真的被还原且内容与备份逐字节相同”。
func TestRollbackToOldKernelWithoutVersionFlag(t *testing.T) {
dir := t.TempDir()
dataDir := t.TempDir()
// 备份:真实的旧内核形态(没有 --version)
backupDir := filepath.Join(dataDir, BackupDirPrefix+NowStamp())
if err := os.MkdirAll(backupDir, 0755); err != nil {
t.Fatal(err)
}
oldBin := oldKernelBin(t, backupDir)
oldSum, err := sha256File(oldBin)
if err != nil {
t.Fatal(err)
}
// 现状:升级后的新二进制是坏的(不可执行)
bin := filepath.Join(dir, "homed")
if err := os.WriteFile(bin, []byte(""), 0755); err != nil {
t.Fatal(err)
}
// marker 记录了升级前的 digest(新版 Apply 一定会写)
if err := MarkPendingUpgrade(dataDir, PendingRecord{
FromVer: "1.3.0", FromCommit: "aaa1111",
ToVer: "1.4.0", ToCommit: "bbb2222",
BackupDir: backupDir,
Digests: map[string]string{bin: oldSum},
}); err != nil {
t.Fatal(err)
}
rolled, from, to, err := RollbackIfUnconfirmed(dataDir, bin)
if err != nil {
t.Fatalf("★ 回滚不应失败(旧内核没有 --version 是常态,不是缺陷): %v", err)
}
if !rolled {
t.Fatal("有待确认标记时必须回滚")
}
if from != "1.3.0" || to != "1.4.0" {
t.Errorf("版本轨迹不对: %s → %s", from, to)
}
// ★ 真正要断言的是:磁盘上就是那份旧内核,逐字节相同
got, err := sha256File(bin)
if err != nil {
t.Fatal(err)
}
if got != oldSum {
t.Errorf("★ 回滚后内容与备份不一致:期望 %s 实际 %s", ShortSum(oldSum), ShortSum(got))
}
// marker 必须清掉:回滚成功且已校验
if rec, _ := ReadPendingUpgrade(dataDir); rec != nil {
t.Error("回滚成功后应清除 marker")
}
}
// TestRollbackRejectsCorruptBackupByDigest 钉住"坏备份不能被当成回滚成功"。
//
// 旧实现的判据是 `CopyFile 没报错` —— 零字节备份拷过去也成功。
// 现在用 digest:内容不对就是失败,且 marker 必须保留(保住第二次自愈机会)。
func TestRollbackRejectsCorruptBackupByDigest(t *testing.T) {
dir := t.TempDir()
dataDir := t.TempDir()
backupDir := filepath.Join(dataDir, BackupDirPrefix+NowStamp())
if err := os.MkdirAll(backupDir, 0755); err != nil {
t.Fatal(err)
}
// 备份是零字节(磁盘满时极易发生)
if err := os.WriteFile(filepath.Join(backupDir, "homed"), []byte{}, 0755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(dir, "homed")
if err := os.WriteFile(bin, []byte("BROKEN"), 0755); err != nil {
t.Fatal(err)
}
// marker 里记的是"当时那份文件"的 digest,与零字节备份不符
if err := MarkPendingUpgrade(dataDir, PendingRecord{
FromVer: "1.4.0", FromCommit: "old",
ToVer: "1.4.1", ToCommit: "new",
BackupDir: backupDir,
Digests: map[string]string{bin: strings.Repeat("a", 64)},
}); err != nil {
t.Fatal(err)
}
rolled, _, _, err := RollbackIfUnconfirmed(dataDir, bin)
if err == nil {
t.Fatal("★ 坏备份被当成回滚成功了 —— 未做 digest 校验")
}
if rolled {
t.Error("校验失败时 rolled 必须为 false(否则调用方以为自愈完成)")
}
// ★ marker 必须仍在:失败后要保住第二次自愈机会
if rec, _ := ReadPendingUpgrade(dataDir); rec == nil {
t.Error("★★ 回滚失败却清了 marker ⇒ 失去第二次自愈机会")
}
}
// TestRollbackRemovesDepGraphAddedByUpgrade 钉住「旧 homed + 新 depgraph」崩溃循环。
//
// 场景:升级前机器没有 depgraph.bin(或备份没带上),升级包带了新的。
// 新内核起不来 ⇒ 回滚。若只换 homed 不删 depgraph,磁盘上是
// 「旧 homed + 新 depgraph」:旧 homed 内嵌的 digest 对不上,
// kernelboot 装载期直接拒绝启动,而 marker 已清 ⇒ 既起不来也回滚不了。
func TestRollbackRemovesDepGraphAddedByUpgrade(t *testing.T) {
dir := t.TempDir()
dataDir := t.TempDir()
backupDir := filepath.Join(dataDir, BackupDirPrefix+NowStamp())
if err := os.MkdirAll(backupDir, 0755); err != nil {
t.Fatal(err)
}
// 备份里**没有** depgraph.bin(升级前机器本来就没有)
oldBin := fakeBin(t, backupDir, "homed", "1.3.0", "aaa")
oldSum, _ := sha256File(oldBin)
bin := filepath.Join(dir, "homed")
if err := os.WriteFile(bin, []byte("BROKEN"), 0755); err != nil {
t.Fatal(err)
}
// 升级时装上了新的 depgraph
dgPath := filepath.Join(dir, "depgraph.bin")
if err := os.WriteFile(dgPath, []byte("NEW-DEPGRAPH"), 0644); err != nil {
t.Fatal(err)
}
if err := MarkPendingUpgrade(dataDir, PendingRecord{
FromVer: "1.3.0", FromCommit: "aaa",
ToVer: "1.4.0", ToCommit: "bbb",
BackupDir: backupDir,
HadDepGraph: false,
Digests: map[string]string{bin: oldSum},
}); err != nil {
t.Fatal(err)
}
rolled, _, _, err := RollbackIfUnconfirmed(dataDir, bin)
if err != nil {
t.Fatalf("回滚不应失败: %v", err)
}
if !rolled {
t.Fatal("应回滚")
}
// ★ 关键断言:新装上的 depgraph 必须被删掉
if _, statErr := os.Stat(dgPath); statErr == nil {
b, _ := os.ReadFile(dgPath)
t.Fatalf("★★ 残留升级时装上的 depgraph(%q)⇒ 旧 homed 的 digest 对不上 ⇒ 崩溃循环", b)
}
}
// TestApplyRefusesWhenCannotWrite 钉住"安装形态决定能不能自替换"。
//
// deb/rpm 形态下直接替换会让包管理器数据库与实际文件不一致。
// 判据:拒绝 + 给出可执行的替代命令,且**磁盘上什么都没变**。
func TestApplyRefusesWhenCannotWrite(t *testing.T) {
dir := t.TempDir()
bin := fakeBin(t, dir, "homed", "1.4.0", "old")
orig, _ := os.ReadFile(bin)
root := newPkgRoot(t, "1.9.9", "new")
// dataDir 为空必须拒绝:无 pending 标记就无回滚能力。
// 这是最前置的守卫,位于预检/备份**之前**。
if _, err := Apply(root, bin, "", "", nil); err == nil {
t.Fatal("dataDir 为空时必须拒绝升级(无 marker 就无回滚能力)")
}
after, _ := os.ReadFile(bin)
if string(after) != string(orig) {
t.Error("★ 被拒绝的升级不应改动任何文件")
}
}
// TestApplyRefusesWhenBinPathEmpty 钉住“binPath 为空必须拒绝”。
//
// ## 为什么要单开一个用例(这是一个真实的误伤现场)
//
// 早先 cmd/homed-upgrade 的 resolveBinPath 在“同目录找不到 homed”时
// 兜底到 os.Executable()。后果:
//
// 本 CLI 叫 homed-upgrade,与 homed **不同名** ⇒ 它把**自己**当成
// 内核 ⇒ “升级 homed”实际变成“把 CLI 覆盖成新包里的 homed”。
//
// 一个 1 字节的假包就能把工具本体换成一段 shell 脚本,而全程无报错。
//
// 现在两道闸:CLI 找不到就返回空并退出;Apply 对空 binPath 直接拒绝。
// 这个用例钉的是**第二道闸**。
func TestApplyRefusesWhenBinPathEmpty(t *testing.T) {
dataDir := t.TempDir()
root := newPkgRoot(t, "1.9.9", "new")
if _, err := Apply(root, "", "", dataDir, nil); err == nil {
t.Fatal("★ binPath 为空时必须拒绝:否则会把未知文件(甚至本工具自己)当成内核替换掉")
}
// 拒绝时不能留下任何痕迹:不能建备份、不能写 marker。
entries, _ := os.ReadDir(dataDir)
for _, e := range entries {
t.Errorf("★ 被拒绝的升级不应在 dataDir 留下 %q", e.Name())
}
}
// TestDetectRefusesUnknownInstall 钉住“探测不出形态就不允许自替换”。
//
// 失败方向必须是收紧:Unknown 意味着“我们不知道这个东西归谁管”,
// 猜错的代价是破坏包管理器数据库,那个后果比“不能升级”严重得多。
//
// ★ 本用例**只在非 root 下有效**,root 下直接 Skip。
//
// 为什么:root 无视目录权限位,chmod 0555 对它依旧可写,于是
// dirWritable 返回 true、用例恒红。强行用 root 跑会让它变成
// “CI 里必红/本机必绿”的假信号 —— 本仓已经吃过这类亏
// (用“本机是否装了 homed”验版本推断,CI 假绿、生产真红)。
// 所以:能跑就当真值验,不能跑就明说跳过,不以假结论填充。
func TestDetectRefusesUnknownInstall(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root 无视目录权限位,本用例无法构造“不可写”状态")
}
dir := t.TempDir()
bin := fakeBin(t, dir, "homed", "1.4.0", "x")
if err := os.Chmod(dir, 0555); err != nil {
t.Skipf("无法改目录权限: %v", err)
}
t.Cleanup(func() { os.Chmod(dir, 0755) })
c := Detect(bin, t.TempDir())
if c.CanWriteBin {
t.Errorf("★ 目录不可写时不得声称可以自替换(kind=%s)", c.Kind)
}
if c.Reason == "" {
t.Error("拒绝时必须给出可执行的原因(不能只有 bool)")
}
}
// TestDetectContainerRefusesSelfReplace 钉住容器内的判定(不依赖权限)。
//
// 容器里文件系统随容器销毁还原,自替换毫无意义。这条判据跨环境稳定:
// 它不依赖运行时的 euid,只依赖 /.dockerenv 之类的标记。
// 在本机(非容器)时它测的是“非容器不应被误判成容器”。
func TestDetectContainerRefusesSelfReplace(t *testing.T) {
c := Detect("/bin/sh", t.TempDir())
if c.Kind == KindContainer && c.CanWriteBin {
t.Error("★ 容器内不得声称可以自替换")
}
if c.Kind != KindContainer && inContainer() {
t.Error("★ 在容器内却未识别出容器形态")
}
}
// TestApplyRecordsDigests 钉住"Apply 必须把升级前文件的 digest 写进 marker"。
//
// 没有 digest 就没有可靠的旧版本回滚判据(旧内核没有 --version)。
func TestApplyRecordsDigests(t *testing.T) {
dir := t.TempDir()
dataDir := t.TempDir()
bin := fakeBin(t, dir, "homed", "1.4.0", "old")
wantSum, err := sha256File(bin)
if err != nil {
t.Fatal(err)
}
root := newPkgRoot(t, "1.5.0", "new")
out, err := Apply(root, bin, "", dataDir, nil)
if err != nil {
t.Fatalf("Apply 失败: %v", err)
}
if out.To.Version != "1.5.0" {
t.Errorf("新版本不对: %s", out.To.Version)
}
rec, rerr := ReadPendingUpgrade(dataDir)
if rerr != nil || rec == nil {
t.Fatalf("Apply 成功后必须有 pending marker: rec=%v err=%v", rec, rerr)
}
if got, ok := rec.Digests[bin]; !ok {
t.Fatalf("★ marker 缺少 %s 的 digest ⇒ 未来无法可靠回滚到旧版本", bin)
} else if got != wantSum {
t.Errorf("digest 记错:期望 %s 实际 %s", ShortSum(wantSum), ShortSum(got))
}
if !rec.HadDepGraph == true && rec.HadDepGraph {
// 占位:HadDepGraph 在本用例里没有 depgraph,应为 false
}
if rec.HadDepGraph {
t.Error("机器上本来没有 depgraph.bin,HadDepGraph 应为 false")
}
}
// TestApplyRestoresFullyBeforeClearingMarker 钉住"还原完整才清 marker"。
//
// 旧实现先清 marker 再还原,于是"还原也失败"时磁盘上是半新半旧、
// 而 marker 已经没了 ⇒ 失去第二次自愈机会。
func TestApplyRestoresFullyBeforeClearingMarker(t *testing.T) {
dir := t.TempDir()
dataDir := t.TempDir()
bin := fakeBin(t, dir, "homed", "1.4.0", "old")
orig, _ := os.ReadFile(bin)
root := newPkgRoot(t, "1.5.0", "new")
// 让替换阶段的 homed 目标变成"不可 rename":
// 把临时文件路径占成一个目录 ⇒ CopyFileAtomic 在 OpenFile 阶段失败。
// 这样 homed 没被替换,restore 阶段也应成功还原(因为什么都没改)。
blocker := bin + atomicTmpSuffix
if err := os.MkdirAll(blocker, 0755); err != nil {
t.Fatal(err)
}
_, err := Apply(root, bin, "", dataDir, nil)
if err == nil {
t.Fatal("替换失败时应返回 error")
}
// 文件必须原样(restore 成功)
after, _ := os.ReadFile(bin)
if string(after) != string(orig) {
t.Error("★ 替换失败后 homed 未被还原")
}
}

195
internal/update/channel.go Normal file
View File

@ -0,0 +1,195 @@
package update
// 升级的**执行分派**:同一个"升级"请求,按安装形态走不同通道。
//
// ## 为什么需要这一层(这是 update 独立成模块的核心价值)
//
// 「升级」不是一件动作,而是一个**按环境分派**的决定:
//
// deb/rpm → 包管理器执行(文件归 dpkg/rpm 所有,绕过它会破坏数据库)
// tar + 可写 → 自替换(本模块直接动手)
// tar + 不可写 → 只告知命令(服务以非 root 运行,动不了 /usr/bin)
// container → 重拉镜像(容器文件系统随销毁还原)
//
// 在这层存在之前,三个调用点(WebUI / agent 工具 / CLI)各自假定
// "升级 = 替换二进制",于是 deb 形态下要么 EACCES、要么(root 时)
// 把 dpkg 数据库搞脏。
//
// ## 设计约定
//
// 本函数**不重启**、**不弹交互**。它要么完成、要么返回一个带
// 可执行命令的"需要委托"结果。重启与提示由调用方负责。
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// UpgradePlan 是一次升级的**执行计划**(在动手之前确定)。
type UpgradePlan struct {
Capabilities Capabilities `json:"capabilities"`
Channel UpgradeChannel `json:"channel"`
// Executable 表示本模块能否自己完成(true 时才可调用 Execute)。
Executable bool `json:"executable"`
// Commands 是交给人的命令(Executable=false 时非空)。
Commands []string `json:"commands,omitempty"`
// Reason 解释为什么不能自己执行。
Reason string `json:"reason,omitempty"`
}
// PlanUpgrade 决定这次升级该怎么执行。
//
// target 是本地包路径(可选);包管理器通道下它会作为"直接装这个包"的备选。
func PlanUpgrade(c Capabilities, target string) UpgradePlan {
p := UpgradePlan{Capabilities: c, Channel: c.Channel()}
switch p.Channel {
case ChannelSelfReplace:
if c.CanWriteBin {
p.Executable = true
return p
}
p.Reason = c.Reason
case ChannelPackageManager:
p.Reason = "文件归包管理器所有:直接替换会让 dpkg/rpm 数据库与实际内容不一致"
case ChannelContainerImage:
p.Reason = "运行在容器内:文件系统随容器销毁还原,升级应重拉镜像"
default:
p.Reason = c.Reason
if p.Reason == "" {
p.Reason = "无法确定安装形态,拒绝自动升级"
}
}
// 给出可执行命令(这是"不能自己做"时的唯一有用产出)。
if cmd := c.UpgradeCommand(target); cmd != "" {
p.Commands = []string{cmd}
}
if p.Channel == ChannelContainerImage {
p.Commands = []string{
"docker pull <镜像>:<新tag> # 或你们的镜像仓库地址",
"docker compose up -d # 用新镜像重建容器",
}
}
return p
}
// PackageManagerUpgrade 用本机包管理器升级本包(deb/rpm 通道)。
//
// ## 为什么需要 root,以及为什么本函数不自己提权
//
// dpkg/rpm 都要 root。而内核以非 root 服务账号运行(ProtectSystem=strict)。
// **本函数不自带提权**:sudo/polkit 的形态因发行版与部署策略而异,
// 在代码里猜一个(例如 exec sudo)会在无 tty 的服务环境里静默挂起等密码。
//
// 所以:能在 root 下调用就调(CLI 用 sudo 跑),否则返回一个
// ErrNeedPrivilege,由调用方转成"请执行这条命令"。
func PackageManagerUpgrade(c Capabilities, target string) error {
// 预检:非 root 直接返回可执行错误,不要等到 dpkg 报一堆权限噪音。
if os.Geteuid() != 0 {
return fmt.Errorf("%w:需要 root 执行 `%s`", ErrNeedPrivilege, c.UpgradeCommand(target))
}
switch c.Kind {
case KindDeb:
return upgradeDeb(c, target)
case KindRPM:
return upgradeRPM(c, target)
default:
return fmt.Errorf("包管理器升级不适用于形态 %q", c.Kind)
}
}
// ErrNeedPrivilege 表示这条路径需要提权(调用方应转成可执行提示)。
var ErrNeedPrivilege = fmt.Errorf("需要提权")
func upgradeDeb(c Capabilities, target string) error {
pkg := c.PackageName
if pkg == "" {
pkg = "hologramagent"
}
// 有本地包 ⇒ 先试 dpkg -i(离线/指定版本场景最可靠)。
// 没本地包 ⇒ 走 apt(从配置的源取最新)。
if target != "" {
if err := runPkgCmd("dpkg", "-i", target); err != nil {
// dpkg -i 常因依赖缺失失败;退回 apt -f install 补齐。
// ★ 不吞错:第二次也失败就把两个错误一起报。
if fixErr := runPkgCmd("apt-get", "install", "-f", "-y"); fixErr != nil {
return fmt.Errorf("dpkg -i 失败(%v),且 apt -f install 也失败: %w", err, fixErr)
}
}
return nil
}
return runPkgCmd("apt-get", "install", "--only-upgrade", "-y", pkg)
}
func upgradeRPM(c Capabilities, target string) error {
pkg := c.PackageName
if pkg == "" {
pkg = "hologramagent"
}
if target != "" {
// dnf 优先(能解依赖),没有就退回 rpm -U
if _, err := exec.LookPath("dnf"); err == nil {
return runPkgCmd("dnf", "install", "-y", target)
}
return runPkgCmd("rpm", "-U", target)
}
if _, err := exec.LookPath("dnf"); err == nil {
return runPkgCmd("dnf", "upgrade", "-y", pkg)
}
return fmt.Errorf("找不到 dnf:请手工升级 %s", pkg)
}
// pkgCmdRunner 是包管理器命令的**执行器**(可注入)。
//
// ★ 为什么提为变量:包管理器命令会真的改动系统,而单元测试**绝不能**
// 真的跑 `apt-get install`(实测踩到:root 下跑真 apt,报
// "Unable to locate package",还被当成业务逻辑失败)。
// 测试把它换成一个记录调用的假执行器,从而能断言“选了哪条命令”。
// 生产行为完全不变(默认就是下面那个真跑的实现)。
var pkgCmdRunner = realRunPkgCmd
// runPkgCmd 走当前执行器(生产 = realRunPkgCmd,测试可替换)。
func runPkgCmd(name string, args ...string) error { return pkgCmdRunner(name, args...) }
// realRunPkgCmd 执行包管理器命令,带输出上限与超时。
//
// ★ 为什么要超时:dpkg 在等锁(另一个 apt 在跑)时会长时间挂住,
// 而调用方(WebUI 的 HTTP handler)会因此一直持有连接。
func realRunPkgCmd(name string, args ...string) error {
cmd := exec.Command(name, args...)
var sb strings.Builder
cmd.Stdout = &sb
cmd.Stderr = &sb
done := make(chan error, 1)
if err := cmd.Start(); err != nil {
return fmt.Errorf("启动 %s: %w", name, err)
}
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err != nil {
return fmt.Errorf("%s %s 失败: %w\n输出尾部:\n%s",
name, strings.Join(args, " "), err, tailLines(sb.String(), 20))
}
return nil
case <-time.After(10 * time.Minute):
_ = cmd.Process.Kill()
return fmt.Errorf("%s %s 超时(10 分钟):可能卡在包管理器锁上",
name, strings.Join(args, " "))
}
}
// tailLines 取文本末尾 n 行(错误消息里只需要尾部)。
func tailLines(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) <= n {
return s
}
return "…(前略)\n" + strings.Join(lines[len(lines)-n:], "\n")
}

View File

@ -0,0 +1,197 @@
package update
// 升级通道分派的判据。
//
// 这一层的存在理由:同一个"升级"在不同安装形态下必须走不同通道。
// 判据要钉住的不是"函数能跑",而是**分派结果是否正确**——
// 分派错了的后果是破坏包管理器数据库,那是要人工上机器救的故障。
import (
"errors"
"os"
"strings"
"testing"
)
// ① deb/rpm 一律走包管理器,且**不可自替换**(即便当前是 root)。
func TestPlanUpgrade_DebGoesToPackageManager(t *testing.T) {
c := Capabilities{
Kind: KindDeb, Variant: VariantServer,
BinPath: "/usr/bin/homed", PackageName: "hologramagent-server",
CanWriteBin: false, Reason: "由 dpkg 安装",
}
p := PlanUpgrade(c, "")
if p.Channel != ChannelPackageManager {
t.Fatalf("deb 应走包管理器,实际 %s", p.Channel)
}
if p.Executable {
t.Fatal("★ deb 形态绝不允许自替换(会破坏 dpkg 数据库)")
}
if len(p.Commands) == 0 {
t.Fatal("不能自执行时必须给出可执行命令")
}
if !strings.Contains(p.Commands[0], "hologramagent-server") {
t.Errorf("命令应带包名,实际: %s", p.Commands[0])
}
if p.Reason == "" {
t.Error("拒绝自执行必须说明原因")
}
}
// ② tar 且可写 ⇒ 自替换。
func TestPlanUpgrade_TarWritableSelfReplaces(t *testing.T) {
c := Capabilities{Kind: KindTar, Variant: VariantServer, CanWriteBin: true}
p := PlanUpgrade(c, "")
if p.Channel != ChannelSelfReplace || !p.Executable {
t.Fatalf("tar+可写应自替换,实际 channel=%s executable=%v", p.Channel, p.Executable)
}
if len(p.Commands) != 0 {
t.Error("能自己做的事不该给命令(会让人以为要手动)")
}
}
// ③ tar 但不可写 ⇒ 不可执行,但必须给出命令(这是本机的主要现实形态)。
func TestPlanUpgrade_TarUnwritableGivesCommand(t *testing.T) {
c := Capabilities{
Kind: KindTar, Variant: VariantServer, CanWriteBin: false,
Reason: "当前用户对 /usr/local/bin 没有写权限",
}
p := PlanUpgrade(c, "")
if p.Executable {
t.Fatal("不可写时不得声称可执行")
}
if len(p.Commands) == 0 {
t.Fatal("★ 不可写时必须给出可执行命令,否则用户无从下手")
}
if !strings.Contains(strings.Join(p.Commands, " "), "homed-upgrade") {
t.Errorf("tar 形态应建议 homed-upgrade,实际: %v", p.Commands)
}
}
// ④ 容器 ⇒ 重拉镜像,不给包管理器命令。
func TestPlanUpgrade_ContainerPullsImage(t *testing.T) {
c := Capabilities{Kind: KindContainer, Variant: VariantServer}
p := PlanUpgrade(c, "")
if p.Channel != ChannelContainerImage {
t.Fatalf("容器应走镜像通道,实际 %s", p.Channel)
}
if p.Executable {
t.Fatal("容器内不得自替换(重启即还原)")
}
if len(p.Commands) < 2 {
t.Errorf("应给出 pull + 重建两条命令,实际: %v", p.Commands)
}
}
// ⑤ 源码运行/未知 ⇒ 拒绝,且必须说明。
func TestPlanUpgrade_SourceRefuses(t *testing.T) {
for _, kind := range []InstallKind{KindSource, KindUnknown} {
c := Capabilities{Kind: kind, Variant: VariantUnknown}
p := PlanUpgrade(c, "")
if p.Executable {
t.Errorf("%s 形态不得自替换", kind)
}
if p.Reason == "" {
t.Errorf("%s 拒绝时必须说明原因(不能只有 bool)", kind)
}
}
}
// ⑥ 非 root 时 PackageManagerUpgrade 返回 ErrNeedPrivilege,而不是去跑 dpkg。
//
// 判据重点是**没有执行命令**:非 root 下 dpkg 会输出一堆权限噪音,
// 而真正该告诉用户的是"你需要 sudo"。
// ⑥ 包管理器执行**不**真跑命令,而是通过可注入执行器验证选了什么。
//
// ## 为什么必须注入(真实事故)
//
// 本用例最初直接调 PackageManagerUpgrade,在 root 下真的执行了
// `apt-get install --only-upgrade -y hologramagent-full` —— 报
// "Unable to locate package" 并把真实的系统查询当成了业务失败。
// 单元测试**绝不能**真的调用包管理器。
//
// 现在把 pkgCmdRunner 换掉,从而验证的是“选了哪条命令”,
// 而不是“命令能不能跑”。
func TestPackageManagerUpgrade_ChoosesRightCommand(t *testing.T) {
var got []string
old := pkgCmdRunner
pkgCmdRunner = func(name string, args ...string) error {
got = append(got, name+" "+strings.Join(args, " "))
return nil
}
t.Cleanup(func() { pkgCmdRunner = old })
// deb 无本地包 ⇒ 走 apt-get --only-upgrade
c := Capabilities{Kind: KindDeb, PackageName: "hologramagent-full"}
if err := PackageManagerUpgrade(c, ""); err != nil {
if errors.Is(err, ErrNeedPrivilege) {
t.Skip("非 root:本用例需要 root 才能走到执行分支")
}
t.Fatalf("执行失败: %v", err)
}
if len(got) != 1 || !strings.Contains(got[0], "--only-upgrade") {
t.Errorf("deb 应选 apt-get --only-upgrade,实际: %v", got)
}
if !strings.Contains(got[0], "hologramagent-full") {
t.Errorf("命令应带包名,实际: %v", got)
}
}
// ⑥b 非 root 时必须返回 ErrNeedPrivilege(而不是去跑命令)。
//
// 判据重点是**没有执行命令**:非 root 下 dpkg 只会输出权限噪音,
// 而真正该告诉用户的是“你需要 sudo”。
func TestPackageManagerUpgrade_NonRootReturnsNeedPrivilege(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root 环境无法验证非 root 分支")
}
called := false
old := pkgCmdRunner
pkgCmdRunner = func(name string, args ...string) error { called = true; return nil }
t.Cleanup(func() { pkgCmdRunner = old })
c := Capabilities{Kind: KindDeb, PackageName: "hologramagent-full"}
err := PackageManagerUpgrade(c, "")
if err == nil {
t.Fatal("非 root 时应报错(不得静默成功)")
}
if !errors.Is(err, ErrNeedPrivilege) {
t.Errorf("应返回 ErrNeedPrivilege 以便调用方转成提示,实际: %v", err)
}
if called {
t.Error("★ 非 root 时不应去执行包管理器命令")
}
if !strings.Contains(err.Error(), "apt") {
t.Errorf("错误里应含可执行命令,实际: %v", err)
}
}
// ⑦ detectVariant 不能因为缺文件就 panic,且 client(无 homed)要能识别。
func TestDetectVariant_NoKernelIsClientOrUnknown(t *testing.T) {
v := detectVariant("/nonexistent/homed")
if v != VariantClient && v != VariantUnknown {
t.Errorf("无 homed 时应为 client 或 unknown,实际 %s", v)
}
}
// ⑧ isGoBuildTempPath 只认 go build 的临时目录形态,不认任意 /tmp。
//
// 这一条防的是"用 /tmp/ 判源码运行"的宽判据:那会把
// "解包到 /tmp 试装"和 CI 集成测都误判成 dev 环境而拒绝升级。
func TestIsGoBuildTempPath(t *testing.T) {
cases := []struct {
path string
want bool
}{
{"/tmp/go-build123/b001/exe/homed", true},
{"/root/.cache/go-build/ab/cd-homed", false}, // 构建缓存,不是 go run 产物
{"/tmp/somewhere/homed", false}, // 普通 /tmp 路径不算
{"/usr/local/bin/homed", false},
{"", false},
}
for _, c := range cases {
if got := isGoBuildTempPath(c.path); got != c.want {
t.Errorf("isGoBuildTempPath(%q) = %v, want %v", c.path, got, c.want)
}
}
}

370
internal/update/delta.go Normal file
View File

@ -0,0 +1,370 @@
package update
// 二进制差分:用「旧版本 + 小补丁」重建新版本,避免每次升下载 88MB。
//
// ## 为什么必须做(实测数据)
//
// 两次构建(同 tag、同参数、-trimpath):
//
// 基线 88,036,432 字节
// 改一行逻辑 88,036,432 字节,但**逐字节差异只有 80 字节**
//
// 而 OTA 现在下的是整个 tar.gz。也就是说 99.9999% 的流量在传
// 与上一版完全相同的字节。
//
// 前提是可复现构建 —— 本项目已满足(实测两次构建 sha256 相同),
// 这是差分能成立的基础:同一份源码必须产出同一份字节。
//
// ## 算法选择:为什么不直接用 bsdiff
//
// bsdiff 需要 ~17×N 的峰值内存(88MB → 约 1.5GB),在系统内存紧张的
// 机器上会 OOM。而升级恰恰发生在可能内存不足的场景里。
//
// 这里用 rsync 族的**滚动哈希块匹配**:
//
// 构建期:把旧文件按 blockSize 切块建索引(88MB/4KB ≈ 21500 条)
// 差分时:在新文件上逐字节滚动 blockSize 窗口的哈希,命中即验证并
// 向后扩张匹配,发出 COPY;否则累积 LITERAL
//
// 峰值内存 ≈ 旧文件 + 新文件 + 索引(约 200MB / 88MB 文件),
// 且索引只有几万条,与文件大小线性但系数极小。
//
// ## 为什么 COPY 可以跨任意偏移
//
// 索引里的键是**块内容的哈希**,不是偏移。所以新文件里同一段内容出现在
// 任何位置都能命中 —— 这正好处理"插入/删除几个字节导致后续全部错位"
// 这种最常见的情形(改代码几乎必然改变 .text 的偏移)。
//
// ## 安全边界
//
// 差分的结果**必须**用全部内容的 sha256 校验。patch 应用成功不等于
// 内容正确(旧版本不同、磁盘损坏、patch 被截断都会"成功"应用出
// 一份错误的二进制)。校验失败就丢弃,回退到完整包。
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"hash/crc32"
"io"
"os"
"sort"
)
const (
// blockSize 是索引的匹配粒度。
//
// 选 4KB 的理由:
// · 索引条数 = 文件大小/4KB(88MB → 21500 条),内存可忽略
// · 匹配粒度足够:改一行代码只影响几十字节,其余 4KB 块全部命中
// · 太小(如 64B)会让索引暴涨到 137 万条且收益变差
blockSize = 4096
// deltaMagic 是补丁文件头,用于识别格式与版本。
deltaMagic = "HADELTA1"
// maxDeltaInput 是允许做差分的单文件上限。
//
// 88MB 的二进制 + 索引约需 200MB 峰值。设 256MB 上限是为了防止
// 误用(例如有人对模型文件做差分)导致内存爆掉。
maxDeltaInput = 256 << 20
opCopy = byte(0x01)
opLiteral = byte(0x02)
)
// DeltaHeader 是补丁的元信息。
type DeltaHeader struct {
OldSize int64
NewSize int64
// NewSHA256 是**新文件的完整 sha256**,应用后必须校验。
NewSHA256 string
// OldSHA256 是**旧文件的完整 sha256**,应用前必须校验。
//
// ★ 为什么记旧文件的哈希:差分只能对"特定旧版本"应用。
// 用错基线(例如两个版本之间的跨版本 delta)会静默产出一份垃圾,
// 而它的大小、结构都"看起来正常"。
OldSHA256 string
}
// MakeDelta 生成把 oldData 变成 newData 的补丁。
func MakeDelta(oldData, newData []byte) ([]byte, *DeltaHeader, error) {
if len(oldData) > maxDeltaInput || len(newData) > maxDeltaInput {
return nil, nil, fmt.Errorf("文件超出差分上限(%d MB)", maxDeltaInput>>20)
}
oldSum := sha256.Sum256(oldData)
newSum := sha256.Sum256(newData)
hdr := &DeltaHeader{
OldSize: int64(len(oldData)),
NewSize: int64(len(newData)),
OldSHA256: hex.EncodeToString(oldSum[:]),
NewSHA256: hex.EncodeToString(newSum[:]),
}
idx := buildBlockIndex(oldData)
var body bytes.Buffer
// 预留:最坏情况全是字面量,但字面量会分片合并,实际远小于 N。
body.Grow(len(newData)/64 + 1024)
var lit []byte
flushLit := func() {
if len(lit) == 0 {
return
}
body.WriteByte(opLiteral)
var tmp [4]byte
binary.LittleEndian.PutUint32(tmp[:], uint32(len(lit)))
body.Write(tmp[:])
body.Write(lit)
lit = lit[:0]
}
writeCopy := func(off int64, n int) {
body.WriteByte(opCopy)
var tmp [8]byte
binary.LittleEndian.PutUint64(tmp[:], uint64(off))
body.Write(tmp[:])
var t4 [4]byte
binary.LittleEndian.PutUint32(t4[:], uint32(n))
body.Write(t4[:])
}
i := 0
for i < len(newData) {
// 窗口不足一个块时无法匹配,直接当字面量
if i+blockSize > len(newData) {
lit = append(lit, newData[i:]...)
break
}
h := crc32.ChecksumIEEE(newData[i : i+blockSize])
cands := idx[h]
matched := int64(-1)
matchLen := 0
for _, off := range cands {
// 哈希命中后必须**逐字节验证**:CRC32 会碰撞,
// 而碰撞导致的错误 COPY 会让输出静默损坏。
if !bytes.Equal(oldData[off:off+blockSize], newData[i:i+blockSize]) {
continue
}
// 向后扩张:把相邻的相同字节一起纳入 COPY
n := blockSize
for i+n < len(newData) && off+int64(n) < int64(len(oldData)) &&
newData[i+n] == oldData[off+int64(n)] {
n++
}
if n > matchLen {
matchLen = n
matched = off
}
if matchLen >= 1<<20 {
break // 足够长,不必再找
}
}
if matched >= 0 {
flushLit()
writeCopy(matched, matchLen)
i += matchLen
continue
}
lit = append(lit, newData[i])
i++
}
flushLit()
var out bytes.Buffer
out.WriteString(deltaMagic)
var b8 [8]byte
binary.LittleEndian.PutUint64(b8[:], uint64(len(oldData)))
out.Write(b8[:])
binary.LittleEndian.PutUint64(b8[:], uint64(len(newData)))
out.Write(b8[:])
// 两个哈希固定 64 字节 hex
oldHex := make([]byte, 64)
newHex := make([]byte, 64)
copy(oldHex, hdr.OldSHA256)
copy(newHex, hdr.NewSHA256)
out.Write(oldHex)
out.Write(newHex)
out.Write(body.Bytes())
return out.Bytes(), hdr, nil
}
// buildBlockIndex 把旧文件按 blockSize 切块,建「CRC32 → 偏移列表」索引。
func buildBlockIndex(data []byte) map[uint32][]int64 {
idx := make(map[uint32][]int64, len(data)/blockSize+1)
for off := 0; off+blockSize <= len(data); off += blockSize {
h := crc32.ChecksumIEEE(data[off : off+blockSize])
idx[h] = append(idx[h], int64(off))
}
return idx
}
// ParseDeltaHeader 读补丁头(不应用)。
func ParseDeltaHeader(patch []byte) (*DeltaHeader, error) {
if len(patch) < len(deltaMagic)+16+128 {
return nil, fmt.Errorf("补丁太短(%d 字节),不是有效差分", len(patch))
}
if string(patch[:len(deltaMagic)]) != deltaMagic {
return nil, fmt.Errorf("补丁魔数不符:期望 %q,实际 %q", deltaMagic, patch[:len(deltaMagic)])
}
p := len(deltaMagic)
hdr := &DeltaHeader{
OldSize: int64(binary.LittleEndian.Uint64(patch[p:])),
NewSize: int64(binary.LittleEndian.Uint64(patch[p+8:])),
OldSHA256: string(patch[p+16 : p+16+64]),
NewSHA256: string(patch[p+16+64 : p+16+128]),
}
return hdr, nil
}
// ApplyDelta 用旧数据 + 补丁重建新数据,并**校验两端的 sha256**。
//
// 校验是硬性的:patch 应用"成功"不代表内容正确。旧版本不匹配、
// 磁盘损坏、patch 被截断,都会产出一份"看起来正常"的错误二进制。
// 唯一可信的判据是全内容 sha256。
func ApplyDelta(oldData, patch []byte) ([]byte, error) {
hdr, err := ParseDeltaHeader(patch)
if err != nil {
return nil, err
}
// ① 先验旧文件:差分只对**特定旧版本**成立。
oldSum := sha256.Sum256(oldData)
if got := hex.EncodeToString(oldSum[:]); got != hdr.OldSHA256 {
return nil, fmt.Errorf("基线不匹配:补丁针对 %s,本地是 %s",
ShortSum(hdr.OldSHA256), ShortSum(got))
}
if int64(len(oldData)) != hdr.OldSize {
return nil, fmt.Errorf("基线大小不匹配:期望 %d,实际 %d", hdr.OldSize, len(oldData))
}
// ② 执行指令
p := len(deltaMagic) + 16 + 128
out := make([]byte, 0, hdr.NewSize)
for p < len(patch) {
if p+1 > len(patch) {
return nil, fmt.Errorf("补丁在偏移 %d 处截断(缺操作码)", p)
}
op := patch[p]
p++
switch op {
case opCopy:
if p+12 > len(patch) {
return nil, fmt.Errorf("补丁在偏移 %d 处截断(COPY 参数)", p)
}
off := int64(binary.LittleEndian.Uint64(patch[p:]))
n := int(binary.LittleEndian.Uint32(patch[p+8:]))
p += 12
if off < 0 || n < 0 || off+int64(n) > int64(len(oldData)) {
return nil, fmt.Errorf("COPY 越界:off=%d n=%d oldSize=%d", off, n, len(oldData))
}
out = append(out, oldData[off:off+int64(n)]...)
case opLiteral:
if p+4 > len(patch) {
return nil, fmt.Errorf("补丁在偏移 %d 处截断(LITERAL 长度)", p)
}
n := int(binary.LittleEndian.Uint32(patch[p:]))
p += 4
if n < 0 || p+n > len(patch) {
return nil, fmt.Errorf("LITERAL 越界:n=%d 剩余=%d", n, len(patch)-p)
}
out = append(out, patch[p:p+n]...)
p += n
default:
return nil, fmt.Errorf("未知操作码 0x%02x(偏移 %d)", op, p-1)
}
// 防御:补丁可以声明很小的 NewSize 但指令产出巨量数据
if int64(len(out)) > hdr.NewSize+int64(blockSize) {
return nil, fmt.Errorf("补丁产出的数据超过声明大小(%d > %d)", len(out), hdr.NewSize)
}
}
if int64(len(out)) != hdr.NewSize {
return nil, fmt.Errorf("重建大小不符:期望 %d,实际 %d", hdr.NewSize, len(out))
}
// ③ 校验结果:这是唯一可信的判据
newSum := sha256.Sum256(out)
if got := hex.EncodeToString(newSum[:]); got != hdr.NewSHA256 {
return nil, fmt.Errorf("重建内容校验失败:期望 %s,实际 %s",
ShortSum(hdr.NewSHA256), ShortSum(got))
}
return out, nil
}
// ── 文件级入口(供 CLI / 发布脚本使用) ────────────────────────
// MakeDeltaFile 从磁盘上的两个文件生成补丁文件。
func MakeDeltaFile(oldPath, newPath, patchPath string) (*DeltaHeader, int64, error) {
oldData, err := os.ReadFile(oldPath)
if err != nil {
return nil, 0, fmt.Errorf("读旧文件: %w", err)
}
newData, err := os.ReadFile(newPath)
if err != nil {
return nil, 0, fmt.Errorf("读新文件: %w", err)
}
patch, hdr, err := MakeDelta(oldData, newData)
if err != nil {
return nil, 0, err
}
if err := WriteFileAtomic(patchPath, patch, 0644); err != nil {
return nil, 0, fmt.Errorf("写补丁: %w", err)
}
return hdr, int64(len(patch)), nil
}
// ApplyDeltaFile 用旧文件 + 补丁重建新文件。
func ApplyDeltaFile(oldPath, patchPath, outPath string) (int64, error) {
oldData, err := os.ReadFile(oldPath)
if err != nil {
return 0, fmt.Errorf("读基线: %w", err)
}
patch, err := os.ReadFile(patchPath)
if err != nil {
return 0, fmt.Errorf("读补丁: %w", err)
}
out, err := ApplyDelta(oldData, patch)
if err != nil {
return 0, err
}
if err := WriteFileAtomic(outPath, out, 0755); err != nil {
return 0, fmt.Errorf("写结果: %w", err)
}
return int64(len(out)), nil
}
// DeltaStats 用于报告压缩效果(发布脚本 / 日志)。
type DeltaStats struct {
OldSize int64
NewSize int64
PatchSize int64
Ratio float64
}
// Stats 计算压缩比。
func (h *DeltaHeader) Stats(patchSize int64) DeltaStats {
r := 0.0
if h.NewSize > 0 {
r = float64(patchSize) / float64(h.NewSize)
}
return DeltaStats{OldSize: h.OldSize, NewSize: h.NewSize, PatchSize: patchSize, Ratio: r}
}
// sortedKeys 仅用于测试与诊断(map 遍历顺序不稳定)。
func sortedKeys(m map[uint32][]int64) []uint32 {
out := make([]uint32, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
var _ = io.Discard

View File

@ -0,0 +1,128 @@
package update
// 差分级别的**发包/取包**:把「整包下载」换成「delta + 本地基线重建」。
//
// ## 为什么需要这一层(实测)
//
// 一次逻辑改动,88MB 的二进制只差 80 字节;而 OTA 下的是整个 tar.gz。
// 差分把这一步从 88MB 降到几十 KB 量级。
//
// ## 为什么 delta 必须绑定旧文件的 sha256
//
// 差分只能对**特定旧版本**应用。用错基线会静默产出一份"看起来正常"
// 的垃圾 —— 大小对、结构对,就是内容错。所以:
//
// ① 构建期在 delta 里写入旧/新两者完整 sha256
// ② 取包时先验本地基线 sha256,不符就**放弃差分,退回整包**
// ③ 应用后验结果 sha256,不符同样退回整包
//
// 回退而不是报错,是这个设计的要点:差分是**优化**,不是可用性前提。
import (
"fmt"
"os"
"path/filepath"
)
// DeltaAssetName 是发布时 delta 资产的命名约定。
//
// 形如:hologramagent-1.4.0-linux-amd64-from-<oldCommit8>.delta
// 带上 from-commit 是为了让下载方一眼看出它对应哪个基线,
// 而不是"随便挑一个 delta 试试"。
func DeltaAssetName(version, goos, goarch, fromCommit string) string {
return fmt.Sprintf("hologramagent-%s-%s-%s-from-%s.delta", version, goos, goarch, ShortCommit(fromCommit))
}
// TryDeltaUpgrade 尝试用 delta 重建新二进制到 outDir。
//
// 返回值:
//
// true, nil —— 成功重建(outDir/homed 已就位)
// false, nil —— 没有可用 delta 或不适用(调用方应退回整包)
// false, err —— 出错(同样建议退回整包,但错误应记录)
//
// ## 什么情况下退回整包
//
// · 本地没有基线二进制(首次安装 / 二进制已被替换)
// · 发布里没有匹配当前版本的 delta
// · 基线 sha256 不符(本地版本不是 delta 针对的那个)
// · 应用后校验失败
//
// 这四种都**不是**致命错误:整包永远可用,差分只是省流量。
func TryDeltaUpgrade(pkgDir, binPath, version string, rel *ReleaseInfo) (bool, error) {
if rel == nil || rel.DeltaURL == "" {
return false, nil // 该 release 没提供 delta
}
if !FileExists(binPath) {
return false, nil
}
// ① 读本地基线(要用它的字节重建)
base, err := os.ReadFile(binPath)
if err != nil {
return false, fmt.Errorf("读本地基线: %w", err)
}
// ② 下载 delta
patchPath := filepath.Join(pkgDir, "kernel.delta")
if err := downloadFile(rel.DeltaURL, patchPath); err != nil {
return false, fmt.Errorf("下载 delta: %w", err)
}
patch, err := os.ReadFile(patchPath)
if err != nil {
return false, fmt.Errorf("读 delta: %w", err)
}
// ③ 应用(内含基线校验 + 结果校验)
out, err := ApplyDelta(base, patch)
if err != nil {
// ★ 退回整包,不当失败:可能是本地版本与 delta 基线不符
return false, err
}
// ④ 落位
dst := filepath.Join(pkgDir, "bin", "homed")
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return false, err
}
if err := WriteFileAtomic(dst, out, 0755); err != nil {
return false, err
}
return true, nil
}
// MakeDeltaForRelease 生成发布用的 delta(构建脚本 / 发布流程调用)。
//
// oldBin 是**上一个已发布版本**的二进制,newBin 是本版。
func MakeDeltaForRelease(oldBin, newBin, outPath string) (*DeltaHeader, int64, error) {
hdr, size, err := MakeDeltaFile(oldBin, newBin, outPath)
if err != nil {
return nil, 0, err
}
st := hdr.Stats(size)
// 只在**确实变小**时才值得发:偶尔(重构、换编译器)delta 可能
// 比整包还大,那时发布方应直接不发 delta。
if st.Ratio > 0.5 {
return hdr, size, fmt.Errorf(
"delta 过大(%.1f%% of %d bytes):本版不适合差分发布,请只发整包",
st.Ratio*100, hdr.NewSize)
}
return hdr, size, nil
}
// currentVersionForDelta 读本机内核版本,读不到时返回 nil。
//
// ★ 为什么必须传真实版本给 CheckRelease(而不是 nil):
// delta 的资产名里带 `-from-<commit8>`,而那个 commit 来自 CurrentC。
// 传 nil 会让 CurrentC 为空 ⇒ 永远匹配不到 delta ⇒ 整条差分路径静默失效
// (看起来"差分已上线",实际每次都在下整包)。
//
// 读不到版本时返回 nil 是合理的降级:老内核没有 --version,
// 那种情况下也确实不该尝试差分(连基线身份都无法确认)。
func currentVersionForDelta() *BinVersion {
v, err := ReadBinVersion(KernelBinaryPath())
if err != nil {
return nil
}
return v
}

View File

@ -0,0 +1,188 @@
package update
// 差分 OTA 的判据。
//
// 这一层的收益是**实测的**:改一行逻辑,88MB 的二进制只差 80 字节。
// 但机制本身有真实风险 —— 错误应用会静默产出一份"看起来正常"的坏二进制。
// 所以判据的重点是「错了一定被咬住」,而不是「正常情况能跑」。
import (
"bytes"
"crypto/rand"
"os"
"path/filepath"
"testing"
)
// ① 往返:make → apply 得到逐字节相同的内容。
func TestDeltaRoundTrip(t *testing.T) {
old := bytes.Repeat([]byte("ABCDEFGHIJKLMNOP"), 5000) // 80KB
newData := append([]byte(nil), old...)
// 中间插入 3 个字节(模拟改代码导致后续偏移全变)
mid := len(newData) / 2
newData = append(newData[:mid], append([]byte("XYZ"), newData[mid:]...)...)
patch, hdr, err := MakeDelta(old, newData)
if err != nil {
t.Fatalf("生成补丁: %v", err)
}
if hdr.NewSize != int64(len(newData)) || hdr.OldSize != int64(len(old)) {
t.Fatalf("头部大小不对: %+v", hdr)
}
got, err := ApplyDelta(old, patch)
if err != nil {
t.Fatalf("应用补丁: %v", err)
}
if !bytes.Equal(got, newData) {
t.Fatal("★ 重建内容与目标不一致")
}
}
// ② 关键收益:小改动 ⇒ 小补丁。
//
// 这条判据钉住的是**压缩比**本身。若实现退化成"全字面量",
// 往返仍能通过但 OTA 一点没省 —— 那是最容易漏掉的失败形态。
func TestDeltaSmallChangeProducesSmallPatch(t *testing.T) {
old := make([]byte, 1<<20) // 1MB
if _, err := rand.Read(old); err != nil {
t.Fatal(err)
}
newData := append([]byte(nil), old...)
// 只改 64 字节
for i := 0; i < 64; i++ {
newData[i*100] ^= 0xFF
}
patch, hdr, err := MakeDelta(old, newData)
if err != nil {
t.Fatal(err)
}
st := hdr.Stats(int64(len(patch)))
t.Logf("patch=%d bytes, ratio=%.4f", len(patch), st.Ratio)
// 1MB 随机内容,只有 64 字节变化 ⇒ 补丁应远小于原文件。
// 这里用 5% 作宽松上限(块粒度 4KB,改动分散在 100 字节间隔上,
// 会打断一些块,实际比例低于此)。
if st.Ratio > 0.05 {
t.Errorf("★ 补丁过大(%.2f%% of %d bytes):差分没起作用", st.Ratio*100, hdr.NewSize)
}
}
// ③ 基线不符必须拒绝(用错旧版本 ⇒ 产出垃圾)。
func TestDeltaRejectsWrongBaseline(t *testing.T) {
old := []byte("the-original-content-here")
newData := []byte("the-original-content-here-plus-more")
patch, _, err := MakeDelta(old, newData)
if err != nil {
t.Fatal(err)
}
if _, err := ApplyDelta([]byte("a-completely-different-baseline"), patch); err == nil {
t.Fatal("★ 基线不符时必须拒绝(否则静默产出垃圾)")
}
}
// ④ 补丁被截断必须拒绝,不得"部分成功"。
func TestDeltaRejectsTruncatedPatch(t *testing.T) {
old := bytes.Repeat([]byte("Z"), 8192)
newData := append([]byte(nil), old...)
newData = append(newData, []byte("TAIL")...)
patch, _, err := MakeDelta(old, newData)
if err != nil {
t.Fatal(err)
}
for _, cut := range []int{len(deltaMagic), 20, len(patch) / 2, len(patch) - 1} {
if _, err := ApplyDelta(old, patch[:cut]); err == nil {
t.Errorf("★ 截断到 %d/%d 字节仍被接受", cut, len(patch))
}
}
}
// ⑤ 内容被篡改必须被 sha256 咬住。
//
// 这是最后一道防线:patch 结构合法、指令也不越界,但产出内容错。
func TestDeltaRejectsCorruptedOutput(t *testing.T) {
old := bytes.Repeat([]byte("Q"), 4096)
newData := append([]byte(nil), old...)
newData[0] = 'X'
patch, hdr, err := MakeDelta(old, newData)
if err != nil {
t.Fatal(err)
}
// 篡改头部里的 NewSHA256(模拟恶意/损坏的补丁)
b := append([]byte(nil), patch...)
off := len(deltaMagic) + 16 + 64
b[off] ^= 0xFF
if _, err := ApplyDelta(old, b); err == nil {
t.Fatal("★ 内容哈希不符时必须拒绝")
}
_ = hdr
}
// ⑥ 空文件与超小文件不崩。
func TestDeltaEdgeCases(t *testing.T) {
cases := [][2][]byte{
{nil, nil},
{[]byte("a"), []byte("b")},
{bytes.Repeat([]byte("a"), 100), bytes.Repeat([]byte("a"), 100)},
{[]byte{}, []byte("new")},
{[]byte("old"), []byte{}},
}
for i, c := range cases {
patch, _, err := MakeDelta(c[0], c[1])
if err != nil {
t.Fatalf("case %d 生成失败: %v", i, err)
}
got, err := ApplyDelta(c[0], patch)
if err != nil {
t.Fatalf("case %d 应用失败: %v", i, err)
}
if !bytes.Equal(got, c[1]) {
t.Errorf("case %d 结果不符", i)
}
}
}
// ⑦ 文件级入口(CLI 用)。
func TestDeltaFileRoundTrip(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "homed.old")
newPath := filepath.Join(dir, "homed.new")
patchPath := filepath.Join(dir, "homed.patch")
outPath := filepath.Join(dir, "homed.out")
oldData := bytes.Repeat([]byte("0123456789abcdef"), 4096)
newData := append([]byte(nil), oldData...)
newData = append(newData[:100], append([]byte("INSERTED"), newData[100:]...)...)
if err := os.WriteFile(oldPath, oldData, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(newPath, newData, 0755); err != nil {
t.Fatal(err)
}
hdr, patchSize, err := MakeDeltaFile(oldPath, newPath, patchPath)
if err != nil {
t.Fatal(err)
}
t.Logf("patch=%d bytes (%.2f%% of new)", patchSize, hdr.Stats(patchSize).Ratio*100)
if _, err := ApplyDeltaFile(oldPath, patchPath, outPath); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(outPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, newData) {
t.Fatal("★ 文件级往返结果不一致")
}
}
// ⑧ 头部解析拒绝非本格式。
func TestParseDeltaHeaderRejectsGarbage(t *testing.T) {
if _, err := ParseDeltaHeader([]byte("not a delta file at all")); err == nil {
t.Fatal("应拒绝非补丁数据")
}
}

180
internal/update/executor.go Normal file
View File

@ -0,0 +1,180 @@
package update
// 内核侧的**升级执行者**:订阅 update_request 并真正执行升级。
//
// ## 为什么执行权必须在内核,而不是某个插件
//
// 升级会替换内核二进制并让进程消失。这个动作的执行者必须满足三条:
//
// ① 不会被用户禁用(插件可以 auto_start:false / disable)
// ② 不依赖插件进程活着(插件崩了请求就丢了)
// ③ 知道自己的 dataDir / binPath(内核才有 ks.DataDir 这个权威值)
//
// 只有内核模块同时满足。而 pluginmgr 作为插件,三条全不满足。
//
// ## 为什么是「延迟挂载」而不是在 Init 里订阅
//
// update 模块必须在**第 0 层**(回滚判定要早于一切模块),但事件总线
// 由 kernelapi 层产出 —— 第 0 层时它还不存在。
//
// 所以分两段:
//
// Init(第 0 层) : 回滚判定 + 能力探测(不需要总线)
// Attach(总线就绪后): 订阅 update_request,接管执行
//
// Attach 由调用方(cmd/homed)在 kernelapi 层完成后调用。它幂等:
// 重复调用不会重复订阅。
import (
"fmt"
"log"
"os"
"sync"
"github.com/JianFeeeee/HologramAgent/internal/events"
)
// executor 是内核侧的升级执行器实例(进程级单例)。
type executor struct {
mu sync.Mutex
attached bool
unsub func()
// running 防止并发升级:两个请求同时进来会互相覆盖备份目录、
// 交错替换 homed 与 depgraph。(旧实现完全没有互斥。)
running bool
}
var kernelExec = &executor{}
// AttachExecutor 挂载内核侧升级执行器(幂等)。
//
// bus 为空时什么也不做(未接总线的宿主,如部分单测)。
// 返回的 cleanup 用于关停时退订。
func AttachExecutor(bus *events.Bus, dataDir string) func() {
if bus == nil {
return func() {}
}
kernelExec.mu.Lock()
if kernelExec.attached {
kernelExec.mu.Unlock()
return func() {}
}
kernelExec.attached = true
kernelExec.unsub = bus.Subscribe(events.EventUpdateRequest, func(evt *events.Event) {
handleUpdateRequest(evt, dataDir, bus)
})
kernelExec.mu.Unlock()
log.Printf("[update] kernel-side upgrade executor attached (data_dir=%s)", dataDir)
return func() {
kernelExec.mu.Lock()
defer kernelExec.mu.Unlock()
if kernelExec.unsub != nil {
kernelExec.unsub()
kernelExec.unsub = nil
}
kernelExec.attached = false
}
}
// handleUpdateRequest 处理一条 update_request。
//
// ## 语义分工
//
// scope=kernel → **本模块**执行(内核升级是内核的职责)
// scope=plugin → 交给插件通道(pluginmgr 自己订阅处理)
//
// 内核侧只接管 kernel,不越界去管插件 —— 插件升级要用 hmapdev 现场编译,
// 那是应用域的事。
//
// ## 为什么异步
//
// 升级会替换二进制并请求重启,本进程随后消失。事件处理器是**同步投递**的
// (events.Bus.Publish 直接调 handler),在处理器里同步做完会让发布方卡住
// 直到进程被杀。
func handleUpdateRequest(evt *events.Event, dataDir string, bus *events.Bus) {
if evt == nil || evt.Payload == nil {
return
}
scope, _ := evt.Payload["scope"].(string)
if scope != "kernel" {
return // 插件升级不归内核执行器
}
pkgPath, _ := evt.Payload["pkg"].(string)
release, _ := evt.Payload["release"].(bool)
kernelExec.mu.Lock()
if kernelExec.running {
kernelExec.mu.Unlock()
log.Printf("[update] 拒绝并发升级请求(来源 %s):已有升级在进行中", evt.Source)
publishResult(bus, "kernel", "", false, "", "", false,
"已有升级正在进行中,本次请求被拒绝(避免备份与替换交错)")
return
}
kernelExec.running = true
kernelExec.mu.Unlock()
go func() {
defer func() {
kernelExec.mu.Lock()
kernelExec.running = false
kernelExec.mu.Unlock()
}()
if err := RunUpgrade(pkgPath, release, dataDir, bus); err != nil {
log.Printf("[update] upgrade failed (source=%s): %v", evt.Source, err)
}
}()
}
// RunUpgrade 执行一次内核升级(内核侧入口,含形态分派)。
//
// 流程:解析来源 → 形态闸门 → 取包 → Apply(写 pending + 原子替换)。
// **不重启**:重启由调用方走 KernelControlAPI(restartCh → exit 42)。
func RunUpgrade(pkgPath string, release bool, dataDir string, bus *events.Bus) error {
src, err := ParseSource(pkgPath, release)
if err != nil {
publishResult(bus, "kernel", "", false, "", "", false, err.Error())
return err
}
bus = eventBusOr(bus)
binPath := KernelBinaryPath()
// ★ 形态闸门:先判断能不能自己升,再决定要不要下载 880MB。
capab := Detect(binPath, dataDir)
plan := PlanUpgrade(capab, pkgPath)
if !plan.Executable {
publishResult(bus, "kernel", "", false, "", "", false, plan.Reason)
return &NotExecutableError{Plan: plan}
}
workDir, err := os.MkdirTemp("", "kernel-upgrade-*")
if err != nil {
publishResult(bus, "kernel", "", false, "", "", false, err.Error())
return fmt.Errorf("建临时目录: %w", err)
}
defer os.RemoveAll(workDir)
root, _, err := src.Resolve(workDir)
if err != nil {
publishResult(bus, "kernel", "", false, "", "", false, err.Error())
return err
}
newDG := FindFile(root, "depgraph.bin")
_, err = Apply(root, binPath, newDG, dataDir, bus)
return err
}
// NotExecutableError 表示本机形态不允许自替换(调用方应转成可执行提示)。
type NotExecutableError struct {
Plan UpgradePlan
}
func (e *NotExecutableError) Error() string {
if len(e.Plan.Commands) > 0 {
return e.Plan.Reason + ";请执行: " + e.Plan.Commands[0]
}
return e.Plan.Reason
}

View File

@ -0,0 +1,160 @@
package update
// 内核侧升级执行器的判据。
//
// 这些用例钉住的是「谁执行」与「能不能并发」——两者错了的后果都比较重:
// 前者会让升级依赖一个可被禁用的插件,后者会让两次升级交错替换文件。
import (
"os"
"path/filepath"
"sync"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/events"
)
// ① AttachExecutor 幂等:重复挂载不重复订阅。
func TestAttachExecutor_Idempotent(t *testing.T) {
bus := events.NewBus()
c1 := AttachExecutor(bus, t.TempDir())
c2 := AttachExecutor(bus, t.TempDir())
if c1 == nil || c2 == nil {
t.Fatal("应返回非 nil 的 cleanup")
}
// 第二次应返回空操作,并且不应再订阅一次
kernelExec.mu.Lock()
attached := kernelExec.attached
kernelExec.mu.Unlock()
if !attached {
t.Error("应处于已挂载状态")
}
c1()
c2()
}
// ② bus 为 nil 时不 panic(未接总线的宿主)。
func TestAttachExecutor_NilBus(t *testing.T) {
c := AttachExecutor(nil, t.TempDir())
if c == nil {
t.Fatal("应返回可调用的 cleanup 而不是 nil")
}
c() // 不应 panic
}
// ③ scope=plugin 的请求**不被内核执行器接管**。
//
// 这是职责边界:插件升级要 hmapdev 现场编译,那是应用域的事。
// 内核越界去管会让两条通道互相打架。
func TestHandleUpdateRequest_IgnoresPluginScope(t *testing.T) {
bus := events.NewBus()
// 清掉可能残留的挂载状态
kernelExec.mu.Lock()
kernelExec.running = false
kernelExec.mu.Unlock()
handleUpdateRequest(&events.Event{
Type: events.EventUpdateRequest,
Source: "test",
Payload: map[string]interface{}{"scope": "plugin", "name": "timer"},
}, t.TempDir(), bus)
kernelExec.mu.Lock()
running := kernelExec.running
kernelExec.mu.Unlock()
if running {
t.Error("★ scope=plugin 不应被内核执行器接管")
}
}
// ④ 并发请求只允许一个通过(旧实现完全没有互斥)。
//
// 判据不是"返回了错误",而是**第二个请求确实没有开始执行**:
// 两个升级同时跑会互相覆盖备份目录、交错替换 homed 与 depgraph。
func TestHandleUpdateRequest_RejectsConcurrent(t *testing.T) {
bus := events.NewBus()
// 手工占住 running,模拟"升级进行中"
kernelExec.mu.Lock()
kernelExec.running = true
kernelExec.mu.Unlock()
t.Cleanup(func() {
kernelExec.mu.Lock()
kernelExec.running = false
kernelExec.mu.Unlock()
})
// 发一个 kernel 请求:应被拒绝且**不启动 goroutine**
handleUpdateRequest(&events.Event{
Type: events.EventUpdateRequest,
Source: "agent",
Payload: map[string]interface{}{"scope": "kernel", "release": true},
}, t.TempDir(), bus)
// running 仍是 true(被拒绝的请求没有改动它)
kernelExec.mu.Lock()
stillRunning := kernelExec.running
kernelExec.mu.Unlock()
if !stillRunning {
t.Error("被拒绝的并发请求不应改变 running 标志")
}
}
// ⑤ RunUpgrade 在形态不允许自替换时返回 NotExecutableError(带可执行命令)。
//
// 这条保证:请求方拿到的是「该走哪条通道」而不是一个裸 error。
func TestRunUpgrade_NonExecutableReturnsPlan(t *testing.T) {
// 造一个"不可写"的环境不可靠(root 无视权限),所以直接构造
// 一个自替换被禁的 Capabilities 路径:用容器形态的判据。
// 这里改为验证 NotExecutableError 本身的语义,不依赖运行环境。
p := UpgradePlan{
Channel: ChannelPackageManager,
Reason: "由 dpkg 安装",
Commands: []string{"sudo apt install --only-upgrade hologramagent-full"},
}
err := &NotExecutableError{Plan: p}
msg := err.Error()
if msg == "" {
t.Fatal("错误消息不能为空")
}
if got := msg; !contains(got, "sudo apt") {
t.Errorf("应包含可执行命令,实际: %s", got)
}
}
func contains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && indexOfStr(s, sub) >= 0)
}
func indexOfStr(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// ⑥ 大量并发调用不产生数据竞争(配合 -race)。
func TestHandleUpdateRequest_NoRaceOnRunning(t *testing.T) {
bus := events.NewBus()
kernelExec.mu.Lock()
kernelExec.running = false
kernelExec.mu.Unlock()
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
handleUpdateRequest(&events.Event{
Type: events.EventUpdateRequest,
Source: "race",
Payload: map[string]interface{}{"scope": "plugin"}, // 不触发执行
}, t.TempDir(), bus)
}()
}
wg.Wait()
_ = os.Getpid
_ = filepath.Join
}

145
internal/update/fsutil.go Normal file
View File

@ -0,0 +1,145 @@
package update
// 文件写入的**唯一**原语。
//
// ## 为什么必须收敛到这里
//
// 全仓曾经有 5 处各自实现的「写临时文件 → rename」:
//
// internal/memory/pipeline/pipeline.go writeFileAtomic
// internal/security/store.go 写密钥库
// internal/update/release.go downloadFile
// internal/update/kernel.go CopyFile
// internal/update/rollback.go (PR 前是直接 os.WriteFile)
//
// 它们的行为不一致,而且**只有一处**在 rename 前 fsync。不一致的方向
// 恰恰是危险的那一侧:没有 fsync 的 rename 在掉电后可能留下**零长度**
// 或**半截**的目标文件 —— rename 的原子性只保证"名字指向谁",不保证
// "内容已落盘"。
//
// 升级路径上这个问题不可接受:homed 被替换成半截文件 ⇒ 内核起不来,
// 而 pending 标记可能已经清了 ⇒ 无法回滚。
//
// ## 三条不变量
//
// 1. 目标路径在任何时刻要么是**完整的旧内容**、要么是**完整的新内容**
// 2. 返回 nil 意味着内容**已落盘**(fsync 过),不只是交给了页缓存
// 3. 失败时**不动**目标文件(临时文件被清掉)
import (
"fmt"
"io"
"os"
"path/filepath"
)
// 临时文件后缀。刻意区分于 Apply 的 ".tmp-upgrade":
// 那个是旧实现的遗留命名,新代码统一用 WriteFileAtomic/CopyFileAtomic。
const atomicTmpSuffix = ".tmp-atomic"
// WriteFileAtomic 原子地写入文件(临时文件 → fsync → rename → fsync 目录)。
//
// perm 只在新文件创建时生效;目标已存在时保留其原有权限位
// (升级替换 homed 时不能因为传了 0644 就把可执行位丢掉)。
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("建目录 %s: %w", dir, err)
}
mode := perm
if st, err := os.Stat(path); err == nil {
mode = st.Mode().Perm()
}
tmp := path + atomicTmpSuffix
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return fmt.Errorf("建临时文件 %s: %w", tmp, err)
}
cleanup := func() {
f.Close()
os.Remove(tmp)
}
if _, err := f.Write(data); err != nil {
cleanup()
return fmt.Errorf("写 %s: %w", tmp, err)
}
// ★ fsync 必须在 Close 之前:Close 只把数据交给内核页缓存。
if err := f.Sync(); err != nil {
cleanup()
return fmt.Errorf("fsync %s: %w", tmp, err)
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return fmt.Errorf("close %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename %s → %s: %w", tmp, path, err)
}
syncDir(dir)
return nil
}
// CopyFileAtomic 原子地复制文件(用于替换可执行文件)。
//
// 与 WriteFileAtomic 的差别只有来源:这里流式拷贝,避免把 88MB 的
// 二进制整个读进内存。
//
// ★ 权限位取**源文件**的(升级包里的 homed 是 0755,必须保留可执行位)。
func CopyFileAtomic(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
st, err := in.Stat()
if err != nil {
return err
}
dir := filepath.Dir(dst)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("建目录 %s: %w", dir, err)
}
tmp := dst + atomicTmpSuffix
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, st.Mode().Perm())
if err != nil {
return fmt.Errorf("建临时文件 %s: %w", tmp, err)
}
cleanup := func() {
out.Close()
os.Remove(tmp)
}
if _, err := io.Copy(out, in); err != nil {
cleanup()
return fmt.Errorf("拷贝 %s → %s: %w", src, tmp, err)
}
if err := out.Sync(); err != nil {
cleanup()
return fmt.Errorf("fsync %s: %w", tmp, err)
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return fmt.Errorf("close %s: %w", tmp, err)
}
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename %s → %s: %w", tmp, dst, err)
}
syncDir(dir)
return nil
}
// syncDir 让 rename 本身落盘。
//
// ★ 失败只忽略,不报错:部分文件系统(overlay/tmpfs/容器层)对目录
// fsync 返回 EINVAL。此时 rename 已完成、内容已正确,为此让升级失败
// 只会把可用环境弄成不可用。方向是安全的:最坏情况是掉电后连 rename
// 都没生效,磁盘上留的仍是**完整的旧文件**。
func syncDir(dir string) {
d, err := os.Open(dir)
if err != nil {
return
}
defer d.Close()
_ = d.Sync()
}

141
internal/update/gc.go Normal file
View File

@ -0,0 +1,141 @@
package update
// 备份目录的垃圾回收。
//
// ## 为什么必须有 GC
//
// 每次 Apply 都在 dataDir 下留一个 homed-upgrade-backup-<ts>(homed +
// depgraph.bin,通常几十 MB),每次启动时回滚坏二进制还会多一个
// homed-broken-<ts>。旧代码里 Outcome.BackupDir 的注释写着
// "确认稳定后可删",但**全仓没有一行删除逻辑** —— 注释承诺了一个
// 不存在的机制,而这类注释比没注释更糟:读代码的人会以为已经处理了。
//
// 不删的后果不是"占点磁盘"这么温和:dataDir 和 pending 标记在同一个
// 文件系统上,磁盘被备份吃满之后 pending 标记就写不进去 ⇒ Apply 拒绝
// 升级(守卫是对的,但保护频繁触发本身就是故障)。
//
// ## 保留策略:upgrade 备 3 个,broken 备 1 个
//
// 为什么不对称:
//
// · homed-upgrade-backup-* 是**回滚源**,可能被任意一次"上次升级的
// pending 标记"引用。多留几个才能覆盖"升级→重启→再升级"的连环操作。
// · homed-broken-* 是**事后现场**,只有人工排查时才看,且总是看最近
// 一次。留 1 个(而非 0 个)是为了不丢现场。
//
// 排序用目录名而不是 mtime:名字里的 ts 是 NowStamp() 的
// "20060102-150405"(定长、字典序 == 时间序),不依赖文件系统时间戳的
// 精度和时区,也不会被一次 cp -a 打乱。
import (
"log"
"os"
"path/filepath"
"sort"
"strings"
)
const (
// BackupDirPrefix 是升级前备份目录名的前缀,需与 Apply 中拼接的一致。
BackupDirPrefix = "homed-upgrade-backup-"
// BrokenDirPrefix 是回滚时留存的"坏二进制"目录名前缀。
BrokenDirPrefix = "homed-broken-"
// keepUpgradeBackups 保留的升级备份个数(回滚源,需要覆盖连环升级)。
keepUpgradeBackups = 3
// keepBrokenDirs 保留的坏二进制现场个数(只用于事后排查)。
keepBrokenDirs = 1
)
// BrokenDirPath 返回某个时间戳的坏二进制留档目录(位于 dataDir 下)。
//
// ★ 为什么不放在二进制目录旁:二进制目录通常是 /usr/local/bin,
// 在 ProtectSystem=strict 下只读,写不进去就等于**丢掉现场**。
func BrokenDirPath(dataDir, ts string) string {
return filepath.Join(dataDir, BrokenDirPrefix+ts)
}
// PruneOldBackups 清理 dataDir 下过量的备份目录。
//
// 保留最近 keepUpgradeBackups 个 homed-upgrade-backup-* 与最近
// keepBrokenDirs 个 homed-broken-*,其余 RemoveAll。
//
// 设计上的克制(这几条都是为了"GC 本身不能造成故障"):
//
// - dataDir 为空 ⇒ 直接返回。空值曾被当作"当前目录",在这类函数里
// 那就是灾难(会去删 CWD 下匹配前缀的东西)。
// - 任何一次 RemoveAll 失败都只 log,不返回 error —— 调用点在 Apply
// 成功路径的末尾,GC 失败绝不能让一次**已经成功的升级**变成失败。
// - 只处理目录、只处理名字精确匹配 "<前缀><ts>" 形态的项(ts 必须是
// NowStamp 的形状,见 isBackupStamp)—— 不递归、不碰 pending 标记、
// 不碰名字碰巧带同样前缀的其它东西。
func PruneOldBackups(dataDir string) {
if dataDir == "" {
return
}
pruneByPrefix(dataDir, BackupDirPrefix, keepUpgradeBackups)
pruneByPrefix(dataDir, BrokenDirPrefix, keepBrokenDirs)
}
// isBackupStamp 判断 s 是否是 NowStamp() 的形状(20060102-150405)。
//
// 只认定长数字+一个分隔短横:定长保证字典序 == 时间序,数字保证
// 不会被任意名字混进来。
func isBackupStamp(s string) bool {
if len(s) != len("20060102-150405") {
return false
}
for i := 0; i < len(s); i++ {
if i == 8 {
if s[i] != '-' {
return false
}
continue
}
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// pruneByPrefix 保留名字以 prefix 开头的最新 keep 个目录,其余删除。
func pruneByPrefix(dir, prefix string, keep int) {
entries, err := os.ReadDir(dir)
if err != nil {
return // 目录还不存在(首次升级)就没什么可清的
}
var names []string
for _, e := range entries {
if !e.IsDir() {
continue
}
n := e.Name()
if !strings.HasPrefix(n, prefix) || len(n) == len(prefix) {
continue
}
// ★ 后缀必须是时间戳形态。
//
// 只查前缀不查后缀的话,一个名字碰巧以该前缀开头的目录(如手工建的
// homed-upgrade-backup-manual)会被当成备份,且因为 'm' > '9' 会排在
// 时间戳**前面**当作"最新" ⇒ 它挤掉真正的备份、然后真正的最新备份
// 反被删。GC 只应动它自己造的东西。
if !isBackupStamp(n[len(prefix):]) {
continue
}
names = append(names, n)
}
if len(names) <= keep {
return
}
// 名字里的时间戳定长且字典序 == 时间序(见文件头),倒序即最新在前。
sort.Sort(sort.Reverse(sort.StringSlice(names)))
for _, n := range names[keep:] {
p := filepath.Join(dir, n)
if err := os.RemoveAll(p); err != nil {
log.Printf("[update] prune %s failed: %v", p, err)
continue
}
log.Printf("[update] pruned old backup %s(保留最近 %d 个)", p, keep)
}
}

548
internal/update/install.go Normal file
View File

@ -0,0 +1,548 @@
package update
// 安装形态与升级能力**探测**。
//
// ## 为什么必须有这一层
//
// 同一次升级,在不同安装形态下的正确做法完全不同:
//
// deb/rpm → 文件归包管理器所有。直接替换会让 dpkg/rpm 数据库与实际文件
// 不一致:下次 `apt upgrade` 会把改动覆盖回去,或者报
// "本地文件被修改"并拒绝升级。**必须交给包管理器**。
// tar → 没有包管理器,自己替换是正确做法。
// container→ 容器文件系统是镜像的一部分,重启即还原。**升级必须重拉镜像**。
//
// 在这层存在之前,`Apply` 只有一条路(直接替换二进制),于是在 deb 形态下
// 它要么因权限失败、要么(以 root 运行时)悄悄把包管理器数据库搞脏。
//
// ## 为什么不是"猜路径"
//
// 判据全部来自**可观测的系统事实**(dpkg 的 info 目录、rpm 数据库、
// 容器标记文件),而不是"二进制在 /usr/bin 所以大概是 deb"这类推断。
// 推断错了的后果是选错升级通道 —— 而这类错误在生产上极难反查。
import (
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/systemd"
)
// DefaultServiceUnit 是内核的服务单元名。
//
// ★ 从 internal/systemd 取,**不在本包重复定义**:单元名曾经在两处各写一份,
// 改名时只改了一处(systemd.UnitName="hologramagent.service"),而
// cmd/homed-upgrade 仍硬编码 "homeagent" —— 升级成功的机器重启了错误的服务。
const DefaultServiceUnit = systemd.UnitName
// InstallKind 是本机的安装形态。
type InstallKind string
const (
// KindDeb 表示由 dpkg 安装(.deb)。
KindDeb InstallKind = "deb"
// KindRPM 表示由 rpm 安装(.rpm)。
KindRPM InstallKind = "rpm"
// KindTar 表示解包安装(无包管理器记录)。
KindTar InstallKind = "tar"
// KindContainer 表示运行在容器里(升级必须重拉镜像)。
KindContainer InstallKind = "container"
// KindSource 表示从源码直接运行(dev 环境)。
KindSource InstallKind = "source"
// KindUnknown 表示探测不出来。
//
// ★ 它的语义是「不知道」,**不是**「没有包管理器所以大概是 tar」。
// 调用方拿到 Unknown 必须拒绝自动升级,而不是退回一条猜的路径。
KindUnknown InstallKind = "unknown"
)
// Variant 是发行包变体(决定包里有哪些文件)。
type Variant string
const (
VariantFull Variant = "full" // homed + waiter + GUI + 模型 + ORT
VariantServer Variant = "server" // homed + waiter + 模型 + ORT
VariantClient Variant = "client" // waiter + GUI(**没有 homed**)
VariantToolchain Variant = "toolchain" // homed + waiter + hmapdev + SDK + 模型
VariantUnknown Variant = "unknown"
)
// HasKernel 报告该变体是否包含内核二进制。
//
// client 包没有 homed ⇒ 它的「升级」不涉及内核,任何内核 OTA 对它都不适用。
func (v Variant) HasKernel() bool {
return v != VariantClient && v != VariantUnknown
}
// Capabilities 是本机升级能力的快照。**所有升级决策的唯一输入**。
type Capabilities struct {
Kind InstallKind `json:"kind"`
Variant Variant `json:"variant"`
BinPath string `json:"bin_path"`
DataDir string `json:"data_dir"`
DepGraph string `json:"depgraph,omitempty"`
PackageName string `json:"package_name,omitempty"` // deb/rpm 的包名
ServiceUnit string `json:"service_unit,omitempty"`
// CanWriteBin 报告当前进程能否**直接替换**二进制。
//
// ★ 这是"能不能自己动手"的唯一判据。为 false 时绝不能尝试替换:
// 失败会在替换中途发生(例如 depgraph 已换、homed 没换),
// 留下一个不一致的安装。
CanWriteBin bool `json:"can_write_bin"`
// Reason 说明为什么当前形态不能自替换(可直接呈现给用户)。
Reason string `json:"reason,omitempty"`
}
// UpgradeChannel 是本机应当走的升级通道。
type UpgradeChannel string
const (
// ChannelPackageManager 表示升级必须由包管理器执行(deb/rpm)。
ChannelPackageManager UpgradeChannel = "package-manager"
// ChannelSelfReplace 表示可以自替换(tar 且目录可写)。
ChannelSelfReplace UpgradeChannel = "self-replace"
// ChannelContainerImage 表示必须重拉镜像。
ChannelContainerImage UpgradeChannel = "container-image"
// ChannelUnsupported 表示无法自动升级(需人工介入)。
ChannelUnsupported UpgradeChannel = "unsupported"
)
// Channel 决定本机该走哪条升级通道。
//
// ## 为什么 deb/rpm 一律走包管理器(即使用户有 root)
//
// 「能写文件」不等于「该写文件」。dpkg 记录了每个受管文件的校验和,
// 绕过它替换的后果:
//
// · 下次 apt upgrade 覆盖回旧版(升级"消失")
// · 或 dpkg 检测到本地修改,**拒绝**后续升级(机器被卡住)
// · 两种都必须人工介入才能恢复
func (c Capabilities) Channel() UpgradeChannel {
switch c.Kind {
case KindContainer:
return ChannelContainerImage
case KindDeb, KindRPM:
return ChannelPackageManager
case KindTar:
if c.CanWriteBin {
return ChannelSelfReplace
}
return ChannelUnsupported
default:
return ChannelUnsupported
}
}
// UpgradeCommand 返回本机**应当执行**的升级命令(给人看的)。
//
// 空串表示“没有可建议的命令”(container/source/unknown)。
func (c Capabilities) UpgradeCommand(target string) string {
switch c.Channel() {
case ChannelPackageManager:
pkg := c.PackageName
if pkg == "" {
pkg = "hologramagent"
}
if c.Kind == KindRPM {
return "sudo dnf upgrade " + pkg
}
if target != "" {
return "sudo apt install --only-upgrade " + pkg + " # 或: sudo dpkg -i " + target
}
return "sudo apt install --only-upgrade " + pkg
case ChannelSelfReplace:
return "homed-upgrade -apply # 本机安装形态允许自替换"
case ChannelUnsupported:
// ★ tar 但目录不可写(最常见的本机形态)也必须给命令。
//
// 此时 Channel() 返回 Unsupported(因为不能自替换),但**升级
// 本身是可行的** —— 只需提权。返回空串会让用户看到“无法升级”
// 却不知道下一步做什么,而实际上一行 sudo 就解决了。
//
// 判据:形态是 tar 且唯一障碍是权限。
if c.Kind == KindTar && strings.Contains(c.Reason, "没有写权限") {
return "sudo homed-upgrade -apply # 需要提权:服务以非 root 运行,动不了 " + filepath.Dir(c.BinPath)
}
return ""
default:
return ""
}
}
// Detect 探测本机安装形态与能力。
//
// binPath 为空时取当前进程的内核二进制路径。**这个兜底只适用于本进程
// 就是 homed 的情形**(kernelmod 启动路径);外部工具(homed-upgrade)
// 必须自己解析并显式传入,否则会把“自己”当成内核。
//
// ★ 实测踩到:CLI 同目录没有 homed 时,resolveBinPath 曾兜底到
//
// os.Executable() ⇒ “升级 homed”实际变成“把 CLI 覆盖成新包里的 homed”,
// 一个 1 字节的假包就能把工具本体换成一段 shell 脚本。
// 现在 resolveBinPath 找不到就返回空,而 Apply 对空 binPath 直接拒绝。
//
// 任何一步探测失败都不会 panic:返回已确定的字段 + Unknown/空值。
func Detect(binPath, dataDir string) Capabilities {
if binPath == "" {
binPath = KernelBinaryPath()
}
c := Capabilities{
BinPath: binPath,
DataDir: dataDir,
ServiceUnit: DefaultServiceUnit,
Kind: KindUnknown,
Variant: VariantUnknown,
}
if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); FileExists(dg) {
c.DepGraph = dg
}
// 容器优先判:容器里也可能装了 deb(基础镜像带来的),
// 但那时替换文件毫无意义 —— 重启容器就回到镜像原样。
if inContainer() {
c.Kind = KindContainer
c.Reason = "运行在容器内:镜像文件系统只读且重启即还原,升级应重拉镜像"
} else {
c.Kind, c.PackageName = detectPackageKind(binPath)
}
c.Variant = detectVariant(binPath)
c.CanWriteBin, c.Reason = canReplaceBinary(c.Kind, binPath)
return c
}
// inContainer 判断是否运行在容器里。
//
// 用容器运行时留下的标准标记(Docker 的 /.dockerenv、Podman/CRI-O 的
// /run/.containerenv)。**不用** /proc/1/cgroup 里的 "docker" 字样:
// systemd-nspawn、WSL2、以及某些嵌套 cgroup 环境会误判。
func inContainer() bool {
for _, p := range []string{"/.dockerenv", "/run/.containerenv"} {
if _, err := os.Stat(p); err == nil {
return true
}
}
return false
}
// detectPackageKind 判断二进制是否归包管理器所有,并返回包名。
//
// 判据是**文件属主记录**,不是路径:
// - dpkg 把每个包的文件清单写在 /var/lib/dpkg/info/<pkg>.list
// - rpm 有 rpm 命令可查
//
// 因此 deb 包装在 /opt 也能被认出来,tar 包解到 /usr/bin 也不会被误认。
func detectPackageKind(binPath string) (InstallKind, string) {
if name := dpkgOwner(binPath); name != "" {
return KindDeb, name
}
if name := rpmOwner(binPath); name != "" {
return KindRPM, name
}
// 无包管理器记录:区分“源码编译产物”与“tar 解包安装”。
//
// ★ 只认 go build 的临时目录形态(/go-build<N>/),**不能用** "/tmp/"。
// 那条太宽了:用户把 tar 包解到 /tmp 试装、或 CI 在 /tmp 里跑
// 集成测,都会被误判成“源码运行”而拒绝升级。判据必须指向
// **构建行为本身**,而不是“文件放在哪”。
if isGoBuildTempPath(binPath) {
return KindSource, ""
}
return KindTar, ""
}
// isGoBuildTempPath 判断路径是否是 `go run`/`go test` 的临时产物。
//
// 形态:<tmpdir>/go-build<digits>/b001/exe/<name>
// 只匹配这个结构,不匹配“放在 tmp 下”。
func isGoBuildTempPath(p string) bool {
i := strings.Index(p, "/go-build")
if i < 0 {
return false
}
// go-build 后面必须紧跟数字(go 的实际命名),否则可能是用户自建的目录。
rest := p[i+len("/go-build"):]
j := 0
for j < len(rest) && rest[j] >= '0' && rest[j] <= '9' {
j++
}
return j > 0 && j < len(rest) && rest[j] == '/'
}
// dpkgOwner 在 dpkg 数据库里查 binPath 属于哪个包。
//
// 直接扫 info 目录里以本仓包名前缀开头的 .list,比 exec dpkg -S 更快
// 且不依赖 dpkg 可执行文件(容器里常被精简掉)。
func dpkgOwner(binPath string) string {
abs, err := filepath.Abs(binPath)
if err != nil {
return ""
}
entries, err := os.ReadDir("/var/lib/dpkg/info")
if err != nil {
return ""
}
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".list") {
continue
}
pkg := strings.TrimSuffix(name, ".list")
// 只看本仓的包:避免为了查一个文件而读几百个包的清单
if !strings.HasPrefix(pkg, "hologramagent") && !strings.HasPrefix(pkg, "homeagent") {
continue
}
b, err := os.ReadFile(filepath.Join("/var/lib/dpkg/info", name))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
if strings.TrimSpace(line) == abs {
return pkg
}
}
}
return ""
}
// rpmOwner 用 rpm 查文件属主(rpm 数据库格式不稳定,交给官方命令)。
func rpmOwner(binPath string) string {
out, err := runQuiet("rpm", "-qf", binPath)
if err != nil {
return ""
}
out = strings.TrimSpace(out)
// rpm 未命中时输出 "file ... is not owned by any package" 且退出码非 0,
// 走到这里说明命中了。
if out == "" || strings.Contains(out, "not owned") {
return ""
}
if i := strings.IndexByte(out, '\n'); i > 0 {
out = out[:i]
}
return strings.TrimSpace(out)
}
// detectVariant 从安装布局推断变体。
//
// 判据用**文件存在性**(包内容),不用包名:包名里的变体后缀
// 在迁移/改名后未必可靠,而"有没有 GUI / hmapdev / 模型"是事实。
func detectVariant(binPath string) Variant {
const (
modelDir = "/usr/lib/hologram/models"
guiDir = "/usr/lib/hologram-gui"
)
hasKernel := FileExists(binPath)
hasModel := dirExists(modelDir)
hasGUI := dirExists(guiDir)
hasToolchain := FileExists("/usr/bin/hmapdev")
switch {
case !hasKernel:
// 没有 homed:只有 client 包是这样
if FileExists("/usr/bin/waiter") || hasGUI {
return VariantClient
}
return VariantUnknown
case hasToolchain && hasModel:
return VariantToolchain
case hasGUI && hasModel:
return VariantFull
case hasModel:
return VariantServer
case hasKernel:
// 有内核但无模型:老包或最小安装
return VariantServer
default:
return VariantUnknown
}
}
// canReplaceBinary 判定能否直接替换二进制,并给出不可行的原因。
//
// 四个条件全部满足才允许:
// 1. 不是包管理器拥有的文件(否则会破坏数据库一致性)
// 2. 不在容器里
// 3. 不是从源码直接运行(那是 dev 环境,升级无意义)
// 4. 当前进程对**目标目录**有写权限(rename 需要目录可写,不是文件可写)
func canReplaceBinary(kind InstallKind, binPath string) (bool, string) {
switch kind {
case KindDeb:
return false, "由 dpkg 安装:直接替换会让包数据库与实际文件不一致,必须用 apt/dpkg 升级"
case KindRPM:
return false, "由 rpm 安装:直接替换会让包数据库与实际文件不一致,必须用 dnf/rpm 升级"
case KindContainer:
return false, "运行在容器内:文件系统随容器销毁还原,必须重拉镜像"
case KindSource:
// ★ 必须显式拒绝:源码运行环境的“升级”是重新构建,不是换磁盘上的文件。
// 旧版本的 Channel() 对 source 返回 unsupported,而 CanWriteBin 却因为
// 目录可写而为 true —— 两个判据互相矛盾,调用方会拿到“能写但不能升”。
return false, "从源码直接运行:升级方式是重新构建,而不是替换二进制"
case KindUnknown:
return false, "无法确定安装形态:拒绝自动替换(可能破坏包管理器状态)"
}
dir := filepath.Dir(binPath)
if !dirWritable(dir) {
return false, "当前用户对 " + dir + " 没有写权限(服务以非 root 用户运行),需提权或改用包管理器"
}
return true, ""
}
// dirWritable 报告能否在 dir 里创建/改名文件。
//
// ★ 必须测**目录**而不是文件:原子替换用的是 rename,它要求的是
//
// 目录写权限。文件本身可写但目录不可写时,rename 依然失败。
func dirWritable(dir string) bool {
probe := filepath.Join(dir, ".ha-write-probe")
f, err := os.OpenFile(probe, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return false
}
f.Close()
os.Remove(probe)
return true
}
func dirExists(p string) bool {
st, err := os.Stat(p)
return err == nil && st.IsDir()
}
// runQuiet 执行一个只读查询命令,返回 stdout(失败返回 error,由调用方忽略)。
//
// 单独抽出来是为了让探测代码不直接依赖 os/exec:探测必须**永不 panic、
// 永不阻塞**,而 exec 的失败模式(命令不存在、权限、超时)都该收敛在一处。
func runQuiet(name string, args ...string) (string, error) {
out, err := exec.Command(name, args...).Output()
return string(out), err
}
// SystemdActive 报告某个 systemd 单元是否正在运行。
//
// 用途只有一个:CLI 升级完成后要告诉用户“接下来会发生什么”——
// 服务在跑就说明必须走热重启通道;没跑就说明下次启动即生效。
//
// ★ 不用它做“要不要自替换”的判据:那由 Capabilities.CanWriteBin 决定,
//
// 两件事没有关系(服务没跑也照样可能没有写权限)。
func SystemdActive(unit string) bool {
if unit == "" {
return false
}
out, err := runQuiet("systemctl", "is-active", unit)
return err == nil && strings.TrimSpace(out) == "active"
}
// DataDirFromConfigDB 只读地取 config.db 里的 core.daemon.data_dir。
//
// ## 为什么要单独一个入口(而不是让调用方自己开库)
//
// data_dir 就在 config.db 里,而 config.db 就在 data_dir 里 —— 这是个
// 循环依赖,所以“找到一个候选 config.db”永远是猜的。但这不是问题:
// **猜的是线索,不是结论**。读出来的值是内核自己写下的,不是我们的推测。
//
// ## ★ 为什么复用 internal/config 而不是自己 sql.Open
//
// 旧实现自己 `sql.Open("sqlite3", ...)` + `PRAGMA query_only=1`。问题是
// cmd/homed-upgrade 用 `CGO_ENABLED=0` 打包,而 go-sqlite3 在无 cgo 时是
// **stub**:连接能开、查询必然失败并返回 "requires cgo to work" ——
// 错误被吞掉、函数返回空串 ⇒ 发行包里“自动发现 dataDir”这条路径
// **从未真正工作过**,而且症状是“无法确定数据目录”,指着完全错误的方向。
//
// 复用 ConfigRegistry 的好处:同一套打开/读取语义、随仓库一起演进,
// 且它本来就处理“库不存在/表不存在”的情形(返回默认值)。
func DataDirFromConfigDB(dir string) string {
p := filepath.Join(dir, "config.db")
if !FileExists(p) {
return ""
}
reg := config.NewConfigRegistry(p)
defer reg.Close()
return strings.TrimSpace(reg.GetString("core.daemon.data_dir", ""))
}
// ── 内核数据目录的**权威源** ─────────────────────────────────────
//
// ## 为什么要有这两个函数
//
// pending 标记、备份目录、坏二进制留档全落在 dataDir 下。而"内核实际用的是
// 哪个 dataDir"只有一个可靠来源:内核启动时的 `-data`(ks.DataDir)。
//
// 历史教训(三类都踩过):
//
// ① 从 exe 路径推导 → /usr/bin/data,而生产是 /var/lib/hologram
// ⇒ 备份目录建在只读目录(EROFS),升级必挂
// ② 硬编码生产路径 → 目录/用户迁移后指向不存在的目录
// ⇒ 标记落到内核读不到的地方 ⇒ **升级失败静默不回滚**
// ③ 读 HOMEAGENT_DATA_DIR → 部署方可能不设,设了也未必与 -data 一致
//
// 所以:**不猜**。内核模块在启动时注入真值,其余调用方读它;
// 取不到就返回空串,由 Apply 的空值守卫拒绝升级。
//
// 「不知道」伪装成「知道」比没有守卫更危险:守卫看起来在守,
// 实际守了个永远非空的常量。
var (
kernelDataDirMu sync.RWMutex
kernelDataDir string
)
// SetKernelDataDir 由内核适配层(internal/kernelmod/update)在启动时注入
// 真实的 `-data` 值。**不是给普通调用方用的**。
func SetKernelDataDir(dir string) {
kernelDataDirMu.Lock()
defer kernelDataDirMu.Unlock()
kernelDataDir = dir
}
// KernelDataDir 返回内核实际使用的 dataDir(取不到时为空串)。
func KernelDataDir() string {
kernelDataDirMu.RLock()
defer kernelDataDirMu.RUnlock()
return kernelDataDir
}
// ── 事件总线的**内核侧注入** ──────────────────────────────────
var (
eventBusMu sync.RWMutex
eventBus *events.Bus
)
// SetEventBus 由内核适配层在启动时注入事件总线。
//
// ## ★ 为什么改成注入,而不是让调用方每次传进来
//
// `Apply` 需要发进度/结果事件。此前签名收 `*events.Bus`,于是**插件**必须
// 拿到总线才能触发升级 —— 而插件拿总线正是"安全监控可被绕过"的根源:
// 一旦把 *Bus 交给插件,它就能 bus.Publish 伪造任意事件。
//
// 改成内核注入后:
//
// · 插件调用 Apply 不用再碰总线(传 nil 即可)
// · 进度事件由**内核子系统**以内核身份发出(Source="update"),语义也正确
// · 插件侧再没有理由持有 *events.Bus
func SetEventBus(b *events.Bus) {
eventBusMu.Lock()
defer eventBusMu.Unlock()
eventBus = b
}
// eventBusOr 返回传入的总线;为空时退回内核注入的那个。
func eventBusOr(b *events.Bus) *events.Bus {
if b != nil {
return b
}
eventBusMu.RLock()
defer eventBusMu.RUnlock()
return eventBus
}

View File

@ -35,12 +35,17 @@ type BinVersion struct {
SDKVersion string
}
// readBinVersion 跑 `<bin> --version` 并解析。
// ReadBinVersion 跑 `<bin> --version` 并解析(含老版本回退,用于**探测**)。
//
// ★ 为什么用"跑它自己"而不是读文件里的字符串:
//
// 版本是 ldflags 注入的,从 ELF 里 grep 只能撞运气;而 --version 是内核
// 自己算出来的,还能一并拿到 commit("同版本号不同 commit"很难察觉)。
//
// ★★ 注意:这个回退使 ReadBinVersion **不能**用作回滚校验。推断出来的
// 版本可能来自**另一个文件**(或运行中的老进程),拿它当“刚拷过去的
// 文件能执行”的证词,会让一个零字节的坏备份“校验通过”。回滚路径用
// digest(见 rollback.go 的 verifyRestored),不用版本。
func ReadBinVersion(bin string) (*BinVersion, error) {
if _, err := os.Stat(bin); err != nil {
return nil, err
@ -174,6 +179,54 @@ func ShortCommit(c string) string {
return c
}
// readBinVersionStrict 只认**这个文件自己**能报出版本,任何回退都不要。
//
// 与 ReadBinVersion 的唯一区别:exec 失败直接返回 error,**不**走
// InferVersionFromRunning。用于“确认分支”这类必须证明“这个文件能用”
// 的判定。
//
// ★ 不要拿它当回滚校验:老内核(1.3.x 及以前)没有 --version,
// 而它们恰恰是最常见的回滚目标。回滚用 digest(verifyRestored)。
func readBinVersionStrict(bin string) (*BinVersion, error) {
if _, err := os.Stat(bin); err != nil {
return nil, err
}
out, err := exec.Command(bin, "--version").CombinedOutput()
if err != nil {
return nil, fmt.Errorf("执行 --version: %v(该文件不可执行或架构不符)", err)
}
v := &BinVersion{}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
k, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "version":
v.Version = val
case "commit":
v.Commit = val
case "build_time":
v.BuildTime = val
case "sdk":
v.SDKVersion = val
}
}
if v.Version == "" {
return nil, fmt.Errorf("--version 输出里没有 version= 行: %q", strings.TrimSpace(string(out)))
}
return v, nil
}
// ShortSum 把 sha256 缩到前 12 位,用于错误消息(完整值对排查无益)。
func ShortSum(s string) string {
if len(s) > 12 {
return s[:12]
}
return s
}
// extractPkg 解包并返回包含 bin/ 的根目录。
func ExtractPkg(pkg, dst string) (string, error) {
f, err := os.Open(pkg)
@ -264,14 +317,44 @@ func FileSize(p string) int64 {
// 所以这里只承诺到"磁盘上的产物已就绪,pending 标记已写"。
// 后续重启 → 新内核启动 → update 模块(第 0 层)确认或回滚。
//
// 每一步失败都往回退:替换中途失败 ⇒ 立即用备份还原并清标记
// (此时尚未重启,回滚在本进程内完成,机器始终可启动)。
// ## 每一步都是可撤销的(顺序不可换)
//
// 预检 → 备份 → 记录 digest → 写 pending → 替换 depgraph → 替换 homed
//
// 任一步失败就地回滚,且**回滚完整性本身就是一种可验证的事实**:
// 每个文件都记了 sha256,还原后逐一校对;只有全部对上才清 marker。
// 这不是“多抄一遍”,“重新读版本”在旧内核上根本做不到 —— 旧内核没有
// `--version`,而旧内核恰恰是最常见的回滚目标。
func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, error) {
// ★ 调用方可以传 nil:内核在启动时 SetEventBus 注入过总线,
// 这里取回那份(插件不再需要持有 *events.Bus)。
bus = eventBusOr(bus)
newBin := FindFile(root, "homed")
if newBin == "" {
return nil, fmt.Errorf("包内无 homed")
}
// ── ★ 最前置的拒绝条件:没有 dataDir 就没有回滚能力 ──
//
// pending 标记、备份目录、坏二进制留档全落在 dataDir 下。空 dataDir
// 意味着标记写不进去 ⇒ 新内核起不来时没有任何人知道该回滚。
// 这种升级不是升级,是赌运气,赌输的代价是机器进崩溃循环。
if dataDir == "" {
return nil, fmt.Errorf("dataDir 为空,拒绝升级:无 pending 标记就无回滚能力")
}
// ── ★ 安装形态闸门:包管理器拥有的文件不允许直接替换 ──
//
// 「能写」不等于「该写」。dpkg/rpm 记录了受管文件的校验和,绕过它
// 替换的后果是下次 apt upgrade 覆盖回旧版,或 dpkg 拒绡后续升级。
capab := Detect(binPath, dataDir)
if !capab.CanWriteBin {
return nil, fmt.Errorf("本机不允许直接替换二进制(%s/%s):%s\n"+
"建议改用包管理器升级:%s",
capab.Kind, capab.Variant, capab.Reason, capab.UpgradeCommand(""))
}
// ── 预检:新二进制必须能自报版本(说明它是可执行的、架构对的)──
nv, err := ReadBinVersion(newBin)
if err != nil {
@ -280,18 +363,36 @@ func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, err
publishProgress(bus, "kernel", "", "verify", "新内核可执行,版本 "+nv.Version)
fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version)
// ── 备份:只备本次会动的文件(不整目录拷贝,那是几百 MB)──
// ── 备份:只备本次会动的文件(不整目录拷贝,那是个几百 MB)──
//
// ★ 备份必须落在 dataDir 下,不能是 /root:服务以 ProtectSystem=strict
// + 非 root 运行,对 /root 没有写权限 ⇒ MkdirAll 必 EACCES。
ts := NowStamp()
backup := filepath.Join("/root", "homed-upgrade-backup-"+ts)
backup := filepath.Join(dataDir, BackupDirPrefix+ts)
if err := os.MkdirAll(backup, 0755); err != nil {
return nil, fmt.Errorf("建备份目录: %w", err)
}
// targets 是本轮会动的**全部**文件。每个都记录 digest —— 回滚时那
// 一组 digest 就是“已完整还原”的判据。
targets := []string{binPath}
if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); FileExists(dg) {
targets = append(targets, dg)
dgPath := filepath.Join(filepath.Dir(binPath), "depgraph.bin")
// ★ hadDepGraph 必须在**备份之前**取:若原本没有、而新包带了,
// 回滚时必须把它删掉。留着会让旧 homed 的内嵌 digest 对不上,
// 而那时 marker 已清 ⇒ 进崩溃循环且无第二次自愈。
hadDepGraph := FileExists(dgPath)
if hadDepGraph {
targets = append(targets, dgPath)
}
digests := make(map[string]string, len(targets))
for _, t := range targets {
if err := CopyFile(t, filepath.Join(backup, filepath.Base(t))); err != nil {
sum, serr := sha256File(t)
if serr != nil {
return nil, fmt.Errorf("计算 %s 的 digest: %w", t, serr)
}
digests[t] = sum
if err := CopyFileAtomic(t, filepath.Join(backup, filepath.Base(t))); err != nil {
return nil, fmt.Errorf("备份 %s: %w", t, err)
}
}
@ -300,62 +401,104 @@ func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, err
// ── 写待确认升级标记(在替换**之前**)──
//
// 顺序很关键:先写标记再替换。若反过来(替换后才写),"替换成功但
// 没来得及写标记就崩了"会丢标记 ⇒ 下次启动不知道该回滚。
// 顺序很关键:先写标记再替换。若反过来(替换后才写),“替换成功但
// 没来得及写标记就崩了”会丢标记 ⇒ 下次启动不知道该回滚。
curVer := &BinVersion{}
if v, verr := ReadBinVersion(binPath); verr == nil {
curVer = v
}
if dataDir != "" {
if perr := MarkPendingUpgrade(dataDir, PendingRecord{
FromVer: curVer.Version,
FromCommit: curVer.Commit,
ToVer: nv.Version,
ToCommit: nv.Commit,
BackupDir: backup,
}); perr != nil {
// 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力,
// 而不带回滚的升级等于赌运气。
return nil, fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr)
}
fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir))
if perr := MarkPendingUpgrade(dataDir, PendingRecord{
FromVer: curVer.Version,
FromCommit: curVer.Commit,
ToVer: nv.Version,
ToCommit: nv.Commit,
BackupDir: backup,
HadDepGraph: hadDepGraph,
Digests: digests,
}); perr != nil {
// 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力。
return nil, fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr)
}
fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir))
// replaceState 记录“替换阶段已经动过哪些文件(以及新包是否带了 depgraph)”。
//
// ★ 为什么不用 targets/resources 推断:旧实现直接用 targets 还原,于是
// “新包带了 depgraph、原机器没有”这种情况会多出一条无源可还原的项,
// 以及“原本有、新包没带”会被误删。动作记录才是事实。
var touchedDepGraph bool
// restore 在替换失败时把已动的文件还原(尚未重启,机器仍可启动)。
//
// ★ restore 完整才清 marker:旧实现先清 marker 再还原,于是“还原也
// 失败”时磁盘上可能是半新半旧、而 marker 已经没了 ⇒ 失去第二次自愈。
restore := func(cause error) (*Outcome, error) {
publishProgress(bus, "kernel", "", "rollback", cause.Error())
fmt.Printf("\n❌ %v\n== 就地还原(尚未重启,机器仍可启动)==\n", cause)
if dataDir != "" {
if cerr := ClearPendingUpgrade(dataDir); cerr != nil {
fmt.Printf(" ⚠️ 清除升级确认标记失败: %v\n", cerr)
}
}
var failed []string
for _, t := range targets {
src := filepath.Join(backup, filepath.Base(t))
if err := CopyFile(src, t); err != nil {
fmt.Printf(" ⚠️ 还原 %s 失败: %v(需人工处理)\n", t, err)
// ★ 用 digest 判“已还原”:比“CopyFile 没报错”强。
// CopyFileAtomic 成功只说明字节搬完了,不说明内容与当时一致
// (备份本身可能已损坏)。
if err := CopyFileAtomic(src, t); err != nil {
failed = append(failed, fmt.Sprintf("%s: %v", t, err))
continue
}
fmt.Printf(" 已恢复 %s\n", t)
if got, serr := sha256File(t); serr != nil || got != digests[t] {
failed = append(failed, fmt.Sprintf("%s: digest 不符", t))
continue
}
fmt.Printf(" 已恢复并校验 %s\n", t)
}
// 新包装了 depgraph 而原机器没有 ⇒ 回滚时必须删掉它。
if touchedDepGraph && !hadDepGraph {
if err := os.Remove(dgPath); err != nil && !os.IsNotExist(err) {
failed = append(failed, fmt.Sprintf("%s: 删除失败 %v", dgPath, err))
} else {
fmt.Printf(" 已移除新装上的 %s(原机器没有)\n", dgPath)
}
}
if len(failed) > 0 {
// ★ marker 保留:磁盘状态不确定,保留才能让下次启动再试。
publishResult(bus, "kernel", "", false, curVer.Version, nv.Version, false,
"替换失败且就地还原不完整: "+strings.Join(failed, "; "))
return nil, fmt.Errorf("替换失败且就地还原不完整(pending 标记已保留,下次启动会再试): %w\n 未还原: %s",
cause, strings.Join(failed, "; "))
}
if cerr := ClearPendingUpgrade(dataDir); cerr != nil {
fmt.Printf(" ⚠️ 清除升级确认标记失败: %v\n", cerr)
}
publishResult(bus, "kernel", "", false, curVer.Version, nv.Version, true, cause.Error())
return nil, fmt.Errorf("替换失败已还原(未重启): %w", cause)
return nil, fmt.Errorf("替换失败已完整还原(未重启,机器仍可启动): %w", cause)
}
// ── 替换(depgraph 必须与 homed 同批,顺序无关但都要成)──
if newDG != "" {
dst := filepath.Join(filepath.Dir(binPath), "depgraph.bin")
if err := CopyFile(newDG, dst); err != nil {
touchedDepGraph = true
if err := CopyFileAtomic(newDG, dgPath); err != nil {
return restore(fmt.Errorf("替换 depgraph.bin: %w", err))
}
fmt.Println("已替换 depgraph.bin")
}
if err := CopyFile(newBin, binPath); err != nil {
if err := CopyFileAtomic(newBin, binPath); err != nil {
return restore(fmt.Errorf("替换 homed: %w", err))
}
fmt.Println("已替换 homed")
publishProgress(bus, "kernel", "", "ready", "新内核已就位,等待重启")
// ★ 成功路径也必须发 result 事件:
// 此前只有失败路径发,订阅方只能靠“进度停在最后一步且没看到 failed”
// 来猜成功,猜错的方向是“以为还在升级”。先发事件再返回:返回后
// 调用方可能立即重启,进程消失。
publishResult(bus, "kernel", "", true, curVer.Version, nv.Version, false, "")
// 备份必须 GC:旧注释写“确认稳定后可删”,但全仓从来没有一行删除逻辑。
PruneOldBackups(dataDir)
return &Outcome{
From: curVer,
To: nv,
@ -370,33 +513,13 @@ type Outcome struct {
BackupDir string // 备份目录(确认稳定后可删)
}
func CopyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
st, err := in.Stat()
if err != nil {
return err
}
tmp := dst + ".tmp-upgrade"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, st.Mode())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
// 原子替换:先写临时文件再 rename,避免"写到一半进程在跑"
return os.Rename(tmp, dst)
}
// CopyFile 是**向后兼容的别名**,内部走 CopyFileAtomic(含 fsync)。
//
// ★ 保留它而不是删:旧调用点(含测试)仍在用这个名字,而旧实现在
//
// rename 前**没有 fsync** —— 掉电后会留下半截文件。统一到原子实现,
// 调用方无需改一行。
func CopyFile(src, dst string) error { return CopyFileAtomic(src, dst) }
func FileExists(p string) bool {
_, err := os.Stat(p)

View File

@ -68,8 +68,20 @@ type ReleaseInfo struct {
Asset string `json:"asset,omitempty"` // 匹配到的资产名(tar.gz)
AssetURL string `json:"asset_url,omitempty"` // 资产下载地址
AssetDigest string `json:"asset_digest,omitempty"` // GitHub SHA-256 digest
HasUpdate bool `json:"has_update"`
Reason string `json:"reason,omitempty"` // 查不到时的说明
// DeltaAsset / DeltaURL 是本版针对**当前本机版本**的差分资产(可选)。
//
// ★ 为何单独一字段而不是复用 Asset:
// 整包与 delta 是两条不同的取包路径,一条失败能退回另一条。
// 混在一个字段里就无法表达“有整包、但 delta 不适用”。
//
// 空串 = 该 release 没有为当前版本提供 delta(首次安装、跳版本、
// 或发布时判定不适合差分)⇒ 调用方直接走整包。
DeltaAsset string `json:"delta_asset,omitempty"`
DeltaURL string `json:"delta_url,omitempty"`
HasUpdate bool `json:"has_update"`
Reason string `json:"reason,omitempty"` // 查不到时的说明
}
// CheckRelease 查 GitHub Release 并与本机版本比较。
@ -114,6 +126,24 @@ func CheckRelease(current *BinVersion) *ReleaseInfo {
}
}
// 找针对**当前本机 commit** 的 delta(可选)。
//
// 命名约定:...-from-<oldCommit8>.delta。带上来源 commit 而不是
// “随便挑一个 delta”:差分只能对特定旧版本应用,挑错基线会静默产出一份
// 看起来正常的垃圾(大小结构都对、就是内容错)。
//
// 找不到就不填 —— 调用方直接走整包,不是错误。
if info.CurrentC != "" && info.CurrentC != "unknown" {
want := "from-" + ShortCommit(info.CurrentC) + ".delta"
for _, a := range rel.Assets {
if strings.HasSuffix(a.Name, want) {
info.DeltaAsset = a.Name
info.DeltaURL = a.BrowserDownloadURL
break
}
}
}
if current == nil || current.Version == "" {
info.Reason = "本机版本未知(内核过老,无 --version):无法比较,请人工确认"
return info

View File

@ -36,6 +36,7 @@ import (
"log"
"os"
"path/filepath"
"strings"
"time"
)
@ -50,8 +51,30 @@ type PendingRecord struct {
// ToVer / ToCommit 是升级**后**的版本(当前正在试的)。
ToVer string `json:"to_version"`
ToCommit string `json:"to_commit"`
// BackupDir 是升级前备份目录(DoUpgrade 创建),回滚从这里取二进制。
// BackupDir 是升级前备份目录(Apply 创建),回滚从这里取二进制。
BackupDir string `json:"backup_dir"`
// HadDepGraph 记录升级**前**机器上是否存在 depgraph.bin。
//
// ★ 为什么必须记(这是一个真实的崩溃循环):新包带了 depgraph、而原机器
// 没有时,回滚只把 homed 换回旧版、不动 depgraph ⇒ 磁盘上是
// 「旧 homed + 新 depgraph」。旧 homed 内嵌的 digest 对不上,
// kernelboot 装载期直接拒绕启动,而 marker 此时已被清 ⇒
// **既起不来也回滚不了**。零值 false 与“旧 marker 无此字段”天然兼容。
HadDepGraph bool `json:"had_depgraph"`
// Digests 是升级前各目标文件的 sha256(key 为**绝对路径**)。
//
// ★ 为什么不能用 `--version` 当回滚判据:旧内核(1.3.x 及以前)
// **没有 --version flag**,而旧内核恰恰是最常见的回滚目标。
// 强行要求它自报版本 = 从旧版本升级失败后永远回滚不了(strict
// 回读失败 → 拒绝启动 → 下次启动仍同一条路径)。
// digest 是“恢复的是当时那份文件”的证据,与二进制支不支持 --version 无关。
//
// 兼容性:缺失时回退到“能执行 + 版本相等”(旧逻辑),
// 新写的人一定带它;老 marker 也能继续工作。
Digests map[string]string `json:"digests,omitempty"`
// At 是写入时间(诊断用)。
At time.Time `json:"at"`
}
@ -131,18 +154,27 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error
//
// 这一步是必须的,而且顺序不能反(2026-10-26 真机实测踩到):
// 标记是在**替换之前**写的,所以升级后的**第一次启动**必然看到它。
// 若不比对版本就直接回滚,那么每次升级都会在第一次启动时被立刻撤销 ——
// 实测表现:`-apply` 报"升级完成",重启后日志却是
// "ROLLED BACK kernel 1.4.0dev → 1.4.0dev(上一次升级未能确认启动)",
// 也就是升级永远不可能生效。
// 若不比对版本就直接回滚,那么每次升级都会在第一次启动时被立刻撤销。
//
// 语义区分(这才是两段式确认的本意):
//
// 当前版本 == 标记里的 To ⇒ 新版本**已经跑起来了** ⇒ 确认,清标记,继续启动
// 当前版本 != To(或读不到)⇒ 新版本没能成为运行版本(exec 失败、被 guard 回退等)
// ⇒ 回滚到备份
//
// ★★★ 确认分支**必须**用严格版本(不可用带推断回退的 ReadBinVersion)。
//
// 这里曾经用 ReadBinVersion,于是与回滚校验是同一个病:它在 exec 失败时会
// 回退到 InferVersionFromRunning()(读 /usr/local/bin/homed 的字符串、或跑
// /usr/local/bin/waiter --version)。已实测的事故形态:binPath 是一个
// 不可执行的残文件,marker.ToVer 恰好等于本机 waiter 的版本 ⇒ 推断回退从
// **waiter** 借到相同版本 ⇒ 与 ToVer 相等 ⇒ confirmed=true ⇒ 清 marker、
// **不回滚** ⇒ 机器带着跑不起来的二进制继续启动,且失去自愈机会。
//
// 判据只有一条:这个文件自己能不能报出版本。报不出 ⇒ confirmed=false
// ⇒ 走回滚。绝不用推断值去证明"新版已跑起来"。
confirmed := false
if cur, cerr := ReadBinVersion(binPath); cerr == nil {
if cur, cerr := readBinVersionStrict(binPath); cerr == nil {
sameVer := cur.Version == rec.ToVer
sameCommit := rec.ToCommit == "" || cur.Commit == rec.ToCommit
confirmed = sameVer && sameCommit
@ -170,10 +202,15 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error
}
// 先备份"当前这个坏的"——万一回滚目标也有问题,至少能回到现场
//
// ★ 落在 dataDir 下,不是二进制目录:二进制目录通常是 /usr/local/bin,
// 只读(ProtectSystem=strict)⇒ MkdirAll 静默失败(旧代码没查 err),
// 现场丢失且日志里只有一条无关的 copy 失败。
ts := NowStamp()
brokenDir := filepath.Join(filepath.Dir(binPath), "homed-broken-"+ts)
os.MkdirAll(brokenDir, 0755)
if err := CopyFile(binPath, filepath.Join(brokenDir, "homed")); err != nil {
brokenDir := BrokenDirPath(dataDir, ts)
if err := os.MkdirAll(brokenDir, 0755); err != nil {
log.Printf("[update] create broken-binary dir %s failed: %v", brokenDir, err)
} else if err := CopyFileAtomic(binPath, filepath.Join(brokenDir, "homed")); err != nil {
// 备份失败不阻断回滚:回滚是主线,留现场是支线
log.Printf("[update] backup of broken binary failed: %v", err)
} else {
@ -181,22 +218,192 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error
}
// 原子替换回旧版
if err := CopyFile(backupBin, binPath); err != nil {
return false, "", "", fmt.Errorf("回滚二进制: %w", err)
if err := CopyFileAtomic(backupBin, binPath); err != nil {
// ★ 不清 marker:拷贝失败时磁盘上可能已是**半新半旧**的文件,
// 保留标记才有"下次启动再试"的机会。
return false, "", "", fmt.Errorf("回滚二进制: %w(marker 保留,下次启动会再试)", err)
}
// ★★★ 回滚的可信性判据:**digest**,不是 `--version`。
//
// ## 为什么不能要求 `--version`(这是 PR #31 引入的兼容性回归)
//
// 旧内核(1.3.x 及以前)**没有 `--version` flag** —— 它是 2026-10-25
// 才加的。而旧内核恰恰是最常见的回滚目标:从 1.3.0 升到 1.4.0、
// 1.4.0 起不来,要回滚的位置就是 1.3.0。
//
// 要求回滚后读版本,结果就是:文件已正确还原成可执行的 1.3.0,
// 但读取严格版本失败 ⇒ 报“回滚失败” ⇒ kernelmod/update 拒绝启动
// ⇒ **从旧版本升级失败后永久回滚不了**,而 marker 保留使其卡在
// 同一个循环里。
//
// ## digest 为什么是更强的判据
//
// 要证明的不是“它能报版本”,而是“恢复的就是升级前那一份”。
// marker 里记了升级前每个文件的 sha256,回滚后逐一校对 ——
// 比 `CopyFile 没报错` 强(字节搬完 ≠ 内容一致),也比 `--version`
// 强(后者对旧版本不可用,对零字节备份可能撞到别的版本)。
rb, verr := verifyRestored(rec, binPath)
if verr != nil {
// 不清 marker:让下次启动再试(也让人有机会介入)
return false, "", "", fmt.Errorf(
"回滚后校验失败: %v;marker 保留(备份可能已损坏,需人工用已知良好版本替换 %s)",
verr, binPath)
}
// 回滚 depgraph.bin(必须与 homed 同批)
if backupDG := filepath.Join(rec.BackupDir, "depgraph.bin"); FileExists(backupDG) {
if err := CopyFile(backupDG, filepath.Join(filepath.Dir(binPath), "depgraph.bin")); err != nil {
return false, "", "", fmt.Errorf("回滚 depgraph.bin: %w", err)
}
//
// ★ 三种情况,缺一不可(旧实现只处理了第一种):
//
// 原来有 + 备份有 → 还原
// 原来有 + 备份无 → 不算成功(两文件不同批)
// 原来无 → **删掉**新包装上的那份,否则旧 homed 的内嵌
// digest 对不上 ⇒ kernelboot 拒绕启动 ⇒ 崩溃循环
if err := restoreDepGraph(rec, binPath); err != nil {
// ★ 不清 marker:homed 回滚了但 depgraph 没跟上 ⇒ 两者不同批。
// 这种状态**不能**当成功:内核启动时会因依赖图不匹配而失败,
// 保留 marker 才能让下次启动再试一遍(且人能看到日志)。
return false, "", "", err
}
// 清标记:已回到旧版,不再有"待确认的新版"
// 只有上面全部通过才清 marker:已确认回到旧版且**可执行**,
// 不存在"待确认的新版"。
if err := ClearPendingUpgrade(dataDir); err != nil {
log.Printf("[update] clear pending after rollback: %v", err)
}
log.Printf("[update] rolled back to %s(备份 %s)", rec.FromVer, rec.BackupDir)
log.Printf("[update] rolled back to %s(备份 %s,已校验)", rb, rec.BackupDir)
return true, rec.FromVer, rec.ToVer, nil
}
// verifyRestored 校验回滚后的状态,返回它现在报的版本(仅作日志用)。
//
// 判据优先级(新 marker 走 digest,旧 marker 降级):
//
// ① marker 里有 Digests[binPath] ⇒ 逐字节 sha256 比对(最可靠)
// ② 没有 digest(旧 marker)⇒ 至少要求能执行且版本 == FromVer
// ③ FromVer 为空 ⇒ 只要求能执行
//
// ★ 降级路径不删除:现场可能还躺着旧 marker(升级进行中、marker 由旧版
// 本写的),而那种 marker 没有 digests。让它显式降级,而不是直接失败。
func verifyRestored(rec *PendingRecord, binPath string) (string, error) {
abs, err := filepath.Abs(binPath)
if err != nil {
abs = binPath
}
want, hasDigest := rec.Digests[abs]
if !hasDigest {
want, hasDigest = rec.Digests[binPath]
}
if hasDigest {
got, serr := sha256File(binPath)
if serr != nil {
return "", fmt.Errorf("计算 %s 的 sha256: %w", binPath, serr)
}
if !strings.EqualFold(got, want) {
return "", fmt.Errorf("digest 不符:期望 %s,实际 %s", ShortSum(want), ShortSum(got))
}
// digest 对上已足够。版本仅用于日志:老内核报不出就报 "unknown"。
if v, verr := readBinVersionStrict(binPath); verr == nil {
return v.Version, nil
}
return rec.FromVer, nil
}
// 旧 marker:没有 digest,降级为“还原内容 == 备份内容”。
//
// ★ 为什么不再用 `--version`(PR #31 的回归):老内核没有这个 flag,
// 而老内核恰恰是最常见的回滚目标 —— 要求它就把“从旧版升级失败”
// 变成永久不能回滚。
//
// 这个降级只能证明“拷过去的就是备份里那份”,不能证明备份本身完好 ——
// 而在旧 marker 上没有更好的证据可用(digest 是本次新增的字段)。
// 新写的人一定有 digest,所以这个分支只在升级进行中、跨版本的
// 遗留 marker 上生效。
backupBin := filepath.Join(rec.BackupDir, filepath.Base(binPath))
wantSum, werr := sha256File(backupBin)
if werr != nil {
return "", fmt.Errorf("备份不可读(%s): %w", backupBin, werr)
}
gotSum, gerr := sha256File(binPath)
if gerr != nil {
return "", fmt.Errorf("读回 %s: %w", binPath, gerr)
}
if !strings.EqualFold(gotSum, wantSum) {
return "", fmt.Errorf("还原内容与备份不一致(期望 %s,实际 %s)",
ShortSum(wantSum), ShortSum(gotSum))
}
// 备份可能与 marker 的 FromVer 不符(备份被别的升级覆盖过)。
// 读得到版本就校,读不到(老内核)就放行 —— 内容一致已经是本分支的上限。
if v, verr := readBinVersionStrict(binPath); verr == nil {
if rec.FromVer != "" && v.Version != rec.FromVer {
return "", fmt.Errorf("版本不符:期望 %s(标记里的 FromVer),实际 %s",
rec.FromVer, v.Version)
}
return v.Version, nil
}
return rec.FromVer, nil
}
// restoreDepGraph 把 depgraph.bin 恢复到升级前的状态。
//
// 这个函数的存在理由是一个真实的崩溃循环:新包带了 depgraph、而原机器
// 没有时,回滚只换 homed 不删 depgraph ⇒ 「旧 homed + 新 depgraph」。
// 生产构建的 homed 会校验编译期内嵌的图 digest,不匹配则**拒绝启动**,
// 而 marker 此时已被清 ⇒ 既起不来也回滚不了。
func restoreDepGraph(rec *PendingRecord, binPath string) error {
dgPath := filepath.Join(filepath.Dir(binPath), "depgraph.bin")
// ★ 先看备份里到底有没有 depgraph,再看 marker 怎么说。
//
// 为什么不能只看 rec.HadDepGraph:这个字段是本次新增的,**存量 marker
// 里都是零值 false**。而一个从旧版本升上来的机器显然是有 depgraph 的
// (打包脚本强制同装)。只看字段会把“该还原”误判成“该删除”。
//
// 反向也成立:字段说 true 但备份里没有,说明备份不完整 —— 那时不能
// 把依图留着不管(会与旧 homed 不批),必须报错。
//
// 所以:**备份里有就是事实**,它优先级高于 marker 字段。
backupDG := filepath.Join(rec.BackupDir, "depgraph.bin")
haveBackup := FileExists(backupDG)
if !haveBackup && !rec.HadDepGraph {
// 升级前没有 ⇒ 现在也不该有。
if err := os.Remove(dgPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("移除升级时新增的 %s: %w(marker 保留)", dgPath, err)
}
log.Printf("[update] removed %s(升级前不存在)", dgPath)
return nil
}
if !haveBackup {
return fmt.Errorf("备份缺 depgraph.bin(%s):homed 已回滚但依图未跟上,marker 保留", backupDG)
}
if err := CopyFileAtomic(backupDG, dgPath); err != nil {
return fmt.Errorf("回滚 depgraph.bin: %w(homed 已回滚但 depgraph 未跟上,marker 保留)", err)
}
// 有 digest 就校:依图错了一样本会拒绕启动。
abs, _ := filepath.Abs(dgPath)
if want, ok := rec.Digests[abs]; ok {
if got, serr := sha256File(dgPath); serr != nil || !strings.EqualFold(got, want) {
return fmt.Errorf("depgraph.bin digest 不符(marker 保留)")
}
}
log.Printf("[update] restored %s", dgPath)
return nil
}
// quarantineMarker 把读不懂的 marker 改名留存(不删)。
//
// 为什么改名而不是删除:那个文件里可能有部分可用信息(比如完整的
// from_version 但 to_ 被截断),是排查"上次升级到底发生了什么"的唯一线索。
// 删了就永久没了。改名能让下一次启动干净地当作"无标记",同时保留现场。
func quarantineMarker(dataDir string) (string, error) {
src := pendingPath(dataDir)
dst := src + ".corrupt-" + NowStamp()
if err := os.Rename(src, dst); err != nil {
return "", err
}
return dst, nil
}

View File

@ -87,6 +87,30 @@ func (s Source) Resolve(workDir string) (root string, rel *ReleaseInfo, err erro
return root, nil, err
case FromRelease:
// ★ 先试差分(第 4 项):只需下载几十 KB 的 delta,用本地旧二进制
// 重建新二进制,不用下 88MB 整包。
//
// 差分是**优化**,不是可用性前提:任何一步不可用就退回整包。
// 实测量级:改一行逻辑只差 80 字节,1MB 文件小改的补丁占 0.8%。
//
// 为何先取 info 再决定:delta 是否可用取决于**当前本机 commit**
// 能否在 release 资产里找到匹配的 `-from-<commit>.delta`。
// 为何传 current 而不是 nil:delta 的匹配需要**本机 commit**
// (命名里的 `-from-<commit8>`)。传 nil 会让 CurrentC 为空,
// 于是永远匹配不到 delta —— 那会让整条差分路径静默失效。
preInfo := CheckRelease(currentVersionForDelta())
if preInfo.DeltaURL != "" {
if ok, derr := TryDeltaUpgrade(workDir, KernelBinaryPath(), preInfo.Latest, preInfo); ok {
root := filepath.Join(workDir)
if FindFile(root, "homed") != "" {
return root, preInfo, nil
}
} else if derr != nil {
// 不当作失败:记录后走整包。不向上抛,避免“差分失败→升级失败”。
preInfo.Reason = "差分不可用,退回整包:" + derr.Error()
}
}
pkgPath := filepath.Join(workDir, "release.tar.gz")
info, derr := DownloadLatestRelease(pkgPath)
if derr != nil {