Files
johnandClaude Opus 4.7 7baf6794b6 fix(schema): mark plugin config injection as uncertain
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>
2026-09-18 15:18:27 +08:00

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
})
}