/ internal / skills / marketplace.go
marketplace.go
  1  package skills
  2  
  3  import (
  4  	"archive/zip"
  5  	"bytes"
  6  	"context"
  7  	"encoding/json"
  8  	"errors"
  9  	"fmt"
 10  	"io"
 11  	"io/fs"
 12  	"net/http"
 13  	"os"
 14  	"os/exec"
 15  	"path/filepath"
 16  	"sort"
 17  	"strings"
 18  	"sync"
 19  	"time"
 20  )
 21  
 22  // marketplaceDownloadClient is the HTTP client used for zip-transport
 23  // installs. Separate from the MarketplaceClient's registry client so
 24  // downloads can tolerate slower upstream responses, but still has a
 25  // 2-minute ceiling as a safety floor if the caller's context has no
 26  // deadline.
 27  var marketplaceDownloadClient = &http.Client{Timeout: 2 * time.Minute}
 28  
 29  // runGitCtx is a context-aware variant of runGit from api.go. Lets
 30  // InstallFromMarketplace cancel in-flight clones when the request
 31  // context is canceled.
 32  func runGitCtx(ctx context.Context, dir string, args ...string) error {
 33  	cmd := exec.CommandContext(ctx, "git", args...)
 34  	cmd.Dir = dir
 35  	out, err := cmd.CombinedOutput()
 36  	if err != nil {
 37  		return fmt.Errorf("%s: %s", err, strings.TrimSpace(string(out)))
 38  	}
 39  	return nil
 40  }
 41  
 42  // RegistryIndex is the top-level JSON document served by the marketplace
 43  // registry repo. Field names match the schema in
 44  // docs/superpowers/specs/2026-04-06-skill-marketplace-design.md.
 45  type RegistryIndex struct {
 46  	Version   int                `json:"version"`
 47  	UpdatedAt string             `json:"updated_at"`
 48  	Skills    []MarketplaceEntry `json:"skills"`
 49  }
 50  
 51  // MarketplaceEntry is one skill listing in the registry.
 52  //
 53  // Transport: either Repo (git clone) or DownloadURL (HTTP zip) must be set.
 54  // The git path is the primary transport for skills that have a public
 55  // source repository. DownloadURL is the fallback for ClawHub skills that
 56  // exist only as zip artifacts served by ClawHub's Convex backend — there
 57  // is no GitHub repo to clone, so we fetch the zip and extract it
 58  // directly into the skills directory.
 59  type MarketplaceEntry struct {
 60  	Slug        string       `json:"slug"`
 61  	Name        string       `json:"name"`
 62  	Description string       `json:"description"`
 63  	Author      string       `json:"author"`
 64  	License     string       `json:"license,omitempty"`
 65  	Repo        string       `json:"repo,omitempty"`
 66  	RepoPath    string       `json:"repo_path,omitempty"`
 67  	Ref         string       `json:"ref,omitempty"`
 68  	DownloadURL string       `json:"download_url,omitempty"`
 69  	Homepage    string       `json:"homepage,omitempty"`
 70  	Downloads   int          `json:"downloads,omitempty"`
 71  	Stars       int          `json:"stars,omitempty"`
 72  	Version     string       `json:"version,omitempty"`
 73  	Security    SecurityScan `json:"security,omitempty"`
 74  	Tags        []string     `json:"tags,omitempty"`
 75  }
 76  
 77  // SecurityScan mirrors the scan results published by ClawHub.
 78  // Empty strings mean "not scanned" and render as a neutral badge.
 79  type SecurityScan struct {
 80  	VirusTotal string `json:"virustotal,omitempty"`
 81  	OpenClaw   string `json:"openclaw,omitempty"`
 82  	ScannedAt  string `json:"scanned_at,omitempty"`
 83  }
 84  
 85  // IsMalicious returns true when any scanner flagged the entry as malicious.
 86  // Used as a server-side gate in both the list and install endpoints.
 87  func (e MarketplaceEntry) IsMalicious() bool {
 88  	return e.Security.VirusTotal == "malicious" || e.Security.OpenClaw == "malicious"
 89  }
 90  
 91  // defaultStaleCooldown is the minimum gap between upstream refetch
 92  // attempts once we've started serving stale. Without it, every Load
 93  // during a registry outage would re-hit the remote and turn normal UI
 94  // traffic into a retry storm.
 95  const defaultStaleCooldown = 1 * time.Minute
 96  
 97  // MarketplaceClient fetches and caches the registry index.
 98  //
 99  // Caching rules (see design doc §Registry Cache):
