mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-09 01:46:48 +08:00
Merge pull request #4185 from router-for-me/codex/fix-p2-oauth-plugin-source
Codex/fix p2 oauth plugin source
This commit is contained in:
@@ -2627,8 +2627,12 @@ func (h *Handler) GetAuthStatus(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
provider, status, isPlugin, metadata, ok := GetOAuthSessionDetails(state)
|
||||
provider, status, isPlugin, metadata, completed, ok := GetOAuthSessionDetails(state)
|
||||
if !ok {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "error", "error": "unknown or expired state"})
|
||||
return
|
||||
}
|
||||
if completed {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,11 +86,15 @@ func (h *Handler) handleOAuthCallback(c *gin.Context, req oauthCallbackRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sessionProvider, sessionStatus, isPlugin, _, ok := GetOAuthSessionDetails(state)
|
||||
sessionProvider, sessionStatus, isPlugin, _, completed, ok := GetOAuthSessionDetails(state)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"status": "error", "error": "unknown or expired state"})
|
||||
return
|
||||
}
|
||||
if completed {
|
||||
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "oauth flow is already completed"})
|
||||
return
|
||||
}
|
||||
provider := strings.TrimSpace(req.Provider)
|
||||
if provider == "" {
|
||||
provider = sessionProvider
|
||||
|
||||
@@ -12,8 +12,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
oauthSessionTTL = 10 * time.Minute
|
||||
maxOAuthStateLength = 128
|
||||
oauthSessionTTL = 10 * time.Minute
|
||||
oauthCompletedSessionTTL = time.Minute
|
||||
maxOAuthStateLength = 128
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -33,23 +34,30 @@ type oauthSession struct {
|
||||
Status string
|
||||
Source string
|
||||
Metadata map[string]any
|
||||
Completed bool
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type oauthSessionStore struct {
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
sessions map[string]oauthSession
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
completedTTL time.Duration
|
||||
sessions map[string]oauthSession
|
||||
}
|
||||
|
||||
func newOAuthSessionStore(ttl time.Duration) *oauthSessionStore {
|
||||
if ttl <= 0 {
|
||||
ttl = oauthSessionTTL
|
||||
}
|
||||
completedTTL := oauthCompletedSessionTTL
|
||||
if ttl < completedTTL {
|
||||
completedTTL = ttl
|
||||
}
|
||||
return &oauthSessionStore{
|
||||
ttl: ttl,
|
||||
sessions: make(map[string]oauthSession),
|
||||
ttl: ttl,
|
||||
completedTTL: completedTTL,
|
||||
sessions: make(map[string]oauthSession),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +135,7 @@ func (s *oauthSessionStore) SetError(state, message string) {
|
||||
|
||||
s.purgeExpiredLocked(now)
|
||||
session, ok := s.sessions[state]
|
||||
if !ok {
|
||||
if !ok || session.Completed {
|
||||
return
|
||||
}
|
||||
session.Status = message
|
||||
@@ -146,7 +154,15 @@ func (s *oauthSessionStore) Complete(state string) {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.purgeExpiredLocked(now)
|
||||
delete(s.sessions, state)
|
||||
session, ok := s.sessions[state]
|
||||
if !ok || session.Completed {
|
||||
return
|
||||
}
|
||||
session.Status = ""
|
||||
session.Metadata = nil
|
||||
session.Completed = true
|
||||
session.ExpiresAt = now.Add(s.completedTTL)
|
||||
s.sessions[state] = session
|
||||
}
|
||||
|
||||
func (s *oauthSessionStore) CompleteProvider(provider string, source string) int {
|
||||
@@ -163,8 +179,12 @@ func (s *oauthSessionStore) CompleteProvider(provider string, source string) int
|
||||
s.purgeExpiredLocked(now)
|
||||
removed := 0
|
||||
for state, session := range s.sessions {
|
||||
if strings.EqualFold(session.Provider, provider) && (source == "" || session.Source == source) {
|
||||
delete(s.sessions, state)
|
||||
if !session.Completed && strings.EqualFold(session.Provider, provider) && (source == "" || session.Source == source) {
|
||||
session.Status = ""
|
||||
session.Metadata = nil
|
||||
session.Completed = true
|
||||
session.ExpiresAt = now.Add(s.completedTTL)
|
||||
s.sessions[state] = session
|
||||
removed++
|
||||
}
|
||||
}
|
||||
@@ -197,7 +217,7 @@ func (s *oauthSessionStore) IsPending(state, provider string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if session.Status != "" {
|
||||
if session.Completed || session.Status != "" {
|
||||
return false
|
||||
}
|
||||
if provider == "" {
|
||||
@@ -239,18 +259,18 @@ func CompletePluginOAuthSessionsByProvider(provider string) int {
|
||||
|
||||
func GetOAuthSession(state string) (provider string, status string, ok bool) {
|
||||
session, ok := oauthSessions.Get(state)
|
||||
if !ok {
|
||||
if !ok || session.Completed {
|
||||
return "", "", false
|
||||
}
|
||||
return session.Provider, session.Status, true
|
||||
}
|
||||
|
||||
func GetOAuthSessionDetails(state string) (provider string, status string, isPlugin bool, metadata map[string]any, ok bool) {
|
||||
func GetOAuthSessionDetails(state string) (provider string, status string, isPlugin bool, metadata map[string]any, completed bool, ok bool) {
|
||||
session, ok := oauthSessions.Get(state)
|
||||
if !ok {
|
||||
return "", "", false, nil, false
|
||||
return "", "", false, nil, false, false
|
||||
}
|
||||
return session.Provider, session.Status, session.Source == oauthSessionSourcePlugin, cloneOAuthSessionMetadata(session.Metadata), true
|
||||
return session.Provider, session.Status, session.Source == oauthSessionSourcePlugin, cloneOAuthSessionMetadata(session.Metadata), session.Completed, true
|
||||
}
|
||||
|
||||
func IsOAuthSessionPending(state, provider string) bool {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
func TestOAuthSessionStoreCompleteKeepsShortLivedSession(t *testing.T) {
|
||||
store := newOAuthSessionStore(time.Minute)
|
||||
store.Register("completed-state", "codex")
|
||||
|
||||
store.Complete("completed-state")
|
||||
|
||||
if _, ok := store.Get("completed-state"); !ok {
|
||||
t.Fatal("completed OAuth session was deleted instead of retained as a tombstone")
|
||||
}
|
||||
if store.IsPending("completed-state", "codex") {
|
||||
t.Fatal("completed OAuth session remained pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthSessionStoreCompleteDoesNotExtendCompletedSession(t *testing.T) {
|
||||
store := newOAuthSessionStore(time.Minute)
|
||||
store.Register("completed-state", "codex")
|
||||
store.Complete("completed-state")
|
||||
before, ok := store.Get("completed-state")
|
||||
if !ok {
|
||||
t.Fatal("completed OAuth session tombstone is missing")
|
||||
}
|
||||
|
||||
store.completedTTL = 2 * time.Minute
|
||||
store.Complete("completed-state")
|
||||
after, ok := store.Get("completed-state")
|
||||
if !ok {
|
||||
t.Fatal("completed OAuth session tombstone is missing after repeated completion")
|
||||
}
|
||||
if !after.ExpiresAt.Equal(before.ExpiresAt) {
|
||||
t.Fatalf("repeated completion extended expiry from %s to %s", before.ExpiresAt, after.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthSessionStoreCompleteProviderSkipsCompletedSessions(t *testing.T) {
|
||||
store := newOAuthSessionStore(time.Minute)
|
||||
store.Register("completed-state", "codex")
|
||||
store.Register("pending-state", "codex")
|
||||
store.Complete("completed-state")
|
||||
completedBefore, ok := store.Get("completed-state")
|
||||
if !ok {
|
||||
t.Fatal("completed OAuth session tombstone is missing")
|
||||
}
|
||||
|
||||
store.completedTTL = 2 * time.Minute
|
||||
if got := store.CompleteProvider("codex", oauthSessionSourceBuiltin); got != 1 {
|
||||
t.Fatalf("CompleteProvider() = %d, want 1 newly completed session", got)
|
||||
}
|
||||
completedAfter, ok := store.Get("completed-state")
|
||||
if !ok {
|
||||
t.Fatal("completed OAuth session tombstone is missing after provider completion")
|
||||
}
|
||||
if !completedAfter.ExpiresAt.Equal(completedBefore.ExpiresAt) {
|
||||
t.Fatalf("provider completion extended existing tombstone from %s to %s", completedBefore.ExpiresAt, completedAfter.ExpiresAt)
|
||||
}
|
||||
pendingAfter, ok := store.Get("pending-state")
|
||||
if !ok || !pendingAfter.Completed {
|
||||
t.Fatalf("pending session completed/ok = %t/%t, want true/true", pendingAfter.Completed, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOAuthSessionHidesCompletedSession(t *testing.T) {
|
||||
store := newOAuthSessionStore(time.Minute)
|
||||
replaceOAuthSessionStoreForTest(t, store)
|
||||
store.Register("completed-state", "codex")
|
||||
store.Complete("completed-state")
|
||||
|
||||
provider, status, ok := GetOAuthSession("completed-state")
|
||||
if ok {
|
||||
t.Fatalf("GetOAuthSession() = (%q, %q, true), want completed session hidden", provider, status)
|
||||
}
|
||||
|
||||
_, _, _, _, completed, detailsOK := GetOAuthSessionDetails("completed-state")
|
||||
if !detailsOK || !completed {
|
||||
t.Fatalf("GetOAuthSessionDetails() completed/ok = %t/%t, want true/true", completed, detailsOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthStatusRejectsUnknownStateAndAcceptsCompletedState(t *testing.T) {
|
||||
store := newOAuthSessionStore(time.Minute)
|
||||
replaceOAuthSessionStoreForTest(t, store)
|
||||
|
||||
handler := &Handler{}
|
||||
router := gin.New()
|
||||
router.GET("/status", handler.GetAuthStatus)
|
||||
|
||||
unknown := performOAuthStatusRequest(t, router, "unknown-state")
|
||||
if unknown.Status != "error" || unknown.Error != "unknown or expired state" {
|
||||
t.Fatalf("unknown state response = %#v, want unknown/expired error", unknown)
|
||||
}
|
||||
|
||||
store.Register("completed-state", "codex")
|
||||
store.Complete("completed-state")
|
||||
completed := performOAuthStatusRequest(t, router, "completed-state")
|
||||
if completed.Status != "ok" || completed.Error != "" {
|
||||
t.Fatalf("completed state response = %#v, want success", completed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallbackRejectsCompletedSession(t *testing.T) {
|
||||
store := newOAuthSessionStore(time.Minute)
|
||||
replaceOAuthSessionStoreForTest(t, store)
|
||||
store.Register("completed-state", "codex")
|
||||
store.Complete("completed-state")
|
||||
|
||||
handler := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil)
|
||||
router := gin.New()
|
||||
router.POST("/oauth-callback", handler.PostOAuthCallback)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/oauth-callback",
|
||||
strings.NewReader(`{"provider":"codex","state":"completed-state","code":"test-code"}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("completed callback status = %d, want %d; body=%s", w.Code, http.StatusConflict, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type oauthStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func performOAuthStatusRequest(t *testing.T, router http.Handler, state string) oauthStatusResponse {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/status?state="+state, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status request returned %d, want %d; body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
var response oauthStatusResponse
|
||||
if errDecode := json.Unmarshal(w.Body.Bytes(), &response); errDecode != nil {
|
||||
t.Fatalf("decode status response: %v", errDecode)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func replaceOAuthSessionStoreForTest(t *testing.T, store *oauthSessionStore) {
|
||||
t.Helper()
|
||||
original := oauthSessions
|
||||
oauthSessions = store
|
||||
t.Cleanup(func() {
|
||||
oauthSessions = original
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user