mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 09:17:31 +00:00
feat(update): 差分 OTA + 安装形态分派 + 事件总线统一受安全监控
三条主线,由 e2e 实测驱动。 ## 一、差分 OTA(internal/update/delta.go) 实测:改一行逻辑,88MB 的二进制逐字节只差 80 字节,而 OTA 一直下整包。 用 rsync 族的滚动哈希块匹配(4KB 粒度),不是 bsdiff —— 后者需 17×N 峰值内存(88MB → 1.5GB),而升级恰可能发生在内存紧张时。 真实二进制实测:88,036,728 字节 → 补丁 77,316 字节(0.088%)。 三道闸:应用前验旧文件 sha256(错基线会静默产出垃圾)、应用后验结果 sha256、COPY/LITERAL 越界防护。任何失败**退回整包**,不向上抛 —— 差分是优化,不是可用性前提。 接入:Source.Resolve 优先试 delta;CheckRelease 按 -from-<commit8>.delta 匹配(不猜基线);CLI 新增 -make-delta;打包脚本 build_delta(需 PREVIOUS_HOMED),delta 进 SHA256SUMS。 ## 二、安装形态分派(internal/update/install.go + channel.go) 内核对「能不能自己升级」此前只有一个答案:直接替换二进制。而 deb/rpm 形态下那样做会让 dpkg/rpm 数据库与实际内容不一致(下次 apt upgrade 覆盖回旧版,或 dpkg 拒绝后续升级)。 新增探测:deb/rpm/tar/container/source 五类形态 + 变体(full/server/ client/toolchain)+ 目录写权限。判据全部来自可观测系统事实(dpkg 的 info 目录、容器标记文件),不做路径推断。 三端统一走 PlanUpgrade 分派;形态闸门放在**下载 880MB 之前**。 ## 三、事件总线统一受安全监控(internal/events + security) 用户指正:「eventbus 上所有事件都要接受安全模块的监控」。 此前 EventPolicy 只被 internal/sdk/events.go 调用,而那是四条路径中的 一条。s.Publish / s.Subscribe / s.EventBus() 拿的是裸 *events.Bus, 任何插件都能绕过策略发任意事件(含伪造内核事件)。 现在闸门在 Bus.Publish/Subscribe 本体上(Guard 接口注入,避免 events ↔ security 成环),四条路径全部覆盖。sdk.EventBus() 改返回带身份的 BusHandle。内核身份免授权,但载荷照样过凭据闸门。 白名单按主体收窄:agent_output/stage/terminal_output 只允许承载者 (webui/cli/agentcli)发布 —— 它们是内核向外的输出面,谁都能发就等于 能伪造 agent 说的话。 ## e2e 暴露并修掉的三个真实回归 [bus] subscribe denied: pluginmgr type=update_request [bus] subscribe denied: skillmgr type=skill_detected 前两个让升级请求静默丢失、职责链交接断掉。内部协作事件必须可订阅 (安全边界在发布侧,订阅只是「听得到」)。 ## 顺带的止血修复 - 回滚判据从 `--version` 改为 digest:旧内核(1.3.x 及以前)没有该 flag,而它恰是最常见的回滚目标 —— 原逻辑让「从旧版升级失败」永久 无法回滚。 - CopyFile 补 fsync(替换 homed 本体时掉电会留半截文件)。 - restore 完整还原才清 marker(原先先清再还原,还原失败就失去第二次 自愈机会)。 - 回滚时移除升级中新增的 depgraph(否则「旧 homed + 新 depgraph」 会因 digest 不匹配崩溃循环)。 - homed-upgrade 不再硬编码 systemctl restart(丢 guard 状态、unit 名 漂移);改用内核已有的 restartCh → exit 42 语义。 - homed-upgrade 不再用 CGO_ENABLED=0 编却依赖 go-sqlite3(stub 下 静默失效,该路径从未工作过);改用 internal/config。 - 服务单元补 User=hologramagent + 三条安装路径补建用户(此前发行物里 完全没有落实降权)。 - rpm 的 fpm 调用补 --after-install(此前 rpm 装完根本不建用户)。 ## 判据 新增 30 条:差分 8(往返/压缩比/错基线/截断/篡改/边界)、通道 8、 执行器 6、事件闸门 8、按主体授权 8。全仓 go test ./... 0 失败。
This commit is contained in:
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user