71c7672545
* feat(skill-hub): add Skill Hub catalog, publish flow, and lite materialize pipeline Introduce Skill Hub for browsing, importing, publishing, and installing skills, with lite instance package materialization and runtime sync support. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(skill-hub): remove token-governance hooks from Skill Hub PR Strip validateManagedRuntimeEnvironmentOverrides, network lock policy sync, and egress proxy audit wiring that belong to the upcoming token-usage work, so the Skill Hub branch compiles independently. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(skill-hub): update migration number in materialize docs Co-authored-by: Cursor <cursoragent@cursor.com> * fix(skill-hub): make RuntimeAgentClient test stub and hub tests compile-safe Add ResyncInstanceSkills to the runtime pool handler fake client, and harden skill hub payload helpers/tests against nil storage/instance repos so go test passes. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: add Skill Hub hardening with session usage tracking and egress governance Unify Skill Hub runtime sync improvements with session-token observability, egress network policy, and admin/instance usage reporting for reopenable PR. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(skill-hub): repair CI tests and nested skill install * fix(ci): restore release deployment configuration --------- Co-authored-by: heshengran <heshengran@ieisystem.com> Co-authored-by: Cursor <cursoragent@cursor.com>
172 lines
4.7 KiB
Go
172 lines
4.7 KiB
Go
package services
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"clawreef/internal/models"
|
|
)
|
|
|
|
const runtimeSkillDiscoveryMaxDepth = 2
|
|
|
|
type runtimeSkillDiscovery struct {
|
|
RelativePath string
|
|
SkillRoot string
|
|
}
|
|
|
|
func runtimeSkillInstallRoot(instance *models.Instance) string {
|
|
if instance == nil || instance.WorkspacePath == nil || strings.TrimSpace(*instance.WorkspacePath) == "" {
|
|
return ""
|
|
}
|
|
workspacePath := filepath.Clean(strings.TrimSpace(*instance.WorkspacePath))
|
|
if isLiteRuntimeInstance(instance) {
|
|
if strings.EqualFold(strings.TrimSpace(instance.Type), RuntimeTypeHermes) {
|
|
return filepath.Join(workspacePath, "home", ".hermes", "skills")
|
|
}
|
|
return filepath.Join(workspacePath, "home", ".openclaw", "workspace", "skills")
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(instance.Type), RuntimeTypeHermes) {
|
|
return filepath.Join(workspacePath, ".hermes", "skills")
|
|
}
|
|
return filepath.Join(workspacePath, "home", ".openclaw", "workspace", "skills")
|
|
}
|
|
|
|
func liteSkillInstallRoot(instance *models.Instance) string {
|
|
return runtimeSkillInstallRoot(instance)
|
|
}
|
|
|
|
func sanitizeWorkspaceRelativePath(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
value = strings.ReplaceAll(value, "\\", "/")
|
|
value = strings.Trim(value, "/")
|
|
if value == "" || strings.Contains(value, "..") {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, strings.Count(value, "/")+1)
|
|
for _, part := range strings.Split(value, "/") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" || part == "." || part == ".." || strings.HasPrefix(part, ".") {
|
|
return ""
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
if len(parts) == 0 || len(parts) > runtimeSkillDiscoveryMaxDepth {
|
|
return ""
|
|
}
|
|
return strings.Join(parts, "/")
|
|
}
|
|
|
|
func skillKeyFromRelativePath(relativePath string) string {
|
|
relativePath = sanitizeWorkspaceRelativePath(relativePath)
|
|
if relativePath == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(relativePath, "/")
|
|
return sanitizeSkillKey(parts[len(parts)-1])
|
|
}
|
|
|
|
func runtimeSkillInstallRelativePath(instance *models.Instance, relativePath string) string {
|
|
relativePath = sanitizeWorkspaceRelativePath(relativePath)
|
|
if relativePath == "" {
|
|
return ""
|
|
}
|
|
if instance == nil || instance.WorkspacePath == nil {
|
|
return relativePath
|
|
}
|
|
workspacePath := filepath.Clean(strings.TrimSpace(*instance.WorkspacePath))
|
|
target := filepath.Join(runtimeSkillInstallRoot(instance), filepath.FromSlash(relativePath))
|
|
rel, err := filepath.Rel(workspacePath, target)
|
|
if err != nil {
|
|
return filepath.ToSlash(relativePath)
|
|
}
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
|
|
func joinRuntimeSkillPath(root, relativePath string) (string, error) {
|
|
root = filepath.Clean(strings.TrimSpace(root))
|
|
relativePath = sanitizeWorkspaceRelativePath(relativePath)
|
|
if root == "" || relativePath == "" {
|
|
return "", filepath.ErrBadPattern
|
|
}
|
|
target := filepath.Join(root, filepath.FromSlash(relativePath))
|
|
if !isPathWithin(root, target) {
|
|
return "", filepath.ErrBadPattern
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
func discoverRuntimeSkillDirectories(root string, maxDepth int) ([]runtimeSkillDiscovery, error) {
|
|
root = filepath.Clean(strings.TrimSpace(root))
|
|
if root == "" {
|
|
return nil, nil
|
|
}
|
|
if maxDepth <= 0 {
|
|
maxDepth = runtimeSkillDiscoveryMaxDepth
|
|
}
|
|
result := make([]runtimeSkillDiscovery, 0)
|
|
var walk func(currentRoot, relativePrefix string, depth int) error
|
|
walk = func(currentRoot, relativePrefix string, depth int) error {
|
|
entries, err := readDirNames(currentRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
name := strings.TrimSpace(entry.Name)
|
|
if name == "" || strings.HasPrefix(name, ".") || name == ".tmp" {
|
|
continue
|
|
}
|
|
if !entry.IsDir {
|
|
continue
|
|
}
|
|
skillRoot := filepath.Join(currentRoot, name)
|
|
relativePath := name
|
|
if relativePrefix != "" {
|
|
relativePath = relativePrefix + "/" + name
|
|
}
|
|
relativePath = sanitizeWorkspaceRelativePath(relativePath)
|
|
if relativePath == "" {
|
|
continue
|
|
}
|
|
files, err := collectLiteSkillDirectoryFiles(skillRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(files) > 0 {
|
|
result = append(result, runtimeSkillDiscovery{
|
|
RelativePath: relativePath,
|
|
SkillRoot: skillRoot,
|
|
})
|
|
continue
|
|
}
|
|
if depth < maxDepth {
|
|
if err := walk(skillRoot, relativePath, depth+1); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if err := walk(root, "", 1); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func readDirNames(path string) ([]dirEntryName, error) {
|
|
entries, err := os.ReadDir(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]dirEntryName, 0, len(entries))
|
|
for _, entry := range entries {
|
|
result = append(result, dirEntryName{Name: entry.Name(), IsDir: entry.IsDir()})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
type dirEntryName struct {
|
|
Name string
|
|
IsDir bool
|
|
}
|