refactor(test): 肃清全部白盒测试 —— 源码目录零 _test.go,判据只走模块导出接口

## 用户裁定(本次唯一方向)

1. 「测试不应该面向过程而是面向模块的输入和输出」
2. 「每个模块下应该使用 test 目录搜集所有测试文件,而不是和源码混在一起」
3. 「现在 test 都超过源码数量了,这很明显不符合模块接口黑盒化的要求」
4. 「ast判包依赖?这不是工具链该做的吗?为什么在测试里」
5. 「抽行为判据?只需要面向模块输入接受模块输出就行」
6. 「不管ci,直接把所有白盒测试全清理了再回去一鼓作气改ci」

## 实测起点

    537 个测试文件 / 377 个源码文件 / 2853 条 Test
    523 / 537(97%)直接调用私有函数
    514 个是 in-package 白盒(package xxx,与源码同目录)
     88 个读 .go/.md 源码做断言
     94 个断言私有字段(f.Step / r.buf / a.sched …)
     38 个直调私有函数(stepLLM / runStage / classifyProviderError …)
    internal/agent/core: 140 test / 52 src

## 做了什么

### ① 删除 514 个 in-package 白盒测试

不做「抽行为判据」,也不做逐文件搬运。理由:Go 按目录分包,
`pkg/test/` 里写 `package pkg` 也看不到 unexported 符号 —— 所以
「挪进 test/」和「改成黑盒」是同一件事的两面。把 514 个白盒文件
搬走只会得到一堆编译不过的文件,或者为了迁就它们把内部全导出。

用户明确否定「抽」这个动作后,剩下 9 个 test/ 里的判据也一并
重新审视:凡是靠读源码/前端资源文本做断言的(dashboardJSSource /
FrontendAsset / GraphDataForVisual / SSEEventRing …)全部删除,
只留走模块**本来就有**的公开 API 的输入→输出判据:

    internal/agent/core/child/test/     Registry 的 Add/Get/Stop/Remove 输出
    internal/agent/core/test/           StageHost 的 Register/Run 输出
    internal/plugins/files/test/        工具 handler 的 {path} → {content/error}
    internal/plugins/webui/test/        HTTP 请求 → 响应(NewHandler/RegisterRoutes)
    internal/tfidf/test/                文本 → 向量/检索结果

现存 9 个测试文件全部在 `<module>/test/`,源码目录零 `_test.go`。

### ② 回退「为测试而导出」的接口

这些是前几轮为了把白盒判据挪出源码目录而导出的,属于白盒残留,
现在没有调用方了,全部收回私有:

    webui.FrontendAsset / DashboardPage        删除
    webui.GraphDataForVisual → graphDataForVisual
    webui.SSEEventRing/Record → sseEventRing/sseEventRecord(含字段)
    webui.NewSSEEventRing                       删除
    kstate.MountForTest                         删除
    security.ClassifyPrincipalForTest           删除

生产代码对它们的内部调用同步改回私有名,不改任何行为。

### ③ CI 一鼓作气改到位(用户指定顺序)

**删**:`整机启动图判据` 单列步骤(它跑的 bootgraph_test.go 已删),
以及 `check-ci-gates.py` 里盯着那个步骤的断言 —— 继续盯着一个
不存在的步骤就是永远红的空转。

**加两道构建期静态门**(原先这些约束错放在 go test 里):

- `测试归拢(源码目录不得有 _test.go)`
  `find internal cmd pkg providers deploy -name '*_test.go' -not -path '*/test/*'`
  非空即红。物理上挡住白盒回潮。
- `包依赖门(kstate 为叶子 / 底层包不反向依赖装配器)`
  用 `go list -deps` 判 8 组禁止依赖 + `go build core` 确认环不存在。
  这是**工具链**的职责,不该由 `go test` 兼职。

`check-ci-gates.py` 同步改为盯这两道门;ci.yml 里 13 处既有
>80 字符行一并折行(门禁标了 STOP,不留尾巴)。

### ④ 顺带(上一批未提交的)

`scripts/make-package.sh` 产物名 `HomeAgent-linux-*` →
`HologramAgent-linux-*`;清理 gitignored 构建产物
`build/ dist/ .go/ .npm-cache/` 共 20G(工作区 21G → 1.7G)。
运行契约(~/.homeagent/cli.sock、线上域名、兼容别名)未动。

## 验证

    go build ./...                     PASS
    go vet ./...                       PASS
    gofmt -l                           干净
    python3 .github/scripts/check-ci-gates.py   PASS
    go test ./... -count=1             5 个测试包全过 / 100 个包无测试 / 0 FAIL
    源码目录 _test.go 数              0
    测试文件数                         9(全部在 <module>/test/)

## 后续

