fix(plugin-store): preserve installed source identity

This commit is contained in:
Supra4E8C
2026-07-10 15:48:09 +08:00
parent 1af2334477
commit f081b91e57
2 changed files with 330 additions and 60 deletions
+170 -60
View File
@@ -59,32 +59,34 @@ type pluginStoreSourceErr struct {
}
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"`
Author string `json:"author"`
Version string `json:"version"`
Repository string `json:"repository"`
InstallType string `json:"install_type"`
AuthRequired bool `json:"auth_required"`
AuthConfigured bool `json:"auth_configured"`
Platforms []pluginStorePlatform `json:"platforms,omitempty"`
Logo string `json:"logo,omitempty"`
Homepage string `json:"homepage,omitempty"`
License string `json:"license,omitempty"`
Tags []string `json:"tags,omitempty"`
Installed bool `json:"installed"`
InstalledVersion string `json:"installed_version"`
Path string `json:"path"`
Configured bool `json:"configured"`
Registered bool `json:"registered"`
Enabled bool `json:"enabled"`
EffectiveEnabled bool `json:"effective_enabled"`
UpdateAvailable bool `json:"update_available"`
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"`
Author string `json:"author"`
Version string `json:"version"`
Repository string `json:"repository"`
InstallType string `json:"install_type"`
AuthRequired bool `json:"auth_required"`
AuthConfigured bool `json:"auth_configured"`
Platforms []pluginStorePlatform `json:"platforms,omitempty"`
Logo string `json:"logo,omitempty"`
Homepage string `json:"homepage,omitempty"`
License string `json:"license,omitempty"`
Tags []string `json:"tags,omitempty"`
Installed bool `json:"installed"`
InstalledVersion string `json:"installed_version"`
InstalledSourceID string `json:"installed_source_id,omitempty"`
InstallSourceStatus string `json:"install_source_status,omitempty"`
Path string `json:"path"`
Configured bool `json:"configured"`
Registered bool `json:"registered"`
Enabled bool `json:"enabled"`
EffectiveEnabled bool `json:"effective_enabled"`
UpdateAvailable bool `json:"update_available"`
}
type pluginStorePlatform struct {
@@ -110,13 +112,16 @@ type pluginInstallRequest struct {
}
type pluginLocalStatus struct {
Installed bool
InstalledVersion string
Path string
Configured bool
Registered bool
Enabled bool
EffectiveEnabled bool
Installed bool
InstalledVersion string
StoreManaged bool
InstalledSourceID string
InstalledSourceURL string
Path string
Configured bool
Registered bool
Enabled bool
EffectiveEnabled bool
}
type sourcedPlugin struct {
@@ -148,11 +153,21 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
}
client := h.newPluginStoreClient(proxyURL, "", storeAuth)
latestVersions := h.latestPluginVersions(c.Request.Context(), client, latestInput)
pluginSourceCounts := make(map[string]int, len(plugins))
for _, item := range plugins {
pluginSourceCounts[item.plugin.ID]++
}
entries := make([]pluginStoreListEntry, 0, len(plugins))
for index, item := range plugins {
plugin := item.plugin
status := statuses[plugin.ID]
installedSourceID, installSourceStatus, sourceAllowsUpdate := pluginStoreInstallSourceStatus(
status,
sources,
item.source.ID,
pluginSourceCounts[plugin.ID],
)
installedVersion := status.InstalledVersion
// Fall back to the registry version when the latest release is unknown.
storeVersion := plugin.Version
@@ -160,32 +175,34 @@ 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),
Author: htmlsanitize.String(plugin.Author),
Version: htmlsanitize.String(storeVersion),
Repository: htmlsanitize.String(plugin.Repository),
InstallType: htmlsanitize.String(pluginstore.PluginInstallType(plugin)),
AuthRequired: plugin.AuthRequired,
AuthConfigured: pluginAuthConfigured(item.source, plugin, storeAuth),
Platforms: sanitizePluginStorePlatforms(pluginstore.PluginPlatforms(plugin)),
Logo: htmlsanitize.String(plugin.Logo),
Homepage: htmlsanitize.String(plugin.Homepage),
License: htmlsanitize.String(plugin.License),
Tags: htmlsanitize.Strings(plugin.Tags),
Installed: status.Installed,
InstalledVersion: htmlsanitize.String(installedVersion),
Path: htmlsanitize.String(status.Path),
Configured: status.Configured,
Registered: status.Registered,
Enabled: status.Enabled,
EffectiveEnabled: status.EffectiveEnabled,
UpdateAvailable: pluginstore.UpdateAvailable(installedVersion, storeVersion),
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),
Author: htmlsanitize.String(plugin.Author),
Version: htmlsanitize.String(storeVersion),
Repository: htmlsanitize.String(plugin.Repository),
InstallType: htmlsanitize.String(pluginstore.PluginInstallType(plugin)),
AuthRequired: plugin.AuthRequired,
AuthConfigured: pluginAuthConfigured(item.source, plugin, storeAuth),
Platforms: sanitizePluginStorePlatforms(pluginstore.PluginPlatforms(plugin)),
Logo: htmlsanitize.String(plugin.Logo),
Homepage: htmlsanitize.String(plugin.Homepage),
License: htmlsanitize.String(plugin.License),
Tags: htmlsanitize.Strings(plugin.Tags),
Installed: status.Installed,
InstalledVersion: htmlsanitize.String(installedVersion),
InstalledSourceID: htmlsanitize.String(installedSourceID),
InstallSourceStatus: htmlsanitize.String(installSourceStatus),
Path: htmlsanitize.String(status.Path),
Configured: status.Configured,
Registered: status.Registered,
Enabled: status.Enabled,
EffectiveEnabled: status.EffectiveEnabled,
UpdateAvailable: sourceAllowsUpdate && pluginstore.UpdateAvailable(installedVersion, storeVersion),
})
}
@@ -213,7 +230,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
return
}
installCtx := c.Request.Context()
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, _, host := h.pluginStoreSnapshot()
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, 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()})
@@ -223,6 +240,9 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
if !okPlugin {
return
}
if !validatePluginStoreInstallSource(c, configs, sources, id, source.ID) {
return
}
pluginIsBusy := func() bool { return pluginBusy(host, id) }
installOptions := pluginstore.InstallOptions{
PluginsDir: pluginsDir,
@@ -717,6 +737,7 @@ func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[str
status := statuses[id]
status.Configured = true
status.Enabled = pluginInstanceEnabled(item)
status.InstalledSourceID, status.InstalledSourceURL, status.StoreManaged = pluginStoreConfiguredSource(item)
statuses[id] = status
}
if host != nil {
@@ -738,6 +759,95 @@ func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[str
return statuses, nil
}
func pluginStoreConfiguredSource(item config.PluginInstanceConfig) (sourceID string, sourceURL string, managed bool) {
storeNode := pluginStoreConfigNode(item)
if storeNode == nil {
return "", "", false
}
var manifest pluginstore.Manifest
if errDecode := storeNode.Decode(&manifest); errDecode != nil {
return "", "", true
}
return strings.TrimSpace(manifest.SourceID), strings.TrimSpace(manifest.SourceURL), true
}
func pluginStoreResolveInstalledSource(status pluginLocalStatus, sources []pluginstore.Source) (string, bool) {
sourceID := strings.TrimSpace(status.InstalledSourceID)
sourceURL := strings.TrimSpace(status.InstalledSourceURL)
if sourceID != "" {
for _, source := range sources {
if strings.TrimSpace(source.ID) != sourceID {
continue
}
if sourceURL != "" && strings.TrimSpace(source.URL) != sourceURL {
return "", false
}
return sourceID, true
}
return sourceID, true
}
if sourceURL == "" {
return "", false
}
for _, source := range sources {
if strings.TrimSpace(source.URL) == sourceURL {
return strings.TrimSpace(source.ID), true
}
}
return "", false
}
func pluginStoreInstallSourceStatus(status pluginLocalStatus, sources []pluginstore.Source, entrySourceID string, sourceCount int) (installedSourceID string, sourceStatus string, allowUpdate bool) {
if !status.Installed && !status.Configured && !status.Registered {
return "", "", true
}
if sourceID, known := pluginStoreResolveInstalledSource(status, sources); known {
if sourceID == strings.TrimSpace(entrySourceID) {
return sourceID, "matched", true
}
return sourceID, "different", false
}
if status.StoreManaged || sourceCount > 1 {
return "", "unknown", false
}
return "", "assumed", true
}
func validatePluginStoreInstallSource(c *gin.Context, configs map[string]config.PluginInstanceConfig, sources []pluginstore.Source, id string, requestedSourceID string) bool {
item, configured := configs[id]
if !configured {
return true
}
installedSourceID, installedSourceURL, managed := pluginStoreConfiguredSource(item)
if !managed {
return true
}
status := pluginLocalStatus{
StoreManaged: true,
InstalledSourceID: installedSourceID,
InstalledSourceURL: installedSourceURL,
}
resolvedSourceID, known := pluginStoreResolveInstalledSource(status, sources)
if !known {
c.JSON(http.StatusConflict, gin.H{
"error": "plugin_store_installed_source_unknown",
"message": "installed plugin source cannot be verified; uninstall it before reinstalling from the store",
"requested_source_id": strings.TrimSpace(requestedSourceID),
})
return false
}
if resolvedSourceID != strings.TrimSpace(requestedSourceID) {
c.JSON(http.StatusConflict, gin.H{
"error": "plugin_store_source_conflict",
"message": "installed plugin belongs to a different store source; uninstall it before switching sources",
"installed_source_id": resolvedSourceID,
"requested_source_id": strings.TrimSpace(requestedSourceID),
})
return false
}
return true
}
func pluginStoreDesiredVersions(configs map[string]config.PluginInstanceConfig) map[string]string {
if len(configs) == 0 {
return nil
@@ -402,6 +402,154 @@ func TestListPluginStoreIncludesThirdPartySources(t *testing.T) {
}
}
func TestListPluginStoreMatchesInstalledStatusToManifestSource(t *testing.T) {
t.Parallel()
pluginsDir := t.TempDir()
archDir := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH)
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
t.Fatalf("MkdirAll(%s) error = %v", archDir, errMkdirAll)
}
pluginPath := filepath.Join(archDir, "sample-provider-v0.0.1"+managementPluginExtension(runtime.GOOS))
if errWriteFile := os.WriteFile(pluginPath, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", pluginPath, errWriteFile)
}
communityURL := "https://community.example/registry.json"
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: pluginsDir,
StoreSources: []string{communityURL},
Configs: map[string]config.PluginInstanceConfig{
"sample-provider": pluginConfigWithStoreSource(t, pluginstore.DefaultSourceID, pluginstore.DefaultRegistryURL),
},
},
},
configFilePath: writeTestConfigFile(t),
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
pluginstore.DefaultRegistryURL: registryJSON(t),
communityURL: thirdPartySampleRegistryJSON(t),
},
}
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 struct {
Plugins []struct {
SourceID string `json:"source_id"`
InstalledSourceID string `json:"installed_source_id"`
InstallSourceStatus string `json:"install_source_status"`
UpdateAvailable bool `json:"update_available"`
} `json:"plugins"`
}
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
}
if len(body.Plugins) != 2 {
t.Fatalf("plugins len = %d, want 2", len(body.Plugins))
}
entries := make(map[string]struct {
InstalledSourceID string
InstallSourceStatus string
UpdateAvailable bool
}, len(body.Plugins))
for _, entry := range body.Plugins {
entries[entry.SourceID] = struct {
InstalledSourceID string
InstallSourceStatus string
UpdateAvailable bool
}{entry.InstalledSourceID, entry.InstallSourceStatus, entry.UpdateAvailable}
}
official := entries[pluginstore.DefaultSourceID]
if official.InstalledSourceID != pluginstore.DefaultSourceID || official.InstallSourceStatus != "matched" || !official.UpdateAvailable {
t.Fatalf("official entry = %#v, want matched update", official)
}
communitySourceID := pluginstore.SourceID(communityURL)
community := entries[communitySourceID]
if community.InstalledSourceID != pluginstore.DefaultSourceID || community.InstallSourceStatus != "different" || community.UpdateAvailable {
t.Fatalf("community entry = %#v, want different source without update", community)
}
}
func TestInstallPluginFromStoreRejectsImplicitSourceSwitch(t *testing.T) {
t.Parallel()
communityURL := "https://community.example/registry.json"
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: writeManagementPluginFile(t, "sample-provider"),
StoreSources: []string{communityURL},
Configs: map[string]config.PluginInstanceConfig{
"sample-provider": pluginConfigWithStoreSource(t, pluginstore.DefaultSourceID, pluginstore.DefaultRegistryURL),
},
},
},
configFilePath: writeTestConfigFile(t),
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
pluginstore.DefaultRegistryURL: registryJSON(t),
communityURL: thirdPartySampleRegistryJSON(t),
},
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "sample-provider"}}
communitySourceID := pluginstore.SourceID(communityURL)
c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install?source="+communitySourceID, 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_conflict") || !strings.Contains(rec.Body.String(), pluginstore.DefaultSourceID) {
t.Fatalf("body = %s, want source conflict with installed source", rec.Body.String())
}
}
func TestInstallPluginFromStoreRejectsUnknownManagedSource(t *testing.T) {
t.Parallel()
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: writeManagementPluginFile(t, "sample-provider"),
Configs: map[string]config.PluginInstanceConfig{
"sample-provider": pluginConfigWithStoreSource(t, "", ""),
},
},
},
configFilePath: writeTestConfigFile(t),
pluginStoreRegistryURL: "https://registry.example/registry.json",
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
"https://registry.example/registry.json": registryJSON(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_installed_source_unknown") {
t.Fatalf("body = %s, want unknown installed source error", rec.Body.String())
}
}
func TestListPluginStoreIncludesDirectMetadataAndAuth(t *testing.T) {
t.Setenv("PLUGIN_STORE_TOKEN", "secret-token")
@@ -1147,6 +1295,18 @@ func testStoreManifest() pluginstore.Manifest {
}
}
func pluginConfigWithStoreSource(t *testing.T, sourceID string, sourceURL string) config.PluginInstanceConfig {
t.Helper()
sourceFields := ""
if sourceID != "" {
sourceFields += " source-id: " + sourceID + "\n"
}
if sourceURL != "" {
sourceFields += " source-url: " + sourceURL + "\n"
}
return pluginConfigFromYAML(t, "enabled: true\nstore:\n schema-version: 1\n id: sample-provider\n version: 0.0.1\n release-tag: v0.0.1\n repository: https://github.com/author-name/cliproxy-sample-provider-plugin\n"+sourceFields+" install:\n type: github-release\n")
}
func pluginStoreManifestFromConfig(t *testing.T, item config.PluginInstanceConfig) pluginstore.Manifest {
t.Helper()