diff --git a/.gitignore b/.gitignore
index 10dbb9af..638faef2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -131,3 +131,6 @@ dist/
# 有意不提交:二进制大文件会永久留在 git 历史里,且需与本机 nlp 加载路径配套。
# 需要时由部署脚本/人工放置到该目录。
/nlp/
+
+# 内核升级 CLI 的本地构建产物(与 /homed 同理,不入库)
+/homed-upgrade
diff --git a/cmd/homed-upgrade/main.go b/cmd/homed-upgrade/main.go
index dad9ece1..66f6e082 100644
--- a/cmd/homed-upgrade/main.go
+++ b/cmd/homed-upgrade/main.go
@@ -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
` 运行,标记必须落在同一个 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("", "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 (与内核 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 `(人工直接给,最高优先)
+// 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
diff --git a/cmd/homed/main.go b/cmd/homed/main.go
index 9661f5d4..e4d3a617 100644
--- a/cmd/homed/main.go
+++ b/cmd/homed/main.go
@@ -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 是从 /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
diff --git a/deploy/hologramagent.service b/deploy/hologramagent.service
index 8f636ed0..367febff 100644
--- a/deploy/hologramagent.service
+++ b/deploy/hologramagent.service
@@ -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。
diff --git a/deploy/packaging/linux/deb/postinst b/deploy/packaging/linux/deb/postinst
index 14e3a860..02b920f6 100644
--- a/deploy/packaging/linux/deb/postinst
+++ b/deploy/packaging/linux/deb/postinst
@@ -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"
# 初始化凭据和数据库。
diff --git a/deploy/packaging/linux/hologramagent.spec b/deploy/packaging/linux/hologramagent.spec
index fdddf0c6..d17043fd 100644
--- a/deploy/packaging/linux/hologramagent.spec
+++ b/deploy/packaging/linux/hologramagent.spec
@@ -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
diff --git a/deploy/packaging/linux/setup-user.sh b/deploy/packaging/linux/setup-user.sh
new file mode 100755
index 00000000..75629d72
--- /dev/null
+++ b/deploy/packaging/linux/setup-user.sh
@@ -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
diff --git a/deploy/packaging/package-linux.sh b/deploy/packaging/package-linux.sh
index 056a6ae5..48d995c7 100644
--- a/deploy/packaging/package-linux.sh
+++ b/deploy/packaging/package-linux.sh
@@ -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
diff --git a/internal/events/bus.go b/internal/events/bus.go
index 03aeabc4..e2506b41 100644
--- a/internal/events/bus.go
+++ b/internal/events/bus.go
@@ -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
}
diff --git a/internal/events/guard.go b/internal/events/guard.go
new file mode 100644
index 00000000..2a52710b
--- /dev/null
+++ b/internal/events/guard.go
@@ -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)
+}
diff --git a/internal/events/guard_test.go b/internal/events/guard_test.go
new file mode 100644
index 00000000..19e4a419
--- /dev/null
+++ b/internal/events/guard_test.go
@@ -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)
+ }
+}
diff --git a/internal/kernelboot/kernelstate.go b/internal/kernelboot/kernelstate.go
index 93984bfd..371e3c7c 100644
--- a/internal/kernelboot/kernelstate.go
+++ b/internal/kernelboot/kernelstate.go
@@ -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
diff --git a/internal/kernelmod/security/mod.go b/internal/kernelmod/security/mod.go
index 19221528..91700446 100644
--- a/internal/kernelmod/security/mod.go
+++ b/internal/kernelmod/security/mod.go
@@ -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))
}
diff --git a/internal/kernelmod/update/mod.go b/internal/kernelmod/update/mod.go
index 1e1b2691..7edde175 100644
--- a/internal/kernelmod/update/mod.go
+++ b/internal/kernelmod/update/mod.go
@@ -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
}
diff --git a/internal/memory/cut.go b/internal/memory/cut.go
index 2123a0a8..8570e758 100644
--- a/internal/memory/cut.go
+++ b/internal/memory/cut.go
@@ -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)")
diff --git a/internal/memory/jieba_embed.go b/internal/memory/jieba_embed.go
index 80cda2e4..2bd4daae 100644
--- a/internal/memory/jieba_embed.go
+++ b/internal/memory/jieba_embed.go
@@ -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) {
+// ① 部署目录 /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()
diff --git a/internal/memory/jieba_external_test.go b/internal/memory/jieba_external_test.go
new file mode 100644
index 00000000..6b24e10e
--- /dev/null
+++ b/internal/memory/jieba_external_test.go
@@ -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
+}
diff --git a/internal/nlp/onnx.go b/internal/nlp/onnx.go
index f0eb7b54..1bea9047 100644
--- a/internal/nlp/onnx.go
+++ b/internal/nlp/onnx.go
@@ -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 版本之间它从未变过。
+ //
+ // 查找顺序(只认非空文件):
+ // ① /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")
diff --git a/internal/plugins/pluginmgr/kernel_update.go b/internal/plugins/pluginmgr/kernel_update.go
index ff9bc1cb..9766c3cc 100644
--- a/internal/plugins/pluginmgr/kernel_update.go
+++ b/internal/plugins/pluginmgr/kernel_update.go
@@ -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 反推:/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 {
- // /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)
}
}()
diff --git a/internal/plugins/pluginmgr/plugin.go b/internal/plugins/pluginmgr/plugin.go
index 34d3aff3..67f58819 100644
--- a/internal/plugins/pluginmgr/plugin.go
+++ b/internal/plugins/pluginmgr/plugin.go
@@ -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{
diff --git a/internal/plugins/webui/kernel_update.go b/internal/plugins/webui/kernel_update.go
index 144a2f6a..09e5d199 100644
--- a/internal/plugins/webui/kernel_update.go
+++ b/internal/plugins/webui/kernel_update.go
@@ -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()
}
diff --git a/internal/sdk/events_test.go b/internal/sdk/events_test.go
index 6bdf58d7..dabace53 100644
--- a/internal/sdk/events_test.go
+++ b/internal/sdk/events_test.go
@@ -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 {
diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go
index ffe51de9..e649598b 100644
--- a/internal/sdk/plugin.go
+++ b/internal/sdk/plugin.go
@@ -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 消费。
diff --git a/internal/security/eventguard.go b/internal/security/eventguard.go
new file mode 100644
index 00000000..6fff4cb8
--- /dev/null
+++ b/internal/security/eventguard.go
@@ -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)
diff --git a/internal/security/eventpolicy.go b/internal/security/eventpolicy.go
index 2066cfd2..ede0f578 100644
--- a/internal/security/eventpolicy.go
+++ b/internal/security/eventpolicy.go
@@ -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) {
diff --git a/internal/security/eventpolicy_test.go b/internal/security/eventpolicy_test.go
index a3c3e4c9..b89bd365 100644
--- a/internal/security/eventpolicy_test.go
+++ b/internal/security/eventpolicy_test.go
@@ -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("订阅未登记事件应被拒绝")
}
}
diff --git a/internal/security/eventpolicy_update_test.go b/internal/security/eventpolicy_update_test.go
new file mode 100644
index 00000000..1e0d8a3c
--- /dev/null
+++ b/internal/security/eventpolicy_update_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/update/apply_regression_test.go b/internal/update/apply_regression_test.go
new file mode 100644
index 00000000..1d200829
--- /dev/null
+++ b/internal/update/apply_regression_test.go
@@ -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 未被还原")
+ }
+}
diff --git a/internal/update/channel.go b/internal/update/channel.go
new file mode 100644
index 00000000..1760bbe9
--- /dev/null
+++ b/internal/update/channel.go
@@ -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")
+}
diff --git a/internal/update/channel_test.go b/internal/update/channel_test.go
new file mode 100644
index 00000000..fb0549f3
--- /dev/null
+++ b/internal/update/channel_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/update/delta.go b/internal/update/delta.go
new file mode 100644
index 00000000..68a4b716
--- /dev/null
+++ b/internal/update/delta.go
@@ -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
diff --git a/internal/update/delta_ota.go b/internal/update/delta_ota.go
new file mode 100644
index 00000000..79f549c9
--- /dev/null
+++ b/internal/update/delta_ota.go
@@ -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-.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-`,而那个 commit 来自 CurrentC。
+// 传 nil 会让 CurrentC 为空 ⇒ 永远匹配不到 delta ⇒ 整条差分路径静默失效
+// (看起来"差分已上线",实际每次都在下整包)。
+//
+// 读不到版本时返回 nil 是合理的降级:老内核没有 --version,
+// 那种情况下也确实不该尝试差分(连基线身份都无法确认)。
+func currentVersionForDelta() *BinVersion {
+ v, err := ReadBinVersion(KernelBinaryPath())
+ if err != nil {
+ return nil
+ }
+ return v
+}
diff --git a/internal/update/delta_test.go b/internal/update/delta_test.go
new file mode 100644
index 00000000..1f4cde00
--- /dev/null
+++ b/internal/update/delta_test.go
@@ -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("应拒绝非补丁数据")
+ }
+}
diff --git a/internal/update/executor.go b/internal/update/executor.go
new file mode 100644
index 00000000..a24dd190
--- /dev/null
+++ b/internal/update/executor.go
@@ -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
+}
diff --git a/internal/update/executor_test.go b/internal/update/executor_test.go
new file mode 100644
index 00000000..e8b69f1a
--- /dev/null
+++ b/internal/update/executor_test.go
@@ -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
+}
diff --git a/internal/update/fsutil.go b/internal/update/fsutil.go
new file mode 100644
index 00000000..be1812ec
--- /dev/null
+++ b/internal/update/fsutil.go
@@ -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()
+}
diff --git a/internal/update/gc.go b/internal/update/gc.go
new file mode 100644
index 00000000..c402d929
--- /dev/null
+++ b/internal/update/gc.go
@@ -0,0 +1,141 @@
+package update
+
+// 备份目录的垃圾回收。
+//
+// ## 为什么必须有 GC
+//
+// 每次 Apply 都在 dataDir 下留一个 homed-upgrade-backup-(homed +
+// depgraph.bin,通常几十 MB),每次启动时回滚坏二进制还会多一个
+// homed-broken-。旧代码里 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 必须是
+// 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)
+ }
+}
diff --git a/internal/update/install.go b/internal/update/install.go
new file mode 100644
index 00000000..ad7d690b
--- /dev/null
+++ b/internal/update/install.go
@@ -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/.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/),**不能用** "/tmp/"。
+ // 那条太宽了:用户把 tar 包解到 /tmp 试装、或 CI 在 /tmp 里跑
+ // 集成测,都会被误判成“源码运行”而拒绝升级。判据必须指向
+ // **构建行为本身**,而不是“文件放在哪”。
+ if isGoBuildTempPath(binPath) {
+ return KindSource, ""
+ }
+ return KindTar, ""
+}
+
+// isGoBuildTempPath 判断路径是否是 `go run`/`go test` 的临时产物。
+//
+// 形态:/go-build/b001/exe/
+// 只匹配这个结构,不匹配“放在 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
+}
diff --git a/internal/update/kernel.go b/internal/update/kernel.go
index 93e3caa0..4aa4c0b2 100644
--- a/internal/update/kernel.go
+++ b/internal/update/kernel.go
@@ -35,12 +35,17 @@ type BinVersion struct {
SDKVersion string
}
-// readBinVersion 跑 ` --version` 并解析。
+// ReadBinVersion 跑 ` --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)
diff --git a/internal/update/release.go b/internal/update/release.go
index 7e9f7ad3..eb521bd3 100644
--- a/internal/update/release.go
+++ b/internal/update/release.go
@@ -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-.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
diff --git a/internal/update/rollback.go b/internal/update/rollback.go
index 9fe100cd..27bdd042 100644
--- a/internal/update/rollback.go
+++ b/internal/update/rollback.go
@@ -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
+}
diff --git a/internal/update/source.go b/internal/update/source.go
index 26631ab9..cb243e9d 100644
--- a/internal/update/source.go
+++ b/internal/update/source.go
@@ -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-.delta`。
+ // 为何传 current 而不是 nil:delta 的匹配需要**本机 commit**
+ // (命名里的 `-from-`)。传 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 {