mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
306 lines
9.4 KiB
Go
306 lines
9.4 KiB
Go
package skills
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/memohai/memoh/internal/workspace/bridge"
|
|
pb "github.com/memohai/memoh/internal/workspace/bridgepb"
|
|
)
|
|
|
|
func TestParseFileFallbacks(t *testing.T) {
|
|
raw := "# Use this skill\n\nDo something useful."
|
|
got := ParseFile(raw, "plain-skill")
|
|
|
|
if got.Name != "plain-skill" {
|
|
t.Fatalf("expected name plain-skill, got %q", got.Name)
|
|
}
|
|
if got.Description != "plain-skill" {
|
|
t.Fatalf("expected description plain-skill, got %q", got.Description)
|
|
}
|
|
if got.Content != raw {
|
|
t.Fatalf("expected content to keep original markdown, got %q", got.Content)
|
|
}
|
|
}
|
|
|
|
func TestResolveSupportsDisabledFallbackAndShadowing(t *testing.T) {
|
|
items := []Entry{
|
|
{Name: "alpha", SourcePath: "/data/skills/alpha/SKILL.md", Managed: true, SourceKind: SourceKindManaged},
|
|
{Name: "alpha", SourcePath: "/data/.agents/skills/alpha/SKILL.md", SourceKind: SourceKindCompat},
|
|
{Name: "beta", SourcePath: "/data/.agents/skills/beta/SKILL.md", SourceKind: SourceKindCompat},
|
|
}
|
|
|
|
resolved := resolve(items, map[string]indexOverride{
|
|
"/data/skills/alpha/SKILL.md": {Disabled: true},
|
|
})
|
|
|
|
managedAlpha, ok := findBySourcePath(resolved, "/data/skills/alpha/SKILL.md")
|
|
if !ok {
|
|
t.Fatalf("managed alpha not found in resolved items")
|
|
}
|
|
if managedAlpha.State != StateDisabled {
|
|
t.Fatalf("managed alpha state = %q, want disabled", managedAlpha.State)
|
|
}
|
|
compatAlpha, ok := findBySourcePath(resolved, "/data/.agents/skills/alpha/SKILL.md")
|
|
if !ok {
|
|
t.Fatalf("compat alpha not found in resolved items")
|
|
}
|
|
if compatAlpha.State != StateEffective {
|
|
t.Fatalf("compat alpha state = %q, want effective", compatAlpha.State)
|
|
}
|
|
beta, ok := findBySourcePath(resolved, "/data/.agents/skills/beta/SKILL.md")
|
|
if !ok {
|
|
t.Fatalf("beta not found in resolved items")
|
|
}
|
|
if beta.State != StateEffective {
|
|
t.Fatalf("beta state = %q, want effective", beta.State)
|
|
}
|
|
}
|
|
|
|
func TestListReadsFullRawContentAndWritesIndex(t *testing.T) {
|
|
client := newFakeClient()
|
|
client.listings[ManagedDirPath] = []*pb.FileEntry{{Path: "alpha", IsDir: true}}
|
|
client.files[pathJoin(ManagedDirPath, "alpha", "SKILL.md")] = "---\nname: alpha\ndescription: Alpha\n---\n\n" + strings.Repeat("A", 7000)
|
|
|
|
items, err := List(context.Background(), client, nil)
|
|
if err != nil {
|
|
t.Fatalf("List returned error: %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("expected 1 item, got %d", len(items))
|
|
}
|
|
if len(items[0].Raw) <= 7000 {
|
|
t.Fatalf("expected full raw content, got len=%d", len(items[0].Raw))
|
|
}
|
|
if _, ok := client.files[IndexFilePath]; !ok {
|
|
t.Fatalf("expected index file to be written")
|
|
}
|
|
}
|
|
|
|
func TestApplyActionAdoptAndDisable(t *testing.T) {
|
|
client := newFakeClient()
|
|
externalPath := pathJoin("/data/.agents/skills", "alpha", "SKILL.md")
|
|
client.listings["/data/.agents/skills"] = []*pb.FileEntry{{Path: "alpha", IsDir: true}}
|
|
client.files[externalPath] = "---\nname: alpha\ndescription: Alpha\n---\n\n# Alpha"
|
|
|
|
if err := ApplyAction(context.Background(), client, nil, ActionRequest{
|
|
Action: ActionAdopt,
|
|
TargetPath: externalPath,
|
|
}); err != nil {
|
|
t.Fatalf("adopt returned error: %v", err)
|
|
}
|
|
if _, ok := client.files[pathJoin(ManagedDirPath, "alpha", "SKILL.md")]; !ok {
|
|
t.Fatalf("expected managed copy after adopt")
|
|
}
|
|
|
|
if err := ApplyAction(context.Background(), client, nil, ActionRequest{
|
|
Action: ActionDisable,
|
|
TargetPath: externalPath,
|
|
}); err != nil {
|
|
t.Fatalf("disable returned error: %v", err)
|
|
}
|
|
idx := readIndex(context.Background(), client)
|
|
if !idx.Overrides[externalPath].Disabled {
|
|
t.Fatalf("expected disabled override for %s", externalPath)
|
|
}
|
|
}
|
|
|
|
func TestApplyActionAdoptRejectsInvalidManagedName(t *testing.T) {
|
|
client := newFakeClient()
|
|
externalPath := pathJoin("/data/.agents/skills", "escape", "SKILL.md")
|
|
client.listings["/data/.agents/skills"] = []*pb.FileEntry{{Path: "escape", IsDir: true}}
|
|
client.files[externalPath] = "---\nname: ..\ndescription: Escape\n---\n\n# Escape"
|
|
|
|
err := ApplyAction(context.Background(), client, nil, ActionRequest{
|
|
Action: ActionAdopt,
|
|
TargetPath: externalPath,
|
|
})
|
|
if !errors.Is(err, bridge.ErrBadRequest) {
|
|
t.Fatalf("adopt err = %v, want ErrBadRequest", err)
|
|
}
|
|
if _, ok := client.files[pathJoin(ManagedDirPath, "..", "SKILL.md")]; ok {
|
|
t.Fatalf("unexpected managed write for invalid adopted name")
|
|
}
|
|
}
|
|
|
|
func TestIsValidNameRejectsTraversalPatterns(t *testing.T) {
|
|
for _, name := range []string{
|
|
"",
|
|
".",
|
|
"..",
|
|
".hidden",
|
|
"alpha..beta",
|
|
"../escape",
|
|
"alpha/../beta",
|
|
} {
|
|
if IsValidName(name) {
|
|
t.Fatalf("IsValidName(%q) = true, want false", name)
|
|
}
|
|
}
|
|
|
|
for _, name := range []string{"alpha", "alpha-beta", "alpha_beta", "alpha.beta"} {
|
|
if !IsValidName(name) {
|
|
t.Fatalf("IsValidName(%q) = false, want true", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestManagedSkillDirForNameRejectsEscapingNames(t *testing.T) {
|
|
for _, name := range []string{".", "..", ".alpha", "alpha..beta"} {
|
|
if _, err := ManagedSkillDirForName(name); !errors.Is(err, bridge.ErrBadRequest) {
|
|
t.Fatalf("ManagedSkillDirForName(%q) err = %v, want ErrBadRequest", name, err)
|
|
}
|
|
}
|
|
|
|
dirPath, err := ManagedSkillDirForName("alpha.beta")
|
|
if err != nil {
|
|
t.Fatalf("ManagedSkillDirForName(valid) returned error: %v", err)
|
|
}
|
|
if dirPath != pathJoin(ManagedDirPath, "alpha.beta") {
|
|
t.Fatalf("ManagedSkillDirForName(valid) = %q, want %q", dirPath, pathJoin(ManagedDirPath, "alpha.beta"))
|
|
}
|
|
}
|
|
|
|
func TestDiscoveryRootsMatchDefaultPolicy(t *testing.T) {
|
|
roots := DiscoveryRoots(nil)
|
|
want := []Root{
|
|
{Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
|
|
{Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
|
|
{Path: "/data/.agents/skills", Kind: SourceKindCompat, Managed: false},
|
|
{Path: "/root/.agents/skills", Kind: SourceKindCompat, Managed: false},
|
|
}
|
|
if !slices.Equal(roots, want) {
|
|
t.Fatalf("DiscoveryRoots() = %+v, want %+v", roots, want)
|
|
}
|
|
}
|
|
|
|
func TestDiscoveryRootsUseConfiguredCompatRoots(t *testing.T) {
|
|
roots := DiscoveryRoots([]string{
|
|
" /custom/skills ",
|
|
"/data/skills",
|
|
"/custom/skills",
|
|
"relative/skills",
|
|
"/root/.openclaw/skills",
|
|
})
|
|
want := []Root{
|
|
{Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
|
|
{Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
|
|
{Path: "/custom/skills", Kind: SourceKindCompat, Managed: false},
|
|
{Path: "/root/.openclaw/skills", Kind: SourceKindCompat, Managed: false},
|
|
}
|
|
if !slices.Equal(roots, want) {
|
|
t.Fatalf("DiscoveryRoots(custom) = %+v, want %+v", roots, want)
|
|
}
|
|
}
|
|
|
|
func TestDiscoveryRootsAllowExplicitEmptyCompatRoots(t *testing.T) {
|
|
roots := DiscoveryRoots([]string{})
|
|
want := []Root{
|
|
{Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
|
|
{Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
|
|
}
|
|
if !slices.Equal(roots, want) {
|
|
t.Fatalf("DiscoveryRoots(empty) = %+v, want %+v", roots, want)
|
|
}
|
|
}
|
|
|
|
func TestListScansConfiguredDiscoveryRootsInOrder(t *testing.T) {
|
|
client := newFakeClient()
|
|
rawCompatRoots := []string(nil)
|
|
for _, root := range DiscoveryRoots(rawCompatRoots) {
|
|
client.listings[root.Path] = nil
|
|
}
|
|
client.listings[ManagedDirPath] = []*pb.FileEntry{{Path: "alpha", IsDir: true}}
|
|
client.files[pathJoin(ManagedDirPath, "alpha", "SKILL.md")] = "---\nname: alpha\ndescription: Alpha\n---\n\n# Alpha"
|
|
|
|
items, err := List(context.Background(), client, rawCompatRoots)
|
|
if err != nil {
|
|
t.Fatalf("List returned error: %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].SourceRoot != ManagedDirPath {
|
|
t.Fatalf("List() items = %+v, want managed alpha only", items)
|
|
}
|
|
|
|
wantCalls := make([]string, 0, len(DiscoveryRoots(rawCompatRoots)))
|
|
for _, root := range DiscoveryRoots(rawCompatRoots) {
|
|
wantCalls = append(wantCalls, root.Path)
|
|
}
|
|
if !slices.Equal(client.listCalls, wantCalls) {
|
|
t.Fatalf("ListDirAll calls = %+v, want %+v", client.listCalls, wantCalls)
|
|
}
|
|
}
|
|
|
|
func TestContainerEnvUsesDataHomeAndXDGDirs(t *testing.T) {
|
|
env := ContainerEnv(nil)
|
|
for _, want := range []string{
|
|
"HOME=/data",
|
|
"XDG_CONFIG_HOME=/data/.config",
|
|
"XDG_DATA_HOME=/data/.local/share",
|
|
"XDG_CACHE_HOME=/data/.cache",
|
|
"MEMOH_SKILL_DISCOVERY_ROOTS=/data/.agents/skills:/root/.agents/skills",
|
|
} {
|
|
if !slices.Contains(env, want) {
|
|
t.Fatalf("env %+v does not contain %q", env, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestContainerEnvUsesConfiguredSkillDiscoveryRoots(t *testing.T) {
|
|
env := ContainerEnv([]string{"/custom/skills", "/root/.openclaw/skills"})
|
|
want := SkillDiscoveryRootsEnvVar + "=/custom/skills:/root/.openclaw/skills"
|
|
if !slices.Contains(env, want) {
|
|
t.Fatalf("env %+v does not contain %q", env, want)
|
|
}
|
|
}
|
|
|
|
type fakeClient struct {
|
|
listings map[string][]*pb.FileEntry
|
|
files map[string]string
|
|
listCalls []string
|
|
}
|
|
|
|
func newFakeClient() *fakeClient {
|
|
return &fakeClient{
|
|
listings: make(map[string][]*pb.FileEntry),
|
|
files: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
func (f *fakeClient) ListDirAll(_ context.Context, p string, _ bool) ([]*pb.FileEntry, error) {
|
|
f.listCalls = append(f.listCalls, p)
|
|
items, ok := f.listings[p]
|
|
if !ok {
|
|
return nil, io.EOF
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (f *fakeClient) ReadRaw(_ context.Context, p string) (io.ReadCloser, error) {
|
|
content, ok := f.files[p]
|
|
if !ok {
|
|
return nil, io.EOF
|
|
}
|
|
return io.NopCloser(strings.NewReader(content)), nil
|
|
}
|
|
|
|
func (f *fakeClient) WriteRaw(_ context.Context, p string, r io.Reader) (int64, error) {
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
f.files[p] = string(data)
|
|
return int64(len(data)), nil
|
|
}
|
|
|
|
func (*fakeClient) Mkdir(_ context.Context, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func pathJoin(parts ...string) string {
|
|
return strings.Join(parts, "/")
|
|
}
|