Files
yuan-lab-llm--clawmanager/backend/internal/services/skill_service_test.go
T
heranran 71c7672545 feat: Skill Hub hardening with session usage tracking and egress governance (#163)
* 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>
2026-07-17 12:37:01 +08:00

496 lines
16 KiB
Go

package services
import (
"archive/zip"
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"os"
"path"
"path/filepath"
"sort"
"strings"
"testing"
"clawreef/internal/models"
)
func TestHashDirectoryPreservesSingleTopLevelSubdirectory(t *testing.T) {
files := map[string][]byte{
"src/main.py": []byte("print('hello')\n"),
}
got := hashDirectory(files)
want := referenceSkillContentMD5(map[string][]byte{
"src/main.py": []byte("print('hello')\n"),
})
if got != want {
t.Fatalf("hashDirectory() = %s, want %s", got, want)
}
flattened := referenceSkillContentMD5(map[string][]byte{
"main.py": []byte("print('hello')\n"),
})
if got == flattened {
t.Fatalf("hashDirectory stripped the skill's internal src/ directory")
}
}
func TestExtractSkillDirectoriesStripsArchiveRootOnlyOnce(t *testing.T) {
archive := buildTestZip(t, map[string][]byte{
"weather/SKILL.md": []byte("# Weather\n"),
"weather/src/main.py": []byte("print('weather')\n"),
})
dirs, err := extractSkillDirectories("weather.zip", archive)
if err != nil {
t.Fatalf("extractSkillDirectories() error = %v", err)
}
if len(dirs) != 1 {
t.Fatalf("extractSkillDirectories() returned %d dirs, want 1", len(dirs))
}
if _, ok := dirs[0].Files["src/main.py"]; !ok {
t.Fatalf("expected skill files to preserve src/main.py after stripping archive root once: %#v", dirs[0].Files)
}
got := hashDirectory(dirs[0].Files)
want := referenceSkillContentMD5(map[string][]byte{
"SKILL.md": []byte("# Weather\n"),
"src/main.py": []byte("print('weather')\n"),
})
if got != want {
t.Fatalf("hashDirectory(extracted files) = %s, want %s", got, want)
}
}
func TestExtractSkillDirectoriesAcceptsRootSkillPackage(t *testing.T) {
archive := buildTestZip(t, map[string][]byte{
".env": []byte("TOKEN=example\n"),
"SKILL.md": []byte("# Weather\n"),
"__MACOSX/._SKILL.md": []byte("metadata\n"),
"scripts/run.sh": []byte("python src/main.py\n"),
"src/main.py": []byte("print('weather')\n"),
})
dirs, err := extractSkillDirectories("weather.zip", archive)
if err != nil {
t.Fatalf("extractSkillDirectories() error = %v", err)
}
if len(dirs) != 1 {
t.Fatalf("extractSkillDirectories() returned %d dirs, want 1", len(dirs))
}
if dirs[0].Name != "weather" {
t.Fatalf("root skill package name = %q, want weather", dirs[0].Name)
}
if _, ok := dirs[0].Files["SKILL.md"]; !ok {
t.Fatalf("expected root SKILL.md to be preserved: %#v", dirs[0].Files)
}
if _, ok := dirs[0].Files["scripts/run.sh"]; !ok {
t.Fatalf("expected root scripts/run.sh to be preserved: %#v", dirs[0].Files)
}
if _, ok := dirs[0].Files[".env"]; !ok {
t.Fatalf("expected root .env to be preserved for scanning: %#v", dirs[0].Files)
}
if _, ok := dirs[0].Files["__MACOSX/._SKILL.md"]; ok {
t.Fatalf("expected archive metadata to be ignored: %#v", dirs[0].Files)
}
}
func TestExtractSkillDirectoriesImportsMultipleManifestDirs(t *testing.T) {
archive := buildTestZip(t, map[string][]byte{
"alpha/SKILL.md": []byte("# Alpha\n"),
"beta/SKILL.md": []byte("# Beta\n"),
})
dirs, err := extractSkillDirectories("skills.zip", archive)
if err != nil {
t.Fatalf("extractSkillDirectories() error = %v", err)
}
if len(dirs) != 2 {
t.Fatalf("extractSkillDirectories() returned %d dirs, want 2", len(dirs))
}
if dirs[0].Name != "alpha" || dirs[1].Name != "beta" {
t.Fatalf("skill directory names = %q, %q; want alpha, beta", dirs[0].Name, dirs[1].Name)
}
}
func TestExtractSkillDirectoriesRejectsTopLevelDirWithoutManifest(t *testing.T) {
archive := buildTestZip(t, map[string][]byte{
"weather/src/main.py": []byte("print('weather')\n"),
})
_, err := extractSkillDirectories("weather.zip", archive)
if err == nil {
t.Fatal("extractSkillDirectories() error = nil, want SKILL.md error")
}
if !strings.Contains(err.Error(), "SKILL.md") {
t.Fatalf("extractSkillDirectories() error = %v, want SKILL.md error", err)
}
}
func TestExtractSkillDirectoriesRejectsLooseFileWithoutRootManifest(t *testing.T) {
archive := buildTestZip(t, map[string][]byte{
"README.md": []byte("not a skill manifest\n"),
"weather/SKILL.md": []byte("# Weather\n"),
})
_, err := extractSkillDirectories("weather.zip", archive)
if err == nil {
t.Fatal("extractSkillDirectories() error = nil, want loose file error")
}
if !strings.Contains(err.Error(), "loose file README.md") {
t.Fatalf("extractSkillDirectories() error = %v, want loose README.md error", err)
}
}
func TestFlattenSingleTopLevelDirForArchiveRoot(t *testing.T) {
files := map[string][]byte{
"weather/src/main.py": []byte("print('weather')\n"),
}
got := hashDirectory(flattenSingleTopLevelDir(files))
want := referenceSkillContentMD5(map[string][]byte{
"src/main.py": []byte("print('weather')\n"),
})
if got != want {
t.Fatalf("hashDirectory(flattenSingleTopLevelDir(files)) = %s, want %s", got, want)
}
}
func TestMaterializeLiteInstanceSkillWritesOpenClawWorkspaceSkill(t *testing.T) {
archive := buildTestZip(t, map[string][]byte{
"paper-ranker/SKILL.md": []byte("# Paper Ranker\n"),
"paper-ranker/src/rank.py": []byte("print('rank')\n"),
"paper-ranker/.ignored-file": []byte("local secret\n"),
})
workspacePath := filepath.Join(t.TempDir(), "openclaw", "user-45", "instance-77")
if err := os.MkdirAll(workspacePath, 0750); err != nil {
t.Fatalf("MkdirAll(workspacePath): %v", err)
}
instanceRepo := newV2LifecycleInstanceRepo()
instanceRepo.byID[77] = &models.Instance{
ID: 0,
UserID: 45,
Type: RuntimeTypeOpenClaw,
RuntimeType: RuntimeBackendGateway,
InstanceMode: InstanceModeLite,
WorkspacePath: &workspacePath,
}
service := &skillService{
instanceRepo: instanceRepo,
storage: fakeObjectStorage{"skills/paper-ranker.zip": archive},
}
err := service.materializeLiteInstanceSkill(context.Background(), 77, &models.Skill{
SkillKey: "paper-ranker",
}, &models.SkillBlob{
ObjectKey: "skills/paper-ranker.zip",
FileName: "paper-ranker.zip",
})
if err != nil {
t.Fatalf("materializeLiteInstanceSkill() error = %v", err)
}
target := filepath.Join(workspacePath, "home", ".openclaw", "workspace", "skills", "paper-ranker")
assertFileEquals(t, filepath.Join(target, "SKILL.md"), "# Paper Ranker\n")
assertFileEquals(t, filepath.Join(target, "src", "rank.py"), "print('rank')\n")
if _, err := os.Stat(filepath.Join(target, ".ignored-file")); !os.IsNotExist(err) {
t.Fatalf("hidden archive entry was materialized, stat err = %v", err)
}
}
func TestMaterializeLiteInstanceSkillWritesHermesHomeSkill(t *testing.T) {
archive := buildTestZip(t, map[string][]byte{
"paper-ranker/SKILL.md": []byte("# Paper Ranker\n"),
})
workspacePath := filepath.Join(t.TempDir(), "hermes", "user-45", "instance-90")
if err := os.MkdirAll(workspacePath, 0750); err != nil {
t.Fatalf("MkdirAll(workspacePath): %v", err)
}
instanceRepo := newV2LifecycleInstanceRepo()
instanceRepo.byID[90] = &models.Instance{
ID: 90,
UserID: 45,
Type: RuntimeTypeHermes,
RuntimeType: RuntimeBackendGateway,
InstanceMode: InstanceModeLite,
WorkspacePath: &workspacePath,
}
service := &skillService{
instanceRepo: instanceRepo,
storage: fakeObjectStorage{"skills/paper-ranker.zip": archive},
}
err := service.materializeLiteInstanceSkill(context.Background(), 90, &models.Skill{
SkillKey: "paper-ranker",
}, &models.SkillBlob{
ObjectKey: "skills/paper-ranker.zip",
FileName: "paper-ranker.zip",
})
if err != nil {
t.Fatalf("materializeLiteInstanceSkill() error = %v", err)
}
target := filepath.Join(workspacePath, "home", ".hermes", "skills", "paper-ranker")
assertFileEquals(t, filepath.Join(target, "SKILL.md"), "# Paper Ranker\n")
}
func TestChownRuntimePathToleratesNonRootPermissionDenied(t *testing.T) {
target := filepath.Join(t.TempDir(), "skill-file")
if err := os.WriteFile(target, []byte("skill"), 0644); err != nil {
t.Fatalf("WriteFile(target): %v", err)
}
oldChown := chownRuntimePathOwner
oldEffectiveUID := currentEffectiveUID
t.Cleanup(func() {
chownRuntimePathOwner = oldChown
currentEffectiveUID = oldEffectiveUID
})
chownRuntimePathOwner = func(string, int, int) error {
return os.ErrPermission
}
currentEffectiveUID = func() int {
return 1000
}
if err := chownRuntimePath(target, RuntimeLinuxID(77), RuntimeLinuxID(77), 0600); err != nil {
t.Fatalf("chownRuntimePath() error = %v", err)
}
if os.PathSeparator == '/' {
info, err := os.Stat(target)
if err != nil {
t.Fatalf("Stat(target): %v", err)
}
if got := info.Mode().Perm(); got != 0600 {
t.Fatalf("target mode = %v, want 0600", got)
}
}
}
func TestChownRuntimePathReportsRootPermissionDenied(t *testing.T) {
target := filepath.Join(t.TempDir(), "skill-file")
if err := os.WriteFile(target, []byte("skill"), 0644); err != nil {
t.Fatalf("WriteFile(target): %v", err)
}
oldChown := chownRuntimePathOwner
oldEffectiveUID := currentEffectiveUID
t.Cleanup(func() {
chownRuntimePathOwner = oldChown
currentEffectiveUID = oldEffectiveUID
})
chownRuntimePathOwner = func(string, int, int) error {
return os.ErrPermission
}
currentEffectiveUID = func() int {
return 0
}
err := chownRuntimePath(target, RuntimeLinuxID(90), RuntimeLinuxID(90), 0600)
if err == nil || !strings.Contains(err.Error(), "failed to set lite runtime owner") {
t.Fatalf("chownRuntimePath() error = %v, want owner error", err)
}
}
func TestWriteSkillDirectoryAtomicallyNestedCategoryPath(t *testing.T) {
targetRoot := t.TempDir()
err := writeSkillDirectoryAtomically(targetRoot, "productivity/my-skill", map[string][]byte{
"SKILL.md": []byte("# Nested Skill\n"),
})
if err != nil {
t.Fatalf("writeSkillDirectoryAtomically() error = %v", err)
}
target := filepath.Join(targetRoot, "productivity", "my-skill", "SKILL.md")
if _, err := os.Stat(target); err != nil {
t.Fatalf("expected nested skill directory, stat err = %v", err)
}
}
func TestWriteSkillDirectoryAtomicallyUsesNestedTempRoot(t *testing.T) {
targetRoot := t.TempDir()
err := writeSkillDirectoryAtomically(targetRoot, "marker-pdf-ingest", map[string][]byte{
"SKILL.md": []byte("# Marker PDF Ingest\n"),
"scripts/parse_pdf.py": []byte("print('parse')\n"),
"scripts/helpers/io.py": []byte("print('io')\n"),
})
if err != nil {
t.Fatalf("writeSkillDirectoryAtomically() error = %v", err)
}
target := filepath.Join(targetRoot, "marker-pdf-ingest")
assertFileEquals(t, filepath.Join(target, "scripts", "parse_pdf.py"), "print('parse')\n")
if _, err := os.Stat(filepath.Join(targetRoot, ".tmp-skill-marker-pdf-ingest")); !os.IsNotExist(err) {
t.Fatalf("temporary skill directory leaked at root, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(targetRoot, ".tmp")); err != nil {
t.Fatalf("expected nested temporary root to remain available, stat err = %v", err)
}
}
func TestRemoveLiteInstanceSkillDeletesOpenClawWorkspaceSkill(t *testing.T) {
workspacePath := filepath.Join(t.TempDir(), "openclaw", "user-45", "instance-77")
target := filepath.Join(workspacePath, "home", ".openclaw", "workspace", "skills", "paper-ranker")
if err := os.MkdirAll(target, 0750); err != nil {
t.Fatalf("MkdirAll(target): %v", err)
}
if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("# Paper Ranker\n"), 0640); err != nil {
t.Fatalf("WriteFile(SKILL.md): %v", err)
}
instanceRepo := newV2LifecycleInstanceRepo()
instanceRepo.byID[77] = &models.Instance{
ID: 77,
UserID: 45,
Type: RuntimeTypeOpenClaw,
RuntimeType: RuntimeBackendGateway,
InstanceMode: InstanceModeLite,
WorkspacePath: &workspacePath,
}
installPath := "home/.openclaw/workspace/skills/paper-ranker"
service := &skillService{instanceRepo: instanceRepo}
if err := service.removeLiteInstanceSkillDirectory(77, &models.InstanceSkill{SkillID: 12, InstallPath: &installPath}); err != nil {
t.Fatalf("removeLiteInstanceSkillDirectory() error = %v", err)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("expected skill directory to be removed, stat err = %v", err)
}
}
func TestRemoveLiteInstanceSkillDeletesHermesHomeSkill(t *testing.T) {
workspacePath := filepath.Join(t.TempDir(), "hermes", "user-45", "instance-90")
target := filepath.Join(workspacePath, "home", ".hermes", "skills", "paper-ranker")
if err := os.MkdirAll(target, 0750); err != nil {
t.Fatalf("MkdirAll(target): %v", err)
}
if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("# Paper Ranker\n"), 0640); err != nil {
t.Fatalf("WriteFile(SKILL.md): %v", err)
}
instanceRepo := newV2LifecycleInstanceRepo()
instanceRepo.byID[90] = &models.Instance{
ID: 90,
UserID: 45,
Type: RuntimeTypeHermes,
RuntimeType: RuntimeBackendGateway,
InstanceMode: InstanceModeLite,
WorkspacePath: &workspacePath,
}
installPath := "home/.hermes/skills/paper-ranker"
service := &skillService{instanceRepo: instanceRepo}
if err := service.removeLiteInstanceSkillDirectory(90, &models.InstanceSkill{SkillID: 12, InstallPath: &installPath}); err != nil {
t.Fatalf("removeLiteInstanceSkillDirectory() error = %v", err)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("expected skill directory to be removed, stat err = %v", err)
}
}
func TestLiteRuntimePersistentAncestorsIncludeOpenClawHome(t *testing.T) {
workspacePath := filepath.Join(t.TempDir(), "openclaw", "user-1", "instance-89")
persistentRoot := filepath.Join(workspacePath, "home", ".openclaw")
got := liteRuntimePersistentAncestors(workspacePath, persistentRoot)
want := []string{
workspacePath,
filepath.Join(workspacePath, "home"),
filepath.Join(workspacePath, "home", ".openclaw"),
}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("liteRuntimePersistentAncestors() = %#v, want %#v", got, want)
}
}
type fakeObjectStorage map[string][]byte
func (f fakeObjectStorage) PutObject(context.Context, string, []byte, string) error {
return nil
}
func (f fakeObjectStorage) GetObject(_ context.Context, objectKey string) ([]byte, error) {
return f[objectKey], nil
}
func assertFileEquals(t *testing.T, filePath string, want string) {
t.Helper()
body, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("ReadFile(%q): %v", filePath, err)
}
if got := string(body); got != want {
t.Fatalf("ReadFile(%q) = %q, want %q", filePath, got, want)
}
}
func buildTestZip(t *testing.T, files map[string][]byte) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
keys := make([]string, 0, len(files))
for key := range files {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
entry, err := writer.Create(key)
if err != nil {
t.Fatalf("Create(%q): %v", key, err)
}
if _, err := entry.Write(files[key]); err != nil {
t.Fatalf("Write(%q): %v", key, err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("Close(): %v", err)
}
return buffer.Bytes()
}
func referenceSkillContentMD5(files map[string][]byte) string {
entryKinds := map[string]string{}
fileMap := map[string][]byte{}
for key, body := range files {
clean := path.Clean(key)
if clean == "." || clean == "" {
continue
}
fileMap[clean] = body
entryKinds[clean] = "file"
parts := splitTestPath(clean)
for i := 1; i < len(parts); i++ {
entryKinds[path.Join(parts[:i]...)] = "dir"
}
}
keys := make([]string, 0, len(entryKinds))
for key := range entryKinds {
keys = append(keys, key)
}
sort.Strings(keys)
digest := md5.New()
for _, key := range keys {
_, _ = digest.Write([]byte(key))
_, _ = digest.Write([]byte("\n"))
if entryKinds[key] == "dir" {
_, _ = digest.Write([]byte("dir\n"))
continue
}
_, _ = digest.Write([]byte("file\n"))
_, _ = digest.Write(fileMap[key])
_, _ = digest.Write([]byte("\n"))
}
return hex.EncodeToString(digest.Sum(nil))
}
func splitTestPath(value string) []string {
result := []string{}
for _, part := range bytes.Split([]byte(value), []byte("/")) {
if len(part) > 0 {
result = append(result, string(part))
}
}
return result
}