Files
HomeAgent/internal/plugins/pluginmgr/kernel_update.go
JianFeeeee 55fa2685ae feat(security,device): security 成为独立内核模块(闸门+策略+限式),device 接管内置工具路由
security(新模块 internal/kernelmod/security)
- 凭据闸门:出向拦截成 @{别名}、入向清洗、执行前回填(internal/security/{gate,runtime}.go)
- 工具黑白名单 + 沙箱(读写根、网络、.. 逃逸):internal/security/policy.go
- 敏感词过滤 + 提示词注入预警(Finding 不带原文):internal/security/content.go
- 按主体授权(hard-private 对非内部主体永久拒绝):internal/security/principal.go
- 插件命名限式(可选档位,插件声明/内核执行,只能收窄):internal/security/profile.go
- 启动装配从 cmd/homed 迁入模块;启动顺序判据改盯真实装配点

device(新模块)
- 正式内核模块,Provides FacDevice,KernelState.Device
- 内置工具路由表下沉到 internal/toolroute(避免 core↔kernelboot 环),
  device 保留对外入口;agent 未挂载时退化本地 switch
- Agent 侧只留 api.ToolHost/DeviceSurface 适配层

SDK
- InjectOptions.SecurityProfile:插件按 inputch 声明本轮安全档位(可选)
- InjectOptions.TurnHint:插件注入**本轮事实**,内核以独立 system 消息注入,
  不混进用户正文(人设负责怎么说话,TurnHint 只放这一轮成立的事实)
- FacilitySecurity / FacilityDevice

提示词
- 内核自带 prompt/skill/工具返回全面英文化(中文提示会让模型行为漂移)
- 机制解释下沉到 4 个内置 skill(memory/output/tool-order/plugins),
  skill 描述压到 80 runes 内(原来 94–108 全被索引截断)
- 默认人格模板英文化;[Voice] 段删除,规则并入 persona 块(单一段落)
- 历史记忆工具改名 coldcontext_query/commit,图记忆语义改称 stable memory

修复
- tracker: exec mount → unix.Mount(降权运行 + AmbientCapabilities 下才能挂)、
  区分 EPERM 与不支持;启动日志说明后果与恢复方式
- healthcheck_kernel 输出瘦身:status.Tools 只给摘要(原 98,989 tokens/151% 预算)
- plugin_list 描述讲清 auto_start(按需)与 alive(此刻是否在跑),
  明确不能由 alive=false 推断能力不存在
- pluginmgr/webui 去掉写死的 /home/newqqagent 回退路径(env → pluginDir 反推 → 报错)
- lua: 盘上适配器与内置版本不一致且无基线时响亮告警(deepseek 流式丢失
  tool_calls 导致'模型不调工具'的真因)

测试:go build ./... 通过;go test ./... 60 包全绿
2026-10-26 20:55:25 +08:00

