Files
HomeAgent/internal/plugin/kernel_control_gate_test.go
JianFeeeee 817f6c59ae fix(update): 重启交还 guard、OTA 支持 release/包两种来源、修三处真 bug
## 一、重启不再由 update 自己 systemctl(用户口径:应该通知 guard)

原 internal/update 直接 `systemctl restart homeagent`。三个问题:

① 杀掉整个 cgroup(unit 里 KillMode=control-group)⇒ **guard 跟着死**,
   而 guard 存在的意义就是不让自己死。
② 丢失 exit 42 语义:guard 的 failures/failbackRound/failbackDone 全丢。
③ 自毁竞态:发命令的进程正是被重启杀掉的进程 ⇒ CombinedOutput 可能报错,
   **一次成功的升级被误判失败而回滚**。

现在 internal/update 只到"产物就绪":预检 → 备份 → 写 pending → 原子替换。
重启由调用方走**既有**通道(restartCh → exit 42 → guard 用新二进制重拉):

· webui      h.kernelCtl.RequestKernelRestart(本来就有,且是白名单内)
· pluginmgr  p.sdk.KernelControl().RequestKernelRestart(见下面第三条)
· CLI        外部管理命令,restart unit 后按**版本自报**核对是否真生效

## 二、OTA 支持两种来源(release 自动更新 / 指定安装包)

新增 internal/update/source.go:

	Source{Kind: FromPackage, Path}  → 直接用本地包
	Source{Kind: FromRelease}        → 查最新 Release → 下载 → 校验 digest

ParseSource(pkg, release) 统一判空规则(pkg 优先),避免三端各写一套。
两条来源解析后进**同一条** Apply,不存在两份升级实现。

## 三、修三处已提交代码里的真 bug

① **HEAD 编译不过**:d17e261 提交了 kernelmod/update/mod.go(引用 ks.Update),
   但 kernelboot/kernelstate.go 的字段改动从未提交。干净 worktree 实测
   `ks.Update undefined` —— 推上去的仓库是坏的。本次补上。

② **kernelDataDir 算错一级**:Dir(Dir("/home/newqqagent/plugins")) = "/home",
   而第 0 层 update 模块从 ks.DataDir=/home/newqqagent 读 pending
   ⇒ **agent 发起的升级一旦启动失败就永不回滚**(静默失效)。改为 Dir 一次。

③ **pluginmgr 的 KernelControl 恒为 nil**:它不在 kernelControlAllowed 里,
   于是 `if kc := p.sdk.KernelControl(); kc != nil` 永不成立 ⇒
   换完二进制**从不重启** ⇒ pending 留着 ⇒ 下次重启被判"未确认"而回滚。

   修法**不是**把 pluginmgr 加进完整白名单 —— 那等于让 LLM 能关机整个服务
   (kernel_control_gate_test 明确拦这个)。而是拆出**仅重启**授权:

	kernelControlAllowed  = {webui}       完整(含关机)
	kernelRestartAllowed  = {pluginmgr}   仅重启(shutdown 直接报错)

   两者的危险程度差一个量级:重启几秒后 guard 自己拉回来,关机不会自己回来。

## 四、EventBus:插件请求更新不必 import internal/update

新增 events.EventUpdateRequest,pluginmgr 订阅并受理:
scope=kernel → kernelUpdateApply;scope=plugin → pluginUpdate(apply)。

它在 internal/plugins/webui 的 SSE 守卫里显式登记为**内核内部控制事件**
(不该推给浏览器:前端要的是 update_progress/update_result,不是"谁发了请求")。

## 五、顺手修掉的静默失败

· webui /kernel/update 原实现:**先**写 202 Accepted,**再**解析 body,
  之后每个错误都直接 `return` —— 用户看到"已受理"然后什么都没发生。
  改为同步 Apply(失败可如实报 HTTP 状态),成功后才请求重启。
· pluginmgr 拿不到控制通道时不再假装成功,而是回报 applied=true + 明确原因
  (否则用户以为已升级,进程却还在跑旧镜像)。
· update 事件 publishResult 的 errMsg 原先是死代码(`_ = errMsg`),
  按 payload 约定写回 error 字段。
2026-10-26 13:45:54 +08:00

113 lines
4.1 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.

