mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
【SDK DataDir API】
- SettingsAPI 新增 DataDir() string:返回插件专属数据目录
<data>/plugin_data/<name>(内核保证存在),解决此前插件只能
靠 GetCore("daemon.data_dir") 手工解析的缺陷
- settingsImpl 新增 dataDir 字段 + SetDataDir;Registry buildSDK
注入(<data>/plugin_data/<name> 并 MkdirAll);main.go 接线
- cabi 新增 CORE_SETTINGS_DATA_DIR (id 51);plugindev dispatchSettings
模板补 DataDir() 实现
【ai_image 交付本地路径】
- 生成后下载临时 S3 URL 到插件数据目录,返回本地文件路径(永久不
过期),而非 1 小时过期的 S3 URL。带 UA 规避图床对无 UA 客户端拦截
(此前 agent 裸 curl 验证被拒导致误报失败)
- 返回 local_paths 字段 + 提示用 output_send(type=image) 展示
端到端:ai_image_generate → plugin_data/ai_image/*.png 有效 PNG(1024²),
经 llmsproxy→siliconflow 生成。
371 lines
11 KiB
Go
371 lines
11 KiB
Go
package skillmgr
|
||
|
||
import (
|
||
"archive/tar"
|
||
"compress/gzip"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/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) 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)
|
||
}
|
||
}
|