From 303c0f2f5336d81086b3bfb508e9db2d3fbc759e Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Mon, 15 Jun 2026 00:50:22 +0800 Subject: [PATCH 1/3] feat(pluginstore): add support for third-party plugin store sources and enhance plugin management --- config.example.yaml | 5 + .../api/handlers/management/plugin_store.go | 213 ++++++++++++++++-- .../handlers/management/plugin_store_test.go | 182 +++++++++++++++ internal/config/config.go | 22 ++ internal/config/plugin_config_test.go | 23 ++ internal/pluginstore/registry.go | 45 ++++ internal/pluginstore/registry_test.go | 37 +++ 7 files changed, 506 insertions(+), 21 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 1b408fadd..8ed17c2ab 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -61,6 +61,11 @@ pprof: plugins: enabled: false dir: "plugins" + # Additional plugin store registries. The built-in official registry is always included. + # store-sources: + # - id: community + # name: Community Plugins + # url: "https://example.com/cliproxy-plugins/registry.json" configs: example: enabled: true diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go index d1a186240..9e9d9768b 100644 --- a/internal/api/handlers/management/plugin_store.go +++ b/internal/api/handlers/management/plugin_store.go @@ -37,10 +37,29 @@ type pluginReleaseCacheEntry struct { type pluginStoreListResponse struct { PluginsEnabled bool `json:"plugins_enabled"` PluginsDir string `json:"plugins_dir"` + Sources []pluginStoreSource `json:"sources"` + SourceErrors []pluginStoreSourceErr `json:"source_errors,omitempty"` Plugins []pluginStoreListEntry `json:"plugins"` } +type pluginStoreSource struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` +} + +type pluginStoreSourceErr struct { + SourceID string `json:"source_id"` + SourceName string `json:"source_name"` + SourceURL string `json:"source_url"` + Message string `json:"message"` +} + type pluginStoreListEntry struct { + StoreID string `json:"store_id"` + SourceID string `json:"source_id"` + SourceName string `json:"source_name"` + SourceURL string `json:"source_url"` ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` @@ -63,6 +82,9 @@ type pluginStoreListEntry struct { type pluginInstallResponse struct { Status string `json:"status"` + SourceID string `json:"source_id"` + SourceName string `json:"source_name"` + SourceURL string `json:"source_url"` ID string `json:"id"` Version string `json:"version"` Path string `json:"path"` @@ -80,12 +102,21 @@ type pluginLocalStatus struct { EffectiveEnabled bool } +type sourcedPlugin struct { + source pluginstore.Source + plugin pluginstore.Plugin +} + func (h *Handler) ListPluginStore(c *gin.Context) { - pluginsEnabled, pluginsDir, proxyURL, configs, host := h.pluginStoreSnapshot() - client := h.newPluginStoreClient(proxyURL) - registry, errRegistry := client.FetchRegistry(c.Request.Context()) - if errRegistry != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": errRegistry.Error()}) + pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, configs, host := h.pluginStoreSnapshot() + sources, errSources := h.pluginStoreSources(sourceConfigs) + if errSources != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()}) + return + } + plugins, sourceErrors := h.fetchSourcedPlugins(c.Request.Context(), proxyURL, sources) + if len(plugins) == 0 && len(sourceErrors) > 0 { + c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": sourceErrors[0].Message}) return } statuses, errStatus := pluginLocalStatuses(pluginsEnabled, pluginsDir, configs, host) @@ -94,10 +125,16 @@ func (h *Handler) ListPluginStore(c *gin.Context) { return } - latestVersions := h.latestPluginVersions(c.Request.Context(), client, registry.Plugins) + latestInput := make([]pluginstore.Plugin, 0, len(plugins)) + for _, item := range plugins { + latestInput = append(latestInput, item.plugin) + } + client := h.newPluginStoreClient(proxyURL, "") + latestVersions := h.latestPluginVersions(c.Request.Context(), client, latestInput) - entries := make([]pluginStoreListEntry, 0, len(registry.Plugins)) - for index, plugin := range registry.Plugins { + entries := make([]pluginStoreListEntry, 0, len(plugins)) + for index, item := range plugins { + plugin := item.plugin status := statuses[plugin.ID] installedVersion := status.InstalledVersion // Fall back to the registry version when the latest release is unknown. @@ -106,6 +143,10 @@ func (h *Handler) ListPluginStore(c *gin.Context) { storeVersion = latestVersions[index] } entries = append(entries, pluginStoreListEntry{ + StoreID: htmlsanitize.String(item.source.ID + "/" + plugin.ID), + SourceID: htmlsanitize.String(item.source.ID), + SourceName: htmlsanitize.String(item.source.Name), + SourceURL: htmlsanitize.String(item.source.URL), ID: htmlsanitize.String(plugin.ID), Name: htmlsanitize.String(plugin.Name), Description: htmlsanitize.String(plugin.Description), @@ -130,6 +171,8 @@ func (h *Handler) ListPluginStore(c *gin.Context) { c.JSON(http.StatusOK, pluginStoreListResponse{ PluginsEnabled: pluginsEnabled, PluginsDir: htmlsanitize.String(pluginsDir), + Sources: sanitizePluginStoreSources(sources), + SourceErrors: sanitizePluginStoreSourceErrors(sourceErrors), Plugins: entries, }) } @@ -144,16 +187,14 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { return } installCtx := c.Request.Context() - pluginsEnabled, pluginsDir, proxyURL, _, host := h.pluginStoreSnapshot() - client := h.newPluginStoreClient(proxyURL) - registry, errRegistry := client.FetchRegistry(installCtx) - if errRegistry != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": errRegistry.Error()}) + pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, _, host := h.pluginStoreSnapshot() + sources, errSources := h.pluginStoreSources(sourceConfigs) + if errSources != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()}) return } - plugin, okPlugin := registry.PluginByID(id) + source, plugin, client, okPlugin := h.findPluginStoreInstallTarget(installCtx, proxyURL, sources, id, c.Query("source"), c) if !okPlugin { - c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found in registry"}) return } @@ -236,6 +277,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { h.reloadConfigAfterManagementSave(c.Request.Context(), reloadCfg) log.WithFields(log.Fields{ "plugin_id": result.ID, + "source_id": source.ID, "version": result.Version, "path": result.Path, "overwritten": result.Overwritten, @@ -243,6 +285,9 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { c.JSON(http.StatusOK, pluginInstallResponse{ Status: "installed", + SourceID: htmlsanitize.String(source.ID), + SourceName: htmlsanitize.String(source.Name), + SourceURL: htmlsanitize.String(source.URL), ID: htmlsanitize.String(result.ID), Version: htmlsanitize.String(result.Version), Path: htmlsanitize.String(result.Path), @@ -265,27 +310,44 @@ func (h *Handler) enablePluginConfigLocked(id string) error { return nil } -func (h *Handler) pluginStoreSnapshot() (bool, string, string, map[string]config.PluginInstanceConfig, *pluginhost.Host) { +func (h *Handler) pluginStoreSnapshot() (bool, string, string, []config.PluginStoreSource, map[string]config.PluginInstanceConfig, *pluginhost.Host) { if h == nil || h.cfg == nil { - return false, "plugins", "", map[string]config.PluginInstanceConfig{}, nil + return false, "plugins", "", nil, map[string]config.PluginInstanceConfig{}, nil } h.mu.Lock() defer h.mu.Unlock() pluginsEnabled := h.cfg.Plugins.Enabled pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir) proxyURL := strings.TrimSpace(h.cfg.ProxyURL) + sourceConfigs := append([]config.PluginStoreSource(nil), h.cfg.Plugins.StoreSources...) configs := make(map[string]config.PluginInstanceConfig, len(h.cfg.Plugins.Configs)) for id, item := range h.cfg.Plugins.Configs { configs[id] = item } - return pluginsEnabled, pluginsDir, proxyURL, configs, h.pluginHost + return pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, configs, h.pluginHost } -func (h *Handler) newPluginStoreClient(proxyURL string) pluginstore.Client { - registryURL := "" +func (h *Handler) pluginStoreSources(sourceConfigs []config.PluginStoreSource) ([]pluginstore.Source, error) { + if h != nil && strings.TrimSpace(h.pluginStoreRegistryURL) != "" { + source := pluginstore.DefaultSource() + source.URL = strings.TrimSpace(h.pluginStoreRegistryURL) + return []pluginstore.Source{source}, nil + } + sources := make([]pluginstore.Source, 0, len(sourceConfigs)) + for _, source := range sourceConfigs { + sources = append(sources, pluginstore.Source{ + ID: source.ID, + Name: source.Name, + URL: source.URL, + }) + } + return pluginstore.NormalizeSources(sources) +} + +func (h *Handler) newPluginStoreClient(proxyURL string, registryURL string) pluginstore.Client { + registryURL = strings.TrimSpace(registryURL) var httpClient pluginstore.HTTPDoer if h != nil { - registryURL = strings.TrimSpace(h.pluginStoreRegistryURL) httpClient = h.pluginStoreHTTPClient } if registryURL == "" { @@ -301,6 +363,115 @@ func (h *Handler) newPluginStoreClient(proxyURL string) pluginstore.Client { return pluginstore.Client{HTTPClient: client, RegistryURL: registryURL} } +func (h *Handler) fetchSourcedPlugins(ctx context.Context, proxyURL string, sources []pluginstore.Source) ([]sourcedPlugin, []pluginStoreSourceErr) { + plugins := make([]sourcedPlugin, 0) + sourceErrors := make([]pluginStoreSourceErr, 0) + for _, source := range sources { + client := h.newPluginStoreClient(proxyURL, source.URL) + registry, errRegistry := client.FetchRegistry(ctx) + if errRegistry != nil { + sourceErrors = append(sourceErrors, pluginStoreSourceErr{ + SourceID: source.ID, + SourceName: source.Name, + SourceURL: source.URL, + Message: errRegistry.Error(), + }) + continue + } + for _, plugin := range registry.Plugins { + plugins = append(plugins, sourcedPlugin{source: source, plugin: plugin}) + } + } + return plugins, sourceErrors +} + +func (h *Handler) findPluginStoreInstallTarget(ctx context.Context, proxyURL string, sources []pluginstore.Source, id string, requestedSourceID string, c *gin.Context) (pluginstore.Source, pluginstore.Plugin, pluginstore.Client, bool) { + requestedSourceID = strings.TrimSpace(requestedSourceID) + if requestedSourceID != "" { + for _, source := range sources { + if source.ID != requestedSourceID { + continue + } + client := h.newPluginStoreClient(proxyURL, source.URL) + registry, errRegistry := client.FetchRegistry(ctx) + if errRegistry != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": errRegistry.Error()}) + return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false + } + plugin, okPlugin := registry.PluginByID(id) + if !okPlugin { + c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found in registry source"}) + return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false + } + return source, plugin, client, true + } + c.JSON(http.StatusNotFound, gin.H{"error": "plugin_store_source_not_found", "message": "plugin store source not found"}) + return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false + } + + plugins, sourceErrors := h.fetchSourcedPlugins(ctx, proxyURL, sources) + matches := make([]sourcedPlugin, 0) + for _, item := range plugins { + if item.plugin.ID == id { + matches = append(matches, item) + } + } + if len(matches) == 0 { + if len(plugins) == 0 && len(sourceErrors) > 0 { + c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": sourceErrors[0].Message}) + return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false + } + c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found in registry"}) + return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false + } + if len(matches) > 1 { + c.JSON(http.StatusConflict, gin.H{ + "error": "plugin_store_source_required", + "message": "multiple plugin store sources contain this plugin id; specify source", + "sources": sanitizePluginStoreSources(sourcedPluginSources(matches)), + }) + return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false + } + match := matches[0] + return match.source, match.plugin, h.newPluginStoreClient(proxyURL, match.source.URL), true +} + +func sourcedPluginSources(plugins []sourcedPlugin) []pluginstore.Source { + sources := make([]pluginstore.Source, 0, len(plugins)) + for _, item := range plugins { + sources = append(sources, item.source) + } + return sources +} + +func sanitizePluginStoreSources(sources []pluginstore.Source) []pluginStoreSource { + out := make([]pluginStoreSource, 0, len(sources)) + for _, source := range sources { + out = append(out, pluginStoreSource{ + ID: htmlsanitize.String(source.ID), + Name: htmlsanitize.String(source.Name), + URL: htmlsanitize.String(source.URL), + }) + } + return out +} + +func sanitizePluginStoreSourceErrors(sourceErrors []pluginStoreSourceErr) []pluginStoreSourceErr { + if len(sourceErrors) == 0 { + return nil + } + out := make([]pluginStoreSourceErr, 0, len(sourceErrors)) + for _, sourceError := range sourceErrors { + out = append(out, pluginStoreSourceErr{ + SourceID: htmlsanitize.String(sourceError.SourceID), + SourceName: htmlsanitize.String(sourceError.SourceName), + SourceURL: htmlsanitize.String(sourceError.SourceURL), + Message: htmlsanitize.String(sourceError.Message), + }) + } + return out +} + // latestPluginVersions resolves the latest release version of each registry // plugin concurrently, returning results positionally aligned with plugins. // Unresolved entries are left empty so callers can fall back gracefully. diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go index 4cb59b4e4..1b5f1bf8a 100644 --- a/internal/api/handlers/management/plugin_store_test.go +++ b/internal/api/handlers/management/plugin_store_test.go @@ -20,6 +20,7 @@ import ( "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginstore" ) func TestListPluginStoreMergesInstalledStatus(t *testing.T) { @@ -239,6 +240,71 @@ func TestListPluginStoreFallsBackToRegistryVersion(t *testing.T) { } } +func TestListPluginStoreIncludesThirdPartySources(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: t.TempDir(), + StoreSources: []config.PluginStoreSource{{ + ID: "community", + Name: "Community", + URL: "https://community.example/registry.json", + }}, + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreHTTPClient: fakePluginStoreHTTPClient{ + pluginstore.DefaultRegistryURL: registryJSON(t), + "https://community.example/registry.json": []byte(`{ + "schema_version": 1, + "plugins": [{ + "id": "third-provider", + "name": "Third Provider", + "description": "Adds third-party provider support.", + "author": "community", + "version": "0.3.0", + "repository": "https://github.com/community/cliproxy-third-provider-plugin" + }] + }`), + }, + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil) + + h.ListPluginStore(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var body pluginStoreListResponse + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) + } + if len(body.Sources) != 2 { + t.Fatalf("sources len = %d, want 2: %#v", len(body.Sources), body.Sources) + } + if len(body.Plugins) != 2 { + t.Fatalf("plugins len = %d, want 2: %#v", len(body.Plugins), body.Plugins) + } + byID := map[string]pluginStoreListEntry{} + for _, entry := range body.Plugins { + byID[entry.ID] = entry + } + if byID["sample-provider"].SourceID != pluginstore.DefaultSourceID { + t.Fatalf("official source id = %q, want %q", byID["sample-provider"].SourceID, pluginstore.DefaultSourceID) + } + third := byID["third-provider"] + if third.StoreID != "community/third-provider" || third.SourceName != "Community" || third.SourceURL != "https://community.example/registry.json" { + t.Fatalf("third-party source fields = %#v", third) + } +} + func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -317,6 +383,106 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { } } +func TestInstallPluginFromStoreUsesRequestedThirdPartySource(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + pluginsDir := t.TempDir() + archiveData := makeManagementPluginStoreZip(t, "sample-provider"+managementPluginExtension(runtime.GOOS), "third-party-library-data") + archiveName := "sample-provider_0.3.0_" + runtime.GOOS + "_" + runtime.GOARCH + ".zip" + checksum := sha256.Sum256(archiveData) + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: false, + Dir: pluginsDir, + StoreSources: []config.PluginStoreSource{{ + ID: "community", + Name: "Community", + URL: "https://community.example/registry.json", + }}, + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreHTTPClient: fakePluginStoreHTTPClient{ + pluginstore.DefaultRegistryURL: registryJSON(t), + "https://community.example/registry.json": thirdPartySampleRegistryJSON(t), + "https://api.github.com/repos/community/cliproxy-sample-provider-plugin/releases/latest": []byte(`{ + "tag_name": "v0.3.0", + "assets": [ + {"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"}, + {"name": "checksums.txt", "browser_download_url": "https://downloads.example/checksums.txt"} + ] + }`), + "https://downloads.example/" + archiveName: archiveData, + "https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"), + }, + } + + 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?source=community", nil) + + h.InstallPluginFromStore(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var body pluginInstallResponse + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) + } + if body.SourceID != "community" || body.Version != "0.3.0" { + t.Fatalf("install response = %#v, want community source version 0.3.0", body) + } + targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider"+managementPluginExtension(runtime.GOOS)) + data, errRead := os.ReadFile(targetPath) + if errRead != nil { + t.Fatalf("ReadFile(%s) error = %v", targetPath, errRead) + } + if string(data) != "third-party-library-data" { + t.Fatalf("installed file = %q, want third-party-library-data", data) + } +} + +func TestInstallPluginFromStoreRequiresSourceForDuplicateIDs(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: false, + Dir: t.TempDir(), + StoreSources: []config.PluginStoreSource{{ + ID: "community", + URL: "https://community.example/registry.json", + }}, + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreHTTPClient: fakePluginStoreHTTPClient{ + pluginstore.DefaultRegistryURL: registryJSON(t), + "https://community.example/registry.json": thirdPartySampleRegistryJSON(t), + }, + } + + 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.StatusConflict { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusConflict, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "plugin_store_source_required") { + t.Fatalf("body = %s, want source required error", rec.Body.String()) + } +} + func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -500,6 +666,22 @@ func registryJSON(t *testing.T) []byte { }`) } +func thirdPartySampleRegistryJSON(t *testing.T) []byte { + t.Helper() + + return []byte(`{ + "schema_version": 1, + "plugins": [{ + "id": "sample-provider", + "name": "Sample Provider Community Build", + "description": "Adds sample provider support from a third-party source.", + "author": "community", + "version": "0.3.0", + "repository": "https://github.com/community/cliproxy-sample-provider-plugin" + }] + }`) +} + func makeManagementPluginStoreZip(t *testing.T, name string, content string) []byte { t.Helper() diff --git a/internal/config/config.go b/internal/config/config.go index 12ba870d4..3691712b5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -156,10 +156,19 @@ type PluginsConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` // Dir is the plugin discovery directory. Dir string `yaml:"dir" json:"dir"` + // StoreSources appends third-party plugin store registries to the built-in official source. + StoreSources []PluginStoreSource `yaml:"store-sources,omitempty" json:"store-sources,omitempty"` // Configs stores per-plugin instance configuration by plugin ID. Configs map[string]PluginInstanceConfig `yaml:"configs" json:"configs"` } +// PluginStoreSource describes an additional plugin store registry. +type PluginStoreSource struct { + ID string `yaml:"id" json:"id"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + URL string `yaml:"url" json:"url"` +} + // PluginInstanceConfig stores host-owned plugin settings and the original plugin YAML subtree. type PluginInstanceConfig struct { // Enabled toggles this plugin instance. Nil is normalized to true during YAML parsing. @@ -776,6 +785,19 @@ func (cfg *Config) NormalizePluginsConfig() { if cfg.Plugins.Dir == "" { cfg.Plugins.Dir = "plugins" } + if len(cfg.Plugins.StoreSources) > 0 { + sources := make([]PluginStoreSource, 0, len(cfg.Plugins.StoreSources)) + for _, source := range cfg.Plugins.StoreSources { + source.ID = strings.TrimSpace(source.ID) + source.Name = strings.TrimSpace(source.Name) + source.URL = strings.TrimSpace(source.URL) + if source.URL == "" { + continue + } + sources = append(sources, source) + } + cfg.Plugins.StoreSources = sources + } if cfg.Plugins.Configs == nil { cfg.Plugins.Configs = map[string]PluginInstanceConfig{} } diff --git a/internal/config/plugin_config_test.go b/internal/config/plugin_config_test.go index 5ed2b89c2..632a4d6f4 100644 --- a/internal/config/plugin_config_test.go +++ b/internal/config/plugin_config_test.go @@ -31,6 +31,29 @@ plugins: {} } } +func TestParseConfigBytes_PluginStoreSources(t *testing.T) { + cfg, errParse := ParseConfigBytes([]byte(` +plugins: + store-sources: + - id: " community " + name: " Community " + url: " https://community.example/registry.json " + - id: empty + url: "" +`)) + if errParse != nil { + t.Fatalf("ParseConfigBytes() error = %v", errParse) + } + + if len(cfg.Plugins.StoreSources) != 1 { + t.Fatalf("Plugins.StoreSources len = %d, want 1", len(cfg.Plugins.StoreSources)) + } + source := cfg.Plugins.StoreSources[0] + if source.ID != "community" || source.Name != "Community" || source.URL != "https://community.example/registry.json" { + t.Fatalf("Plugins.StoreSources[0] = %#v", source) + } +} + func TestParseConfigBytes_PluginInstanceEmptyRawYAML(t *testing.T) { cfg, errParse := ParseConfigBytes([]byte(` plugins: diff --git a/internal/pluginstore/registry.go b/internal/pluginstore/registry.go index f49a91f83..2d9075b0e 100644 --- a/internal/pluginstore/registry.go +++ b/internal/pluginstore/registry.go @@ -13,10 +13,19 @@ import ( const ( DefaultRegistryURL = "https://raw.githubusercontent.com/router-for-me/CLIProxyAPI-Plugins-Store/main/registry.json" + DefaultSourceID = "official" + DefaultSourceName = "Official" SchemaVersion = 1 ) var pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`) +var sourceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +type Source struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` +} type Registry struct { SchemaVersion int `json:"schema_version"` @@ -36,6 +45,42 @@ type Plugin struct { Tags []string `json:"tags,omitempty"` } +func DefaultSource() Source { + return Source{ + ID: DefaultSourceID, + Name: DefaultSourceName, + URL: DefaultRegistryURL, + } +} + +func NormalizeSources(sources []Source) ([]Source, error) { + out := []Source{DefaultSource()} + seen := map[string]struct{}{DefaultSourceID: {}} + for index, source := range sources { + source.ID = strings.TrimSpace(source.ID) + source.Name = strings.TrimSpace(source.Name) + source.URL = strings.TrimSpace(source.URL) + if source.URL == "" { + continue + } + if source.ID == "" { + source.ID = fmt.Sprintf("source-%d", index+1) + } + if !sourceIDPattern.MatchString(source.ID) { + return nil, fmt.Errorf("invalid plugin store source id %q", source.ID) + } + if _, exists := seen[source.ID]; exists { + return nil, fmt.Errorf("duplicate plugin store source id %q", source.ID) + } + seen[source.ID] = struct{}{} + if source.Name == "" { + source.Name = source.ID + } + out = append(out, source) + } + return out, nil +} + func ParseRegistry(data []byte) (Registry, error) { var registry Registry decoder := json.NewDecoder(bytes.NewReader(data)) diff --git a/internal/pluginstore/registry_test.go b/internal/pluginstore/registry_test.go index 1f95f4fbb..89798fac3 100644 --- a/internal/pluginstore/registry_test.go +++ b/internal/pluginstore/registry_test.go @@ -160,6 +160,43 @@ func TestValidateRegistryRejectsInvalidEntries(t *testing.T) { } } +func TestNormalizeSourcesAppendsToDefaultSource(t *testing.T) { + t.Parallel() + + sources, errNormalize := NormalizeSources([]Source{{ + ID: " community ", + Name: " Community ", + URL: " https://community.example/registry.json ", + }}) + if errNormalize != nil { + t.Fatalf("NormalizeSources() error = %v", errNormalize) + } + if len(sources) != 2 { + t.Fatalf("sources len = %d, want 2", len(sources)) + } + if sources[0].ID != DefaultSourceID || sources[0].URL != DefaultRegistryURL { + t.Fatalf("default source = %#v", sources[0]) + } + if sources[1].ID != "community" || sources[1].Name != "Community" || sources[1].URL != "https://community.example/registry.json" { + t.Fatalf("third-party source = %#v", sources[1]) + } +} + +func TestNormalizeSourcesRejectsInvalidSourceIDs(t *testing.T) { + t.Parallel() + + _, errNormalize := NormalizeSources([]Source{{ + ID: "../community", + URL: "https://community.example/registry.json", + }}) + if errNormalize == nil { + t.Fatal("NormalizeSources() error = nil") + } + if !strings.Contains(errNormalize.Error(), "invalid plugin store source id") { + t.Fatalf("NormalizeSources() error = %v, want invalid source id", errNormalize) + } +} + func TestGitHubRepositoryPartsRejectsNonRepositoryURLs(t *testing.T) { t.Parallel() From 239d7ee0b075157fd7c2c298a26a5b999587506d Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Mon, 15 Jun 2026 00:54:32 +0800 Subject: [PATCH 2/3] feat(pluginstore): refactor plugin store source handling to use string URLs --- config.example.yaml | 4 +- .../api/handlers/management/plugin_store.go | 16 ++---- .../handlers/management/plugin_store_test.go | 37 ++++++-------- internal/config/config.go | 17 ++----- internal/config/plugin_config_test.go | 9 ++-- internal/pluginstore/registry.go | 49 ++++++++++++------- internal/pluginstore/registry_test.go | 31 ++++++------ 7 files changed, 72 insertions(+), 91 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 8ed17c2ab..1949195ec 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -63,9 +63,7 @@ plugins: dir: "plugins" # Additional plugin store registries. The built-in official registry is always included. # store-sources: - # - id: community - # name: Community Plugins - # url: "https://example.com/cliproxy-plugins/registry.json" + # - "https://example.com/cliproxy-plugins/registry.json" configs: example: enabled: true diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go index 9e9d9768b..c123d2df9 100644 --- a/internal/api/handlers/management/plugin_store.go +++ b/internal/api/handlers/management/plugin_store.go @@ -310,7 +310,7 @@ func (h *Handler) enablePluginConfigLocked(id string) error { return nil } -func (h *Handler) pluginStoreSnapshot() (bool, string, string, []config.PluginStoreSource, map[string]config.PluginInstanceConfig, *pluginhost.Host) { +func (h *Handler) pluginStoreSnapshot() (bool, string, string, []string, map[string]config.PluginInstanceConfig, *pluginhost.Host) { if h == nil || h.cfg == nil { return false, "plugins", "", nil, map[string]config.PluginInstanceConfig{}, nil } @@ -319,7 +319,7 @@ func (h *Handler) pluginStoreSnapshot() (bool, string, string, []config.PluginSt pluginsEnabled := h.cfg.Plugins.Enabled pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir) proxyURL := strings.TrimSpace(h.cfg.ProxyURL) - sourceConfigs := append([]config.PluginStoreSource(nil), h.cfg.Plugins.StoreSources...) + sourceConfigs := append([]string(nil), h.cfg.Plugins.StoreSources...) configs := make(map[string]config.PluginInstanceConfig, len(h.cfg.Plugins.Configs)) for id, item := range h.cfg.Plugins.Configs { configs[id] = item @@ -327,21 +327,13 @@ func (h *Handler) pluginStoreSnapshot() (bool, string, string, []config.PluginSt return pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, configs, h.pluginHost } -func (h *Handler) pluginStoreSources(sourceConfigs []config.PluginStoreSource) ([]pluginstore.Source, error) { +func (h *Handler) pluginStoreSources(sourceConfigs []string) ([]pluginstore.Source, error) { if h != nil && strings.TrimSpace(h.pluginStoreRegistryURL) != "" { source := pluginstore.DefaultSource() source.URL = strings.TrimSpace(h.pluginStoreRegistryURL) return []pluginstore.Source{source}, nil } - sources := make([]pluginstore.Source, 0, len(sourceConfigs)) - for _, source := range sourceConfigs { - sources = append(sources, pluginstore.Source{ - ID: source.ID, - Name: source.Name, - URL: source.URL, - }) - } - return pluginstore.NormalizeSources(sources) + return pluginstore.NormalizeSources(sourceConfigs) } func (h *Handler) newPluginStoreClient(proxyURL string, registryURL string) pluginstore.Client { diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go index 1b5f1bf8a..9f10b1285 100644 --- a/internal/api/handlers/management/plugin_store_test.go +++ b/internal/api/handlers/management/plugin_store_test.go @@ -247,13 +247,9 @@ func TestListPluginStoreIncludesThirdPartySources(t *testing.T) { h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ - Enabled: true, - Dir: t.TempDir(), - StoreSources: []config.PluginStoreSource{{ - ID: "community", - Name: "Community", - URL: "https://community.example/registry.json", - }}, + Enabled: true, + Dir: t.TempDir(), + StoreSources: []string{"https://community.example/registry.json"}, }, }, configFilePath: writeTestConfigFile(t), @@ -300,7 +296,8 @@ func TestListPluginStoreIncludesThirdPartySources(t *testing.T) { t.Fatalf("official source id = %q, want %q", byID["sample-provider"].SourceID, pluginstore.DefaultSourceID) } third := byID["third-provider"] - if third.StoreID != "community/third-provider" || third.SourceName != "Community" || third.SourceURL != "https://community.example/registry.json" { + communitySourceID := pluginstore.SourceID("https://community.example/registry.json") + if third.StoreID != communitySourceID+"/third-provider" || third.SourceID != communitySourceID || third.SourceName != "community.example" || third.SourceURL != "https://community.example/registry.json" { t.Fatalf("third-party source fields = %#v", third) } } @@ -394,13 +391,9 @@ func TestInstallPluginFromStoreUsesRequestedThirdPartySource(t *testing.T) { h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ - Enabled: false, - Dir: pluginsDir, - StoreSources: []config.PluginStoreSource{{ - ID: "community", - Name: "Community", - URL: "https://community.example/registry.json", - }}, + Enabled: false, + Dir: pluginsDir, + StoreSources: []string{"https://community.example/registry.json"}, }, }, configFilePath: writeTestConfigFile(t), @@ -422,7 +415,8 @@ func TestInstallPluginFromStoreUsesRequestedThirdPartySource(t *testing.T) { 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?source=community", nil) + communitySourceID := pluginstore.SourceID("https://community.example/registry.json") + c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install?source="+communitySourceID, nil) h.InstallPluginFromStore(c) @@ -433,7 +427,7 @@ func TestInstallPluginFromStoreUsesRequestedThirdPartySource(t *testing.T) { if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) } - if body.SourceID != "community" || body.Version != "0.3.0" { + if body.SourceID != communitySourceID || body.Version != "0.3.0" { t.Fatalf("install response = %#v, want community source version 0.3.0", body) } targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider"+managementPluginExtension(runtime.GOOS)) @@ -453,12 +447,9 @@ func TestInstallPluginFromStoreRequiresSourceForDuplicateIDs(t *testing.T) { h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ - Enabled: false, - Dir: t.TempDir(), - StoreSources: []config.PluginStoreSource{{ - ID: "community", - URL: "https://community.example/registry.json", - }}, + Enabled: false, + Dir: t.TempDir(), + StoreSources: []string{"https://community.example/registry.json"}, }, }, configFilePath: writeTestConfigFile(t), diff --git a/internal/config/config.go b/internal/config/config.go index 3691712b5..66feabe0d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -157,18 +157,11 @@ type PluginsConfig struct { // Dir is the plugin discovery directory. Dir string `yaml:"dir" json:"dir"` // StoreSources appends third-party plugin store registries to the built-in official source. - StoreSources []PluginStoreSource `yaml:"store-sources,omitempty" json:"store-sources,omitempty"` + StoreSources []string `yaml:"store-sources,omitempty" json:"store-sources,omitempty"` // Configs stores per-plugin instance configuration by plugin ID. Configs map[string]PluginInstanceConfig `yaml:"configs" json:"configs"` } -// PluginStoreSource describes an additional plugin store registry. -type PluginStoreSource struct { - ID string `yaml:"id" json:"id"` - Name string `yaml:"name,omitempty" json:"name,omitempty"` - URL string `yaml:"url" json:"url"` -} - // PluginInstanceConfig stores host-owned plugin settings and the original plugin YAML subtree. type PluginInstanceConfig struct { // Enabled toggles this plugin instance. Nil is normalized to true during YAML parsing. @@ -786,12 +779,10 @@ func (cfg *Config) NormalizePluginsConfig() { cfg.Plugins.Dir = "plugins" } if len(cfg.Plugins.StoreSources) > 0 { - sources := make([]PluginStoreSource, 0, len(cfg.Plugins.StoreSources)) + sources := make([]string, 0, len(cfg.Plugins.StoreSources)) for _, source := range cfg.Plugins.StoreSources { - source.ID = strings.TrimSpace(source.ID) - source.Name = strings.TrimSpace(source.Name) - source.URL = strings.TrimSpace(source.URL) - if source.URL == "" { + source = strings.TrimSpace(source) + if source == "" { continue } sources = append(sources, source) diff --git a/internal/config/plugin_config_test.go b/internal/config/plugin_config_test.go index 632a4d6f4..ddf1c7a6a 100644 --- a/internal/config/plugin_config_test.go +++ b/internal/config/plugin_config_test.go @@ -35,11 +35,8 @@ func TestParseConfigBytes_PluginStoreSources(t *testing.T) { cfg, errParse := ParseConfigBytes([]byte(` plugins: store-sources: - - id: " community " - name: " Community " - url: " https://community.example/registry.json " - - id: empty - url: "" + - " https://community.example/registry.json " + - "" `)) if errParse != nil { t.Fatalf("ParseConfigBytes() error = %v", errParse) @@ -49,7 +46,7 @@ plugins: t.Fatalf("Plugins.StoreSources len = %d, want 1", len(cfg.Plugins.StoreSources)) } source := cfg.Plugins.StoreSources[0] - if source.ID != "community" || source.Name != "Community" || source.URL != "https://community.example/registry.json" { + if source != "https://community.example/registry.json" { t.Fatalf("Plugins.StoreSources[0] = %#v", source) } } diff --git a/internal/pluginstore/registry.go b/internal/pluginstore/registry.go index 2d9075b0e..7f611318b 100644 --- a/internal/pluginstore/registry.go +++ b/internal/pluginstore/registry.go @@ -2,6 +2,8 @@ package pluginstore import ( "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "net/url" @@ -19,7 +21,6 @@ const ( ) var pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`) -var sourceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) type Source struct { ID string `json:"id"` @@ -53,34 +54,46 @@ func DefaultSource() Source { } } -func NormalizeSources(sources []Source) ([]Source, error) { +func NormalizeSources(registryURLs []string) ([]Source, error) { out := []Source{DefaultSource()} - seen := map[string]struct{}{DefaultSourceID: {}} - for index, source := range sources { - source.ID = strings.TrimSpace(source.ID) - source.Name = strings.TrimSpace(source.Name) - source.URL = strings.TrimSpace(source.URL) - if source.URL == "" { + seenIDs := map[string]string{DefaultSourceID: DefaultRegistryURL} + seenURLs := map[string]struct{}{DefaultRegistryURL: {}} + for _, registryURL := range registryURLs { + registryURL = strings.TrimSpace(registryURL) + if registryURL == "" { continue } - if source.ID == "" { - source.ID = fmt.Sprintf("source-%d", index+1) + if _, exists := seenURLs[registryURL]; exists { + continue } - if !sourceIDPattern.MatchString(source.ID) { - return nil, fmt.Errorf("invalid plugin store source id %q", source.ID) + source := Source{ + ID: SourceID(registryURL), + Name: SourceName(registryURL), + URL: registryURL, } - if _, exists := seen[source.ID]; exists { - return nil, fmt.Errorf("duplicate plugin store source id %q", source.ID) - } - seen[source.ID] = struct{}{} - if source.Name == "" { - source.Name = source.ID + if existingURL, exists := seenIDs[source.ID]; exists { + return nil, fmt.Errorf("plugin store source id collision for %q and %q", existingURL, registryURL) } + seenIDs[source.ID] = registryURL + seenURLs[registryURL] = struct{}{} out = append(out, source) } return out, nil } +func SourceID(registryURL string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(registryURL))) + return "source-" + hex.EncodeToString(sum[:])[:12] +} + +func SourceName(registryURL string) string { + parsed, errParse := url.Parse(strings.TrimSpace(registryURL)) + if errParse != nil || strings.TrimSpace(parsed.Host) == "" { + return strings.TrimSpace(registryURL) + } + return parsed.Host +} + func ParseRegistry(data []byte) (Registry, error) { var registry Registry decoder := json.NewDecoder(bytes.NewReader(data)) diff --git a/internal/pluginstore/registry_test.go b/internal/pluginstore/registry_test.go index 89798fac3..73aba00ab 100644 --- a/internal/pluginstore/registry_test.go +++ b/internal/pluginstore/registry_test.go @@ -160,14 +160,10 @@ func TestValidateRegistryRejectsInvalidEntries(t *testing.T) { } } -func TestNormalizeSourcesAppendsToDefaultSource(t *testing.T) { +func TestNormalizeSourcesAppendsURLsToDefaultSource(t *testing.T) { t.Parallel() - sources, errNormalize := NormalizeSources([]Source{{ - ID: " community ", - Name: " Community ", - URL: " https://community.example/registry.json ", - }}) + sources, errNormalize := NormalizeSources([]string{" https://community.example/registry.json "}) if errNormalize != nil { t.Fatalf("NormalizeSources() error = %v", errNormalize) } @@ -177,23 +173,26 @@ func TestNormalizeSourcesAppendsToDefaultSource(t *testing.T) { if sources[0].ID != DefaultSourceID || sources[0].URL != DefaultRegistryURL { t.Fatalf("default source = %#v", sources[0]) } - if sources[1].ID != "community" || sources[1].Name != "Community" || sources[1].URL != "https://community.example/registry.json" { + if sources[1].ID != SourceID("https://community.example/registry.json") || + sources[1].Name != "community.example" || + sources[1].URL != "https://community.example/registry.json" { t.Fatalf("third-party source = %#v", sources[1]) } } -func TestNormalizeSourcesRejectsInvalidSourceIDs(t *testing.T) { +func TestNormalizeSourcesSkipsDuplicates(t *testing.T) { t.Parallel() - _, errNormalize := NormalizeSources([]Source{{ - ID: "../community", - URL: "https://community.example/registry.json", - }}) - if errNormalize == nil { - t.Fatal("NormalizeSources() error = nil") + sources, errNormalize := NormalizeSources([]string{ + DefaultRegistryURL, + "https://community.example/registry.json", + "https://community.example/registry.json", + }) + if errNormalize != nil { + t.Fatalf("NormalizeSources() error = %v", errNormalize) } - if !strings.Contains(errNormalize.Error(), "invalid plugin store source id") { - t.Fatalf("NormalizeSources() error = %v, want invalid source id", errNormalize) + if len(sources) != 2 { + t.Fatalf("sources len = %d, want 2: %#v", len(sources), sources) } } From 6f3bd7641b46793a7273de8b2ef033db05f52b3b Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Mon, 15 Jun 2026 01:21:07 +0800 Subject: [PATCH 3/3] feat(pluginstore): improve nil checks in pluginStoreSnapshot function --- internal/api/handlers/management/plugin_store.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go index c123d2df9..a41aae3c9 100644 --- a/internal/api/handlers/management/plugin_store.go +++ b/internal/api/handlers/management/plugin_store.go @@ -311,11 +311,14 @@ func (h *Handler) enablePluginConfigLocked(id string) error { } func (h *Handler) pluginStoreSnapshot() (bool, string, string, []string, map[string]config.PluginInstanceConfig, *pluginhost.Host) { - if h == nil || h.cfg == nil { + if h == nil { return false, "plugins", "", nil, map[string]config.PluginInstanceConfig{}, nil } h.mu.Lock() defer h.mu.Unlock() + if h.cfg == nil { + return false, "plugins", "", nil, map[string]config.PluginInstanceConfig{}, nil + } pluginsEnabled := h.cfg.Plugins.Enabled pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir) proxyURL := strings.TrimSpace(h.cfg.ProxyURL)