mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
新增 internal/plugins/skillmgr(native skill 全生命周期 owner): - skill_list/info/load/unload/enable/disable/create/export/install - skill_create 两步式:先生成骨架模板,LLM 补全后传 content 覆盖写入 (plugin.ValidateSKILLContent 校验)并自动加载生效 - .skm 分发包(tar.gz):packSkill/unpackSkill 含 TarSlip 防护 (拒绝绝对路径/../逃逸、强制单根目录、校验包内 SKILL.md) - skills 目录扫描:纯 SKILL.md/skill.json 条目归本插件; sidecar(main.js/main.py)/OC plugin(openclaw.plugin.json) 留给兼容层 clawhubadapter 职责分离(OpenClaw 兼容层不再持有 native skill): - 删除 p.skills 字段与 default 分支 LoadSKILL 逻辑 - 发现纯 SKILL 条目改为发布 events.EventSkillDetected 移交事件, 由 skillmgr 订阅注册;启动时序 c<s 下全扫兜底,事件用于热新增 - claw_list/plugin_info 不再输出 SKILL 段,统一走 skill_list 方案B prompt 注入: - agentCore 新增 SkillIndexProvider 接口 + SetSkillIndexProvider - buildSystemPrompt 注入【可用技能】轻量索引(名称+版本+描述), LLM 匹配场景时主动 skill_info 拉全文按文档执行 - main.go 在插件加载后将 skillmgr 实例接线到 agent 内核小修: - extractDescription 跳过 YAML frontmatter 块(此前所有带 frontmatter 的 SKILL.md 描述都被误判为 '---') - extractField 剥离 YAML 成对引号(version: "1.0" 不再带尾引号) - plugin.ValidateSKILLContent 导出供生成侧校验
267 lines
6.3 KiB
Go
267 lines
6.3 KiB
Go
package skillmgr
|
||
|
||
import (
|
||
"archive/tar"
|
||
"compress/gzip"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
)
|
||
|
||
// packSkill 把 skill 目录打包为 .skm(tar.gz)。返回打包的文件数。
|
||
// 包内路径统一为 <skillName>/<相对路径>,解包端按首段目录还原。
|
||
func packSkill(srcDir, outPath string) (int, error) {
|
||
info, err := os.Stat(srcDir)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("stat skill dir: %w", err)
|
||
}
|
||
if !info.IsDir() {
|
||
// 单 .md 文件 skill:包装成只含一个文件的包
|
||
return packSingleFile(srcDir, outPath)
|
||
}
|
||
|
||
skillName := filepath.Base(srcDir)
|
||
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
f, err := os.Create(outPath)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("create pack: %w", err)
|
||
}
|
||
defer f.Close()
|
||
|
||
gz := gzip.NewWriter(f)
|
||
defer gz.Close()
|
||
tw := tar.NewWriter(gz)
|
||
defer tw.Close()
|
||
|
||
count := 0
|
||
err = filepath.Walk(srcDir, func(path string, fi os.FileInfo, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 跳过隐藏文件与 node_modules
|
||
rel, rerr := filepath.Rel(srcDir, path)
|
||
if rerr != nil {
|
||
return rerr
|
||
}
|
||
if rel == "." {
|
||
return nil
|
||
}
|
||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||
if strings.HasPrefix(part, ".") || part == "node_modules" {
|
||
if fi.IsDir() {
|
||
return filepath.SkipDir
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
hdr, err := tar.FileInfoHeader(fi, "")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
hdr.Name = filepath.ToSlash(filepath.Join(skillName, rel))
|
||
if fi.IsDir() {
|
||
hdr.Name += "/"
|
||
if err := tw.WriteHeader(hdr); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
if !fi.Mode().IsRegular() {
|
||
return nil // 跳过符号链接等非常规文件,避免路径逃逸
|
||
}
|
||
if err := tw.WriteHeader(hdr); err != nil {
|
||
return err
|
||
}
|
||
src, err := os.Open(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = io.Copy(tw, src)
|
||
src.Close()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
count++
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return 0, fmt.Errorf("pack walk: %w", err)
|
||
}
|
||
return count, nil
|
||
}
|
||
|
||
func packSingleFile(mdPath, outPath string) (int, error) {
|
||
skillName := strings.TrimSuffix(filepath.Base(mdPath), ".md")
|
||
data, err := os.ReadFile(mdPath)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
f, err := os.Create(outPath)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer f.Close()
|
||
gz := gzip.NewWriter(f)
|
||
defer gz.Close()
|
||
tw := tar.NewWriter(gz)
|
||
defer tw.Close()
|
||
|
||
hdr := &tar.Header{
|
||
Name: filepath.ToSlash(filepath.Join(skillName, SkillFileName)),
|
||
Mode: 0644,
|
||
Size: int64(len(data)),
|
||
}
|
||
if err := tw.WriteHeader(hdr); err != nil {
|
||
return 0, err
|
||
}
|
||
if _, err := tw.Write(data); err != nil {
|
||
return 0, err
|
||
}
|
||
return 1, nil
|
||
}
|
||
|
||
// unpackSkill 解压 .skm 到临时目录后整体 rename 为 dstParent。
|
||
// 校验包内所有条目必须位于同一首段目录下,防止 TarSlip 路径逃逸。
|
||
// 返回解出的文件数。
|
||
func unpackSkill(packPath, dstParent string) (int, error) {
|
||
f, err := os.Open(packPath)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer f.Close()
|
||
|
||
gz, err := gzip.NewReader(f)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("not a gzip pack: %w", err)
|
||
}
|
||
defer gz.Close()
|
||
|
||
tw := tar.NewReader(gz)
|
||
var root string // 包内唯一根目录名
|
||
count := 0
|
||
|
||
tmpDir, err := os.MkdirTemp(filepath.Dir(dstParent), ".skillmgr-unpack-")
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer os.RemoveAll(tmpDir)
|
||
|
||
for {
|
||
hdr, err := tw.Next()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return count, fmt.Errorf("read tar: %w", err)
|
||
}
|
||
|
||
// 清洗路径:拒绝绝对路径与 .. 逃逸
|
||
name := filepath.Clean(filepath.FromSlash(hdr.Name))
|
||
if filepath.IsAbs(name) || strings.HasPrefix(name, "..") {
|
||
return count, fmt.Errorf("unsafe entry in pack: %q", hdr.Name)
|
||
}
|
||
parts := strings.Split(name, string(filepath.Separator))
|
||
if len(parts) < 2 {
|
||
return count, fmt.Errorf("entry outside skill root dir: %q", hdr.Name)
|
||
}
|
||
if root == "" {
|
||
root = parts[0]
|
||
} else if parts[0] != root {
|
||
return count, fmt.Errorf("multiple root dirs in pack: %q vs %q", root, parts[0])
|
||
}
|
||
|
||
target := filepath.Join(tmpDir, name)
|
||
if hdr.Typeflag == tar.TypeDir {
|
||
if err := os.MkdirAll(target, 0755); err != nil {
|
||
return count, err
|
||
}
|
||
continue
|
||
}
|
||
if hdr.Typeflag != tar.TypeReg {
|
||
continue // 跳过符号链接等
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||
return count, err
|
||
}
|
||
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode)&0755)
|
||
if err != nil {
|
||
return count, err
|
||
}
|
||
_, err = io.Copy(out, tw)
|
||
out.Close()
|
||
if err != nil {
|
||
return count, err
|
||
}
|
||
count++
|
||
}
|
||
|
||
if root == "" {
|
||
return 0, fmt.Errorf("empty pack")
|
||
}
|
||
// 校验解包结果是合法 skill(有 SKILL.md 或 skill.json)
|
||
unpackedRoot := filepath.Join(tmpDir, root)
|
||
if !fileExists(filepath.Join(unpackedRoot, SkillFileName)) && !fileExists(filepath.Join(unpackedRoot, MetaFileName)) {
|
||
return count, fmt.Errorf("pack has no %s or %s at its root", SkillFileName, MetaFileName)
|
||
}
|
||
// rename 到期望位置(包内根名与期望名不同时先改根目录名再落位)
|
||
src := unpackedRoot
|
||
if filepath.Base(dstParent) != root {
|
||
src = filepath.Join(tmpDir, ".renamed")
|
||
if err := os.Rename(unpackedRoot, src); err != nil {
|
||
return count, err
|
||
}
|
||
}
|
||
if err := os.Rename(src, dstParent); err != nil {
|
||
return count, err
|
||
}
|
||
return count, nil
|
||
}
|
||
|
||
// copyDir 递归复制目录(跳过隐藏文件与 node_modules)。
|
||
func copyDir(src, dst string) error {
|
||
return filepath.Walk(src, func(path string, fi os.FileInfo, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rel, rerr := filepath.Rel(src, path)
|
||
if rerr != nil {
|
||
return rerr
|
||
}
|
||
if rel == "." {
|
||
return os.MkdirAll(dst, 0755)
|
||
}
|
||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||
if strings.HasPrefix(part, ".") || part == "node_modules" {
|
||
if fi.IsDir() {
|
||
return filepath.SkipDir
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
target := filepath.Join(dst, rel)
|
||
if fi.IsDir() {
|
||
return os.MkdirAll(target, 0755)
|
||
}
|
||
if !fi.Mode().IsRegular() {
|
||
return nil
|
||
}
|
||
in, err := os.Open(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer in.Close()
|
||
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, fi.Mode())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = io.Copy(out, in)
|
||
out.Close()
|
||
return err
|
||
})
|
||
}
|