mirror of
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
synced 2026-09-28 21:25:22 +08:00
Prevent failed or removed plugins from leaving injected environment state eligible for parent-process Schema cache publication, with coverage for the child builder and protocol branches. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
888 lines
26 KiB
Go
888 lines
26 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
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
)
|
|
|
|
// Loader scans plugin directories and returns loaded, validated plugins.
|
|
type Loader struct {
|
|
// PluginsDir is the root directory for all plugins.
|
|
// Defaults to ~/.dws/plugins/.
|
|
PluginsDir string
|
|
|
|
// CLIVersion is the current CLI version, used for
|
|
// minCLIVersion compatibility checks.
|
|
CLIVersion string
|
|
}
|
|
|
|
var (
|
|
pluginUserHomeDir = os.UserHomeDir
|
|
pluginReadDir = os.ReadDir
|
|
pluginMkdirTemp = os.MkdirTemp
|
|
pluginRemoveAll = os.RemoveAll
|
|
pluginMkdirAll = os.MkdirAll
|
|
pluginReadFile = os.ReadFile
|
|
pluginWriteFile = os.WriteFile
|
|
pluginRemove = os.Remove
|
|
pluginWalk = filepath.Walk
|
|
pluginRel = filepath.Rel
|
|
pluginCopyDir = copyDir
|
|
pluginRunBuild = runBuild
|
|
)
|
|
|
|
// NewLoader creates a Loader with default paths.
|
|
func NewLoader(cliVersion string) *Loader {
|
|
configDir := strings.TrimSpace(os.Getenv("DWS_CONFIG_DIR"))
|
|
if configDir == "" {
|
|
home, _ := pluginUserHomeDir()
|
|
configDir = filepath.Join(home, ".dws")
|
|
}
|
|
return &Loader{
|
|
PluginsDir: filepath.Join(configDir, "plugins"),
|
|
CLIVersion: cliVersion,
|
|
}
|
|
}
|
|
|
|
// Settings holds user preferences for plugin management.
|
|
type Settings struct {
|
|
EnabledPlugins map[string]bool `json:"enabledPlugins,omitempty"`
|
|
PluginConfigs map[string]map[string]any `json:"pluginConfigs,omitempty"`
|
|
PluginAutoUpdate bool `json:"pluginAutoUpdate,omitempty"`
|
|
DevPlugins map[string]string `json:"devPlugins,omitempty"` // name → absolute path
|
|
}
|
|
|
|
// LoadUser scans ~/.dws/plugins/user/ and returns enabled user plugins.
|
|
func (l *Loader) LoadUser() []*Plugin {
|
|
userDir := filepath.Join(l.PluginsDir, "user")
|
|
settings := l.loadSettings()
|
|
|
|
var plugins []*Plugin
|
|
// User plugins may be nested: user/{workspace}/{name}/
|
|
entries, err := pluginReadDir(userDir)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
slog.Debug("plugin: cannot read user dir", "path", userDir, "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
entryPath := filepath.Join(userDir, entry.Name())
|
|
|
|
// Check if this is a direct plugin directory (has plugin.json)
|
|
if _, err := os.Stat(filepath.Join(entryPath, "plugin.json")); err == nil {
|
|
p := l.loadPlugin(entryPath)
|
|
if p != nil && isPluginEnabled(settings, p.Manifest.Name) {
|
|
plugins = append(plugins, p)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Otherwise treat as workspace directory: user/{workspace}/{name}/
|
|
subEntries, err := pluginReadDir(entryPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, sub := range subEntries {
|
|
if !sub.IsDir() {
|
|
continue
|
|
}
|
|
subPath := filepath.Join(entryPath, sub.Name())
|
|
p := l.loadPlugin(subPath)
|
|
if p != nil {
|
|
qualifiedName := entry.Name() + "/" + p.Manifest.Name
|
|
if isPluginEnabled(settings, qualifiedName) {
|
|
plugins = append(plugins, p)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return plugins
|
|
}
|
|
|
|
// LoadAll loads user + dev plugins.
|
|
func (l *Loader) LoadAll() []*Plugin {
|
|
user := l.LoadUser()
|
|
dev := l.LoadDev()
|
|
return append(user, dev...)
|
|
}
|
|
|
|
// loadPlugin reads and validates a single plugin directory.
|
|
func (l *Loader) loadPlugin(dir string) *Plugin {
|
|
manifestPath := filepath.Join(dir, "plugin.json")
|
|
manifest, err := ParseManifest(manifestPath)
|
|
if err != nil {
|
|
slog.Debug("plugin: failed to parse manifest",
|
|
"path", manifestPath, "error", err)
|
|
return nil
|
|
}
|
|
|
|
if err := manifest.Validate(l.CLIVersion); err != nil {
|
|
slog.Debug("plugin: validation failed",
|
|
"plugin", manifest.Name, "error", err)
|
|
return nil
|
|
}
|
|
|
|
return &Plugin{
|
|
Manifest: *manifest,
|
|
Root: dir,
|
|
}
|
|
}
|
|
|
|
// settingsPath returns the path to settings.json.
|
|
// Uses PluginsDir's parent (~/.dws/) for production, PluginsDir itself for tests.
|
|
func (l *Loader) settingsPath() string {
|
|
// If PluginsDir ends with "plugins", go up one level to ~/.dws/
|
|
if filepath.Base(l.PluginsDir) == "plugins" {
|
|
return filepath.Join(filepath.Dir(l.PluginsDir), "settings.json")
|
|
}
|
|
// For test temp dirs, use PluginsDir directly
|
|
return filepath.Join(l.PluginsDir, "settings.json")
|
|
}
|
|
|
|
// loadSettings reads settings.json from the parent of PluginsDir.
|
|
func (l *Loader) loadSettings() *Settings {
|
|
settingsPath := l.settingsPath()
|
|
data, err := os.ReadFile(settingsPath)
|
|
if err != nil {
|
|
return &Settings{}
|
|
}
|
|
var s Settings
|
|
if err := json.Unmarshal(data, &s); err != nil {
|
|
slog.Debug("plugin: failed to parse settings.json", "error", err)
|
|
return &Settings{}
|
|
}
|
|
return &s
|
|
}
|
|
|
|
func isPluginEnabled(s *Settings, name string) bool {
|
|
if s == nil || s.EnabledPlugins == nil {
|
|
return true // default: enabled
|
|
}
|
|
enabled, exists := s.EnabledPlugins[name]
|
|
if !exists {
|
|
return true // not in list = enabled
|
|
}
|
|
return enabled
|
|
}
|
|
|
|
// InstalledPlugins returns the list of all installed plugins with their
|
|
// status info. Used by `dws plugin list`.
|
|
type PluginInfo struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Type string `json:"type"` // "user" or "dev"
|
|
Enabled bool `json:"enabled"`
|
|
Path string `json:"path"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// ListInstalled returns info about all installed plugins.
|
|
func (l *Loader) ListInstalled() []PluginInfo {
|
|
var result []PluginInfo
|
|
settings := l.loadSettings()
|
|
|
|
// User plugins
|
|
userDir := filepath.Join(l.PluginsDir, "user")
|
|
if entries, err := os.ReadDir(userDir); err == nil {
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
l.collectUserPluginInfos(filepath.Join(userDir, entry.Name()), entry.Name(), settings, &result)
|
|
}
|
|
}
|
|
|
|
// Dev plugins
|
|
for name, dir := range settings.DevPlugins {
|
|
m, err := ParseManifest(filepath.Join(dir, "plugin.json"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
result = append(result, PluginInfo{
|
|
Name: name,
|
|
Version: m.Version,
|
|
Type: "dev",
|
|
Enabled: true,
|
|
Path: dir,
|
|
Description: m.Description,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (l *Loader) collectUserPluginInfos(dir, prefix string, settings *Settings, result *[]PluginInfo) {
|
|
// Direct plugin
|
|
if m, err := ParseManifest(filepath.Join(dir, "plugin.json")); err == nil {
|
|
qualName := prefix
|
|
*result = append(*result, PluginInfo{
|
|
Name: qualName,
|
|
Version: m.Version,
|
|
Type: "user",
|
|
Enabled: isPluginEnabled(settings, qualName),
|
|
Path: dir,
|
|
Description: m.Description,
|
|
})
|
|
return
|
|
}
|
|
// Workspace: dir is a workspace, iterate sub-plugins
|
|
subEntries, err := pluginReadDir(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, sub := range subEntries {
|
|
if !sub.IsDir() {
|
|
continue
|
|
}
|
|
subDir := filepath.Join(dir, sub.Name())
|
|
m, err := ParseManifest(filepath.Join(subDir, "plugin.json"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
qualName := prefix + "/" + m.Name
|
|
*result = append(*result, PluginInfo{
|
|
Name: qualName,
|
|
Version: m.Version,
|
|
Type: "user",
|
|
Enabled: isPluginEnabled(settings, qualName),
|
|
Path: subDir,
|
|
Description: m.Description,
|
|
})
|
|
}
|
|
}
|
|
|
|
// InstallFromDir copies a plugin from a source directory to the user
|
|
// plugins directory.
|
|
func (l *Loader) InstallFromDir(srcDir string) (*Plugin, error) {
|
|
manifestPath := filepath.Join(srcDir, "plugin.json")
|
|
manifest, err := ParseManifest(manifestPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid plugin: %w", err)
|
|
}
|
|
if err := manifest.Validate(l.CLIVersion); err != nil {
|
|
return nil, fmt.Errorf("plugin validation failed: %w", err)
|
|
}
|
|
|
|
destDir := filepath.Join(l.PluginsDir, "user", manifest.Name)
|
|
if err := pluginCopyDir(srcDir, destDir); err != nil {
|
|
return nil, fmt.Errorf("install failed: %w", err)
|
|
}
|
|
|
|
// Remove stale files in destDir that no longer exist in srcDir.
|
|
removeStaleFiles(srcDir, destDir)
|
|
|
|
// Run build if configured (compile server to binary).
|
|
if manifest.Build != nil {
|
|
if err := pluginRunBuild(destDir, manifest.Build); err != nil {
|
|
// Clean up on build failure.
|
|
_ = os.RemoveAll(destDir)
|
|
return nil, fmt.Errorf("plugin build failed: %w", err)
|
|
}
|
|
}
|
|
|
|
// Enable by default in settings
|
|
l.setPluginEnabled(manifest.Name, true)
|
|
|
|
return &Plugin{
|
|
Manifest: *manifest,
|
|
Root: destDir,
|
|
}, nil
|
|
}
|
|
|
|
// InstallFromGit clones a git repository and installs the plugin.
|
|
// The workspace is extracted from the git URL (e.g. github.com/{workspace}/{name}).
|
|
func (l *Loader) InstallFromGit(gitURL string) (*Plugin, error) {
|
|
workspace, repoName, err := parseGitURL(gitURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid git URL: %w", err)
|
|
}
|
|
|
|
// Clone to temp directory.
|
|
tmpDir, err := pluginMkdirTemp("", "dws-plugin-git-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create temp dir: %w", err)
|
|
}
|
|
defer pluginRemoveAll(tmpDir)
|
|
|
|
cloneDir := filepath.Join(tmpDir, repoName)
|
|
cmd := exec.Command("git", "clone", "--depth", "1", gitURL, cloneDir)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("git clone failed: %w", err)
|
|
}
|
|
|
|
// Parse and validate manifest.
|
|
manifest, err := ParseManifest(filepath.Join(cloneDir, "plugin.json"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid plugin: %w", err)
|
|
}
|
|
if err := manifest.Validate(l.CLIVersion); err != nil {
|
|
return nil, fmt.Errorf("plugin validation failed: %w", err)
|
|
}
|
|
|
|
// All plugins install to the user directory with workspace nesting:
|
|
// ~/.dws/plugins/user/{workspace}/{name}/. There is no privileged
|
|
// workspace — every plugin is third-party.
|
|
destDir := filepath.Join(l.PluginsDir, config.PluginUserDir, workspace, manifest.Name)
|
|
|
|
// Remove .git directory before copying.
|
|
_ = pluginRemoveAll(filepath.Join(cloneDir, ".git"))
|
|
|
|
if err := pluginCopyDir(cloneDir, destDir); err != nil {
|
|
return nil, fmt.Errorf("install failed: %w", err)
|
|
}
|
|
|
|
// Run build if configured (compile server to binary).
|
|
if manifest.Build != nil {
|
|
if err := pluginRunBuild(destDir, manifest.Build); err != nil {
|
|
// Clean up on build failure.
|
|
_ = pluginRemoveAll(destDir)
|
|
return nil, fmt.Errorf("plugin build failed: %w", err)
|
|
}
|
|
}
|
|
|
|
qualifiedName := workspace + "/" + manifest.Name
|
|
l.setPluginEnabled(qualifiedName, true)
|
|
|
|
return &Plugin{
|
|
Manifest: *manifest,
|
|
Root: destDir,
|
|
}, nil
|
|
}
|
|
|
|
// parseGitURL extracts workspace and repo name from a git URL.
|
|
// Supports: https://github.com/org/repo.git, git@github.com:org/repo.git
|
|
// Rejects file:// and other local protocols to prevent reading local files.
|
|
func parseGitURL(gitURL string) (workspace, repoName string, err error) {
|
|
gitURL = strings.TrimSpace(gitURL)
|
|
|
|
// Reject dangerous protocols that could read local files.
|
|
lower := strings.ToLower(gitURL)
|
|
if strings.HasPrefix(lower, "file://") || strings.HasPrefix(lower, "/") || strings.HasPrefix(lower, ".") {
|
|
return "", "", fmt.Errorf("local paths and file:// URLs are not allowed: %q", gitURL)
|
|
}
|
|
|
|
// Handle SSH format: git@github.com:org/repo.git
|
|
if strings.HasPrefix(gitURL, "git@") {
|
|
parts := strings.SplitN(gitURL, ":", 2)
|
|
if len(parts) != 2 {
|
|
return "", "", fmt.Errorf("cannot parse SSH URL %q", gitURL)
|
|
}
|
|
path := strings.TrimSuffix(parts[1], ".git")
|
|
segments := strings.Split(path, "/")
|
|
if len(segments) < 2 {
|
|
return "", "", fmt.Errorf("SSH URL %q must have org/repo format", gitURL)
|
|
}
|
|
return segments[len(segments)-2], segments[len(segments)-1], nil
|
|
}
|
|
|
|
// Handle HTTPS format.
|
|
u, err := url.Parse(gitURL)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("cannot parse URL %q: %w", gitURL, err)
|
|
}
|
|
|
|
// Only allow https:// and http:// schemes.
|
|
if u.Scheme != "https" && u.Scheme != "http" {
|
|
return "", "", fmt.Errorf("unsupported URL scheme %q: only https and ssh are allowed", u.Scheme)
|
|
}
|
|
|
|
path := strings.TrimSuffix(strings.Trim(u.Path, "/"), ".git")
|
|
segments := strings.Split(path, "/")
|
|
if len(segments) < 2 {
|
|
return "", "", fmt.Errorf("URL %q must have org/repo format", gitURL)
|
|
}
|
|
|
|
return segments[len(segments)-2], segments[len(segments)-1], nil
|
|
}
|
|
|
|
// RemovePlugin removes an installed plugin by name.
|
|
func (l *Loader) RemovePlugin(name string, keepData bool) error {
|
|
pluginDir := l.findUserPluginDir(name)
|
|
if pluginDir == "" {
|
|
return fmt.Errorf("plugin %q not found", name)
|
|
}
|
|
|
|
if err := pluginRemoveAll(pluginDir); err != nil {
|
|
return fmt.Errorf("failed to remove plugin: %w", err)
|
|
}
|
|
|
|
if !keepData {
|
|
dataDir := filepath.Join(l.PluginsDir, config.PluginDataDir, name)
|
|
_ = pluginRemoveAll(dataDir)
|
|
}
|
|
|
|
l.purgePluginFromSettings(name)
|
|
return nil
|
|
}
|
|
|
|
// purgePluginFromSettings removes all traces of a plugin from settings.json:
|
|
// its enabled flag and any persisted pluginConfigs entry. Called after
|
|
// RemovePlugin succeeds so settings.json does not retain dangling state for
|
|
// plugins that no longer exist on disk.
|
|
func (l *Loader) purgePluginFromSettings(name string) {
|
|
settings := l.loadSettings()
|
|
changed := false
|
|
if _, ok := settings.EnabledPlugins[name]; ok {
|
|
delete(settings.EnabledPlugins, name)
|
|
changed = true
|
|
}
|
|
if _, ok := settings.PluginConfigs[name]; ok {
|
|
delete(settings.PluginConfigs, name)
|
|
changed = true
|
|
}
|
|
if !changed {
|
|
return
|
|
}
|
|
l.saveSettings(settings)
|
|
}
|
|
|
|
// SetEnabled enables or disables a plugin in settings.json.
|
|
func (l *Loader) SetEnabled(name string, enabled bool) error {
|
|
if l.findUserPluginDir(name) == "" {
|
|
return fmt.Errorf("plugin %q not found", name)
|
|
}
|
|
l.setPluginEnabled(name, enabled)
|
|
return nil
|
|
}
|
|
|
|
func (l *Loader) findUserPluginDir(name string) string {
|
|
// name may be direct ("plugin") or qualified ("workspace/plugin").
|
|
// filepath.Join handles both forms.
|
|
dir := filepath.Join(l.PluginsDir, "user", name)
|
|
if _, err := os.Stat(filepath.Join(dir, "plugin.json")); err == nil {
|
|
return dir
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (l *Loader) setPluginEnabled(name string, enabled bool) {
|
|
settings := l.loadSettings()
|
|
if settings.EnabledPlugins == nil {
|
|
settings.EnabledPlugins = make(map[string]bool)
|
|
}
|
|
settings.EnabledPlugins[name] = enabled
|
|
l.saveSettings(settings)
|
|
}
|
|
|
|
func (l *Loader) saveSettings(s *Settings) {
|
|
settingsPath := l.settingsPath()
|
|
data, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
slog.Debug("plugin: failed to marshal settings", "error", err)
|
|
return
|
|
}
|
|
_ = os.MkdirAll(filepath.Dir(settingsPath), 0o700)
|
|
_ = os.WriteFile(settingsPath, data, 0o600)
|
|
}
|
|
|
|
// GetPluginConfig returns the value of a config key for a plugin.
|
|
// It checks pluginConfigs in settings.json first, then falls back to
|
|
// the userConfig default in the plugin's manifest.
|
|
func (l *Loader) GetPluginConfig(pluginName, key string) (string, bool) {
|
|
settings := l.loadSettings()
|
|
if settings.PluginConfigs != nil {
|
|
if pluginCfg, ok := settings.PluginConfigs[pluginName]; ok {
|
|
if val, ok := pluginCfg[key]; ok {
|
|
if s, ok := val.(string); ok {
|
|
return s, true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// SetPluginConfig persists a config key-value pair for a plugin.
|
|
func (l *Loader) SetPluginConfig(pluginName, key, value string) {
|
|
settings := l.loadSettings()
|
|
if settings.PluginConfigs == nil {
|
|
settings.PluginConfigs = make(map[string]map[string]any)
|
|
}
|
|
if settings.PluginConfigs[pluginName] == nil {
|
|
settings.PluginConfigs[pluginName] = make(map[string]any)
|
|
}
|
|
settings.PluginConfigs[pluginName][key] = value
|
|
l.saveSettings(settings)
|
|
}
|
|
|
|
// UnsetPluginConfig removes a config key for a plugin.
|
|
func (l *Loader) UnsetPluginConfig(pluginName, key string) bool {
|
|
settings := l.loadSettings()
|
|
if settings.PluginConfigs == nil {
|
|
return false
|
|
}
|
|
pluginCfg, ok := settings.PluginConfigs[pluginName]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if _, exists := pluginCfg[key]; !exists {
|
|
return false
|
|
}
|
|
delete(pluginCfg, key)
|
|
if len(pluginCfg) == 0 {
|
|
delete(settings.PluginConfigs, pluginName)
|
|
}
|
|
l.saveSettings(settings)
|
|
return true
|
|
}
|
|
|
|
// ListPluginConfig returns all config key-value pairs for a plugin.
|
|
func (l *Loader) ListPluginConfig(pluginName string) map[string]string {
|
|
settings := l.loadSettings()
|
|
result := make(map[string]string)
|
|
if settings.PluginConfigs == nil {
|
|
return result
|
|
}
|
|
pluginCfg, ok := settings.PluginConfigs[pluginName]
|
|
if !ok {
|
|
return result
|
|
}
|
|
for k, v := range pluginCfg {
|
|
if s, ok := v.(string); ok {
|
|
result[k] = s
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// InjectPluginConfigEnv reads pluginConfigs from settings.json and sets
|
|
// environment variables for each configured key. This allows
|
|
// expandPluginVars (which calls os.Expand) to resolve ${KEY} references
|
|
// in plugin.json headers, endpoints, etc.
|
|
//
|
|
// Environment variables already set by the user take precedence — only
|
|
// keys not already present in the environment are injected. The return value
|
|
// reports whether at least one value was injected.
|
|
// dangerousEnvVars contains environment variable names that must never be
|
|
// set from plugin config because they can alter process behavior in
|
|
// security-critical ways (library injection, executable search path, etc.).
|
|
var dangerousEnvVars = map[string]bool{
|
|
"PATH": true, "HOME": true, "USER": true, "SHELL": true,
|
|
"LD_PRELOAD": true, "LD_LIBRARY_PATH": true,
|
|
"DYLD_INSERT_LIBRARIES": true, "DYLD_LIBRARY_PATH": true, "DYLD_FRAMEWORK_PATH": true,
|
|
"NODE_OPTIONS": true, "PYTHONPATH": true, "RUBYLIB": true,
|
|
"GOPATH": true, "GOROOT": true,
|
|
"HTTP_PROXY": true, "HTTPS_PROXY": true, "ALL_PROXY": true, "NO_PROXY": true,
|
|
"http_proxy": true, "https_proxy": true, "all_proxy": true, "no_proxy": true,
|
|
}
|
|
|
|
func (l *Loader) InjectPluginConfigEnv() bool {
|
|
settings := l.loadSettings()
|
|
injected := false
|
|
if len(settings.PluginConfigs) == 0 {
|
|
return false
|
|
}
|
|
for _, pluginCfg := range settings.PluginConfigs {
|
|
for key, val := range pluginCfg {
|
|
strVal, ok := val.(string)
|
|
if !ok || strVal == "" {
|
|
continue
|
|
}
|
|
// Block dangerous environment variable names.
|
|
if dangerousEnvVars[key] {
|
|
slog.Warn("plugin: blocked dangerous env var from config",
|
|
"key", key)
|
|
continue
|
|
}
|
|
// Do not override existing environment variables.
|
|
if _, exists := os.LookupEnv(key); exists {
|
|
continue
|
|
}
|
|
_ = os.Setenv(key, strVal)
|
|
injected = true
|
|
}
|
|
}
|
|
return injected
|
|
}
|
|
|
|
// LoadDev loads dev plugins registered via `dws plugin dev`.
|
|
// Dev plugins are loaded from their source directories without copying.
|
|
func (l *Loader) LoadDev() []*Plugin {
|
|
settings := l.loadSettings()
|
|
if len(settings.DevPlugins) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var plugins []*Plugin
|
|
for name, dir := range settings.DevPlugins {
|
|
if _, err := os.Stat(filepath.Join(dir, "plugin.json")); err != nil {
|
|
slog.Debug("plugin: dev plugin directory missing, skipping",
|
|
"name", name, "dir", dir)
|
|
continue
|
|
}
|
|
p := l.loadPlugin(dir)
|
|
if p != nil {
|
|
plugins = append(plugins, p)
|
|
slog.Debug("plugin: loaded dev plugin", "name", name, "dir", dir)
|
|
}
|
|
}
|
|
return plugins
|
|
}
|
|
|
|
// RegisterDevPlugin registers a source directory as a dev plugin.
|
|
func (l *Loader) RegisterDevPlugin(name, absDir string) error {
|
|
settings := l.loadSettings()
|
|
if settings.DevPlugins == nil {
|
|
settings.DevPlugins = make(map[string]string)
|
|
}
|
|
settings.DevPlugins[name] = absDir
|
|
l.saveSettings(settings)
|
|
return nil
|
|
}
|
|
|
|
// UnregisterDevPlugin removes a dev plugin registration.
|
|
func (l *Loader) UnregisterDevPlugin(name string) error {
|
|
settings := l.loadSettings()
|
|
if settings.DevPlugins == nil || settings.DevPlugins[name] == "" {
|
|
return fmt.Errorf("dev plugin %q is not registered", name)
|
|
}
|
|
delete(settings.DevPlugins, name)
|
|
l.saveSettings(settings)
|
|
return nil
|
|
}
|
|
|
|
// SyncSkills copies plugin SKILL.md files into all detected agent
|
|
// skill directories (e.g. ~/.claude/skills/dws/, ~/.cursor/skills/dws/).
|
|
// This makes plugin skills available to AI agents without CLI releases.
|
|
func SyncSkills(plugins []*Plugin) {
|
|
if len(plugins) == 0 {
|
|
return
|
|
}
|
|
|
|
homeDir, err := pluginUserHomeDir()
|
|
if err != nil {
|
|
slog.Debug("plugin: cannot get home dir for skill sync", "error", err)
|
|
return
|
|
}
|
|
|
|
// Known agent skill directories (subset of upgrade/paths.go knownSkillDirs).
|
|
agentDirs := []string{
|
|
".agents/skills",
|
|
".claude/skills",
|
|
".cursor/skills",
|
|
".qoder/skills",
|
|
".codex/skills",
|
|
}
|
|
|
|
for _, p := range plugins {
|
|
skillsDir := p.SkillsDir()
|
|
if _, err := os.Stat(skillsDir); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Walk the plugin's skills directory and copy files to each agent dir.
|
|
entries, err := pluginReadDir(skillsDir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, agentDir := range agentDirs {
|
|
agentBase := filepath.Join(homeDir, agentDir)
|
|
// Only sync to agents that are actually installed (parent dir exists).
|
|
parentGate := filepath.Dir(agentBase)
|
|
if _, err := os.Stat(parentGate); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
src := filepath.Join(skillsDir, entry.Name())
|
|
// Place plugin skills under dws/plugins/{plugin-name}/
|
|
dest := filepath.Join(agentBase, "dws", "plugins", p.Manifest.Name, entry.Name())
|
|
if entry.IsDir() {
|
|
_ = pluginCopyDir(src, dest)
|
|
} else {
|
|
_ = os.MkdirAll(filepath.Dir(dest), 0o755)
|
|
data, readErr := os.ReadFile(src)
|
|
if readErr == nil {
|
|
_ = os.WriteFile(dest, data, 0o644)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
slog.Debug("plugin: skill sync completed", "plugins", len(plugins))
|
|
}
|
|
|
|
// BuildPlugin runs the build command declared in plugin.json.
|
|
// It compiles the plugin's stdio server into a native binary so that
|
|
// users don't need language runtimes. Returns nil if no build is configured.
|
|
func BuildPlugin(pluginDir string) error {
|
|
manifest, err := ParseManifest(filepath.Join(pluginDir, "plugin.json"))
|
|
if err != nil {
|
|
return fmt.Errorf("parse manifest: %w", err)
|
|
}
|
|
if manifest.Build == nil {
|
|
return nil // no build configured
|
|
}
|
|
return runBuild(pluginDir, manifest.Build)
|
|
}
|
|
|
|
// runBuild executes the build command and verifies the output exists.
|
|
func runBuild(pluginDir string, build *BuildConfig) error {
|
|
if build.Command == "" {
|
|
return fmt.Errorf("build.command is empty")
|
|
}
|
|
|
|
// Validate build.output is a relative path within the plugin directory.
|
|
if build.Output != "" {
|
|
if filepath.IsAbs(build.Output) {
|
|
return fmt.Errorf("build.output must be a relative path, got %q", build.Output)
|
|
}
|
|
cleanOut := filepath.Clean(build.Output)
|
|
if strings.HasPrefix(cleanOut, "..") {
|
|
return fmt.Errorf("build.output must not escape plugin directory: %q", build.Output)
|
|
}
|
|
}
|
|
|
|
slog.Info("plugin: building", "dir", pluginDir, "command", build.Command)
|
|
|
|
cmd := buildCommandFor(runtime.GOOS, build.Command)
|
|
cmd.Dir = pluginDir
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
// Pass through environment + plugin root
|
|
cmd.Env = append(os.Environ(), "DWS_PLUGIN_ROOT="+pluginDir)
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("build failed: %w", err)
|
|
}
|
|
|
|
// Verify output binary exists
|
|
if build.Output != "" {
|
|
outPath := filepath.Join(pluginDir, build.Output)
|
|
info, err := os.Stat(outPath)
|
|
if err != nil {
|
|
return fmt.Errorf("build output not found at %s: %w", build.Output, err)
|
|
}
|
|
// Ensure the output is executable
|
|
if info.Mode()&0o111 == 0 {
|
|
_ = os.Chmod(outPath, info.Mode()|0o755)
|
|
}
|
|
}
|
|
|
|
slog.Info("plugin: build succeeded", "output", build.Output)
|
|
return nil
|
|
}
|
|
|
|
func buildCommandFor(goos, command string) *exec.Cmd {
|
|
if goos == "windows" {
|
|
return exec.Command("cmd", "/C", command)
|
|
}
|
|
return exec.Command("sh", "-c", command)
|
|
}
|
|
|
|
// copyDir recursively copies src to dst, skipping files whose content
|
|
// is identical to the destination. This avoids overwriting locked
|
|
// executables (e.g. a running stdio plugin on Windows).
|
|
// Symlinks are skipped for security (prevents path traversal attacks).
|
|
func copyDir(src, dst string) error {
|
|
cleanDst := filepath.Clean(dst) + string(os.PathSeparator)
|
|
if err := pluginMkdirAll(dst, 0o755); err != nil {
|
|
return err
|
|
}
|
|
return pluginWalk(src, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Skip symlinks to prevent path traversal.
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
rel, err := pluginRel(src, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(dst, rel)
|
|
// Guard against path traversal via crafted relative paths.
|
|
if target != cleanDst[:len(cleanDst)-1] && !strings.HasPrefix(target, cleanDst) {
|
|
return fmt.Errorf("path traversal detected: %s", rel)
|
|
}
|
|
if info.IsDir() {
|
|
return pluginMkdirAll(target, info.Mode())
|
|
}
|
|
data, err := pluginReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Skip if destination already has identical content (cheap size check first).
|
|
if targetInfo, statErr := os.Stat(target); statErr == nil && targetInfo.Size() == int64(len(data)) {
|
|
if existing, readErr := os.ReadFile(target); readErr == nil && bytes.Equal(existing, data) {
|
|
return nil
|
|
}
|
|
}
|
|
return pluginWriteFile(target, data, info.Mode())
|
|
})
|
|
}
|
|
|
|
// removeStaleFiles deletes files under dst that do not exist in src.
|
|
// Best-effort: errors are logged but do not fail the install.
|
|
func removeStaleFiles(src, dst string) {
|
|
srcSet := make(map[string]struct{})
|
|
_ = pluginWalk(src, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel, relErr := pluginRel(src, path)
|
|
if relErr != nil {
|
|
return nil
|
|
}
|
|
srcSet[rel] = struct{}{}
|
|
return nil
|
|
})
|
|
|
|
_ = pluginWalk(dst, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel, relErr := pluginRel(dst, path)
|
|
if relErr != nil {
|
|
return nil
|
|
}
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
if _, exists := srcSet[rel]; !exists {
|
|
if info.IsDir() {
|
|
_ = pluginRemoveAll(path)
|
|
return filepath.SkipDir
|
|
}
|
|
if removeErr := pluginRemove(path); removeErr != nil {
|
|
slog.Debug("plugin: failed to remove stale file", "path", path, "error", removeErr)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|