Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
112 lines
3.3 KiB
Go
112 lines
3.3 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// LocalResolver fetches plugins from a filesystem directory shipped with
|
|
// the platform (the canonical /plugins registry). This is the default
|
|
// source for bare names in the install API; most deployments point it
|
|
// at the repo's `plugins/` directory.
|
|
type LocalResolver struct {
|
|
// BaseDir is the absolute path to the directory that contains one
|
|
// subdirectory per available plugin (e.g. repo-root/plugins).
|
|
BaseDir string
|
|
}
|
|
|
|
// NewLocalResolver constructs a LocalResolver pointing at baseDir.
|
|
func NewLocalResolver(baseDir string) *LocalResolver {
|
|
return &LocalResolver{BaseDir: baseDir}
|
|
}
|
|
|
|
// Scheme returns "local".
|
|
func (r *LocalResolver) Scheme() string { return "local" }
|
|
|
|
// localNameRE constrains plugin names to safe identifiers. Matches
|
|
// validatePluginName in the handlers package; duplicated here so the
|
|
// plugins package has no reverse dependency.
|
|
//
|
|
// Length-bounded at 128 chars (1 + 127 tail). agentskills.io caps
|
|
// skill names at 64; our plugin-level names are a superset (collection
|
|
// of skills) so we allow a bit more headroom, but not unbounded.
|
|
var localNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`)
|
|
|
|
// Fetch copies the plugin directory from BaseDir/<spec> into dst.
|
|
//
|
|
// `spec` is the plain plugin name (e.g. "molecule-dev"). Path-traversal
|
|
// attempts (slashes, "..", empty) are rejected.
|
|
func (r *LocalResolver) Fetch(ctx context.Context, spec string, dst string) (string, error) {
|
|
name := strings.TrimSpace(spec)
|
|
if name == "" {
|
|
return "", fmt.Errorf("local resolver: empty plugin name")
|
|
}
|
|
if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
|
|
return "", fmt.Errorf("local resolver: invalid plugin name %q", name)
|
|
}
|
|
if !localNameRE.MatchString(name) {
|
|
return "", fmt.Errorf("local resolver: plugin name %q must match %s", name, localNameRE)
|
|
}
|
|
|
|
src := filepath.Join(r.BaseDir, name)
|
|
info, err := os.Stat(src)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", fmt.Errorf("local resolver: plugin %q: %w", name, ErrPluginNotFound)
|
|
}
|
|
return "", fmt.Errorf("local resolver: stat %s: %w", src, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return "", fmt.Errorf("local resolver: %q is not a directory", src)
|
|
}
|
|
|
|
// Copy the directory tree into dst (which the caller has created).
|
|
if err := copyTree(ctx, src, dst); err != nil {
|
|
return "", fmt.Errorf("local resolver: copy failed: %w", err)
|
|
}
|
|
|
|
return name, nil
|
|
}
|
|
|
|
// copyTree does a recursive copy honouring ctx cancellation. Avoids a
|
|
// dependency on os/exec (no need to shell out to cp).
|
|
func copyTree(ctx context.Context, src, dst string) error {
|
|
return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(src, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(dst, rel)
|
|
if info.IsDir() {
|
|
return os.MkdirAll(target, info.Mode()&os.ModePerm)
|
|
}
|
|
return copyFile(path, target, info.Mode()&os.ModePerm)
|
|
})
|
|
}
|
|
|
|
func copyFile(src, dst string, mode os.FileMode) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
_, err = io.Copy(out, in)
|
|
return err
|
|
}
|