molecule-core/platform/internal/handlers/plugins_install.go
Hongming Wang b773276ba5 refactor(platform): split 981-line plugins.go into per-domain modules
Pure mechanical split — no behavior changes. Groups the PluginsHandler
surface area by responsibility so each file stays focused and readable.

Before: plugins.go — 981 lines, 32 funcs
After:
  plugins.go                   — 194  (struct, constructor, shared helpers)
  plugins_sources.go           —  14  (ListSources)
  plugins_listing.go           — 174  (ListRegistry, ListInstalled,
                                       ListAvailableForWorkspace,
                                       CheckRuntimeCompatibility)
  plugins_install.go           — 276  (Install, Uninstall, Download handlers)
  plugins_install_pipeline.go  — 368  (resolveAndStage, deliverToContainer,
                                       copy/stream tar, CLAUDE.md marker
                                       stripping, dirSize, httpErr,
                                       installRequest/stageResult,
                                       install-layer consts + envx caps)

plugins_test.go (1365 lines) untouched — tests pass unchanged.
go build, go vet, and go test -race ./internal/handlers/... all clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:01:59 -07:00

277 lines
10 KiB
Go

package handlers
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/envx"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
)
// Install handles POST /workspaces/:id/plugins — installs a plugin.
//
// Body: {"source": "<scheme>://<spec>"}
//
// - {"source": "local://my-plugin"} → install from platform registry
// - {"source": "github://owner/repo"} → install from GitHub
// - {"source": "github://owner/repo#v1.2.0"} → pinned ref
// - {"source": "clawhub://sonoscli@1.2.0"} → when a ClawHub resolver is registered
//
// The shape of the plugin (agentskills.io format, MCP server, DeepAgents
// sub-agent, …) is orthogonal and handled by the per-runtime adapter
// inside the workspace at startup.
func (h *PluginsHandler) Install(c *gin.Context) {
workspaceID := c.Param("id")
// Cap the JSON body so a pathological POST can't exhaust parser memory.
bodyMax := envx.Int64("PLUGIN_INSTALL_BODY_MAX_BYTES", defaultInstallBodyMaxBytes)
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, bodyMax)
// Bound the whole install (fetch + copy) so a slow/malicious source
// can't tie up an HTTP handler goroutine indefinitely. Overridable
// via PLUGIN_INSTALL_FETCH_TIMEOUT (duration string, e.g. "10m").
timeout := envx.Duration("PLUGIN_INSTALL_FETCH_TIMEOUT", defaultInstallFetchTimeout)
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
var req installRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result, err := h.resolveAndStage(ctx, req)
if err != nil {
var he *httpErr
if errors.As(err, &he) {
c.JSON(he.Status, he.Body)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// On success, we own stagedDir cleanup. On error, resolveAndStage
// has already cleaned it up (and its returned result is nil).
defer os.RemoveAll(result.StagedDir)
if err := h.deliverToContainer(ctx, workspaceID, result); err != nil {
var he *httpErr
if errors.As(err, &he) {
c.JSON(he.Status, he.Body)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
log.Printf("Plugin install: %s via %s → workspace %s (restarting)", result.PluginName, result.Source.Scheme, workspaceID)
c.JSON(http.StatusOK, gin.H{
"status": "installed",
"plugin": result.PluginName,
"source": result.Source.Raw(),
})
}
// Uninstall handles DELETE /workspaces/:id/plugins/:name — removes a plugin.
func (h *PluginsHandler) Uninstall(c *gin.Context) {
workspaceID := c.Param("id")
pluginName := c.Param("name")
ctx := c.Request.Context()
if err := validatePluginName(pluginName); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
containerName := h.findRunningContainer(ctx, workspaceID)
if containerName == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "workspace container not running"})
return
}
// Read the plugin's manifest BEFORE deletion to learn which skill dirs
// it owns, so we can clean them out of /configs/skills/ and avoid the
// auto-restart re-mounting them. Issue #106.
skillNames := h.readPluginSkillsFromContainer(ctx, containerName, pluginName)
// 1. Strip plugin's rule/fragment markers from CLAUDE.md (mirrors
// AgentskillsAdaptor.uninstall lines 184-188). Best-effort: if
// the user edited CLAUDE.md, our marker stays untouched.
h.stripPluginMarkersFromMemory(ctx, containerName, pluginName)
// 2. Remove copied skill dirs declared in the plugin's plugin.yaml.
for _, skill := range skillNames {
if err := validatePluginName(skill); err != nil {
// Defensive: a malformed skill name in plugin.yaml shouldn't
// turn into a path-traversal exec. Just skip it.
log.Printf("Plugin uninstall: skipping invalid skill name %q in %s: %v", skill, pluginName, err)
continue
}
_, _ = h.execAsRoot(ctx, containerName, []string{
"rm", "-rf", "/configs/skills/" + skill,
})
}
// 3. Delete the plugin directory itself (as root to handle file ownership).
_, err := h.execAsRoot(ctx, containerName, []string{
"rm", "-rf", "/configs/plugins/" + pluginName,
})
if err != nil {
log.Printf("Plugin uninstall: failed to remove %s from %s: %v", pluginName, workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove plugin"})
return
}
// Verify deletion before restart
h.execInContainer(ctx, containerName, []string{"sync"})
// Auto-restart (small delay to ensure fs writes are flushed)
if h.restartFunc != nil {
go func() {
time.Sleep(2 * time.Second)
h.restartFunc(workspaceID)
}()
}
log.Printf("Plugin uninstall: %s from workspace %s (restarting)", pluginName, workspaceID)
c.JSON(http.StatusOK, gin.H{
"status": "uninstalled",
"plugin": pluginName,
})
}
// Download handles GET /workspaces/:id/plugins/:name/download?source=<scheme://spec>
//
// Phase 30.3 — stream the named plugin as a gzipped tarball so remote
// agents can pull and unpack locally. Replaces the Docker-exec install
// path for `runtime='external'` workspaces.
//
// The `source` query parameter is optional. When omitted we default to
// `local://<name>` (the platform's curated registry). When set, any
// registered scheme works — `github://owner/repo`, future `clawhub://…`,
// etc. — which lets a workspace install plugins from upstream repos
// without the platform pre-staging them.
//
// Auth: requires the workspace's bearer token (same shape as 30.2). A
// plugin tarball often ships rule text + skill files that reference
// internal APIs, so we prefer fail-closed on DB errors to prevent a
// hiccup from turning this into an unauth'd download endpoint.
func (h *PluginsHandler) Download(c *gin.Context) {
workspaceID := c.Param("id")
pluginName := c.Param("name")
ctx := c.Request.Context()
if err := validatePluginName(pluginName); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Auth gate — workspace token required (fail-closed on DB errors).
hasLive, hlErr := wsauth.HasAnyLiveToken(ctx, db.DB, workspaceID)
if hlErr != nil {
log.Printf("wsauth: plugin.Download HasAnyLiveToken(%s) failed: %v", workspaceID, hlErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "auth check failed"})
return
}
if hasLive {
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
if tok == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing workspace auth token"})
return
}
if err := wsauth.ValidateToken(ctx, db.DB, workspaceID, tok); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid workspace auth token"})
return
}
}
// Resolve source — default to local://<name> when caller doesn't
// specify. This is the common case: pulling a platform-curated
// plugin by its canonical name.
source := c.Query("source")
if source == "" {
source = "local://" + pluginName
}
// Reuse the existing install-layer bounds so download shares
// fetch-timeout, body limits, and staged-dir size caps with Install.
timeout := envx.Duration("PLUGIN_INSTALL_FETCH_TIMEOUT", defaultInstallFetchTimeout)
fetchCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result, err := h.resolveAndStage(fetchCtx, installRequest{Source: source})
if err != nil {
var he *httpErr
if errors.As(err, &he) {
c.JSON(he.Status, he.Body)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer os.RemoveAll(result.StagedDir)
// Sanity: resolved plugin name must match the URL path param.
// Resolvers can return a plugin.yaml-derived name that differs
// from the URL segment; reject the mismatch rather than ship a
// tarball labeled "foo" that actually contains plugin "bar".
if result.PluginName != pluginName {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("source resolved to plugin %q but URL requested %q", result.PluginName, pluginName),
"resolved_name": result.PluginName,
"requested_name": pluginName,
})
return
}
// Buffer the full tar.gz before writing any response bytes. This lets
// us emit a clean 5xx if tar packing fails — previously, a partial
// stream surfaced as HTTP 200 + truncated body, which made remote
// agents fail at unpack time with cryptic gzip errors instead of
// distinguishing "platform borked" from "network glitch".
//
// Plugin sizes are bounded by PLUGIN_INSTALL_MAX_DIR_BYTES (default
// 100 MiB) which `resolveAndStage` already validated — buffering at
// that scale is acceptable. If we ever raise the cap above ~500 MiB,
// switch to a temp file backed io.ReadSeeker and use http.ServeContent.
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
if err := streamDirAsTar(result.StagedDir, tw); err != nil {
log.Printf("plugin.Download: tar pack failed for %s: %v", pluginName, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to pack plugin",
"plugin": pluginName,
})
return
}
if err := tw.Close(); err != nil {
log.Printf("plugin.Download: tar close failed for %s: %v", pluginName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to finalize tar"})
return
}
if err := gz.Close(); err != nil {
log.Printf("plugin.Download: gzip close failed for %s: %v", pluginName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to finalize gzip"})
return
}
c.Header("Content-Type", "application/gzip")
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.tar.gz"`, pluginName))
c.Header("Content-Length", fmt.Sprintf("%d", buf.Len()))
c.Header("X-Plugin-Name", pluginName)
c.Header("X-Plugin-Source", result.Source.Raw())
if _, err := c.Writer.Write(buf.Bytes()); err != nil {
log.Printf("plugin.Download: response write failed for %s: %v", pluginName, err)
}
}