Files
修雨 4f915e4e2c refactor(plugin): drop managed/ compat layer left by PR #126 (#135)
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.
2026-04-22 14:56:11 +08:00

258 lines
8.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}