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

三条主线,由 e2e 实测驱动。

## 一、差分 OTA(internal/update/delta.go)

实测:改一行逻辑,88MB 的二进制逐字节只差 80 字节,而 OTA 一直下整包。

用 rsync 族的滚动哈希块匹配(4KB 粒度),不是 bsdiff —— 后者需 17×N
峰值内存(88MB → 1.5GB),而升级恰可能发生在内存紧张时。

真实二进制实测:88,036,728 字节 → 补丁 77,316 字节(0.088%)。

三道闸:应用前验旧文件 sha256(错基线会静默产出垃圾)、应用后验结果
sha256、COPY/LITERAL 越界防护。任何失败**退回整包**,不向上抛 ——
差分是优化,不是可用性前提。

接入:Source.Resolve 优先试 delta;CheckRelease 按 -from-<commit8>.delta
匹配(不猜基线);CLI 新增 -make-delta;打包脚本 build_delta(需
PREVIOUS_HOMED),delta 进 SHA256SUMS。

## 二、安装形态分派(internal/update/install.go + channel.go)

内核对「能不能自己升级」此前只有一个答案:直接替换二进制。而 deb/rpm
形态下那样做会让 dpkg/rpm 数据库与实际内容不一致(下次 apt upgrade
覆盖回旧版,或 dpkg 拒绝后续升级)。

新增探测:deb/rpm/tar/container/source 五类形态 + 变体(full/server/
client/toolchain)+ 目录写权限。判据全部来自可观测系统事实(dpkg 的
info 目录、容器标记文件),不做路径推断。

三端统一走 PlanUpgrade 分派;形态闸门放在**下载 880MB 之前**。

## 三、事件总线统一受安全监控(internal/events + security)

用户指正:「eventbus 上所有事件都要接受安全模块的监控」。

此前 EventPolicy 只被 internal/sdk/events.go 调用,而那是四条路径中的
一条。s.Publish / s.Subscribe / s.EventBus() 拿的是裸 *events.Bus,
任何插件都能绕过策略发任意事件(含伪造内核事件)。

现在闸门在 Bus.Publish/Subscribe 本体上(Guard 接口注入,避免 events
↔ security 成环),四条路径全部覆盖。sdk.EventBus() 改返回带身份的
BusHandle。内核身份免授权,但载荷照样过凭据闸门。

白名单按主体收窄:agent_output/stage/terminal_output 只允许承载者
(webui/cli/agentcli)发布 —— 它们是内核向外的输出面,谁都能发就等于
能伪造 agent 说的话。

## e2e 暴露并修掉的三个真实回归

	[bus] subscribe denied: pluginmgr type=update_request
	[bus] subscribe denied: skillmgr type=skill_detected

前两个让升级请求静默丢失、职责链交接断掉。内部协作事件必须可订阅
(安全边界在发布侧,订阅只是「听得到」)。

## 顺带的止血修复

- 回滚判据从 `--version` 改为 digest:旧内核(1.3.x 及以前)没有该
  flag,而它恰是最常见的回滚目标 —— 原逻辑让「从旧版升级失败」永久
  无法回滚。
- CopyFile 补 fsync(替换 homed 本体时掉电会留半截文件)。
- restore 完整还原才清 marker(原先先清再还原,还原失败就失去第二次
  自愈机会)。
- 回滚时移除升级中新增的 depgraph(否则「旧 homed + 新 depgraph」
  会因 digest 不匹配崩溃循环)。
- homed-upgrade 不再硬编码 systemctl restart(丢 guard 状态、unit 名
  漂移);改用内核已有的 restartCh → exit 42 语义。
- homed-upgrade 不再用 CGO_ENABLED=0 编却依赖 go-sqlite3(stub 下
  静默失效,该路径从未工作过);改用 internal/config。
- 服务单元补 User=hologramagent + 三条安装路径补建用户(此前发行物里
  完全没有落实降权)。
- rpm 的 fpm 调用补 --after-install(此前 rpm 装完根本不建用户)。

## 判据

新增 30 条:差分 8(往返/压缩比/错基线/截断/篡改/边界)、通道 8、
执行器 6、事件闸门 8、按主体授权 8。全仓 go test ./... 0 失败。
This commit is contained in:
JianFeeeee
2026-10-29 09:54:55 +08:00
parent 7f68d2b1bf
commit ba281a855a
42 changed files with 4897 additions and 272 deletions

View File

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

View File

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