按模块导出接口清单重写判据(不是从旧白盒里抽):
internal/agent/api、internal/memory、internal/security、internal/plugin、
internal/knowledge、cmd/* 等目前 0 判据,需要逐个列接口 → 写输入→输出判据
→ 变异验证。这是新一轮工作,本提交不含。
This commit is contained in:
JianFeeeee
2026-11-09 11:13:56 +08:00
parent eac8e808f1
commit 53cdc7f2a6
539 changed files with 158 additions and 116960 deletions

View File

@ -1,49 +0,0 @@
package pluginmgr
import (
"net"
"strings"
"testing"
)
// TestTwoInstances_ListenIndependently 是「监听地址必须是实例状态」的**端到端**判据。
//
// 为什么不能只断言字段不共享:那是实现细节,且容易被「变异后仍通过」的弱测试骗过
// (我第一版就写了那样的测试,变异证明它没有牙)。真正的性质是:
//
// 两个实例能**同时**成功监听,且各自 HTTPURL 指向自己那个端口。
//
// 曾经的缺陷(包级可变全局 `var HTTPAddr` + Start() 反写它 + startHTTPServer 读它)
// 恰好会违背这一点:两个实例共用同一个地址 → 第二个 Listen 报
// `bind: address already in use`,实测在生产机上与 homed 抢 9876。
//
// 用 `127.0.0.1:0` 让 OS 分配端口,避免测试自身依赖任何固定端口。
func TestTwoInstances_ListenIndependently(t *testing.T) {
a, b := New("a"), New("b")
a.httpAddr, b.httpAddr = "127.0.0.1:0", "127.0.0.1:0"
a.startHTTPServer()
b.startHTTPServer()
t.Cleanup(func() {
_ = a.Stop()
_ = b.Stop()
})
ua, ub := a.HTTPURL(), b.HTTPURL()
if ua == "" || ub == "" {
t.Fatalf("实例未成功监听:a=%q b=%q(包级全局会让第二个 bind 失败)", ua, ub)
}
if ua == ub {
t.Fatalf("两个实例报出同一个地址 %q —— 监听地址被共享了,不是实例状态", ua)
}
// 两个地址都必须**真的可连**(不能只报一个字符串)
for name, u := range map[string]string{"a": ua, "b": ub} {
addr := strings.TrimPrefix(u, "http://")
ln, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("实例 %s 报的地址 %s 不可连: %v", name, addr, err)
}
ln.Close()
}
}

View File

@ -1,167 +0,0 @@
package pluginmgr
import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"testing"
)
// TestBuildPluginBundlesInstallable 校验「发布用插件包」能被内核真实解包安装。
//
// 为什么要这个测试:release 此前只发 homed/waiter 与 hmapdev 工具链,**不发插件包**,
// 用户得自己装 Go + hmapdev 逐插件构建。现在发布流程会带上 .hmap(见 SDK 仓的
// scripts/build_plugin_bundles.sh),但「能构建出来」不等于「内核装得上」——
// 包内可能是 bundle 形态(plugin.bin.<os>.<arch>),也可能是单平台或 Lua 形态,
// 三种都要能被 extractPackage 正确处理并落到规范名 plugin.bin。
//
// 测试用内核自己的 extractPackage(而非测试里另写一份解包),所以它验证的是
// 真实安装路径。
func TestBuildPluginBundlesInstallable(t *testing.T) {
dir := os.Getenv("HMAP_BUNDLE_DIR")
if dir == "" {
t.Skip("未设 HMAP_BUNDLE_DIR(指向 build_plugin_bundles.sh 的产出目录)")
}
// 相对路径按**仓根**而非本包目录解析:测试的 cwd 是包目录,
// 而调用方通常写 `HMAP_BUNDLE_DIR=dist/plugins`(相对仓根),不修正就会
// 报 “no such file or directory”,看起来像产物不存在。
if !filepath.IsAbs(dir) {
if abs, err := filepath.Abs(filepath.Join(repoRoot(), dir)); err == nil {
dir = abs
}
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("读产物目录 %s: %v", dir, err)
}
n := 0
for _, e := range entries {
name := e.Name()
if filepath.Ext(name) != ".hmap" {
continue
}
data, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("%s: 读文件: %v", name, err)
}
// 每个包解到独立目录,互不干扰
tmp := t.TempDir()
if err := extractPackage(data, tmp); err != nil {
t.Errorf("%s: extractPackage 失败: %v", name, err)
continue
}
// 解包后必须有一个规范入口可执行文件(plugin.bin / main.lua / SKILL.md)
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Errorf("%s: 重新读 zip: %v", name, err)
continue
}
var pkgName string
for _, f := range zr.File {
if f.Name == "plugin.json" {
rc, _ := f.Open()
buf := new(bytes.Buffer)
buf.ReadFrom(rc)
rc.Close()
// 只要 name 字段,避免为此引 json(本文件保持轻量)
pkgName = extractField(buf.String(), "name")
break
}
}
if pkgName == "" {
t.Errorf("%s: 包内 plugin.json 无 name", name)
continue
}
installed := filepath.Join(tmp, pkgName)
var okEntry string
for _, cand := range []string{"plugin.bin", "main.lua", "SKILL.md"} {
if _, err := os.Stat(filepath.Join(installed, cand)); err == nil {
okEntry = cand
break
}
}
if okEntry == "" {
// 列出实际落了什么,便于定位
got, _ := os.ReadDir(installed)
var names []string
for _, g := range got {
names = append(names, g.Name())
}
t.Errorf("%s: 安装目录 %s 下无规范入口(plugin.bin/main.lua/SKILL.md),实际: %v",
name, pkgName, names)
continue
}
// 入口必须非空且可执行位(内核按子进程 spawn)
fi, err := os.Stat(filepath.Join(installed, okEntry))
if err != nil || fi.Size() == 0 {
t.Errorf("%s: 入口 %s 为空或不可读", name, okEntry)
continue
}
n++
t.Logf("✓ %-46s → %s/%s (%.1f MB)", name, pkgName, okEntry, float64(fi.Size())/1048576)
}
if n == 0 {
t.Fatal("没有任何 .hmap 通过安装校验")
}
t.Logf("共 %d 个插件包通过内核解包校验", n)
}
// repoRoot 从本文件位置向上找到仓根(含 go.mod 的目录)。
func repoRoot() string {
d, err := os.Getwd()
if err != nil {
return "."
}
for i := 0; i < 8; i++ {
if _, err := os.Stat(filepath.Join(d, "go.mod")); err == nil {
return d
}
parent := filepath.Dir(d)
if parent == d {
break
}
d = parent
}
return "."
}
// extractField 从 JSON 文本里取一个字符串字段(本文件不想为此引 encoding/json)。
func extractField(js, key string) string {
pat := `"` + key + `"`
i := indexOf(js, pat)
if i < 0 {
return ""
}
rest := js[i+len(pat):]
j := indexOf(rest, ":")
if j < 0 {
return ""
}
rest = rest[j+1:]
k := indexOf(rest, `"`)
if k < 0 {
return ""
}
rest = rest[k+1:]
m := indexOf(rest, `"`)
if m < 0 {
return ""
}
return rest[:m]
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}

View File

@ -1,105 +0,0 @@
package pluginmgr
// 插件部署**必须走 plugin_install**,不能手工 install/cp 二进制。
//
// ## 我犯的错(2026-10-18)
//
// 改完 commondev 后,我是这么"部署"的:
//
// python3 -c "zipfile...extractall('/tmp/x')"
// install -m 0755 /tmp/x/plugin.bin <插件目录>/plugin.bin
// install -m 0644 /tmp/x/rawfile/runtime/pi/sidecar.mjs <插件目录>/...
// systemctl restart homeagent
//
// 用户一句「为什么不打包为 hmap 安装,而是手动替换」点破了问题。
//
// ## 手动替换丢了什么
//
// 走 installFromData 能拿到、而手工 cp 拿不到的:
//
// · **整包校验和**:返回 checksum="13a46f87…",正是 .hmap 的 sha256。
// 手工替换只动了 plugin.bin,包与安装结果之间没有任何可核对的凭据。
// · **备份 + 失败回滚**:安装是「先备份旧目录 → 解包 → 失败则回滚到
// 旧版本」。手工 install 半途失败会留下**半新半旧**的目录,
// 那比彻底失败更难查(插件能加载、某个功能诡异失效)。
// · **全量路径校验**:validatePackage 遍历**所有**条目拒绝 `..` 与绝对
// 路径(该循环曾因 break 而漏检 rawfile 条目,已修)。
// · **rawfile 全量解包**:我手工只覆盖了 2 个 sidecar,其余 rawfile 内容
// (node/pi 运行时)仍是旧的。实测 `rawfile/` 目录 mtime 停在 10-16,
// 而标准安装后整个目录 mtime 才更新 —— 这就是漏解包的证据。
// · **配置保留与 skill 注册**:返回 config_kept=true / skills_registered=1。
//
// ## 判据做什么
//
// 代码层面无法禁止人手工 cp;但可以把**唯一正确入口**钉住:插件包的
// 落盘必须经过 extractPackage / installFromData 这条链。这里断言
// installFromData 仍然具备"先备份、失败回滚、校验路径"这三件事 ——
// 一旦有人把安装简化成"直接解压覆盖",判据立刻变红。
import (
"os"
"strings"
"testing"
)
// ★★★ 判据:安装路径必须保留「备份 → 解包 → 失败回滚」。
func TestInstallKeepsBackupAndRollback(t *testing.T) {
b, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
src := string(b)
// 定位 installFromData 函数体
i := strings.Index(src, "func (p *Plugin) installFromData(")
if i < 0 {
t.Fatal("找不到 installFromData —— 安装入口被改名/移走了")
}
body := src[i:]
if j := strings.Index(body[1:], "\nfunc "); j > 0 {
body = body[:j+1]
}
// ① 必须备份旧目录
if !strings.Contains(body, "backup old plugin dir failed") {
t.Fatal("★ 安装不再备份旧目录 —— 失败就没法回滚")
}
// ② 解包失败必须回滚
if !strings.Contains(body, "rolled back to") {
t.Fatal("★ 安装失败时没有回滚 —— 会留下半新半旧的插件目录")
}
// ③ 必须走统一解包(而不是就地 zip 展开)
if !strings.Contains(body, "extractPackage") {
t.Fatal("★ 安装没有经过 extractPackage —— 路径校验与解包语义会分叉")
}
}
// ★★★ 判据:路径校验必须遍历**全部**条目(历史上因 break 漏检 rawfile)。
func TestValidatePackageChecksAllEntries(t *testing.T) {
b, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
src := string(b)
if !strings.Contains(src, "for _, f := range reader.File {\n\t\tif strings.Contains(f.Name, \"..\")") {
// 允许 gofmt 对齐差异,用更宽松的判定
if !strings.Contains(src, `strings.Contains(f.Name, "..")`) {
t.Fatal("★ 包校验没有再检查路径穿越")
}
}
// 关键:这段检查必须**独立于**找 manifest 的循环(不能有 break 在前)
i := strings.Index(src, `strings.Contains(f.Name, "..")`)
if i < 0 {
t.Fatal("找不到路径校验")
}
head := src[:i]
lastLoop := strings.LastIndex(head, "for _, f := range reader.File {")
if lastLoop < 0 {
t.Fatal("路径校验不在遍历条目的循环里")
}
seg := head[lastLoop:]
if strings.Contains(seg, "break") {
t.Fatal("★ 路径校验所在的循环里有 break —— " +
"排在 manifest 之后的条目(rawfile 恒在其后)会被漏检")
}
}

View File

@ -1,109 +0,0 @@
package pluginmgr
import (
"strings"
"testing"
)
// ── plugin_update / kernel_update 的入口层判据 ────────────────
//
// 这里只测**入口层**(参数解析、范围选择、结果形状);
// 底层逻辑(Check / Upgrade / DoUpgrade)的判据在各自包里:
// internal/update — 拉源码、编译、版本比较
// internal/update — 版本比较、解包、替换、回滚
// 入口层若长出业务逻辑,就违背了"三端共享一个底层"的架构约束。
// 判据 1:plugin_update 检查阶段(apply=false)不要求 plugins 参数,
// 且结果含 updatable / no_repo 两类 —— 两者必须分开。
//
// 混在一起的后果:用户无法区分"没更新"和"根本不能自动更新"(内置插件)。
func TestPluginUpdate_CheckShape(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()} // 空目录:没有已装插件
out, err := p.pluginUpdate(false, "")
if err != nil {
t.Fatalf("检查不应失败: %v", err)
}
m, ok := out.(map[string]interface{})
if !ok {
t.Fatalf("返回应为 map,实际 %T", out)
}
if m["apply"] != false {
t.Errorf("apply 应为 false,实际 %v", m["apply"])
}
if _, ok := m["updatable"]; !ok {
t.Error("结果应有 updatable 字段")
}
if _, ok := m["no_repo"]; !ok {
t.Error("结果应有 no_repo 字段(未声明仓库的插件单独列出)")
}
}
// 判据 2:apply=true 但没给 plugins ⇒ 明确报错,不默认全升。
//
// 默认全升的危险:一次误触就把所有插件换了。
func TestPluginUpdate_ApplyRequiresScope(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()}
out, err := p.pluginUpdate(true, "")
if err != nil {
t.Fatalf("应返回可呈现的错误而不是 error: %v", err)
}
m := out.(map[string]interface{})
if _, hasErr := m["error"]; !hasErr {
t.Errorf("apply=true 缺 plugins 时必须报错,实际: %v", m)
}
}
// 判据 3:kernel_update 检查阶段不依赖包存在,返回版本比较结果。
func TestKernelUpdate_CheckShape(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()}
out, err := p.kernelUpdateCheck()
if err != nil {
t.Fatalf("检查不应失败: %v", err)
}
m := out.(map[string]interface{})
if m["apply"] != false {
t.Errorf("apply 应为 false,实际 %v", m["apply"])
}
// current/latest/reason 三者在"读不到本机版本"时也要给
if m["reason"] == nil {
t.Error("结果应有 reason 字段(说明能否比较/为何不能)")
}
}
// 判据 4:kernel_update apply 没给来源 ⇒ 明确报错(不许"猜一个默认动作")。
//
// 旧判据要求错误里出现 homed-upgrade —— 那是"agent 不该在推理中发起长下载"
// 时期的限制。现在支持 release=true 自动取包(下载放在内核侧统一实现),
// 所以判据改为:必须说清"两种来源二选一"。
func TestKernelUpdate_ApplyRequiresSource(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()}
out, err := p.kernelUpdateApply("", false)
if err != nil {
t.Fatalf("应返回可呈现的错误: %v", err)
}
m := out.(map[string]interface{})
msg := fmtSprint(m["error"])
if !strings.Contains(msg, "pkg") {
t.Errorf("错误应说明需要 pkg,实际: %v", msg)
}
if !strings.Contains(msg, "Release") && !strings.Contains(msg, "release") {
t.Errorf("错误应说明可从 Release 自动更新,实际: %v", msg)
}
}
// 判据 5:kernel_update apply 的 pkg 是目录/不存在 ⇒ 拒绝。
func TestKernelUpdate_ApplyRejectsBadPkg(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()}
out, _ := p.kernelUpdateApply(t.TempDir(), false) // 目录不是包
m := out.(map[string]interface{})
if _, hasErr := m["error"]; !hasErr {
t.Errorf("目录不应被当作安装包,实际: %v", m)
}
}
func fmtSprint(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}

View File

@ -1,153 +0,0 @@
package pluginmgr
// plugin_start/stop/enable/disable 工具的判据(2026-10-16)。
//
// 设计见 docs/zh/kernel-modularization-design.md §2.6.7.4。
//
// ## 判据重点:**工具的措辞必须让 agent 选对**
//
// 四个工具里两两一对,差别只在**是否持久**:
//
// plugin_stop vs plugin_disable (停掉 vs 停掉且以后也不自启)
// plugin_start vs plugin_enable (拉起 vs 拉起且以后开机也自启)
//
// agent 只能靠 Description 分辨。若描述不区分,agent 会在"临时关一下"
// 的场景误用 plugin_disable ⇒ 用户下次开机发现插件不自启(真实缺陷场景)。
//
// ## 判据必须走真实 registerTools
//
// 本仓教训:只查"源码里有这个字符串"会被 `if false &&` 之类的
// 写法骗过(E-3 那次实测)。所以这里真的调 registerTools 并捕获注册结果。
import (
"os"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// collectToolDefs 走真实 registerTools 收集工具定义。
//
// 捕获点是 `SDKConfig.RegTool`(真实注册落点),不是别处的副本 ——
// 这样"某工具被删掉/改名"必然反映在这里。
func collectToolDefs(t *testing.T) map[string]sdk.ToolDef {
t.Helper()
defs := map[string]sdk.ToolDef{}
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: &pmSettings{},
EventBus: events.NewBus(),
PluginMgr: &fakePluginMgr{},
RegTool: func(name string, def sdk.ToolDef, h sdk.ToolHandler) error {
defs[name] = def
return nil
},
})
p := &Plugin{name: "pluginmgr"}
p.registerTools(sdkInst)
if len(defs) == 0 {
t.Fatal("registerTools 未注册任何工具 —— 捕获点失效,本文件全部判据会假绿")
}
return defs
}
// ★★★ 判据:**四个生命周期工具全部注册**(走真实注册路径)。
func TestLifecycleToolsAreRegistered(t *testing.T) {
defs := collectToolDefs(t)
for _, want := range []string{"plugin_start", "plugin_stop", "plugin_enable", "plugin_disable"} {
if _, ok := defs[want]; !ok {
t.Errorf("★ 缺少工具 %s —— agent 无法%s插件", want, want)
}
}
// 原有 6 个不得因新增而丢失(回归)
for _, want := range []string{"plugin_install", "plugin_list", "plugin_status",
"plugin_restart", "plugin_remove", "plugin_info"} {
if _, ok := defs[want]; !ok {
t.Errorf("★ 原有工具 %s 丢失(回归)", want)
}
}
}
// ★★★ 判据:**运行态与持久态工具的描述必须互相点名**。
//
// 这是防止 agent 误用的**唯一手段**(工具选择完全靠描述)。
func TestRuntimeVsPersistentToolDescriptionsDisambiguate(t *testing.T) {
defs := collectToolDefs(t)
// ★ 两条**独立**断言(不能用 || 合并):
//
// 第一版写成 `!A && !B` ⇒ 去掉 A("不改持久状态")后 B("不会影响开机自启")
// 仍在 ⇒ 整条通过 ⇒ **变异没咬住**。两个措辞各有作用:
// A = 直接陈述"不改持久"(正面定义)
// B = 描述用户可观察的后果(背面印证)
// 缺任一都让 agent 更难判断,所以**两条都要在**。
stop := defs["plugin_stop"].Description
if !strings.Contains(stop, "不改持久状态") {
t.Errorf("★ plugin_stop 描述未出现\"不改持久状态\"(正面定义缺失)\n%s", stop)
}
if !strings.Contains(stop, "不会影响开机自启") {
t.Errorf("★ plugin_stop 描述未出现\"不会影响开机自启\"(后果说明缺失)\n%s", stop)
}
if !strings.Contains(stop, "plugin_disable") {
t.Errorf("★ plugin_stop 描述未指向 plugin_disable(持久关闭)作为对比\n%s", stop)
}
dis := defs["plugin_disable"].Description
if !strings.Contains(dis, "跨重启保留") && !strings.Contains(dis, "持久") {
t.Errorf("★ plugin_disable 描述未说明它是**持久**的\n%s", dis)
}
if !strings.Contains(dis, "plugin_stop") {
t.Errorf("★ plugin_disable 描述未指向 plugin_stop(临时关闭)作为对比\n%s", dis)
}
if st := defs["plugin_start"].Description; !strings.Contains(st, "持久") {
t.Errorf("★ plugin_start 描述未提持久禁用的处理\n%s", st)
}
if en := defs["plugin_enable"].Description; !strings.Contains(en, "清除") || !strings.Contains(en, "自启") {
t.Errorf("★ plugin_enable 描述未说明它会让之后开机自启\n%s", en)
}
}
// ★★★ 判据:**四个工具各自转调不同的 API**(不是别名)。
//
// 若 plugin_stop 内部转调 DisablePlugin(或反过来),两者行为相同
// ⇒ 上述描述全是谎话。这里对每个工具的 handler 做"源码块内包含期望调用"检查。
//
// ★ 为何能接受源码检查(本仓曾因此吃亏):这里检查的是**期望调用一定存在**,
// 是单向的(存在性),不会被 `if false &&` 骗过 —— 那种写法仍需写出该调用。
// 真正的行为验证在 internal/plugin 包的 runtime_state_test.go(真加载真启停)。
func TestRuntimeToolsCallDistinctAPIs(t *testing.T) {
src := readPluginSource(t)
cases := []struct{ tool, want string }{
{"plugin_start", "mgr.StartPlugin("},
{"plugin_stop", "mgr.StopPlugin("},
{"plugin_enable", "mgr.EnablePlugin("},
{"plugin_disable", "mgr.DisablePlugin("},
}
for _, c := range cases {
idx := strings.Index(src, `s.RegisterTool("`+c.tool+`"`)
if idx < 0 {
t.Fatalf("未找到 %s 的注册", c.tool)
}
rest := src[idx:]
// 截到下一个 RegisterTool
if nxt := strings.Index(rest[len(c.tool)+20:], `s.RegisterTool(`); nxt >= 0 {
rest = rest[:len(c.tool)+20+nxt]
}
if !strings.Contains(rest, c.want) {
t.Errorf("★ %s 的 handler 未调用 %s —— 可能误指向另一个 API(行为与描述不符)",
c.tool, c.want)
}
}
}
// readPluginSource 读本包源码(判据的静态检查用)。
func readPluginSource(t *testing.T) string {
t.Helper()
b, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatalf("读 plugin.go: %v", err)
}
return string(b)
}

View File

@ -1,325 +0,0 @@
package pluginmgr
// 插件包大小上限的判据。
//
// 核心命题(本次修复的实质):
// ① 上限**可配置**(而非硬编码 100MB)—— 内嵌运行时的包能装进来
// ② 上限在**读入内存之前**生效 —— 否则限制等于没有
// ③ 0 = 不限制
//
// ★ ② 是这次真正的修复。原实现在 io.ReadAll 之后才判大小,
// 一个声明超大 Content-Length 的请求会先把内存吃满再收到"包太大"。
// 判据必须能咬住"位置"这个属性,而不只是"超限会被拒绝"。
import (
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// countingReader 记录**实际被读走了多少字节**。
//
// 这是判断"限流在读前生效"的关键工具:若实现先全量读再检查,
// 计数会等于源总长;若在读中限流,计数只到 limit+1 就停。
type countingReader struct {
r io.Reader
read int64
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.read += int64(n)
return n, err
}
// ★★★ 判据:超限时**没有把整个 source 读进内存**。
//
// 这条咬的是"防线的位置",是本次修复的核心性质。
func TestPackageLimitStopsBeforeReadingEverything(t *testing.T) {
const limit = 1024
// 源远大于 limit(1MB)
source := bytes.Repeat([]byte("A"), 1<<20)
cr := &countingReader{r: bytes.NewReader(source)}
_, err := readLimited(cr, limit)
if err == nil {
t.Fatal("超限应报错")
}
// ★ 关键断言:读取量必须远小于源大小
// (允许多读 1 字节用于边界判定)
if cr.read > limit+1 {
t.Fatalf("★ 超限时读了 %d 字节(源 %d,上限 %d)—— 说明是先全量读再检查,防线位置错了",
cr.read, len(source), limit)
}
}
// ★★ 判据:恰好等于上限的包**不被误拒**(边界差一)。
func TestPackageLimitBoundaryIsInclusive(t *testing.T) {
const limit = 512
exact := bytes.Repeat([]byte("A"), limit)
if _, err := readLimited(bytes.NewReader(exact), limit); err != nil {
t.Errorf("恰好等于上限应通过: %v", err)
}
over := bytes.Repeat([]byte("A"), limit+1)
if _, err := readLimited(bytes.NewReader(over), limit); err == nil {
t.Error("超过上限 1 字节应被拒")
}
}
// ★★ 判据:0 = 不限制(内嵌运行时可能要更大的包)。
func TestPackageLimitZeroMeansUnlimited(t *testing.T) {
big := bytes.Repeat([]byte("A"), 4<<20) // 4MB
got, err := readLimited(bytes.NewReader(big), 0)
if err != nil {
t.Fatalf("0 应为不限制: %v", err)
}
if len(got) != len(big) {
t.Errorf("不限时长度应一致: %d vs %d", len(got), len(big))
}
// 负数同样视为不限(防御式:配置项误填负数不该变成"拒一切")
if _, err := readLimited(bytes.NewReader(big), -1); err != nil {
t.Errorf("负数应视为不限制: %v", err)
}
}
// ★★ 判据:默认上限**足够容纳内嵌运行时并留出真实余量**。
//
// 实测数据(2026-10-15,commondev 内嵌 node+pi):
// - 包大小 103,795,462 字节 = 99.0 MiB
// - 旧的硬编码上限 100<<20 = 104,857,600 字节
// ⇒ 余量仅 1,062,138 字节(1.01 MiB)
//
// ★ 这条判据的措辞经过修正。我最初写的是"内嵌运行时需要 >100MB,
// 所以旧上限会挡住它"——那是**错的**:实测包 99.0 MiB 并未超过
// 100 MiB。我自己写的对照项判据(TestRealEmbeddedRuntimeRejectedByOldLimit)
// 把这一点咬了出来。
//
// 所以真实情况是:旧上限**没挡住当前这个包**,但它只剩 1.01 MiB 余量 ——
// 任何新增一个资源文件就会爆。放宽到 512MB 的意义是把"随时会爆"
// 变成"有合理空间",而不是"从装不进变成装得进"。
func TestDefaultLimitLeavesRealHeadroom(t *testing.T) {
const observedPackageBytes = 103795462 // 实测 commondev 包
limit := int64(defaultMaxPackageMB) << 20
if limit <= observedPackageBytes {
t.Fatalf("★ 默认上限 %dMB 装不下实测的内嵌运行时包(%.1f MiB)",
defaultMaxPackageMB, float64(observedPackageBytes)/(1<<20))
}
// 余量要求:至少 2 倍于实测包大小(否则"随时会爆"的问题没解决)
if limit < observedPackageBytes*2 {
t.Errorf("★ 余量不足:上限 %dMB 对实测 %.1f MiB 的包余量不够(应至少 2 倍)",
defaultMaxPackageMB, float64(observedPackageBytes)/(1<<20))
}
}
// ★★★ 判据:HTTP 上传超限返回 **413**,且错误信息给出可执行提示。
//
// 413 而非 400/500:这是语义正确的状态码,调用方能据此区分
// "包太大"与"包损坏"。
func TestHTTPUploadOverLimitReturns413(t *testing.T) {
p := &Plugin{maxPackageBytes: 1024} // 故意设小
body := bytes.Repeat([]byte("A"), 4096)
req := httptest.NewRequest(http.MethodPost, "/plugins", bytes.NewReader(body))
rec := httptest.NewRecorder()
// 直接调用 handler(真实入口,不是 helper)
req.Body = http.MaxBytesReader(rec, req.Body, 1<<30) // 不干扰本判据
p.handlePlugins(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("★ 超限应返回 413,实际 %d(body=%s)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "max_package_mb") {
t.Errorf("错误信息应指出如何放宽(max_package_mb): %s", rec.Body.String())
}
}
// ★★ 判据:本地文件路径也执行上限(先 Stat 快速拒绝)。
func TestInstallFromPathEnforcesLimit(t *testing.T) {
dir := t.TempDir()
pth := filepath.Join(dir, "big.hmap")
if err := os.WriteFile(pth, bytes.Repeat([]byte("A"), 4096), 0o644); err != nil {
t.Fatal(err)
}
p := &Plugin{maxPackageBytes: 1024}
_, err := p.installFromPath(pth, false)
if err == nil {
t.Fatal("本地文件超限应报错")
}
if !strings.Contains(err.Error(), "too large") {
t.Errorf("应为大小错误,实际: %v", err)
}
}
// ★★ 判据:放宽上限后,**真正约 100MB 的包能装进来** —— 本次的目的。
//
// 用"读入 + 校验"而非完整安装:完整安装要写盘,成本高且与本判据无关。
// 这里钉住的是"读入阶段不再拦它"。
func TestLargePackagePassesWhenLimitRaised(t *testing.T) {
const size = 101 << 20 // 101MB,旧上限下必被拒
// 用惰性 reader 避免真的分配 101MB
lr := io.LimitReader(zeroReader{}, size)
p := &Plugin{maxPackageBytes: int64(defaultMaxPackageMB) << 20}
data, err := readLimited(lr, p.maxPackageBytes)
if err != nil {
t.Fatalf("★ 放宽后 101MB 包仍被拒: %v", err)
}
if int64(len(data)) != size {
t.Fatalf("大小不对: %d", len(data))
}
// 对照:旧的 100MB 硬编码上限会拒它
if _, err := readLimited(io.LimitReader(zeroReader{}, size), 100<<20); err == nil {
t.Error("对照项:100MB 上限应拒绝 101MB 包")
}
}
// zeroReader 无限供应零字节(惰性,不占内存)。
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 0
}
return len(p), nil
}
// ★ 判据:错误类型可判定(供上层区分"太大"与其他失败)。
func TestTooLargeErrorIsIdentifiable(t *testing.T) {
err := errPackageTooLarge(42 << 20)
if err == nil {
t.Fatal("应返回错误")
}
if !strings.Contains(err.Error(), "42MB") {
t.Errorf("错误应包含上限值: %v", err)
}
// 不应是 io.EOF 之类的伪装
if errors.Is(err, io.EOF) {
t.Error("不应是 io.EOF")
}
}
// ★★★ 判据:配置值按**真实形态**(string)被解析。
//
// 这是本仓反复出现的坑:配置从 DB 读出来是字符串
// (config.PluginSettings.Get → openFromStorage 返回 string)。
// 初版照抄 agentcli 的 `v.(float64)` 假设,switch 全不匹配 ⇒
// **配置被静默忽略** —— 用户改了上限却不生效,且无任何提示。
//
// 判据的形态很重要:**必须喂 string**。如果这里喂 int,
// 判据会绿,而生产仍然不生效。
func TestLimitConfigParsedFromString(t *testing.T) {
cases := []struct {
in interface{}
want int64 // MB
desc string
}{
{"64", 64, "字符串(真实形态)"},
{"0", 0, "0 = 不限制"},
{" 128 ", 128, "带空白的字符串"},
{"-5", 0, "负数视为不限制(不是拒一切)"},
{int(256), 256, "int(兼容)"},
{int64(300), 300, "int64(兼容)"},
{float64(32), 32, "float64(兼容)"},
}
for _, c := range cases {
got := parseLimitMB(c.in)
if got != c.want<<20 {
t.Errorf("parseLimitMB(%#v) [%s] = %d 字节,期望 %dMB", c.in, c.desc, got, c.want)
}
}
// 非法输入回退默认(不静默变成 0=不限,那会悄悄关掉防护)
if got := parseLimitMB("abc"); got != int64(defaultMaxPackageMB)<<20 {
t.Errorf("非法输入应回退默认,实际 %d", got)
}
if got := parseLimitMB(""); got != int64(defaultMaxPackageMB)<<20 {
t.Errorf("空串应回退默认,实际 %d", got)
}
if got := parseLimitMB(struct{}{}); got != int64(defaultMaxPackageMB)<<20 {
t.Errorf("意外类型应回退默认,实际 %d", got)
}
}
// limitSettings 是能返回**真实形态**(string)配置值的 settings mock。
//
// 用真实 SettingsAPI 接口,走 p.Start() 这条生产路径,
// 从而验证"配置值 → p.maxPackageBytes"的完整链路。
type limitSettings struct {
pmSettings
maxMB string
dir string
}
func (s *limitSettings) Get(key string) (interface{}, error) {
if key == "max_package_mb" {
// ★ 返回 string:这是 config.PluginSettings.Get 的真实形态
// (openFromStorage 返回 string)。返回 int 会让判据失去意义。
return s.maxMB, nil
}
return nil, nil
}
func (s *limitSettings) GetCore(key string) (interface{}, error) {
if key == "plugin.dir" {
return s.dir, nil
}
return nil, nil
}
func (s *limitSettings) RegisterDef(sdk.ConfigDef) {}
// ★★★ 判据:配置项经 **真实 Start()** 生效。
//
// 这条覆盖的是我初版真正出错的环节:Get 返回 string,
// 而代码按 float64 断言 ⇒ switch 全不匹配 ⇒ 配置被静默忽略。
// 上面的 parseLimitMB 单测也喂 string,但只有走 Start() 才能证明
// "装配路径确实把 string 交给了它"。
func TestLimitConfigAppliedThroughStart(t *testing.T) {
dir := t.TempDir()
for _, tc := range []struct {
cfg string
want int64
}{
{"64", 64 << 20},
{"0", 0},
{"-1", 0},
} {
s := &limitSettings{maxMB: tc.cfg, dir: dir}
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: s,
EventBus: events.NewBus(),
})
p := &Plugin{name: "pluginmgr", mux: http.NewServeMux(), httpAddr: ""}
if err := p.Start(sdkInst); err != nil {
// HTTP 关闭(httpAddr 空)时 Start 仍应成功
t.Fatalf("Start 失败: %v", err)
}
if p.maxPackageBytes != tc.want {
t.Errorf("★ max_package_mb=%q 未生效: got %d, want %d(配置被静默忽略)",
tc.cfg, p.maxPackageBytes, tc.want)
}
}
}
// ★★ 判据:**未配置时用默认值**(不能变成 0=不限而悄悄关掉防护)。
func TestLimitDefaultsWhenNotConfigured(t *testing.T) {
s := &limitSettings{maxMB: "", dir: t.TempDir()}
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: s,
EventBus: events.NewBus(),
})
p := &Plugin{name: "pluginmgr", mux: http.NewServeMux(), httpAddr: ""}
if err := p.Start(sdkInst); err != nil {
t.Fatal(err)
}
if p.maxPackageBytes != int64(defaultMaxPackageMB)<<20 {
t.Errorf("★ 未配置时应为默认 %dMB,实际 %d 字节(可能被误设为不限)",
defaultMaxPackageMB, p.maxPackageBytes)
}
}

View File

@ -1,157 +0,0 @@
package pluginmgr
// rawfile 与安装链路的端到端判据。
//
// 目标:**用内核自己的 extractPackage**(不是测试里另写一份解包)
// 处理一个真实带 rawfile 的 .hmap,验证资源落在 <插件目录>/rawfile/ 下
// 且保持结构 —— 这正是插件运行期 RawFilesAPI 会去读的位置。
import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// buildHmapWithRaw 造一个最小可安装包(含 plugin.json + 假二进制 + rawfile 条目)。
func buildHmapWithRaw(t *testing.T, rawEntries map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
add := func(name, content string) {
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
fw, err := w.CreateHeader(hdr)
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
add("plugin.json", `{"name":"rawtest","version":"1.0.0","entry":"plugin.bin","platforms":["linux/amd64"]}`)
add("plugin.bin.linux.amd64", "#!/bin/sh\necho fake\n")
for name, content := range rawEntries {
add(name, content)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// ★★ 判据:安装后资源落在 <插件目录>/rawfile/,**保持嵌套结构**。
func TestInstallPreservesRawFileStructure(t *testing.T) {
data := buildHmapWithRaw(t, map[string]string{
"rawfile/node/bin/node": "fake-node",
"rawfile/pi/node_modules/x/index.js": "module.exports=1",
"rawfile/main/prompt.txt": "you are a dev",
})
dir := t.TempDir()
if err := extractPackage(data, dir); err != nil {
t.Fatalf("extractPackage 失败: %v", err)
}
base := filepath.Join(dir, "rawtest")
for _, rel := range []string{
"rawfile/node/bin/node",
"rawfile/pi/node_modules/x/index.js",
"rawfile/main/prompt.txt",
} {
p := filepath.Join(base, rel)
if _, err := os.Stat(p); err != nil {
// 若被拍平,文件会在 rawfile/ 下以 basename 出现 —— 报清楚
t.Errorf("★ 安装后资源不在预期路径 %s: %v", rel, err)
}
}
// 明确检查"没有拍平"
if _, err := os.Stat(filepath.Join(base, "rawfile", "node")); err != nil {
t.Errorf("★ 目录结构被拍平(rawfile/node 不存在): %v", err)
}
// 内容正确
b, err := os.ReadFile(filepath.Join(base, "rawfile/main/prompt.txt"))
if err != nil || string(b) != "you are a dev" {
t.Errorf("内容不对: %q err=%v", b, err)
}
}
// ★★ 判据:可执行位在安装后保留(内嵌 node/pi 必须能被执行)。
func TestInstallPreservesExecutableBit(t *testing.T) {
var buf bytes.Buffer
w := zip.NewWriter(&buf)
add := func(name, content string, mode os.FileMode) {
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
hdr.SetMode(mode)
fw, _ := w.CreateHeader(hdr)
fw.Write([]byte(content))
}
add("plugin.json", `{"name":"rawtest2","version":"1.0.0","entry":"plugin.bin","platforms":["linux/amd64"]}`, 0o644)
add("plugin.bin.linux.amd64", "#!/bin/sh\n", 0o755)
add("rawfile/node/bin/node", "#!/bin/sh\necho ok\n", 0o755)
if err := w.Close(); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := extractPackage(buf.Bytes(), dir); err != nil {
t.Fatal(err)
}
st, err := os.Stat(filepath.Join(dir, "rawtest2", "rawfile/node/bin/node"))
if err != nil {
t.Fatal(err)
}
if st.Mode()&0o111 == 0 {
t.Errorf("★ 可执行位丢失: %v(内嵌 node 将无法执行)", st.Mode())
}
}
// ★ 判据:rawfile 条目不能绕过 validatePackage 的路径安全检查。
func TestRawFileEntriesCannotEscapePackage(t *testing.T) {
// 恶意包:条目名带 ..(zip-slip)
var buf bytes.Buffer
w := zip.NewWriter(&buf)
add := func(name, content string) {
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
fw, _ := w.CreateHeader(hdr)
fw.Write([]byte(content))
}
add("plugin.json", `{"name":"evil","version":"1.0.0","entry":"plugin.bin","platforms":["linux/amd64"]}`)
add("plugin.bin.linux.amd64", "#!/bin/sh\n")
add("rawfile/../../../etc/evil.sh", "pwned")
if err := w.Close(); err != nil {
t.Fatal(err)
}
// ★★ 结论(实测澄清):**真正挡住它的是 extractPackage,不是 validatePackage**。
//
// validatePackage 的条目循环在读到 plugin.json 后 **break**,
// 于是排在 plugin.json **之后**的条目根本没做路径检查 ——
// 而 hmapdev 打包出的 rawfile 条目恒在 plugin.json 之后。
//
// 但解包有两个独立入口:
// ① validatePackage —— 元数据/平台校验(有早退,路径检查不完整)
// ② extractPackage —— 真正落盘(遍历**全部**条目,有 path traversal 检查)
// 所以不存在可被利用的漏洞;这里钉住的是 **② 必须始终挡住**。
//
// ★ 教训:判据不能只断言 err != nil。初版这样写"通过"了,
// 但理由其实是 "no valid binary found"(我造的包缺二进制)——
// 也就是说判据在**没有路径保护时照样通过**,完全没验证目标性质。
data := buf.Bytes()
// ① 准入校验必须**以路径理由**拒绝(修掉早退盲区后为真)
if _, err := validatePackage(data); err == nil {
t.Fatal("含 ../ 的包应被拒")
} else if !strings.Contains(err.Error(), "invalid path") {
t.Errorf("★ validatePackage 未以路径理由拒绝(实际: %v)—— 路径检查被 manifest 早退跳过了", err)
}
// 真正要钉的是解包路径
dir := t.TempDir()
err := extractPackage(data, dir)
if err == nil {
t.Fatal("★ extractPackage 未挡住路径穿越条目")
}
if !strings.Contains(err.Error(), "traversal") {
t.Fatalf("★ 拒绝理由不是路径穿越(实际: %v)—— 该用例没走到安全检查", err)
}
// 确认没有文件落到插件目录之外
if _, serr := os.Stat(filepath.Join(dir, "evil.sh")); serr == nil {
t.Error("★ 恶意文件落到了插件目录下")
}
}

View File

@ -1,101 +0,0 @@
package pluginmgr
// ★★★ 真实大包安装的端到端判据。
//
// 用**内核自己的 installFromData** 装一个真实约 99MB 的 .hmap
// (commondev 内嵌 node+pi 的产物)。这条判据回答的是用户的问题:
// "解除限制后,内嵌运行时真能装进来吗?"
import (
"os"
"path/filepath"
"testing"
)
func TestRealEmbeddedRuntimePackageInstalls(t *testing.T) {
src := os.Getenv("HA_RAWFILE_E2E_HMAP")
if src == "" {
t.Skip("设 HA_RAWFILE_E2E_HMAP=<hmap路径> 以运行")
}
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("读包失败: %v", err)
}
t.Logf("真实包大小: %.1f MiB", float64(len(data))/(1<<20))
// ★ 旧上限下这个包会被拒(对照)
if int64(len(data)) > 100<<20 {
t.Logf("注意:该包超过旧的 100MB 硬编码上限")
}
// ★ pluginDir 必须显式给定,否则 installFromData 直接返回
// {"error":"plugin dir not configured"} 且 err == nil ——
// 初版没设它,于是判据"通过"了但什么都没装。这是本仓反复出现的
// "假通过"模式:**必须断言返回值的内容,不能只看 err**。
dir := t.TempDir()
p := &Plugin{
pluginDir: dir,
maxPackageBytes: int64(defaultMaxPackageMB) << 20,
}
res, err := p.installFromData(data, false)
if err != nil {
t.Fatalf("安装失败: %v", err)
}
if m, ok := res.(map[string]interface{}); ok {
if e, bad := m["error"]; bad {
t.Fatalf("★ 放宽上限后真实包仍装不进来: %v (details=%v)", e, m["details"])
}
} else {
t.Fatalf("返回值类型意外: %T", res)
}
// 装完必须能看到内嵌运行时资源(这才证明"包真的解开了")
installed := filepath.Join(dir, "commondev")
if _, err := os.Stat(installed); err != nil {
t.Fatalf("★ 插件目录不存在,包没被解开: %v", err)
}
node := filepath.Join(installed, "rawfile", "node", "bin", "node")
st, err := os.Stat(node)
if err != nil {
t.Fatalf("★ 内嵌 node 不在预期路径: %v", err)
}
if st.Mode()&0o111 == 0 {
t.Errorf("★ 内嵌 node 丢了可执行位: %v", st.Mode())
}
t.Logf("解包成功:内嵌 node %d 字节,可执行位 OK", st.Size())
}
// ★★★ 判据(对照):钉住"真实包与旧上限的关系"这个**事实**。
//
// 这条判据的来历值得留着:我原本写的是"该包在旧 100MB 上限下会被拒",
// 想用它证明上面的安装判据有意义。**它报红了** —— 实测包 103,795,462 字节
// < 旧上限 104,857,600 字节,差 1.01 MiB,根本没超。
//
// 也就是说:旧上限**从来不是**当前这个包的障碍,真正的风险是
// "余量只剩 1 MiB,加任何一个文件就爆"。搞错这一点会得出错误的
// "解除了一个阻塞"的结论。判据在这里的作用就是不让错的叙事留下来。
func TestRealPackageHeadroomAgainstOldLimit(t *testing.T) {
src := os.Getenv("HA_RAWFILE_E2E_HMAP")
if src == "" {
t.Skip("设 HA_RAWFILE_E2E_HMAP=<hmap路径> 以运行")
}
st, err := os.Stat(src)
if err != nil {
t.Fatal(err)
}
const oldLimit = 100 << 20
headroom := oldLimit - st.Size()
t.Logf("真实包 %.1f MiB,旧上限 %.1f MiB,余量 %.2f MiB",
float64(st.Size())/(1<<20), float64(oldLimit)/(1<<20), float64(headroom)/(1<<20))
// 事实断言:余量小于 5MiB 就算"贴近上限",属于真实风险
if headroom < 5<<20 {
t.Logf("★ 确认:旧上限下余量仅 %.2f MiB —— 虽未超限,但任何新增资源都会爆。",
float64(headroom)/(1<<20))
}
// 新上限必须给出实质空间
newLimit := int64(defaultMaxPackageMB) << 20
if newLimit-st.Size() < st.Size() {
t.Errorf("★ 新上限 %dMB 未给出足够余量", defaultMaxPackageMB)
}
}

View File

@ -1,385 +0,0 @@
package pluginmgr
// 随包 skill 移交的判据。
//
// 覆盖的缺口:插件与 skill 此前是两种**互不相通**的分发物 —— 插件解压到
// <plugin.dir>/<name>/,skill 由 skillmgr 扫 <data.dir>/skills/。
// 于是随插件分发的 skill 装完就到不了用户手上。
//
// 修法:pluginmgr(内置插件,持有完整 SDK)在解压后扫 <插件目录>/skill/,
// 对每个条目发 skill_detected;skillmgr 已订阅该事件并接管注册。
//
// 判据必须钉住**三个环节**,缺一个链路就不通:
// ① 发现:只认真正的纯 SKILL,不误发别的目录
// ② 移交:事件类型/载荷正确(skillmgr 只读 payload["path"])
// ③ 贯通:事件真的会被 skillmgr 的订阅者收到(用真事件总线验证)
import (
"archive/zip"
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// buildHmapWithSkills 造一个带 skill/ 目录的插件包。
func buildHmapWithSkills(t *testing.T, files map[string]string) []byte {
return buildHmapWithSkillsVer(t, "1.0.0", files)
}
// buildHmapWithSkillsVer 同前,但可指定版本号。
//
// ★ 不能对已生成的 zip 做字节替换来改版本 —— 那会破坏 zip 的校验和,
// 报 "zip: checksum error"(实测踩到)。必须**重新打包**。
func buildHmapWithSkillsVer(t *testing.T, version string, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
add := func(name, content string) {
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
fw, err := w.CreateHeader(hdr)
if err != nil {
t.Fatal(err)
}
fw.Write([]byte(content))
}
// ★ 不带 platforms 字段 ⇒ legacy 模式:entry 直接指向 plugin.bin。
//
// 初版写了 platforms 却没提供对应名字的二进制(platformBinary() 期望
// plugin.bin.<goos>.<goarch>),于是 validatePackage 直接以
// "no valid binary found" 拒绝 —— 判据全红但并非功能缺失。
// 这是**判据自己的缺陷**(造的包不合规),不是被测代码的问题。
add("plugin.json", `{"name":"skilltest","version":"`+version+`","entry":"plugin.bin"}`)
add("plugin.bin", "#!/bin/sh\n")
for n, c := range files {
add(n, c)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// newTestPluginWithBus 造一个挂了**真事件总线**的 pluginmgr。
func newTestPluginWithBus(t *testing.T, dir string) (*Plugin, *events.Bus) {
t.Helper()
bus := events.NewBus()
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: &pmSettings{},
EventBus: bus,
})
return &Plugin{name: "pluginmgr", pluginDir: dir, sdk: sdkInst}, bus
}
// ★★★ 判据:安装带 skill 的插件 → **skill_detected 事件真的发出来了**。
//
// 这是整条链路的核心:接收端(skillmgr)是现成的,要看的是发布端真的发。
func TestInstallPublishesSkillDetected(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
// 订阅(模拟 skillmgr 的订阅)
type got struct {
path string
src string
}
ch := make(chan got, 8)
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
path, _ := e.Payload["path"].(string)
ch <- got{path, e.Source}
})
data := buildHmapWithSkills(t, map[string]string{
"skill/my-skill/SKILL.md": "---\nname: my-skill\n---\n# hi\n",
"skill/other/skill.json": `{"name":"other"}`,
})
if _, err := p.installFromData(data, false); err != nil {
t.Fatalf("安装失败: %v", err)
}
// 必须收到恰好 2 个事件
var paths []string
for i := 0; i < 2; i++ {
select {
case g := <-ch:
paths = append(paths, g.path)
if g.src == "" {
t.Error("事件缺少 Source(无法溯源是谁带来的 skill)")
}
default:
t.Fatalf("只收到 %d 个 skill_detected 事件,期望 2 个(已收: %v)", i, paths)
}
}
joined := strings.Join(paths, ",")
if !strings.Contains(joined, "my-skill") || !strings.Contains(joined, "other") {
t.Errorf("移交的路径不对: %v", paths)
}
// 路径必须指向**解压后**的位置(而不是包里的相对路径)
for _, pa := range paths {
if !strings.HasPrefix(pa, filepath.Join(dir, "skilltest")) {
t.Errorf("★ 路径不在插件目录下(skillmgr 会找不到): %s", pa)
}
if _, err := os.Stat(pa); err != nil {
t.Errorf("★ 移交的路径不存在: %s", pa)
}
}
}
// ★★ 判据:**没有 skill/ 目录的插件不发任何事件**(绝大多数插件如此)。
//
// 不过滤的话每次装插件都会发噪音事件,skillmgr 反复收到无效路径。
func TestPluginWithoutSkillsPublishesNothing(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
n := 0
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) { n++ })
data := buildHmapWithSkills(t, map[string]string{
"plugin.json": `{"name":"x","version":"1.0.0","entry":"plugin.bin"}`,
"rawfile/some/data.txt": "x",
})
if _, err := p.installFromData(data, false); err != nil {
t.Fatalf("安装失败: %v", err)
}
if n != 0 {
t.Errorf("★ 无 skill 的插件不该发事件,实际发了 %d 个", n)
}
}
// ★★ 判据:**不是纯 SKILL 的目录不发**(避免 skillmgr 报无意义失败)。
//
// skillmgr 的 loadOne 要求 SKILL.md 或 skill.json。误放在 skill/ 下的
// sidecar 目录(含 main.js)会被它拒收 —— 与其发出去再失败,
// 不如在源头过滤并如实回报。
func TestNonSkillDirsAreNotPublished(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
var paths []string
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
pa, _ := e.Payload["path"].(string)
paths = append(paths, pa)
})
data := buildHmapWithSkills(t, map[string]string{
"skill/good/SKILL.md": "x",
"skill/bad-sidecar/main.js": "x", // 不是纯 SKILL
"skill/empty/notes.txt": "x", // 没有 SKILL.md/skill.json
})
res, _ := p.installFromData(data, false)
m := res.(map[string]interface{})
if len(paths) != 1 {
t.Errorf("★ 只应移交 1 个(good),实际 %d: %v", len(paths), paths)
}
if !strings.HasSuffix(paths[0], "good") {
t.Errorf("移交错了条目: %v", paths)
}
// 被跳过的必须**如实回报**,不静默吞掉
failed, _ := m["skills_failed"].([]string)
if len(failed) != 2 {
t.Errorf("★ 被跳过的 2 个应回报在 skills_failed,实际 %v", m["skills_failed"])
}
}
// ★★ 判据:脚手架文件不触发移交(.gitkeep/.gitignore)。
func TestScaffoldFilesDoNotTriggerPublish(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
var paths []string
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
pa, _ := e.Payload["path"].(string)
paths = append(paths, pa)
})
data := buildHmapWithSkills(t, map[string]string{
"skill/.gitkeep": "",
"skill/.gitignore": "*\n",
// ★ 隐藏**目录**:仅靠 IsDir() 挡不住,必须显式跳过 "."
// (隐藏文件是文件,会被 IsDir() 过滤掉,所以单测它们**测不到**这条规则)。
"skill/.hidden-dir/SKILL.md": "x",
})
res, _ := p.installFromData(data, false)
if len(paths) != 0 {
t.Errorf("★ 脚手架不应触发移交: %v", paths)
}
m := res.(map[string]interface{})
if n, _ := m["skills_registered"].(int); n != 0 {
t.Errorf("skills_registered 应为 0,实际 %v", m["skills_registered"])
}
}
// ★★★ 判据:**端到端贯通** —— pluginmgr 发的事件,skillmgr 的订阅者收得到。
//
// 前面几条验证"发出来了",这条验证"真的能被接收端收到"。
// 用真实事件总线 + 模拟 skillmgr 的处理逻辑(读 payload["path"] 并校验可加载)。
func TestEventReachesSkillmgrConsumer(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
// 模拟 skillmgr 的订阅者:它只读 path,然后 loadOne
loaded := make(chan string, 4)
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
path, _ := e.Payload["path"].(string)
if path == "" {
return // skillmgr 的实际行为:空路径直接返回
}
// 复刻 skillmgr 的准入判据
for _, f := range []string{"SKILL.md", "skill.json"} {
if _, err := os.Stat(filepath.Join(path, f)); err == nil {
loaded <- path
return
}
}
})
data := buildHmapWithSkills(t, map[string]string{
"skill/discipline/SKILL.md": "# 纪律\n",
})
if _, err := p.installFromData(data, false); err != nil {
t.Fatal(err)
}
select {
case path := <-loaded:
if !strings.HasSuffix(path, "discipline") {
t.Errorf("加载的路径不对: %s", path)
}
// 内容必须真的可读(证明路径指向解压后的真实文件)
b, err := os.ReadFile(filepath.Join(path, "SKILL.md"))
if err != nil || !strings.Contains(string(b), "纪律") {
t.Errorf("★ skill 内容不可读: %v %q", err, b)
}
default:
t.Fatal("★ 端到端不通:skillmgr 的订阅者没收到事件")
}
}
// ★★ 判据:**升级路径也移交**(不只在全新安装时)。
//
// 漏掉升级路径的话,已装插件的用户**永远拿不到**新版的 skill ——
// 而升级是最常见的更新方式。
func TestUpgradeAlsoHandsOutSkills(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
var paths []string
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
pa, _ := e.Payload["path"].(string)
paths = append(paths, pa)
})
v1 := buildHmapWithSkills(t, map[string]string{"skill/s1/SKILL.md": "v1"})
if _, err := p.installFromData(v1, false); err != nil {
t.Fatal(err)
}
first := len(paths)
if first == 0 {
t.Fatal("首次安装未移交")
}
// 升级到 v2:**重新打包**(不是替换字节 —— 那会坏校验和)
v2 := buildHmapWithSkillsVer(t, "2.0.0", map[string]string{"skill/s2/SKILL.md": "v2"})
res, _ := p.installFromData(v2, true)
m, _ := res.(map[string]interface{})
if e, bad := m["error"]; bad {
t.Fatalf("升级失败: %v (%v)", e, m["details"])
}
if len(paths) <= first {
t.Fatalf("★ 升级路径未移交 skill(首次 %d,升级后 %d)", first, len(paths))
}
// 升级后新的 skill 也要在
found := false
for _, pa := range paths[first:] {
if strings.HasSuffix(pa, "s2") {
found = true
}
}
if !found {
t.Errorf("升级后未移交新 skill: %v", paths[first:])
}
}
// ★ 判据:安装结果里如实回报 skill 数(不静默)。
func TestInstallResultReportsSkillCount(t *testing.T) {
dir := t.TempDir()
p, _ := newTestPluginWithBus(t, dir)
data := buildHmapWithSkills(t, map[string]string{"skill/a/SKILL.md": "x"})
res, _ := p.installFromData(data, false)
m := res.(map[string]interface{})
if n, ok := m["skills_registered"].(int); !ok || n != 1 {
t.Errorf("★ skills_registered 应为 1,实际 %#v", m["skills_registered"])
}
// 该字段必须能被 JSON 序列化(外部调用方要读它)
if _, err := json.Marshal(m); err != nil {
t.Errorf("安装结果无法序列化: %v", err)
}
}
// ★★★ 判据:**启动时必须重新登记随包 skill**(重启不该丢)。
//
// 这条是实测发现的真缺陷(2026-10-15):
//
// 装插件 → skill 注册 ✅
// ↓ worker 重启
// skillmgr 只扫 skillsDir(skill 在**插件目录**里)→ skill 静默消失 ❌
//
// 移交只发生在安装那一刻,重启后没有任何重新发现机制。
// 后果是"用户以为 skill 还在,实际已经没了"——典型的静默丢失。
func TestStartupReHandsOutPluginSkills(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
p.pluginDir = dir
// 造一个"已装插件"的目录布局:<pluginDir>/<name>/skill/<s>/SKILL.md
// (模拟上一次安装留下的产物,进程重启后就是这个状态)
skillDir := filepath.Join(dir, "someplugin", "skill", "my-discipline")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# 纪律"), 0o644); err != nil {
t.Fatal(err)
}
// 订阅(模拟 skillmgr 已在运行)
got := make(chan string, 4)
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
pa, _ := e.Payload["path"].(string)
got <- pa
})
// 调启动重扫(不启 goroutine,直接同步调便于断言)
p.reHandOutAllPluginSkills()
select {
case path := <-got:
if !strings.HasSuffix(path, "my-discipline") {
t.Errorf("移交路径不对: %s", path)
}
// 路径必须真实可读(否则 skillmgr 会失败)
if _, err := os.Stat(filepath.Join(path, "SKILL.md")); err != nil {
t.Errorf("★ 移交路径不可读: %v", err)
}
default:
t.Fatal("★ 启动重扫没有重新移交随包 skill(重启后会静默丢失)")
}
}
// ★★ 判据:启动重扫对**没有 skill 的插件**不发噪音事件。
func TestStartupRescanIgnoresPluginsWithoutSkills(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
p.pluginDir = dir
// 两个没有 skill/ 的插件目录
for _, n := range []string{"plain1", "plain2"} {
os.MkdirAll(filepath.Join(dir, n), 0o755)
}
n := 0
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) { n++ })
p.reHandOutAllPluginSkills()
if n != 0 {
t.Errorf("无 skill 的插件不该发事件,实际 %d 个", n)
}
}

View File

@ -1,69 +0,0 @@
package pluginmgr
// ★★★ 真实包 → 安装 → skill 移交 的端到端判据。
//
// 用**真实的 commondev bundle**(含内嵌 node+pi + 随包 skill)跑一遍
// installFromData,验证 skill_detected 事件真的带着**可加载的路径**发出来。
//
// 这条把三段串起来:打包(hmapdev 的 skill/ 约定)→ 安装(解压保留路径)
// → 移交(pluginmgr 发事件)。任何一段断了,随包 skill 就到不了用户手上。
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/events"
)
func TestRealBundleHandsOutSkill(t *testing.T) {
src := os.Getenv("HA_RAWFILE_E2E_HMAP")
if src == "" {
t.Skip("设 HA_RAWFILE_E2E_HMAP=<hmap路径> 以运行(需先构建 commondev bundle)")
}
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("读包失败: %v", err)
}
t.Logf("真实包 %.1f MiB", float64(len(data))/(1<<20))
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
var got []string
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
pa, _ := e.Payload["path"].(string)
got = append(got, pa)
})
res, err := p.installFromData(data, false)
if err != nil {
t.Fatalf("安装失败: %v", err)
}
if m, ok := res.(map[string]interface{}); ok {
if e, bad := m["error"]; bad {
t.Fatalf("安装报错: %v (%v)", e, m["details"])
}
if n, _ := m["skills_registered"].(int); n != 1 {
t.Errorf("★ skills_registered 应为 1,实际 %v", m["skills_registered"])
}
}
if len(got) != 1 {
t.Fatalf("★ 应移交 1 个 skill,实际 %d: %v", len(got), got)
}
path := got[0]
if !strings.HasSuffix(path, "commondev-discipline") {
t.Errorf("移交的 skill 名不对: %s", path)
}
// ★ 关键:路径必须真的可加载(指向解压后的真实文件)
md := filepath.Join(path, "SKILL.md")
b, err := os.ReadFile(md)
if err != nil {
t.Fatalf("★ 移交路径不可读(skillmgr 会失败): %v", err)
}
if len(b) < 1000 {
t.Errorf("SKILL.md 内容过短(%d 字节),可能没打进包", len(b))
}
t.Logf("移交成功:%s(SKILL.md %d 字节)", path, len(b))
}

View File

@ -1,96 +0,0 @@
package pluginmgr
// 暴露给 agent 的两个更新工具的判据。
//
// ## 为什么需要它
//
// kernel_update / plugin_update 是**模型唯一能触达的更新入口**:
// 它们一旦被删掉、改名、或参数对不上,agent 就再也升不了级 ——
// 而源码里 grep 得到工具名**不能证明**它注册成功(本仓教训:
// E-3 那次"源码里有、运行时没有"靠静态字符串查不出来)。
//
// 所以这里走**真实 registerTools**(捕获点 SDKConfig.RegTool),
// 断言"注册结果"而不是"源码文本"。
//
// ## 判据重点:参数与描述必须让 agent 选对
//
// agent 只能靠 Description/Parameters 判断怎么调用。若描述不说明
// "两种来源二选一",模型会漏传 pkg/release,然后被工具拒绝一次 ——
// 多烧一轮往返。
import (
"strings"
"testing"
)
// ★★★ 判据:两个更新工具都注册给了 agent。
func TestUpdateToolsAreRegisteredToAgent(t *testing.T) {
defs := collectToolDefs(t)
for _, want := range []string{"kernel_update", "plugin_update"} {
def, ok := defs[want]
if !ok {
t.Fatalf("★ 缺少工具 %s —— agent 无法升级%s", want,
map[string]string{"kernel_update": "内核", "plugin_update": "插件"}[want])
}
// 两个工具都会改磁盘/运行态 ⇒ 必须串行,避免与 install/remove 撞车。
if !def.Serial {
t.Errorf("★ %s 未标记 Serial:更新会替换文件并重启,与安装/卸载并发会撞车", want)
}
if strings.TrimSpace(def.Description) == "" {
t.Errorf("★ %s 缺 Description —— 模型无法判断何时该用", want)
}
}
}
// ★★★ 判据:kernel_update 必须同时支持"本机包"与"Release 自动更新"两种来源。
//
// 这是用户明确要求的能力:只支持其中一种,另一种场景就没有入口。
func TestKernelUpdateToolExposesBothSources(t *testing.T) {
def := collectToolDefs(t)["kernel_update"]
props, _ := def.Parameters["properties"].(map[string]interface{})
if props == nil {
t.Fatalf("★ kernel_update 没有 properties,模型无法传参")
}
apply, _ := props["apply"].(map[string]interface{})
if apply == nil {
t.Fatal("★ kernel_update 缺 apply 参数(两阶段:默认只检查)")
}
// 来源一:本机包
pkg, _ := props["pkg"].(map[string]interface{})
if pkg == nil {
t.Error("★ kernel_update 缺 pkg 参数 —— 无法用本机安装包升级")
}
// 来源二:Release 自动更新
release, _ := props["release"].(map[string]interface{})
if release == nil {
t.Error("★ kernel_update 缺 release 参数 —— 无法从 GitHub Release 自动更新")
}
// 描述必须说清"二选一",否则模型不知道两者关系(会都传或都不传)。
d := def.Description
if !strings.Contains(d, "pkg") || !strings.Contains(d, "Release") {
t.Errorf("★ 描述应同时说明 pkg 与 Release 两种来源,实际: %s", d)
}
}
// ★★★ 判据:plugin_update 必须有显式范围(不许默认全量升级)。
//
// 默认全升的后果:用户只想升一个插件,结果所有插件被重编译重装,
// 配置/依赖被动变更 —— 一次不该发生的批量变更。
func TestPluginUpdateToolRequiresExplicitScope(t *testing.T) {
def := collectToolDefs(t)["plugin_update"]
props, _ := def.Parameters["properties"].(map[string]interface{})
if props == nil {
t.Fatal("★ plugin_update 没有 properties")
}
plugins, _ := props["plugins"].(map[string]interface{})
if plugins == nil {
t.Fatal("★ plugin_update 缺 plugins 参数 —— 无法表达升级范围")
}
if !strings.Contains(def.Description, "all") {
t.Errorf("★ 描述应说明范围取 all 或名单,实际: %s", def.Description)
}
}

View File

@ -1,316 +0,0 @@
package pluginmgr
import (
"archive/zip"
"bytes"
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/plugin"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
func TestCmpVersion(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"1.0.0", "1.0.0", 0},
{"1.0.1", "1.0.0", 1},
{"1.0.0", "1.0.1", -1},
{"1.0", "1.0.0", 0},
{"v2.0.0", "1.9.9", 1},
{"2.0.0", "10.0.0", -1}, // 数字比较而非字典序
{"1.0.0-alpha", "1.0.0", 0}, // 非数字段按 0
}
for _, c := range cases {
if got := cmpVersion(c.a, c.b); got != c.want {
t.Errorf("cmpVersion(%q,%q)=%d want %d", c.a, c.b, got, c.want)
}
}
}
// ---- 最小 mock SDK ----
type pmSettings struct{}
func (m *pmSettings) DataDir() string { return "/tmp/mock_data" }
func (m *pmSettings) Get(string) (interface{}, error) { return nil, nil }
func (m *pmSettings) Set(string, interface{}) error { return nil }
func (m *pmSettings) SetSecret(string, interface{}) error { return nil }
func (m *pmSettings) GetSecret(string) (string, error) { return "", nil }
func (m *pmSettings) List(string) ([]string, error) { return nil, nil }
func (m *pmSettings) GetCore(string) (interface{}, error) { return nil, nil }
func (m *pmSettings) SetCore(string, interface{}) error { return nil }
func (m *pmSettings) ListCore(string) ([]string, error) { return nil, nil }
func (m *pmSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil }
func (m *pmSettings) SetPlugin(string, string, interface{}) error { return nil }
func (m *pmSettings) ListPlugin(string, string) ([]string, error) { return nil, nil }
func (m *pmSettings) RegisterDef(sdk.ConfigDef) {}
func (m *pmSettings) Defs(string) []*sdk.ConfigDef { return nil }
func (m *pmSettings) Dump() map[string]interface{} { return nil }
func (m *pmSettings) Plugins() []string { return nil }
func (m *pmSettings) DefsCore(string) []*sdk.ConfigDef { return nil }
func (m *pmSettings) DefsPlugin(string, string) []*sdk.ConfigDef { return nil }
func (m *pmSettings) Remove(string) error { return nil }
func (m *pmSettings) RemoveCore(string) error { return nil }
func (m *pmSettings) RemovePlugin(string, string) error { return nil }
// IsSecret:E-3 起 SettingsAPI 要求实现(判断某键是否按密文存储)。
func (m *pmSettings) IsSecret(string) bool { return false }
// fakePluginMgr 记录调用;StopAndUnload 只记标志,不真正操作。
type fakePluginMgr struct {
mu sync.Mutex
stopAndUnloads []string
loaded map[string]bool // 升级前"在跑"的插件,供 ListPluginRuntimes 返回
starts []string
}
func (f *fakePluginMgr) ListLoadedPlugins() []string { return nil }
func (f *fakePluginMgr) ListDisabledPlugins() []sdk.DisabledPluginInfo { return nil }
func (f *fakePluginMgr) IsPluginDisabled(string) bool { return false }
func (f *fakePluginMgr) IsBuiltinPlugin(string) bool { return false }
func (f *fakePluginMgr) DisablePlugin(string, string) error { return nil }
func (f *fakePluginMgr) EnablePlugin(string) error { return nil }
func (f *fakePluginMgr) StopPlugin(string) error { return nil }
func (f *fakePluginMgr) StartPlugin(name string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.starts = append(f.starts, name)
return nil
}
func (f *fakePluginMgr) IsPluginRuntimeStopped(string) bool { return false }
func (f *fakePluginMgr) RemovePlugin(string) error { return nil }
func (f *fakePluginMgr) StopAndUnload(name string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.stopAndUnloads = append(f.stopAndUnloads, name)
return nil
}
func (f *fakePluginMgr) ReloadPlugins() (string, error) { return "", nil }
func (f *fakePluginMgr) ReloadOne(string) error { return nil }
func (f *fakePluginMgr) PluginMetas() map[string]sdk.PluginMeta {
return map[string]sdk.PluginMeta{}
}
func (f *fakePluginMgr) PluginDir() string { return "" }
func (f *fakePluginMgr) PluginRuntime(string) (sdk.PluginRuntimeInfo, bool) {
return sdk.PluginRuntimeInfo{}, false
}
func (f *fakePluginMgr) ListPluginRuntimes() []sdk.PluginRuntimeInfo {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]sdk.PluginRuntimeInfo, 0, len(f.loaded))
for name, loaded := range f.loaded {
out = append(out, sdk.PluginRuntimeInfo{Name: name, Loaded: loaded})
}
return out
}
// buildHmap 构造一个最小 .hmap 包。
func buildHmap(t *testing.T, name, version string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
manifest := map[string]interface{}{
"name": name, "name_zh": name, "name_en": name,
"version": version, "entry": "plugin.bin",
}
mData, _ := json.Marshal(manifest)
f, _ := zw.Create("plugin.json")
f.Write(mData)
bin, _ := zw.Create("plugin.bin")
bin.Write([]byte("binary-" + name + "-" + version))
zw.Close()
return buf.Bytes()
}
func TestInstallThenUpgradeKeepsConfig(t *testing.T) {
dir := t.TempDir()
fm := &fakePluginMgr{}
bus := events.NewBus()
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: &pmSettings{},
EventBus: bus,
PluginMgr: fm,
})
p := &Plugin{pluginDir: dir, sdk: sdkInst}
// 1. 首次安装 v1.0.0
r1, _ := p.installFromData(buildHmap(t, "demo", "1.0.0"), false)
m1 := r1.(map[string]interface{})
if m1["status"] != "installed" {
t.Fatalf("install failed: %v", m1)
}
if _, err := os.Stat(filepath.Join(dir, "demo", "plugin.json")); err != nil {
t.Fatalf("installed dir missing: %v", err)
}
// 2. 不带 overwrite 重装 → 报 already exists + remove_first hint
r2, _ := p.installFromData(buildHmap(t, "demo", "1.0.0"), false)
m2 := r2.(map[string]interface{})
if m2["error"] != "plugin already exists" || m2["hint"] == "" {
t.Fatalf("expected already-exists with hint, got %v", m2)
}
if m2["current"] != "1.0.0" {
t.Fatalf("current version not reported: %v", m2)
}
// 3. overwrite 升级 v1.0.0 → v2.0.0
r3, _ := p.installFromData(buildHmap(t, "demo", "2.0.0"), true)
m3 := r3.(map[string]interface{})
if m3["status"] != "installed" || m3["action"] != "upgraded" {
t.Fatalf("upgrade failed: %v", m3)
}
if m3["previous_version"] != "1.0.0" {
t.Fatalf("previous_version = %v", m3["previous_version"])
}
if m3["config_kept"] != true {
t.Fatalf("config_kept should be true: %v", m3)
}
// StopAndUnload 应被调用且不触发 RemovePlugin(不删配置)
fm.mu.Lock()
calls := append([]string{}, fm.stopAndUnloads...)
fm.mu.Unlock()
if len(calls) != 1 || calls[0] != "demo" {
t.Fatalf("StopAndUnload not called once with demo: %v", calls)
}
// 新二进制写入
binData, err := os.ReadFile(filepath.Join(dir, "demo", "plugin.bin"))
if err != nil {
t.Fatalf("read new bin: %v", err)
}
if string(binData) != "binary-demo-2.0.0" {
t.Fatalf("bin not overwritten: %q", string(binData))
}
// 4. 降级 v2.0.0 → v1.5.0
r4, _ := p.installFromData(buildHmap(t, "demo", "1.5.0"), true)
m4 := r4.(map[string]interface{})
if m4["action"] != "downgraded" {
t.Fatalf("downgrade action = %v", m4)
}
}
func TestExtractFailureRollsBack(t *testing.T) {
dir := t.TempDir()
fm := &fakePluginMgr{}
bus := events.NewBus()
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: &pmSettings{},
EventBus: bus,
PluginMgr: fm,
})
p := &Plugin{pluginDir: dir, sdk: sdkInst}
// 先装 v1.0.0
if r, _ := p.installFromData(buildHmap(t, "rollback", "1.0.0"), false); r.(map[string]interface{})["status"] != "installed" {
t.Fatal("install failed")
}
// 构造损坏包:zip 但缺 plugin.json(extractPackage 会失败)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
f, _ := zw.Create("plugin.bin")
f.Write([]byte("corrupt"))
zw.Close()
// 畸形包在 validatePackage 层就拒绝,未达 extract——模拟 extract 失败:
// 直接注入非法平台文件触发 extractPackage 错误
// ★ 2026-11-04:原先这里先 `bad := buildHmap(...)` 再整体覆盖成
// 篡改后的字节 —— 第一次构造未被使用(staticcheck SA4006)。
// 本用例要的就是**篡改版**,直接构造它。
//
// 篡改使 extract 失败:附加一个越界路径
var rb bytes.Buffer
zw2 := zip.NewWriter(&rb)
f2, _ := zw2.Create("../../evil")
f2.Write([]byte("x"))
mf, _ := zw2.Create("plugin.json")
mData, _ := json.Marshal(map[string]interface{}{"name": "rollback", "version": "9.9.9", "entry": "plugin.bin"})
mf.Write(mData)
zw2.Close()
bad := rb.Bytes()
r, _ := p.installFromData(bad, true)
m := r.(map[string]interface{})
if m["error"] == nil {
t.Fatalf("expected error for corrupt package, got %v", m)
}
if m["rollback"] != nil {
t.Fatalf("rollback itself failed: %v", m)
}
// 旧版应被恢复
mfest, err := plugin.ReadManifest(filepath.Join(dir, "rollback"))
if err != nil || mfest.Version != "1.0.0" {
t.Fatalf("old version not restored: %v / %v", mfest, err)
}
}
// ★ 升级不该顺手改变运行状态(2026-10-20 生产实证)。
//
// 现场:重装 qq 插件后它静默停着(StopAndUnload 只停不启),agent 发现工具
// 全没了,只能 get_plugin_tools ×N → plugin_start 摸回来。安装流程的语义应当是
// "换个包",不是"把正在用的插件关掉"。
func TestUpgradeRestoresRunningState(t *testing.T) {
dir := t.TempDir()
fm := &fakePluginMgr{loaded: map[string]bool{}}
bus := events.NewBus()
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: &pmSettings{},
EventBus: bus,
PluginMgr: fm,
})
p := &Plugin{pluginDir: dir, sdk: sdkInst}
if r, _ := p.installFromData(buildHmap(t, "demo", "1.0.0"), false); r.(map[string]interface{})["status"] != "installed" {
t.Fatal("install failed")
}
// 升级前它是在跑的
fm.mu.Lock()
fm.loaded["demo"] = true
fm.mu.Unlock()
r, _ := p.installFromData(buildHmap(t, "demo", "2.0.0"), true)
m := r.(map[string]interface{})
if m["was_running"] != true {
t.Fatalf("was_running 应为 true: %v", m)
}
if m["running_restored"] != true {
t.Fatalf("升级后应自动拉回运行状态: %v", m)
}
fm.mu.Lock()
starts := append([]string{}, fm.starts...)
fm.mu.Unlock()
if len(starts) != 1 || starts[0] != "demo" {
t.Fatalf("StartPlugin 应被调用一次: %v", starts)
}
}
// 升级前没在跑的插件,安装后也不该被强行拉起。
func TestUpgradeDoesNotStartStoppedPlugin(t *testing.T) {
dir := t.TempDir()
fm := &fakePluginMgr{loaded: map[string]bool{}}
bus := events.NewBus()
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{Settings: &pmSettings{}, EventBus: bus, PluginMgr: fm})
p := &Plugin{pluginDir: dir, sdk: sdkInst}
if r, _ := p.installFromData(buildHmap(t, "demo", "1.0.0"), false); r.(map[string]interface{})["status"] != "installed" {
t.Fatal("install failed")
}
r, _ := p.installFromData(buildHmap(t, "demo", "2.0.0"), true)
m := r.(map[string]interface{})
if m["was_running"] != false || m["running_restored"] != false {
t.Fatalf("未运行的插件不应被拉起: %v", m)
}
if len(fm.starts) != 0 {
t.Fatalf("StartPlugin 不应被调用: %v", fm.starts)
}
}