239 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// kernel_update 工具的 agent 侧实现。
//
// ## 三个入口共享一个底层(架构约束)
//
// agent 工具 kernel_update ┐
// WebAPI /kernel/update ├─ internal/update(CheckRelease / DoUpgrade)
// CLI homed-upgrade ┘
//
// 三份实现必然漂移("agent 说没有新版、WebUI 却升了"),所以入口层只做
// 参数解析与结果呈现。
//
// ## 为什么实现放在 pluginmgr 而不是 agent/core
//
// 1. 与 plugin_update 对称(都在 pluginmgr,用户认知一致)
// 2. agent/core 拿不到 SDK 句柄,而升级完成后要 RequestKernelRestart
// (那是 sdk.KernelControlAPI)
// 3. 与内核工具分派(toolcall.go)不同:这里走插件的 RegisterTool,
// 不需要改核心包
//
// ## 两阶段(用户口径)
//
// apply=false(默认)→ 只查 GitHub Release 与本地版本比较,不改动任何东西
// apply=true → 解包 → 校验 → 备份 → 原子替换(**不重启**)
// → 请求 guard 重拉 → 新内核启动时由 update 模块确认或回滚
//
// ★ 重启为什么不在 internal/update 里做:worker 是被守护方,只有 guard 知道
// 这次退出该不该重拉(exit 42 → 重拉且不计失败轮次)。详见
// cmd/homed/kernel_control.go。
package pluginmgr
import (
"fmt"
"os"
"path/filepath"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/update"
)
// kernelUpdateCheck 是 kernel_update 的检查半边(apply=false)。
func (p *Plugin) kernelUpdateCheck() (interface{}, error) {
cur := currentKernelVersion(p)
info := update.CheckRelease(cur)
return map[string]interface{}{
"apply": false,
"current": info.Current,
"latest": info.Latest,
"has_update": info.HasUpdate,
"asset": info.Asset,
"release": info.LatestURL,
"reason": info.Reason,
"hint": "确认后传 apply=true 执行升级(会替换二进制并重启内核,连接断开数秒)",
}, nil
}
// kernelUpdateApply 是 kernel_update 的执行半边(apply=true)。
//
// 来源二选一(update.ParseSource):
//
// · pkg 非空 → 用本机已有的安装包
// · release=true → 从仓库最新 GitHub Release 自动下载
//
// ★ 顺序(此前是反的,会出真事故):
//
// 先 Apply(备份→pending→替换),**再**请求重启。
// 反过来(先请求重启)会在新二进制还没就位时就重拉,
// 或重启恰好发生在替换中途 ⇒ 跑起来的是半新半旧的文件。
//
// ★ 重启走既有的 p.sdk.KernelControl()(restartCh → exit 42 → guard 重拉)。
// 它依赖 kernelControlAllowed 白名单 —— pluginmgr 已加入(否则该接口恒为 nil,
// 以前写在那里等于静默不重启)。
func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, error) {
src, err := update.ParseSource(pkgPath, release)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
tmp, err := os.MkdirTemp("", "kernel-upgrade-*")
if err != nil {
return nil, fmt.Errorf("建临时目录: %w", err)
}
defer os.RemoveAll(tmp)
root, rel, err := src.Resolve(tmp)
if err != nil {
return map[string]interface{}{"error": "取包失败: " + err.Error()}, nil
}
newBin := update.FindFile(root, "homed")
newDG := update.FindFile(root, "depgraph.bin")
if newBin == "" {
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)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
// 产物已就位 ⇒ 走**既有**重启通道(KernelControlAPI → restartCh → exit 42
// → guard 用新二进制重拉)。不自己 systemctl,也不另开通道。
//
// ★ 顺序关键:Apply 完成**之后**才请求重启。反过来会在新二进制还没
// 就位时重拉,或重启恰好落在替换中途。
if kc := p.sdk.KernelControl(); kc != nil {
if rerr := kc.RequestKernelRestart("kernel_update apply"); rerr != nil {
return map[string]interface{}{
"applied": true,
"error": "新内核已就位,但请求重启失败: " + rerr.Error(),
"hint": "手动重启服务即可;pending 标记会让首次启动自动确认或回滚",
}, nil
}
} else {
// 拿不到通道时**如实报告**,不要静默当成功:否则用户以为已升级,
// 而进程还在跑旧镜像;pending 标记会在下次重启时被判"未确认"→ 回滚。
return map[string]interface{}{
"applied": true,
"error": "新内核已就位,但本插件没有被授予内核控制通道(kernelControlAllowed),无法请求重启",
"hint": "手动重启服务完成升级;或改用 WebUI 的 /api/v1/kernel/update",
}, nil
}
res := map[string]interface{}{
"applied": true,
"from": out.From.Version,
"to": out.To.Version,
"backup": out.BackupDir,
"message": "新内核已就位,正在由 guard 重拉。重启后若新版本无法启动,第 0 层 update 模块会自动回滚。",
}
if rel != nil {
res["release"] = rel.Latest
res["asset"] = rel.Asset
}
return res, nil
}
// kernelDataDir 推导数据目录(升级确认标记的落点)。
//
// 从 pluginDir 反推:<dataDir>/plugins ⇒ 上一级即 dataDir。
// 与内核 --data 的值一致,update 模块(第 0 层)才能读到同一个标记。
func kernelDataDir(p *Plugin) string {
// <dataDir>/plugins ⇒ 上一级即 dataDir。
//
// ★ 只 Dir 一次。此前写成 Dir(Dir(...)),把 /home/newqqagent/plugins
// 算成 /home —— pending 标记落到 /home/kernel-upgrade-pending.json,
// 而第 0 层 update 模块从 ks.DataDir 读 ⇒ **永远读不到标记** ⇒
// agent 发起的升级一旦启动失败就**不会回滚**(静默失效)。
// ★ 不再写死生产路径(2026-10-26 迁移教训):
// 硬编码 /home/newqqagent 在目录/用户迁移后会让 pending 标记落到一个
// **已经不存在的目录**,而第 0 层 update 从 ks.DataDir 读 ⇒ 永远读不到
// ⇒ agent 发起的升级失败时静默不回滚。
//
// 优先级:显式环境变量(部署方声明自己在哪)→ 从 pluginDir 反推 → 空。
// 空表示"不知道",由调用方拒绝执行并报错,而不是猜一个路径。
if d := os.Getenv(dataDirEnv); d != "" {
return d
}
if p.pluginDir != "" {
return filepath.Dir(p.pluginDir)
}
return ""
}
// dataDirEnv 是部署方声明数据目录的环境变量(与 webui 侧同名)。
const dataDirEnv = "HOMEAGENT_DATA_DIR"
// currentKernelVersion 读本机 homed 自报版本(尽力而为)。
func currentKernelVersion(p *Plugin) *update.BinVersion {
v, err := update.ReadBinVersion(kernelBinPath(p))
if err != nil {
return nil
}
return v
}
// kernelBinPath 是本机 homed 的路径。
//
// 由 pluginDir 推导(<data>/../homed 不总是成立),优先按**本进程**
// 的可执行路径:homed 与内核同二进制,这是最可靠的来源。
func kernelBinPath(p *Plugin) string {
if exe, err := os.Executable(); err == nil && filepath.Base(exe) == "homed" {
return exe
}
if _, err := os.Stat("/usr/local/bin/homed"); err == nil {
return "/usr/local/bin/homed"
}
return "homed"
}
// onUpdateRequest 处理来自其他插件的更新请求(EventBus)。
//
// 请求方不必知道"怎么更新",只需发一个事件:
//
// scope: "kernel" | "plugin"
// name: 插件名(scope=plugin 时必填)
// pkg: 本机安装包路径(可选)
// release: bool,从仓库最新 Release 自动取包(可选)
//
// 为什么必须**异步**:升级会替换二进制并(对内核)请求重启,
// 本进程随后消失。事件处理器是同步投递的(见 events.Bus.Publish),
// 在处理器里同步做完会让发布方卡住直到进程被杀。
func (p *Plugin) onUpdateRequest(evt *events.Event) {
if evt == nil || evt.Payload == nil {
return
}
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)
case "plugin":
if name == "" {
err = fmt.Errorf("plugin 更新请求缺 name")
break
}
// 复用既有 plugin_update 的 apply 通路(范围即该插件名)
_, err = p.pluginUpdate(true, name)
default:
err = fmt.Errorf("未知更新范围 %q(来源 %s)", scope, evt.Source)
}
if err != nil {
// 事件是单向的,没有返回值可取 ⇒ 失败必须留日志,
// 否则请求方看到的就是"发了、没反应"。
fmt.Fprintf(os.Stderr, "[pluginmgr] update_request (%s/%s) failed: %v\n", scope, name, err)
}
}()
}