diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go index d3bef4b1..81b6363e 100644 --- a/internal/api/handlers/management/plugin_store.go +++ b/internal/api/handlers/management/plugin_store.go @@ -131,6 +131,12 @@ type sourcedPlugin struct { func (h *Handler) ListPluginStore(c *gin.Context) { pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, configs, host := h.pluginStoreSnapshot() + resolvedPluginsDir, errResolvePluginsDir := config.ResolvePluginsDir(pluginsDir) + if errResolvePluginsDir != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_directory_invalid", "message": errResolvePluginsDir.Error()}) + return + } + pluginsDir = resolvedPluginsDir sources, errSources := h.pluginStoreSources(sourceConfigs) if errSources != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()}) @@ -231,6 +237,12 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { } installCtx := c.Request.Context() pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, configs, host := h.pluginStoreSnapshot() + resolvedPluginsDir, errResolvePluginsDir := config.ResolvePluginsDir(pluginsDir) + if errResolvePluginsDir != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_directory_invalid", "message": errResolvePluginsDir.Error()}) + return + } + pluginsDir = resolvedPluginsDir sources, errSources := h.pluginStoreSources(sourceConfigs) if errSources != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()}) diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go index 1a153290..478f9697 100644 --- a/internal/api/handlers/management/plugin_store_test.go +++ b/internal/api/handlers/management/plugin_store_test.go @@ -690,23 +690,69 @@ func TestListPluginStoreReportsGitHubMetadataAuth(t *testing.T) { } } -func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { - t.Parallel() +func TestInstallPluginFromStoreRejectsUnresolvedPluginsDir(t *testing.T) { + workspace := t.TempDir() + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + t.Chdir(workspace) - pluginsDir := t.TempDir() - archiveData := makeManagementPluginStoreZip(t, "sample-provider"+managementPluginExtension(runtime.GOOS), "library-data") - archiveName := "sample-provider_0.1.0_" + runtime.GOOS + "_" + runtime.GOARCH + ".zip" - checksum := sha256.Sum256(archiveData) h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ - Enabled: false, - Dir: pluginsDir, - Configs: map[string]config.PluginInstanceConfig{ - "sample-provider": pluginConfigFromYAML(t, "enabled: false\nmode: fast\n"), - }, + Dir: "~/.cli-proxy-api/plugins", + Configs: map[string]config.PluginInstanceConfig{}, }, }, + pluginStoreRegistryURL: "https://registry.example/registry.json", + pluginStoreHTTPClient: fakePluginStoreHTTPClient{}, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Params = gin.Params{{Key: "id", Value: "sample-provider"}} + c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install", nil) + + h.InstallPluginFromStore(c) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String()) + } + var body map[string]any + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) + } + if body["error"] != "plugin_directory_invalid" { + t.Fatalf("error = %#v, want plugin_directory_invalid", body["error"]) + } + if _, errStat := os.Stat(filepath.Join(workspace, "~")); !os.IsNotExist(errStat) { + t.Fatalf("literal tilde directory stat error = %v, want not exist", errStat) + } +} + +func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { + workspace := t.TempDir() + homeDir := filepath.Join(workspace, "home") + if errMkdir := os.MkdirAll(homeDir, 0o755); errMkdir != nil { + t.Fatalf("MkdirAll(%s) error = %v", homeDir, errMkdir) + } + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + t.Chdir(workspace) + + cfg, errParse := config.ParseConfigBytes([]byte(` +plugins: + enabled: false + dir: "~/.cli-proxy-api/plugins" +`)) + if errParse != nil { + t.Fatalf("ParseConfigBytes() error = %v", errParse) + } + cfg.Plugins.Configs["sample-provider"] = pluginConfigFromYAML(t, "enabled: false\nmode: fast\n") + pluginsDir := filepath.Join(homeDir, ".cli-proxy-api", "plugins") + archiveData := makeManagementPluginStoreZip(t, "sample-provider"+managementPluginExtension(runtime.GOOS), "library-data") + archiveName := "sample-provider_0.1.0_" + runtime.GOOS + "_" + runtime.GOARCH + ".zip" + checksum := sha256.Sum256(archiveData) + h := &Handler{ + cfg: cfg, configFilePath: writeTestConfigFile(t), pluginStoreRegistryURL: "https://registry.example/registry.json", pluginStoreHTTPClient: fakePluginStoreHTTPClient{ diff --git a/internal/api/handlers/management/plugins.go b/internal/api/handlers/management/plugins.go index 76c9391c..3409f6a7 100644 --- a/internal/api/handlers/management/plugins.go +++ b/internal/api/handlers/management/plugins.go @@ -81,6 +81,12 @@ func (h *Handler) ListPlugins(c *gin.Context) { host := h.pluginHost h.mu.Unlock() + resolvedPluginsDir, errResolvePluginsDir := config.ResolvePluginsDir(pluginsDir) + if errResolvePluginsDir != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_directory_invalid", "message": errResolvePluginsDir.Error()}) + return + } + pluginsDir = resolvedPluginsDir entries := make(map[string]pluginListEntry) files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir, pluginStoreDesiredVersions(configs)) if errDiscover != nil { @@ -185,7 +191,12 @@ func (h *Handler) GetPluginConfig(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) return } - discovered, errDiscover := pluginDiscovered(pluginsDir, id) + resolvedPluginsDir, errResolvePluginsDir := config.ResolvePluginsDir(pluginsDir) + if errResolvePluginsDir != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_directory_invalid", "message": errResolvePluginsDir.Error()}) + return + } + discovered, errDiscover := pluginDiscovered(resolvedPluginsDir, id) if errDiscover != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_discovery_failed", "message": errDiscover.Error()}) return @@ -326,6 +337,12 @@ func (h *Handler) DeletePlugin(c *gin.Context) { host := h.pluginHost h.mu.Unlock() + resolvedPluginsDir, errResolvePluginsDir := config.ResolvePluginsDir(pluginsDir) + if errResolvePluginsDir != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_directory_invalid", "message": errResolvePluginsDir.Error()}) + return + } + pluginsDir = resolvedPluginsDir var desiredVersions map[string]string if configured { desiredVersions = pluginStoreDesiredVersions(map[string]config.PluginInstanceConfig{id: item}) diff --git a/internal/api/handlers/management/plugins_test.go b/internal/api/handlers/management/plugins_test.go index a9937194..ca112e58 100644 --- a/internal/api/handlers/management/plugins_test.go +++ b/internal/api/handlers/management/plugins_test.go @@ -522,6 +522,57 @@ func TestPatchPluginConfigMergesAndDeletesFields(t *testing.T) { } } +func TestDeletePluginRejectsUnresolvedPluginsDir(t *testing.T) { + workspace := t.TempDir() + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + t.Chdir(workspace) + + literalPluginsDir := filepath.Join(workspace, "~", ".cli-proxy-api", "plugins") + targetDir := filepath.Join(literalPluginsDir, runtime.GOOS, runtime.GOARCH) + if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { + t.Fatalf("MkdirAll(%s) error = %v", targetDir, errMkdir) + } + target := filepath.Join(targetDir, "sample"+managementPluginExtension(runtime.GOOS)) + if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil { + t.Fatalf("WriteFile(%s) error = %v", target, errWrite) + } + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Dir: "~/.cli-proxy-api/plugins", + Configs: map[string]config.PluginInstanceConfig{ + "sample": pluginConfigFromYAML(t, "enabled: false\n"), + }, + }, + }, + configFilePath: writeTestConfigFile(t), + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Params = gin.Params{{Key: "id", Value: "sample"}} + c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/plugins/sample", nil) + + h.DeletePlugin(c) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String()) + } + var body map[string]any + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) + } + if body["error"] != "plugin_directory_invalid" { + t.Fatalf("error = %#v, want plugin_directory_invalid", body["error"]) + } + if _, errStat := os.Stat(target); errStat != nil { + t.Fatalf("literal tilde target stat error = %v, want retained", errStat) + } + if _, configured := h.cfg.Plugins.Configs["sample"]; !configured { + t.Fatal("plugin config removed after directory resolution failure") + } +} + func TestDeletePluginRemovesDiscoveredFileAndConfig(t *testing.T) { t.Parallel() diff --git a/internal/config/config.go b/internal/config/config.go index a7a45c9f..0647fb8f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -811,6 +811,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { } cfg.NormalizePluginsConfig() + if errResolvePluginsDir := cfg.ResolvePluginsDir(); errResolvePluginsDir != nil && cfg.Plugins.Enabled { + return nil, errResolvePluginsDir + } // Sanitize Gemini API key configuration and migrate legacy entries. cfg.SanitizeGeminiKeys() @@ -859,7 +862,7 @@ func (cfg *Config) NormalizePluginsConfig() { } cfg.Plugins.Dir = strings.TrimSpace(cfg.Plugins.Dir) if cfg.Plugins.Dir == "" { - cfg.Plugins.Dir = "plugins" + cfg.Plugins.Dir = defaultPluginsDir } if len(cfg.Plugins.StoreSources) > 0 { sources := make([]string, 0, len(cfg.Plugins.StoreSources)) diff --git a/internal/config/parse.go b/internal/config/parse.go index f432aefa..7dd818cc 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -76,6 +76,9 @@ func ParseConfigBytes(data []byte) (*Config, error) { } cfg.NormalizePluginsConfig() + if errResolvePluginsDir := cfg.ResolvePluginsDir(); errResolvePluginsDir != nil && cfg.Plugins.Enabled { + return nil, errResolvePluginsDir + } // Apply the same sanitization pipeline. cfg.SanitizeGeminiKeys() diff --git a/internal/config/plugin_config_test.go b/internal/config/plugin_config_test.go index 0eb2813f..e1a81d6b 100644 --- a/internal/config/plugin_config_test.go +++ b/internal/config/plugin_config_test.go @@ -31,6 +31,45 @@ plugins: {} } } +func TestParseConfigBytes_PluginsDirExpandsLeadingTilde(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + cfg, errParse := ParseConfigBytes([]byte(` +plugins: + dir: "~/.cli-proxy-api/plugins" +`)) + if errParse != nil { + t.Fatalf("ParseConfigBytes() error = %v", errParse) + } + + want := filepath.Join(homeDir, ".cli-proxy-api", "plugins") + if cfg.Plugins.Dir != want { + t.Fatalf("Plugins.Dir = %q, want %q", cfg.Plugins.Dir, want) + } +} + +func TestLoadConfig_PluginsDirExpandsLeadingTilde(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + configPath := filepath.Join(t.TempDir(), "config.yaml") + if errWrite := os.WriteFile(configPath, []byte("plugins:\n dir: \"~/.cli-proxy-api/plugins\"\n"), 0o600); errWrite != nil { + t.Fatalf("os.WriteFile() error = %v", errWrite) + } + + cfg, errLoad := LoadConfig(configPath) + if errLoad != nil { + t.Fatalf("LoadConfig() error = %v", errLoad) + } + + want := filepath.Join(homeDir, ".cli-proxy-api", "plugins") + if cfg.Plugins.Dir != want { + t.Fatalf("Plugins.Dir = %q, want %q", cfg.Plugins.Dir, want) + } +} + func TestParseConfigBytes_PluginStoreSources(t *testing.T) { cfg, errParse := ParseConfigBytes([]byte(` plugins: diff --git a/internal/config/plugin_path.go b/internal/config/plugin_path.go new file mode 100644 index 00000000..b42c0464 --- /dev/null +++ b/internal/config/plugin_path.go @@ -0,0 +1,46 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const defaultPluginsDir = "plugins" + +// ResolvePluginsDir normalizes the plugin directory for consistent use throughout the app. +// It expands a leading tilde (~) to the user's home directory and defaults empty values to plugins. +func ResolvePluginsDir(pluginsDir string) (string, error) { + pluginsDir = strings.TrimSpace(pluginsDir) + if pluginsDir == "" { + pluginsDir = defaultPluginsDir + } + if strings.HasPrefix(pluginsDir, "~") { + homeDir, errUserHomeDir := os.UserHomeDir() + if errUserHomeDir != nil { + return "", fmt.Errorf("resolve plugins directory: %w", errUserHomeDir) + } + remainder := strings.TrimPrefix(pluginsDir, "~") + remainder = strings.TrimLeft(remainder, "/\\") + if remainder == "" { + return filepath.Clean(homeDir), nil + } + normalized := strings.ReplaceAll(remainder, "\\", "/") + return filepath.Clean(filepath.Join(homeDir, filepath.FromSlash(normalized))), nil + } + return filepath.Clean(pluginsDir), nil +} + +// ResolvePluginsDir resolves and stores the effective plugin directory. +func (cfg *Config) ResolvePluginsDir() error { + if cfg == nil { + return nil + } + pluginsDir, errResolvePluginsDir := ResolvePluginsDir(cfg.Plugins.Dir) + if errResolvePluginsDir != nil { + return errResolvePluginsDir + } + cfg.Plugins.Dir = pluginsDir + return nil +} diff --git a/internal/homeplugins/sync.go b/internal/homeplugins/sync.go index 9fd21093..8ed2f16d 100644 --- a/internal/homeplugins/sync.go +++ b/internal/homeplugins/sync.go @@ -136,9 +136,11 @@ func SyncPlatformWithReport(ctx context.Context, cfg *config.Config, pluginRunti return report, errPlatform } report.Platform = platform - root := strings.TrimSpace(cfg.Plugins.Dir) - if root == "" { - root = "plugins" + root, errResolvePluginsDir := config.ResolvePluginsDir(cfg.Plugins.Dir) + if errResolvePluginsDir != nil { + errPluginsDir := fmt.Errorf("home plugins: %w", errResolvePluginsDir) + finishReport(&report, errPluginsDir) + return report, errPluginsDir } client := newPluginStoreClient(cfg) var syncErrors []error @@ -226,9 +228,14 @@ func DeleteWithReport(ctx context.Context, cfg *config.Config, pluginRuntime Plu finishReport(&report, errors.New(status.Error)) return report } - root := strings.TrimSpace(cfg.Plugins.Dir) - if root == "" { - root = "plugins" + root, errResolvePluginsDir := config.ResolvePluginsDir(cfg.Plugins.Dir) + if errResolvePluginsDir != nil { + errPluginsDir := fmt.Errorf("home plugins: %w", errResolvePluginsDir) + status.InstallStatus = pluginInstallStatusFailed + status.Error = errPluginsDir.Error() + report.Plugins = append(report.Plugins, status) + finishReport(&report, errPluginsDir) + return report } path, deleted, errDelete := deletePluginArtifact(root, pluginID, pluginRuntime) status.Path = strings.TrimSpace(path) diff --git a/internal/homeplugins/sync_test.go b/internal/homeplugins/sync_test.go index 5421cb6a..8a636515 100644 --- a/internal/homeplugins/sync_test.go +++ b/internal/homeplugins/sync_test.go @@ -302,6 +302,44 @@ func TestMarkLoadResultsPreservesInstallFailure(t *testing.T) { } } +func TestDeleteWithReportRejectsUnresolvedPluginsDir(t *testing.T) { + workspace := t.TempDir() + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + t.Chdir(workspace) + + literalPluginsDir := filepath.Join(workspace, "~", ".cli-proxy-api", "plugins") + targetDir := filepath.Join(literalPluginsDir, runtime.GOOS, runtime.GOARCH) + if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { + t.Fatalf("MkdirAll(%s) error = %v", targetDir, errMkdir) + } + target := filepath.Join(targetDir, "sample"+pluginExtension(runtime.GOOS)) + if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil { + t.Fatalf("WriteFile(%s) error = %v", target, errWrite) + } + cfg := &config.Config{ + Home: config.HomeConfig{Enabled: true}, + Plugins: config.PluginsConfig{ + Dir: "~/.cli-proxy-api/plugins", + }, + } + + report := DeleteWithReport(context.Background(), cfg, nil, 41, "sample") + + if report.OK || report.Status != pluginTaskStatusError { + t.Fatalf("report = %+v, want failed delete task", report) + } + if len(report.Plugins) != 1 || report.Plugins[0].InstallStatus != pluginInstallStatusFailed { + t.Fatalf("plugin report = %+v, want failed status", report.Plugins) + } + if !strings.Contains(report.Plugins[0].Error, "resolve plugins directory") { + t.Fatalf("plugin error = %q, want directory resolution error", report.Plugins[0].Error) + } + if _, errStat := os.Stat(target); errStat != nil { + t.Fatalf("literal tilde target stat error = %v, want retained", errStat) + } +} + func TestDeleteWithReportRemovesCurrentPlatformPlugin(t *testing.T) { root := t.TempDir() targetDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH) diff --git a/internal/pluginhost/config.go b/internal/pluginhost/config.go index a004eea9..04649c46 100644 --- a/internal/pluginhost/config.go +++ b/internal/pluginhost/config.go @@ -26,20 +26,24 @@ type runtimeItemConfig struct { ConfigYAML []byte } -func runtimeConfigFromConfig(cfg *config.Config) runtimeConfig { +func runtimeConfigFromConfig(cfg *config.Config) (runtimeConfig, error) { out := runtimeConfig{ Dir: "plugins", Items: make(map[string]runtimeItemConfig), } if cfg == nil { - return out + return out, nil } out.Enabled = cfg.Plugins.Enabled - out.Dir = strings.TrimSpace(cfg.Plugins.Dir) - if out.Dir == "" { - out.Dir = "plugins" + if !out.Enabled { + return out, nil } + pluginsDir, errResolvePluginsDir := config.ResolvePluginsDir(cfg.Plugins.Dir) + if errResolvePluginsDir != nil { + return runtimeConfig{}, errResolvePluginsDir + } + out.Dir = pluginsDir ids := make([]string, 0, len(cfg.Plugins.Configs)) for id := range cfg.Plugins.Configs { @@ -62,7 +66,7 @@ func runtimeConfigFromConfig(cfg *config.Config) runtimeConfig { ConfigYAML: runtimeConfigYAML(item, enabled), } } - return out + return out, nil } func defaultRuntimeItemConfig(id string) runtimeItemConfig { diff --git a/internal/pluginhost/config_test.go b/internal/pluginhost/config_test.go index cc5b9899..8c387ffe 100644 --- a/internal/pluginhost/config_test.go +++ b/internal/pluginhost/config_test.go @@ -68,7 +68,10 @@ func TestRuntimeConfigFromConfigExtractsStoreVersion(t *testing.T) { }, } - got := runtimeConfigFromConfig(cfg) + got, errRuntimeConfig := runtimeConfigFromConfig(cfg) + if errRuntimeConfig != nil { + t.Fatalf("runtimeConfigFromConfig() error = %v", errRuntimeConfig) + } if got.Items["alpha"].Version != "1.0.3" { t.Fatalf("runtimeConfigFromConfig() version = %q, want 1.0.3", got.Items["alpha"].Version) } @@ -92,7 +95,10 @@ func TestRuntimeConfigFromConfigDerivesStoreVersionFromReleaseTag(t *testing.T) }, } - got := runtimeConfigFromConfig(cfg) + got, errRuntimeConfig := runtimeConfigFromConfig(cfg) + if errRuntimeConfig != nil { + t.Fatalf("runtimeConfigFromConfig() error = %v", errRuntimeConfig) + } if got.Items["alpha"].Version != "1.0.3" { t.Fatalf("runtimeConfigFromConfig() version = %q, want 1.0.3", got.Items["alpha"].Version) } diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go index 301945a0..12b4aa5c 100644 --- a/internal/pluginhost/host.go +++ b/internal/pluginhost/host.go @@ -186,7 +186,11 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { h.applyMu.Lock() defer h.applyMu.Unlock() - rc := runtimeConfigFromConfig(cfg) + rc, errRuntimeConfig := runtimeConfigFromConfig(cfg) + if errRuntimeConfig != nil { + log.WithError(errRuntimeConfig).Error("failed to apply plugin runtime config") + return + } h.mu.Lock() h.runtimeConfig = cfg h.mu.Unlock() diff --git a/internal/pluginhost/host_test.go b/internal/pluginhost/host_test.go index 483fb848..39f8a224 100644 --- a/internal/pluginhost/host_test.go +++ b/internal/pluginhost/host_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "net/http" + "path/filepath" "strings" "sync" "sync/atomic" @@ -48,6 +49,77 @@ func TestHostApplyConfig_DisabledGlobalSkipsSnapshot(t *testing.T) { } } +func TestHostApplyConfig_DisabledGlobalDoesNotResolvePluginsDir(t *testing.T) { + loader := newTestSymbolLoader() + plugin := &testPlugin{ + registerResult: validTestPlugin("alpha"), + reconfigureResult: validTestPlugin("alpha"), + } + loader.lookups["alpha"] = newTestSymbolLookup(plugin) + h := NewForTest(loader) + t.Cleanup(h.ShutdownAll) + + h.ApplyConfig(context.Background(), &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: makePluginDir(t, "alpha"), + Configs: enabledPluginConfigs("alpha"), + }, + }) + if !h.PluginRegistered("alpha") { + t.Fatal("PluginRegistered(alpha) = false, want true before disable") + } + + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + disabledCfg, errParseConfig := config.ParseConfigBytes([]byte(` +plugins: + enabled: false + dir: "~/.cli-proxy-api/plugins" +`)) + if errParseConfig != nil { + t.Fatalf("ParseConfigBytes() error = %v", errParseConfig) + } + h.ApplyConfig(context.Background(), disabledCfg) + + if h.PluginRegistered("alpha") { + t.Fatal("PluginRegistered(alpha) = true, want false after disable") + } + if snap := h.Snapshot(); snap.enabled || len(snap.records) != 0 { + t.Fatalf("Snapshot() = %+v, want empty disabled snapshot", snap) + } +} + +func TestHostApplyConfig_ExpandsPluginsDirLeadingTilde(t *testing.T) { + loader := newTestSymbolLoader() + plugin := &testPlugin{ + registerResult: validTestPlugin("alpha"), + reconfigureResult: validTestPlugin("alpha"), + } + loader.lookups["alpha"] = newTestSymbolLookup(plugin) + h := NewForTest(loader) + t.Cleanup(h.ShutdownAll) + + pluginsDir := makePluginDir(t, "alpha") + homeDir := filepath.Dir(pluginsDir) + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + h.ApplyConfig(context.Background(), &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: "~/" + filepath.ToSlash(filepath.Base(pluginsDir)), + Configs: enabledPluginConfigs("alpha"), + }, + }) + + if loader.openCalls != 1 { + t.Fatalf("Open calls = %d, want 1", loader.openCalls) + } + if !h.PluginRegistered("alpha") { + t.Fatal("PluginRegistered(alpha) = false, want true") + } +} + func TestHostApplyConfig_DisabledPluginSkipsCapability(t *testing.T) { enabled := false loader := newTestSymbolLoader() diff --git a/sdk/cliproxy/builder.go b/sdk/cliproxy/builder.go index 24ac43c3..2c17bb8b 100644 --- a/sdk/cliproxy/builder.go +++ b/sdk/cliproxy/builder.go @@ -187,6 +187,10 @@ func (b *Builder) Build() (*Service, error) { if b.configPath == "" { return nil, fmt.Errorf("cliproxy: configuration path is required") } + b.cfg.NormalizePluginsConfig() + if errResolvePluginsDir := b.cfg.ResolvePluginsDir(); errResolvePluginsDir != nil && b.cfg.Plugins.Enabled { + return nil, fmt.Errorf("cliproxy: %w", errResolvePluginsDir) + } tokenProvider := b.tokenProvider if tokenProvider == nil {