mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-12 20:27:30 +00:00
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:
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 恒在其后)会被漏检")
|
||||
}
|
||||
}
|
||||
@ -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 ""
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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("★ 恶意文件落到了插件目录下")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user