mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/1Panel-dev/1Panel.git
synced 2026-09-21 00:24:12 +08:00
911 lines
25 KiB
Go
911 lines
25 KiB
Go
package service
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
"github.com/1Panel-dev/1Panel/agent/app/task"
|
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
"github.com/1Panel-dev/1Panel/agent/global"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
|
)
|
|
|
|
const clawhubGlobalRegistry = "https://clawhub.com"
|
|
const clawhubChinaRegistry = "https://mirror-cn.clawhub.com"
|
|
const localSkillHubSource = "local-hub"
|
|
const localSkillHubPublishedStatus = "published"
|
|
const clawHubSkillTmpSubDir = "1panel/tmp/clawhub-skills"
|
|
const hermesManagedSkillsDir = "/opt/data/skills"
|
|
|
|
type openclawSkillsList struct {
|
|
Skills []openclawSkillListItem `json:"skills"`
|
|
}
|
|
|
|
type openclawSkillListItem struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Source string `json:"source"`
|
|
Bundled bool `json:"bundled"`
|
|
Disabled bool `json:"disabled"`
|
|
}
|
|
|
|
type openclawSkillInfo struct {
|
|
SkillKey string `json:"skillKey"`
|
|
}
|
|
|
|
type skillhubSearchPayload struct {
|
|
Skills []dto.AgentSkillSearchItem `json:"skills"`
|
|
Results []dto.AgentSkillSearchItem `json:"results"`
|
|
}
|
|
|
|
type localSkillHubItem struct {
|
|
ID uint
|
|
Name string
|
|
Status string
|
|
ApplicableAgent string
|
|
PackagePath string
|
|
}
|
|
|
|
var clawhubSearchLinePattern = regexp.MustCompile(`^(\S+)\s+(.+?)\s+\(([\d.]+)\)$`)
|
|
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
|
|
var safeLocalSkillDirNamePattern = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
|
|
|
func (a AgentService) ListSkills(req dto.AgentIDReq) ([]dto.AgentSkillItem, error) {
|
|
agent, install, err := a.loadAgentAndInstall(req.AgentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureContainerRunning(install.ContainerName); err != nil {
|
|
return nil, err
|
|
}
|
|
if agent.AgentType == constant.AppHermesAgent {
|
|
return listHermesSkills(install.ContainerName)
|
|
}
|
|
if agent.AgentType != constant.AppOpenclaw {
|
|
return nil, fmt.Errorf("%s does not support", agent.AgentType)
|
|
}
|
|
output, err := cmd.RunDockerExecWithStdout(5*time.Minute, install.ContainerName, "openclaw", "skills", "list", "--json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(output) == 0 {
|
|
return nil, nil
|
|
}
|
|
return parseOpenclawSkillsList(output)
|
|
}
|
|
|
|
func (a AgentService) SearchSkills(req dto.AgentSkillSearchReq) ([]dto.AgentSkillSearchItem, error) {
|
|
if req.Source == localSkillHubSource {
|
|
return nil, fmt.Errorf("local skills hub is provided by enterprise edition")
|
|
}
|
|
if global.CONF.Base.IsOffline {
|
|
return nil, fmt.Errorf("offline environment cannot access remote Skills Hub")
|
|
}
|
|
agent, install, err := a.loadAgentAndInstall(req.AgentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureContainerRunning(install.ContainerName); err != nil {
|
|
return nil, err
|
|
}
|
|
if agent.AgentType == constant.AppHermesAgent {
|
|
return searchHermesSkills(install.ContainerName, req.Source, req.Keyword)
|
|
}
|
|
if agent.AgentType != constant.AppOpenclaw {
|
|
return nil, fmt.Errorf("%s does not support", agent.AgentType)
|
|
}
|
|
output, err := loadOpenclawSkillSearchOutput(install.ContainerName, req.Source, req.Keyword)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(output) == 0 {
|
|
return nil, nil
|
|
}
|
|
switch req.Source {
|
|
case "skillhub":
|
|
return parseSkillhubSearchResult(output)
|
|
default:
|
|
return parseClawhubSearchResult(output, req.Source), nil
|
|
}
|
|
}
|
|
|
|
func (a AgentService) UpdateSkill(req dto.AgentSkillUpdateReq) error {
|
|
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := ensureContainerRunning(install.ContainerName); err != nil {
|
|
return err
|
|
}
|
|
conf, err := readOpenclawConfig(agent.ConfigPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
skillKey, err := getOpenclawSkillKey(install.ContainerName, req.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
setOpenclawSkillEnabled(conf, skillKey, req.Enabled)
|
|
return writeOpenclawConfigRaw(agent.ConfigPath, conf)
|
|
}
|
|
|
|
func (a AgentService) InstallSkill(req dto.AgentSkillInstallReq) error {
|
|
if global.CONF.Base.IsOffline && req.Source != localSkillHubSource {
|
|
return fmt.Errorf("offline environment cannot access remote Skills Hub")
|
|
}
|
|
agent, install, err := a.loadAgentAndInstall(req.AgentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := ensureContainerRunning(install.ContainerName); err != nil {
|
|
return err
|
|
}
|
|
if req.Source == localSkillHubSource {
|
|
if !global.IsMaster {
|
|
return fmt.Errorf("local skills hub is only available on the master node")
|
|
}
|
|
hostPath, skillName, err := resolveLocalSkillPackage(agent.AgentType, req.Slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
installTask, err := task.NewTaskWithOps(skillName, task.TaskInstall, task.TaskScopeAI, req.TaskID, req.AgentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
installTask.AddSubTask("Install local skill", func(t *task.Task) error {
|
|
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(20*time.Minute))
|
|
return installLocalSkillPackage(mgr, install.ContainerName, agent.AgentType, agent.ConfigPath, hostPath, skillName)
|
|
}, nil)
|
|
go func() {
|
|
if err := installTask.Execute(); err != nil {
|
|
global.LOG.Errorf("install local skill failed: %v", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
installTask, err := task.NewTaskWithOps(req.Slug, task.TaskInstall, task.TaskScopeAI, req.TaskID, req.AgentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if agent.AgentType == constant.AppHermesAgent {
|
|
installTask.AddSubTask("Install Hermes skill", func(t *task.Task) error {
|
|
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(20*time.Minute))
|
|
return mgr.Run("docker", buildHermesDockerExecArgs(install.ContainerName, "skills", "install", req.Slug, "--yes")...)
|
|
}, nil)
|
|
go func() {
|
|
if err := installTask.Execute(); err != nil {
|
|
global.LOG.Errorf("install hermes skill failed: %v", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
if agent.AgentType != constant.AppOpenclaw {
|
|
return fmt.Errorf("%s does not support", agent.AgentType)
|
|
}
|
|
installTask.AddSubTask("Install OpenClaw skill", func(t *task.Task) error {
|
|
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(20*time.Minute))
|
|
return installOpenclawSkill(mgr, install.ContainerName, req.Source, req.Slug)
|
|
}, nil)
|
|
go func() {
|
|
if err := installTask.Execute(); err != nil {
|
|
global.LOG.Errorf("install openclaw skill failed: %v", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func installLocalSkillPackage(mgr *cmd.CommandHelper, containerName, agentType, configPath, hostPath, skillName string) error {
|
|
targetDir, err := resolveAgentLocalSkillDir(agentType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempDir, err := os.MkdirTemp("", "1panel-agent-skill-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(tempDir)
|
|
extractRoot := filepath.Join(tempDir, "extract")
|
|
if err := os.MkdirAll(extractRoot, 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := extractLocalSkillPackage(hostPath, extractRoot); err != nil {
|
|
return err
|
|
}
|
|
copyRoot, installedSkillName, err := normalizeLocalSkillInstallRoot(extractRoot, filepath.Join(tempDir, "install"), skillName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := mgr.Run("docker", "exec", containerName, "mkdir", "-p", targetDir); err != nil {
|
|
return err
|
|
}
|
|
if err := mgr.Run("docker", "cp", copyRoot+"/.", containerName+":"+targetDir); err != nil {
|
|
return err
|
|
}
|
|
if agentType == constant.AppOpenclaw {
|
|
return registerOpenclawLocalSkill(containerName, configPath, installedSkillName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveAgentLocalSkillDir(agentType string) (string, error) {
|
|
switch agentType {
|
|
case constant.AppOpenclaw:
|
|
return openclawManagedSkillsDir, nil
|
|
case constant.AppHermesAgent:
|
|
return hermesManagedSkillsDir, nil
|
|
default:
|
|
return "", fmt.Errorf("%s does not support", agentType)
|
|
}
|
|
}
|
|
|
|
func resolveLocalSkillPackage(agentType, skillID string) (string, string, error) {
|
|
skillID = strings.TrimSpace(skillID)
|
|
id, err := strconv.ParseUint(skillID, 10, 64)
|
|
if err != nil || id == 0 {
|
|
return "", "", fmt.Errorf("invalid local skill id")
|
|
}
|
|
dbPath := filepath.Join(global.CONF.Base.InstallDir, "1panel", "db", "enterprise.db")
|
|
if _, err := os.Stat(dbPath); err != nil {
|
|
return "", "", fmt.Errorf("local skills hub is unavailable")
|
|
}
|
|
db, err := common.GetDBWithPath(dbPath)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer common.CloseDB(db)
|
|
var item localSkillHubItem
|
|
if err := db.Table("skill_hub_items").
|
|
Select("id,name,status,applicable_agent,package_path").
|
|
Where("id = ? AND status = ?", uint(id), localSkillHubPublishedStatus).
|
|
First(&item).Error; err != nil {
|
|
return "", "", fmt.Errorf("local skill is not published or does not exist")
|
|
}
|
|
if !matchLocalSkillAgent(item.ApplicableAgent, agentType) {
|
|
return "", "", fmt.Errorf("local skill does not support %s", agentType)
|
|
}
|
|
packagePath, err := validateLocalSkillPackagePath(item.PackagePath)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return packagePath, sanitizeLocalSkillDirName(item.Name, packagePath, skillID), nil
|
|
}
|
|
|
|
func matchLocalSkillAgent(applicableAgent, agentType string) bool {
|
|
applicableAgent = strings.TrimSpace(applicableAgent)
|
|
if applicableAgent == "" {
|
|
return true
|
|
}
|
|
for _, item := range strings.Split(applicableAgent, ",") {
|
|
normalized := normalizeLocalSkillAgent(strings.TrimSpace(item))
|
|
if normalized == "common" || normalized == normalizeLocalSkillAgent(agentType) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizeLocalSkillAgent(agentType string) string {
|
|
switch strings.ToLower(strings.TrimSpace(agentType)) {
|
|
case constant.AppHermesAgent:
|
|
return "hermes"
|
|
default:
|
|
return strings.ToLower(strings.TrimSpace(agentType))
|
|
}
|
|
}
|
|
|
|
func sanitizeLocalSkillDirName(name, packagePath, fallback string) string {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
name = strings.TrimSuffix(filepath.Base(packagePath), localSkillPackageExtension(packagePath))
|
|
}
|
|
name = strings.Trim(safeLocalSkillDirNamePattern.ReplaceAllString(name, "-"), "-")
|
|
if name == "" || name == "." || name == ".." {
|
|
name = "skill-" + strings.TrimSpace(fallback)
|
|
}
|
|
if name == "skill-" {
|
|
return "skill"
|
|
}
|
|
return name
|
|
}
|
|
|
|
func validateLocalSkillPackagePath(packagePath string) (string, error) {
|
|
packagePath = strings.TrimSpace(packagePath)
|
|
if packagePath == "" {
|
|
return "", fmt.Errorf("skill package path is required")
|
|
}
|
|
if _, err := localSkillPackageFormat(packagePath); err != nil {
|
|
return "", err
|
|
}
|
|
absPath, err := filepath.Abs(packagePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !isLocalSkillPackageAllowedPath(resolvedPath) {
|
|
return "", fmt.Errorf("invalid local skill package path")
|
|
}
|
|
info, err := os.Stat(resolvedPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if info.IsDir() {
|
|
return "", fmt.Errorf("skill package must be a file")
|
|
}
|
|
return resolvedPath, nil
|
|
}
|
|
|
|
func isLocalSkillPackageAllowedPath(resolvedPath string) bool {
|
|
roots := []string{
|
|
filepath.Join(global.CONF.Base.InstallDir, "1panel", "uploads", "skills-hub"),
|
|
filepath.Join(global.CONF.Base.InstallDir, filepath.FromSlash(clawHubSkillTmpSubDir)),
|
|
}
|
|
for _, root := range roots {
|
|
if isPathInsideResolvedRoot(resolvedPath, root) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isPathInsideResolvedRoot(resolvedPath, root string) bool {
|
|
absRoot, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
resolvedRoot, err := filepath.EvalSymlinks(absRoot)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
rel, err := filepath.Rel(resolvedRoot, resolvedPath)
|
|
return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
|
}
|
|
|
|
func normalizeLocalSkillInstallRoot(extractRoot, installRoot, skillName string) (string, string, error) {
|
|
entries, err := os.ReadDir(extractRoot)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if len(entries) == 0 {
|
|
return "", "", fmt.Errorf("skill package is empty")
|
|
}
|
|
if len(entries) == 1 && entries[0].IsDir() {
|
|
return extractRoot, entries[0].Name(), nil
|
|
}
|
|
skillRoot := filepath.Join(installRoot, skillName)
|
|
if err := os.MkdirAll(skillRoot, 0755); err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := copyLocalDirContents(extractRoot, skillRoot); err != nil {
|
|
return "", "", err
|
|
}
|
|
return installRoot, skillName, nil
|
|
}
|
|
|
|
func registerOpenclawLocalSkill(containerName, configPath, skillName string) error {
|
|
conf, err := readOpenclawConfig(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
skillKey, err := getOpenclawSkillKey(containerName, skillName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
setOpenclawSkillEnabled(conf, skillKey, true)
|
|
return writeOpenclawConfigRaw(configPath, conf)
|
|
}
|
|
|
|
func extractLocalSkillPackage(packagePath, targetDir string) error {
|
|
format, err := localSkillPackageFormat(packagePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch format {
|
|
case "zip":
|
|
return unzipLocalSkillPackage(packagePath, targetDir)
|
|
case "tar", "targz":
|
|
return untarLocalSkillPackage(packagePath, targetDir, format == "targz")
|
|
case "7z":
|
|
return un7zLocalSkillPackage(packagePath, targetDir)
|
|
default:
|
|
return fmt.Errorf("unsupported skill package format")
|
|
}
|
|
}
|
|
|
|
func unzipLocalSkillPackage(packagePath, targetDir string) error {
|
|
reader, err := zip.OpenReader(packagePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer reader.Close()
|
|
root, err := filepath.Abs(targetDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, file := range reader.File {
|
|
name, err := safeLocalSkillZipEntryName(file.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
targetPath := filepath.Join(root, name)
|
|
rel, err := filepath.Rel(root, targetPath)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
|
return fmt.Errorf("invalid zip entry: %s", file.Name)
|
|
}
|
|
if file.FileInfo().IsDir() {
|
|
if err := os.MkdirAll(targetPath, file.Mode()); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if file.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("unsupported zip entry: %s", file.Name)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
|
return err
|
|
}
|
|
rc, err := file.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dst, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.Mode())
|
|
if err != nil {
|
|
_ = rc.Close()
|
|
return err
|
|
}
|
|
_, copyErr := io.Copy(dst, rc)
|
|
closeErr := dst.Close()
|
|
_ = rc.Close()
|
|
if copyErr != nil {
|
|
return copyErr
|
|
}
|
|
if closeErr != nil {
|
|
return closeErr
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func untarLocalSkillPackage(packagePath, targetDir string, gzipped bool) error {
|
|
src, err := os.Open(packagePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer src.Close()
|
|
var reader io.Reader = src
|
|
var gzipReader *gzip.Reader
|
|
if gzipped {
|
|
gzipReader, err = gzip.NewReader(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer gzipReader.Close()
|
|
reader = gzipReader
|
|
}
|
|
root, err := filepath.Abs(targetDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tarReader := tar.NewReader(reader)
|
|
for {
|
|
header, err := tarReader.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name, err := safeLocalSkillZipEntryName(header.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
targetPath := filepath.Join(root, name)
|
|
rel, err := filepath.Rel(root, targetPath)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
|
return fmt.Errorf("invalid tar entry: %s", header.Name)
|
|
}
|
|
info := header.FileInfo()
|
|
if info.IsDir() {
|
|
if err := os.MkdirAll(targetPath, info.Mode()); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA {
|
|
continue
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
|
return err
|
|
}
|
|
dst, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, copyErr := io.Copy(dst, tarReader)
|
|
closeErr := dst.Close()
|
|
if copyErr != nil {
|
|
return copyErr
|
|
}
|
|
if closeErr != nil {
|
|
return closeErr
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func un7zLocalSkillPackage(packagePath, targetDir string) error {
|
|
if _, err := listLocal7zEntries(packagePath); err != nil {
|
|
return err
|
|
}
|
|
root, err := filepath.Abs(targetDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bin, err := findLocal7zBinary()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempDir, err := os.MkdirTemp("", "1panel-agent-skill-7z-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(tempDir)
|
|
if err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Minute)).Run(bin, "x", "-y", "-o"+tempDir, packagePath); err != nil {
|
|
return err
|
|
}
|
|
if err := validateExtractedLocalSkillPackage(tempDir); err != nil {
|
|
return err
|
|
}
|
|
return copyLocalDirContents(tempDir, root)
|
|
}
|
|
|
|
func safeLocalSkillZipEntryName(name string) (string, error) {
|
|
clean := path.Clean(strings.ReplaceAll(name, "\\", "/"))
|
|
clean = strings.TrimLeft(clean, "/")
|
|
if clean == "." || clean == "" || clean == ".." || strings.HasPrefix(clean, "../") {
|
|
return "", fmt.Errorf("invalid zip entry: %s", name)
|
|
}
|
|
return clean, nil
|
|
}
|
|
|
|
func copyLocalDirContents(sourceRoot, targetRoot string) error {
|
|
return filepath.Walk(sourceRoot, func(sourcePath string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sourcePath == sourceRoot {
|
|
return nil
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) {
|
|
return fmt.Errorf("unsupported skill package entry: %s", sourcePath)
|
|
}
|
|
rel, err := filepath.Rel(sourceRoot, sourcePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name, err := safeLocalSkillZipEntryName(rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
targetPath := filepath.Join(targetRoot, name)
|
|
if info.IsDir() {
|
|
return os.MkdirAll(targetPath, info.Mode())
|
|
}
|
|
return copyLocalSkillFile(sourcePath, targetPath)
|
|
})
|
|
}
|
|
|
|
func copyLocalSkillFile(source, target string) error {
|
|
src, err := os.Open(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer src.Close()
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return err
|
|
}
|
|
dst, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(dst, src); err != nil {
|
|
_ = dst.Close()
|
|
return err
|
|
}
|
|
return dst.Close()
|
|
}
|
|
|
|
func validateExtractedLocalSkillPackage(root string) error {
|
|
return filepath.Walk(root, func(currentPath string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if currentPath == root {
|
|
return nil
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) {
|
|
return fmt.Errorf("unsupported skill package entry: %s", currentPath)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func localSkillPackageExtension(name string) string {
|
|
lower := strings.ToLower(filepath.Base(strings.TrimSpace(name)))
|
|
if lower == "" || lower == "." {
|
|
return ""
|
|
}
|
|
if strings.HasSuffix(lower, ".tar.gz") {
|
|
return ".tar.gz"
|
|
}
|
|
return strings.ToLower(filepath.Ext(lower))
|
|
}
|
|
|
|
func localSkillPackageFormat(name string) (string, error) {
|
|
switch localSkillPackageExtension(name) {
|
|
case ".zip":
|
|
return "zip", nil
|
|
case ".7z":
|
|
return "7z", nil
|
|
case ".tar":
|
|
return "tar", nil
|
|
case ".tar.gz":
|
|
return "targz", nil
|
|
default:
|
|
return "", fmt.Errorf("only .zip, .7z, .tar, and .tar.gz skill packages are supported")
|
|
}
|
|
}
|
|
|
|
func findLocal7zBinary() (string, error) {
|
|
for _, name := range []string{"7z", "7za", "7zr"} {
|
|
if path, err := exec.LookPath(name); err == nil {
|
|
return path, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("7z command is required to process .7z skill packages")
|
|
}
|
|
|
|
func listLocal7zEntries(packagePath string) ([]string, error) {
|
|
bin, err := findLocal7zBinary()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
output, err := exec.Command(bin, "l", "-slt", packagePath).CombinedOutput()
|
|
if err != nil {
|
|
if message := strings.TrimSpace(string(output)); message != "" {
|
|
return nil, fmt.Errorf("7z failed: %w: %s", err, message)
|
|
}
|
|
return nil, fmt.Errorf("7z failed: %w", err)
|
|
}
|
|
entries := make([]string, 0)
|
|
for _, line := range strings.Split(string(output), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if !strings.HasPrefix(line, "Path = ") {
|
|
continue
|
|
}
|
|
entry := strings.TrimSpace(strings.TrimPrefix(line, "Path = "))
|
|
if entry == "" || entry == packagePath || entry == filepath.Base(packagePath) {
|
|
continue
|
|
}
|
|
name, err := safeLocalSkillZipEntryName(entry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries = append(entries, name)
|
|
}
|
|
if len(entries) == 0 {
|
|
return nil, fmt.Errorf("skill package is empty")
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func (a AgentService) UninstallSkill(req dto.AgentSkillUninstallReq) error {
|
|
agent, install, err := a.loadAgentAndInstall(req.AgentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if agent.AgentType != constant.AppHermesAgent {
|
|
return fmt.Errorf("%s does not support", agent.AgentType)
|
|
}
|
|
if err := ensureContainerRunning(install.ContainerName); err != nil {
|
|
return err
|
|
}
|
|
return cmd.NewCommandMgr(cmd.WithTimeout(5*time.Minute)).Run(
|
|
"docker",
|
|
buildHermesDockerExecCommandArgs(
|
|
install.ContainerName,
|
|
"sh",
|
|
"-lc",
|
|
fmt.Sprintf(`printf 'y\n' | %s skills uninstall "$1"`, hermesExecutablePath),
|
|
"sh",
|
|
req.Name,
|
|
)...,
|
|
)
|
|
}
|
|
|
|
func parseOpenclawSkillsList(output string) ([]dto.AgentSkillItem, error) {
|
|
payloadBytes, err := extractEmbeddedJSON(output)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(payloadBytes) == 0 {
|
|
return nil, nil
|
|
}
|
|
var payload openclawSkillsList
|
|
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]dto.AgentSkillItem, 0, len(payload.Skills))
|
|
for _, item := range payload.Skills {
|
|
items = append(items, dto.AgentSkillItem{
|
|
Name: item.Name,
|
|
Description: item.Description,
|
|
Source: item.Source,
|
|
Bundled: item.Bundled,
|
|
Disabled: item.Disabled,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func loadOpenclawSkillSearchOutput(containerName, source, keyword string) (string, error) {
|
|
switch source {
|
|
case "skillhub":
|
|
return cmd.RunDockerExecWithStdout(2*time.Minute, containerName, "skillhub", "search", keyword, "--json")
|
|
default:
|
|
return cmd.NewCommandMgr(cmd.WithTimeout(2*time.Minute)).
|
|
RunWithStdout(
|
|
"docker",
|
|
"exec",
|
|
"-e",
|
|
"CLAWHUB_REGISTRY="+resolveClawhubRegistry(source),
|
|
containerName,
|
|
"clawhub",
|
|
"search",
|
|
keyword,
|
|
)
|
|
}
|
|
}
|
|
|
|
func parseSkillhubSearchResult(output string) ([]dto.AgentSkillSearchItem, error) {
|
|
trimmed := strings.TrimSpace(output)
|
|
if trimmed == "" {
|
|
return nil, nil
|
|
}
|
|
var list []dto.AgentSkillSearchItem
|
|
if err := json.Unmarshal([]byte(trimmed), &list); err == nil {
|
|
for i := range list {
|
|
list[i].Source = "skillhub"
|
|
}
|
|
return list, nil
|
|
}
|
|
var payload skillhubSearchPayload
|
|
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
items := payload.Skills
|
|
if len(items) == 0 {
|
|
items = payload.Results
|
|
}
|
|
for i := range items {
|
|
items[i].Source = "skillhub"
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func parseClawhubSearchResult(output, source string) []dto.AgentSkillSearchItem {
|
|
lines := strings.Split(strings.TrimSpace(output), "\n")
|
|
items := make([]dto.AgentSkillSearchItem, 0, len(lines))
|
|
for _, line := range lines {
|
|
matches := clawhubSearchLinePattern.FindStringSubmatch(strings.TrimSpace(line))
|
|
if len(matches) != 4 {
|
|
continue
|
|
}
|
|
items = append(items, dto.AgentSkillSearchItem{
|
|
Slug: matches[1],
|
|
Name: matches[2],
|
|
Score: matches[3],
|
|
Source: source,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func installOpenclawSkill(mgr *cmd.CommandHelper, containerName, source, slug string) error {
|
|
if err := mgr.Run("docker", "exec", containerName, "mkdir", "-p", openclawManagedSkillsDir); err != nil {
|
|
return err
|
|
}
|
|
switch source {
|
|
case "clawhub-global", "clawhub-cn":
|
|
return mgr.Run(
|
|
"docker",
|
|
"exec",
|
|
"-e",
|
|
"CLAWHUB_REGISTRY="+resolveClawhubRegistry(source),
|
|
containerName,
|
|
"clawhub",
|
|
"--workdir",
|
|
"/home/node/.openclaw",
|
|
"--dir",
|
|
"skills",
|
|
"install",
|
|
slug,
|
|
)
|
|
default:
|
|
return mgr.Run("docker", "exec", containerName, "skillhub", "--dir", openclawManagedSkillsDir, "install", slug)
|
|
}
|
|
}
|
|
|
|
func resolveClawhubRegistry(source string) string {
|
|
switch source {
|
|
case "clawhub-cn":
|
|
return clawhubChinaRegistry
|
|
default:
|
|
return clawhubGlobalRegistry
|
|
}
|
|
}
|
|
|
|
func getOpenclawSkillKey(containerName, name string) (string, error) {
|
|
output, err := cmd.RunDockerExecWithStdout(2*time.Minute, containerName, "openclaw", "skills", "info", name, "--json")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return parseOpenclawSkillKey(name, output)
|
|
}
|
|
|
|
func parseOpenclawSkillKey(name, output string) (string, error) {
|
|
payloadBytes, err := extractEmbeddedJSON(output)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var payload openclawSkillInfo
|
|
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
|
return "", err
|
|
}
|
|
if payload.SkillKey == "" {
|
|
return name, nil
|
|
}
|
|
return payload.SkillKey, nil
|
|
}
|
|
|
|
func extractEmbeddedJSON(output string) ([]byte, error) {
|
|
trimmed := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(output, ""))
|
|
if trimmed == "" {
|
|
return nil, nil
|
|
}
|
|
for i := 0; i < len(trimmed); i++ {
|
|
if trimmed[i] != '{' && trimmed[i] != '[' {
|
|
continue
|
|
}
|
|
decoder := json.NewDecoder(strings.NewReader(trimmed[i:]))
|
|
var raw json.RawMessage
|
|
if err := decoder.Decode(&raw); err == nil {
|
|
return raw, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("json payload not found")
|
|
}
|
|
|
|
func setOpenclawSkillEnabled(conf map[string]interface{}, skillKey string, enabled bool) {
|
|
skills := ensureChildMap(conf, "skills")
|
|
entries := ensureChildMap(skills, "entries")
|
|
entry := ensureChildMap(entries, skillKey)
|
|
entry["enabled"] = enabled
|
|
}
|