Files
core-devops d7a8855198
CI / Python Lint & Test (push) Successful in 5s
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / detect-changes (push) Successful in 6s
Harness Replays / detect-changes (push) Successful in 6s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (push) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 6s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 9s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 6s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge (compile+skip) (push) Successful in 17s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 19s
Handlers Postgres Integration / detect-changes (push) Successful in 18s
Lint publish-runner timeout-minutes / Lint publish-runner timeout-minutes (push) Successful in 15s
lint-no-coe-on-required / lint-no-coe-on-required (push) Successful in 17s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 16s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
lint-setup-go-cache / lint-setup-go-cache (push) Successful in 23s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 32s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 21s
E2E Chat / detect-changes (push) Successful in 39s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 39s
CI / Detect changes (push) Successful in 43s
E2E API Smoke Test / detect-changes (push) Successful in 44s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 3s
CI / Canvas (Next.js) (push) Successful in 3s
CI / Canvas Deploy Status (push) Successful in 1s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (push) Successful in 31s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 45s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (push) Successful in 29s
Harness Replays / Harness Replays (push) Successful in 1m22s
CI / Shellcheck (E2E scripts) (push) Successful in 1m7s
E2E Chat / E2E Chat (push) Successful in 1m26s
E2E Staging SaaS (full lifecycle) / E2E Staging Workspace Requests (core#2606) (push) Successful in 2m31s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Platform Agent (push) Successful in 2m53s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge user_tasks (push) Successful in 2m55s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m30s
CI / Platform (Go) (push) Successful in 3m2s
CI / all-required (push) Successful in 4s
publish-workspace-server-image / build-and-push (push) Successful in 4m1s
publish-workspace-server-image / Staging auto-deploy (push) Failing after 36s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m44s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Successful in 5m31s
publish-workspace-server-image / Production auto-deploy (push) Successful in 2m24s
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (push) Successful in 7m19s
template-delivery-e2e / Template-asset delivery (fresh seo-agent boots WITH skills) (push) Compensated by status-reaper (push run was cancelled/superseded; Gitea 1.22.6 reports cancelled runs as failure statuses)
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 9m6s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Creates Workspace (push) Failing after 17m51s
RFC#2843 #32: install declared plugins dynamically post-online, not via provisioning (#2995)
Co-authored-by: core-devops <52+core-devops@noreply.git.moleculesai.app>
Co-committed-by: core-devops <52+core-devops@noreply.git.moleculesai.app>
2026-06-16 20:15:25 +00:00

204 lines
7.2 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
}
// PluginNameFromSource derives the install name (/configs/plugins/<name>/
// directory key) a source spec WILL resolve to, WITHOUT fetching. The
// post-online reconcile (RFC#2843) needs this at declaration time — before
// the box is online and a real Fetch is possible — to (a) record the declared
// row's plugin_name and (b) diff declared-vs-installed.
//
// The derivation MUST match each resolver's Fetch return value:
// - local://<name> → <name>
// - github://<owner>/<repo>[#ref] → <repo>
// - gitea://<owner>/<repo>[/<sub...>][#ref]
// → last subpath segment, or <repo> when no subpath
// - bare "<name>" → <name> (treated as local)
//
// Returns an error for unparseable input or a scheme whose naming rule isn't
// known here (so a new resolver can't silently get a wrong declared name).
func PluginNameFromSource(input string) (string, error) {
src, err := ParseSource(input)
if err != nil {
return "", err
}
switch src.Scheme {
case "local":
return src.Spec, nil
case "github":
// "<owner>/<repo>[#ref]" → repo segment.
spec := src.Spec
if i := strings.Index(spec, "#"); i >= 0 {
spec = spec[:i]
}
parts := strings.Split(strings.Trim(spec, "/"), "/")
if len(parts) < 2 || parts[1] == "" {
return "", fmt.Errorf("github source %q: cannot derive plugin name", input)
}
return parts[1], nil
case "gitea":
p, err := parseGiteaSpec(src.Spec)
if err != nil {
return "", err
}
if p.subpath != "" {
seg := strings.Split(p.subpath, "/")
return seg[len(seg)-1], nil
}
return p.repo, nil
default:
return "", fmt.Errorf("source %q: cannot derive plugin name for scheme %q", input, src.Scheme)
}
}
// 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
}