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/ 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 }