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>
155 lines
5.5 KiB
Go
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
|
|
}
|