package plugin
// 内核控制通道的**授权边界**判据(2026-10-17)。
//
// ## 为什么这条判据必须存在
//
// 重启/关机通道能让**整个服务停下来**,而且关机后不会自己回来
// (guard 显式 systemctl stop 压制了 Restart=always)。
//
// 我做这个功能时第一版是"有就发":`KernelControl: r.kernelCtl` 无条件
// 传给**所有**内置插件。那意味着任何一个内置插件(包括将来新加的)
// 都能一键关掉整个服务 —— 攻击面 = 全部内置插件。
//
// ⇒ 必须按插件名白名单授予,且这条边界要有判据钉住。
import (
"context"
"testing"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// fakeKernelCtl 记录收到的请求(判据只关心"能不能拿到/调用")。
type fakeKernelCtl struct {
restartCalls int
shutdownCalls int
}
func (f *fakeKernelCtl) RequestKernelRestart(reason string) error {
f.restartCalls++
return nil
}
func (f *fakeKernelCtl) RequestKernelShutdown(reason string) error {
f.shutdownCalls++
return nil
}
// ★★★ 判据:只有白名单里的插件能拿到内核控制通道。
func TestKernelControlOnlyForAllowlistedPlugins(t *testing.T) {
r := NewRegistry()
fake := &fakeKernelCtl{}
r.SetKernelControl(fake)
if got := r.kernelControlFor("webui"); got == nil {
t.Fatal("★ webui 应拿到内核控制通道(它是有鉴权的管理界面)")
}
// 典型的内置插件都不该拿到**完整**通道(能关机)
for _, name := range []string{"cmd", "files", "memo", "healthcheck", "timer"} {
if got := r.kernelControlFor(name); got != nil {
t.Fatalf("★ 内置插件 %q 拿到了内核控制通道 —— "+
"任何插件都能关机整个服务,攻击面失控", name)
}
}
// ★★★ pluginmgr 只拿到**仅重启**通道(kernelRestartAllowed):
//
// 为什么需要它:kernel_update 已经把新二进制换好,不重启等于升级
// 永远不生效,而 pending 标记会在下次启动时被判"未确认"并回滚。
// 为什么不能给完整通道:关机后服务不会自己回来(压制 Restart=always),
// 而重启几秒后 guard 会自己拉回来 —— 两者危险程度差一个量级。
pm := r.kernelControlFor("pluginmgr")
if pm == nil {
t.Fatal("★ pluginmgr 应拿到**仅重启**通道(否则 kernel_update 换完二进制却不重启)")
}
if err := pm.RequestKernelShutdown("t"); err == nil {
t.Fatal("★★★ pluginmgr 竟然能关机整个服务 —— 攻击面失控(只该授予重启)")
}
if fake.shutdownCalls != 0 {
t.Fatalf("★ 关机请求被转发到底层通道(%d 次)—— 包装没有拦住", fake.shutdownCalls)
}
if err := pm.RequestKernelRestart("t"); err != nil {
t.Fatalf("pluginmgr 应能重启: %v", err)
}
if fake.restartCalls != 1 {
t.Fatalf("重启请求未到达底层通道(%d)", fake.restartCalls)
}
// 未知/外部插件名
if got := r.kernelControlFor("some_third_party"); got != nil {
t.Fatal("★ 未登记插件拿到了内核控制通道")
}
}
// ★★★ 判据:内核未装配控制通道时,白名单插件也只拿到 nil(不 panic)。
func TestKernelControlNilWhenNotWired(t *testing.T) {
r := NewRegistry()
// 故意不 SetKernelControl(模拟非 systemd 环境/未启用)
if got := r.kernelControlFor("webui"); got != nil {
t.Fatal("★ 通道未装配时不该返回非 nil")
}
}
// ★★★ 判据:SDK 侧 KernelControl() 未注入时返回 nil,且调用方判 nil 不 panic。
func TestSDKKernelControlIsNilSafe(t *testing.T) {
s := sdk.New("probe", sdk.SDKConfig{})
if kc := s.KernelControl(); kc != nil {
t.Fatal("未注入时 KernelControl() 应为 nil")
}
// 注入后能拿到
fake := &fakeKernelCtl{}
s2 := sdk.New("probe2", sdk.SDKConfig{KernelControl: fake})
if s2.KernelControl() == nil {
t.Fatal("注入后应能拿到")
}
if err := s2.KernelControl().RequestKernelRestart("t"); err != nil {
t.Fatalf("调用失败: %v", err)
}
if fake.restartCalls != 1 {
t.Fatalf("fake 未收到请求(%d)", fake.restartCalls)
}
}
var _ = context.Background