100  //   - First fetch populates the in-memory cache.
101  //   - Subsequent calls within TTL return the cached copy.
102  //   - After TTL expires, the next call refetches; on fetch failure the
103  //     previous cache is served as stale (IsStale() returns true) and a
104  //     retry cooldown is set so further Loads keep serving stale without
105  //     hammering the upstream.
106  //   - If no cache exists and fetch fails, Load returns the error.
107  type MarketplaceClient struct {
108  	url  string
109  	ttl  time.Duration
110  	http *http.Client
111  
112  	// staleCooldown bounds how often we re-attempt an upstream fetch
113  	// while in stale mode. Exposed as a field (not a constructor arg)
114  	// so tests can set a short cooldown directly.
115  	staleCooldown time.Duration
116  
117  	mu         sync.Mutex
118  	cache      *RegistryIndex
119  	fetched    time.Time
120  	stale      bool
121  	retryAfter time.Time
122  }
123  
124  // NewMarketplaceClient constructs a client with the given registry URL and
125  // cache TTL. A TTL of 0 forces every call to refetch (used by stale-on-error
126  // tests and by operators who explicitly disable caching).
127  func NewMarketplaceClient(url string, ttl time.Duration) *MarketplaceClient {
128  	return &MarketplaceClient{
129  		url:           url,
130  		ttl:           ttl,
131  		http:          &http.Client{Timeout: 15 * time.Second},
132  		staleCooldown: defaultStaleCooldown,
133  	}
134  }
135  
136  // Load returns the current registry index, refetching when the cache is
137  // empty or past TTL. See the type doc for stale-on-error semantics.
138  func (c *MarketplaceClient) Load(ctx context.Context) (*RegistryIndex, error) {
139  	c.mu.Lock()
140  	defer c.mu.Unlock()
141  
142  	// Fresh cache → return immediately.
143  	if c.cache != nil && time.Since(c.fetched) < c.ttl {
144  		c.stale = false
145  		return c.cache, nil
146  	}
147  
148  	// Stale-mode cooldown in effect → keep serving stale without
149  	// re-attempting the upstream fetch. Prevents retry storms during
150  	// registry outages.
151  	if c.cache != nil && !c.retryAfter.IsZero() && time.Now().Before(c.retryAfter) {
152  		c.stale = true
153  		return c.cache, nil
154  	}
155  
156  	idx, err := c.fetch(ctx)
157  	if err != nil {
158  		if c.cache != nil {
159  			c.stale = true
160  			c.retryAfter = time.Now().Add(c.staleCooldown)
161  			return c.cache, nil
162  		}
163  		return nil, err
164  	}
165  
166  	c.cache = idx
167  	c.fetched = time.Now()
168  	c.stale = false
169  	c.retryAfter = time.Time{}
170  	return c.cache, nil
171  }
172  
173  // IsStale reports whether the most recent Load served a stale cache because
174  // the upstream fetch failed. Handlers use this to set an X-Cache-Stale header.
175  func (c *MarketplaceClient) IsStale() bool {
176  	c.mu.Lock()
177  	defer c.mu.Unlock()
178  	return c.stale
179  }
180  
181  // Sentinel errors so daemon handlers can map to exact HTTP statuses without
182  // parsing message strings.
183  var (
184  	ErrSkillAlreadyInstalled      = errors.New("skill already installed")
185  	ErrMaliciousSkill             = errors.New("skill blocked by security scan")
186  	ErrInvalidSkillPayload        = errors.New("invalid skill payload")
187  	ErrMarketplaceUpstreamFailure = errors.New("marketplace upstream failure")
188  )
189  
190  // InstallFromMarketplace runs the full install flow for a marketplace entry.
191  // Dispatches to the git transport (clone → stage) when entry.Repo is set,
192  // or the zip transport (HTTP GET → extract) when entry.DownloadURL is set.
193  // Both paths share the same validation rules, slug lock, sentinel errors,
194  // and cleanup guarantees.
195  //
196  // ctx is propagated into the transport layer: git clone runs under
197  // exec.CommandContext, zip downloads run under an http.Request with the
198  // same context. Cancellation aborts the in-flight operation and cleans
199  // up staging dirs on the way out.
200  //
201  // Steps common to both transports:
202  //  1. Validate slug.
203  //  2. Security gate (malicious → ErrMaliciousSkill).
204  //  3. Per-slug lock (serializes concurrent installs for the same slug).
205  //  4. Already-installed check (→ ErrSkillAlreadyInstalled).
206  //  5. Transport-specific payload acquisition into a stage directory.
207  //  6. Verify SKILL.md exists and parses; verify frontmatter name == slug.
208  //  7. Atomic rename stage → ~/.shannon/skills/<slug>/.
209  //
210  // All failures clean up temp directories. No partial installs ever remain.
211  func InstallFromMarketplace(ctx context.Context, shannonDir string, entry MarketplaceEntry, locks *SlugLocks) error {
212  	if err := ValidateSkillName(entry.Slug); err != nil {
213  		return err
214  	}
215  	if entry.IsMalicious() {
216  		return ErrMaliciousSkill
217  	}
218  	if entry.Repo == "" && entry.DownloadURL == "" {
219  		return fmt.Errorf("%w: entry has no transport (need repo or download_url)", ErrInvalidSkillPayload)
220  	}
221  
222  	unlock := locks.Lock(entry.Slug)
223  	defer unlock()
224  
225  	destDir := filepath.Join(shannonDir, "skills", entry.Slug)
226  	if _, err := os.Stat(filepath.Join(destDir, "SKILL.md")); err == nil {
227  		return ErrSkillAlreadyInstalled
228  	}
229  
230  	tmpRoot := filepath.Join(shannonDir, "tmp")
231  	if err := os.MkdirAll(tmpRoot, 0700); err != nil {
232  		return fmt.Errorf("create tmp root: %w", err)
233  	}
234  
235  	stageDir, err := os.MkdirTemp(tmpRoot, "skill-stage-"+entry.Slug+"-*")
236  	if err != nil {
237  		return fmt.Errorf("create stage dir: %w", err)
238  	}
239  	// stageDir is removed on failure; on success we rename it away so
240  	// the RemoveAll is a no-op.
241  	defer os.RemoveAll(stageDir)
242  
243  	// Transport dispatch: git path clones via exec.CommandContext so
244  	// cancellation aborts in-flight clones; zip path passes ctx to the
245  	// http.Request so cancellation aborts in-flight downloads.
246  	if entry.Repo != "" {
247  		if err := installFromGit(ctx, entry, stageDir, tmpRoot); err != nil {
248  			return err
249  		}
250  	} else {
251  		// Remove the empty stageDir MkdirTemp created; extractZipToSkill
252  		// recreates it inside its own cleanup guarantees.
253  		os.RemoveAll(stageDir)
254  		if err := installFromZip(ctx, entry, stageDir); err != nil {
255  			return err
256  		}
257  	}
258  
259  	// Verify SKILL.md exists and matches the declared slug. Same rules
260  	// apply regardless of transport.
261  	skillFile := filepath.Join(stageDir, "SKILL.md")
262  	if _, err := os.Stat(skillFile); err != nil {
263  		return fmt.Errorf("%w: SKILL.md missing at stage dir", ErrInvalidSkillPayload)
264  	}
265  	// loadSkillMD passes dirName=entry.Slug and enforces that the zip's
266  	// canonical identity (frontmatter `slug` when present, else `name`)
267  	// matches — so a separate `parsed.Name != entry.Slug` check here would
268  	// just duplicate that invariant.
269  	if _, err := loadSkillMD(skillFile, entry.Slug, "marketplace"); err != nil {
270  		return fmt.Errorf("%w: %v", ErrInvalidSkillPayload, err)
271  	}
272  	if err := writeMarketplaceProvenance(stageDir, entry.Slug); err != nil {
273  		return fmt.Errorf("write marketplace provenance: %w", err)
274  	}
275  
276  	// Atomic rename into place.
277  	if err := os.MkdirAll(filepath.Dir(destDir), 0700); err != nil {
278  		return fmt.Errorf("create skills dir: %w", err)
279  	}
280  	if err := os.Rename(stageDir, destDir); err != nil {
281  		return fmt.Errorf("install rename: %w", err)
282  	}
283  
284  	return nil
285  }
286  
287  // installFromGit clones entry.Repo into a temp dir, selects the right
288  // subtree (entry.RepoPath or the clone root), and stages a clean copy
289  // into stageDir. Git subprocesses run under ctx so cancellation
290  // propagates. Payload-level validation errors (symlink, walk failure)
291  // are wrapped as ErrInvalidSkillPayload so the handler maps them to
292  // 422, matching the design doc's error matrix.
293  func installFromGit(ctx context.Context, entry MarketplaceEntry, stageDir, tmpRoot string) error {
294  	cloneDir, err := os.MkdirTemp(tmpRoot, "skill-clone-"+entry.Slug+"-*")
295  	if err != nil {
296  		return fmt.Errorf("create clone dir: %w", err)
297  	}
298  	defer os.RemoveAll(cloneDir)
299  
300  	ref := entry.Ref
301  	if ref == "" {
302  		ref = "main"
303  	}
304  	if entry.RepoPath == "" {
305  		if err := runGitCtx(ctx, cloneDir, "clone", "--depth=1", "--branch", ref, entry.Repo, "."); err != nil {
306  			return fmt.Errorf("%w: git clone: %v", ErrMarketplaceUpstreamFailure, err)
307  		}
308  	} else {
309  		if err := runGitCtx(ctx, cloneDir, "clone", "--depth=1", "--filter=blob:none", "--sparse", "--branch", ref, entry.Repo, "."); err != nil {
310  			return fmt.Errorf("%w: git clone: %v", ErrMarketplaceUpstreamFailure, err)
311  		}
312  		if err := runGitCtx(ctx, cloneDir, "sparse-checkout", "set", entry.RepoPath); err != nil {
313  			return fmt.Errorf("%w: git sparse-checkout: %v", ErrMarketplaceUpstreamFailure, err)
314  		}
315  	}
316  
317  	srcDir := cloneDir
318  	if entry.RepoPath != "" {
319  		srcDir = filepath.Join(cloneDir, entry.RepoPath)
320  	}
321  
322  	// Remove the empty stageDir MkdirTemp created before calling this
323  	// function; stageCleanPayload recreates it.
324  	os.RemoveAll(stageDir)
325  	if err := stageCleanPayload(srcDir, stageDir); err != nil {
326  		// Payload-level failures (symlinks, walk errors) are client-
327  		// visible invalid payloads, not upstream or internal errors.
328  		return fmt.Errorf("%w: %v", ErrInvalidSkillPayload, err)
329  	}
330  	return nil
331  }
332  
333  // installFromZip fetches entry.DownloadURL and extracts it into stageDir
334  // via extractZipToSkill. HTTP failures surface as
335  // ErrMarketplaceUpstreamFailure so the handler maps to 502. Uses the
336  // caller's ctx directly so client disconnect or daemon shutdown aborts
337  // the in-flight download. marketplaceDownloadClient provides a 2-minute
338  // safety ceiling when ctx has no deadline.
339  func installFromZip(ctx context.Context, entry MarketplaceEntry, stageDir string) error {
340  	req, err := http.NewRequestWithContext(ctx, "GET", entry.DownloadURL, nil)
341  	if err != nil {
342  		return fmt.Errorf("%w: build download request: %v", ErrMarketplaceUpstreamFailure, err)
343  	}
344  	resp, err := marketplaceDownloadClient.Do(req)
345  	if err != nil {
346  		return fmt.Errorf("%w: download: %v", ErrMarketplaceUpstreamFailure, err)
347  	}
348  	defer resp.Body.Close()
349  	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
350  		return fmt.Errorf("%w: download status %d", ErrMarketplaceUpstreamFailure, resp.StatusCode)
351  	}
352  
353  	if err := extractZipToSkill(resp.Body, stageDir); err != nil {
354  		// Payload-level failures (symlink, zip-slip, zip-bomb, bad
355  		// archive) are client-visible invalid payloads. Mapped to 422.
356  		return fmt.Errorf("%w: %v", ErrInvalidSkillPayload, err)
357  	}
358  	return nil
359  }
360  
361  // Caps for zip-based skill installs. 50 MB is more than generous for
362  // any realistic skill (ontology was 12 KB); 200 MB uncompressed guards
363  // against zip bombs. Variables (not consts) so tests can set a small
364  // cap to exercise the guard without allocating 200 MB of in-memory
365  // content.
366  var (
367  	maxZipCompressedBytes   int64 = 50 * 1024 * 1024
368  	maxZipUncompressedBytes int64 = 200 * 1024 * 1024
369  )
370  
371  // extractZipToSkill reads a zip archive from body and extracts it into
372  // destDir, applying the same exclusion, symlink rejection, and mode
373  // preservation rules as stageCleanPayload. It is the zip-transport
374  // equivalent of (git clone + stageCleanPayload) collapsed into one
375  // step because a zip archive is already a self-contained payload.
376  //
377  // Rejections (all with destDir cleanup):
378  //   - Compressed body > maxZipCompressedBytes
379  //   - Sum of uncompressed entry sizes > maxZipUncompressedBytes (zip bomb guard)
380  //   - Any entry with a symlink mode bit
381  //   - Any entry whose cleaned path escapes destDir (zip-slip)
382  //   - Any entry whose first path segment is excluded git metadata
383  func extractZipToSkill(body io.Reader, destDir string) error {
384  	// Read the entire compressed payload into memory through a hard cap.
385  	// archive/zip requires a ReaderAt, so we buffer the body.
386  	limited := io.LimitReader(body, maxZipCompressedBytes+1)
387  	raw, err := io.ReadAll(limited)
388  	if err != nil {
389  		return fmt.Errorf("read zip body: %w", err)
390  	}
391  	if int64(len(raw)) > maxZipCompressedBytes {
392  		return fmt.Errorf("zip payload exceeds %d bytes", maxZipCompressedBytes)
393  	}
394  
395  	zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
396  	if err != nil {
397  		return fmt.Errorf("parse zip: %w", err)
398  	}
399  
400  	excluded := map[string]bool{
401  		".git":           true,
402  		".github":        true,
403  		".gitignore":     true,
404  		".gitattributes": true,
405  	}
406  
407  	if err := os.MkdirAll(destDir, 0700); err != nil {
408  		return fmt.Errorf("create dest dir: %w", err)
409  	}
410  
411  	// All work happens inside a closure so any failure triggers cleanup
412  	// via the single RemoveAll below.
413  	extractErr := func() error {
414  		absDest, err := filepath.Abs(destDir)
415  		if err != nil {
416  			return fmt.Errorf("resolve dest dir: %w", err)
417  		}
418  
419  		// Zip-bomb guard: bound the TOTAL actual bytes decompressed
420  		// across all entries, using a LimitReader that counts real
421  		// bytes read — not the attacker-controlled UncompressedSize64
422  		// in the zip header. This prevents a malicious archive from
423  		// declaring 0-byte entries and then streaming gigabytes into
424  		// memory via ReadAll.
425  		remaining := maxZipUncompressedBytes
426  
427  		for _, f := range zr.File {
428  			// Symlink rejection.
429  			if f.Mode()&os.ModeSymlink != 0 {
430  				return fmt.Errorf("unsupported symlink in skill payload: %s", f.Name)
431  			}
432  
433  			// Clean the path and verify it stays within destDir.
434  			// filepath.Clean normalizes ../ which would otherwise escape.
435  			cleanRel := filepath.Clean(f.Name)
436  			if cleanRel == "." || cleanRel == "" {
437  				continue
438  			}
439  			destPath := filepath.Join(absDest, cleanRel)
440  			absPath, err := filepath.Abs(destPath)
441  			if err != nil {
442  				return fmt.Errorf("resolve entry path %q: %w", f.Name, err)
443  			}
444  			if absPath != absDest && !strings.HasPrefix(absPath, absDest+string(filepath.Separator)) {
445  				return fmt.Errorf("zip entry %q escapes dest dir", f.Name)
446  			}
447  
448  			// Exclusion check: any path segment matches.
449  			segments := strings.Split(cleanRel, string(filepath.Separator))
450  			skip := false
451  			for _, seg := range segments {
452  				if excluded[seg] {
453  					skip = true
454  					break
455  				}
456  			}
457  			if skip {
458  				continue
459  			}
460  
461  			if f.FileInfo().IsDir() {
462  				if err := os.MkdirAll(destPath, 0700); err != nil {
463  					return fmt.Errorf("mkdir %q: %w", destPath, err)
464  				}
465  				continue
466  			}
467  
468  			// Ensure parent exists (zip entries may list files before dirs).
469  			if err := os.MkdirAll(filepath.Dir(destPath), 0700); err != nil {
470  				return fmt.Errorf("mkdir parent of %q: %w", destPath, err)
471  			}
472  
473  			// Read with a per-entry budget of (remaining+1) bytes.
474  			// If we can read even 1 byte past the budget, the archive
475  			// exceeds the uncompressed cap. This tracks ACTUAL bytes
476  			// decompressed, not declared size.
477  			rc, err := f.Open()
478  			if err != nil {
479  				return fmt.Errorf("open zip entry %q: %w", f.Name, err)
480  			}
481  			content, err := io.ReadAll(io.LimitReader(rc, remaining+1))
482  			rc.Close()
483  			if err != nil {
484  				return fmt.Errorf("read zip entry %q: %w", f.Name, err)
485  			}
486  			if int64(len(content)) > remaining {
487  				return fmt.Errorf("zip uncompressed size exceeds %d bytes", maxZipUncompressedBytes)
488  			}
489  			remaining -= int64(len(content))
490  
491  			srcMode := f.Mode().Perm() & 0755
492  			if srcMode&0400 == 0 {
493  				srcMode |= 0400
494  			}
495  			if err := os.WriteFile(destPath, content, srcMode); err != nil {
496  				return fmt.Errorf("write %q: %w", destPath, err)
497  			}
498  			if err := os.Chmod(destPath, srcMode); err != nil {
499  				return fmt.Errorf("chmod %q: %w", destPath, err)
500  			}
501  		}
502  		return nil
503  	}()
504  
505  	if extractErr != nil {
506  		os.RemoveAll(destDir)
507  		return extractErr
508  	}
509  	return nil
510  }
511  
512  // stageCleanPayload walks src and copies every regular file into dst,
513  // excluding git metadata (.git/, .github/, .gitignore, .gitattributes) at
514  // any depth. Symlinks are rejected unconditionally: if the walk encounters
515  // one, the function removes dst (cleaning up any partial copy) and returns
516  // a 422-worthy error. See design doc §Install flow step 9.
517  //
518  // Exclusions match on the base name of any path segment, so nested .git dirs
519  // are also skipped.
520  //
521  // File modes are preserved from the source (masked to 0755 to strip any
522  // setuid/setgid/sticky bits), so shipped helper scripts keep their
523  // executable bit — this matters for community skills like
524  // self-improving-agent that ship scripts/activator.sh.
525  func stageCleanPayload(src, dst string) error {
526  	excluded := map[string]bool{
527  		".git":           true,
528  		".github":        true,
529  		".gitignore":     true,
530  		".gitattributes": true,
531  	}
532  
533  	if err := os.MkdirAll(dst, 0700); err != nil {
534  		return fmt.Errorf("create stage dir: %w", err)
535  	}
536  
537  	walkErr := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
538  		if err != nil {
539  			return err
540  		}
541  		if path == src {
542  			return nil
543  		}
544  
545  		// Reject symlinks outright. WalkDir gives us the lstat'd entry via
546  		// d.Type(), which preserves the Symlink bit.
547  		if d.Type()&os.ModeSymlink != 0 {
548  			return fmt.Errorf("unsupported symlink in skill payload: %s", path)
549  		}
550  
551  		rel, err := filepath.Rel(src, path)
552  		if err != nil {
553  			return err
554  		}
555  
556  		// Exclude if any path segment matches.
557  		for _, seg := range strings.Split(rel, string(filepath.Separator)) {
558  			if excluded[seg] {
559  				if d.IsDir() {
560  					return filepath.SkipDir
561  				}
562  				return nil
563  			}
564  		}
565  
566  		destPath := filepath.Join(dst, rel)
567  		if d.IsDir() {
568  			return os.MkdirAll(destPath, 0700)
569  		}
570  
571  		// Preserve source file mode so shipped helper scripts keep their
572  		// executable bit. Mask to 0755 so no file can become setuid/setgid/
573  		// sticky via install, and ensure owner-read is always set.
574  		info, err := os.Lstat(path)
575  		if err != nil {
576  			return err
577  		}
578  		srcMode := info.Mode().Perm() & 0755
579  		if srcMode&0400 == 0 {
580  			srcMode |= 0400
581  		}
582  
583  		content, err := os.ReadFile(path)
584  		if err != nil {
585  			return err
586  		}
587  		if err := os.WriteFile(destPath, content, srcMode); err != nil {
588  			return err
589  		}
590  		// os.WriteFile respects the umask on some platforms; chmod to
591  		// guarantee the requested mode lands on disk.
592  		return os.Chmod(destPath, srcMode)
593  	})
594  
595  	if walkErr != nil {
596  		os.RemoveAll(dst)
597  		return walkErr
598  	}
599  	return nil
600  }
601  
602  // SlugLocks is a map of per-slug mutexes. The outer mutex protects map access;
603  // each per-slug lock serializes install/uninstall/usage-check operations for
604  // that slug only. Different slugs never block each other.
605  //
606  // Usage:
607  //
608  //	unlock := locks.Lock("my-skill")
609  //	defer unlock()
610  type SlugLocks struct {
611  	outer   sync.Mutex
612  	perSlug map[string]*sync.Mutex
613  }
614  
615  // NewSlugLocks creates an empty SlugLocks.
616  func NewSlugLocks() *SlugLocks {
617  	return &SlugLocks{perSlug: make(map[string]*sync.Mutex)}
618  }
619  
620  // Lock acquires the per-slug mutex and returns a function that releases it.
621  // The returned function is safe to call exactly once (typically via defer).
622  func (l *SlugLocks) Lock(slug string) func() {
623  	l.outer.Lock()
624  	m, ok := l.perSlug[slug]
625  	if !ok {
626  		m = &sync.Mutex{}
627  		l.perSlug[slug] = m
628  	}
629  	l.outer.Unlock()
630  
631  	m.Lock()
632  	return m.Unlock
633  }
634  
635  // FilterSortPaginate applies the marketplace list pipeline to a raw index slice.
636  // Returns the page slice plus the total count of entries that matched the
637  // filter (used by the API response for client-side pagination controls).
638  //
639  // Pipeline:
640  //  1. Drop malicious entries (server-side security gate).
641  //  2. Apply case-insensitive substring search against name+description+author.
642  //  3. Sort by the requested key (downloads|stars|name); unknown keys fall
643  //     back to downloads desc.
644  //  4. Slice to the requested page. Out-of-range pages return an empty slice.
645  //
646  // Sort keys:
647  //   - "downloads" (default): descending by Downloads, ties broken by name asc
648  //   - "stars":               descending by Stars, ties broken by name asc
649  //   - "name":                ascending by Name
650  func FilterSortPaginate(entries []MarketplaceEntry, query, sortKey string, page, size int) ([]MarketplaceEntry, int) {
651  	if page < 1 {
652  		page = 1
653  	}
654  	if size < 1 {
655  		size = 20
656  	}
657  	if size > 100 {
658  		size = 100
659  	}
660  
661  	// Step 1+2: filter.
662  	q := strings.ToLower(strings.TrimSpace(query))
663  	filtered := make([]MarketplaceEntry, 0, len(entries))
664  	for _, e := range entries {
665  		if e.IsMalicious() {
666  			continue
667  		}
668  		if q != "" {
669  			hay := strings.ToLower(e.Name + " " + e.Description + " " + e.Author)
670  			if !strings.Contains(hay, q) {
671  				continue
672  			}
673  		}
674  		filtered = append(filtered, e)
675  	}
676  
677  	// Step 3: sort.
678  	switch sortKey {
679  	case "name":
680  		sort.SliceStable(filtered, func(i, j int) bool {
681  			return filtered[i].Name < filtered[j].Name
682  		})
683  	case "stars":
684  		sort.SliceStable(filtered, func(i, j int) bool {
685  			if filtered[i].Stars != filtered[j].Stars {
686  				return filtered[i].Stars > filtered[j].Stars
687  			}
688  			return filtered[i].Name < filtered[j].Name
689  		})
690  	default: // "downloads" and unknown
691  		sort.SliceStable(filtered, func(i, j int) bool {
692  			if filtered[i].Downloads != filtered[j].Downloads {
693  				return filtered[i].Downloads > filtered[j].Downloads
694  			}
695  			return filtered[i].Name < filtered[j].Name
696  		})
697  	}
698  
699  	total := len(filtered)
700  
701  	// Step 4: paginate.
702  	start := (page - 1) * size
703  	if start >= total {
704  		return []MarketplaceEntry{}, total
705  	}
706  	end := start + size
707  	if end > total {
708  		end = total
709  	}
710  	return filtered[start:end], total
711  }
712  
713  func (c *MarketplaceClient) fetch(ctx context.Context) (*RegistryIndex, error) {
714  	req, err := http.NewRequestWithContext(ctx, "GET", c.url, nil)
715  	if err != nil {
716  		return nil, fmt.Errorf("build request: %w", err)
717  	}
718  	resp, err := c.http.Do(req)
719  	if err != nil {
720  		return nil, fmt.Errorf("fetch registry: %w", err)
721  	}
722  	defer resp.Body.Close()
723  	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
724  		return nil, fmt.Errorf("fetch registry: status %d", resp.StatusCode)
725  	}
726  	body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10 MB cap
727  	if err != nil {
728  		return nil, fmt.Errorf("read registry body: %w", err)
729  	}
730  	var idx RegistryIndex
731  	if err := json.Unmarshal(body, &idx); err != nil {
732  		return nil, fmt.Errorf("parse registry: %w", err)
733  	}
734  	return &idx, nil
735  }