diff --git a/internal/handlers/skills_test.go b/internal/handlers/skills_test.go index f0bc8765..7810df91 100644 --- a/internal/handlers/skills_test.go +++ b/internal/handlers/skills_test.go @@ -1,42 +1,692 @@ package handlers -import "testing" +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "os" + "path" + "path/filepath" + "sort" + "strings" + "testing" + "time" -func TestParseSkillFile_NoFrontmatterFallbacks(t *testing.T) { - raw := "# Use this skill\n\nDo something useful." - got := parseSkillFile(raw, "plain-skill") + "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" - if got.Name != "plain-skill" { - t.Fatalf("expected name plain-skill, got %q", got.Name) + "github.com/memohai/memoh/internal/accounts" + "github.com/memohai/memoh/internal/agent" + "github.com/memohai/memoh/internal/bots" + "github.com/memohai/memoh/internal/config" + "github.com/memohai/memoh/internal/db/sqlc" + skillset "github.com/memohai/memoh/internal/skills" + "github.com/memohai/memoh/internal/workspace" + pb "github.com/memohai/memoh/internal/workspace/bridgepb" +) + +func TestListSkillsAPIReportsEffectiveShadowedAndSourceMetadata(t *testing.T) { + env := newSkillsTestEnv(t) + env.writeSkillFile(t, path.Join(skillset.ManagedDir(), "alpha", "SKILL.md"), managedSkillRaw("alpha", "Managed Alpha")) + env.writeSkillFile(t, path.Join("/data/.openclaw/skills", "alpha", "SKILL.md"), managedSkillRaw("alpha", "Compat Alpha")) + env.writeSkillFile(t, path.Join("/data/.openclaw/skills", "beta", "SKILL.md"), managedSkillRaw("beta", "Compat Beta")) + + rec, err := env.callJSON(t, http.MethodGet, "/bots/:bot_id/container/skills", nil, env.handler.ListSkills) + if err != nil { + t.Fatalf("ListSkills returned error: %v", err) } - if got.Description != "plain-skill" { - t.Fatalf("expected description plain-skill, got %q", got.Description) + if rec.Code != http.StatusOK { + t.Fatalf("ListSkills status = %d, want 200", rec.Code) } - if got.Content != raw { - t.Fatalf("expected content to keep original markdown, got %q", got.Content) + + var resp SkillsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode skills response: %v", err) + } + if len(resp.Skills) != 3 { + t.Fatalf("expected 3 skills, got %d", len(resp.Skills)) + } + + alphaManaged := mustFindSkillByPath(t, resp.Skills, path.Join(skillset.ManagedDir(), "alpha", "SKILL.md")) + if !alphaManaged.Managed { + t.Fatalf("managed alpha should be managed: %+v", alphaManaged) + } + if alphaManaged.State != skillset.StateEffective { + t.Fatalf("managed alpha state = %q, want %q", alphaManaged.State, skillset.StateEffective) + } + if alphaManaged.SourceKind != skillset.SourceKindManaged { + t.Fatalf("managed alpha source_kind = %q, want %q", alphaManaged.SourceKind, skillset.SourceKindManaged) + } + + alphaCompatPath := path.Join("/data/.openclaw/skills", "alpha", "SKILL.md") + alphaCompat := mustFindSkillByPath(t, resp.Skills, alphaCompatPath) + if alphaCompat.Managed { + t.Fatalf("compat alpha should not be managed: %+v", alphaCompat) + } + if alphaCompat.State != skillset.StateShadowed { + t.Fatalf("compat alpha state = %q, want %q", alphaCompat.State, skillset.StateShadowed) + } + if alphaCompat.ShadowedBy != alphaManaged.SourcePath { + t.Fatalf("compat alpha shadowed_by = %q, want %q", alphaCompat.ShadowedBy, alphaManaged.SourcePath) + } + if alphaCompat.SourceKind != skillset.SourceKindCompat { + t.Fatalf("compat alpha source_kind = %q, want %q", alphaCompat.SourceKind, skillset.SourceKindCompat) + } + + betaCompat := mustFindSkillByPath(t, resp.Skills, path.Join("/data/.openclaw/skills", "beta", "SKILL.md")) + if betaCompat.State != skillset.StateEffective { + t.Fatalf("beta compat state = %q, want %q", betaCompat.State, skillset.StateEffective) + } + + if _, err := os.Stat(env.localPath(skillset.IndexFilePath)); err != nil { + t.Fatalf("expected derived skill index to be written: %v", err) } } -func TestParseSkillFile_FrontmatterDescriptionFallback(t *testing.T) { - raw := "---\nname: hello-skill\n---\n\nBody content" - got := parseSkillFile(raw, "fallback") +func TestSkillsActionsAPIAdoptDisableEnableAndDeleteManaged(t *testing.T) { + env := newSkillsTestEnv(t) + externalPath := path.Join("/data/.openclaw/skills", "alpha", "SKILL.md") + managedPath := path.Join(skillset.ManagedDir(), "alpha", "SKILL.md") + env.writeSkillFile(t, externalPath, managedSkillRaw("alpha", "Compat Alpha")) - if got.Name != "hello-skill" { - t.Fatalf("expected frontmatter name hello-skill, got %q", got.Name) + rec, err := env.callJSON(t, http.MethodPost, "/bots/:bot_id/container/skills/actions", SkillsActionRequest{ + Action: skillset.ActionAdopt, + TargetPath: externalPath, + }, env.handler.ApplySkillAction) + if err != nil { + t.Fatalf("adopt returned error: %v", err) } - if got.Description != "hello-skill" { - t.Fatalf("expected description fallback to name, got %q", got.Description) + if rec.Code != http.StatusOK { + t.Fatalf("adopt status = %d, want 200", rec.Code) } - if got.Content != "Body content" { - t.Fatalf("expected content Body content, got %q", got.Content) + if _, err := os.Stat(env.localPath(managedPath)); err != nil { + t.Fatalf("expected managed skill after adopt: %v", err) + } + + adopted := env.listSkills(t) + adoptedManaged := mustFindSkillByPath(t, adopted, managedPath) + if adoptedManaged.State != skillset.StateEffective { + t.Fatalf("managed adopted skill state = %q, want %q", adoptedManaged.State, skillset.StateEffective) + } + adoptedCompat := mustFindSkillByPath(t, adopted, externalPath) + if adoptedCompat.State != skillset.StateShadowed { + t.Fatalf("compat adopted skill state = %q, want %q", adoptedCompat.State, skillset.StateShadowed) + } + + rec, err = env.callJSON(t, http.MethodPost, "/bots/:bot_id/container/skills/actions", SkillsActionRequest{ + Action: skillset.ActionDisable, + TargetPath: managedPath, + }, env.handler.ApplySkillAction) + if err != nil { + t.Fatalf("disable returned error: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("disable status = %d, want 200", rec.Code) + } + + disabled := env.listSkills(t) + disabledManaged := mustFindSkillByPath(t, disabled, managedPath) + if disabledManaged.State != skillset.StateDisabled { + t.Fatalf("managed disabled skill state = %q, want %q", disabledManaged.State, skillset.StateDisabled) + } + disabledCompat := mustFindSkillByPath(t, disabled, externalPath) + if disabledCompat.State != skillset.StateEffective { + t.Fatalf("compat fallback state = %q, want %q", disabledCompat.State, skillset.StateEffective) + } + + rec, err = env.callJSON(t, http.MethodPost, "/bots/:bot_id/container/skills/actions", SkillsActionRequest{ + Action: skillset.ActionEnable, + TargetPath: managedPath, + }, env.handler.ApplySkillAction) + if err != nil { + t.Fatalf("enable returned error: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("enable status = %d, want 200", rec.Code) + } + + reenabled := env.listSkills(t) + if got := mustFindSkillByPath(t, reenabled, managedPath).State; got != skillset.StateEffective { + t.Fatalf("managed state after enable = %q, want %q", got, skillset.StateEffective) + } + if got := mustFindSkillByPath(t, reenabled, externalPath).State; got != skillset.StateShadowed { + t.Fatalf("compat state after enable = %q, want %q", got, skillset.StateShadowed) + } + + rec, err = env.callJSON(t, http.MethodDelete, "/bots/:bot_id/container/skills", SkillsDeleteRequest{ + Names: []string{"alpha"}, + }, env.handler.DeleteSkills) + if err != nil { + t.Fatalf("DeleteSkills returned error: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("DeleteSkills status = %d, want 200", rec.Code) + } + if _, err := os.Stat(env.localPath(managedPath)); !os.IsNotExist(err) { + t.Fatalf("expected managed skill to be removed, stat err=%v", err) + } + + deleted := env.listSkills(t) + if len(deleted) != 1 { + t.Fatalf("expected only compat skill after delete, got %d items", len(deleted)) + } + if got := mustFindSkillByPath(t, deleted, externalPath).State; got != skillset.StateEffective { + t.Fatalf("compat state after delete = %q, want %q", got, skillset.StateEffective) } } -func TestParseSkillFile_EmptyBodyFallbacksToDescription(t *testing.T) { - raw := "---\nname: hello-skill\ndescription: say hello\n---\n" - got := parseSkillFile(raw, "fallback") +func TestDeleteSkillsAPIRejectsExternalOnlySkill(t *testing.T) { + env := newSkillsTestEnv(t) + env.writeSkillFile(t, path.Join("/data/.openclaw/skills", "alpha", "SKILL.md"), managedSkillRaw("alpha", "Compat Alpha")) - if got.Content != "say hello" { - t.Fatalf("expected content fallback to description, got %q", got.Content) + _, err := env.callJSON(t, http.MethodDelete, "/bots/:bot_id/container/skills", SkillsDeleteRequest{ + Names: []string{"alpha"}, + }, env.handler.DeleteSkills) + if err == nil { + t.Fatal("expected deleting external-only skill to fail") + } + var httpErr *echo.HTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusNotFound { + t.Fatalf("delete external-only status = %d, want 404", httpErr.Code) } } + +func TestLoadSkillsUsesEffectiveSetAndPromptReflectsOverrideFallback(t *testing.T) { + env := newSkillsTestEnv(t) + managedPath := path.Join(skillset.ManagedDir(), "alpha", "SKILL.md") + compatPath := path.Join("/data/.openclaw/skills", "alpha", "SKILL.md") + env.writeSkillFile(t, managedPath, managedSkillRaw("alpha", "Managed Alpha")) + env.writeSkillFile(t, compatPath, managedSkillRaw("alpha", "Compat Alpha")) + env.writeSkillFile(t, path.Join("/data/.openclaw/skills", "beta", "SKILL.md"), managedSkillRaw("beta", "Compat Beta")) + + loaded, err := env.handler.LoadSkills(context.Background(), env.botID) + if err != nil { + t.Fatalf("LoadSkills returned error: %v", err) + } + if len(loaded) != 2 { + t.Fatalf("expected 2 effective skills, got %d", len(loaded)) + } + if got := loaded[0].Name + ":" + loaded[0].Description + "|" + loaded[1].Name + ":" + loaded[1].Description; !strings.Contains(got, "alpha:Managed Alpha") { + t.Fatalf("effective skills should include managed alpha, got %s", got) + } + promptBefore := promptFromLoadedSkills(loaded) + if !strings.Contains(promptBefore, "Managed Alpha") { + t.Fatalf("prompt should include managed alpha description:\n%s", promptBefore) + } + if strings.Contains(promptBefore, "Compat Alpha") { + t.Fatalf("prompt should not include shadowed compat alpha:\n%s", promptBefore) + } + + client, err := env.handler.manager.MCPClient(context.Background(), env.botID) + if err != nil { + t.Fatalf("get bridge client: %v", err) + } + if err := skillset.ApplyAction(context.Background(), client, skillset.ActionRequest{ + Action: skillset.ActionDisable, + TargetPath: managedPath, + }); err != nil { + t.Fatalf("disable managed alpha via skillset.ApplyAction: %v", err) + } + + fallback, err := env.handler.LoadSkills(context.Background(), env.botID) + if err != nil { + t.Fatalf("LoadSkills after disable returned error: %v", err) + } + if len(fallback) != 2 { + t.Fatalf("expected 2 effective skills after disable, got %d", len(fallback)) + } + alphaFallback := mustFindLoadedSkillByName(t, fallback, "alpha") + if alphaFallback.Description != "Compat Alpha" { + t.Fatalf("effective alpha description after disable = %q, want %q", alphaFallback.Description, "Compat Alpha") + } + promptAfter := promptFromLoadedSkills(fallback) + if !strings.Contains(promptAfter, "Compat Alpha") { + t.Fatalf("prompt should include compat alpha after fallback:\n%s", promptAfter) + } + if strings.Contains(promptAfter, "Managed Alpha") { + t.Fatalf("prompt should not include disabled managed alpha after fallback:\n%s", promptAfter) + } +} + +type skillsTestEnv struct { + handler *ContainerdHandler + dataRoot string + botID string + userID string +} + +func newSkillsTestEnv(t *testing.T) *skillsTestEnv { + t.Helper() + + dataRoot, err := os.MkdirTemp("", "memoh-skills-") + if err != nil { + t.Fatalf("create temp data root: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dataRoot) }) + userID := "00000000-0000-0000-0000-000000000001" + botID := "00000000-0000-0000-0000-000000000010" + startSkillsTestBridgeServer(t, dataRoot, botID) + + cfg := config.WorkspaceConfig{DataRoot: dataRoot} + db := &skillsTestDB{userID: userID, botID: botID} + manager := workspace.NewManager(slog.Default(), nil, cfg, "", nil) + handler := NewContainerdHandler( + slog.Default(), + manager, + cfg, + "", + bots.NewService(slog.Default(), sqlc.New(db)), + accounts.NewService(slog.Default(), sqlc.New(db)), + nil, + ) + + return &skillsTestEnv{ + handler: handler, + dataRoot: dataRoot, + botID: botID, + userID: userID, + } +} + +func (e *skillsTestEnv) callJSON(t *testing.T, method, routePath string, body any, fn func(echo.Context) error) (*httptest.ResponseRecorder, error) { + t.Helper() + + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request body: %v", err) + } + bodyReader = strings.NewReader(string(data)) + } + + req := httptest.NewRequest(method, routePath, bodyReader) + if body != nil { + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + } + rec := httptest.NewRecorder() + ctx := echo.New().NewContext(req, rec) + ctx.SetPath(routePath) + ctx.SetParamNames("bot_id") + ctx.SetParamValues(e.botID) + ctx.Set("user", &jwt.Token{ + Valid: true, + Claims: jwt.MapClaims{"user_id": e.userID, "sub": e.userID}, + }) + + return rec, fn(ctx) +} + +func (e *skillsTestEnv) listSkills(t *testing.T) []SkillItem { + t.Helper() + rec, err := e.callJSON(t, http.MethodGet, "/bots/:bot_id/container/skills", nil, e.handler.ListSkills) + if err != nil { + t.Fatalf("ListSkills returned error: %v", err) + } + var resp SkillsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode ListSkills response: %v", err) + } + return resp.Skills +} + +func (e *skillsTestEnv) writeSkillFile(t *testing.T, containerPath, raw string) { + t.Helper() + local := e.localPath(containerPath) + if err := os.MkdirAll(filepath.Dir(local), 0o750); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(local), err) + } + //nolint:gosec // test-only temp workspace path + if err := os.WriteFile(local, []byte(raw), 0o600); err != nil { + t.Fatalf("write %s: %v", local, err) + } +} + +func (e *skillsTestEnv) localPath(containerPath string) string { + clean := path.Clean("/" + strings.TrimSpace(containerPath)) + if clean == "/" { + return e.dataRoot + } + return filepath.Join(e.dataRoot, filepath.FromSlash(strings.TrimPrefix(clean, "/"))) +} + +type skillsTestDB struct { + userID string + botID string +} + +func (*skillsTestDB) Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) { + return pgconn.CommandTag{}, nil +} + +func (*skillsTestDB) Query(context.Context, string, ...interface{}) (pgx.Rows, error) { + return nil, nil +} + +func (d *skillsTestDB) QueryRow(_ context.Context, sql string, _ ...interface{}) pgx.Row { + switch { + case strings.Contains(sql, "FROM users WHERE id = $1"): + return makeUserRow(mustParseUUID(d.userID), "user") + case strings.Contains(sql, "FROM bots"): + return makeBotRow(mustParseUUID(d.botID), mustParseUUID(d.userID)) + default: + return &skillsTestRow{scanFunc: func(_ ...any) error { return pgx.ErrNoRows }} + } +} + +type skillsTestRow struct { + scanFunc func(dest ...any) error +} + +func (r *skillsTestRow) Scan(dest ...any) error { + return r.scanFunc(dest...) +} + +func makeUserRow(userID pgtype.UUID, role string) *skillsTestRow { + return &skillsTestRow{ + scanFunc: func(dest ...any) error { + if len(dest) < 14 { + return pgx.ErrNoRows + } + *dest[0].(*pgtype.UUID) = userID + *dest[1].(*pgtype.Text) = pgtype.Text{String: "owner", Valid: true} + *dest[2].(*pgtype.Text) = pgtype.Text{} + *dest[3].(*pgtype.Text) = pgtype.Text{} + *dest[4].(*string) = role + *dest[5].(*pgtype.Text) = pgtype.Text{String: "Owner", Valid: true} + *dest[6].(*pgtype.Text) = pgtype.Text{} + *dest[7].(*string) = "UTC" + *dest[8].(*pgtype.Text) = pgtype.Text{} + *dest[9].(*pgtype.Timestamptz) = pgtype.Timestamptz{} + *dest[10].(*bool) = true + *dest[11].(*[]byte) = []byte(`{}`) + *dest[12].(*pgtype.Timestamptz) = pgtype.Timestamptz{} + *dest[13].(*pgtype.Timestamptz) = pgtype.Timestamptz{} + return nil + }, + } +} + +func makeBotRow(botID, ownerUserID pgtype.UUID) *skillsTestRow { + return &skillsTestRow{ + scanFunc: func(dest ...any) error { + if len(dest) < 23 { + return pgx.ErrNoRows + } + *dest[0].(*pgtype.UUID) = botID + *dest[1].(*pgtype.UUID) = ownerUserID + *dest[2].(*pgtype.Text) = pgtype.Text{String: "test-bot", Valid: true} + *dest[3].(*pgtype.Text) = pgtype.Text{} + *dest[4].(*pgtype.Text) = pgtype.Text{} + *dest[5].(*bool) = true + *dest[6].(*string) = bots.BotStatusReady + *dest[7].(*string) = "en" + *dest[8].(*bool) = false + *dest[9].(*string) = "medium" + *dest[10].(*pgtype.UUID) = pgtype.UUID{} + *dest[11].(*pgtype.UUID) = pgtype.UUID{} + *dest[12].(*pgtype.UUID) = pgtype.UUID{} + *dest[13].(*bool) = false + *dest[14].(*int32) = 30 + *dest[15].(*string) = "" + *dest[16].(*bool) = false + *dest[17].(*int32) = 100000 + *dest[18].(*int32) = 80 + *dest[19].(*pgtype.UUID) = pgtype.UUID{} + *dest[20].(*[]byte) = []byte(`{}`) + *dest[21].(*pgtype.Timestamptz) = pgtype.Timestamptz{} + *dest[22].(*pgtype.Timestamptz) = pgtype.Timestamptz{} + return nil + }, + } +} + +func mustParseUUID(s string) pgtype.UUID { + var u pgtype.UUID + if err := u.Scan(s); err != nil { + panic(err) + } + return u +} + +type skillsTestBridgeServer struct { + pb.UnimplementedContainerServiceServer + root string +} + +func startSkillsTestBridgeServer(t *testing.T, dataRoot, botID string) { + t.Helper() + + socketPath := filepath.Join(dataRoot, "run", botID, "bridge.sock") + if err := os.MkdirAll(filepath.Dir(socketPath), 0o750); err != nil { + t.Fatalf("mkdir socket dir: %v", err) + } + var lc net.ListenConfig + lis, err := lc.Listen(context.Background(), "unix", socketPath) + if err != nil { + t.Fatalf("listen unix socket: %v", err) + } + + srv := grpc.NewServer() + pb.RegisterContainerServiceServer(srv, &skillsTestBridgeServer{root: dataRoot}) + + done := make(chan struct{}) + go func() { + defer close(done) + _ = srv.Serve(lis) + }() + + t.Cleanup(func() { + srv.Stop() + _ = lis.Close() + <-done + }) +} + +func (s *skillsTestBridgeServer) ListDir(_ context.Context, req *pb.ListDirRequest) (*pb.ListDirResponse, error) { + containerPath, localPath := s.resolvePath(req.GetPath()) + entries, err := os.ReadDir(localPath) + if err != nil { + return nil, toStatusError(err, req.GetPath()) + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + resp := make([]*pb.FileEntry, 0, len(entries)) + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + return nil, status.Errorf(codes.Internal, "stat %s: %v", entry.Name(), err) + } + entryPath := path.Join(containerPath, entry.Name()) + if containerPath == "/" { + entryPath = "/" + entry.Name() + } + resp = append(resp, &pb.FileEntry{ + Path: entryPath, + IsDir: entry.IsDir(), + Size: info.Size(), + Mode: info.Mode().String(), + ModTime: info.ModTime().UTC().Format(time.RFC3339), + }) + } + if len(resp) > 1<<31-1 { + return nil, status.Error(codes.Internal, "too many entries") + } + //nolint:gosec // len(resp) is bounds-checked just above + totalCount := int32(len(resp)) + return &pb.ListDirResponse{ + Entries: resp, + TotalCount: totalCount, + }, nil +} + +func (s *skillsTestBridgeServer) ReadRaw(req *pb.ReadRawRequest, stream pb.ContainerService_ReadRawServer) error { + _, localPath := s.resolvePath(req.GetPath()) + //nolint:gosec // test-only temp workspace path + data, err := os.ReadFile(localPath) + if err != nil { + return toStatusError(err, req.GetPath()) + } + if len(data) == 0 { + return nil + } + return stream.Send(&pb.DataChunk{Data: data}) +} + +func (s *skillsTestBridgeServer) WriteRaw(stream pb.ContainerService_WriteRawServer) error { + var containerPath string + var data []byte + for { + chunk, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return err + } + if containerPath == "" { + containerPath = chunk.GetPath() + } + data = append(data, chunk.GetData()...) + } + if strings.TrimSpace(containerPath) == "" { + return status.Error(codes.InvalidArgument, "path is required") + } + _, localPath := s.resolvePath(containerPath) + if err := os.MkdirAll(filepath.Dir(localPath), 0o750); err != nil { + return status.Errorf(codes.Internal, "mkdir parent for %s: %v", containerPath, err) + } + //nolint:gosec // test-only temp workspace path + if err := os.WriteFile(localPath, data, 0o600); err != nil { + return status.Errorf(codes.Internal, "write %s: %v", containerPath, err) + } + return stream.SendAndClose(&pb.WriteRawResponse{BytesWritten: int64(len(data))}) +} + +func (s *skillsTestBridgeServer) WriteFile(_ context.Context, req *pb.WriteFileRequest) (*pb.WriteFileResponse, error) { + _, localPath := s.resolvePath(req.GetPath()) + if err := os.MkdirAll(filepath.Dir(localPath), 0o750); err != nil { + return nil, status.Errorf(codes.Internal, "mkdir parent for %s: %v", req.GetPath(), err) + } + //nolint:gosec // test-only temp workspace path + if err := os.WriteFile(localPath, req.GetContent(), 0o600); err != nil { + return nil, status.Errorf(codes.Internal, "write %s: %v", req.GetPath(), err) + } + return &pb.WriteFileResponse{}, nil +} + +func (s *skillsTestBridgeServer) Stat(_ context.Context, req *pb.StatRequest) (*pb.StatResponse, error) { + containerPath, localPath := s.resolvePath(req.GetPath()) + info, err := os.Stat(localPath) + if err != nil { + return nil, toStatusError(err, req.GetPath()) + } + return &pb.StatResponse{Entry: &pb.FileEntry{ + Path: containerPath, + IsDir: info.IsDir(), + Size: info.Size(), + Mode: info.Mode().String(), + ModTime: info.ModTime().UTC().Format(time.RFC3339), + }}, nil +} + +func (s *skillsTestBridgeServer) Mkdir(_ context.Context, req *pb.MkdirRequest) (*pb.MkdirResponse, error) { + _, localPath := s.resolvePath(req.GetPath()) + if err := os.MkdirAll(localPath, 0o750); err != nil { + return nil, status.Errorf(codes.Internal, "mkdir %s: %v", req.GetPath(), err) + } + return &pb.MkdirResponse{}, nil +} + +func (s *skillsTestBridgeServer) DeleteFile(_ context.Context, req *pb.DeleteFileRequest) (*pb.DeleteFileResponse, error) { + _, localPath := s.resolvePath(req.GetPath()) + if _, err := os.Stat(localPath); err != nil { + return nil, toStatusError(err, req.GetPath()) + } + var err error + if req.GetRecursive() { + err = os.RemoveAll(localPath) + } else { + err = os.Remove(localPath) + } + if err != nil { + return nil, status.Errorf(codes.Internal, "delete %s: %v", req.GetPath(), err) + } + return &pb.DeleteFileResponse{}, nil +} + +func (s *skillsTestBridgeServer) resolvePath(containerPath string) (string, string) { + clean := path.Clean("/" + strings.TrimSpace(containerPath)) + if clean == "/" { + return clean, s.root + } + return clean, filepath.Join(s.root, filepath.FromSlash(strings.TrimPrefix(clean, "/"))) +} + +func toStatusError(err error, containerPath string) error { + if os.IsNotExist(err) { + return status.Errorf(codes.NotFound, "path not found: %s", containerPath) + } + if os.IsPermission(err) { + return status.Errorf(codes.PermissionDenied, "permission denied: %s", containerPath) + } + return status.Errorf(codes.Internal, "%v", err) +} + +func mustFindSkillByPath(t *testing.T, items []SkillItem, sourcePath string) SkillItem { + t.Helper() + for _, item := range items { + if item.SourcePath == sourcePath { + return item + } + } + t.Fatalf("skill with source path %q not found in %+v", sourcePath, items) + return SkillItem{} +} + +func mustFindLoadedSkillByName(t *testing.T, items []SkillItem, name string) SkillItem { + t.Helper() + for _, item := range items { + if item.Name == name { + return item + } + } + t.Fatalf("loaded skill %q not found in %+v", name, items) + return SkillItem{} +} + +func promptFromLoadedSkills(items []SkillItem) string { + skills := make([]agent.SkillEntry, 0, len(items)) + for _, item := range items { + skills = append(skills, agent.SkillEntry{ + Name: item.Name, + Description: item.Description, + Content: item.Content, + Metadata: item.Metadata, + }) + } + return agent.GenerateSystemPrompt(agent.SystemPromptParams{ + SessionType: "chat", + Skills: skills, + Now: time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC), + Timezone: "UTC", + }) +} + +func managedSkillRaw(name, description string) string { + return "---\nname: " + name + "\ndescription: " + description + "\n---\n\n# " + description + "\n" +} diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go new file mode 100644 index 00000000..69595965 --- /dev/null +++ b/internal/skills/skills_test.go @@ -0,0 +1,180 @@ +package skills + +import ( + "context" + "io" + "slices" + "strings" + "testing" + + 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/.openclaw/skills/alpha/SKILL.md", SourceKind: SourceKindCompat}, + {Name: "beta", SourcePath: "/data/.openclaw/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/.openclaw/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/.openclaw/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) + 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/.openclaw/skills", "alpha", "SKILL.md") + client.listings["/data/.openclaw/skills"] = []*pb.FileEntry{{Path: "alpha", IsDir: true}} + client.files[externalPath] = "---\nname: alpha\ndescription: Alpha\n---\n\n# Alpha" + + if err := ApplyAction(context.Background(), client, 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, 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 TestDiscoveryRootsStartWithManagedAndLegacy(t *testing.T) { + roots := DiscoveryRoots() + if len(roots) < 2 { + t.Fatalf("expected at least 2 discovery roots, got %d", len(roots)) + } + if roots[0].Path != ManagedDirPath || !roots[0].Managed { + t.Fatalf("first root = %+v, want managed %q", roots[0], ManagedDirPath) + } + if roots[1].Path != LegacyDirPath || roots[1].Managed { + t.Fatalf("second root = %+v, want legacy %q", roots[1], LegacyDirPath) + } +} + +func TestContainerEnvUsesDataHomeAndXDGDirs(t *testing.T) { + env := ContainerEnv() + for _, want := range []string{ + "HOME=/data", + "XDG_CONFIG_HOME=/data/.config", + "XDG_DATA_HOME=/data/.local/share", + "XDG_CACHE_HOME=/data/.cache", + } { + 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 +} + +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) { + 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, "/") +}