molecule-core/workspace-server/internal/plugins/source.go
Hongming Wang d8026347e5 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

155 lines
5.5 KiB
Go

// Package plugins owns the plugin-install source layer.
//
// A plugin "source" is where the platform fetches plugin files from before
// it hands them to a workspace container. Sources are pluggable by scheme
// so new registries (ClawHub, enterprise private registries, direct HTTP
// tarballs, etc.) can be added without touching install handlers.
//
// The on-disk SHAPE of a plugin (agentskills.io format, MCP server,
// DeepAgents sub-agent, custom) is a separate concern handled by the
// per-runtime adapter layer inside the workspace — see
// workspace-template/plugins_registry.
package plugins
import (
"context"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"sync"
)
// ErrPluginNotFound is returned by a SourceResolver when the requested
// plugin does not exist at the source (e.g. local dir missing, GitHub
// repo 404). Handlers use errors.Is to map this to HTTP 404 rather than
// relying on fragile string matching of the error message.
var ErrPluginNotFound = errors.New("plugin not found")
// SourceResolver fetches a plugin from a remote or local source into a
// local directory that the install handler can then tar+copy into the
// workspace container.
//
// Implementations MUST:
// - Return an absolute path to a directory containing the plugin's
// top-level files (plugin.yaml, adapters/, rules/, skills/, etc.).
// - Clean up any intermediate state on error.
// - Honour ctx cancellation for long-running fetches (git clone, http
// download, …).
//
// Registered at router wiring time via Registry.Register.
type SourceResolver interface {
// Scheme is the URL scheme this resolver handles ("local", "github",
// "clawhub", "https", …). Must be unique per platform.
Scheme() string
// Fetch retrieves the plugin identified by `spec` (scheme-specific
// path, e.g. "org/repo#v1.0" for github) and writes its contents to
// `dst`, which the caller creates and owns. Returns the resolved
// plugin name (used for /configs/plugins/<name>/).
Fetch(ctx context.Context, spec string, dst string) (pluginName string, err error)
}
// Source is a parsed plugin spec of the form "<scheme>://<spec>". Bare
// names are treated as "local://<name>".
type Source struct {
Scheme string
Spec string
}
// Raw returns the normalized string form ("scheme://spec"). Note this
// is the normalized form: `ParseSource("foo")` → `{local, foo}` → Raw
// returns `"local://foo"`, NOT the original input.
func (s Source) Raw() string {
return s.Scheme + "://" + s.Spec
}
// String is Raw so Source satisfies fmt.Stringer and logs cleanly.
func (s Source) String() string { return s.Raw() }
// schemeRE matches "<scheme>://" where scheme is the usual URL-scheme
// grammar (ASCII letters, digits, +, -, .). The body match is `.*` (can
// be empty) so we can emit a targeted error for `local://` rather than
// letting it fall through to the bare-name branch and produce a
// nonsensical Source{Scheme:"local", Spec:"local://"}.
var schemeRE = regexp.MustCompile(`^([a-zA-Z][a-zA-Z0-9+\-.]*)://(.*)$`)
// ParseSource parses a plugin source spec.
//
// Accepted forms:
//
// "my-plugin" → Source{Scheme: "local", Spec: "my-plugin"}
// "local://my-plugin" → Source{Scheme: "local", Spec: "my-plugin"}
// "github://foo/bar" → Source{Scheme: "github", Spec: "foo/bar"}
// "github://foo/bar#v1.0" → Source{Scheme: "github", Spec: "foo/bar#v1.0"}
// "clawhub://sonoscli@1.2" → Source{Scheme: "clawhub", Spec: "sonoscli@1.2"}
//
// An empty input returns an error.
func ParseSource(input string) (Source, error) {
input = strings.TrimSpace(input)
if input == "" {
return Source{}, fmt.Errorf("empty source spec")
}
m := schemeRE.FindStringSubmatch(input)
if m == nil {
// Bare name → local.
return Source{Scheme: "local", Spec: input}, nil
}
scheme, spec := m[1], strings.TrimSpace(m[2])
if spec == "" {
return Source{}, fmt.Errorf("source %q has empty spec after %q scheme", input, scheme)
}
return Source{Scheme: scheme, Spec: spec}, nil
}
// Registry holds the set of registered SourceResolvers keyed by scheme.
//
// Writes (Register) should happen at startup on a single goroutine, but
// the RWMutex makes concurrent Resolve/Schemes + Register combinations
// safe should a future deployment register resolvers dynamically (e.g.
// an enterprise control-plane that enables new schemes at runtime).
type Registry struct {
mu sync.RWMutex
resolvers map[string]SourceResolver
}
// NewRegistry returns an empty Registry.
func NewRegistry() *Registry {
return &Registry{resolvers: map[string]SourceResolver{}}
}
// Register adds a resolver. Overwrites any existing resolver for the
// same scheme; a log line in the router surface is the right place to
// warn on accidental double-registration.
func (r *Registry) Register(resolver SourceResolver) {
r.mu.Lock()
defer r.mu.Unlock()
r.resolvers[resolver.Scheme()] = resolver
}
// Resolve returns the resolver for a source's scheme, or an error if
// no resolver has been registered for that scheme.
func (r *Registry) Resolve(source Source) (SourceResolver, error) {
r.mu.RLock()
defer r.mu.RUnlock()
resolver, ok := r.resolvers[source.Scheme]
if !ok {
return nil, fmt.Errorf("no resolver registered for scheme %q", source.Scheme)
}
return resolver, nil
}
// Schemes returns the sorted list of registered schemes — useful for
// surfacing supported sources via the API.
func (r *Registry) Schemes() []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.resolvers))
for s := range r.resolvers {
out = append(out, s)
}
sort.Strings(out)
return out
}