molecule-core/workspace-server/internal/plugins/local.go
Hongming Wang 479a027e4b chore: open-source restructure — rename dirs, remove internal files, scrub secrets
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>
2026-04-18 00:24:44 -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
}