mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
0425cd4d77
* 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
1864 lines
56 KiB
Go
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()
|
|
}
|