mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
## 为什么必须做
SDK 仓(独立仓库)已把 module 从 `github.com/JianFeeeee/homeagentsdk`
改名为 `hologramagentsdk`。主仓通过 `replace` 直接引用它的**物理目录**:
replace github.com/JianFeeeee/homeagentsdk => ./third_party/homeagent-sdk
一旦 SDK 侧改成新 module 名,主仓立刻炸:
go: ./third_party/homeagent-sdk@ used for two different module paths
(github.com/JianFeeeee/hologramagentsdk and github.com/JianFeeeee/homeagentsdk)
Go **不允许**同一目录被两个 module 路径 replace。所以不能"新旧并存",
只能把主仓 59 个文件的 import 全部改到新名,并删掉旧 replace。
## 一个被证伪的前提
我最初按"third_party/homeagent-sdk 是独立仓库,不能在此仓修改其源码"
来处理,并因此判断两个仓的改动可以隔离。实际上主仓是靠 `replace` 指向
该目录的物理副本 —— **两个仓的改动不可能真正隔离**:SDK 侧改 module 名,
主仓必然跟着改 import。
这个前提应该在做任何改动之前用 `go list -m` 验证,而不是假设。
## 连带修掉两处
| 缺陷 | 性质 |
|---|---|
| `TestLuaSDKMockSingleSource` 失败 | SDK 的 `sdk/lua/sdk.lua` 变了,主仓内嵌副本未同步(跑 `scripts/sync-lua-sdk.sh` 修复) |
| `sdkModuleNames` 分散两处 | 已在 SDK 仓合并为单一数据源(见 SDK 仓 725732b) |
第一条是**副本一致性判据抓到的**,不是我自己发现的 —— 这类判据的价值在此:
两个仓必然有副本,靠人记住"改了 A 要同步 B"是做不到的。
## 验证
- `go build ./...` / `go vet ./...` 干净
- `go test ./... -count=1`:**58 包 0 失败**
- `internal/plugin`(Lua SDK 副本一致性)+ `internal/sdk` 全绿
- 主仓 `third_party/homeagent-sdk/` 与 SDK 仓 `main` 无差异
378 lines
11 KiB
Go
378 lines
11 KiB
Go
package skillmgr
|
||
|
||
import (
|
||
"archive/tar"
|
||
"compress/gzip"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||
)
|
||
|
||
// ---- 测试辅助 ----
|
||
|
||
type nilSettings struct{}
|
||
|
||
func (m *nilSettings) DataDir() string { return "/tmp/mock_data" }
|
||
|
||
func (m *nilSettings) Get(string) (interface{}, error) { return nil, nil }
|
||
func (m *nilSettings) Set(string, interface{}) error { return nil }
|
||
func (m *nilSettings) SetSecret(string, interface{}) error { return nil }
|
||
func (m *nilSettings) GetSecret(string) (string, error) { return "", nil }
|
||
|
||
// IsSecret:E-3 起 SettingsAPI 要求实现(判断某键是否按密文存储)。
|
||
func (m *nilSettings) IsSecret(string) bool { return false }
|
||
func (m *nilSettings) List(string) ([]string, error) { return nil, nil }
|
||
func (m *nilSettings) GetCore(string) (interface{}, error) { return nil, nil }
|
||
func (m *nilSettings) SetCore(string, interface{}) error { return nil }
|
||
func (m *nilSettings) ListCore(string) ([]string, error) { return nil, nil }
|
||
func (m *nilSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil }
|
||
func (m *nilSettings) SetPlugin(string, string, interface{}) error { return nil }
|
||
func (m *nilSettings) ListPlugin(string, string) ([]string, error) { return nil, nil }
|
||
func (m *nilSettings) RegisterDef(pubsdk.ConfigDef) {}
|
||
func (m *nilSettings) Defs(string) []*pubsdk.ConfigDef { return nil }
|
||
func (m *nilSettings) Dump() map[string]interface{} { return nil }
|
||
func (m *nilSettings) Plugins() []string { return nil }
|
||
func (m *nilSettings) DefsCore(string) []*sdk.ConfigDef { return nil }
|
||
func (m *nilSettings) DefsPlugin(string, string) []*sdk.ConfigDef { return nil }
|
||
func (m *nilSettings) Remove(string) error { return nil }
|
||
func (m *nilSettings) RemoveCore(string) error { return nil }
|
||
func (m *nilSettings) RemovePlugin(string, string) error { return nil }
|
||
|
||
// toolSpy 记录注册的工具,并可按名调用 handler。
|
||
type toolSpy struct {
|
||
mu sync.Mutex
|
||
handlers map[string]func(args map[string]interface{}) (interface{}, error)
|
||
}
|
||
|
||
func newToolSpy() *toolSpy {
|
||
return &toolSpy{handlers: map[string]func(map[string]interface{}) (interface{}, error){}}
|
||
}
|
||
|
||
func (s *toolSpy) Register(name string, def interface{}, h func(args map[string]interface{}) (interface{}, error)) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.handlers[name] = h
|
||
return nil
|
||
}
|
||
|
||
func (s *toolSpy) call(t *testing.T, name string, args map[string]interface{}) (interface{}, error) {
|
||
t.Helper()
|
||
s.mu.Lock()
|
||
h, ok := s.handlers[name]
|
||
s.mu.Unlock()
|
||
if !ok {
|
||
t.Fatalf("tool %q not registered", name)
|
||
}
|
||
return h(args)
|
||
}
|
||
|
||
// newTestPlugin 构造带真实事件总线的 skillmgr 实例。
|
||
func newTestPlugin(t *testing.T) (*Plugin, *toolSpy, *events.Bus) {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
bus := events.NewBus()
|
||
p := New("skillmgr", dir)
|
||
|
||
reg := func(name string, def interface{}, h func(args map[string]interface{}) (interface{}, error)) error {
|
||
_ = name
|
||
_ = def
|
||
_ = h
|
||
return nil
|
||
}
|
||
_ = reg // 占位避免误用
|
||
|
||
spy := newToolSpy()
|
||
cfgSDK := sdk.New("skillmgr", sdk.SDKConfig{
|
||
RegTool: func(name string, def sdk.ToolDef, h sdk.ToolHandler) error {
|
||
return spy.Register(name, def, func(args map[string]interface{}) (interface{}, error) {
|
||
return h(args)
|
||
})
|
||
},
|
||
Settings: &nilSettings{},
|
||
EventBus: bus,
|
||
})
|
||
if err := p.Start(cfgSDK); err != nil {
|
||
t.Fatalf("Start: %v", err)
|
||
}
|
||
t.Cleanup(func() { p.Stop() })
|
||
return p, spy, bus
|
||
}
|
||
|
||
func writeSkill(t *testing.T, dir, name, description string) string {
|
||
t.Helper()
|
||
sd := filepath.Join(dir, name)
|
||
os.MkdirAll(sd, 0755)
|
||
content := "---\nname: " + name + "\nversion: 1.0.0\n---\n\n# " + name + "\n\n" + description + "\n"
|
||
if err := os.WriteFile(filepath.Join(sd, SkillFileName), []byte(content), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return sd
|
||
}
|
||
|
||
const sampleSkillContent = `---
|
||
name: demo-skill
|
||
version: 0.2.0
|
||
author: tester
|
||
---
|
||
|
||
# demo-skill
|
||
|
||
用于测试的演示技能。
|
||
|
||
## 使用时机
|
||
|
||
测试时使用。
|
||
|
||
## 操作步骤
|
||
|
||
### step-1
|
||
|
||
第一步说明。
|
||
|
||
- target: 目标参数
|
||
|
||
执行示例:
|
||
|
||
` + "```bash\necho demo\n```" + `
|
||
`
|
||
|
||
// ---- 用例 ----
|
||
|
||
func TestScanExistingLoadsPureSkillsOnly(t *testing.T) {
|
||
dir := t.TempDir()
|
||
writeSkill(t, dir, "alpha", "alpha desc")
|
||
writeSkill(t, dir, "beta", "beta desc")
|
||
|
||
// sidecar 目录(main.js)应被跳过
|
||
scDir := filepath.Join(dir, "sidecar-thing")
|
||
os.MkdirAll(scDir, 0755)
|
||
os.WriteFile(filepath.Join(scDir, "main.js"), []byte("//"), 0644)
|
||
|
||
// OC plugin 目录应被跳过
|
||
ocDir := filepath.Join(dir, "oc-thing")
|
||
os.MkdirAll(ocDir, 0755)
|
||
os.WriteFile(filepath.Join(ocDir, "openclaw.plugin.json"), []byte("{}"), 0644)
|
||
|
||
// 隐藏目录跳过
|
||
hd := filepath.Join(dir, ".hidden")
|
||
os.MkdirAll(hd, 0755)
|
||
os.WriteFile(filepath.Join(hd, SkillFileName), []byte("x"), 0644)
|
||
|
||
p := New("skillmgr", dir)
|
||
bus := events.NewBus()
|
||
sdkInst := sdk.New("skillmgr", sdk.SDKConfig{
|
||
Settings: &nilSettings{},
|
||
EventBus: bus,
|
||
})
|
||
if err := p.Start(sdkInst); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer p.Stop()
|
||
|
||
names := map[string]bool{}
|
||
for _, e := range p.snapshot() {
|
||
names[e.sk.Name()] = true
|
||
}
|
||
if !names["alpha"] || !names["beta"] {
|
||
t.Fatalf("expected alpha+beta loaded, got %v", names)
|
||
}
|
||
if names["sidecar-thing"] || names["oc-thing"] || names[".hidden"] {
|
||
t.Fatalf("sidecar/OC/hidden should not load, got %v", names)
|
||
}
|
||
}
|
||
|
||
func TestSkillDetectedEventHandoff(t *testing.T) {
|
||
p, _, bus := newTestPlugin(t)
|
||
|
||
// 模拟 clawhubadapter 移交事件
|
||
dir := p.skillsDir
|
||
sd := filepath.Join(dir, "handed")
|
||
os.MkdirAll(sd, 0755)
|
||
os.WriteFile(filepath.Join(sd, SkillFileName), []byte(sampleSkillContent), 0644)
|
||
|
||
bus.Publish(&events.Event{
|
||
Type: detectEvent,
|
||
Source: "clawhubadapter",
|
||
Payload: map[string]interface{}{"path": sd},
|
||
Timestamp: time.Now().Unix(),
|
||
})
|
||
|
||
deadline := time.Now().Add(2 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
if _, ok := p.get("handed"); ok {
|
||
break
|
||
}
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
if _, ok := p.get("handed"); !ok {
|
||
t.Fatal("handed-off skill not registered after event")
|
||
}
|
||
}
|
||
|
||
func TestCreateValidateLoadFlow(t *testing.T) {
|
||
p, spy, _ := newTestPlugin(t)
|
||
|
||
// 两步式:先骨架
|
||
out, err := spy.call(t, "skill_create", map[string]interface{}{
|
||
"name": "weather-notify",
|
||
"description": "天气通知",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create skeleton: %v", err)
|
||
}
|
||
_ = out
|
||
if !fileExists(filepath.Join(p.skillsDir, "weather-notify", SkillFileName)) {
|
||
t.Fatal("skeleton SKILL.md missing")
|
||
}
|
||
|
||
// 第二步:完整内容覆盖 → 自动加载
|
||
if _, err := spy.call(t, "skill_create", map[string]interface{}{
|
||
"name": "weather-notify",
|
||
"content": sampleSkillContent,
|
||
}); err != nil {
|
||
t.Fatalf("create full: %v", err)
|
||
}
|
||
if _, ok := p.get("weather-notify"); !ok {
|
||
t.Fatal("full-content skill not loaded under its own name")
|
||
}
|
||
|
||
// 空白 content 走模板分支:生成骨架而非报错
|
||
if _, err := spy.call(t, "skill_create", map[string]interface{}{
|
||
"name": "bad-skill",
|
||
"description": "",
|
||
}); err != nil {
|
||
t.Fatalf("skeleton-only create should succeed: %v", err)
|
||
}
|
||
if !fileExists(filepath.Join(p.skillsDir, "bad-skill", SkillFileName)) {
|
||
t.Fatal("bad-skill skeleton missing")
|
||
}
|
||
|
||
// 有内容但缺描述的 content 被校验拒绝
|
||
if _, err := spy.call(t, "skill_create", map[string]interface{}{
|
||
"name": "no-desc",
|
||
"content": "---\nname: no-desc\n---\n\n## 步骤\n",
|
||
}); err == nil {
|
||
t.Fatal("content without description should be rejected by ValidateSKILLContent")
|
||
}
|
||
|
||
// 非法名称被拒
|
||
if _, err := spy.call(t, "skill_create", map[string]interface{}{"name": "../evil"}); err == nil {
|
||
t.Fatal("path traversal name should be rejected")
|
||
}
|
||
}
|
||
|
||
func TestExportInstallRoundTrip(t *testing.T) {
|
||
p, spy, _ := newTestPlugin(t)
|
||
|
||
// 先创建并加载一个 skill
|
||
if _, err := spy.call(t, "skill_create", map[string]interface{}{
|
||
"name": "roundtrip",
|
||
"content": strings.ReplaceAll(sampleSkillContent, "demo-skill", "roundtrip"),
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
outDir := filepath.Join(p.skillsDir, "..", "exports")
|
||
packPath := filepath.Join(outDir, "roundtrip.skm")
|
||
if _, err := spy.call(t, "skill_export", map[string]interface{}{"name": "roundtrip"}); err != nil {
|
||
t.Fatalf("export: %v", err)
|
||
}
|
||
if !fileExists(packPath) {
|
||
t.Fatalf("pack missing at %s", packPath)
|
||
}
|
||
|
||
// 卸载 + 删文件后从包重装
|
||
if _, err := spy.call(t, "skill_unload", map[string]interface{}{"name": "roundtrip", "delete_files": true}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, ok := p.get("roundtrip"); ok {
|
||
t.Fatal("skill should be unloaded")
|
||
}
|
||
if fileExists(filepath.Join(p.skillsDir, "roundtrip")) {
|
||
t.Fatal("files should be deleted")
|
||
}
|
||
|
||
if _, err := spy.call(t, "skill_install", map[string]interface{}{"source": packPath}); err != nil {
|
||
t.Fatalf("install: %v", err)
|
||
}
|
||
e, ok := p.get("roundtrip")
|
||
if !ok {
|
||
t.Fatal("reinstalled skill not loaded")
|
||
}
|
||
if e.sk.Version() != "0.2.0" {
|
||
t.Fatalf("unexpected version %q", e.sk.Version())
|
||
}
|
||
}
|
||
|
||
func TestEnableDisable(t *testing.T) {
|
||
p, spy, _ := newTestPlugin(t)
|
||
writeSkill(t, p.skillsDir, "toggle", "toggle desc")
|
||
p.scanExisting()
|
||
|
||
if _, err := spy.call(t, "skill_disable", map[string]interface{}{"name": "toggle"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if e, _ := p.get("toggle"); e.enabled {
|
||
t.Fatal("should be disabled")
|
||
}
|
||
if _, err := spy.call(t, "skill_enable", map[string]interface{}{"name": "toggle"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if e, _ := p.get("toggle"); !e.enabled {
|
||
t.Fatal("should be enabled")
|
||
}
|
||
}
|
||
|
||
func TestPackTarSlipRejected(t *testing.T) {
|
||
tmp := t.TempDir()
|
||
|
||
// 构造带 .. 逃逸条目的恶意包
|
||
packPath := filepath.Join(tmp, "evil.skm")
|
||
f, _ := os.Create(packPath)
|
||
gz := gzip.NewWriter(f)
|
||
tw := tar.NewWriter(gz)
|
||
hdr := &tar.Header{Name: "../../../evil.txt", Mode: 0644, Size: 3}
|
||
tw.WriteHeader(hdr)
|
||
tw.Write([]byte("bad"))
|
||
tw.Close()
|
||
gz.Close()
|
||
f.Close()
|
||
|
||
dst := filepath.Join(tmp, "out")
|
||
if _, err := unpackSkill(packPath, dst); err == nil {
|
||
t.Fatal("tar slip entry must be rejected")
|
||
}
|
||
}
|
||
|
||
func TestSkillIndex(t *testing.T) {
|
||
p, spy, _ := newTestPlugin(t)
|
||
|
||
if idx := p.SkillIndex(); idx != "" {
|
||
t.Fatalf("empty registry should give empty index, got %q", idx)
|
||
}
|
||
|
||
if _, err := spy.call(t, "skill_create", map[string]interface{}{
|
||
"name": "idx-test",
|
||
"content": strings.ReplaceAll(sampleSkillContent, "demo-skill", "idx-test"),
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
idx := p.SkillIndex()
|
||
if !strings.Contains(idx, "idx-test v0.2.0") || !strings.Contains(idx, "用于测试的演示技能。") {
|
||
t.Fatalf("index missing name/desc: %q", idx)
|
||
}
|
||
|
||
// 禁用后不出现在索引里
|
||
spy.call(t, "skill_disable", map[string]interface{}{"name": "idx-test"})
|
||
if idx := p.SkillIndex(); strings.Contains(idx, "idx-test") {
|
||
t.Fatalf("disabled skill should be excluded from index: %q", idx)
|
||
}
|
||
}
|