Files
picoclaw/web/backend/api/skills_test.go
T
lxowalle 0425cd4d77 refactor skills registries and add GitHub-backed skill discovery (#2442)
* refactor skills registries and add GitHub-backed skill discovery

* fix ci

* fix command error

* fix default skills install registry behavior

* fix github registry URL parsing and versioned skill links

* fix skills registry config compatibility and URL installs

* * fix lint

* fix deprecated github base url compatibility

* fix skills registry yaml and github default branch handling

* fix github skills registry fallback and install metadata

* fix cli skills install origin metadata

* fix clawhub registry env compatibility

* fix skills registry config merge compatibility

* fix skill install metadata consistency and onboard template copy

* fix yaml overrides for default skills registries

* fix install_skill registry metadata normalization

* fix github skill URL parsing for slash branch names

* fix skills registry install/search validation and github URLs

* fix github skill URL host validation

* fix install_skill validation for invalid registry archives

* fix redundant skills registry names in saved config

* fix github blob skill URL installs and metadata links

* fix github registry URL scheme validation

* fix v0 skills migration preserving github registry defaults

* fix github blob skill install directory resolution

* fix install_skill rollback on origin metadata write failure

* fix github skill URL validation and registry JSON merging

* fix github registry target resolution and metadata links

* fix install_skill force reinstall rollback

* fix skills config compatibility and legacy security overlays

* fix ci
2026-04-14 15:14:16 +08:00

1864 lines
56 KiB
Go

package api
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/sipeed/picoclaw/pkg/config"
)
func setClawHubBaseURL(cfg *config.Config, baseURL string) {
registryCfg, _ := cfg.Tools.Skills.Registries.Get("clawhub")
registryCfg.BaseURL = baseURL
cfg.Tools.Skills.Registries.Set("clawhub", registryCfg)
}
func setGithubBaseURL(cfg *config.Config, baseURL string) {
registryCfg, ok := cfg.Tools.Skills.Registries.Get("github")
if !ok {
return
}
registryCfg.BaseURL = baseURL
cfg.Tools.Skills.Registries.Set("github", registryCfg)
}
func TestHandleListSkills(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
if err := os.MkdirAll(filepath.Join(workspace, "skills", "workspace-skill"), 0o755); err != nil {
t.Fatalf("MkdirAll(workspace skill) error = %v", err)
}
if err := os.WriteFile(
filepath.Join(workspace, "skills", "workspace-skill", "SKILL.md"),
[]byte("---\nname: workspace-skill\ndescription: Workspace skill\n---\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile(workspace skill) error = %v", err)
}
globalSkillDir := filepath.Join(globalConfigDir(), "skills", "global-skill")
if err := os.MkdirAll(globalSkillDir, 0o755); err != nil {
t.Fatalf("MkdirAll(global skill) error = %v", err)
}
if err := os.WriteFile(
filepath.Join(globalSkillDir, "SKILL.md"),
[]byte("---\nname: global-skill\ndescription: Global skill\n---\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile(global skill) error = %v", err)
}
builtinRoot := filepath.Join(t.TempDir(), "builtin-skills")
oldBuiltin := os.Getenv("PICOCLAW_BUILTIN_SKILLS")
if err := os.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot); err != nil {
t.Fatalf("Setenv(PICOCLAW_BUILTIN_SKILLS) error = %v", err)
}
defer func() {
if oldBuiltin == "" {
_ = os.Unsetenv("PICOCLAW_BUILTIN_SKILLS")
} else {
_ = os.Setenv("PICOCLAW_BUILTIN_SKILLS", oldBuiltin)
}
}()
builtinSkillDir := filepath.Join(builtinRoot, "builtin-skill")
if err := os.MkdirAll(builtinSkillDir, 0o755); err != nil {
t.Fatalf("MkdirAll(builtin skill) error = %v", err)
}
if err := os.WriteFile(
filepath.Join(builtinSkillDir, "SKILL.md"),
[]byte("---\nname: builtin-skill\ndescription: Builtin skill\n---\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile(builtin skill) error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillSupportResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Skills) != 3 {
t.Fatalf("skills count = %d, want 3", len(resp.Skills))
}
gotSkills := make(map[string]string, len(resp.Skills))
gotOriginKinds := make(map[string]string, len(resp.Skills))
for _, skill := range resp.Skills {
gotSkills[skill.Name] = skill.Source
gotOriginKinds[skill.Name] = skill.OriginKind
}
if gotSkills["workspace-skill"] != "workspace" {
t.Fatalf("workspace-skill source = %q, want workspace", gotSkills["workspace-skill"])
}
if gotSkills["global-skill"] != "global" {
t.Fatalf("global-skill source = %q, want global", gotSkills["global-skill"])
}
if gotSkills["builtin-skill"] != "builtin" {
t.Fatalf("builtin-skill source = %q, want builtin", gotSkills["builtin-skill"])
}
if gotOriginKinds["workspace-skill"] != "builtin" {
t.Fatalf("workspace-skill origin_kind = %q, want builtin", gotOriginKinds["workspace-skill"])
}
if gotOriginKinds["global-skill"] != "builtin" {
t.Fatalf("global-skill origin_kind = %q, want builtin", gotOriginKinds["global-skill"])
}
if gotOriginKinds["builtin-skill"] != "builtin" {
t.Fatalf("builtin-skill origin_kind = %q, want builtin", gotOriginKinds["builtin-skill"])
}
}
func TestHandleGetSkill(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
skillDir := filepath.Join(workspace, "skills", "viewer-skill")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte(
"---\nname: viewer-skill\ndescription: Viewable skill\n---\n# Viewer Skill\n\nThis is visible content.\n",
),
0o644,
); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills/viewer-skill", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillDetailResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if resp.Name != "viewer-skill" || resp.Source != "workspace" || resp.Description != "Viewable skill" {
t.Fatalf("unexpected response: %#v", resp)
}
if resp.OriginKind != "builtin" {
t.Fatalf("resp.OriginKind = %q, want builtin", resp.OriginKind)
}
if resp.Content != "# Viewer Skill\n\nThis is visible content.\n" {
t.Fatalf("content = %q", resp.Content)
}
}
func TestHandleGetSkillUsesResolvedPath(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
skillDir := filepath.Join(workspace, "skills", "folder-name")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("---\nname: display-name\ndescription: Mismatched path skill\n---\n# Display Name\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills/display-name", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillDetailResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if resp.Name != "display-name" {
t.Fatalf("resp.Name = %q, want display-name", resp.Name)
}
if resp.Content != "# Display Name\n" {
t.Fatalf("content = %q", resp.Content)
}
}
func TestHandleImportSkill(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "Plain Skill.md")
if err != nil {
t.Fatalf("CreateFormFile() error = %v", err)
}
_, err = io.WriteString(part, "# Plain Skill\n\nUse this skill to test imports.\n")
if err != nil {
t.Fatalf("WriteString() error = %v", err)
}
err = writer.Close()
if err != nil {
t.Fatalf("Close() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
skillFile := filepath.Join(workspace, "skills", "plain-skill", "SKILL.md")
content, err := os.ReadFile(skillFile)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
expected := "---\nname: plain-skill\ndescription: Plain Skill\n---\n\n# Plain Skill\n\nUse this skill to test imports.\n"
if string(content) != expected {
t.Fatalf("saved skill content mismatch:\n%s", string(content))
}
metaContent, err := os.ReadFile(filepath.Join(workspace, "skills", "plain-skill", ".skill-origin.json"))
if err != nil {
t.Fatalf("ReadFile(origin metadata) error = %v", err)
}
var originMeta installedSkillOriginMeta
if err := json.Unmarshal(metaContent, &originMeta); err != nil {
t.Fatalf("Unmarshal(origin metadata) error = %v", err)
}
if originMeta.OriginKind != "manual" {
t.Fatalf("originMeta.OriginKind = %q, want manual", originMeta.OriginKind)
}
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/api/skills", nil)
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("list status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String())
}
var listResp skillSupportResponse
if err := json.Unmarshal(rec2.Body.Bytes(), &listResp); err != nil {
t.Fatalf("Unmarshal list response error = %v", err)
}
found := false
for _, skill := range listResp.Skills {
if skill.Name == "plain-skill" && skill.Source == "workspace" && skill.Description == "Plain Skill" {
found = true
}
}
if !found {
t.Fatalf("plain-skill should be listed after import, got %#v", listResp.Skills)
}
}
func TestHandleImportSkillZip(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
zipContent := buildSkillZip(t, map[string]string{
"Wrapped Skill/SKILL.md": "---\nname: wrapped-skill\ndescription: Wrapped skill\n---\n# Wrapped Skill\n\nUse this skill from zip.\n",
"Wrapped Skill/docs/README.md": "# Extra file\n",
})
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, createErr := writer.CreateFormFile("file", "Wrapped Skill.zip")
if createErr != nil {
t.Fatalf("CreateFormFile() error = %v", createErr)
}
if _, writeErr := part.Write(zipContent); writeErr != nil {
t.Fatalf("Write(zipContent) error = %v", writeErr)
}
if closeErr := writer.Close(); closeErr != nil {
t.Fatalf("Close() error = %v", closeErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
skillDir := filepath.Join(workspace, "skills", "wrapped-skill")
skillFile := filepath.Join(skillDir, "SKILL.md")
content, err := os.ReadFile(skillFile)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
expected := "---\nname: wrapped-skill\ndescription: Wrapped skill\n---\n\n# Wrapped Skill\n\nUse this skill from zip.\n"
if string(content) != expected {
t.Fatalf("saved skill content mismatch:\n%s", string(content))
}
extraFile := filepath.Join(skillDir, "docs", "README.md")
extraContent, err := os.ReadFile(extraFile)
if err != nil {
t.Fatalf("ReadFile(extra file) error = %v", err)
}
if string(extraContent) != "# Extra file\n" {
t.Fatalf("extra file content = %q", string(extraContent))
}
}
func TestHandleImportSkillZipRejectsArchiveWithoutSkill(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
zipContent := buildSkillZip(t, map[string]string{
"README.md": "# Not a skill\n",
})
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "invalid.zip")
if err != nil {
t.Fatalf("CreateFormFile() error = %v", err)
}
if _, err := part.Write(zipContent); err != nil {
t.Fatalf("Write(zipContent) error = %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(workspace, "skills", "invalid")); !os.IsNotExist(err) {
t.Fatalf("invalid archive should not leave behind a skill dir, stat err=%v", err)
}
}
func TestHandleImportSkillRollsBackOnOriginMetadataWriteFailure(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
previousPersist := persistSkillOriginMeta
persistSkillOriginMeta = func(targetDir string, meta installedSkillOriginMeta) error {
return errors.New("forced metadata failure")
}
defer func() {
persistSkillOriginMeta = previousPersist
}()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "Rollback Skill.md")
if err != nil {
t.Fatalf("CreateFormFile() error = %v", err)
}
if _, err := io.WriteString(part, "# Rollback Skill\n"); err != nil {
t.Fatalf("WriteString() error = %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String())
}
skillDir := filepath.Join(workspace, "skills", "rollback-skill")
if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
t.Fatalf("skill directory should be removed after metadata write failure, stat err=%v", err)
}
}
func TestHandleDeleteSkill(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
skillDir := filepath.Join(workspace, "skills", "delete-me")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("---\nname: delete-me\ndescription: delete me\n---\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
t.Fatalf("skill directory should be removed, stat err=%v", err)
}
}
func TestHandleDeleteSkillPrefersWorkspaceMatch(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
homeDir := t.TempDir()
t.Setenv(config.EnvHome, homeDir)
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
workspaceSkillDir := filepath.Join(workspace, "skills", "delete-me-workspace")
if err := os.MkdirAll(workspaceSkillDir, 0o755); err != nil {
t.Fatalf("MkdirAll(workspace) error = %v", err)
}
if err := os.WriteFile(
filepath.Join(workspaceSkillDir, "SKILL.md"),
[]byte("---\nname: delete-me\ndescription: workspace delete me\n---\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile(workspace) error = %v", err)
}
globalSkillDir := filepath.Join(homeDir, "skills", "delete-me-global")
if err := os.MkdirAll(globalSkillDir, 0o755); err != nil {
t.Fatalf("MkdirAll(global) error = %v", err)
}
if err := os.WriteFile(
filepath.Join(globalSkillDir, "SKILL.md"),
[]byte("---\nname: delete-me\ndescription: global delete me\n---\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile(global) error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if _, err := os.Stat(workspaceSkillDir); !os.IsNotExist(err) {
t.Fatalf("workspace skill directory should be removed, stat err=%v", err)
}
if _, err := os.Stat(globalSkillDir); err != nil {
t.Fatalf("global skill directory should remain, stat err=%v", err)
}
}
func TestHandleSearchSkills(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
if err := os.MkdirAll(filepath.Join(workspace, "skills", "github"), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(
filepath.Join(workspace, "skills", "github", "SKILL.md"),
[]byte("---\nname: github\ndescription: Installed GitHub skill\n---\n# GitHub\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/search" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("q"); got != "github" {
t.Fatalf("query = %q, want github", got)
}
json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{
{
"score": 0.95,
"slug": "github",
"displayName": "GitHub",
"summary": "GitHub integration skill",
"version": "1.2.3",
},
{
"score": 0.87,
"slug": "jira",
"displayName": "Jira",
"summary": "Issue tracker skill",
"version": "0.9.0",
},
},
})
}))
defer server.Close()
setClawHubBaseURL(cfg, server.URL)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=5", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillSearchResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if resp.Limit != 5 {
t.Fatalf("limit = %d, want 5", resp.Limit)
}
if resp.Offset != 0 {
t.Fatalf("offset = %d, want 0", resp.Offset)
}
if resp.HasMore {
t.Fatalf("has_more = true, want false")
}
if len(resp.Results) != 2 {
t.Fatalf("results count = %d, want 2", len(resp.Results))
}
if resp.Results[0].URL != server.URL+"/skills/github" {
t.Fatalf("first result URL = %q, want %q", resp.Results[0].URL, server.URL+"/skills/github")
}
if !resp.Results[0].Installed || resp.Results[0].InstalledName != "github" {
t.Fatalf("first result should be treated as occupying the workspace slug, got %#v", resp.Results[0])
}
if resp.Results[1].Installed {
t.Fatalf("second result should not be installed, got %#v", resp.Results[1])
}
}
func TestHandleSearchSkillsUsesGitHubResultVersionInURL(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/search/code" {
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{
"path": "skills/pr-review/SKILL.md",
"score": 10,
"repository": map[string]any{
"full_name": "foo/bar",
"name": "bar",
"description": "Review pull requests",
"default_branch": "master",
},
},
},
})
}))
defer server.Close()
setGithubBaseURL(cfg, server.URL)
clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub")
clawHubRegistry.Enabled = false
cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillSearchResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Results) != 1 {
t.Fatalf("results count = %d, want 1", len(resp.Results))
}
if resp.Results[0].URL != server.URL+"/foo/bar/tree/master/skills/pr-review" {
t.Fatalf("result URL = %q", resp.Results[0].URL)
}
}
func TestHandleSearchSkillsGitHubRateLimitDegradesGracefully(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/search/code" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"message":"API rate limit exceeded for 1.2.3.4"}`))
}))
defer server.Close()
setGithubBaseURL(cfg, server.URL)
clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub")
clawHubRegistry.Enabled = false
cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillSearchResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Results) != 0 {
t.Fatalf("results count = %d, want 0", len(resp.Results))
}
}
func TestHandleSearchSkillsPagination(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/search" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("limit"); got != "5" {
t.Fatalf("limit = %q, want 5", got)
}
json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{
{
"score": 0.99,
"slug": "skill-1",
"displayName": "Skill 1",
"summary": "Summary 1",
"version": "1.0.0",
},
{
"score": 0.98,
"slug": "skill-2",
"displayName": "Skill 2",
"summary": "Summary 2",
"version": "1.0.0",
},
{
"score": 0.97,
"slug": "skill-3",
"displayName": "Skill 3",
"summary": "Summary 3",
"version": "1.0.0",
},
{
"score": 0.96,
"slug": "skill-4",
"displayName": "Skill 4",
"summary": "Summary 4",
"version": "1.0.0",
},
},
})
}))
defer server.Close()
setClawHubBaseURL(cfg, server.URL)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=2&offset=2", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillSearchResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if resp.Limit != 2 {
t.Fatalf("limit = %d, want 2", resp.Limit)
}
if resp.Offset != 2 {
t.Fatalf("offset = %d, want 2", resp.Offset)
}
if resp.HasMore {
t.Fatalf("has_more = true, want false")
}
if len(resp.Results) != 2 {
t.Fatalf("results count = %d, want 2", len(resp.Results))
}
if resp.Results[0].Slug != "skill-3" || resp.Results[1].Slug != "skill-4" {
t.Fatalf("unexpected paged results: %#v", resp.Results)
}
if resp.NextOffset != 0 {
t.Fatalf("next_offset = %d, want 0", resp.NextOffset)
}
}
func TestHandleSearchSkillsClampsRegistryFanout(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/search" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("limit"); got != strconv.Itoa(maxRegistrySearchFanout) {
t.Fatalf("limit = %q, want %d", got, maxRegistrySearchFanout)
}
json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{
{
"score": 0.99,
"slug": "skill-1",
"displayName": "Skill 1",
"summary": "Summary 1",
"version": "1.0.0",
},
},
})
}))
defer server.Close()
setClawHubBaseURL(cfg, server.URL)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=20&offset=100000", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillSearchResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Results) != 0 {
t.Fatalf("results count = %d, want 0", len(resp.Results))
}
}
func TestHandleInstallSkill(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
zipContent := buildSkillZip(t, map[string]string{
"SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n\nUse this skill.\n",
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/search":
json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{
{
"score": 0.95,
"slug": "github",
"displayName": "GitHub",
"summary": "GitHub registry skill",
"version": "1.2.3",
},
},
})
case "/api/v1/skills/github":
json.NewEncoder(w).Encode(map[string]any{
"slug": "github",
"displayName": "GitHub",
"summary": "GitHub registry skill",
"latestVersion": map[string]any{
"version": "1.2.3",
},
"moderation": map[string]any{
"isMalwareBlocked": false,
"isSuspicious": false,
},
})
case "/api/v1/download":
if got := r.URL.Query().Get("slug"); got != "github" {
t.Fatalf("slug = %q, want github", got)
}
if got := r.URL.Query().Get("version"); got != "1.2.3" {
t.Fatalf("version = %q, want 1.2.3", got)
}
w.Header().Set("Content-Type", "application/zip")
_, _ = w.Write(zipContent)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
setClawHubBaseURL(cfg, server.URL)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
body, err := json.Marshal(installSkillRequest{
Slug: "github",
Registry: "clawhub",
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp installSkillResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if resp.Status != "ok" || resp.Version != "1.2.3" || resp.InstalledSkill == nil {
t.Fatalf("unexpected response: %#v", resp)
}
if resp.InstalledSkill.OriginKind != "third_party" {
t.Fatalf("resp.InstalledSkill.OriginKind = %q, want third_party", resp.InstalledSkill.OriginKind)
}
if resp.InstalledSkill.RegistryURL != server.URL+"/skills/github" {
t.Fatalf(
"resp.InstalledSkill.RegistryURL = %q, want %q",
resp.InstalledSkill.RegistryURL,
server.URL+"/skills/github",
)
}
skillFile := filepath.Join(workspace, "skills", "github", "SKILL.md")
if _, err := os.Stat(skillFile); err != nil {
t.Fatalf("installed skill file missing: %v", err)
}
if _, err := os.Stat(filepath.Join(workspace, "skills", "github", ".skill-origin.json")); err != nil {
t.Fatalf("origin metadata missing: %v", err)
}
detailRec := httptest.NewRecorder()
detailReq := httptest.NewRequest(http.MethodGet, "/api/skills/github", nil)
mux.ServeHTTP(detailRec, detailReq)
if detailRec.Code != http.StatusOK {
t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusOK, detailRec.Body.String())
}
var detailResp skillDetailResponse
if err := json.Unmarshal(detailRec.Body.Bytes(), &detailResp); err != nil {
t.Fatalf("Unmarshal(detail response) error = %v", err)
}
if detailResp.RegistryURL != server.URL+"/skills/github" {
t.Fatalf("detailResp.RegistryURL = %q, want %q", detailResp.RegistryURL, server.URL+"/skills/github")
}
searchRec := httptest.NewRecorder()
searchReq := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=5", nil)
mux.ServeHTTP(searchRec, searchReq)
if searchRec.Code != http.StatusOK {
t.Fatalf("search status = %d, want %d, body=%s", searchRec.Code, http.StatusOK, searchRec.Body.String())
}
var searchResp skillSearchResponse
if err := json.Unmarshal(searchRec.Body.Bytes(), &searchResp); err != nil {
t.Fatalf("Unmarshal(search response) error = %v", err)
}
if len(searchResp.Results) != 1 {
t.Fatalf("search results count = %d, want 1", len(searchResp.Results))
}
if !searchResp.Results[0].Installed || searchResp.Results[0].InstalledName != "github" {
t.Fatalf("search result should be treated as installed after registry install, got %#v", searchResp.Results[0])
}
}
func TestHandleInstallSkillForcePreservesExistingSkillOnFailure(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
skillDir := filepath.Join(workspace, "skills", "github")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
oldContent := []byte("---\nname: github\ndescription: Existing skill\n---\n# Existing\n")
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), oldContent, 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/skills/github":
json.NewEncoder(w).Encode(map[string]any{
"slug": "github",
"displayName": "GitHub",
"summary": "GitHub registry skill",
"latestVersion": map[string]any{
"version": "1.2.3",
},
"moderation": map[string]any{
"isMalwareBlocked": false,
"isSuspicious": false,
},
})
case "/api/v1/download":
http.Error(w, "upstream download failed", http.StatusBadGateway)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
setClawHubBaseURL(cfg, server.URL)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
body, err := json.Marshal(installSkillRequest{
Slug: "github",
Registry: "clawhub",
Force: true,
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadGateway, rec.Body.String())
}
gotContent, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if !bytes.Equal(gotContent, oldContent) {
t.Fatalf("existing skill should remain unchanged, got:\n%s", string(gotContent))
}
}
func TestHandleInstallSkillDefaultsRegistryToGitHub(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/repos/foo/bar":
json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"})
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
assert.Equal(t, "ref=master", r.URL.RawQuery)
json.NewEncoder(w).Encode([]map[string]any{
{
"type": "file",
"name": "SKILL.md",
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
},
})
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
_, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
if !ok {
t.Fatalf("github registry missing from default config")
}
githubRegistry.BaseURL = server.URL
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
body, err := json.Marshal(installSkillRequest{
Slug: "foo/bar/.agents/skills/pr-review",
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp installSkillResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if resp.Registry != "github" {
t.Fatalf("resp.Registry = %q, want github", resp.Registry)
}
}
func TestHandleInstallSkillTracksGitHubURLInstallsAsInstalled(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/repos/foo/bar":
json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"})
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
assert.Equal(t, "ref=master", r.URL.RawQuery)
json.NewEncoder(w).Encode([]map[string]any{{
"type": "file",
"name": "SKILL.md",
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
}})
case "/api/v3/search/code":
json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{{
"path": ".agents/skills/pr-review/SKILL.md",
"score": 10,
"repository": map[string]any{
"full_name": "foo/bar",
"name": "bar",
"description": "PR review skill",
"default_branch": "master",
},
}},
})
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
_, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
setGithubBaseURL(cfg, server.URL)
clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub")
clawHubRegistry.Enabled = false
cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
installBody, err := json.Marshal(installSkillRequest{
Slug: server.URL + "/foo/bar/tree/master/.agents/skills/pr-review",
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
installRec := httptest.NewRecorder()
installReq := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(installBody))
installReq.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(installRec, installReq)
if installRec.Code != http.StatusOK {
t.Fatalf("install status = %d, want %d, body=%s", installRec.Code, http.StatusOK, installRec.Body.String())
}
searchRec := httptest.NewRecorder()
searchReq := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
mux.ServeHTTP(searchRec, searchReq)
if searchRec.Code != http.StatusOK {
t.Fatalf("search status = %d, want %d, body=%s", searchRec.Code, http.StatusOK, searchRec.Body.String())
}
var searchResp skillSearchResponse
if err := json.Unmarshal(searchRec.Body.Bytes(), &searchResp); err != nil {
t.Fatalf("Unmarshal(search response) error = %v", err)
}
if len(searchResp.Results) != 1 {
t.Fatalf("search results count = %d, want 1", len(searchResp.Results))
}
if !searchResp.Results[0].Installed || searchResp.Results[0].InstalledName != "pr-review" {
t.Fatalf("search result should be treated as installed after URL install, got %#v", searchResp.Results[0])
}
}
func TestHandleSearchSkillsMarksDirectoryCollisionAsInstalled(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
skillDir := filepath.Join(workspace, "skills", "pr-review")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("---\nname: pr-review\ndescription: Workspace PR review skill\n---\n# PR Review\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile(SKILL.md) error = %v", err)
}
if err := writeSkillOriginMeta(skillDir, installedSkillOriginMeta{
Version: 1,
OriginKind: "third_party",
Registry: "github",
Slug: "foo/bar/.agents/skills/pr-review",
RegistryURL: "https://github.com/foo/bar/tree/master/.agents/skills/pr-review",
InstalledVersion: "master",
InstalledAt: time.Now().UnixMilli(),
}); err != nil {
t.Fatalf("writeSkillOriginMeta() error = %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/search":
json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"slug": "pr-review",
"displayName": "PR Review",
"summary": "ClawHub PR review skill",
"version": "1.2.3",
}},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
setClawHubBaseURL(cfg, server.URL)
githubRegistry, _ := cfg.Tools.Skills.Registries.Get("github")
githubRegistry.Enabled = false
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillSearchResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Results) != 1 {
t.Fatalf("results count = %d, want 1", len(resp.Results))
}
if !resp.Results[0].Installed || resp.Results[0].InstalledName != "pr-review" {
t.Fatalf("search result should be treated as installed when directory is occupied, got %#v", resp.Results[0])
}
}
func TestHandleInstallSkillRollsBackOnOriginMetadataWriteFailure(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
zipContent := buildSkillZip(t, map[string]string{
"SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n",
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/skills/github":
json.NewEncoder(w).Encode(map[string]any{
"slug": "github",
"displayName": "GitHub",
"summary": "GitHub registry skill",
"latestVersion": map[string]any{
"version": "1.2.3",
},
"moderation": map[string]any{
"isMalwareBlocked": false,
"isSuspicious": false,
},
})
case "/api/v1/download":
w.Header().Set("Content-Type", "application/zip")
_, _ = w.Write(zipContent)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
setClawHubBaseURL(cfg, server.URL)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
previousPersist := persistSkillOriginMeta
persistSkillOriginMeta = func(targetDir string, meta installedSkillOriginMeta) error {
return errors.New("forced metadata failure")
}
defer func() {
persistSkillOriginMeta = previousPersist
}()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
body, err := json.Marshal(installSkillRequest{
Slug: "github",
Registry: "clawhub",
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String())
}
skillDir := filepath.Join(workspace, "skills", "github")
if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
t.Fatalf("skill directory should be removed after metadata write failure, stat err=%v", err)
}
}
func TestHandleInstallSkillSerializesConcurrentRequests(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
zipContent := buildSkillZip(t, map[string]string{
"SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n",
})
downloadStarted := make(chan struct{}, 2)
releaseFirstDownload := make(chan struct{})
downloadCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/skills/github":
json.NewEncoder(w).Encode(map[string]any{
"slug": "github",
"displayName": "GitHub",
"summary": "GitHub registry skill",
"latestVersion": map[string]any{
"version": "1.2.3",
},
"moderation": map[string]any{
"isMalwareBlocked": false,
"isSuspicious": false,
},
})
case "/api/v1/download":
downloadCount++
downloadStarted <- struct{}{}
if downloadCount == 1 {
<-releaseFirstDownload
}
w.Header().Set("Content-Type", "application/zip")
_, _ = w.Write(zipContent)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
setClawHubBaseURL(cfg, server.URL)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
body, err := json.Marshal(installSkillRequest{
Slug: "github",
Registry: "clawhub",
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
type installResult struct {
code int
body string
}
results := make(chan installResult, 2)
startInstall := func() {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
results <- installResult{
code: rec.Code,
body: rec.Body.String(),
}
}
go startInstall()
select {
case <-downloadStarted:
case <-time.After(time.Second):
t.Fatal("timed out waiting for first install download to start")
}
go startInstall()
select {
case <-downloadStarted:
t.Fatal("second install should not reach registry download before the first request completes")
case <-time.After(200 * time.Millisecond):
}
close(releaseFirstDownload)
firstResult := <-results
secondResult := <-results
codes := map[int]int{
firstResult.code: 1,
secondResult.code: 1,
}
if codes[http.StatusOK] != 1 || codes[http.StatusConflict] != 1 {
t.Fatalf(
"unexpected install results: first=(%d, %q) second=(%d, %q)",
firstResult.code,
firstResult.body,
secondResult.code,
secondResult.body,
)
}
}
func TestHandleImportSkillWaitsForConcurrentInstall(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
zipContent := buildSkillZip(t, map[string]string{
"SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n",
})
downloadStarted := make(chan struct{}, 1)
releaseDownload := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/skills/github":
json.NewEncoder(w).Encode(map[string]any{
"slug": "github",
"displayName": "GitHub",
"summary": "GitHub registry skill",
"latestVersion": map[string]any{
"version": "1.2.3",
},
"moderation": map[string]any{
"isMalwareBlocked": false,
"isSuspicious": false,
},
})
case "/api/v1/download":
downloadStarted <- struct{}{}
<-releaseDownload
w.Header().Set("Content-Type", "application/zip")
_, _ = w.Write(zipContent)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
setClawHubBaseURL(cfg, server.URL)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
installBody, err := json.Marshal(installSkillRequest{
Slug: "github",
Registry: "clawhub",
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
type result struct {
code int
body string
}
installResults := make(chan result, 1)
importResults := make(chan result, 1)
go func() {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(installBody))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
installResults <- result{code: rec.Code, body: rec.Body.String()}
}()
select {
case <-downloadStarted:
case <-time.After(time.Second):
t.Fatal("timed out waiting for install download to start")
}
var importBody bytes.Buffer
writer := multipart.NewWriter(&importBody)
part, err := writer.CreateFormFile("file", "github.md")
if err != nil {
t.Fatalf("CreateFormFile() error = %v", err)
}
if _, err := io.WriteString(part, "# GitHub\n"); err != nil {
t.Fatalf("WriteString() error = %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
go func() {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &importBody)
req.Header.Set("Content-Type", writer.FormDataContentType())
mux.ServeHTTP(rec, req)
importResults <- result{code: rec.Code, body: rec.Body.String()}
}()
select {
case got := <-importResults:
t.Fatalf("import should wait for the install lock, got early response (%d, %q)", got.code, got.body)
case <-time.After(200 * time.Millisecond):
}
close(releaseDownload)
installResult := <-installResults
importResult := <-importResults
if installResult.code != http.StatusOK {
t.Fatalf("install status = %d, want %d, body=%s", installResult.code, http.StatusOK, installResult.body)
}
if importResult.code != http.StatusConflict {
t.Fatalf("import status = %d, want %d, body=%s", importResult.code, http.StatusConflict, importResult.body)
}
}
func TestHandleInstallSkillRejectsInvalidArchive(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
zipContent := buildSkillZip(t, map[string]string{
"README.md": "# Not a skill\n",
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/skills/github":
json.NewEncoder(w).Encode(map[string]any{
"slug": "github",
"displayName": "GitHub",
"summary": "GitHub registry skill",
"latestVersion": map[string]any{
"version": "1.2.3",
},
"moderation": map[string]any{
"isMalwareBlocked": false,
"isSuspicious": false,
},
})
case "/api/v1/download":
w.Header().Set("Content-Type", "application/zip")
_, _ = w.Write(zipContent)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
setClawHubBaseURL(cfg, server.URL)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
body, err := json.Marshal(installSkillRequest{
Slug: "github",
Registry: "clawhub",
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadGateway, rec.Body.String())
}
skillDir := filepath.Join(workspace, "skills", "github")
if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
t.Fatalf("invalid installed archive should be removed, stat err=%v", err)
}
}
func buildSkillZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
zipWriter := zip.NewWriter(&buf)
for name, content := range files {
writer, err := zipWriter.Create(name)
if err != nil {
t.Fatalf("Create(%q) error = %v", name, err)
}
if _, err := io.WriteString(writer, content); err != nil {
t.Fatalf("WriteString(%q) error = %v", name, err)
}
}
if err := zipWriter.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
return buf.Bytes()
}