docs: add docs/plugins/sources.md

This commit is contained in:
molecule-ai[bot] 2026-04-21 08:02:29 +00:00 committed by GitHub
parent 6aece0f6de
commit aef44dacce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -0,0 +1,167 @@
# Plugin install sources
> **TL;DR** — plugin **sources** (where a plugin comes from) and plugin
> **shapes** (what's inside it) are independent axes. Both are pluggable.
> Today we ship two sources (`local`, `github`) and one shape adapter
> (`AgentskillsAdaptor`). Both layers scale the same way: write one new
> class, register it, done.
## The two axes
```
┌──────────────────────────────────────────────┐
│ SOURCE — where we fetch the plugin from │
│ │
│ local://my-plugin │
│ github://owner/repo#v1.0 │
│ clawhub://name@1.2 │
│ https://example.com/plugin.tgz │
│ │
│ registered via plugins.Registry │
└──────────────────────────────────────────────┘
│ 1. resolver.Fetch() → platform-local staging dir
│ 2. tar + copy → workspace container /configs/plugins/<name>/
┌──────────────────────────────────────────────┐
│ SHAPE — what the plugin's files mean │
│ │
│ agentskills.io format (SKILL.md + scripts) │
│ MCP server │
│ DeepAgents sub-agent │
│ LangGraph sub-graph │
│ │
│ registered via plugins_registry resolver │
│ inside the workspace runtime │
└──────────────────────────────────────────────┘
```
Neither layer mandates the other. A plugin installed from `github://…`
might be agentskills-format. A plugin installed from `local://…` might
be an MCP server. A plugin installed from `clawhub://…` in the future
might be whatever shape ClawHub packs happen to be.
## Source API
`POST /workspaces/:id/plugins` takes a single `source` field:
```json
{"source": "local://my-plugin"} // platform registry
{"source": "github://org/repo"} // GitHub default branch (public repos only)
{"source": "github://org/repo#v1.2.0"} // pinned tag/branch/sha
// Future: clawhub://, https://, oci:// — not registered by default.
// Call GET /plugins/sources to see what's actually wired.
```
`GET /plugins/sources` lists the currently registered schemes:
```bash
curl $PLATFORM/plugins/sources
{"schemes":["github","local"]}
```
## Registering a new source
```go
// workspace-server/internal/router/router.go
plgh := handlers.NewPluginsHandler(pluginsDir, dockerCli, wh.RestartByID).
WithSourceResolver(NewClawhubResolver(clawhubToken))
```
A `SourceResolver` must satisfy:
```go
type SourceResolver interface {
Scheme() string // unique scheme name
Fetch(ctx context.Context, spec, dst string) (string, error) // copy into dst, return plugin name
}
```
Implementations must honour `ctx` cancellation, clean up temp state on
error, and validate their spec format before hitting the network.
## Built-in sources
### `local` — filesystem
```
local://<name>
<name> # bare name, same as above
```
Reads from the directory configured as `pluginsDir` at startup (defaults
to the repo's `plugins/` directory). Name must match
`^[a-z0-9][a-z0-9._-]*$`; path-traversal attempts are rejected pre-fetch.
### `github` — GitHub repository
```
github://<owner>/<repo>
github://<owner>/<repo>#<ref>
```
**Public repositories only.** The resolver performs an anonymous shallow
clone via the system `git` binary (the platform Dockerfile installs
`git`); it does not authenticate. Private-repo support is deliberately
out of scope for now — doing it safely requires per-tenant credential
storage, scope-limited tokens, and an audit trail, none of which have
been designed yet. Until that lands, private-repo installs fail at clone
time with a 404 (mapped from git's "repository not found" output).
Owner + repo names are length-bounded; refs cannot start with `-` to
prevent ref-as-flag injection. The resolver also passes `--` before the
URL when invoking git, as belt-and-braces defense.
### Future resolvers (not yet implemented)
- **`https://<tarball-url>`** — direct HTTP tarball install. Planned.
- **`clawhub://<name>@<version>`** — ClawHub registry install. Planned
behind a third-party package dep on the ClawHub client.
- **`oci://<registry>/<image>:<tag>`** — OCI artifact install. Planned
for enterprise registries.
Adding a resolver is a single Go file — see "Registering a new source"
above. The set of built-in resolvers is intentionally small; anything
beyond `local` + `github` is extension territory.
## Security model
**In scope (enforced):**
- Every resolver validates its spec format before any network or
filesystem operation (regex + length caps).
- The handler re-validates the plugin name returned by the resolver
before using it as a path component, so a hostile resolver can't
smuggle a traversal name into `/configs/plugins/`.
- Request body is size-capped (`PLUGIN_INSTALL_BODY_MAX_BYTES`,
default 64 KiB) to bound JSON-parser work.
- Fetch is timed out (`PLUGIN_INSTALL_FETCH_TIMEOUT`, default 5 min)
so a slow/malicious source can't tie up a handler indefinitely.
- Staged tree is size-capped (`PLUGIN_INSTALL_MAX_DIR_BYTES`, default
100 MiB) before copy into the container.
- Concurrent registry writes protected by RWMutex.
**Out of scope (not enforced):**
- **Plugin file contents are trusted.** Installing a plugin is a
code-execution grant for the workspace's runtime. Audit plugin
sources as you would any dependency.
- Network egress from resolvers isn't sandboxed (no netns/cgroup
isolation around `git clone`). If you self-host Molecule AI in a
multi-tenant or untrusted-tenant context, restrict egress at the
network layer — an egress firewall, VPC NAT with allowlist, or
equivalent. The platform itself does not isolate resolver traffic.
- No signature or checksum verification on fetched content — planned
alongside an OCI-based resolver where content addressability is
native.
- No per-workspace rate limit on installs (platform-global rate limit
applies via the standard middleware).
## Shapes — the other axis
See [agentskills-compat.md](agentskills-compat.md) for how the shape
layer works. The two are wired together but independent: the source
layer's job ends when plugin files are staged on disk; the shape layer
(per-runtime adapter inside the workspace) decides what to do with them
on workspace startup.