PR #126 removed the privileged managed-plugin mechanism but kept LoadManaged, IsManaged, and ~/.dws/plugins/managed/ fallbacks so that plugins installed by pre-#126 builds would keep loading. The window for that migration was ~4 days (2026-04-15 → 2026-04-19), and the original install path mostly failed anyway (issue #124 / GitHub Pages HTML response), so there is no real user base to preserve. Scope of deletion: - plugin.Loader.LoadManaged, scanDir's isManaged param, loadPlugin's isManaged param. LoadAll now combines user + dev only. - Plugin.IsManaged field. Converter/root.go no longer branch on it; stdio and HTTP descriptors both report source="plugin". - ~/.dws/plugins/managed/ branches in ListInstalled, SetEnabled, RemovePlugin. - config.PluginManagedDir constant. - TestLoaderLoadManaged, TestRemoveLegacyManagedPlugin, and the "legacy managed" table case in TestRemovePluginPurgesSettings. Kept on purpose: - Manifest.Type public schema field ("managed"|"user") — it appears in existing plugin.json files and is only validated, never behaviorally consumed. Users with an orphaned ~/.dws/plugins/managed/ directory can `rm -rf ~/.dws/plugins/managed/` — the CLI no longer reads it.
258 lines
8.5 KiB
Go
258 lines
8.5 KiB
Go
// Copyright 2026 Alibaba Group
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
// you may not use this file except in compliance with the License.
|
||
// You may obtain a copy of the License at
|
||
//
|
||
// http://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing, software
|
||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
// See the License for the specific language governing permissions and
|
||
// limitations under the License.
|
||
|
||
// Package plugin implements the DWS CLI plugin system. It loads,
|
||
// validates, and injects plugin capabilities (MCP servers, skills,
|
||
// pipeline hooks) into the existing CLI infrastructure.
|
||
package plugin
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// namePattern validates plugin names: lowercase kebab-case, 3–50 chars.
|
||
var namePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{2,49}$`)
|
||
|
||
// Manifest represents the parsed contents of a plugin.json file.
|
||
type Manifest struct {
|
||
Name string `json:"name"`
|
||
Version string `json:"version"`
|
||
Description string `json:"description,omitempty"`
|
||
Type string `json:"type,omitempty"` // "managed" or "user"
|
||
MinCLIVersion string `json:"minCLIVersion,omitempty"`
|
||
MCPServers map[string]*MCPServer `json:"mcpServers,omitempty"`
|
||
Skills string `json:"skills,omitempty"`
|
||
Hooks string `json:"hooks,omitempty"`
|
||
Permissions []string `json:"permissions,omitempty"`
|
||
UserConfig map[string]ConfigItem `json:"userConfig,omitempty"`
|
||
Build *BuildConfig `json:"build,omitempty"`
|
||
}
|
||
|
||
// BuildConfig declares how to compile the plugin's stdio server into
|
||
// a native binary. DWS runs this automatically during install so that
|
||
// plugin users never need language runtimes or dependency managers.
|
||
type BuildConfig struct {
|
||
// Command is the shell command to compile the server.
|
||
// Executed via "sh -c" in the plugin root directory.
|
||
// Examples: "bun build --compile src/server.ts --outfile bin/server"
|
||
// "go build -o bin/server ./cmd/server"
|
||
// "pip install pyinstaller && pyinstaller --onefile src/server.py -n server --distpath bin/"
|
||
Command string `json:"command"`
|
||
|
||
// Output is the path to the compiled binary, relative to the plugin root.
|
||
// Used to verify the build succeeded. Example: "bin/server"
|
||
Output string `json:"output"`
|
||
}
|
||
|
||
// MCPServer describes a single MCP server declared by a plugin.
|
||
type MCPServer struct {
|
||
Type string `json:"type"` // "streamable-http" or "stdio"
|
||
Endpoint string `json:"endpoint,omitempty"` // required for streamable-http
|
||
Command string `json:"command,omitempty"` // required for stdio
|
||
Args []string `json:"args,omitempty"`
|
||
Env map[string]string `json:"env,omitempty"`
|
||
Headers map[string]string `json:"headers,omitempty"` // custom HTTP headers (e.g. Authorization for third-party APIs)
|
||
CLI json.RawMessage `json:"cli,omitempty"` // CLIOverlay, passed through
|
||
}
|
||
|
||
// ConfigItem describes a user-configurable setting for a plugin.
|
||
type ConfigItem struct {
|
||
Description string `json:"description,omitempty"`
|
||
Default string `json:"default,omitempty"`
|
||
Sensitive bool `json:"sensitive,omitempty"`
|
||
}
|
||
|
||
// HooksConfig describes pipeline hooks declared in a hooks.json file.
|
||
type HooksConfig struct {
|
||
Hooks []HookEntry `json:"hooks"`
|
||
}
|
||
|
||
// HookEntry describes a single pipeline hook.
|
||
type HookEntry struct {
|
||
Phase string `json:"phase"` // "pre-request", "post-response", etc.
|
||
Matcher string `json:"matcher,omitempty"` // glob pattern, e.g. "conference.*"
|
||
Command string `json:"command"` // shell command to execute
|
||
Timeout int `json:"timeout,omitempty"` // seconds, default 30
|
||
}
|
||
|
||
// Plugin is a loaded, validated plugin ready for injection.
|
||
type Plugin struct {
|
||
Manifest Manifest
|
||
Root string // absolute path to plugin directory
|
||
}
|
||
|
||
// ParseManifest reads and parses a plugin.json file.
|
||
func ParseManifest(path string) (*Manifest, error) {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read plugin.json: %w", err)
|
||
}
|
||
var m Manifest
|
||
if err := json.Unmarshal(data, &m); err != nil {
|
||
return nil, fmt.Errorf("parse plugin.json: %w", err)
|
||
}
|
||
return &m, nil
|
||
}
|
||
|
||
// Validate checks that a manifest is well-formed. It returns an error
|
||
// describing the first problem found, or nil if the manifest is valid.
|
||
// cliVersion is the current CLI version string for compatibility checks.
|
||
func (m *Manifest) Validate(cliVersion string) error {
|
||
if !namePattern.MatchString(m.Name) {
|
||
return fmt.Errorf("invalid plugin name %q: must be lowercase kebab-case, 3–50 chars", m.Name)
|
||
}
|
||
if !isValidSemver(m.Version) {
|
||
return fmt.Errorf("invalid plugin version %q: must be valid semver (e.g. 1.0.0)", m.Version)
|
||
}
|
||
if m.Type != "" && m.Type != "managed" && m.Type != "user" {
|
||
return fmt.Errorf("invalid plugin type %q: must be \"managed\" or \"user\"", m.Type)
|
||
}
|
||
if m.MinCLIVersion != "" && cliVersion != "" && cliVersion != "dev" {
|
||
if compareSemver(cliVersion, m.MinCLIVersion) < 0 {
|
||
return fmt.Errorf("plugin requires CLI >= %s, current is %s", m.MinCLIVersion, cliVersion)
|
||
}
|
||
}
|
||
for key, srv := range m.MCPServers {
|
||
if err := validateMCPServer(key, srv); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if m.Skills != "" {
|
||
if err := validateSafePath(m.Skills); err != nil {
|
||
return fmt.Errorf("skills path: %w", err)
|
||
}
|
||
}
|
||
if m.Hooks != "" {
|
||
if err := validateSafePath(m.Hooks); err != nil {
|
||
return fmt.Errorf("hooks path: %w", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateMCPServer(key string, srv *MCPServer) error {
|
||
switch srv.Type {
|
||
case "streamable-http":
|
||
if strings.TrimSpace(srv.Endpoint) == "" {
|
||
return fmt.Errorf("mcpServers[%q]: streamable-http requires endpoint", key)
|
||
}
|
||
case "stdio":
|
||
if strings.TrimSpace(srv.Command) == "" {
|
||
return fmt.Errorf("mcpServers[%q]: stdio requires command", key)
|
||
}
|
||
// Reject absolute paths in command to encourage relative paths within plugin root.
|
||
if filepath.IsAbs(srv.Command) {
|
||
return fmt.Errorf("mcpServers[%q]: command must be a relative path, got %q", key, srv.Command)
|
||
}
|
||
default:
|
||
return fmt.Errorf("mcpServers[%q]: unsupported type %q (must be streamable-http or stdio)", key, srv.Type)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validateSafePath rejects paths containing ".." traversal.
|
||
func validateSafePath(p string) error {
|
||
cleaned := filepath.Clean(p)
|
||
if strings.Contains(cleaned, "..") {
|
||
return fmt.Errorf("unsafe path %q: must not contain \"..\"", p)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// LoadHooks reads the hooks.json file referenced by the manifest.
|
||
func (p *Plugin) LoadHooks() (*HooksConfig, error) {
|
||
if p.Manifest.Hooks == "" {
|
||
return nil, nil
|
||
}
|
||
hooksPath := filepath.Join(p.Root, p.Manifest.Hooks)
|
||
data, err := os.ReadFile(hooksPath)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil, nil
|
||
}
|
||
return nil, fmt.Errorf("read hooks: %w", err)
|
||
}
|
||
var cfg HooksConfig
|
||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||
return nil, fmt.Errorf("parse hooks: %w", err)
|
||
}
|
||
return &cfg, nil
|
||
}
|
||
|
||
// SkillsDir returns the absolute path to the plugin's skills directory.
|
||
func (p *Plugin) SkillsDir() string {
|
||
dir := p.Manifest.Skills
|
||
if dir == "" {
|
||
dir = "./skills/"
|
||
}
|
||
return filepath.Join(p.Root, dir)
|
||
}
|
||
|
||
// isValidSemver checks if a string is a valid semantic version (major.minor.patch).
|
||
func isValidSemver(v string) bool {
|
||
parts := strings.SplitN(strings.TrimPrefix(v, "v"), "-", 2)
|
||
nums := strings.Split(parts[0], ".")
|
||
if len(nums) != 3 {
|
||
return false
|
||
}
|
||
for _, n := range nums {
|
||
if _, err := strconv.Atoi(n); err != nil {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// parseSemver extracts major, minor, patch from a version string.
|
||
func parseSemver(v string) (int, int, int) {
|
||
v = strings.TrimPrefix(v, "v")
|
||
parts := strings.SplitN(v, "-", 2) // strip pre-release
|
||
nums := strings.Split(parts[0], ".")
|
||
if len(nums) != 3 {
|
||
return 0, 0, 0
|
||
}
|
||
major, _ := strconv.Atoi(nums[0])
|
||
minor, _ := strconv.Atoi(nums[1])
|
||
patch, _ := strconv.Atoi(nums[2])
|
||
return major, minor, patch
|
||
}
|
||
|
||
// compareSemver compares two semver strings. Returns -1, 0, or 1.
|
||
func compareSemver(a, b string) int {
|
||
aMaj, aMin, aPat := parseSemver(a)
|
||
bMaj, bMin, bPat := parseSemver(b)
|
||
if aMaj != bMaj {
|
||
return cmpInt(aMaj, bMaj)
|
||
}
|
||
if aMin != bMin {
|
||
return cmpInt(aMin, bMin)
|
||
}
|
||
return cmpInt(aPat, bPat)
|
||
}
|
||
|
||
func cmpInt(a, b int) int {
|
||
if a < b {
|
||
return -1
|
||
}
|
||
if a > b {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|