molecule-core/platform/internal/plugins/local.go
Hongming Wang 24fec62d7f initial commit — Molecule AI platform
Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1)
with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo.

Brand: Starfire → Molecule AI.
Slug: starfire / agent-molecule → molecule.
Env vars: STARFIRE_* → MOLECULE_*.
Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform.
Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent.
DB: agentmolecule → molecule.

History truncated; see public repo for prior commits and contributor
attribution. Verified green: go test -race ./... (platform), pytest
(workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:55:37 -07:00

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
}